From f2a7656c418e250990ceb3affa15887c82d916c1 Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Fri, 16 Oct 2026 15:39:46 -0700 Subject: [PATCH 1/2] fix(apollo-react): fix chat history and settings behaviour hosts rely on - newChat() now clears activeConversationId. Before, the history kept marking the previous conversation as open after a new chat, and clicking it did nothing. Hosts that read activeConversationId after newChat now get null. - With paginatedHistory, closing the history after a search clears the search box and emits HistorySearch with an empty searchText, so the host returns its unfiltered list: the list always follows the box. Answer it like any other search. It is sent only when the host's list was filtered, and no skeleton waits for it. - appendOlderHistoryItems([]) now stops the load-more indicator. The documented way to stop loading on error (an empty page with done) left the spinner running. - Material: the settings close button runs the ToggleSettings pre-hook; it ran ToggleHistory. - Material: the settings content wraps instead of scrolling sideways (it inherited the panel's nowrap). MST-16568 Co-Authored-By: Claude Opus 5.5 --- .../src/chat/service/ChatService.test.ts | 27 ++++++ .../src/chat/service/ChatService.ts | 5 + .../src/material/components/ap-chat/DOCS.md | 8 +- .../components/history/chat-history.test.tsx | 92 +++++++++++++++++++ .../components/history/chat-history.tsx | 37 +++++++- .../settings/chat-settings-header.tsx | 2 +- .../components/settings/chat-settings.tsx | 4 + 7 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.test.tsx diff --git a/packages/apollo-react/src/chat/service/ChatService.test.ts b/packages/apollo-react/src/chat/service/ChatService.test.ts index e7ac31456..f4055799a 100644 --- a/packages/apollo-react/src/chat/service/ChatService.test.ts +++ b/packages/apollo-react/src/chat/service/ChatService.test.ts @@ -144,4 +144,31 @@ describe('AutopilotChatService', () => { expect(handler).toHaveBeenCalledWith('material'); }); }); + + describe('history', () => { + it('forgets the open conversation on a new chat', () => { + const service = createService(); + service.openConversation('c1'); + + service.newChat(); + + expect(service.activeConversationId).toBeNull(); + }); + + it('stops the load-more indicator when an empty page is appended', () => { + const service = createService(); + const loading = vi.fn(); + const hasMore = vi.fn(); + service.__internalService__.on(AutopilotChatInternalEvent.SetIsLoadingMoreHistory, loading); + service.__internalService__.on( + AutopilotChatInternalEvent.ShouldShowLoadingMoreHistory, + hasMore + ); + + service.appendOlderHistoryItems([], true); + + expect(hasMore).toHaveBeenCalledWith(false); + expect(loading).toHaveBeenCalledWith(false); + }); + }); }); diff --git a/packages/apollo-react/src/chat/service/ChatService.ts b/packages/apollo-react/src/chat/service/ChatService.ts index 1a6aed2d5..c302802f3 100644 --- a/packages/apollo-react/src/chat/service/ChatService.ts +++ b/packages/apollo-react/src/chat/service/ChatService.ts @@ -578,6 +578,9 @@ export class AutopilotChatService { } if (items.length === 0) { + // Nothing to append (an empty last page, or the host giving up after an error): stop the + // loading indicator the load-more request started. + this._internalService.publish(AutopilotChatInternalEvent.SetIsLoadingMoreHistory, false); return; } @@ -782,6 +785,8 @@ export class AutopilotChatService { this._groupId = undefined; this._conversation = []; + // A new chat is no conversation from the history, so the history marks none as open. + this._activeConversationId = null; this._eventBus.publish(AutopilotChatEvent.NewChat); this._internalService.publish(AutopilotChatInternalEvent.ShouldShowLoadingMoreMessages, true); this._internalService.publish(AutopilotChatInternalEvent.SetIsLoadingMoreMessages, false); diff --git a/packages/apollo-react/src/material/components/ap-chat/DOCS.md b/packages/apollo-react/src/material/components/ap-chat/DOCS.md index c49366641..cea178c21 100644 --- a/packages/apollo-react/src/material/components/ap-chat/DOCS.md +++ b/packages/apollo-react/src/material/components/ap-chat/DOCS.md @@ -153,7 +153,7 @@ See [Usage Examples](#usage-examples) for complete setup details. | Property | Description | | ---------------------- | -------------------------------------------------------------------------------------- | -| `activeConversationId` | Returns the current active conversation ID that was set with `openConversation` method | +| `activeConversationId` | Returns the current active conversation ID that was set with `openConversation` method, or `null` after `newChat` or deleting that conversation | | `historyOpen` | Returns the current history panel visibility state | | `settingsOpen` | Returns the current settings panel visibility state | @@ -2333,7 +2333,7 @@ chatService.on(AutopilotChatEvent.SetHistory, (history) => { #### Creating and Opening Conversations ```typescript -// Start a new chat (clears the current conversation) +// Start a new chat (clears the current conversation and the active conversation ID) // The chat component automatically calls this method when a user clicks the "New Chat" button chatService.newChat(); @@ -2506,6 +2506,8 @@ The history search functionality works in two modes: 1. **Non-paginated mode** (`paginatedHistory: false`, default): Search is performed locally on client-side data 2. **Paginated mode** (`paginatedHistory: true`): Search events are emitted for server-side handling +The history always shows the results for its search box. Closing the history clears the box, so in paginated mode closing it after a search emits `HistorySearch` with an empty `searchText`: answer it like any other search, with the unfiltered first page. + ### Implementing Search with Pagination When pagination is enabled, the chat service emits events for both search and load more operations: @@ -2554,7 +2556,7 @@ chatService.on(AutopilotChatEvent.HistoryLoadMore, async (event) => { ### Best Practices for History Pagination with Search 1. **Use appropriate page sizes**: 10-20 history items per batch typically provides good performance -2. **Handle errors gracefully**: Always call `appendOlderHistoryItems([], true)` on error to stop loading +2. **Handle errors gracefully**: Always call `appendOlderHistoryItems([], true)` on error to stop loading (an empty call also stops the loading indicator) 3. **Cache results**: Cache loaded history items and search results to avoid redundant API calls ## Feature Controls diff --git a/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.test.tsx b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.test.tsx new file mode 100644 index 000000000..9ed301fc6 --- /dev/null +++ b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.test.tsx @@ -0,0 +1,92 @@ +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ApChat } from '@uipath/apollo-react/chat/root/ApChat'; +import { + type AutopilotChatConfiguration, + AutopilotChatEvent, + type AutopilotChatHistory, + AutopilotChatMode, + AutopilotChatPreHookAction, + AutopilotChatService, +} from '@uipath/apollo-react/chat/service'; +import { describe, expect, it, vi } from 'vitest'; + +let instanceCount = 0; +const DAY = 86_400_000; +const HISTORY: AutopilotChatHistory[] = [ + { id: 'a', name: 'Invoice batch', timestamp: new Date(Date.now() - 60_000).toISOString() }, + { id: 'b', name: 'Vendor list', timestamp: new Date(Date.now() - 3 * DAY).toISOString() }, +]; + +async function setup(config: Partial = {}) { + const service = AutopilotChatService.Instantiate({ + instanceName: `material-history-${instanceCount++}`, + config: { + mode: AutopilotChatMode.FullScreen, + renderer: 'material', + disabledFeatures: { history: false, renameChat: false }, + ...config, + }, + }); + const events: Array<[string, unknown]> = []; + service.on(AutopilotChatEvent.HistorySearch, (payload: unknown) => + events.push([AutopilotChatEvent.HistorySearch, payload]) + ); + service.setHistory(HISTORY); + render(); + await screen.findByTestId('autopilot-chat-history', {}, { timeout: 25_000 }); + return { service, events }; +} + +const openHistory = async (service: AutopilotChatService) => { + act(() => service.toggleHistory(true)); + return screen.findByRole('presentation'); +}; + +describe('Material chat history and settings', () => { + it('clears the search on close, resetting a paginated host', async () => { + const { service, events } = await setup({ paginatedHistory: true }); + act(() => service.setHistory(HISTORY, false)); + const popover = await openHistory(service); + await userEvent.type(within(popover).getByPlaceholderText('Search'), 'inv'); + await waitFor(() => + expect(events).toContainEqual([AutopilotChatEvent.HistorySearch, { searchText: 'inv' }]) + ); + + act(() => service.toggleHistory(false)); + + expect(events.at(-1)).toEqual([AutopilotChatEvent.HistorySearch, { searchText: '' }]); + }, 30_000); + + it('closes the settings through the ToggleSettings pre-hook', async () => { + const toggleSettings = vi.fn().mockResolvedValue(true); + const toggleHistory = vi.fn().mockResolvedValue(true); + const { service } = await setup({ + disabledFeatures: { settings: false }, + settingsRenderer: () => {}, + preHooks: { + [AutopilotChatPreHookAction.ToggleSettings]: toggleSettings, + [AutopilotChatPreHookAction.ToggleHistory]: toggleHistory, + }, + }); + act(() => service.toggleSettings(true)); + + await userEvent.click(await screen.findByTestId('autopilot-chat-settings-close')); + + await waitFor(() => expect(service.settingsOpen).toBe(false)); + expect(toggleSettings).toHaveBeenCalledWith({ settingsOpen: true }); + expect(toggleHistory).not.toHaveBeenCalled(); + }, 30_000); + + it('wraps the host settings rather than scrolling them sideways', async () => { + const { service } = await setup({ + disabledFeatures: { settings: false }, + settingsRenderer: () => {}, + }); + act(() => service.toggleSettings(true)); + + const content = document.querySelector('.chat-settings-content') as HTMLElement; + expect(getComputedStyle(content).whiteSpace).toBe('normal'); + expect(getComputedStyle(content).overflowX).toBe('hidden'); + }, 30_000); +}); diff --git a/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.tsx b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.tsx index 8db852061..92768b365 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.tsx @@ -7,6 +7,7 @@ import { useScheduledCallback } from '@uipath/apollo-react/chat/headless/hooks/u import { useChatService } from '@uipath/apollo-react/chat/headless/providers/chat-service.provider'; import { useChatState } from '@uipath/apollo-react/chat/headless/providers/chat-state-provider'; import { useChatWidth } from '@uipath/apollo-react/chat/headless/providers/chat-width-provider'; +import { publishChatEvent } from '@uipath/apollo-react/chat/headless/utils/publish-chat-event'; import { AutopilotChatEvent, type AutopilotChatHistorySearchPayload, @@ -126,6 +127,8 @@ const AutopilotChatHistoryComponent: React.FC = ({ const appendingRef = useRef(false); const scrollTopBeforeAppendRef = useRef(0); const searchQueryRef = useRef(''); + // The query the host last searched for, so a reset is sent only when its list is filtered. + const sentQueryRef = useRef(''); const { historyOpen, historyAnchorElement, fullScreenContainer, spacing, portalContainer } = useChatState(); @@ -174,6 +177,7 @@ const AutopilotChatHistoryComponent: React.FC = ({ () => debounce((newSearchQuery: string) => { if (chatService?.getConfig()?.paginatedHistory) { + sentQueryRef.current = newSearchQuery; (chatService as any)._eventBus.publish(AutopilotChatEvent.HistorySearch, { searchText: newSearchQuery, } satisfies AutopilotChatHistorySearchPayload); @@ -249,6 +253,36 @@ const AutopilotChatHistoryComponent: React.FC = ({ searchQueryRef.current = searchQuery; }, [searchQuery]); + // Closing (or unmounting) clears the search, and the host's filtered results with it: the list + // follows the box. A search still waiting out the pause is dropped. + const resetSearch = useCallback(() => { + debouncedSearch.clear(); + searchQueryRef.current = ''; + setSearchQuery(''); + setIsSearching(false); + + if (chatService?.getConfig()?.paginatedHistory && sentQueryRef.current !== '') { + // No skeleton for the empty search: a host may ignore it, and keeps showing its list then. + sentQueryRef.current = ''; + publishChatEvent(chatService, AutopilotChatEvent.HistorySearch, { + searchText: '', + } satisfies AutopilotChatHistorySearchPayload); + internalService.publish(AutopilotChatInternalEvent.SetIsLoadingMoreHistory, false); + } + }, [chatService, internalService, debouncedSearch]); + + useEffect(() => { + if (!historyOpen) { + resetSearch(); + } + }, [historyOpen, resetSearch]); + + useEffect(() => () => debouncedSearch.clear(), [debouncedSearch]); + + const resetSearchRef = useRef(resetSearch); + resetSearchRef.current = resetSearch; + useEffect(() => () => resetSearchRef.current(), []); + const groupedHistory = useMemo(() => { const filteredHistory = chatService?.getConfig()?.paginatedHistory ? history @@ -321,7 +355,8 @@ const AutopilotChatHistoryComponent: React.FC = ({ setSearchQuery(newSearchText); if (chatService?.getConfig()?.paginatedHistory) { - setIsSearching(true); + // The skeleton waits for the results of a query; an emptied box keeps the list shown. + setIsSearching(newSearchText !== ''); debouncedSearch(newSearchText); } }, diff --git a/packages/apollo-react/src/material/components/ap-chat/components/settings/chat-settings-header.tsx b/packages/apollo-react/src/material/components/ap-chat/components/settings/chat-settings-header.tsx index 266c69391..a52f5964a 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/settings/chat-settings-header.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/settings/chat-settings-header.tsx @@ -35,7 +35,7 @@ const AutopilotChatSettingsHeaderComponent: React.FC<{ } chatService - .getPreHook(AutopilotChatPreHookAction.ToggleHistory)({ settingsOpen: isSettingsOpen }) + .getPreHook(AutopilotChatPreHookAction.ToggleSettings)({ settingsOpen: isSettingsOpen }) .then((proceed) => { if (!proceed) { return; diff --git a/packages/apollo-react/src/material/components/ap-chat/components/settings/chat-settings.tsx b/packages/apollo-react/src/material/components/ap-chat/components/settings/chat-settings.tsx index 54a9517b7..c5b55e081 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/settings/chat-settings.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/settings/chat-settings.tsx @@ -39,6 +39,10 @@ const ChatSettingsContainer = styled('div')<{ isOpen: boolean; isFullScreen: boo outline: 'none', flex: 1, overflowY: 'auto', + // The panel keeps its header on one line while its width animates; the host's content + // wraps, or a long line scrolls the panel sideways. + overflowX: 'hidden', + whiteSpace: 'normal', marginRight: `calc(-${token.Spacing.SpacingBase})`, paddingRight: token.Spacing.SpacingBase, }, From 1c25bf5cb0c6b6aff57f3559a3b4ca58568bf0da Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Fri, 16 Oct 2026 15:39:47 -0700 Subject: [PATCH 2/2] feat(apollo-react): add the Wind chat history and settings panels Replace the Wind chat's history and settings placeholders. The history opens as a popover under the header's history button in both modes, as Material's does, with search, date groups, the host's paginated loading, opening, rename and delete. It is not modal, so a host's own dialog opened from a pre-hook stays usable. The settings slide in from the right as Material's do: over the conversation in Embedded, beside it in FullScreen, closing from their X, the header button or Escape through the ToggleSettings pre-hook. The settings panel stays mounted, so the settingsRenderer container is the same element for the chat's life, emptied on close and rendered into on each opening. Hoist the history grouping and search, the list state (debounced HistorySearch, HistoryLoadMore near the end or when the first page does not fill the list, scroll kept on append, the search reset on close, a new chat when the open conversation is deleted), the per-item open, rename and delete flows, and the settingsRenderer lifecycle into src/chat/headless, and switch Material's history and settings to them. The service keeps whether the host has more history, so a list mounted after its last page does not ask again. MST-16568 Co-Authored-By: Claude Opus 5.5 --- packages/apollo-react/src/chat/README.md | 1 + .../headless/history/history-groups.test.ts | 65 ++++ .../chat/headless/history/history-groups.ts | 70 ++++ .../headless/hooks/use-chat-history.test.tsx | 251 ++++++++++++++ .../chat/headless/hooks/use-chat-history.ts | 310 ++++++++++++++++++ .../headless/hooks/use-history-item.test.tsx | 151 +++++++++ .../chat/headless/hooks/use-history-item.ts | 150 +++++++++ .../hooks/use-settings-renderer.test.tsx | 61 ++++ .../headless/hooks/use-settings-renderer.ts | 34 ++ .../providers/chat-state-provider.tsx | 9 + .../apollo-react/src/chat/locales/en.json | 3 +- .../src/chat/service/ChatService.ts | 14 + .../chat/stories/chat-playground.stories.tsx | 3 +- .../src/chat/stories/chat-playground.tsx | 34 +- .../src/chat/stories/chat-story-confirm.ts | 42 +++ .../src/chat/stories/chat-story-fixtures.ts | 58 ++++ .../src/chat/stories/chat-story-scenarios.ts | 75 +++++ .../src/chat/stories/chat-story-settings.tsx | 57 ++++ .../src/chat/wind/common/panel-popover.tsx | 156 +++++++++ .../src/chat/wind/header/chat-header.tsx | 16 +- .../chat/wind/header/header-parity.test.tsx | 27 +- .../chat/wind/history/chat-history.test.tsx | 287 ++++++++++++++++ .../src/chat/wind/history/chat-history.tsx | 50 +++ .../src/chat/wind/history/history-item.tsx | 135 ++++++++ .../src/chat/wind/history/history-panel.tsx | 123 +++++++ .../src/chat/wind/layout/embedded-layout.tsx | 43 ++- .../chat/wind/layout/full-screen-layout.tsx | 34 +- .../src/chat/wind/layout/regions.tsx | 27 -- .../chat/wind/settings/chat-settings.test.tsx | 171 ++++++++++ .../src/chat/wind/settings/chat-settings.tsx | 170 ++++++++++ .../components/history/chat-history-group.tsx | 4 +- .../components/history/chat-history-item.tsx | 172 ++-------- .../components/history/chat-history.test.tsx | 96 +++++- .../components/history/chat-history.tsx | 258 +-------------- .../components/settings/chat-settings.tsx | 35 +- 35 files changed, 2687 insertions(+), 505 deletions(-) create mode 100644 packages/apollo-react/src/chat/headless/history/history-groups.test.ts create mode 100644 packages/apollo-react/src/chat/headless/history/history-groups.ts create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-chat-history.test.tsx create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-chat-history.ts create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-history-item.test.tsx create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-history-item.ts create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-settings-renderer.test.tsx create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-settings-renderer.ts create mode 100644 packages/apollo-react/src/chat/stories/chat-story-confirm.ts create mode 100644 packages/apollo-react/src/chat/stories/chat-story-settings.tsx create mode 100644 packages/apollo-react/src/chat/wind/common/panel-popover.tsx create mode 100644 packages/apollo-react/src/chat/wind/history/chat-history.test.tsx create mode 100644 packages/apollo-react/src/chat/wind/history/chat-history.tsx create mode 100644 packages/apollo-react/src/chat/wind/history/history-item.tsx create mode 100644 packages/apollo-react/src/chat/wind/history/history-panel.tsx create mode 100644 packages/apollo-react/src/chat/wind/settings/chat-settings.test.tsx create mode 100644 packages/apollo-react/src/chat/wind/settings/chat-settings.tsx diff --git a/packages/apollo-react/src/chat/README.md b/packages/apollo-react/src/chat/README.md index 802b5f3ed..75dbf1b93 100644 --- a/packages/apollo-react/src/chat/README.md +++ b/packages/apollo-react/src/chat/README.md @@ -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 diff --git a/packages/apollo-react/src/chat/headless/history/history-groups.test.ts b/packages/apollo-react/src/chat/headless/history/history-groups.test.ts new file mode 100644 index 000000000..3ba4538c0 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/history/history-groups.test.ts @@ -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); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/history/history-groups.ts b/packages/apollo-react/src/chat/headless/history/history-groups.ts new file mode 100644 index 000000000..491086a55 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/history/history-groups.ts @@ -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; +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-chat-history.test.tsx b/packages/apollo-react/src/chat/headless/hooks/use-chat-history.test.tsx new file mode 100644 index 000000000..de238bab0 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-chat-history.test.tsx @@ -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) => ( + + + {node} + + + ); + const view = render(wrap()); + const setOpen = (open: boolean) => + view.rerender(wrap()); + 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([]); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/hooks/use-chat-history.ts b/packages/apollo-react/src/chat/headless/hooks/use-chat-history.ts new file mode 100644 index 000000000..84c97a754 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-chat-history.ts @@ -0,0 +1,310 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import debounce from 'debounce'; +import React from 'react'; + +import { + AutopilotChatEvent, + type AutopilotChatHistory, + type AutopilotChatHistorySearchPayload, + AutopilotChatInternalEvent, +} from '../../service'; +import { + type ChatHistoryBucket, + type ChatHistoryGroup, + filterHistory, + groupHistory, +} from '../history/history-groups'; +import { useChatService } from '../providers/chat-service.provider'; +import { publishChatEvent } from '../utils/publish-chat-event'; + +export const HISTORY_SEARCH_DEBOUNCE_MS = 400; +/** How close to the end of the list scrolling asks the host for older items. */ +export const HISTORY_LOAD_MORE_THRESHOLD_PX = 50; + +export interface TitledChatHistoryGroup extends ChatHistoryGroup { + title: string; +} + +export interface ChatHistoryState { + history: AutopilotChatHistory[]; + groups: TitledChatHistoryGroup[]; + searchQuery: string; + setSearchQuery: (query: string | undefined) => void; + /** The host paginates history (`paginatedHistory`): it searches and loads older items. */ + paginated: boolean; + /** A paginated search is waiting for the host's results. */ + showSearchSkeleton: boolean; + /** The host is loading older items. */ + showLoadingMore: boolean; + /** Show the search field and the list; otherwise the list is empty, with no search under way. */ + hasHistoryData: boolean; + noSearchResults: boolean; +} + +/** The localized title of a date bucket. */ +export function useHistoryGroupTitle() { + const { _ } = useLingui(); + + return React.useCallback( + (bucket: ChatHistoryBucket) => { + switch (bucket.kind) { + case 'today': + return _(msg({ id: 'autopilot-chat.history.group.today', message: `Today` })); + case 'yesterday': + return _(msg({ id: 'autopilot-chat.history.group.yesterday', message: `Yesterday` })); + case 'last-week': + return _(msg({ id: 'autopilot-chat.history.group.last-week', message: `Last 7 days` })); + case 'last-30-days': + return _( + msg({ id: 'autopilot-chat.history.group.last-30-days', message: `Last 30 days` }) + ); + case 'months-ago': { + const count = bucket.count; + return _( + msg({ id: 'autopilot-chat.history.group.months-ago', message: `${count} months ago` }) + ); + } + } + }, + [_] + ); +} + +/** + * The history list for every renderer: the service's history grouped by date, search (client side, + * or debounced `HistorySearch` when the host paginates), and `HistoryLoadMore` when + * `scrollContainer` is scrolled near its end, keeping the scroll position when older items append. + * + * A first page too short to scroll asks for more as soon as the list has its size. + * + * Keep it mounted while the history is closed. Closing clears the search, and the list follows + * the box, so a paginated host is sent an empty `HistorySearch` to reset its results. Deleting the + * open conversation starts a new chat. + */ +export function useChatHistory({ + open, + scrollContainer, +}: { + open: boolean; + scrollContainer: HTMLElement | null; +}): ChatHistoryState { + const chatService = useChatService(); + const internalService = chatService.__internalService__; + const groupTitle = useHistoryGroupTitle(); + const paginated = !!chatService.getConfig()?.paginatedHistory; + + const [history, setHistory] = React.useState( + () => chatService.getHistory() ?? [] + ); + const [searchQuery, setSearchQueryState] = React.useState(''); + const [isLoadingMore, setIsLoadingMore] = React.useState(false); + const [isSearching, setIsSearching] = React.useState(false); + + const isLoadingMoreRef = React.useRef(false); + // Seeded from the service, so a list mounted after the host's last page does not ask again. + const hasMoreRef = React.useRef(chatService.__hasMoreHistory__); + const appendingRef = React.useRef(false); + const scrollTopBeforeAppendRef = React.useRef(0); + const searchQueryRef = React.useRef(''); + // The query the host last searched for, so a reset is sent only when its list is filtered. + const sentQueryRef = React.useRef(''); + + React.useEffect(() => { + const unsubscribeSetHistory = chatService.on( + AutopilotChatEvent.SetHistory, + (next: AutopilotChatHistory[]) => { + setHistory(next); + setIsSearching(false); + } + ); + const unsubscribeLoadingMore = internalService.on( + AutopilotChatInternalEvent.SetIsLoadingMoreHistory, + (value: boolean) => { + isLoadingMoreRef.current = value; + setIsLoadingMore(value); + } + ); + const unsubscribeHasMore = internalService.on( + AutopilotChatInternalEvent.ShouldShowLoadingMoreHistory, + (value: boolean) => { + hasMoreRef.current = value; + } + ); + // The service forgets the open conversation before announcing its deletion, so it is tracked + // here from the events. + let activeId = chatService.activeConversationId; + const unsubscribeOpen = chatService.on( + AutopilotChatEvent.OpenConversation, + (id: string | null) => { + activeId = id; + } + ); + const unsubscribeNewChat = chatService.on(AutopilotChatEvent.NewChat, () => { + activeId = null; + }); + const unsubscribeDelete = chatService.on( + AutopilotChatEvent.DeleteConversation, + (id: string) => { + if (id === activeId) { + chatService.newChat(); + } + } + ); + const unsubscribeAppend = internalService.on( + AutopilotChatInternalEvent.AppendOlderHistory, + () => { + if (scrollContainer) { + appendingRef.current = true; + scrollTopBeforeAppendRef.current = scrollContainer.scrollTop; + } + } + ); + + return () => { + unsubscribeSetHistory(); + unsubscribeLoadingMore(); + unsubscribeHasMore(); + unsubscribeOpen(); + unsubscribeNewChat(); + unsubscribeDelete(); + unsubscribeAppend(); + }; + }, [chatService, internalService, scrollContainer]); + + const loadMoreAtEnd = React.useCallback(() => { + if (!scrollContainer) { + return; + } + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + + if ( + scrollHeight - scrollTop - clientHeight < HISTORY_LOAD_MORE_THRESHOLD_PX && + !isLoadingMoreRef.current && + hasMoreRef.current && + chatService.getConfig()?.paginatedHistory + ) { + publishChatEvent(chatService, AutopilotChatEvent.HistoryLoadMore, { + searchText: searchQueryRef.current, + } satisfies AutopilotChatHistorySearchPayload); + internalService.publish(AutopilotChatInternalEvent.SetIsLoadingMoreHistory, true); + } + }, [scrollContainer, chatService, internalService]); + + React.useEffect(() => { + if (!scrollContainer || !open) { + return; + } + scrollContainer.addEventListener('scroll', loadMoreAtEnd, { passive: true }); + return () => scrollContainer.removeEventListener('scroll', loadMoreAtEnd); + }, [scrollContainer, open, loadMoreAtEnd]); + + // A list that does not fill its container never scrolls, so the same check runs whenever the + // list changes: once the container has a real height and two frames have passed, so the + // layout (and the popover's position) has settled. + // biome-ignore lint/correctness/useExhaustiveDependencies: re-checks after each history update + React.useLayoutEffect(() => { + if (!scrollContainer || !open) { + return; + } + let frame = requestAnimationFrame(() => { + frame = requestAnimationFrame(() => { + if (scrollContainer.clientHeight > 0) { + loadMoreAtEnd(); + } + }); + }); + return () => cancelAnimationFrame(frame); + }, [history, open, scrollContainer, loadMoreAtEnd]); + + // Appending older items keeps the reader where they were. + // biome-ignore lint/correctness/useExhaustiveDependencies: runs after each history update + React.useLayoutEffect(() => { + if (appendingRef.current && scrollContainer) { + scrollContainer.scrollTop = scrollTopBeforeAppendRef.current; + appendingRef.current = false; + } + }, [history, scrollContainer]); + + const debouncedSearch = React.useMemo( + () => + debounce((searchText: string) => { + if (chatService.getConfig()?.paginatedHistory) { + sentQueryRef.current = searchText; + publishChatEvent(chatService, AutopilotChatEvent.HistorySearch, { + searchText, + } satisfies AutopilotChatHistorySearchPayload); + internalService.publish(AutopilotChatInternalEvent.SetIsLoadingMoreHistory, false); + } + }, HISTORY_SEARCH_DEBOUNCE_MS), + [chatService, internalService] + ); + + React.useEffect(() => () => debouncedSearch.clear(), [debouncedSearch]); + + // Closing (or unmounting) clears the search, and the host's filtered results with it. A search + // still waiting out the pause is dropped. + const resetSearch = React.useCallback(() => { + debouncedSearch.clear(); + if (searchQueryRef.current !== '') { + searchQueryRef.current = ''; + setSearchQueryState(''); + } + setIsSearching(false); + + if (chatService.getConfig()?.paginatedHistory && sentQueryRef.current !== '') { + // No skeleton for the empty search: a host may ignore it, and keeps showing its list then. + sentQueryRef.current = ''; + publishChatEvent(chatService, AutopilotChatEvent.HistorySearch, { + searchText: '', + } satisfies AutopilotChatHistorySearchPayload); + internalService.publish(AutopilotChatInternalEvent.SetIsLoadingMoreHistory, false); + } + }, [chatService, internalService, debouncedSearch]); + + React.useEffect(() => { + if (!open) { + resetSearch(); + } + }, [open, resetSearch]); + + const resetSearchRef = React.useRef(resetSearch); + resetSearchRef.current = resetSearch; + React.useEffect(() => () => resetSearchRef.current(), []); + + const setSearchQuery = React.useCallback( + (query: string | undefined) => { + const next = query || ''; + searchQueryRef.current = next; + setSearchQueryState(next); + + if (chatService.getConfig()?.paginatedHistory) { + // The skeleton waits for the results of a query; an emptied box keeps the list shown. + setIsSearching(next !== ''); + debouncedSearch(next); + } + }, + [chatService, debouncedSearch] + ); + + const groups = React.useMemo( + () => + groupHistory(paginated ? history : filterHistory(history, searchQuery)).map((group) => ({ + ...group, + title: groupTitle(group.bucket), + })), + [history, searchQuery, paginated, groupTitle] + ); + + return { + history, + groups, + searchQuery, + setSearchQuery, + paginated, + showSearchSkeleton: isSearching && paginated, + showLoadingMore: isLoadingMore && paginated, + hasHistoryData: history.length > 0 || !!searchQuery || isSearching, + noSearchResults: groups.length === 0 && !!searchQuery, + }; +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-history-item.test.tsx b/packages/apollo-react/src/chat/headless/hooks/use-history-item.test.tsx new file mode 100644 index 000000000..94954ad57 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-history-item.test.tsx @@ -0,0 +1,151 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { + AutopilotChatEvent, + type AutopilotChatHistory, + AutopilotChatPreHookAction, + AutopilotChatService, +} from '../../service'; +import { AutopilotChatServiceProvider } from '../providers/chat-service.provider'; +import { AutopilotLoadingProvider } from '../providers/loading-provider'; +import { type HistoryItemState, useHistoryItem } from './use-history-item'; + +let instanceCounter = 0; +let state: HistoryItemState | undefined; + +const ITEM: AutopilotChatHistory = { + id: 'c1', + name: 'Invoices', + timestamp: new Date().toISOString(), +}; + +function Probe({ historyOpen }: { historyOpen: boolean }) { + state = useHistoryItem(ITEM, historyOpen); + return null; +} + +function setup({ + historyOpen = true, + preHook, +}: { + historyOpen?: boolean; + preHook?: Partial Promise>>; +} = {}) { + const service = AutopilotChatService.Instantiate({ + instanceName: `use-history-item-${++instanceCounter}`, + config: { preHooks: preHook }, + }); + const events: Array<[string, unknown]> = []; + for (const event of [ + AutopilotChatEvent.RenameConversation, + AutopilotChatEvent.DeleteConversation, + AutopilotChatEvent.OpenConversation, + AutopilotChatEvent.NewChat, + ]) { + service.on(event, (payload: unknown) => events.push([event, payload])); + } + render( + + + + + + ); + return { service, events }; +} + +describe('useHistoryItem', () => { + it('opens the conversation and closes the history, once, while it is not active', () => { + const { service, events } = setup(); + act(() => service.toggleHistory(true)); + + act(() => state?.open()); + expect(events).toEqual([[AutopilotChatEvent.OpenConversation, 'c1']]); + expect(service.historyOpen).toBe(false); + expect(state?.isActive).toBe(true); + + act(() => state?.open()); + expect(events).toHaveLength(1); + + act(() => service.openConversation('c2')); + expect(state?.isActive).toBe(false); + }); + + it('renames through the pre-hook, trimming, and cancels an empty or unchanged name', async () => { + const { events } = setup(); + + act(() => state?.startRename()); + expect(state?.isEditing).toBe(true); + expect(state?.draftName).toBe('Invoices'); + + act(() => state?.setDraftName(' ')); + act(() => state?.commitRename()); + expect(state?.isEditing).toBe(false); + + act(() => state?.startRename()); + act(() => state?.setDraftName(' Vendor invoices ')); + act(() => state?.commitRename()); + // The blur after Enter does not rename twice. + act(() => state?.commitRename()); + await waitFor(() => expect(state?.isEditing).toBe(false)); + expect(events).toEqual([ + [AutopilotChatEvent.RenameConversation, { conversationId: 'c1', name: 'Vendor invoices' }], + ]); + }); + + it('cancels the rename when the pre-hook declines or fails', async () => { + const decline = vi.fn().mockResolvedValueOnce(false).mockRejectedValueOnce(new Error('no')); + const { events } = setup({ + preHook: { [AutopilotChatPreHookAction.RenameConversation]: decline }, + }); + + for (let attempt = 0; attempt < 2; attempt++) { + act(() => state?.startRename()); + act(() => state?.setDraftName('Other')); + act(() => state?.commitRename()); + await waitFor(() => expect(state?.isEditing).toBe(false)); + expect(state?.draftName).toBe('Invoices'); + } + expect(decline).toHaveBeenCalledTimes(2); + expect(events).toEqual([]); + }); + + it('does not start renaming while the history is closed', () => { + setup({ historyOpen: false }); + + act(() => state?.startRename()); + + expect(state?.isEditing).toBe(false); + }); + + it('deletes through the pre-hook, keeping the conversation when it declines or fails', async () => { + const confirm = vi + .fn() + .mockResolvedValueOnce(false) + .mockRejectedValueOnce(new Error('no')) + .mockResolvedValue(true); + const { events } = setup({ + preHook: { [AutopilotChatPreHookAction.DeleteConversation]: confirm }, + }); + + await act(async () => state?.remove()); + await act(async () => state?.remove()); + expect(events).toEqual([]); + + await act(async () => state?.remove()); + expect(events).toEqual([[AutopilotChatEvent.DeleteConversation, 'c1']]); + }); + + it('does not rename on the blur that follows Escape', async () => { + const { events } = setup(); + act(() => state?.startRename()); + act(() => state?.setDraftName('Other')); + + act(() => state?.cancelRename()); + act(() => state?.commitRename()); + + await act(async () => {}); + expect(events).toEqual([]); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/hooks/use-history-item.ts b/packages/apollo-react/src/chat/headless/hooks/use-history-item.ts new file mode 100644 index 000000000..312a14e59 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-history-item.ts @@ -0,0 +1,150 @@ +import React from 'react'; + +import { + AutopilotChatEvent, + type AutopilotChatHistory, + AutopilotChatPreHookAction, +} from '../../service'; +import { useChatService } from '../providers/chat-service.provider'; +import { useLoading } from '../providers/loading-provider'; + +export interface HistoryItemState { + /** The conversation is the one open in the chat. */ + isActive: boolean; + /** Opens the conversation and closes the history; a no-op while active or renaming. */ + open: () => void; + isEditing: boolean; + draftName: string; + setDraftName: (name: string) => void; + /** Starts renaming, with the current name as the draft; only while the history is open. */ + startRename: () => void; + /** + * Renames through the `RenameConversation` pre-hook; an empty or unchanged name, a declining or + * failing pre-hook, cancels instead. + */ + commitRename: () => void; + cancelRename: () => void; + /** Deletes through the `DeleteConversation` pre-hook. */ + remove: () => void; +} + +/** + * One history entry for every renderer: whether it is the open conversation, opening it, the + * rename flow and delete. (`useChatHistory` starts a new chat when the open one is deleted.) + */ +export function useHistoryItem(item: AutopilotChatHistory, historyOpen: boolean): HistoryItemState { + const chatService = useChatService(); + const { setWaitingResponse } = useLoading(); + const [isActive, setIsActive] = React.useState(chatService.activeConversationId === item.id); + const [isEditing, setIsEditing] = React.useState(false); + const [draftName, setDraftName] = React.useState(item.name); + // Enter commits, and the blur that follows when the field unmounts would commit again. + const isCommittingRef = React.useRef(false); + // A blur from the field being removed after Escape must not commit. + const isEditingRef = React.useRef(false); + + // The draft follows the host's name while not renaming. + React.useEffect(() => { + if (!isEditing) { + setDraftName(item.name); + } + }, [item.name, isEditing]); + + React.useEffect(() => { + if (chatService.activeConversationId === item.id && !isActive) { + setIsActive(true); + } + + const unsubscribeOpen = chatService.on( + AutopilotChatEvent.OpenConversation, + (id: string | null) => { + if (!isActive && item.id === id) { + setWaitingResponse(false); + } + setIsActive(id === item.id); + } + ); + const unsubscribeNewChat = chatService.on(AutopilotChatEvent.NewChat, () => setIsActive(false)); + + return () => { + unsubscribeOpen(); + unsubscribeNewChat(); + }; + }, [chatService, item.id, isActive, setWaitingResponse]); + + const startRename = React.useCallback(() => { + if (!historyOpen) { + return; + } + isCommittingRef.current = false; + isEditingRef.current = true; + setDraftName(item.name); + setIsEditing(true); + }, [historyOpen, item.name]); + + const cancelRename = React.useCallback(() => { + isCommittingRef.current = false; + isEditingRef.current = false; + setDraftName(item.name); + setIsEditing(false); + }, [item.name]); + + const commitRename = React.useCallback(() => { + if (isCommittingRef.current || !isEditingRef.current) { + return; + } + const name = draftName.trim(); + + if (name === '' || name === item.name) { + cancelRename(); + return; + } + + isCommittingRef.current = true; + chatService + .getPreHook(AutopilotChatPreHookAction.RenameConversation)({ conversationId: item.id, name }) + .then((proceed) => { + if (!proceed) { + cancelRename(); + return; + } + chatService.renameConversation(item.id, name); + isEditingRef.current = false; + setIsEditing(false); + isCommittingRef.current = false; + }) + .catch(() => cancelRename()); + }, [draftName, item.id, item.name, chatService, cancelRename]); + + const remove = React.useCallback(() => { + chatService + .getPreHook(AutopilotChatPreHookAction.DeleteConversation)({ conversationId: item.id }) + .then((proceed) => { + if (proceed) { + chatService.deleteConversation(item.id); + } + }) + // A failing pre-hook keeps the conversation, as a declining one does. + .catch(() => {}); + }, [chatService, item.id]); + + const open = React.useCallback(() => { + if (isEditing || isActive) { + return; + } + chatService.openConversation(item.id); + chatService.toggleHistory(false); + }, [chatService, item.id, isActive, isEditing]); + + return { + isActive, + open, + isEditing, + draftName, + setDraftName, + startRename, + commitRename, + cancelRename, + remove, + }; +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-settings-renderer.test.tsx b/packages/apollo-react/src/chat/headless/hooks/use-settings-renderer.test.tsx new file mode 100644 index 000000000..45d21f216 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-settings-renderer.test.tsx @@ -0,0 +1,61 @@ +import { act, render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { AutopilotChatService } from '../../service'; +import { AutopilotChatServiceProvider } from '../providers/chat-service.provider'; +import { AutopilotChatStateProvider } from '../providers/chat-state-provider'; +import { useSettingsRenderer } from './use-settings-renderer'; + +let instanceCounter = 0; + +function Probe({ container }: { container: HTMLElement }) { + useSettingsRenderer(container); + return null; +} + +function setup(settingsRenderer: (container: HTMLElement) => void) { + const service = AutopilotChatService.Instantiate({ + instanceName: `use-settings-renderer-${++instanceCounter}`, + config: { settingsRenderer }, + }); + const container = document.createElement('div'); + render( + + + + + + ); + return { service, container }; +} + +describe('useSettingsRenderer', () => { + it('renders into the same container on each opening and empties it on close', () => { + const settingsRenderer = vi.fn((target: HTMLElement) => { + target.append(document.createElement('form')); + }); + const { service, container } = setup(settingsRenderer); + expect(settingsRenderer).not.toHaveBeenCalled(); + + act(() => service.toggleSettings(true)); + expect(settingsRenderer).toHaveBeenLastCalledWith(container); + expect(container.children).toHaveLength(1); + + act(() => service.toggleSettings(false)); + expect(container.children).toHaveLength(0); + + act(() => service.toggleSettings(true)); + expect(settingsRenderer).toHaveBeenCalledTimes(2); + expect(settingsRenderer.mock.calls[1]![0]).toBe(container); + expect(container.children).toHaveLength(1); + }); + + it('survives a failing renderer', () => { + const { service, container } = setup(() => { + throw new Error('host failed'); + }); + + expect(() => act(() => service.toggleSettings(true))).not.toThrow(); + expect(container.children).toHaveLength(0); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/hooks/use-settings-renderer.ts b/packages/apollo-react/src/chat/headless/hooks/use-settings-renderer.ts new file mode 100644 index 000000000..d32324798 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-settings-renderer.ts @@ -0,0 +1,34 @@ +import React from 'react'; + +import { useChatService } from '../providers/chat-service.provider'; +import { useChatState } from '../providers/chat-state-provider'; + +/** + * Hands `container` to the host's `settingsRenderer` each time the settings open, and empties it + * when they close. Keep the container mounted while the panel is closed: the host renders into it + * (CAS mounts a React root), so it must not change identity between openings. + */ +export function useSettingsRenderer(container: HTMLElement | null) { + const chatService = useChatService(); + const { settingsOpen } = useChatState(); + + React.useEffect(() => { + const settingsRenderer = chatService.getConfig().settingsRenderer; + + if (!container || !settingsRenderer) { + return; + } + + container.innerHTML = ''; + + if (!settingsOpen) { + return; + } + + try { + settingsRenderer(container); + } catch { + // A failing host renderer leaves the panel empty rather than breaking the chat. + } + }, [chatService, container, settingsOpen]); +} diff --git a/packages/apollo-react/src/chat/headless/providers/chat-state-provider.tsx b/packages/apollo-react/src/chat/headless/providers/chat-state-provider.tsx index 1f71fa6fb..79e3d3c4d 100644 --- a/packages/apollo-react/src/chat/headless/providers/chat-state-provider.tsx +++ b/packages/apollo-react/src/chat/headless/providers/chat-state-provider.tsx @@ -33,6 +33,9 @@ type DeepRequired = { interface AutopilotChatStateContextType { historyAnchorElement: HTMLElement | null; setHistoryAnchorElement: (element: HTMLElement | null) => void; + /** The header's settings button, for renderers that return focus to it on close. */ + settingsAnchorElement: HTMLElement | null; + setSettingsAnchorElement: (element: HTMLElement | null) => void; fullScreenContainer: HTMLElement | null; setFullScreenContainer: (element: HTMLElement | null) => void; historyOpen: boolean; @@ -169,6 +172,9 @@ export const AutopilotChatStateProvider: React.FC(null); + const [settingsAnchorElement, setSettingsAnchorElement] = React.useState( + null + ); const [fullScreenContainer, setFullScreenContainer] = React.useState(null); const [historyOpen, setHistoryOpen] = React.useState(chatService?.historyOpen ?? false); const [settingsOpen, setSettingsOpen] = React.useState(chatService?.settingsOpen ?? false); @@ -338,6 +344,8 @@ export const AutopilotChatStateProvider: React.FC () => - Promise.resolve(window.confirm(`Pre-hook "${action}". Continue?`)); + confirmWithHostDialog(`Pre-hook "${action}". Continue?`); const allowPreHook = () => Promise.resolve(true); const preHooksFrom = (enabled: boolean) => @@ -230,7 +235,7 @@ function createSlotService( selectedModel: STORY_MODELS[0], agentModes: STORY_AGENT_MODES, selectedAgentMode: STORY_AGENT_MODES[0], - settingsRenderer: createStorySettingsRenderer(), + settingsRenderer: createStoryReactSettingsRenderer(), resourceManager: createStoryResourceManager(), }, }); @@ -240,7 +245,7 @@ function createSlotService( service.setLoadingMessage(''); service.setCustomHeaderActions(customActionsFor(renderer, args.customHeaderActions)); service.setDefaultLoadingMessages(STORY_LOADING_MESSAGES); - service.setHistory(storyHistoryItems()); + service.setHistory(storyHistoryBuckets()); if (seedFrom) { service.setConversation(seedFrom.getConversation().map((message) => ({ ...message }))); @@ -384,6 +389,25 @@ function ChatSlot({ ); } }), + // The host side of the history: rename and delete update the list, opening loads a transcript. + service.on( + AutopilotChatEvent.RenameConversation, + ({ conversationId, name }: AutopilotChatRenameConversationPayload) => + service.setHistory( + service + .getHistory() + .map((item) => (item.id === conversationId ? { ...item, name } : item)) + ) + ), + service.on(AutopilotChatEvent.DeleteConversation, (conversationId: string) => + service.setHistory(service.getHistory().filter((item) => item.id !== conversationId)) + ), + service.on(AutopilotChatEvent.OpenConversation, (conversationId: string | null) => { + const item = service.getHistory().find((entry) => entry.id === conversationId); + if (item) { + timers.schedule(() => service.setConversation(storyConversationFor(item)), 400); + } + }), service.on( AutopilotChatEvent.CustomHeaderActionClicked, (action: AutopilotChatCustomHeaderAction) => diff --git a/packages/apollo-react/src/chat/stories/chat-story-confirm.ts b/packages/apollo-react/src/chat/stories/chat-story-confirm.ts new file mode 100644 index 000000000..6c3efeb3c --- /dev/null +++ b/packages/apollo-react/src/chat/stories/chat-story-confirm.ts @@ -0,0 +1,42 @@ +/** + * A host's own confirmation, as a pre-hook might show one: a native `` on the page's + * body, outside the chat, resolving with the choice. + */ +export function confirmWithHostDialog(message: string): Promise { + return new Promise((resolve) => { + const dialog = document.createElement('dialog'); + dialog.dataset.storyConfirm = ''; + dialog.style.cssText = + 'padding: 16px; border: 1px solid #888; border-radius: 8px; font: 14px sans-serif; background: #fff; color: #111;'; + const text = document.createElement('p'); + text.textContent = message; + text.style.margin = '0 0 12px'; + const actions = document.createElement('div'); + actions.style.cssText = 'display: flex; gap: 8px; justify-content: flex-end;'; + + const finish = (proceed: boolean) => { + dialog.close(); + dialog.remove(); + resolve(proceed); + }; + for (const [label, proceed] of [ + ['Cancel', false], + ['Continue', true], + ] as const) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.addEventListener('click', () => finish(proceed)); + actions.append(button); + } + // Escape cancels. + dialog.addEventListener('cancel', (event) => { + event.preventDefault(); + finish(false); + }); + + dialog.append(text, actions); + document.body.append(dialog); + dialog.showModal(); + }); +} diff --git a/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts b/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts index bdb576bca..71e88c7d2 100644 --- a/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts +++ b/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts @@ -291,6 +291,64 @@ export function storyHistoryItems(now = Date.now()): AutopilotChatHistory[] { ]; } +const DAY_MS = 86_400_000; + +/** + * Conversations in every date bucket the history groups by: today, yesterday, the last 7 and 30 + * days, and months ago (one name long enough to truncate). + */ +export function storyHistoryBuckets(now = Date.now()): AutopilotChatHistory[] { + const at = (msAgo: number) => new Date(now - msAgo).toISOString(); + + return [ + { id: 'bucket-today-1', name: 'Invoice extraction workflow', timestamp: at(5 * 60_000) }, + { id: 'bucket-today-2', name: 'Vendor reconciliation', timestamp: at(20 * 60_000) }, + { id: 'bucket-yesterday', name: 'Purchase order approvals', timestamp: at(DAY_MS) }, + { id: 'bucket-week-1', name: 'Debugging a selector timeout', timestamp: at(3 * DAY_MS) }, + { id: 'bucket-week-2', name: 'Queue triggers in Orchestrator', timestamp: at(6 * DAY_MS) }, + { id: 'bucket-month-1', name: 'Document Understanding taxonomy', timestamp: at(12 * DAY_MS) }, + { id: 'bucket-month-2', name: 'Excel automation tips', timestamp: at(25 * DAY_MS) }, + { id: 'bucket-old-1', name: 'Studio Web project setup', timestamp: at(45 * DAY_MS) }, + { + id: 'bucket-old-2', + name: 'A very long conversation title about reconciling vendor statements with the general ledger every month', + timestamp: at(75 * DAY_MS), + }, + { id: 'bucket-old-3', name: 'Attended robot licensing', timestamp: at(200 * DAY_MS) }, + ]; +} + +export const STORY_HISTORY_PAGE_SIZE = 10; + +/** Older conversations for paginated history, two days apart, newest first. */ +export function storyHistoryArchive(now = Date.now(), count = 40): AutopilotChatHistory[] { + return Array.from({ length: count }, (_, index) => ({ + id: `archive-${index + 1}`, + name: `Archived conversation ${index + 1}`, + timestamp: new Date(now - (index + 1) * 2 * DAY_MS).toISOString(), + })); +} + +/** The transcript a host would load when a history entry is opened. */ +export function storyConversationFor(item: AutopilotChatHistory): AutopilotChatMessage[] { + return [ + { + id: `${item.id}-question`, + role: AutopilotChatRole.User, + content: `Let's pick up "${item.name}".`, + created_at: item.timestamp, + widget: '', + }, + { + id: `${item.id}-answer`, + role: AutopilotChatRole.Assistant, + content: `This is the saved conversation **${item.name}**, loaded by the host on OpenConversation.`, + created_at: item.timestamp, + widget: '', + }, + ]; +} + /* ------------------------------------------------------------------ */ /* Messages and payloads */ /* ------------------------------------------------------------------ */ diff --git a/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts b/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts index ef9fae99e..ebc09ec99 100644 --- a/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts +++ b/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts @@ -11,6 +11,7 @@ import { type AutopilotChatConfiguration, AutopilotChatEvent, type AutopilotChatFileInfo, + type AutopilotChatHistorySearchPayload, type AutopilotChatMessage, type AutopilotChatRenderer, type AutopilotChatService, @@ -30,12 +31,15 @@ import { RESOURCE_TOKEN_MESSAGE, RESPONSE_ACTIONS, SAMPLE_CONVERSATION, + STORY_HISTORY_PAGE_SIZE, STREAMED_CITATION_CHUNKS, STREAMED_MARKDOWN, STREAMING_REPLY, type StoryToolCallDisplayMode, storyEscalationSpan, storyFailedToolCallMeta, + storyHistoryArchive, + storyHistoryBuckets, storyHistoryPage, storyInputsAndOutputsMeta, storyToolCallMeta, @@ -231,6 +235,77 @@ export const CHAT_STORY_SCENARIOS = { }); }, }, + history: { + label: 'Chat history', + description: + 'Loads conversations in every date bucket (setHistory) and opens the history (toggleHistory). Search filters the names as you type. Opening a conversation loads a transcript after 400 ms; rename (turn disableRenameChat off) and delete update the list, as a host does on RenameConversation and DeleteConversation. With preHooks on, rename and delete ask first.', + run: (context) => { + const { service } = context; + service.setHistory(storyHistoryBuckets()); + service.toggleHistory(true); + context.onCleanup(() => service.toggleHistory(false)); + }, + }, + 'history-paginated': { + label: 'Paginated chat history', + description: `Turns paginatedHistory on, loads the first ${STORY_HISTORY_PAGE_SIZE} of 40 conversations and opens the history. Scrolling to the end publishes HistoryLoadMore, answered after 600 ms with the next page (appendOlderHistoryItems, done on the last one). Typing publishes HistorySearch after a pause, answered after 600 ms with the first page of matches (setHistory). Changing scenario restores the default history.`, + run: (context) => { + const { service } = context; + const archive = storyHistoryArchive(); + let query = ''; + let loaded = STORY_HISTORY_PAGE_SIZE; + const matches = () => + archive.filter((item) => item.name.toLowerCase().includes(query.toLowerCase())); + + patchConfigInPlace(service, { paginatedHistory: true }); + service.setHistory(archive.slice(0, STORY_HISTORY_PAGE_SIZE), false); + const unsubscribeLoadMore = service.on(AutopilotChatEvent.HistoryLoadMore, () => + context.schedule(() => { + const all = matches(); + const page = all.slice(loaded, loaded + STORY_HISTORY_PAGE_SIZE); + loaded += STORY_HISTORY_PAGE_SIZE; + service.appendOlderHistoryItems(page, loaded >= all.length); + }, 600) + ); + const unsubscribeSearch = service.on( + AutopilotChatEvent.HistorySearch, + ({ searchText }: AutopilotChatHistorySearchPayload) => { + query = searchText; + loaded = STORY_HISTORY_PAGE_SIZE; + context.schedule(() => { + const all = matches(); + service.setHistory( + all.slice(0, STORY_HISTORY_PAGE_SIZE), + STORY_HISTORY_PAGE_SIZE >= all.length + ); + }, 600); + } + ); + service.toggleHistory(true); + context.onCleanup(() => { + unsubscribeLoadMore(); + unsubscribeSearch(); + service.toggleHistory(false); + patchConfigInPlace(service, { paginatedHistory: false }); + service.setHistory(storyHistoryBuckets()); + }); + }, + }, + settings: { + label: 'Settings', + description: + "Enables settings (they are disabled by default, as in the service) and opens them (toggleSettings). The playground's settingsRenderer mounts a React root into the container, as CAS does: the container is emptied on close and handed to the renderer again on each opening, which the page counts. Changing scenario closes them and restores disableSettings.", + run: (context) => { + const { service } = context; + const disabled = service.getConfig().disabledFeatures?.settings; + service.setDisabledFeatures({ settings: false }); + service.toggleSettings(true); + context.onCleanup(() => { + service.toggleSettings(false); + service.setDisabledFeatures({ settings: disabled }); + }); + }, + }, citations: { label: 'Citations', description: diff --git a/packages/apollo-react/src/chat/stories/chat-story-settings.tsx b/packages/apollo-react/src/chat/stories/chat-story-settings.tsx new file mode 100644 index 000000000..05535c847 --- /dev/null +++ b/packages/apollo-react/src/chat/stories/chat-story-settings.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +function StorySettingsPage({ opening }: { opening: number }) { + const [saved, setSaved] = React.useState(0); + + return ( +
+

+ A React root mounted by the host into the settings container, as CAS does. This is opening{' '} + {opening}: the container is emptied on close and handed to the renderer again on the next + opening. +

+ + + +
+ ); +} + +/** + * A `settingsRenderer` that mounts a React root, as CAS does. Each opening mounts a fresh root in + * a child of the container; the previous one, whose DOM the chat already removed, is unmounted. + */ +export function createStoryReactSettingsRenderer() { + let root: Root | undefined; + let openings = 0; + + return (container: HTMLElement) => { + const previous = root; + // Unmounting a root during another root's effects warns, so after them. + queueMicrotask(() => previous?.unmount()); + + openings += 1; + const host = document.createElement('div'); + container.appendChild(host); + root = createRoot(host); + root.render(); + }; +} diff --git a/packages/apollo-react/src/chat/wind/common/panel-popover.tsx b/packages/apollo-react/src/chat/wind/common/panel-popover.tsx new file mode 100644 index 000000000..09e06c302 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/common/panel-popover.tsx @@ -0,0 +1,156 @@ +import { cn, Popover, PopoverAnchor, PopoverContent } from '@uipath/apollo-wind'; +import React from 'react'; + +import { + AutopilotChatMode, + CHAT_HISTORY_FULL_SCREEN_WIDTH, + CHAT_HISTORY_SIDE_BY_SIDE_MAX_HEIGHT, + CHAT_HISTORY_SIDE_BY_SIDE_MAX_WIDTH, +} from '../../service'; + +// Material's history popover keeps 16px from the panel's edges. +const PANEL_INSET = 16; + +function useElementSize(element: HTMLElement | null) { + const [size, setSize] = React.useState({ width: 0, height: 0 }); + + React.useLayoutEffect(() => { + if (!element) { + return; + } + const measure = () => setSize({ width: element.clientWidth, height: element.clientHeight }); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [element]); + + return size; +} + +/** + * Material's history popover size: in a panel up to 568 by 441 and 16px in from its sides; in + * FullScreen 342 wide and as tall as the area below the header. + */ +function popoverSize(mode: AutopilotChatMode, boundary: { width: number; height: number }) { + if (mode === AutopilotChatMode.FullScreen) { + return { + width: Math.min(CHAT_HISTORY_FULL_SCREEN_WIDTH, boundary.width - 2 * PANEL_INSET), + height: boundary.height - PANEL_INSET, + }; + } + return { + width: Math.min(CHAT_HISTORY_SIDE_BY_SIDE_MAX_WIDTH, boundary.width) - 2 * PANEL_INSET, + height: CHAT_HISTORY_SIDE_BY_SIDE_MAX_HEIGHT, + }; +} + +export interface PanelPopoverProps { + open: boolean; + /** The header button it opens from; it anchors there and focus returns there on close. */ + anchor: HTMLElement | null; + /** The area it stays within: the panel, or the area below the header in FullScreen. */ + boundary: HTMLElement | null; + mode: AutopilotChatMode; + label: string; + onClose: () => void; + onEscapeKeyDown?: (event: KeyboardEvent) => void; + className?: string; + 'data-slot'?: string; + children: React.ReactNode; +} + +/** A dialog layer other than this popover: a host's confirmation from a pre-hook, say. */ +const inOtherDialog = (target: EventTarget | null, popover: Element | null) => { + const layer = + target instanceof Element + ? target.closest('dialog[open], [role="dialog"], [role="alertdialog"]') + : null; + return !!layer && layer !== popover && !layer.contains(popover); +}; + +/** + * The history's surface: a popover under the header button that opens it, sized as Material's + * history popover. Focus moves in on open. Escape, a click outside or focus leaving close it; + * focus returns to the button unless a click put it elsewhere. + * + * Not modal: a modal popover makes the rest of the page inert to the pointer and holds focus, so + * a host's own dialog opened from a pre-hook (to confirm a delete, say) could not be used. Work in + * such a dialog, and clicks on the opening button (which toggles it), leave it open. + */ +export function PanelPopover({ + open, + anchor, + boundary, + mode, + label, + onClose, + onEscapeKeyDown, + className, + children, + ...props +}: PanelPopoverProps) { + const boundarySize = useElementSize(boundary); + const virtualAnchor = React.useMemo(() => ({ current: anchor }), [anchor]); + const { width, height } = popoverSize(mode, boundarySize); + const content = React.useRef(null); + const closedByPointer = React.useRef(false); + + const keepOpenFor = (event: { target: EventTarget | null; preventDefault: () => void }) => { + const { target } = event; + if ( + (target instanceof Node && anchor?.contains(target)) || + inOtherDialog(target, content.current) + ) { + event.preventDefault(); + } + }; + + return ( + { + if (!next) { + onClose(); + } + }} + > + {/* `virtualRef`: the header owns the button, so the popover anchors to it from here. */} + } /> + { + closedByPointer.current = false; + }} + onPointerDownOutside={(event) => { + keepOpenFor(event); + closedByPointer.current = !event.defaultPrevented; + }} + onFocusOutside={keepOpenFor} + onCloseAutoFocus={(event) => { + event.preventDefault(); + if (!closedByPointer.current) { + anchor?.focus(); + } + }} + // No open and close animation under reduced motion, as for the settings' slide. + className={cn('flex flex-col p-0 motion-reduce:animate-none!', className)} + style={{ + width: width > 0 ? width : undefined, + height: height > 0 ? height : undefined, + maxHeight: 'var(--radix-popover-content-available-height)', + }} + > + {children} + + + ); +} diff --git a/packages/apollo-react/src/chat/wind/header/chat-header.tsx b/packages/apollo-react/src/chat/wind/header/chat-header.tsx index c41079639..5164c7c78 100644 --- a/packages/apollo-react/src/chat/wind/header/chat-header.tsx +++ b/packages/apollo-react/src/chat/wind/header/chat-header.tsx @@ -21,7 +21,14 @@ import { HeaderActionButton } from './header-action-button'; */ export function WindChatHeader({ className, layout }: { className?: string; layout: ChatLayout }) { const { _ } = useLingui(); - const { disabledFeatures, spacing } = useChatState(); + const { + disabledFeatures, + spacing, + historyOpen, + settingsOpen, + setHistoryAnchorElement, + setSettingsAnchorElement, + } = useChatState(); const { title } = useChatLabels(); const { visibility, @@ -83,7 +90,10 @@ export function WindChatHeader({ className, layout }: { className?: string; layo {visibility.settings && ( @@ -92,7 +102,11 @@ export function WindChatHeader({ className, layout }: { className?: string; layo {visibility.history && ( diff --git a/packages/apollo-react/src/chat/wind/header/header-parity.test.tsx b/packages/apollo-react/src/chat/wind/header/header-parity.test.tsx index fe052c5ca..eed935095 100644 --- a/packages/apollo-react/src/chat/wind/header/header-parity.test.tsx +++ b/packages/apollo-react/src/chat/wind/header/header-parity.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it } from 'vitest'; @@ -13,21 +13,25 @@ import { let instanceCounter = 0; -// Every public event plus the internal panel toggles, in publish order. +// Every public event plus the internal panel toggles, in publish order, while `recording.on`. function recordEvents(service: AutopilotChatService) { const events: Array<[string, unknown]> = []; + const recording = { on: true }; + const record = (event: string) => (data: unknown) => { + if (recording.on) events.push([event, data]); + }; for (const event of Object.values(AutopilotChatEvent)) { - service.on(event, (data) => events.push([event, data])); + service.on(event, record(event)); } for (const event of [ AutopilotChatInternalEvent.ToggleHistory, AutopilotChatInternalEvent.ToggleSettings, ]) { - service.__internalService__.on(event, (data) => events.push([event, data])); + service.__internalService__.on(event, record(event)); } - return events; + return { events, recording }; } async function clickHeaderActions(renderer: AutopilotChatRenderer) { @@ -44,15 +48,26 @@ async function clickHeaderActions(renderer: AutopilotChatRenderer) { // Material loads lazily and its first transform is slow under Vitest. await screen.findByTestId('autopilot-chat-new-chat', {}, { timeout: 25_000 }); - const events = recordEvents(service); + const { events, recording } = recordEvents(service); for (const testId of [ 'autopilot-chat-new-chat', 'autopilot-chat-history', 'autopilot-chat-settings', ]) { + // Each click from closed panels, so each action's events stand alone (Material's history + // popover is modal and would take the next click). + recording.on = false; + act(() => { + service.toggleHistory(false); + service.toggleSettings(false); + }); + recording.on = true; await userEvent.click(screen.getByTestId(testId)); } + recording.on = false; + act(() => service.toggleSettings(false)); + recording.on = true; await userEvent.click(screen.getByTestId('autopilot-chat-action-menu')); await userEvent.click(await screen.findByRole('menuitem', { name: 'Share' })); await userEvent.click(screen.getByTestId('autopilot-chat-close')); diff --git a/packages/apollo-react/src/chat/wind/history/chat-history.test.tsx b/packages/apollo-react/src/chat/wind/history/chat-history.test.tsx new file mode 100644 index 000000000..5b7a7740b --- /dev/null +++ b/packages/apollo-react/src/chat/wind/history/chat-history.test.tsx @@ -0,0 +1,287 @@ +import { act, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { + ApChat, + type AutopilotChatConfiguration, + AutopilotChatEvent, + type AutopilotChatHistory, + AutopilotChatMode, + AutopilotChatPreHookAction, + AutopilotChatService, +} from '../../index'; + +let instanceCounter = 0; +const DAY = 86_400_000; + +const HISTORY: AutopilotChatHistory[] = [ + { id: 'a', name: 'Invoice batch', timestamp: new Date(Date.now() - 60_000).toISOString() }, + { id: 'b', name: 'Vendor list', timestamp: new Date(Date.now() - 3 * DAY).toISOString() }, + { id: 'c', name: 'Old payroll', timestamp: new Date(Date.now() - 70 * DAY).toISOString() }, +]; + +function setup(config: Partial = {}) { + const embeddedContainer = document.createElement('div'); + document.body.append(embeddedContainer); + const service = AutopilotChatService.Instantiate({ + instanceName: `wind-history-${++instanceCounter}`, + config: { + mode: AutopilotChatMode.Embedded, + embeddedContainer, + disabledFeatures: { history: false, renameChat: false }, + ...config, + }, + }); + const events: Array<[string, unknown]> = []; + for (const event of [ + AutopilotChatEvent.OpenConversation, + AutopilotChatEvent.RenameConversation, + AutopilotChatEvent.DeleteConversation, + AutopilotChatEvent.HistorySearch, + AutopilotChatEvent.HistoryLoadMore, + ]) { + service.on(event, (payload: unknown) => events.push([event, payload])); + } + service.setHistory(HISTORY); + render(); + return { service, events }; +} + +// By test id, as the other header buttons are. +const historyButton = () => screen.getByTestId('autopilot-chat-history'); +const panel = () => document.querySelector('[data-slot="wind-chat-history"]') as HTMLElement | null; +const names = () => + [...document.querySelectorAll('[data-slot="wind-chat-history-item"] > button')].map( + (item) => item.textContent + ); + +describe('Wind chat history', () => { + it('opens under the header in Embedded, grouped by date, and searches by name', async () => { + setup(); + + await userEvent.click(historyButton()); + + expect(historyButton()).toHaveAttribute('aria-expanded', 'true'); + const dialog = await screen.findByRole('dialog', { name: 'Chat history' }); + expect( + within(dialog) + .getAllByRole('heading') + .map((heading) => heading.textContent) + ).toEqual(['Today', 'Last week', '2 months ago']); + expect(names()).toEqual(['Invoice batch', 'Vendor list', 'Old payroll']); + await waitFor(() => expect(within(dialog).getByRole('searchbox')).toHaveFocus()); + + await userEvent.type(within(dialog).getByRole('searchbox'), 'VENDOR'); + expect(names()).toEqual(['Vendor list']); + + await userEvent.type(within(dialog).getByRole('searchbox'), 'x'); + expect(within(dialog).getByText('No results found.')).toBeInTheDocument(); + }); + + it('shows the empty state without a search when there is no history', async () => { + const { service } = setup(); + act(() => service.setHistory([])); + + await userEvent.click(historyButton()); + + expect(await screen.findByText('There are no conversations yet.')).toBeInTheDocument(); + expect(screen.queryByRole('searchbox')).toBeNull(); + }); + + it('opens a conversation and closes, marking it as the open one', async () => { + const { service, events } = setup(); + act(() => service.toggleHistory(true)); + + await userEvent.click(await screen.findByRole('button', { name: 'Vendor list' })); + + expect(events).toEqual([[AutopilotChatEvent.OpenConversation, 'b']]); + await waitFor(() => expect(panel()).toBeNull()); + expect(service.historyOpen).toBe(false); + + act(() => service.toggleHistory(true)); + expect(await screen.findByRole('button', { name: 'Vendor list' })).toHaveAttribute( + 'aria-current', + 'true' + ); + }); + + it('closes on Escape and returns focus to the history button', async () => { + const { service } = setup(); + await userEvent.click(historyButton()); + await screen.findByRole('dialog'); + + await userEvent.keyboard('{Escape}'); + + await waitFor(() => expect(panel()).toBeNull()); + expect(service.historyOpen).toBe(false); + expect(historyButton()).toHaveFocus(); + }); + + it('renames with Enter, cancels with Escape without closing, and deletes', async () => { + const { events } = setup(); + await userEvent.click(historyButton()); + await screen.findByRole('dialog'); + const row = () => + screen.getByRole('button', { name: 'Invoice batch' }).closest('li') as HTMLElement; + + await userEvent.click(within(row()).getByTestId('autopilot-chat-history-rename')); + const field = screen.getByRole('textbox', { name: 'Chat name' }); + expect(field).toHaveFocus(); + await userEvent.keyboard('{Escape}'); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.queryByRole('textbox', { name: 'Chat name' })).toBeNull(); + + await userEvent.click(within(row()).getByTestId('autopilot-chat-history-rename')); + await userEvent.clear(screen.getByRole('textbox', { name: 'Chat name' })); + await userEvent.type(screen.getByRole('textbox', { name: 'Chat name' }), 'Invoices{Enter}'); + await waitFor(() => + expect(events).toEqual([ + [AutopilotChatEvent.RenameConversation, { conversationId: 'a', name: 'Invoices' }], + ]) + ); + + await userEvent.click(within(row()).getByTestId('autopilot-chat-history-delete')); + await waitFor(() => + expect(events).toContainEqual([AutopilotChatEvent.DeleteConversation, 'a']) + ); + }); + + it('stays usable beside a host dialog from a pre-hook, and closes on a click elsewhere', async () => { + const confirmInDialog = () => + new Promise((resolve) => { + const dialog = document.createElement('div'); + dialog.setAttribute('role', 'dialog'); + const confirm = document.createElement('button'); + confirm.textContent = 'Confirm'; + confirm.addEventListener('click', () => { + dialog.remove(); + resolve(true); + }); + dialog.append(confirm); + document.body.append(dialog); + confirm.focus(); + }); + const { events } = setup({ + preHooks: { [AutopilotChatPreHookAction.DeleteConversation]: confirmInDialog }, + }); + await userEvent.click(historyButton()); + await screen.findByRole('dialog', { name: 'Chat history' }); + const row = screen.getByRole('button', { name: 'Vendor list' }).closest('li') as HTMLElement; + + await userEvent.click(within(row).getByTestId('autopilot-chat-history-delete')); + await userEvent.click(await screen.findByRole('button', { name: 'Confirm' })); + + await waitFor(() => + expect(events).toContainEqual([AutopilotChatEvent.DeleteConversation, 'b']) + ); + expect(panel()).not.toBeNull(); + + // A click elsewhere in the chat closes it and leaves focus where the click put it. + const textbox = screen.getByRole('textbox', { name: 'Message' }); + await userEvent.click(textbox); + await waitFor(() => expect(panel()).toBeNull()); + expect(textbox).toHaveFocus(); + }); + + it('closes from its header button without reopening', async () => { + const { service } = setup(); + await userEvent.click(historyButton()); + await screen.findByRole('dialog', { name: 'Chat history' }); + + await userEvent.click(historyButton()); + + await waitFor(() => expect(panel()).toBeNull()); + expect(service.historyOpen).toBe(false); + }); + + it('hides rename with disabledFeatures.renameChat', async () => { + setup({ disabledFeatures: { history: false, renameChat: true } }); + await userEvent.click(historyButton()); + await screen.findByRole('dialog'); + + expect(screen.queryByTestId('autopilot-chat-history-rename')).toBeNull(); + expect(screen.getAllByTestId('autopilot-chat-history-delete')).toHaveLength(3); + }); + + it('shows the host search and load-more states when paginated', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const { service, events } = setup({ paginatedHistory: true }); + act(() => service.setHistory(HISTORY, false)); + act(() => service.toggleHistory(true)); + const searchbox = await screen.findByRole('searchbox'); + + await userEvent.type(searchbox, 'inv'); + expect(document.querySelector('[data-slot="wind-chat-history-skeleton"]')).not.toBeNull(); + act(() => vi.advanceTimersByTime(400)); + expect(events).toContainEqual([AutopilotChatEvent.HistorySearch, { searchText: 'inv' }]); + + act(() => service.setHistory([HISTORY[0]!], false)); + expect(document.querySelector('[data-slot="wind-chat-history-skeleton"]')).toBeNull(); + expect(names()).toEqual(['Invoice batch']); + + // happy-dom lays nothing out, so any scroll is at the end of the list. + const list = document.querySelector('[data-slot="wind-chat-history-list"]') as HTMLElement; + act(() => list.dispatchEvent(new Event('scroll'))); + expect(events).toContainEqual([AutopilotChatEvent.HistoryLoadMore, { searchText: 'inv' }]); + expect(screen.getByRole('status', { name: 'Loading more chats' })).toBeInTheDocument(); + + act(() => service.appendOlderHistoryItems([HISTORY[1]!], true)); + expect(screen.queryByRole('status', { name: 'Loading more chats' })).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('is a popover in FullScreen too, with the column making room for it', async () => { + const { service } = setup({ mode: AutopilotChatMode.FullScreen }); + + await userEvent.click(historyButton()); + + const dialog = await screen.findByRole('dialog', { name: 'Chat history' }); + await waitFor(() => expect(within(dialog).getByRole('searchbox')).toHaveFocus()); + expect(document.querySelector('main + [aria-hidden]')).not.toBeNull(); + + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(panel()).toBeNull()); + expect(service.historyOpen).toBe(false); + expect(historyButton()).toHaveFocus(); + expect(document.querySelector('main + [aria-hidden]')).toBeNull(); + }); + + it('clears the search on close, resetting a paginated host to the full list', async () => { + const { service, events } = setup({ paginatedHistory: true }); + act(() => service.setHistory(HISTORY, false)); + act(() => service.toggleHistory(true)); + await userEvent.type(await screen.findByRole('searchbox'), 'inv'); + await waitFor(() => + expect(events).toContainEqual([AutopilotChatEvent.HistorySearch, { searchText: 'inv' }]) + ); + act(() => service.setHistory([HISTORY[0]!], true)); + + act(() => service.toggleHistory(false)); + expect(events.at(-1)).toEqual([AutopilotChatEvent.HistorySearch, { searchText: '' }]); + act(() => service.setHistory(HISTORY, false)); + + act(() => service.toggleHistory(true)); + expect(await screen.findByRole('searchbox')).toHaveValue(''); + expect(names()).toEqual(['Invoice batch', 'Vendor list', 'Old payroll']); + }); + + it('keeps knowing the host is done between openings', async () => { + const { service, events } = setup({ paginatedHistory: true }); + act(() => service.setHistory(HISTORY, true)); + act(() => service.toggleHistory(true)); + await screen.findByRole('searchbox'); + act(() => service.toggleHistory(false)); + await waitFor(() => expect(panel()).toBeNull()); + + act(() => service.toggleHistory(true)); + await screen.findByRole('searchbox'); + const list = document.querySelector('[data-slot="wind-chat-history-list"]') as HTMLElement; + act(() => list.dispatchEvent(new Event('scroll'))); + + expect(events.filter(([event]) => event === AutopilotChatEvent.HistoryLoadMore)).toEqual([]); + }); +}); diff --git a/packages/apollo-react/src/chat/wind/history/chat-history.tsx b/packages/apollo-react/src/chat/wind/history/chat-history.tsx new file mode 100644 index 000000000..33491a753 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/history/chat-history.tsx @@ -0,0 +1,50 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import React from 'react'; + +import { useChatHistory } from '../../headless/hooks/use-chat-history'; +import { useChatService } from '../../headless/providers/chat-service.provider'; +import { useChatState } from '../../headless/providers/chat-state-provider'; +import type { AutopilotChatMode } from '../../service'; +import { PanelPopover } from '../common/panel-popover'; +import { HistoryPanel } from './history-panel'; + +/** + * The history: a modal popover under the header's history button, as Material's, in both modes. + * The list state lives here, mounted while the popover is closed, so it keeps whether the host + * has more items between openings. + */ +export function ChatHistory({ + boundary, + mode, +}: { + boundary: HTMLElement | null; + mode: AutopilotChatMode; +}) { + const { _ } = useLingui(); + const chatService = useChatService(); + const { historyOpen, historyAnchorElement } = useChatState(); + const [scrollContainer, setScrollContainer] = React.useState(null); + const history = useChatHistory({ open: historyOpen, scrollContainer }); + + return ( + chatService.toggleHistory(false)} + onEscapeKeyDown={(event) => { + // Escape in the rename field cancels the rename and leaves the history open. + if (event.target instanceof Element && event.target.closest('[data-renaming]')) { + event.preventDefault(); + } + }} + className="py-2" + > + + + ); +} diff --git a/packages/apollo-react/src/chat/wind/history/history-item.tsx b/packages/apollo-react/src/chat/wind/history/history-item.tsx new file mode 100644 index 000000000..04dd494a8 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/history/history-item.tsx @@ -0,0 +1,135 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { + Button, + Input, + Tooltip, + TooltipContent, + TooltipPortal, + TooltipTrigger, +} from '@uipath/apollo-wind'; +import { Pencil, Trash2 } from 'lucide-react'; +import React from 'react'; + +import { useHistoryItem } from '../../headless/hooks/use-history-item'; +import { useChatState } from '../../headless/providers/chat-state-provider'; +import type { AutopilotChatHistory } from '../../service'; + +function ItemAction({ + label, + onClick, + children, + ...props +}: { + label: string; + onClick: () => void; + children: React.ReactNode; + 'data-testid'?: string; +}) { + return ( + + + + + + {label} + + + ); +} + +/** + * One conversation: opens on click, Enter or Space, with rename and delete shown on hover or + * focus. Renaming swaps the name for a field: Enter or leaving it renames, Escape cancels. + */ +export function HistoryItem({ item }: { item: AutopilotChatHistory }) { + const { _ } = useLingui(); + const { historyOpen, disabledFeatures } = useChatState(); + const { + isActive, + open, + isEditing, + draftName, + setDraftName, + startRename, + commitRename, + cancelRename, + remove, + } = useHistoryItem(item, historyOpen); + const field = React.useRef(null); + + React.useEffect(() => { + if (isEditing) { + field.current?.focus(); + field.current?.select(); + } + }, [isEditing]); + + const renameLabel = _(msg({ id: 'autopilot-chat.history.rename', message: `Rename chat` })); + const deleteLabel = _(msg({ id: 'autopilot-chat.history.delete', message: `Delete chat` })); + + return ( +
  • + {isEditing ? ( + setDraftName(event.target.value)} + onBlur={commitRename} + onKeyDown={(event) => { + // Escape's preventDefault keeps the history open (see its onEscapeKeyDown) and the + // settings' Escape away. + if (event.key === 'Enter') { + event.preventDefault(); + commitRename(); + } else if (event.key === 'Escape') { + event.preventDefault(); + cancelRename(); + } + }} + /> + ) : ( + + )} + {!isEditing && ( + // Shown on hover or focus within the row, and always where nothing hovers (touch). +
    + {!disabledFeatures.renameChat && ( + + + + )} + + + +
    + )} +
  • + ); +} diff --git a/packages/apollo-react/src/chat/wind/history/history-panel.tsx b/packages/apollo-react/src/chat/wind/history/history-panel.tsx new file mode 100644 index 000000000..62887e0d8 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/history/history-panel.tsx @@ -0,0 +1,123 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { cn, Search, Skeleton, Spinner } from '@uipath/apollo-wind'; +import React from 'react'; + +import type { ChatHistoryState } from '../../headless/hooks/use-chat-history'; +import { useChatState } from '../../headless/providers/chat-state-provider'; +import { HistoryItem } from './history-item'; + +function EmptyMessage({ children }: { children: React.ReactNode }) { + return ( +

    + {children} +

    + ); +} + +/** + * The history list: search, conversations grouped by date, and the host's loading states. + */ +export function HistoryPanel({ + className, + history, + scrollContainerRef, +}: { + className?: string; + history: ChatHistoryState; + scrollContainerRef: (element: HTMLDivElement | null) => void; +}) { + const { _ } = useLingui(); + const { historyOpen } = useChatState(); + const idPrefix = React.useId(); + const { + groups, + searchQuery, + setSearchQuery, + hasHistoryData, + noSearchResults, + showSearchSkeleton, + showLoadingMore, + } = history; + + const searchLabel = _( + msg({ id: 'autopilot-chat.history.search-placeholder', message: `Search chats` }) + ); + + const renderList = () => { + if (showSearchSkeleton) { + return ( +
    + {Array.from({ length: 6 }, (_unused, index) => ( + + ))} +
    + ); + } + if (noSearchResults) { + return ( + + {_(msg({ id: 'autopilot-chat.history.no-results', message: `No results found` }))} + + ); + } + return ( + <> + {groups.map((group) => ( +
    +

    + {group.title} +

    +
      + {group.items.map((item) => ( + + ))} +
    +
    + ))} + {showLoadingMore && ( + + )} + + ); + }; + + return ( +
    + {hasHistoryData ? ( + <> +
    + +
    +
    + {renderList()} +
    + + ) : ( + + {_(msg({ id: 'autopilot-chat.history.empty', message: `No chat history` }))} + + )} +
    + ); +} diff --git a/packages/apollo-react/src/chat/wind/layout/embedded-layout.tsx b/packages/apollo-react/src/chat/wind/layout/embedded-layout.tsx index fdedaa24c..aee033236 100644 --- a/packages/apollo-react/src/chat/wind/layout/embedded-layout.tsx +++ b/packages/apollo-react/src/chat/wind/layout/embedded-layout.tsx @@ -1,25 +1,42 @@ +import React from 'react'; + import type { ChatLayout } from '../../headless/layout/chat-layout'; import { useChatState } from '../../headless/providers/chat-state-provider'; import { FirstRun, useFirstRunShown } from '../first-run/first-run'; import { WindChatHeader } from '../header/chat-header'; +import { ChatHistory } from '../history/chat-history'; import { MessageList } from '../messages/message-list'; -import { ComposerRegion, HistoryRegion, SettingsRegion } from './regions'; +import { ChatSettings, useSettingsClose } from '../settings/chat-settings'; +import { ComposerRegion } from './regions'; /** - * The panel layout: header, messages and composer in one column, with history and settings - * covering the area below the header when open. + * The panel layout: header, messages and composer in one column. The history opens as a popover + * under the header; the settings slide in over the conversation below it. */ export function EmbeddedLayout({ layout }: { layout: ChatLayout }) { - const { historyOpen, settingsOpen, spacing } = useChatState(); + const { spacing, settingsOpen } = useChatState(); const firstRunShown = useFirstRunShown(); + const settingsClose = useSettingsClose(); // Material's panel gutter (SpacingL) in both densities, so the renderers line up. const gutter = 'px-6'; + const [root, setRoot] = React.useState(null); + const conversation = React.useRef(null); + + // Material traps focus in its settings panel here; the conversation under it leaves the tab + // order and the accessibility tree while it is open. + React.useEffect(() => { + conversation.current?.toggleAttribute('inert', layout.settingsAvailable && settingsOpen); + }, [layout.settingsAvailable, settingsOpen]); return ( -
    +
    {layout.showHeader && } -
    -
    +
    +
    {/* Hidden rather than unmounted during the first run: it owns the transcript state. */} {/* Docked at the bottom of the message area, above the composer, as Material's is. */} @@ -32,17 +49,9 @@ export function EmbeddedLayout({ layout }: { layout: ChatLayout }) {
    )}
    - {layout.historyAvailable && historyOpen && ( -
    - -
    - )} - {layout.settingsAvailable && settingsOpen && ( -
    - -
    - )} + {layout.settingsAvailable && }
    + {layout.historyAvailable && }
    ); } diff --git a/packages/apollo-react/src/chat/wind/layout/full-screen-layout.tsx b/packages/apollo-react/src/chat/wind/layout/full-screen-layout.tsx index 3823c366d..80866f480 100644 --- a/packages/apollo-react/src/chat/wind/layout/full-screen-layout.tsx +++ b/packages/apollo-react/src/chat/wind/layout/full-screen-layout.tsx @@ -1,12 +1,14 @@ -import type React from 'react'; +import React from 'react'; import type { ChatLayout } from '../../headless/layout/chat-layout'; import { useChatState } from '../../headless/providers/chat-state-provider'; import { CHAT_DRAWER_WIDTH_FULL_SCREEN, CHAT_WIDTH_FULL_SCREEN_MAX_WIDTH } from '../../service'; import { FirstRun, useFirstRunShown } from '../first-run/first-run'; import { WindChatHeader } from '../header/chat-header'; +import { ChatHistory } from '../history/chat-history'; import { MessageList } from '../messages/message-list'; -import { ComposerRegion, HistoryRegion, SettingsRegion } from './regions'; +import { ChatSettings, useSettingsClose } from '../settings/chat-settings'; +import { ComposerRegion } from './regions'; const sizes = { '--chat-column-max-width': CHAT_WIDTH_FULL_SCREEN_MAX_WIDTH, @@ -14,17 +16,23 @@ const sizes = { } as React.CSSProperties; /** - * Full-width header above a centered conversation column, with history and settings as drawers - * on the right. + * Full-width header above a centered conversation column. The history opens as a popover and the + * settings slide in on the right; the column makes room for either, as Material's does. */ export function FullScreenLayout({ layout }: { layout: ChatLayout }) { - const { historyOpen, settingsOpen } = useChatState(); const firstRunShown = useFirstRunShown(); + const settingsClose = useSettingsClose(); + const { historyOpen } = useChatState(); + const [content, setContent] = React.useState(null); return ( -
    +
    -
    +
    {/* Hidden rather than unmounted during the first run: it owns the transcript state. */} )}
    + {/* The history popover's place beside the column, kept clear of the transcript. */} {layout.historyAvailable && historyOpen && ( - - )} - {layout.settingsAvailable && settingsOpen && ( - +
    )} + {layout.settingsAvailable && } + {layout.historyAvailable && }
    ); diff --git a/packages/apollo-react/src/chat/wind/layout/regions.tsx b/packages/apollo-react/src/chat/wind/layout/regions.tsx index ad2ccf9e3..0fbe0e6de 100644 --- a/packages/apollo-react/src/chat/wind/layout/regions.tsx +++ b/packages/apollo-react/src/chat/wind/layout/regions.tsx @@ -1,7 +1,4 @@ import { WindChatComposer } from '../composer/chat-composer'; -import { PlaceholderRegion } from './placeholder-region'; - -// Each region is replaced by its real implementation as the Wind renderer is built out. /** The prompt input, with its error and footer disclaimer. */ export function ComposerRegion() { @@ -11,27 +8,3 @@ export function ComposerRegion() {
    ); } - -export function HistoryRegion() { - return ( - - Chat history: not available in the Wind renderer yet. - - ); -} - -export function SettingsRegion() { - return ( - - Settings: not available in the Wind renderer yet. - - ); -} diff --git a/packages/apollo-react/src/chat/wind/settings/chat-settings.test.tsx b/packages/apollo-react/src/chat/wind/settings/chat-settings.test.tsx new file mode 100644 index 000000000..b7c52300e --- /dev/null +++ b/packages/apollo-react/src/chat/wind/settings/chat-settings.test.tsx @@ -0,0 +1,171 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import { + ApChat, + type AutopilotChatConfiguration, + AutopilotChatMode, + AutopilotChatPreHookAction, + AutopilotChatService, +} from '../../index'; + +let instanceCounter = 0; + +function setup(config: Partial = {}) { + const embeddedContainer = document.createElement('div'); + document.body.append(embeddedContainer); + const settingsRenderer = vi.fn((container: HTMLElement) => { + const form = document.createElement('form'); + form.textContent = 'Host settings'; + container.append(form); + }); + const service = AutopilotChatService.Instantiate({ + instanceName: `wind-settings-${++instanceCounter}`, + config: { + mode: AutopilotChatMode.Embedded, + embeddedContainer, + disabledFeatures: { settings: false }, + settingsRenderer, + ...config, + }, + }); + render(); + return { service, settingsRenderer }; +} + +const settings = () => + document.querySelector('[data-slot="wind-chat-settings"]') as HTMLElement | null; +const content = () => + document.querySelector('[data-slot="wind-chat-settings-content"]') as HTMLElement; +const settingsButton = () => screen.getByTestId('autopilot-chat-settings'); +const inertAncestor = (element: Element | null) => element?.closest('[inert]') ?? null; + +describe('Wind chat settings', () => { + it('keeps one container for the chat, rendered into on each opening and emptied on close', () => { + const { service, settingsRenderer } = setup(); + const container = content(); + expect(settings()).toHaveAttribute('data-state', 'closed'); + expect(settingsRenderer).not.toHaveBeenCalled(); + + act(() => service.toggleSettings(true)); + expect(settings()).toHaveAttribute('data-state', 'open'); + expect(settingsRenderer).toHaveBeenLastCalledWith(container); + expect(container).toHaveTextContent('Host settings'); + + act(() => service.toggleSettings(false)); + expect(content()).toBe(container); + expect(container).toBeEmptyDOMElement(); + + act(() => service.toggleSettings(true)); + expect(settingsRenderer).toHaveBeenCalledTimes(2); + expect(settingsRenderer.mock.calls[1]![0]).toBe(container); + }); + + it('slides in over the conversation in Embedded, trapping focus as Material does', async () => { + const { service } = setup(); + await userEvent.click(settingsButton()); + + expect(service.settingsOpen).toBe(true); + expect(settingsButton()).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('region', { name: 'Chat settings' })).toBe(settings()); + expect(screen.getByTestId('autopilot-chat-settings-close')).toHaveFocus(); + // The conversation is inert; the header and the panel are not. + expect(inertAncestor(screen.getByRole('log'))).not.toBeNull(); + expect(inertAncestor(settingsButton())).toBeNull(); + expect(inertAncestor(settings())).toBeNull(); + // The slide respects reduced motion. + expect(settings()).toHaveClass('motion-reduce:transition-none'); + + await userEvent.click(screen.getByTestId('autopilot-chat-settings-close')); + await waitFor(() => expect(service.settingsOpen).toBe(false)); + expect(settings()).toHaveAttribute('data-state', 'closed'); + expect(settingsButton()).toHaveFocus(); + expect(inertAncestor(screen.getByRole('log'))).toBeNull(); + }); + + it('closes through the ToggleSettings pre-hook from the X and the header button', async () => { + const preHook = vi.fn().mockResolvedValueOnce(false).mockResolvedValue(true); + const { service } = setup({ + preHooks: { [AutopilotChatPreHookAction.ToggleSettings]: preHook }, + }); + act(() => service.toggleSettings(true)); + + await userEvent.click(screen.getByTestId('autopilot-chat-settings-close')); + expect(preHook).toHaveBeenCalledWith({ settingsOpen: true }); + expect(service.settingsOpen).toBe(true); + + await userEvent.click(settingsButton()); + await waitFor(() => expect(service.settingsOpen).toBe(false)); + expect(preHook).toHaveBeenCalledTimes(2); + }); + + it('sits beside the conversation in FullScreen, which makes room, without trapping focus', async () => { + const { service } = setup({ mode: AutopilotChatMode.FullScreen }); + const place = () => + document.querySelector('[data-slot="wind-chat-settings-place"]') as HTMLElement; + expect(place()).toHaveClass('w-0'); + + act(() => service.toggleSettings(true)); + + expect(place()).not.toHaveClass('w-0'); + expect(place()).toContainElement(settings()); + expect(screen.getByTestId('autopilot-chat-settings-close')).toHaveFocus(); + expect(inertAncestor(screen.getByRole('log'))).toBeNull(); + }); + + it('closes on Escape in the chat through the pre-hook, returning focus to the header button', async () => { + const preHook = vi.fn().mockResolvedValue(true); + const { service } = setup({ + preHooks: { [AutopilotChatPreHookAction.ToggleSettings]: preHook }, + }); + const outside = document.createElement('button'); + document.body.append(outside); + act(() => service.toggleSettings(true)); + + // Escape elsewhere on the page is left alone. + outside.focus(); + await userEvent.keyboard('{Escape}'); + expect(service.settingsOpen).toBe(true); + + screen.getByTestId('autopilot-chat-settings-close').focus(); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(service.settingsOpen).toBe(false)); + expect(preHook).toHaveBeenCalledWith({ settingsOpen: true }); + expect(settingsButton()).toHaveFocus(); + + // Closed, Escape in the chat does nothing. + await userEvent.keyboard('{Escape}'); + expect(preHook).toHaveBeenCalledTimes(1); + outside.remove(); + }); + + it('closes once for a held or repeated Escape while the pre-hook runs', async () => { + let resolve: (proceed: boolean) => void = () => {}; + const preHook = vi.fn(() => new Promise((done) => (resolve = done))); + const { service } = setup({ + preHooks: { [AutopilotChatPreHookAction.ToggleSettings]: preHook }, + }); + act(() => service.toggleSettings(true)); + const close = screen.getByTestId('autopilot-chat-settings-close'); + + fireEvent.keyDown(close, { key: 'Escape' }); + fireEvent.keyDown(close, { key: 'Escape', repeat: true }); + fireEvent.keyDown(close, { key: 'Escape' }); + expect(preHook).toHaveBeenCalledTimes(1); + + await act(async () => resolve(true)); + expect(service.settingsOpen).toBe(false); + + // Composing text (an IME) keeps its Escape. + act(() => service.toggleSettings(true)); + fireEvent.keyDown(close, { key: 'Escape', isComposing: true }); + expect(preHook).toHaveBeenCalledTimes(1); + }); + + it('is not rendered when settings are disabled', () => { + setup({ disabledFeatures: { settings: true } }); + + expect(settings()).toBeNull(); + }); +}); diff --git a/packages/apollo-react/src/chat/wind/settings/chat-settings.tsx b/packages/apollo-react/src/chat/wind/settings/chat-settings.tsx new file mode 100644 index 000000000..28a576b9a --- /dev/null +++ b/packages/apollo-react/src/chat/wind/settings/chat-settings.tsx @@ -0,0 +1,170 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { cn } from '@uipath/apollo-wind'; +import { X } from 'lucide-react'; +import React from 'react'; + +import { useSettingsRenderer } from '../../headless/hooks/use-settings-renderer'; +import type { ChatLayout } from '../../headless/layout/chat-layout'; +import { useChatService } from '../../headless/providers/chat-service.provider'; +import { useChatState } from '../../headless/providers/chat-state-provider'; +import { + AutopilotChatMode, + AutopilotChatPreHookAction, + CHAT_HISTORY_FULL_SCREEN_WIDTH, +} from '../../service'; +import { HeaderActionButton } from '../header/header-action-button'; + +/** The history popover's surface, so the two panels read as one family. */ +const PANEL_SURFACE = + 'rounded-md border bg-popover text-popover-foreground shadow-md future:rounded-lg'; + +/** + * Closing the settings from the panel: the ToggleSettings pre-hook, then an explicit close, so a + * second request while the pre-hook runs (a held Escape, a double click) cannot reopen them. + * Focus returns to the header's settings button. `onKeyDown` closes on Escape: put it on the + * layout root, so Escape anywhere in the chat closes the open panel, after Wind's own handlers + * have had the chance to take it. + */ +export function useSettingsClose() { + const chatService = useChatService(); + const { settingsOpen, settingsAnchorElement } = useChatState(); + const pending = React.useRef(false); + + const close = React.useCallback(() => { + if (pending.current || !chatService.settingsOpen) { + return; + } + pending.current = true; + chatService + .getPreHook(AutopilotChatPreHookAction.ToggleSettings)({ settingsOpen: true }) + .then((proceed) => { + if (proceed && chatService.settingsOpen) { + chatService.toggleSettings(false); + settingsAnchorElement?.focus(); + } + }) + .catch(() => {}) + .finally(() => { + pending.current = false; + }); + }, [chatService, settingsAnchorElement]); + + const onKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + settingsOpen && + event.key === 'Escape' && + !event.defaultPrevented && + !event.repeat && + !event.nativeEvent.isComposing + ) { + close(); + } + }, + [settingsOpen, close] + ); + + return { close, onKeyDown }; +} + +/** + * The settings panel, sliding in from the right as Material's does: over the conversation in a + * panel (Embedded), beside it in FullScreen, where the conversation makes room as it opens. It + * closes from its X, the header's settings button or Escape in the chat (`useSettingsClose`), all + * through the ToggleSettings pre-hook. + * + * It stays mounted while closed, so the element the host renders into (CAS mounts a React root + * in it) 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. + */ +export function ChatSettings({ layout, onClose }: { layout: ChatLayout; onClose: () => void }) { + const { _ } = useLingui(); + const { settingsOpen, settingsAnchorElement } = useChatState(); + const [content, setContent] = React.useState(null); + const panel = React.useRef(null); + const close = React.useRef(null); + const titleId = React.useId(); + const fullScreen = layout.mode === AutopilotChatMode.FullScreen; + useSettingsRenderer(content); + + // Focus moves into the panel on open, and back to the settings button when it closes with + // focus inside (the host closing it, say). + const wasOpen = React.useRef(settingsOpen); + React.useEffect(() => { + if (settingsOpen && !wasOpen.current) { + close.current?.focus(); + } else if ( + !settingsOpen && + wasOpen.current && + panel.current?.contains(document.activeElement) + ) { + settingsAnchorElement?.focus(); + } + wasOpen.current = settingsOpen; + }, [settingsOpen, settingsAnchorElement]); + + const title = _(msg({ id: 'autopilot-chat.settings.title', message: `Settings` })); + + const card = ( +
    +
    +

    + {title} +

    + + + +
    +
    +
    + ); + + if (!fullScreen) { + return card; + } + + // FullScreen: the panel's place beside the conversation, which narrows as it opens. + return ( +
    + {card} +
    + ); +} diff --git a/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history-group.tsx b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history-group.tsx index b3f021f5e..ce4a098d9 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history-group.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history-group.tsx @@ -1,9 +1,9 @@ import { styled } from '@mui/material'; import token, { FontVariantToken } from '@uipath/apollo-core'; +import type { TitledChatHistoryGroup } from '@uipath/apollo-react/chat/headless/hooks/use-chat-history'; import { useChatState } from '@uipath/apollo-react/chat/headless/providers/chat-state-provider'; import { ApTypography } from '@uipath/apollo-react/material/components/ap-typography'; import React from 'react'; -import type { ChatHistoryGroup } from './chat-history'; import { AutopilotChatHistoryItem } from './chat-history-item'; const GroupContainer = styled('div')<{ compactMode: boolean }>(({ compactMode }) => ({ @@ -18,7 +18,7 @@ const GroupTitle = styled('h2')(() => ({ })); interface AutopilotChatHistoryGroupProps { - group: ChatHistoryGroup; + group: TitledChatHistoryGroup; isHistoryOpen: boolean; } diff --git a/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history-item.tsx b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history-item.tsx index b3518119c..73575d0d1 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history-item.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history-item.tsx @@ -2,14 +2,9 @@ import { msg } from '@lingui/core/macro'; import { useLingui } from '@lingui/react'; import { styled } from '@mui/material'; import token from '@uipath/apollo-core'; -import { useChatService } from '@uipath/apollo-react/chat/headless/providers/chat-service.provider'; +import { useHistoryItem } from '@uipath/apollo-react/chat/headless/hooks/use-history-item'; import { useChatState } from '@uipath/apollo-react/chat/headless/providers/chat-state-provider'; -import { useLoading } from '@uipath/apollo-react/chat/headless/providers/loading-provider'; -import { - AutopilotChatEvent, - type AutopilotChatHistory, - AutopilotChatPreHookAction, -} from '@uipath/apollo-react/chat/service'; +import type { AutopilotChatHistory } from '@uipath/apollo-react/chat/service'; import { ApTypography } from '@uipath/apollo-react/material/components/ap-typography'; import React from 'react'; import { AutopilotChatActionButton } from '../common/action-button'; @@ -84,32 +79,28 @@ const AutopilotChatHistoryItemComponent: React.FC isHistoryOpen, }) => { const { _ } = useLingui(); - const chatService = useChatService(); const { spacing, disabledFeatures } = useChatState(); const renameEnabled = !disabledFeatures.renameChat; - const [isActive, setIsActive] = React.useState(chatService.activeConversationId === item.id); - const { setWaitingResponse } = useLoading(); + const { + isActive, + open: openConversation, + isEditing, + draftName, + setDraftName, + startRename, + commitRename, + cancelRename, + remove, + } = useHistoryItem(item, isHistoryOpen); const [isActionButtonsVisible, setIsActionButtonsVisible] = React.useState(false); const [isFocused, setIsFocused] = React.useState(false); - const [isEditing, setIsEditing] = React.useState(false); - const [draftName, setDraftName] = React.useState(item.name); const itemRef = React.useRef(null); const inputRef = React.useRef(null); const lastMousePosition = React.useRef({ x: 0, y: 0, }); - // Guards against double-commit when Enter triggers commit then the resulting - // unmount/blur fires again before state settles. - const isCommittingRef = React.useRef(false); - - // Keep the draft in sync with parent-driven updates while not editing - React.useEffect(() => { - if (!isEditing) { - setDraftName(item.name); - } - }, [item.name, isEditing]); // Tooltip interferes with the onMouseEnter/onMouseLeave events, so we need to listen to mouse move events React.useEffect(() => { @@ -141,46 +132,6 @@ const AutopilotChatHistoryItemComponent: React.FC return () => document.removeEventListener('mousemove', handleMouseMove); }, [isFocused]); - React.useEffect(() => { - if (!chatService) { - return; - } - - if (chatService.activeConversationId === item.id && !isActive) { - setIsActive(true); - } - - const unsubscribeOpenConversation = chatService.on( - AutopilotChatEvent.OpenConversation, - (id) => { - if (!isActive && item.id === id) { - setWaitingResponse(false); - } - - setIsActive(id === item.id); - } - ); - - const unsubscribeDeleteConversation = chatService.on( - AutopilotChatEvent.DeleteConversation, - (id) => { - if (isActive && id === item.id) { - chatService.newChat(); - } - } - ); - - const unsubscribeNewChat = chatService.on(AutopilotChatEvent.NewChat, () => { - setIsActive(false); - }); - - return () => { - unsubscribeOpenConversation(); - unsubscribeDeleteConversation(); - unsubscribeNewChat(); - }; - }, [chatService, item.id, isActive, setWaitingResponse]); - React.useEffect(() => { if (isEditing) { inputRef.current?.focus(); @@ -188,102 +139,25 @@ const AutopilotChatHistoryItemComponent: React.FC } }, [isEditing]); - const enterEditMode = React.useCallback(() => { - if (!isHistoryOpen) { - return; - } - isCommittingRef.current = false; - setDraftName(item.name); - setIsEditing(true); - }, [isHistoryOpen, item.name]); - - const cancelRename = React.useCallback(() => { - isCommittingRef.current = false; - setDraftName(item.name); - setIsEditing(false); - }, [item.name]); - - const commitRename = React.useCallback(() => { - if (isCommittingRef.current) { - return; - } - const trimmed = draftName.trim(); - - if (trimmed === '' || trimmed === item.name) { - cancelRename(); - return; - } - - if (!chatService) { - setIsEditing(false); - return; - } - - isCommittingRef.current = true; - chatService - .getPreHook(AutopilotChatPreHookAction.RenameConversation)({ - conversationId: item.id, - name: trimmed, - }) - .then((proceed) => { - if (!proceed) { - cancelRename(); - return; - } - chatService.renameConversation(item.id, trimmed); - setIsEditing(false); - isCommittingRef.current = false; - }) - .catch(() => { - // Pre-hook threw or rejected. Revert so the input doesn't stay stuck. - cancelRename(); - }); - }, [draftName, item.id, item.name, chatService, cancelRename]); - const handleDelete = React.useCallback( - ( - ev: React.KeyboardEvent | React.MouseEvent, - itemId: string - ) => { + (ev: React.KeyboardEvent | React.MouseEvent) => { ev.stopPropagation(); setIsFocused(false); - - if (!chatService) { - return; - } - - chatService - .getPreHook(AutopilotChatPreHookAction.DeleteConversation)({ conversationId: itemId }) - .then((proceed) => { - if (!proceed) { - return; - } - chatService.deleteConversation(itemId); - }); + remove(); }, - [chatService] + [remove] ); const handleRename = React.useCallback( (ev: React.KeyboardEvent | React.MouseEvent) => { ev.stopPropagation(); setIsFocused(false); - enterEditMode(); + startRename(); }, - [enterEditMode] + [startRename] ); - const handleItemClick = React.useCallback( - (itemId: string) => { - if (isEditing || isActive) { - return; - } - - chatService.openConversation(itemId); - chatService.toggleHistory(false); - }, - [chatService, isActive, isEditing] - ); + const handleItemClick = React.useCallback(() => openConversation(), [openConversation]); const renameLabel = _(msg({ id: 'autopilot-chat.history.rename', message: `Rename chat` })); const renameInputLabel = _( @@ -300,13 +174,13 @@ const AutopilotChatHistoryItemComponent: React.FC tabIndex={isHistoryOpen && !isEditing ? 0 : -1} key={item.id} isActive={isActive} - onClick={() => handleItemClick(item.id)} + onClick={handleItemClick} onKeyDown={(ev) => { if (isEditing) { return; } if (ev.key === 'Enter' || ev.key === ' ') { - handleItemClick(item.id); + handleItemClick(); } }} // While editing, drop the button role so the nested is exposed correctly @@ -378,7 +252,7 @@ const AutopilotChatHistoryItemComponent: React.FC )} handleDelete(ev, item.id)} + onClick={handleDelete} onFocus={() => { setIsFocused(true); }} @@ -392,7 +266,7 @@ const AutopilotChatHistoryItemComponent: React.FC } if (ev.key === 'Enter' || ev.key === ' ') { - handleDelete(ev, item.id); + handleDelete(ev); } }} iconName="delete" diff --git a/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.test.tsx b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.test.tsx index 9ed301fc6..83f3ebf49 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.test.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen, waitFor, within } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ApChat } from '@uipath/apollo-react/chat/root/ApChat'; import { @@ -18,6 +18,8 @@ const HISTORY: AutopilotChatHistory[] = [ { id: 'b', name: 'Vendor list', timestamp: new Date(Date.now() - 3 * DAY).toISOString() }, ]; +// Material on the shared history hooks: the list, search, opening, rename, delete, load more +// and the settings container. async function setup(config: Partial = {}) { const service = AutopilotChatService.Instantiate({ instanceName: `material-history-${instanceCount++}`, @@ -29,9 +31,15 @@ async function setup(config: Partial = {}) { }, }); const events: Array<[string, unknown]> = []; - service.on(AutopilotChatEvent.HistorySearch, (payload: unknown) => - events.push([AutopilotChatEvent.HistorySearch, payload]) - ); + for (const event of [ + AutopilotChatEvent.OpenConversation, + AutopilotChatEvent.RenameConversation, + AutopilotChatEvent.DeleteConversation, + AutopilotChatEvent.HistoryLoadMore, + AutopilotChatEvent.HistorySearch, + ]) { + service.on(event, (payload: unknown) => events.push([event, payload])); + } service.setHistory(HISTORY); render(); await screen.findByTestId('autopilot-chat-history', {}, { timeout: 25_000 }); @@ -43,7 +51,85 @@ const openHistory = async (service: AutopilotChatService) => { return screen.findByRole('presentation'); }; -describe('Material chat history and settings', () => { +describe('Material chat history on the shared hooks', () => { + it('groups by date and filters by name', async () => { + const { service } = await setup(); + const popover = await openHistory(service); + + expect(within(popover).getByText('Today')).toBeInTheDocument(); + expect(within(popover).getByText('Last week')).toBeInTheDocument(); + + await userEvent.type(within(popover).getByPlaceholderText('Search'), 'vendor'); + + expect(within(popover).queryByRole('button', { name: 'Invoice batch' })).toBeNull(); + expect(within(popover).getByRole('button', { name: 'Vendor list' })).toBeInTheDocument(); + }, 30_000); + + it('opens a conversation and closes the history', async () => { + const { service, events } = await setup(); + const popover = await openHistory(service); + + await userEvent.click(within(popover).getByRole('button', { name: 'Vendor list' })); + + expect(events).toEqual([[AutopilotChatEvent.OpenConversation, 'b']]); + expect(service.historyOpen).toBe(false); + }, 30_000); + + it('renames with Enter and deletes', async () => { + const { service, events } = await setup(); + const popover = await openHistory(service); + + // A plain click: userEvent's focus moves leave the field before it is filled in happy-dom. + fireEvent.click(within(popover).getAllByTestId('autopilot-chat-history-rename')[0]!); + const field = within(popover).getByTestId('autopilot-chat-history-rename-input'); + await userEvent.clear(field); + await userEvent.type(field, 'Invoices{Enter}'); + await waitFor(() => + expect(events).toContainEqual([ + AutopilotChatEvent.RenameConversation, + { conversationId: 'a', name: 'Invoices' }, + ]) + ); + + await userEvent.click(within(popover).getAllByTestId('autopilot-chat-history-delete')[1]!); + await waitFor(() => + expect(events).toContainEqual([AutopilotChatEvent.DeleteConversation, 'b']) + ); + }, 30_000); + + it('loads more when paginated and scrolled to the end', async () => { + const { service, events } = await setup({ paginatedHistory: true }); + act(() => service.setHistory(HISTORY, false)); + const popover = await openHistory(service); + + const list = popover.querySelector('.chat-history-content') as HTMLElement; + act(() => list.dispatchEvent(new Event('scroll'))); + + expect(events).toContainEqual([AutopilotChatEvent.HistoryLoadMore, { searchText: '' }]); + }, 30_000); + + it('renders the host settings into one container on each opening', async () => { + const settingsRenderer = vi.fn((container: HTMLElement) => { + container.append(document.createElement('form')); + }); + const { service } = await setup({ + disabledFeatures: { settings: false }, + settingsRenderer, + }); + + act(() => service.toggleSettings(true)); + await waitFor(() => expect(settingsRenderer).toHaveBeenCalledTimes(1)); + const container = settingsRenderer.mock.calls[0]![0]; + expect(container.children).toHaveLength(1); + + act(() => service.toggleSettings(false)); + expect(container.children).toHaveLength(0); + + act(() => service.toggleSettings(true)); + expect(settingsRenderer).toHaveBeenCalledTimes(2); + expect(settingsRenderer.mock.calls[1]![0]).toBe(container); + }, 30_000); + it('clears the search on close, resetting a paginated host', async () => { const { service, events } = await setup({ paginatedHistory: true }); act(() => service.setHistory(HISTORY, false)); diff --git a/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.tsx b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.tsx index 92768b365..89eb00ea5 100644 --- a/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.tsx +++ b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.tsx @@ -3,16 +3,12 @@ import { useLingui } from '@lingui/react'; import SearchIcon from '@mui/icons-material/Search'; import { CircularProgress, Popover, styled } from '@mui/material'; import token from '@uipath/apollo-core'; +import { useChatHistory } from '@uipath/apollo-react/chat/headless/hooks/use-chat-history'; import { useScheduledCallback } from '@uipath/apollo-react/chat/headless/hooks/use-scheduled-callback'; import { useChatService } from '@uipath/apollo-react/chat/headless/providers/chat-service.provider'; import { useChatState } from '@uipath/apollo-react/chat/headless/providers/chat-state-provider'; import { useChatWidth } from '@uipath/apollo-react/chat/headless/providers/chat-width-provider'; -import { publishChatEvent } from '@uipath/apollo-react/chat/headless/utils/publish-chat-event'; import { - AutopilotChatEvent, - type AutopilotChatHistorySearchPayload, - type AutopilotChatHistory as AutopilotChatHistoryType, - AutopilotChatInternalEvent, CHAT_HISTORY_FULL_SCREEN_WIDTH, CHAT_HISTORY_SIDE_BY_SIDE_MAX_HEIGHT, CHAT_HISTORY_SIDE_BY_SIDE_MAX_WIDTH, @@ -20,9 +16,7 @@ import { import { ApSkeleton } from '@uipath/apollo-react/material/components/ap-skeleton'; import { ApTextField } from '@uipath/apollo-react/material/components/ap-text-field'; import { ApTypography } from '@uipath/apollo-react/material/components/ap-typography'; -import { differenceInDays, differenceInMonths, isToday, isYesterday } from 'date-fns'; -import debounce from 'debounce'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import FocusLock from 'react-focus-lock'; import { AutopilotChatHistoryGroup } from './chat-history-group'; @@ -92,14 +86,6 @@ const HistorySkeletonLoader: React.FC = () => ( ); -const DEBOUNCE_DELAY_IN_MS = 400; -const LOAD_MORE_THRESHOLD_IN_PX = 50; - -export interface ChatHistoryGroup { - title: string; - items: AutopilotChatHistoryType[]; -} - interface AutopilotChatHistoryProps { open: boolean; isFullScreen: boolean; @@ -111,24 +97,10 @@ const AutopilotChatHistoryComponent: React.FC = ({ }) => { const { _ } = useLingui(); const chatService = useChatService(); - const internalService = chatService?.__internalService__; - const [history, setHistory] = useState( - chatService?.getHistory() ?? [] - ); - const [searchQuery, setSearchQuery] = useState(''); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [isSearching, setIsSearching] = useState(false); const [scrollContainer, setScrollContainer] = useState(null); const popperContainerRef = useRef(null); - const isLoadingMoreRef = useRef(false); - const shouldShowLoadingMoreRef = useRef(true); - const appendingRef = useRef(false); - const scrollTopBeforeAppendRef = useRef(0); - const searchQueryRef = useRef(''); - // The query the host last searched for, so a reset is sent only when its list is filtered. - const sentQueryRef = useRef(''); const { historyOpen, historyAnchorElement, fullScreenContainer, spacing, portalContainer } = useChatState(); @@ -152,221 +124,15 @@ const AutopilotChatHistoryComponent: React.FC = ({ }, [schedulePositionUpdate]); const { width } = useChatWidth(); - const handleScroll = useCallback(() => { - if (!scrollContainer || !chatService) { - return; - } - - const { scrollTop, scrollHeight, clientHeight } = scrollContainer; - const scrollBottom = scrollHeight - scrollTop - clientHeight; - - if ( - scrollBottom < LOAD_MORE_THRESHOLD_IN_PX && - !isLoadingMoreRef.current && - shouldShowLoadingMoreRef.current && - chatService.getConfig()?.paginatedHistory - ) { - (chatService as any)._eventBus.publish(AutopilotChatEvent.HistoryLoadMore, { - searchText: searchQueryRef.current, - } satisfies AutopilotChatHistorySearchPayload); - internalService.publish(AutopilotChatInternalEvent.SetIsLoadingMoreHistory, true); - } - }, [scrollContainer, chatService, internalService]); - - const debouncedSearch = useMemo( - () => - debounce((newSearchQuery: string) => { - if (chatService?.getConfig()?.paginatedHistory) { - sentQueryRef.current = newSearchQuery; - (chatService as any)._eventBus.publish(AutopilotChatEvent.HistorySearch, { - searchText: newSearchQuery, - } satisfies AutopilotChatHistorySearchPayload); - internalService.publish(AutopilotChatInternalEvent.SetIsLoadingMoreHistory, false); - } - }, DEBOUNCE_DELAY_IN_MS), - [chatService, internalService] - ); - - useEffect(() => { - if (!chatService || !internalService) { - return; - } - - const unsubscribeSetHistory = chatService.on(AutopilotChatEvent.SetHistory, (newHistory) => { - setHistory(newHistory); - setIsSearching(false); - }); - - const unsubscribeSetIsLoadingMore = internalService.on( - AutopilotChatInternalEvent.SetIsLoadingMoreHistory, - (value: boolean) => { - isLoadingMoreRef.current = value; - setIsLoadingMore(value); - } - ); - - const unsubscribeShouldShowLoading = internalService.on( - AutopilotChatInternalEvent.ShouldShowLoadingMoreHistory, - (value: boolean) => { - shouldShowLoadingMoreRef.current = value; - } - ); - - const unsubscribeAppendOlderHistory = internalService.on( - AutopilotChatInternalEvent.AppendOlderHistory, - () => { - if (scrollContainer) { - appendingRef.current = true; - scrollTopBeforeAppendRef.current = scrollContainer.scrollTop; - } - } - ); - - return () => { - unsubscribeSetHistory(); - unsubscribeSetIsLoadingMore(); - unsubscribeShouldShowLoading(); - unsubscribeAppendOlderHistory(); - }; - }, [chatService, internalService, scrollContainer]); - - useEffect(() => { - if (!scrollContainer || !historyOpen || !chatService) { - return; - } - - scrollContainer.addEventListener('scroll', handleScroll, { passive: true }); - - return () => { - scrollContainer.removeEventListener('scroll', handleScroll); - }; - }, [scrollContainer, handleScroll, historyOpen, chatService]); - - useEffect(() => { - if (appendingRef.current && scrollContainer) { - scrollContainer.scrollTop = scrollTopBeforeAppendRef.current; - appendingRef.current = false; - } - }, [history, scrollContainer]); - - useEffect(() => { - searchQueryRef.current = searchQuery; - }, [searchQuery]); - - // Closing (or unmounting) clears the search, and the host's filtered results with it: the list - // follows the box. A search still waiting out the pause is dropped. - const resetSearch = useCallback(() => { - debouncedSearch.clear(); - searchQueryRef.current = ''; - setSearchQuery(''); - setIsSearching(false); - - if (chatService?.getConfig()?.paginatedHistory && sentQueryRef.current !== '') { - // No skeleton for the empty search: a host may ignore it, and keeps showing its list then. - sentQueryRef.current = ''; - publishChatEvent(chatService, AutopilotChatEvent.HistorySearch, { - searchText: '', - } satisfies AutopilotChatHistorySearchPayload); - internalService.publish(AutopilotChatInternalEvent.SetIsLoadingMoreHistory, false); - } - }, [chatService, internalService, debouncedSearch]); - - useEffect(() => { - if (!historyOpen) { - resetSearch(); - } - }, [historyOpen, resetSearch]); - - useEffect(() => () => debouncedSearch.clear(), [debouncedSearch]); - - const resetSearchRef = useRef(resetSearch); - resetSearchRef.current = resetSearch; - useEffect(() => () => resetSearchRef.current(), []); - - const groupedHistory = useMemo(() => { - const filteredHistory = chatService?.getConfig()?.paginatedHistory - ? history - : history.filter((item) => item.name.toLowerCase().includes(searchQuery.toLowerCase())); - - const grouped = filteredHistory.reduce((acc, item) => { - const date = new Date(item.timestamp); - const currentDate = new Date(); - const daysAgo = differenceInDays(currentDate, date); - let groupTitle = ''; - - if (isToday(date)) { - groupTitle = _(msg({ id: 'autopilot-chat.history.group.today', message: `Today` })); - } else if (isYesterday(date)) { - groupTitle = _(msg({ id: 'autopilot-chat.history.group.yesterday', message: `Yesterday` })); - } else if (daysAgo <= 7) { - groupTitle = _( - msg({ id: 'autopilot-chat.history.group.last-week', message: `Last 7 days` }) - ); - } else if (daysAgo <= 30) { - groupTitle = _( - msg({ id: 'autopilot-chat.history.group.last-30-days', message: `Last 30 days` }) - ); - } else { - const monthsAgo = differenceInMonths(currentDate, date); - const count = monthsAgo; - groupTitle = _( - msg({ id: 'autopilot-chat.history.group.months-ago', message: `${count} months ago` }) - ); - } - - const existingGroup = acc.find((group) => group.title === groupTitle); - - if (existingGroup) { - existingGroup.items.push(item); - } else { - acc.push({ - title: groupTitle, - items: [item], - }); - } - - return acc; - }, []); - - // Sort groups by date (most recent first) - grouped.sort((a, b) => { - const getDate = (group: ChatHistoryGroup) => { - if (group.items.length === 0) { - return new Date(); - } - - return new Date(group.items[0]!.timestamp); - }; - - return getDate(b).getTime() - getDate(a).getTime(); - }); - - // Sort items within each group by date (most recent first) - grouped.forEach((group) => { - group.items.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); - }); - - return grouped; - }, [history, searchQuery, chatService, _]); - - const handleSearchTextChange = useCallback( - (searchText: string | undefined) => { - const newSearchText = searchText || ''; - setSearchQuery(newSearchText); - - if (chatService?.getConfig()?.paginatedHistory) { - // The skeleton waits for the results of a query; an emptied box keeps the list shown. - setIsSearching(newSearchText !== ''); - debouncedSearch(newSearchText); - } - }, - [chatService, debouncedSearch] - ); - - const hasHistoryData = history.length > 0 || searchQuery || isSearching; - const hasNoSearchResults = groupedHistory.length === 0 && searchQuery; - const showSkeletonLoader = isSearching && chatService?.getConfig()?.paginatedHistory; - const showLoadMoreSpinner = isLoadingMore && chatService?.getConfig()?.paginatedHistory; + const { + groups: groupedHistory, + searchQuery, + setSearchQuery: handleSearchTextChange, + hasHistoryData, + noSearchResults: hasNoSearchResults, + showSearchSkeleton: showSkeletonLoader, + showLoadingMore: showLoadMoreSpinner, + } = useChatHistory({ open: historyOpen, scrollContainer }); const renderEmptyState = (message: string) => ( @@ -379,7 +145,7 @@ const AutopilotChatHistoryComponent: React.FC = ({ const renderHistoryGroups = () => ( <> {groupedHistory.map((group) => ( - + ))} {showLoadMoreSpinner && (
    = ({ open, isFullScreen, }) => { - const containerRef = useRef(null); - const chatService = useChatService(); + const [container, setContainer] = React.useState(null); const { settingsOpen } = useChatState(); + useSettingsRenderer(container); // CSS variables must be set as inline styles (not in the styled component) // to keep the Emotion class stable and enable proper transitions @@ -79,37 +79,12 @@ export const AutopilotChatSettings: React.FC = ({ } as React.CSSProperties; }, [open, isFullScreen]); - useEffect(() => { - if (!containerRef.current || !chatService) { - return; - } - - const config = chatService.getConfig(); - const settingsRenderer = config.settingsRenderer; - - if (!settingsRenderer) { - return; - } - - containerRef.current.innerHTML = ''; - - if (!settingsOpen) { - return; - } - - try { - settingsRenderer(containerRef.current); - } catch (error) { - return; - } - }, [chatService, settingsOpen]); - return ( -
    +
    );