Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/apollo-react/src/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ The Material renderer depends on this folder, never the other way round.

- The root element carries the chat theme as a class (`light`, `dark`, `light-hc`, `dark-hc`, `future-light`, `future-dark`), so it themes itself regardless of the host's body class. Menus, tooltips and hover cards portal inside that root, or into a themed node inside `portalContainer` when the host passes one. Modal dialogs (code block expand and HTML preview) always portal to that themed node, placed in `portalContainer` or else `document.body` at z-index 1300 (Material's modal level), so they cover the page rather than the chat panel.
- `spacing.compactMode` sets `data-density="compact"` on the root.
- History opens as a popover under the header's history button, sized as Material's. It is not modal, so a host's own dialog opened from a rename or delete pre-hook stays usable; clicks there leave it open, a click elsewhere or Escape closes it. Settings slide in from the right as Material's do: over the conversation in Embedded (which is inert meanwhile, as Material traps focus there), beside it in FullScreen, where the column makes room. Settings close from their X, the header button or Escape anywhere in the chat, always through the `ToggleSettings` pre-hook, with focus back on the header button. Both share one surface treatment. The settings panel stays mounted, so the element passed to `settingsRenderer` is the same for the chat's life: it is emptied on close and handed to the renderer again on each opening, as in Material.
- `icon` values on custom header actions, message actions and tree message spans (`span.data.icon`) are renderer-specific: Material takes Material Icons names, Wind takes lucide names (kebab-case such as `file-text`, or the PascalCase export name `FileText`). Wind loads each icon lazily through `lucide-react/dynamic` and shows a neutral box for names it cannot resolve.

## Styles
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest';

import { filterHistory, getHistoryBucket, groupHistory } from './history-groups';

const NOW = new Date(2026, 9, 16, 15, 0, 0);
const DAY = 86_400_000;
const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString();
const item = (id: string, msAgo: number, name = id) => ({ id, name, timestamp: ago(msAgo) });

describe('history buckets', () => {
it('buckets by calendar day relative to now, then by days and months ago', () => {
expect(getHistoryBucket(ago(14 * 3_600_000), NOW)).toEqual({ kind: 'today' });
// Earlier than `now` by less than a day, but on the previous calendar day.
expect(getHistoryBucket(ago(16 * 3_600_000), NOW)).toEqual({ kind: 'yesterday' });
expect(getHistoryBucket(ago(DAY), NOW)).toEqual({ kind: 'yesterday' });
expect(getHistoryBucket(ago(5 * DAY), NOW)).toEqual({ kind: 'last-week' });
expect(getHistoryBucket(ago(7 * DAY), NOW)).toEqual({ kind: 'last-week' });
expect(getHistoryBucket(ago(8 * DAY), NOW)).toEqual({ kind: 'last-30-days' });
expect(getHistoryBucket(ago(30 * DAY), NOW)).toEqual({ kind: 'last-30-days' });
expect(getHistoryBucket(ago(45 * DAY), NOW)).toEqual({ kind: 'months-ago', count: 1 });
expect(getHistoryBucket(ago(100 * DAY), NOW)).toEqual({ kind: 'months-ago', count: 3 });
});
});

describe('groupHistory', () => {
it('orders groups and their items newest first, one group per month count', () => {
const groups = groupHistory(
[
item('older-week', 6 * DAY),
item('three-months', 100 * DAY),
item('newer-week', 3 * DAY),
item('month', 12 * DAY),
item('two-months', 70 * DAY),
item('three-months-b', 95 * DAY),
],
NOW
);

expect(groups.map((group) => group.key)).toEqual([
'last-week',
'last-30-days',
'months-ago-2',
'months-ago-3',
]);
expect(groups[0]!.items.map((entry) => entry.id)).toEqual(['newer-week', 'older-week']);
expect(groups[3]!.items.map((entry) => entry.id)).toEqual(['three-months-b', 'three-months']);
});

it('orders groups by the first item each one received, as Material does', () => {
// The 30-day group's first item is older than the week group's first, so it sorts after it
// even though it also holds a newer item.
const groups = groupHistory([item('a', 20 * DAY), item('b', 6 * DAY), item('c', 9 * DAY)], NOW);

expect(groups.map((group) => group.key)).toEqual(['last-week', 'last-30-days']);
});
});

