+
-
+
{/* 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/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-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
new file mode 100644
index 000000000..83f3ebf49
--- /dev/null
+++ b/packages/apollo-react/src/material/components/ap-chat/components/history/chat-history.test.tsx
@@ -0,0 +1,178 @@
+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 {
+ 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() },
+];
+
+// 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++}`,
+ config: {
+ mode: AutopilotChatMode.FullScreen,
+ renderer: 'material',
+ disabledFeatures: { history: false, renameChat: false },
+ ...config,
+ },
+ });
+ const events: Array<[string, unknown]> = [];
+ 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 });
+ return { service, events };
+}
+
+const openHistory = async (service: AutopilotChatService) => {
+ act(() => service.toggleHistory(true));
+ return screen.findByRole('presentation');
+};
+
+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));
+ 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..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,15 +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 {
- 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,
@@ -19,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';
@@ -91,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;
@@ -110,22 +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('');
const { historyOpen, historyAnchorElement, fullScreenContainer, spacing, portalContainer } =
useChatState();
@@ -149,189 +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) {
- (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]);
-
- 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) {
- setIsSearching(true);
- 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) => (
@@ -344,7 +145,7 @@ const AutopilotChatHistoryComponent: React.FC = ({
const renderHistoryGroups = () => (
<>
{groupedHistory.map((group) => (
-
+
))}
{showLoadMoreSpinner && (
{
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..d11b56419 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
@@ -1,9 +1,9 @@
import { styled } from '@mui/material';
import token from '@uipath/apollo-core';
-import { useChatService } from '@uipath/apollo-react/chat/headless/providers/chat-service.provider';
+import { useSettingsRenderer } from '@uipath/apollo-react/chat/headless/hooks/use-settings-renderer';
import { useChatState } from '@uipath/apollo-react/chat/headless/providers/chat-state-provider';
import { CHAT_DRAWER_WIDTH_FULL_SCREEN } from '@uipath/apollo-react/chat/service';
-import React, { useEffect, useRef } from 'react';
+import React from 'react';
import FocusLock from 'react-focus-lock';
import { AutopilotChatSettingsHeader } from './chat-settings-header';
@@ -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,
},
@@ -54,9 +58,9 @@ export const AutopilotChatSettings: React.FC
= ({
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
@@ -75,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 (
-
+
);