describe('filterHistory', () => {
it('matches names case-insensitively, and keeps everything for an empty query', () => {
const items = [item('1', DAY, 'Invoice batch'), item('2', DAY, 'Vendor list')];

expect(filterHistory(items, 'INVOICE').map((entry) => entry.id)).toEqual(['1']);
expect(filterHistory(items, '')).toBe(items);
});
});
70 changes: 70 additions & 0 deletions packages/apollo-react/src/chat/headless/history/history-groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { differenceInDays, differenceInMonths, isSameDay, subDays } from 'date-fns';

import type { AutopilotChatHistory } from '../../service';

/** The date bucket of a history group; `months-ago` groups carry their month count. */
export type ChatHistoryBucket =
| { kind: 'today' }
| { kind: 'yesterday' }
| { kind: 'last-week' }
| { kind: 'last-30-days' }
| { kind: 'months-ago'; count: number };

export interface ChatHistoryGroup {
/** Stable per bucket (`months-ago-2`), for keys and lookups. */
key: string;
bucket: ChatHistoryBucket;
items: AutopilotChatHistory[];
}

export function getHistoryBucket(timestamp: string, now: Date): ChatHistoryBucket {
const date = new Date(timestamp);
const daysAgo = differenceInDays(now, date);

if (isSameDay(date, now)) return { kind: 'today' };
if (isSameDay(date, subDays(now, 1))) return { kind: 'yesterday' };
if (daysAgo <= 7) return { kind: 'last-week' };
if (daysAgo <= 30) return { kind: 'last-30-days' };
return { kind: 'months-ago', count: differenceInMonths(now, date) };
}

const bucketKey = (bucket: ChatHistoryBucket) =>
bucket.kind === 'months-ago' ? `months-ago-${bucket.count}` : bucket.kind;

/**
* Client-side search, used when the host does not paginate history (with `paginatedHistory` the
* host filters on `HistorySearch`): a case-insensitive match on the name.
*/
export function filterHistory(items: AutopilotChatHistory[], query: string) {
const needle = query.toLowerCase();
return needle ? items.filter((item) => item.name.toLowerCase().includes(needle)) : items;
}

const time = (item: AutopilotChatHistory) => new Date(item.timestamp).getTime();

/**
* Groups history into date buckets, newest first. Groups are ordered by the first item each one
* received, then each group's items by date, as Material always has.
*/
export function groupHistory(items: AutopilotChatHistory[], now = new Date()): ChatHistoryGroup[] {
const groups: ChatHistoryGroup[] = [];

for (const item of items) {
const bucket = getHistoryBucket(item.timestamp, now);
const key = bucketKey(bucket);
const group = groups.find((candidate) => candidate.key === key);

if (group) {
group.items.push(item);
} else {
groups.push({ key, bucket, items: [item] });
}
}

groups.sort((a, b) => time(b.items[0]!) - time(a.items[0]!));
for (const group of groups) {
group.items.sort((a, b) => time(b) - time(a));
}

return groups;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
import { act, render, waitFor } from '@testing-library/react';
import type React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { ApI18nProvider } from '../../../i18n';
import { AutopilotChatEvent, type AutopilotChatHistory, AutopilotChatService } from '../../service';
import { AutopilotChatServiceProvider } from '../providers/chat-service.provider';
import {
type ChatHistoryState,
HISTORY_LOAD_MORE_THRESHOLD_PX,
HISTORY_SEARCH_DEBOUNCE_MS,
useChatHistory,
} from './use-chat-history';

let instanceCounter = 0;
let state: ChatHistoryState | undefined;

const entries = (count: number, prefix = 'Chat'): AutopilotChatHistory[] =>
Array.from({ length: count }, (_, index) => ({
id: `${prefix}-${index}`,
name: `${prefix} ${index}`,
timestamp: new Date(Date.now() - index * 60_000).toISOString(),
}));

function firstGroup() {
const group = state?.groups[0];
if (!group) throw new Error('no history group');
return group;
}

function Probe({
scrollContainer,
open = true,
}: {
scrollContainer: HTMLElement | null;
open?: boolean;
}) {
state = useChatHistory({ open, scrollContainer });
return null;
}

function setup(
paginated: boolean,
scrollContainer: HTMLElement | null = null,
before?: (service: AutopilotChatService) => void
) {
const service = AutopilotChatService.Instantiate({
instanceName: `use-chat-history-${++instanceCounter}`,
config: { paginatedHistory: paginated },
});
before?.(service);
const events: Array<[string, unknown]> = [];
for (const event of [AutopilotChatEvent.HistorySearch, AutopilotChatEvent.HistoryLoadMore]) {
service.on(event, (payload: unknown) => events.push([event, payload]));
}
const wrap = (node: React.ReactNode) => (
<ApI18nProvider component="chat">
<AutopilotChatServiceProvider chatServiceInstance={service}>
{node}
</AutopilotChatServiceProvider>
</ApI18nProvider>
);
const view = render(wrap(<Probe scrollContainer={scrollContainer} />));
const setOpen = (open: boolean) =>
view.rerender(wrap(<Probe scrollContainer={scrollContainer} open={open} />));
return { service, events, setOpen, unmount: view.unmount };
}

/** A scroll container whose metrics the test sets. */
function scroller(scrollHeight: number, clientHeight: number) {
const element = document.createElement('div');
Object.defineProperty(element, 'scrollHeight', { value: scrollHeight, configurable: true });
Object.defineProperty(element, 'clientHeight', { value: clientHeight, configurable: true });
return element;
}

afterEach(() => {
vi.useRealTimers();
});

describe('useChatHistory', () => {
it('filters on the client without pagination, with the empty and no-result flags', () => {
const { service } = setup(false);
expect(state?.hasHistoryData).toBe(false);

act(() => service.setHistory([...entries(2), ...entries(1, 'Invoice')]));
expect(state?.groups.map((group) => group.title)).toEqual(['Today']);
expect(firstGroup().items).toHaveLength(3);

act(() => state?.setSearchQuery('invoice'));
expect(firstGroup().items.map((item) => item.name)).toEqual(['Invoice 0']);
expect(state?.showSearchSkeleton).toBe(false);

act(() => state?.setSearchQuery('nothing'));
expect(state?.noSearchResults).toBe(true);
expect(state?.hasHistoryData).toBe(true);
});

it('asks the host to search when paginated, after a pause, until its results arrive', () => {
vi.useFakeTimers();
const { service, events } = setup(true);
act(() => service.setHistory(entries(3), false));

act(() => state?.setSearchQuery('in'));
act(() => state?.setSearchQuery('inv'));
expect(state?.showSearchSkeleton).toBe(true);
// Paginated: the list is the host's, not filtered here.
expect(firstGroup().items).toHaveLength(3);
expect(events).toEqual([]);

act(() => vi.advanceTimersByTime(HISTORY_SEARCH_DEBOUNCE_MS));
expect(events).toEqual([[AutopilotChatEvent.HistorySearch, { searchText: 'inv' }]]);

act(() => service.setHistory(entries(1, 'Invoice'), true));
expect(state?.showSearchSkeleton).toBe(false);
expect(firstGroup().items.map((item) => item.name)).toEqual(['Invoice 0']);
});

it('clears the search on close, resetting a paginated host once, only if it searched', () => {
vi.useFakeTimers();
const { service, events, setOpen } = setup(true);
act(() => service.setHistory(entries(3), false));

// Closing before the pause ends drops the search; the host never filtered, so no reset.
act(() => state?.setSearchQuery('inv'));
act(() => setOpen(false));
act(() => vi.advanceTimersByTime(HISTORY_SEARCH_DEBOUNCE_MS));
expect(events).toEqual([]);
expect(state?.searchQuery).toBe('');
expect(state?.showSearchSkeleton).toBe(false);

act(() => setOpen(true));
act(() => state?.setSearchQuery('inv'));
act(() => vi.advanceTimersByTime(HISTORY_SEARCH_DEBOUNCE_MS));
act(() => setOpen(false));
expect(events).toEqual([
[AutopilotChatEvent.HistorySearch, { searchText: 'inv' }],
[AutopilotChatEvent.HistorySearch, { searchText: '' }],
]);
// No skeleton for the empty search, so a host that ignores it leaves no skeleton behind.
expect(state?.showSearchSkeleton).toBe(false);

act(() => setOpen(true));
act(() => setOpen(false));
expect(events).toHaveLength(2);
});

it('resets a filtered host when unmounted, even with the box already emptied', () => {
vi.useFakeTimers();
const { events, unmount } = setup(true);
act(() => state?.setSearchQuery('inv'));
act(() => vi.advanceTimersByTime(HISTORY_SEARCH_DEBOUNCE_MS));
// Emptied, but the empty search is still waiting out the pause.
act(() => state?.setSearchQuery(''));
expect(state?.showSearchSkeleton).toBe(false);

unmount();
act(() => vi.advanceTimersByTime(HISTORY_SEARCH_DEBOUNCE_MS));

expect(events).toEqual([
[AutopilotChatEvent.HistorySearch, { searchText: 'inv' }],
[AutopilotChatEvent.HistorySearch, { searchText: '' }],
]);
});

it('only clears the box on close without pagination', () => {
const { service, events, setOpen } = setup(false);
act(() => service.setHistory(entries(3)));
act(() => state?.setSearchQuery('Chat 1'));

act(() => setOpen(false));

expect(state?.searchQuery).toBe('');
expect(events).toEqual([]);
});

it('loads more near the end of the list, once at a time, until the host is done', () => {
const container = scroller(1000, 400);
const { service, events } = setup(true, container);
act(() => service.setHistory(entries(10), false));
const scrollTo = (top: number) =>
act(() => {
container.scrollTop = top;
container.dispatchEvent(new Event('scroll'));
});

scrollTo(100);
expect(events).toEqual([]);

scrollTo(600 - HISTORY_LOAD_MORE_THRESHOLD_PX + 1);
scrollTo(600);
expect(events).toEqual([[AutopilotChatEvent.HistoryLoadMore, { searchText: '' }]]);
expect(state?.showLoadingMore).toBe(true);

// Older items keep the reader where they were.
act(() => service.appendOlderHistoryItems(entries(5, 'Older'), true));
expect(container.scrollTop).toBe(600);
expect(state?.showLoadingMore).toBe(false);

scrollTo(600);
expect(events).toHaveLength(1);
});

it('loads more once the list has its size when the first page does not fill it', async () => {
const container = scroller(300, 400);
const { service, events } = setup(true, container, (s) => s.setHistory(entries(3), false));

await waitFor(() =>
expect(events).toEqual([[AutopilotChatEvent.HistoryLoadMore, { searchText: '' }]])
);

// Still short after the next page, but the host said it was the last: no more asks.
act(() => service.appendOlderHistoryItems(entries(3, 'Older'), true));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(events).toHaveLength(1);
});

it('does not ask a host that was done before the list mounted', async () => {
const container = scroller(300, 400);
const { events } = setup(true, container, (s) => s.setHistory(entries(3), true));

await new Promise((resolve) => setTimeout(resolve, 50));
expect(events).toEqual([]);
});

it('starts a new chat when the open conversation is deleted, whatever is shown', () => {
const { service } = setup(false);
const newChats: unknown[] = [];
service.on(AutopilotChatEvent.NewChat, () => newChats.push(true));

act(() => service.openConversation('a'));
act(() => service.deleteConversation('b'));
expect(newChats).toEqual([]);

act(() => service.deleteConversation('a'));
expect(newChats).toEqual([true]);
});

it('does not load more without pagination', () => {
const container = scroller(1000, 400);
const { service, events } = setup(false, container);
act(() => service.setHistory(entries(10)));

act(() => {
container.scrollTop = 600;
container.dispatchEvent(new Event('scroll'));
});

expect(events).toEqual([]);
});
});
Loading
Loading