diff --git a/packages/apollo-react/package.json b/packages/apollo-react/package.json index 31b1883110..39322aa50c 100644 --- a/packages/apollo-react/package.json +++ b/packages/apollo-react/package.json @@ -148,7 +148,9 @@ "dist/canvas/index.js", "dist/canvas/index.cjs", "dist/chat/index.js", - "dist/chat/index.cjs" + "dist/chat/index.cjs", + "dist/chat/root/ApChat.js", + "dist/chat/root/ApChat.cjs" ], "files": [ "dist" diff --git a/packages/apollo-react/rslib.config.ts b/packages/apollo-react/rslib.config.ts index bf5904e2ad..caca3d1102 100644 --- a/packages/apollo-react/rslib.config.ts +++ b/packages/apollo-react/rslib.config.ts @@ -55,6 +55,9 @@ export default defineConfig({ '!./src/**/*.test.{ts,tsx}', '!./src/**/*.stories.{ts,tsx}', '!./src/**/storybook-utils/**', + // Story harnesses and fixtures, imported only by stories. + '!./src/chat/stories/**', + '!./src/material/stories/**', // Test-only, like `src/test/**`: fixtures are data for the suites, not API, and they // reach for devDependencies (the dnd geometry helper needs `@testing-library/react`), // so there is nothing to gain from publishing them. diff --git a/packages/apollo-react/src/chat/README.md b/packages/apollo-react/src/chat/README.md index 0aa5b31bb2..0e53708e2b 100644 --- a/packages/apollo-react/src/chat/README.md +++ b/packages/apollo-react/src/chat/README.md @@ -13,7 +13,8 @@ The legacy `@uipath/apollo-react/ap-chat` and `@uipath/apollo-react/ap-chat/serv | `service/` | `AutopilotChatService`, `EventBus`, models and constants. No React. | | `headless/` | React providers, hooks and utilities with no visual output, plus the chat and message renderer registries. | | `root/` | `ChatRoot`: the provider stack that resolves and mounts a renderer. | -| `wind/` | The Wind renderer (apollo-wind components and Tailwind classes). For now a placeholder that names the missing renderer. | +| `wind/` | The Wind renderer (apollo-wind components and Tailwind classes). Embedded and FullScreen are supported; SideBySide renders an explicit placeholder. | +| `stories/` | Storybook playground and the fixtures shared with the Material showcase. Not published. | | `locales/` | Lingui catalogs for chat strings, shared by both renderers. | | `styles/` | `tailwind.chat.css`, compiled to `dist/chat/styles/tailwind.chat.css`. | @@ -24,10 +25,16 @@ Code under `src/chat` must not depend on Material UI, so a bundle that only uses - No `@mui/*` or `@emotion/*` imports. - No imports from `src/material`, either relative (`../material/...`) or via `@uipath/apollo-react/material*`. -The one exception is the lazy registration of the Material renderer in `index.ts`, a dynamic `import()` that is split into its own chunk and only loaded when `renderer: 'material'` is requested. `ApChat` from this entry defaults to the Wind renderer; `ApChat` from `@uipath/apollo-react/ap-chat` registers Material eagerly and defaults to it. +The one exception is the lazy registration of the Material renderer in `root/ApChat.tsx`, a dynamic `import()` that is split into its own chunk and only loaded when `renderer: 'material'` is requested. `ApChat` from this entry defaults to the Wind renderer; `ApChat` from `@uipath/apollo-react/ap-chat` registers Material eagerly and defaults to it. The Material renderer depends on this folder, never the other way round. +## Wind renderer notes + +- The root element carries the chat theme as a class (`light`, `dark`, `light-hc`, `dark-hc`), so it themes itself regardless of the host's body class. Menus and tooltips portal inside that root, or into a themed node inside `portalContainer` when the host passes one. +- `spacing.compactMode` sets `data-density="compact"` on the root. +- `icon` values on custom header actions and message actions 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 Consumers using the Wind renderer include the pre-compiled CSS: diff --git a/packages/apollo-react/src/material/components/ap-chat/assets/autopilot-logo.svg b/packages/apollo-react/src/chat/assets/autopilot-logo.tsx similarity index 63% rename from packages/apollo-react/src/material/components/ap-chat/assets/autopilot-logo.svg rename to packages/apollo-react/src/chat/assets/autopilot-logo.tsx index 85888d8cbd..491a98fd51 100644 --- a/packages/apollo-react/src/material/components/ap-chat/assets/autopilot-logo.svg +++ b/packages/apollo-react/src/chat/assets/autopilot-logo.tsx @@ -1,11 +1,31 @@ - - ) { + return ( + + fill="var(--color-icon-default)" + /> + + ); +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-chat-header-actions.test.ts b/packages/apollo-react/src/chat/headless/hooks/use-chat-header-actions.test.ts new file mode 100644 index 0000000000..428d6c0088 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-chat-header-actions.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { AutopilotChatMode } from '../../service'; +import { getChatHeaderActionVisibility } from './use-chat-header-actions'; + +const visibility = ( + chatMode: AutopilotChatMode, + disabledFeatures = {}, + readOnly = false, + customActionCount = 0 +) => getChatHeaderActionVisibility({ chatMode, disabledFeatures, readOnly, customActionCount }); + +describe('getChatHeaderActionVisibility', () => { + it('shows the built-ins outside Embedded', () => { + expect(visibility(AutopilotChatMode.FullScreen, {}, false, 1)).toEqual({ + newChat: true, + settings: true, + history: true, + fullScreenToggle: true, + customActions: true, + close: true, + }); + }); + + it('makes close opt-in and hides the full-screen toggle in Embedded', () => { + expect(visibility(AutopilotChatMode.Embedded)).toMatchObject({ + close: false, + fullScreenToggle: false, + }); + expect(visibility(AutopilotChatMode.Embedded, { close: false }).close).toBe(true); + }); + + it('hides editing actions when read-only but keeps close', () => { + expect(visibility(AutopilotChatMode.SideBySide, {}, true, 2)).toEqual({ + newChat: false, + settings: false, + history: false, + fullScreenToggle: true, + customActions: false, + close: true, + }); + }); + + it('honours each disabled feature', () => { + expect( + visibility(AutopilotChatMode.FullScreen, { + newChat: true, + settings: true, + history: true, + fullScreen: true, + close: true, + }) + ).toEqual({ + newChat: false, + settings: false, + history: false, + fullScreenToggle: false, + customActions: false, + close: false, + }); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/hooks/use-chat-header-actions.ts b/packages/apollo-react/src/chat/headless/hooks/use-chat-header-actions.ts new file mode 100644 index 0000000000..18cce43d3a --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-chat-header-actions.ts @@ -0,0 +1,154 @@ +import React from 'react'; + +import { + type AutopilotChatCustomHeaderAction, + type AutopilotChatDisabledFeatures, + AutopilotChatMode, + AutopilotChatPreHookAction, +} from '../../service'; +import { useAttachments } from '../providers/attachments-provider'; +import { useChatService } from '../providers/chat-service.provider'; +import { useChatState } from '../providers/chat-state-provider'; +import { usePicker } from '../providers/picker-provider'; + +export interface ChatHeaderActionVisibility { + newChat: boolean; + settings: boolean; + history: boolean; + fullScreenToggle: boolean; + customActions: boolean; + close: boolean; +} + +export interface ChatHeaderActionVisibilityInput { + chatMode: AutopilotChatMode; + disabledFeatures: AutopilotChatDisabledFeatures; + readOnly: boolean; + customActionCount: number; +} + +/** + * Which built-in header actions show. Close is opt-in for Embedded (`close: false`) and opt-out + * everywhere else. + */ +export function getChatHeaderActionVisibility({ + chatMode, + disabledFeatures, + readOnly, + customActionCount, +}: ChatHeaderActionVisibilityInput): ChatHeaderActionVisibility { + const isEmbedded = chatMode === AutopilotChatMode.Embedded; + + return { + newChat: !readOnly && !disabledFeatures.newChat, + settings: !readOnly && !disabledFeatures.settings, + history: !readOnly && !disabledFeatures.history, + fullScreenToggle: !disabledFeatures.fullScreen && !isEmbedded, + customActions: !readOnly && customActionCount > 0, + close: isEmbedded ? disabledFeatures.close === false : !disabledFeatures.close, + }; +} + +export interface ChatHeaderActions { + visibility: ChatHeaderActionVisibility; + isFullScreen: boolean; + customHeaderActions: AutopilotChatCustomHeaderAction[]; + newChat: () => void; + toggleSettings: () => void; + toggleHistory: () => void; + toggleFullScreen: () => void; + close: () => void; + runCustomAction: (action: AutopilotChatCustomHeaderAction) => void; +} + +export interface UseChatHeaderActionsOptions { + /** + * Mode to derive visibility from instead of the live one, for a renderer that keeps drawing its + * previous layout while it animates closed. + */ + mode?: AutopilotChatMode; +} + +/** + * Header action visibility and handlers, each gated by its pre-hook, shared by every renderer. + */ +export function useChatHeaderActions({ + mode, +}: UseChatHeaderActionsOptions = {}): ChatHeaderActions { + const chatService = useChatService(); + const { disabledFeatures, chatMode, historyOpen, settingsOpen, readOnly } = useChatState(); + const visibleMode = mode ?? chatMode; + const { clearAttachments } = useAttachments(); + const { customHeaderActions, handleCustomHeaderAction } = usePicker(); + + const withPreHook = React.useCallback( + (action: AutopilotChatPreHookAction, data: unknown, run: () => void) => { + chatService + ?.getPreHook(action)(data) + .then((proceed) => { + if (proceed) { + run(); + } + }); + }, + [chatService] + ); + + const newChat = React.useCallback(() => { + withPreHook(AutopilotChatPreHookAction.NewChat, undefined, () => { + chatService.newChat(); + chatService.stopResponse(); + chatService.setPrompt(''); + clearAttachments(); + }); + }, [chatService, clearAttachments, withPreHook]); + + const toggleHistory = React.useCallback(() => { + withPreHook(AutopilotChatPreHookAction.ToggleHistory, { historyOpen }, () => + chatService.toggleHistory() + ); + }, [chatService, historyOpen, withPreHook]); + + const toggleSettings = React.useCallback(() => { + withPreHook(AutopilotChatPreHookAction.ToggleSettings, { settingsOpen }, () => + chatService.toggleSettings() + ); + }, [chatService, settingsOpen, withPreHook]); + + const toggleFullScreen = React.useCallback(() => { + withPreHook(AutopilotChatPreHookAction.ToggleChat, { chatMode }, () => + chatService.setChatMode( + chatMode === AutopilotChatMode.FullScreen + ? AutopilotChatMode.SideBySide + : AutopilotChatMode.FullScreen + ) + ); + }, [chatService, chatMode, withPreHook]); + + const close = React.useCallback(() => { + withPreHook(AutopilotChatPreHookAction.CloseChat, undefined, () => chatService.close()); + }, [chatService, withPreHook]); + + const visibility = React.useMemo( + () => + getChatHeaderActionVisibility({ + chatMode: visibleMode, + disabledFeatures, + readOnly, + customActionCount: customHeaderActions.length, + }), + [visibleMode, disabledFeatures, readOnly, customHeaderActions.length] + ); + + return { + visibility, + isFullScreen: visibleMode === AutopilotChatMode.FullScreen, + customHeaderActions, + newChat, + toggleSettings, + toggleHistory, + toggleFullScreen, + close, + runCustomAction: handleCustomHeaderAction, + }; +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-chat-labels.ts b/packages/apollo-react/src/chat/headless/hooks/use-chat-labels.ts new file mode 100644 index 0000000000..2e913a25fd --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-chat-labels.ts @@ -0,0 +1,34 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; + +import { useChatState } from '../providers/chat-state-provider'; + +export interface ChatLabels { + title: string; + inputPlaceholder: string; + footerDisclaimer: string; +} + +/** + * The service's `overrideLabels`, falling back to the localized defaults, for every renderer. + */ +export function useChatLabels(): ChatLabels { + const { _ } = useLingui(); + const { overrideLabels } = useChatState(); + + return { + title: + overrideLabels.title ?? _(msg({ id: 'autopilot-chat.header.title', message: `Autopilot` })), + inputPlaceholder: + overrideLabels.inputPlaceholder ?? + _(msg({ id: 'autopilot-chat.input.placeholder', message: `Type a message...` })), + footerDisclaimer: + overrideLabels.footerDisclaimer ?? + _( + msg({ + id: 'autopilot-chat.input.footer', + message: `AI can make mistakes. Check important info.`, + }) + ), + }; +} diff --git a/packages/apollo-react/src/chat/headless/layout/chat-layout.test.ts b/packages/apollo-react/src/chat/headless/layout/chat-layout.test.ts new file mode 100644 index 0000000000..b60b8e9531 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/layout/chat-layout.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { AutopilotChatMode } from '../../service'; +import { resolveChatLayout } from './chat-layout'; + +describe('resolveChatLayout', () => { + it('shows everything by default', () => { + expect( + resolveChatLayout({ + chatMode: AutopilotChatMode.Embedded, + disabledFeatures: {}, + readOnly: false, + }) + ).toEqual({ + mode: AutopilotChatMode.Embedded, + isOpen: true, + showHeader: true, + showHeaderSeparator: true, + showComposer: true, + historyAvailable: true, + settingsAvailable: true, + fullHeight: false, + }); + }); + + it('hides the header only outside FullScreen', () => { + const disabledFeatures = { header: true }; + + expect( + resolveChatLayout({ chatMode: AutopilotChatMode.Embedded, disabledFeatures, readOnly: false }) + .showHeader + ).toBe(false); + expect( + resolveChatLayout({ + chatMode: AutopilotChatMode.FullScreen, + disabledFeatures, + readOnly: false, + }).showHeader + ).toBe(true); + }); + + it('drops the composer when read-only and maps the remaining flags', () => { + const layout = resolveChatLayout({ + chatMode: AutopilotChatMode.Closed, + disabledFeatures: { history: true, settings: true, headerSeparator: true, fullHeight: false }, + readOnly: true, + }); + + expect(layout).toMatchObject({ + isOpen: false, + showComposer: false, + historyAvailable: false, + settingsAvailable: false, + showHeaderSeparator: false, + fullHeight: true, + }); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/layout/chat-layout.ts b/packages/apollo-react/src/chat/headless/layout/chat-layout.ts new file mode 100644 index 0000000000..7d5c68dd53 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/layout/chat-layout.ts @@ -0,0 +1,41 @@ +import { type AutopilotChatDisabledFeatures, AutopilotChatMode } from '../../service'; + +export interface ChatLayoutInput { + chatMode: AutopilotChatMode; + disabledFeatures: AutopilotChatDisabledFeatures; + readOnly: boolean; +} + +export interface ChatLayout { + mode: AutopilotChatMode; + isOpen: boolean; + /** FullScreen always shows the header; `disabledFeatures.header` only applies to the panel. */ + showHeader: boolean; + showHeaderSeparator: boolean; + showComposer: boolean; + historyAvailable: boolean; + settingsAvailable: boolean; + /** Viewport height instead of leaving room for a host header; opt-in via `fullHeight: false`. */ + fullHeight: boolean; +} + +/** + * Which regions a chat layout shows for the current mode and configuration, shared by every + * renderer so the visibility rules stay identical. + */ +export function resolveChatLayout({ + chatMode, + disabledFeatures, + readOnly, +}: ChatLayoutInput): ChatLayout { + return { + mode: chatMode, + isOpen: chatMode !== AutopilotChatMode.Closed, + showHeader: chatMode === AutopilotChatMode.FullScreen || !disabledFeatures.header, + showHeaderSeparator: !disabledFeatures.headerSeparator, + showComposer: !readOnly, + historyAvailable: !disabledFeatures.history, + settingsAvailable: !disabledFeatures.settings, + fullHeight: disabledFeatures.fullHeight === false, + }; +} diff --git a/packages/apollo-react/src/chat/headless/providers/attachments-provider.tsx b/packages/apollo-react/src/chat/headless/providers/attachments-provider.tsx index 66496c7af0..5b553c03ec 100644 --- a/packages/apollo-react/src/chat/headless/providers/attachments-provider.tsx +++ b/packages/apollo-react/src/chat/headless/providers/attachments-provider.tsx @@ -9,6 +9,7 @@ import { AutopilotChatInternalEvent, type AutopilotChatPrompt, } from '../../service'; +import { publishChatEvent } from '../utils/publish-chat-event'; import { useChatService } from './chat-service.provider'; import { useChatState } from './chat-state-provider'; import { useError } from './error-provider'; @@ -68,14 +69,9 @@ export function AutopilotAttachmentsProvider({ children }: { children: React.Rea attachmentsRef.current = attachments; - const eventBus = (chatService as any)?._eventBus; - - if (eventBus && (added.length > 0 || removed.length > 0)) { - eventBus.publish(AutopilotChatEvent.Attachments, attachments); - eventBus.publish(AutopilotChatEvent.SetAttachments, { - added, - removed, - }); + if (chatService && (added.length > 0 || removed.length > 0)) { + publishChatEvent(chatService, AutopilotChatEvent.Attachments, attachments); + publishChatEvent(chatService, AutopilotChatEvent.SetAttachments, { added, removed }); } } }, [attachments, chatService]); diff --git a/packages/apollo-react/src/chat/headless/providers/chat-scroll-provider.tsx b/packages/apollo-react/src/chat/headless/providers/chat-scroll-provider.tsx index 1aabc76e1d..346eb5c57c 100644 --- a/packages/apollo-react/src/chat/headless/providers/chat-scroll-provider.tsx +++ b/packages/apollo-react/src/chat/headless/providers/chat-scroll-provider.tsx @@ -7,6 +7,7 @@ import { CHAT_SCROLL_BOTTOM_BUFFER, CHAT_WIDTH_FULL_SCREEN_MAX_WIDTH, } from '../../service'; +import { publishChatEvent } from '../utils/publish-chat-event'; import { useChatService } from './chat-service.provider'; import { useStreaming } from './streaming-provider'; @@ -174,7 +175,7 @@ export const AutopilotChatScrollProvider: React.FC { - (chatService as any)?._eventBus.publish(AutopilotChatEvent.CustomHeaderActionClicked, action); + if (chatService) { + publishChatEvent(chatService, AutopilotChatEvent.CustomHeaderActionClicked, action); + } }, [chatService] ); diff --git a/packages/apollo-react/src/chat/headless/providers/theme-provider.test.tsx b/packages/apollo-react/src/chat/headless/providers/theme-provider.test.tsx new file mode 100644 index 0000000000..1118c35e75 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/providers/theme-provider.test.tsx @@ -0,0 +1,51 @@ +import { act, render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { type ApChatTheme, AutopilotChatService } from '../../service'; +import { AutopilotChatServiceProvider } from './chat-service.provider'; +import { ThemeProvider, useTheme } from './theme-provider'; + +let instanceCounter = 0; +let current: ReturnType | undefined; + +function Probe() { + current = useTheme(); + return null; +} + +function setup(initialTheme: ApChatTheme) { + const service = AutopilotChatService.Instantiate({ + instanceName: `theme-${++instanceCounter}`, + }); + render( + + + + + + ); + return service; +} + +describe('ThemeProvider', () => { + it.each([ + ['light', false], + ['light-hc', false], + ['future-light', false], + ['dark', true], + ['dark-hc', true], + ['future-dark', true], + ] as const)('reports %s as dark: %s', (theme, isDark) => { + setup(theme); + + expect(current).toEqual({ theme, isDark }); + }); + + it('follows the service theme', () => { + const service = setup('light'); + + act(() => service.setTheme('future-dark')); + + expect(current).toEqual({ theme: 'future-dark', isDark: true }); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/providers/theme-provider.tsx b/packages/apollo-react/src/chat/headless/providers/theme-provider.tsx index bffda52cc5..da78141258 100644 --- a/packages/apollo-react/src/chat/headless/providers/theme-provider.tsx +++ b/packages/apollo-react/src/chat/headless/providers/theme-provider.tsx @@ -44,7 +44,7 @@ export const ThemeProvider: React.FC = ({ children, initialT const value = useMemo( () => ({ theme, - isDark: theme.startsWith('dark'), + isDark: theme.startsWith('dark') || theme === 'future-dark', }), [theme] ); diff --git a/packages/apollo-react/src/chat/headless/utils/publish-chat-event.ts b/packages/apollo-react/src/chat/headless/utils/publish-chat-event.ts new file mode 100644 index 0000000000..eefc6c9c27 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/utils/publish-chat-event.ts @@ -0,0 +1,15 @@ +import type { AutopilotChatEvent, AutopilotChatService } from '../../service'; +import type { EventBus } from '../../service/EventBus'; + +/** + * Publishes a public chat event on behalf of the UI (custom header actions, attachments, load + * more). The service has no public `publish`; the headless layer reaches its private bus only + * through here. Material's message actions and history still access it directly. + */ +export function publishChatEvent( + chatService: AutopilotChatService, + event: AutopilotChatEvent | string, + payload?: unknown +) { + (chatService as unknown as { _eventBus: EventBus })._eventBus.publish(event, payload); +} diff --git a/packages/apollo-react/src/chat/index.ts b/packages/apollo-react/src/chat/index.ts index 9c3ce00d14..826f6ae5da 100644 --- a/packages/apollo-react/src/chat/index.ts +++ b/packages/apollo-react/src/chat/index.ts @@ -1,13 +1,5 @@ -import { registerChatRenderer, registerLazyChatRenderer } from './headless/renderer-registry'; -import { WindChatPlaceholder } from './wind/WindChatPlaceholder'; - -registerChatRenderer('wind', WindChatPlaceholder); -registerLazyChatRenderer( - 'material', - // biome-ignore lint/style/noRestrictedImports: the one allowed src/chat → Material edge; a dynamic import keeps MUI out of the initial chunk - () => import('../material/components/ap-chat/MaterialChatRenderer') -); - +// The Wind and lazy Material renderers are registered by `root/ApChat`, which this entry +// re-exports, so they are in place for every consumer of this entry. export type { SupportedLocale } from '../i18n'; export { type ChatBuiltInMessageRenderer, diff --git a/packages/apollo-react/src/chat/root/ApChat.registration.test.ts b/packages/apollo-react/src/chat/root/ApChat.registration.test.ts new file mode 100644 index 0000000000..b60a0ad24d --- /dev/null +++ b/packages/apollo-react/src/chat/root/ApChat.registration.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +// Imports the ApChat module alone, without the package entry, as a bundler does when it skips a +// re-export-only barrel under `sideEffects`. +describe('ApChat module', () => { + it('registers the Wind and Material renderers on import', async () => { + const registry = await import('../headless/renderer-registry'); + expect(registry.resolveChatRenderer(undefined, 'wind')).toBeUndefined(); + + await import('./ApChat'); + const { WindChatRenderer } = await import('../wind/WindChatRenderer'); + + expect(registry.resolveChatRenderer(undefined, 'wind')).toBe(WindChatRenderer); + expect(registry.resolveChatRenderer(undefined, 'material')).toBeDefined(); + }); +}); diff --git a/packages/apollo-react/src/chat/root/ApChat.test.tsx b/packages/apollo-react/src/chat/root/ApChat.test.tsx index 3679d2eb28..99661773db 100644 --- a/packages/apollo-react/src/chat/root/ApChat.test.tsx +++ b/packages/apollo-react/src/chat/root/ApChat.test.tsx @@ -12,10 +12,10 @@ const createService = (renderer?: 'material' | 'wind') => }); describe('ApChat from @uipath/apollo-react/chat', () => { - it('renders the wind placeholder by default', () => { + it('renders the Wind renderer by default', () => { render(); - expect(screen.getByText(/Wind chat renderer is not available yet/)).toBeInTheDocument(); + expect(document.querySelector('[data-slot="wind-chat"]')).not.toBeNull(); }); it('loads the Material renderer on demand when config.renderer is material', async () => { @@ -23,6 +23,6 @@ describe('ApChat from @uipath/apollo-react/chat', () => { // First load transforms the whole Material tree, which is slow under Vitest. expect(await screen.findByText('Autopilot', {}, { timeout: 25_000 })).toBeInTheDocument(); - expect(screen.queryByText(/Wind chat renderer is not available yet/)).toBeNull(); + expect(document.querySelector('[data-slot="wind-chat"]')).toBeNull(); }, 30_000); }); diff --git a/packages/apollo-react/src/chat/root/ApChat.tsx b/packages/apollo-react/src/chat/root/ApChat.tsx index e5d6c36462..567ded118c 100644 --- a/packages/apollo-react/src/chat/root/ApChat.tsx +++ b/packages/apollo-react/src/chat/root/ApChat.tsx @@ -1,8 +1,19 @@ import React from 'react'; import type { SupportedLocale } from '../../i18n'; +import { registerChatRenderer, registerLazyChatRenderer } from '../headless/renderer-registry'; import type { ApChatTheme, AutopilotChatService } from '../service'; +import { WindChatRenderer } from '../wind/WindChatRenderer'; import { ChatRoot } from './ChatRoot'; +// Registered here rather than in the package entry: bundlers that honour `sideEffects` drop the +// statements of a re-export-only barrel, but keep those of the module that defines `ApChat`. +registerChatRenderer('wind', WindChatRenderer); +registerLazyChatRenderer( + 'material', + // biome-ignore lint/style/noRestrictedImports: the one allowed src/chat → Material edge; a dynamic import keeps MUI out of the initial chunk + () => import('../../material/components/ap-chat/MaterialChatRenderer') +); + export interface ApChatProps { /** * Chat service instance diff --git a/packages/apollo-react/src/chat/service/ChatModel.ts b/packages/apollo-react/src/chat/service/ChatModel.ts index 11b8514a5f..a68bf9dd57 100644 --- a/packages/apollo-react/src/chat/service/ChatModel.ts +++ b/packages/apollo-react/src/chat/service/ChatModel.ts @@ -2,7 +2,13 @@ import type { FontVariantToken } from '@uipath/apollo-core'; import type { ComponentType } from 'react'; import type { SupportedLocale } from '../../i18n'; -export type ApChatTheme = 'light' | 'light-hc' | 'dark' | 'dark-hc'; +export type ApChatTheme = + | 'light' + | 'light-hc' + | 'dark' + | 'dark-hc' + | 'future-light' + | 'future-dark'; /** UI implementation that renders the chat. */ export type AutopilotChatRenderer = 'material' | 'wind'; @@ -516,7 +522,7 @@ export enum AutopilotChatPreHookAction { * @property mode - The mode of the chat * @property embeddedContainer - The container to embed the chat in * @property locale - The locale/language for the chat interface (e.g., 'en', 'de', 'es', 'fr', 'ja', etc.) - * @property theme - The theme variant for the chat ('light', 'dark', 'light-hc', 'dark-hc') + * @property theme - The theme variant for the chat ('light', 'dark', 'light-hc', 'dark-hc', 'future-light', 'future-dark') * @property renderer - The UI implementation to render ('material' or 'wind'); falls back to the host's default * @property disabledFeatures - The disabled features of the chat * @property overrideLabels - The override labels of the chat diff --git a/packages/apollo-react/src/chat/service/ChatService.ts b/packages/apollo-react/src/chat/service/ChatService.ts index 1f2ffb4b6d..1a6aed2d59 100644 --- a/packages/apollo-react/src/chat/service/ChatService.ts +++ b/packages/apollo-react/src/chat/service/ChatService.ts @@ -1148,7 +1148,7 @@ export class AutopilotChatService { /** * Sets the theme variant for the chat interface * - * @param theme - The theme to set ('light', 'dark', 'light-hc', 'dark-hc') + * @param theme - The theme to set ('light', 'dark', 'light-hc', 'dark-hc', 'future-light', 'future-dark') */ setTheme(theme: string) { this._theme = theme; diff --git a/packages/apollo-react/src/chat/stories/chat-playground.stories.tsx b/packages/apollo-react/src/chat/stories/chat-playground.stories.tsx new file mode 100644 index 0000000000..e526130397 --- /dev/null +++ b/packages/apollo-react/src/chat/stories/chat-playground.stories.tsx @@ -0,0 +1,193 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { AutopilotChatMode } from '../service'; +import { + ChatPlayground, + featureArgName, + PLAYGROUND_FEATURE_FLAGS, + type PlaygroundArgs, +} from './chat-playground'; +import { CHAT_LOCALES, mapToChatTheme } from './chat-story-fixtures'; +import { CHAT_STORY_SCENARIOS, type ChatStoryScenarioId } from './chat-story-scenarios'; + +const scenarioIds = Object.keys(CHAT_STORY_SCENARIOS) as ChatStoryScenarioId[]; + +// Service defaults, so the playground starts where a host that sets nothing starts. +const SERVICE_DISABLED_BY_DEFAULT = new Set([ + 'settings', + 'headerSeparator', + 'audio', + 'audioStreaming', + 'fullHeight', + 'htmlPreview', + 'renameChat', +]); + +const featureArgs = Object.fromEntries( + PLAYGROUND_FEATURE_FLAGS.map((flag) => [ + featureArgName(flag), + SERVICE_DISABLED_BY_DEFAULT.has(flag), + ]) +) as Pick>; + +const featureArgTypes = Object.fromEntries( + PLAYGROUND_FEATURE_FLAGS.map((flag) => [ + featureArgName(flag), + { + control: 'boolean', + description: `disabledFeatures.${flag}`, + table: { category: 'Flags' }, + }, + ]) +); + +const ARG_BEHAVIOUR = ` +Controls apply to the running chat through the service setters, without a new chat or a remount: + +- **In place**: \`mode\` (setChatMode), \`readOnly\` (setReadOnly), Flags (setDisabledFeatures), \`title\`, \`inputPlaceholder\` and \`footerDisclaimer\` (setOverrideLabels), \`compactMode\` (patchConfig spacing), \`firstRunExperience\` (setFirstRunExperience, visible while the transcript is empty), \`customHeaderActions\` (setCustomHeaderActions), \`preHooks\` (setPreHook), \`locale\` (prop), \`autoReply\` and \`streamDelayMs\` (read on the next turn). The theme follows the Storybook theme toolbar and is passed to each chat as its \`theme\` prop. Switching between Embedded and FullScreen rebuilds the chat's DOM, because ChatRoot portals only in Embedded, but the service and transcript stay. +- **In place, with a chat added or removed**: \`renderer\`. Switching between wind and material calls setRenderer on the same service and keeps the transcript. Choosing both adds a Material chat on its own service that copies the current transcript; leaving both removes it. +- **Appends**: \`scenario\` scripts its traffic into the current transcript. Scenarios that need an empty transcript start a new chat first and say so: First-run experience, Long history with pagination. Changing scenario stops whatever the previous one still had scheduled (for example the rest of a stream). +- **Remounts**: nothing driven by a control. Storybook's Remount button and the locale toolbar (which remounts every story) are the full reset: the chat keeps its service instance and starts it over from the story's controls. + +Closing a chat leaves an **Open chat** button where the host would put its launcher; it reopens in the selected \`mode\`. + +Icons on custom header actions and message actions come from the consumer: the Material renderer takes Material Icons names (\`picture_as_pdf\`), the Wind renderer takes lucide names, kebab-case (\`file-text\`) or the PascalCase export name (\`FileText\`). Names Wind cannot resolve show a neutral box. The playground gives each renderer its own action set for that reason. + +With both renderers, every control applies to both chats, scenarios replay into both on the same timers, and a message sent from either composer is mirrored to the other. Service events from each chat appear in the Actions panel, prefixed with the renderer. +`; + +/** + * `ApChat` driven by an `AutopilotChatService`, with the configuration exposed as controls. + * `renderer: both` shows Wind and Material on separate services under identical input. + */ +const meta = { + title: 'Playground', + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + docs: { + description: { component: ARG_BEHAVIOUR }, + story: { inline: false, iframeHeight: 760 }, + }, + }, + argTypes: { + renderer: { + control: 'inline-radio', + options: ['wind', 'material', 'both'], + description: 'Renderer. Both shows Wind and Material on separate services.', + table: { category: 'Renderer' }, + }, + mode: { + control: 'inline-radio', + options: [AutopilotChatMode.Embedded, AutopilotChatMode.FullScreen], + description: + 'Chat mode. With both renderers, Embedded is side by side and FullScreen is stacked.', + table: { category: 'Renderer' }, + }, + locale: { + control: 'select', + options: CHAT_LOCALES, + table: { category: 'Renderer' }, + }, + compactMode: { + control: 'boolean', + description: 'spacing.compactMode', + table: { category: 'Configuration' }, + }, + readOnly: { control: 'boolean', table: { category: 'Configuration' } }, + firstRunExperience: { + control: 'boolean', + description: 'Sets a first-run experience; it shows while the transcript is empty.', + table: { category: 'Configuration' }, + }, + customHeaderActions: { + control: 'boolean', + description: + 'Nested custom header actions: lucide icon names for Wind, Material Icons names for Material.', + table: { category: 'Configuration' }, + }, + preHooks: { + control: 'boolean', + description: 'Every pre-hook asks for confirmation before the action runs.', + table: { category: 'Configuration' }, + }, + disableClose: { + control: 'inline-radio', + options: ['unset', 'disabled', 'enabled'], + description: + 'disabledFeatures.close. Unset hides close in Embedded and shows it elsewhere; enabled (false) shows it in Embedded too.', + table: { category: 'Flags' }, + }, + ...featureArgTypes, + title: { control: 'text', table: { category: 'Labels' } }, + inputPlaceholder: { control: 'text', table: { category: 'Labels' } }, + footerDisclaimer: { control: 'text', table: { category: 'Labels' } }, + scenario: { + control: { + type: 'select', + labels: Object.fromEntries(scenarioIds.map((id) => [id, CHAT_STORY_SCENARIOS[id].label])), + }, + options: scenarioIds, + description: scenarioIds + .map( + (id) => `**${CHAT_STORY_SCENARIOS[id].label}**: ${CHAT_STORY_SCENARIOS[id].description}` + ) + .join('
'), + table: { category: 'Traffic' }, + }, + autoReply: { + control: 'inline-radio', + options: ['off', 'echo', 'stream'], + description: 'How the playground answers a message sent from the composer.', + table: { category: 'Traffic' }, + }, + streamDelayMs: { + control: { type: 'range', min: 20, max: 500, step: 10 }, + description: 'Delay between streamed chunks and scripted turns.', + table: { category: 'Traffic' }, + }, + onChatEvent: { action: 'chat event', table: { disable: true } }, + }, + args: { + renderer: 'wind', + mode: AutopilotChatMode.Embedded, + locale: 'en', + compactMode: false, + readOnly: false, + firstRunExperience: true, + customHeaderActions: true, + preHooks: false, + disableClose: 'unset', + ...featureArgs, + title: '', + inputPlaceholder: '', + footerDisclaimer: '', + scenario: 'conversation', + autoReply: 'stream', + streamDelayMs: 80, + }, + render: (args, context) => ( + + ), +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +/** The Wind renderer in Embedded mode, in a host-owned container. */ +export const Embedded: Story = {}; + +/** The Wind renderer in FullScreen mode. */ +export const FullScreen: Story = { + args: { mode: AutopilotChatMode.FullScreen }, +}; + +/** Wind and Material side by side, each on its own service, under identical input. */ +export const CompareRenderers: Story = { + name: 'Compare renderers', + args: { renderer: 'both' }, +}; diff --git a/packages/apollo-react/src/chat/stories/chat-playground.test.tsx b/packages/apollo-react/src/chat/stories/chat-playground.test.tsx new file mode 100644 index 0000000000..8deca8e345 --- /dev/null +++ b/packages/apollo-react/src/chat/stories/chat-playground.test.tsx @@ -0,0 +1,141 @@ +import { act, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { AutopilotChatMode, AutopilotChatService } from '../index'; +import { + ChatPlayground, + type ChatPlaygroundProps, + featureArgName, + PLAYGROUND_FEATURE_FLAGS, +} from './chat-playground'; + +let storyCounter = 0; +let instanceKey = ''; + +beforeEach(() => { + instanceKey = `test-${++storyCounter}`; +}); + +const slotService = (slot: 'primary' | 'compare') => + AutopilotChatService.getInstance(`storybook-chat-playground-${instanceKey}-${slot}`); + +const baseProps = (): ChatPlaygroundProps => ({ + ...(Object.fromEntries( + PLAYGROUND_FEATURE_FLAGS.map((flag) => [featureArgName(flag), false]) + ) as Pick>), + theme: 'light', + instanceKey, + renderer: 'wind', + mode: AutopilotChatMode.Embedded, + locale: 'en', + compactMode: false, + readOnly: false, + disableClose: 'unset', + title: '', + inputPlaceholder: '', + footerDisclaimer: '', + firstRunExperience: false, + customHeaderActions: false, + preHooks: false, + scenario: 'none', + autoReply: 'off', + streamDelayMs: 1, +}); + +const flush = () => act(() => new Promise((resolve) => setTimeout(resolve, 20))); + +describe('ChatPlayground', () => { + it('applies args to the running chat without a new chat', async () => { + const props = baseProps(); + const { rerender } = render(); + await flush(); + const service = slotService('primary'); + const root = document.querySelector('[data-slot="wind-chat"]'); + const transcript = service?.getConversation().length; + + rerender( + + ); + await flush(); + + expect(document.querySelector('[data-slot="wind-chat"]')).toBe(root); + expect(root).toHaveAttribute('data-density', 'compact'); + expect(root).toHaveClass('dark'); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Agent'); + + // ChatRoot stops portalling when leaving Embedded, so the DOM is new but the chat is not. + rerender( + + ); + await flush(); + + expect(slotService('primary')).toBe(service); + expect(service?.getConversation().length).toBe(transcript); + expect(document.querySelector('[data-slot="wind-chat"]')).toHaveAttribute( + 'data-mode', + AutopilotChatMode.FullScreen + ); + }); + + it('appends a scenario to the current transcript', async () => { + const props = baseProps(); + const { rerender } = render(); + await flush(); + const service = slotService('primary'); + expect(service?.getConversation()).toHaveLength(2); + + rerender(); + await flush(); + + expect(service?.getConversation()).toHaveLength(4); + }); + + it('mirrors a sent message to the other renderer once', async () => { + render(); + await flush(); + const wind = slotService('primary'); + const material = slotService('compare'); + + await act(async () => { + material?.sendRequest({ content: 'hello' }); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + expect(material?.getConversation()).toHaveLength(1); + expect(wind?.getConversation().map((message) => message.content)).toEqual(['hello']); + }); + + it('reuses its service across remounts and starts it over', async () => { + const first = render(); + await flush(); + const service = slotService('primary'); + first.unmount(); + + render(); + await flush(); + + expect(slotService('primary')).toBe(service); + expect(service?.getConversation()).toHaveLength(0); + }); + + it('offers to reopen a closed chat in the selected mode', async () => { + render(); + await flush(); + const service = slotService('primary'); + + act(() => service?.close()); + await act(async () => { + screen.getByRole('button', { name: 'Open chat' }).click(); + }); + + expect(service?.getConfig().mode).toBe(AutopilotChatMode.FullScreen); + expect(screen.queryByRole('button', { name: 'Open chat' })).toBeNull(); + }); +}); diff --git a/packages/apollo-react/src/chat/stories/chat-playground.tsx b/packages/apollo-react/src/chat/stories/chat-playground.tsx new file mode 100644 index 0000000000..1f16dd481d --- /dev/null +++ b/packages/apollo-react/src/chat/stories/chat-playground.tsx @@ -0,0 +1,556 @@ +/** + * The service-driven chat playground behind the Apollo React/Chat stories. Each chat on the page + * keeps one `AutopilotChatService` for its whole life; args are applied to it through the service + * setters, so changing a control never starts a new chat. + */ +import React from 'react'; + +import type { SupportedLocale } from '../../i18n'; +// The package entry, so the Wind and lazy Material renderers are registered as for consumers. +import { ApChat } from '../index'; +import { + type ApChatTheme, + type AutopilotChatCustomHeaderAction, + type AutopilotChatDisabledFeatures, + AutopilotChatEvent, + type AutopilotChatMessage, + AutopilotChatMode, + AutopilotChatPreHookAction, + type AutopilotChatRenderer, + AutopilotChatService, +} from '../service'; +import { + createStoryResourceManager, + createStorySettingsRenderer, + customHeaderActionReply, + MATERIAL_CUSTOM_HEADER_ACTIONS, + STORY_AGENT_MODES, + STORY_FIRST_RUN_EXPERIENCE, + STORY_LOADING_MESSAGES, + STORY_MODELS, + STREAMING_REPLY, + storyHistoryItems, + WIND_CUSTOM_HEADER_ACTIONS, +} from './chat-story-fixtures'; +import { + embeddedLauncherStyle, + embeddedPanelLabelStyle, + embeddedPanelStyle, +} from './chat-story-frame'; +import { + CHAT_STORY_SCENARIOS, + type ChatStoryScenario, + type ChatStoryScenarioId, + patchConfigInPlace, + type ScenarioContext, + splitWords, + streamChunks, +} from './chat-story-scenarios'; + +export type PlaygroundRenderer = AutopilotChatRenderer | 'both'; +export type PlaygroundMode = AutopilotChatMode.Embedded | AutopilotChatMode.FullScreen; +export type PlaygroundAutoReply = 'off' | 'echo' | 'stream'; +/** `unset` leaves `close` undefined: Embedded then hides close, other modes show it. */ +export type PlaygroundCloseFlag = 'unset' | 'disabled' | 'enabled'; + +/** `disabledFeatures` flags exposed as boolean args (`close` has its own three-way arg). */ +export const PLAYGROUND_FEATURE_FLAGS = [ + 'header', + 'headerSeparator', + 'preview', + 'newChat', + 'history', + 'settings', + 'fullScreen', + 'feedback', + 'copy', + 'htmlPreview', + 'attachments', + 'stopResponse', + 'footer', + 'audio', + 'audioStreaming', + 'renameChat', + 'resize', + 'fullHeight', +] as const satisfies ReadonlyArray; + +type FeatureFlag = (typeof PLAYGROUND_FEATURE_FLAGS)[number]; +export type FeatureFlagArgs = { [F in FeatureFlag as `disable${Capitalize}`]: boolean }; + +export const featureArgName = (flag: FeatureFlag) => + `disable${flag.charAt(0).toUpperCase()}${flag.slice(1)}` as keyof FeatureFlagArgs; + +export interface PlaygroundArgs extends FeatureFlagArgs { + renderer: PlaygroundRenderer; + mode: PlaygroundMode; + locale: SupportedLocale; + compactMode: boolean; + readOnly: boolean; + disableClose: PlaygroundCloseFlag; + title: string; + inputPlaceholder: string; + footerDisclaimer: string; + firstRunExperience: boolean; + customHeaderActions: boolean; + preHooks: boolean; + scenario: ChatStoryScenarioId; + autoReply: PlaygroundAutoReply; + streamDelayMs: number; + onChatEvent?: (event: string, data?: unknown) => void; +} + +export interface ChatPlaygroundProps extends PlaygroundArgs { + /** Mapped from the Storybook theme toolbar. */ + theme: ApChatTheme; + /** Names this story's service instances (the story id), so they are reused across remounts. */ + instanceKey: string; +} + +const PRE_HOOK_ACTIONS = Object.values(AutopilotChatPreHookAction); + +const NO_FIRST_RUN_EXPERIENCE = { title: '', description: '', suggestions: [] }; + +const firstRunExperienceFor = (enabled: boolean) => + enabled ? STORY_FIRST_RUN_EXPERIENCE : NO_FIRST_RUN_EXPERIENCE; + +const overrideLabelsFrom = (args: PlaygroundArgs) => ({ + title: args.title || undefined, + inputPlaceholder: args.inputPlaceholder || undefined, + footerDisclaimer: args.footerDisclaimer || undefined, +}); + +/** `setTimeout` with one call that cancels everything still pending. */ +function createTimers() { + const pending = new Set>(); + + return { + schedule: (run: () => void, delay: number) => { + const timer = setTimeout(() => { + pending.delete(timer); + run(); + }, delay); + pending.add(timer); + }, + clear: () => { + for (const timer of pending) clearTimeout(timer); + pending.clear(); + }, + }; +} + +function disabledFeaturesFrom(args: PlaygroundArgs): AutopilotChatDisabledFeatures { + const features: AutopilotChatDisabledFeatures = { + close: args.disableClose === 'unset' ? undefined : args.disableClose === 'disabled', + }; + + for (const flag of PLAYGROUND_FEATURE_FLAGS) { + features[flag] = args[featureArgName(flag)]; + } + + return features; +} + +const confirmPreHook = (action: AutopilotChatPreHookAction) => () => + Promise.resolve(window.confirm(`Pre-hook "${action}". Continue?`)); +const allowPreHook = () => Promise.resolve(true); + +const preHooksFrom = (enabled: boolean) => + enabled + ? Object.fromEntries(PRE_HOOK_ACTIONS.map((action) => [action, confirmPreHook(action)])) + : {}; + +const customActionsFor = ( + renderer: AutopilotChatRenderer, + enabled: boolean +): AutopilotChatCustomHeaderAction[] => + enabled + ? renderer === 'wind' + ? WIND_CUSTOM_HEADER_ACTIONS + : MATERIAL_CUSTOM_HEADER_ACTIONS + : []; + +interface SlotHandle { + service: AutopilotChatService; + // Tracked by message id because the service publishes Request asynchronously once it has + // interceptors, so a flag around `sendRequest` would be reset before the handler runs. + /** Scenario turns: no auto reply, no mirroring. */ + quietIds: Set; + /** Turns mirrored from the other chat: auto reply, no mirroring back. */ + mirroredIds: Set; +} + +type SlotRegistry = Map; + +/** Runs effects only when `value` changes after the first commit; the first commit is the config. */ +function useOnChange(value: T, effect: (value: T) => void) { + const previous = React.useRef(value); + + // biome-ignore lint/correctness/useExhaustiveDependencies: `effect` is a fresh closure each render; only `value` re-runs it + React.useEffect(() => { + if (Object.is(previous.current, value)) { + return; + } + previous.current = value; + effect(value); + }, [value]); +} + +/** + * The slot's service, named per story and slot so a remount reuses the instance instead of adding + * one to the service's registry each time; it is reset to the story's configuration here. + */ +function createSlotService( + instanceName: string, + renderer: AutopilotChatRenderer, + args: PlaygroundArgs, + embeddedContainer: HTMLElement, + seedFrom?: AutopilotChatService +) { + const service = AutopilotChatService.Instantiate({ + instanceName, + config: { + mode: args.mode, + renderer, + embeddedContainer, + readOnly: args.readOnly, + disabledFeatures: disabledFeaturesFrom(args), + overrideLabels: overrideLabelsFrom(args), + firstRunExperience: firstRunExperienceFor(args.firstRunExperience), + spacing: { compactMode: args.compactMode }, + preHooks: preHooksFrom(args.preHooks), + models: STORY_MODELS, + selectedModel: STORY_MODELS[0], + agentModes: STORY_AGENT_MODES, + selectedAgentMode: STORY_AGENT_MODES[0], + settingsRenderer: createStorySettingsRenderer(), + resourceManager: createStoryResourceManager(), + }, + }); + + service.newChat(); + service.clearError(); + service.setLoadingMessage(''); + service.setCustomHeaderActions(customActionsFor(renderer, args.customHeaderActions)); + service.setDefaultLoadingMessages(STORY_LOADING_MESSAGES); + service.setHistory(storyHistoryItems()); + + if (seedFrom) { + service.setConversation(seedFrom.getConversation().map((message) => ({ ...message }))); + } + + return service; +} + +interface ChatSlotProps { + id: string; + /** Prefix for the service instance name, unique per story. */ + instanceKey: string; + label: string; + renderer: AutopilotChatRenderer; + args: PlaygroundArgs; + theme: ApChatTheme; + registry: SlotRegistry; + /** Slots added after the story mounted copy this chat's transcript instead of running the scenario. */ + seedFrom?: AutopilotChatService; + /** Docking position in Embedded: 0 is bottom right, 1 sits to its left. */ + slot: number; + showLabel: boolean; +} + +function ChatSlot({ + id, + instanceKey, + label, + renderer, + args, + theme, + registry, + seedFrom, + slot, + showLabel, +}: ChatSlotProps) { + // The embedded container is created once and adopted by the slot, so the service can be + // configured with it before the first render and it never changes identity. + const [embeddedHost] = React.useState(() => { + const element = document.createElement('div'); + element.style.position = 'relative'; + element.style.width = '100%'; + element.style.height = '100%'; + return element; + }); + const [service] = React.useState(() => + createSlotService( + `storybook-chat-playground-${instanceKey}-${id}`, + renderer, + args, + embeddedHost, + seedFrom + ) + ); + const [closed, setClosed] = React.useState( + () => service.getConfig().mode === AutopilotChatMode.Closed + ); + const argsRef = React.useRef(args); + argsRef.current = args; + const labelRef = React.useRef(label); + labelRef.current = label; + + const adoptHost = React.useCallback( + (node: HTMLDivElement | null) => { + if (node && embeddedHost.parentElement !== node) { + node.appendChild(embeddedHost); + } + }, + [embeddedHost] + ); + + const log = React.useCallback((event: string, data?: unknown) => { + argsRef.current.onChatEvent?.(`[${labelRef.current}] ${event}`, data); + }, []); + + // Registration, event logging, mirroring, auto reply and header action replies. + React.useEffect(() => { + const handle: SlotHandle = { service, quietIds: new Set(), mirroredIds: new Set() }; + registry.set(id, handle); + const timers = createTimers(); + + const unsubscribes = Object.values(AutopilotChatEvent).map((event) => + service.on(event, (data) => log(event, data)) + ); + unsubscribes.push( + service.on(AutopilotChatEvent.ModeChange, (mode: AutopilotChatMode) => + setClosed(mode === AutopilotChatMode.Closed) + ) + ); + + unsubscribes.push( + service.on(AutopilotChatEvent.Request, (message: AutopilotChatMessage) => { + if (handle.quietIds.delete(message.id)) { + return; + } + + if (!handle.mirroredIds.delete(message.id)) { + for (const [otherId, other] of registry) { + if (otherId === id) continue; + const mirrorId = crypto.randomUUID(); + other.mirroredIds.add(mirrorId); + other.service.sendRequest({ + id: mirrorId, + content: message.content, + attachments: message.attachments, + }); + } + } + + const { autoReply, streamDelayMs } = argsRef.current; + if (autoReply === 'echo') { + timers.schedule( + () => + service.sendResponse({ + content: `You said: ${message.content}`, + widget: 'apollo-markdown-renderer', + created_at: new Date().toISOString(), + }), + streamDelayMs * 4 + ); + } else if (autoReply === 'stream') { + streamChunks( + { + service, + delayMs: streamDelayMs, + schedule: timers.schedule, + sendScriptedRequest: () => {}, + onCleanup: () => {}, + log, + }, + splitWords(STREAMING_REPLY), + streamDelayMs * 4 + ); + } + }), + service.on( + AutopilotChatEvent.CustomHeaderActionClicked, + (action: AutopilotChatCustomHeaderAction) => + service.sendResponse({ + content: customHeaderActionReply(action), + groupId: `custom-action-${action.id}`, + widget: 'apollo-markdown-renderer', + created_at: new Date().toISOString(), + }) + ) + ); + + return () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + timers.clear(); + registry.delete(id); + service.close(); + }; + }, [id, registry, service, log]); + + // In-place arg updates, through the same setters a host application uses. + useOnChange(args.mode, (mode) => service.setChatMode(mode)); + useOnChange(renderer, (next) => { + service.setRenderer(next); + service.setCustomHeaderActions(customActionsFor(next, argsRef.current.customHeaderActions)); + }); + useOnChange(args.readOnly, (readOnly) => service.setReadOnly(readOnly)); + useOnChange(JSON.stringify(disabledFeaturesFrom(args)), () => + service.setDisabledFeatures(disabledFeaturesFrom(argsRef.current)) + ); + useOnChange(`${args.title}\n${args.inputPlaceholder}\n${args.footerDisclaimer}`, () => + service.setOverrideLabels(overrideLabelsFrom(argsRef.current)) + ); + useOnChange(args.compactMode, (compactMode) => + patchConfigInPlace(service, { spacing: { compactMode } }) + ); + useOnChange(args.firstRunExperience, (enabled) => + service.setFirstRunExperience(firstRunExperienceFor(enabled)) + ); + useOnChange(args.customHeaderActions, (enabled) => + service.setCustomHeaderActions(customActionsFor(renderer, enabled)) + ); + useOnChange(args.preHooks, (enabled) => { + for (const action of PRE_HOOK_ACTIONS) { + service.setPreHook(action, enabled ? confirmPreHook(action) : allowPreHook); + } + }); + + // Scenarios append; the one active at mount runs unless this chat was seeded from another. + const skipInitialScenario = React.useRef(!!seedFrom); + React.useEffect(() => { + if (skipInitialScenario.current) { + skipInitialScenario.current = false; + return; + } + + const handle = registry.get(id); + const scenario: ChatStoryScenario = CHAT_STORY_SCENARIOS[args.scenario]; + const timers = createTimers(); + const cleanups: Array<() => void> = []; + const context: ScenarioContext = { + service, + delayMs: argsRef.current.streamDelayMs, + sendScriptedRequest: (request) => { + const requestId = crypto.randomUUID(); + handle?.quietIds.add(requestId); + service.sendRequest({ ...request, id: requestId }); + }, + schedule: timers.schedule, + onCleanup: (cleanup) => cleanups.push(cleanup), + log, + }; + + if (scenario.emptyTranscript) { + service.newChat(); + } + scenario.run(context); + + return () => { + timers.clear(); + for (const cleanup of cleanups) cleanup(); + }; + }, [args.scenario, id, registry, service, log]); + + const fullScreen = args.mode === AutopilotChatMode.FullScreen; + + return ( +
+ {showLabel && + !closed && + (fullScreen ? ( +
+ {label} +
+ ) : ( +
+ {label} +
+ ))} +
+ {closed && ( + // Host-side reopen, as an application's launcher would; reopens in the selected mode. + + )} +
+ +
+
+ ); +} + +/** + * One chat, or Wind and Material docked side by side (Embedded) or stacked (FullScreen), each on its own + * service with every arg applied to both. + */ +export function ChatPlayground({ theme, instanceKey, ...args }: ChatPlaygroundProps) { + const [registry] = React.useState(() => new Map()); + const both = args.renderer === 'both'; + const primaryRenderer: AutopilotChatRenderer = args.renderer === 'both' ? 'wind' : args.renderer; + const fullScreen = args.mode === AutopilotChatMode.FullScreen; + + return ( +
+ {!fullScreen && Host application} + + {both && ( + + )} +
+ ); +} diff --git a/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts b/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts new file mode 100644 index 0000000000..68ea0c2c3d --- /dev/null +++ b/packages/apollo-react/src/chat/stories/chat-story-fixtures.ts @@ -0,0 +1,915 @@ +/** + * Data shared by the chat Storybook stories (the Apollo React/Chat playground and the Material + * showcase): sample configuration, scripted payloads and mock integrations. MUI-free, so both + * renderers' stories can use it. + */ +import type { SupportedLocale } from '../../i18n'; +import { + type ApChatTheme, + type AutopilotChatAgentModeInfo, + type AutopilotChatCustomHeaderAction, + type AutopilotChatHistory, + type AutopilotChatMessage, + type AutopilotChatMessageAction, + type AutopilotChatModelInfo, + type AutopilotChatResourceItem, + type AutopilotChatResourceManager, + AutopilotChatRole, + type AutopilotChatSuggestion, + type ContentPartChunk, +} from '../service'; + +/* ------------------------------------------------------------------ */ +/* Locale and theme */ +/* ------------------------------------------------------------------ */ + +export const CHAT_LOCALES: SupportedLocale[] = [ + 'en', + 'de', + 'es', + 'es-MX', + 'fr', + 'ja', + 'ko', + 'pt', + 'pt-BR', + 'ro', + 'ru', + 'tr', + 'zh-CN', + 'zh-TW', +]; + +/** Maps Storybook's global locale to a chat-supported locale (fallback: en). */ +export function mapToChatLocale(globalLocale: unknown): SupportedLocale { + return CHAT_LOCALES.includes(globalLocale as SupportedLocale) + ? (globalLocale as SupportedLocale) + : 'en'; +} + +/** + * Maps the Storybook global theme to the closest chat theme. The classic and future themes pass + * through; of the demo themes, wireframe falls back to light and the rest (vertex, canvas) to dark. + */ +export function mapToChatTheme(globalTheme: unknown): ApChatTheme { + switch (globalTheme) { + case 'light': + return 'light'; + case 'future-light': + return 'future-light'; + case 'light-hc': + return 'light-hc'; + case 'dark': + return 'dark'; + case 'dark-hc': + return 'dark-hc'; + case 'future-dark': + return 'future-dark'; + case 'wireframe': + return 'light'; + default: + return 'dark'; + } +} + +/* ------------------------------------------------------------------ */ +/* Configuration */ +/* ------------------------------------------------------------------ */ + +export const STORY_MODELS: AutopilotChatModelInfo[] = [ + { id: 'gpt-4', name: 'GPT-4', description: 'Most capable model' }, + { id: 'gpt-3.5', name: 'GPT-3.5 Turbo', description: 'Fast and efficient' }, + { id: 'claude-3', name: 'Claude 3', description: 'Anthropic model' }, +]; + +export const STORY_AGENT_MODES: AutopilotChatAgentModeInfo[] = [ + { + id: 'agent', + name: 'Agent', + description: 'AI-powered autonomous agent mode', + icon: 'smart_toy', + }, + { id: 'plan', name: 'Plan', description: 'Create and review execution plans', icon: 'edit_note' }, + { id: 'attended', name: 'Attended', description: 'Human assisted execution', icon: 'play_arrow' }, +]; + +/** The flat set the Material showcase starts with. */ +export const BASIC_CUSTOM_HEADER_ACTIONS: AutopilotChatCustomHeaderAction[] = [ + { id: 'export', name: 'Export Chat', icon: 'download' }, + { id: 'share', name: 'Share', icon: 'share' }, +]; + +/** + * Nested custom header actions for the Material renderer, whose `icon` values are Material Icons + * names. + */ +export const MATERIAL_CUSTOM_HEADER_ACTIONS: AutopilotChatCustomHeaderAction[] = [ + { + id: 'export', + name: 'Export', + icon: 'download', + description: 'Export conversation in various formats', + children: [ + { + id: 'export-pdf', + name: 'Export as PDF', + icon: 'picture_as_pdf', + description: 'Download conversation as PDF file', + }, + { + id: 'export-docx', + name: 'Export as DOCX', + icon: 'description', + description: 'Download conversation as Word document', + }, + { + id: 'export-json', + name: 'Export as JSON', + icon: 'data_object', + description: 'Download conversation as JSON file', + }, + { + id: 'export-txt', + name: 'Export as Text', + icon: 'text_snippet', + description: 'Download conversation as plain text', + }, + ], + }, + { + id: 'share', + name: 'Share Conversation', + icon: 'share', + description: 'Share this conversation with others', + }, + { + id: 'translate', + name: 'Translate', + icon: 'translate', + description: 'Translate conversation to different languages', + children: [ + { + id: 'translate-es', + name: 'Spanish', + icon: 'language', + description: 'Translate to Spanish', + }, + { id: 'translate-fr', name: 'French', icon: 'language', description: 'Translate to French' }, + { id: 'translate-de', name: 'German', icon: 'language', description: 'Translate to German' }, + { + id: 'translate-ja', + name: 'Japanese', + icon: 'language', + description: 'Translate to Japanese', + }, + { + id: 'translate-zh', + name: 'Chinese', + icon: 'language', + description: 'Translate to Chinese', + }, + ], + }, + { id: 'print', name: 'Print', icon: 'print', description: 'Print conversation' }, + { + id: 'advanced', + name: 'Advanced Settings', + icon: 'tune', + description: 'Advanced configuration options', + disabled: true, + }, +]; + +// Lucide names for the Wind renderer; both spellings are accepted, so the set uses each. +const LUCIDE_FOR_MATERIAL_ICON: Record = { + download: 'download', + picture_as_pdf: 'file-text', + description: 'FileType', + data_object: 'braces', + text_snippet: 'file-text', + share: 'share-2', + translate: 'languages', + language: 'globe', + print: 'Printer', + tune: 'sliders-horizontal', +}; + +const withLucideIcons = ( + actions: AutopilotChatCustomHeaderAction[] +): AutopilotChatCustomHeaderAction[] => + actions.map(({ icon, children, ...action }) => ({ + ...action, + ...(icon && { icon: LUCIDE_FOR_MATERIAL_ICON[icon] }), + ...(children && { + children: children.map(({ icon: childIcon, ...child }) => ({ + ...child, + ...(childIcon && { icon: LUCIDE_FOR_MATERIAL_ICON[childIcon] }), + })), + }), + })); + +/** + * The same actions for the Wind renderer, which takes lucide icon names (kebab-case or the + * PascalCase export name) where Material takes Material Icons names. + */ +export const WIND_CUSTOM_HEADER_ACTIONS = withLucideIcons(MATERIAL_CUSTOM_HEADER_ACTIONS); + +/** Reply the stories send when a custom header action is clicked. */ +export function customHeaderActionReply(action: AutopilotChatCustomHeaderAction): string { + switch (action.id) { + case 'export-pdf': + return '📄 **Exporting as PDF...**\n\nYour conversation has been exported to a PDF file.'; + case 'export-docx': + return '📝 **Exporting as DOCX...**\n\nYour conversation has been exported to a Word document.'; + case 'export-json': + return '💾 **Exporting as JSON...**\n\nYour conversation has been exported as JSON data.'; + case 'export-txt': + return '📋 **Exporting as Text...**\n\nYour conversation has been exported as plain text.'; + case 'share': + return '🔗 **Share Conversation**\n\nA shareable link has been generated:\n`https://example.com/chat/abc123`'; + case 'translate-es': + return '🇪🇸 **Traduciendo al español...**\n\nSu conversación ha sido traducida al español.'; + case 'translate-fr': + return '🇫🇷 **Traduction en français...**\n\nVotre conversation a été traduite en français.'; + case 'translate-de': + return '🇩🇪 **Übersetzung ins Deutsche...**\n\nIhre Unterhaltung wurde ins Deutsche übersetzt.'; + case 'translate-ja': + return '🇯🇵 **日本語に翻訳中...**\n\n会話が日本語に翻訳されました。'; + case 'translate-zh': + return '🇨🇳 **正在翻译成中文...**\n\n您的对话已翻译成中文。'; + case 'print': + return '🖨️ **Print Conversation**\n\nPreparing conversation for printing...'; + default: + return `✅ Action triggered: **${action.name}** (ID: ${action.id})`; + } +} + +export const STORY_SUGGESTIONS: AutopilotChatSuggestion[] = [ + { label: 'What can you do?', prompt: 'What can you do?' }, + { label: 'Show me an example', prompt: 'Show me an example' }, + { label: 'Help me with code', prompt: 'Help me with code' }, +]; + +export const STORY_LOADING_MESSAGES = [ + 'Thinking...', + 'Processing your request...', + 'Analyzing...', + 'Working on it...', +]; + +export const STORY_FIRST_RUN_EXPERIENCE = { + title: 'Welcome to UiPath Autopilot', + description: "I'm here to help you build, understand, and troubleshoot your automation projects.", + suggestions: [ + { + label: 'New process', + prompt: + 'Can you provide a detailed step-by-step guide on how to create a new automation process?', + }, + { + label: 'Debug workflow', + prompt: + "I'm having trouble with my workflow execution. Can you explain debugging techniques?", + }, + { label: 'Studio updates', prompt: 'What are the latest features in UiPath Studio?' }, + ], +}; + +export function storyHistoryItems(now = Date.now()): AutopilotChatHistory[] { + return [ + { id: '1', name: 'Previous Conversation 1', timestamp: new Date(now - 86400000).toISOString() }, + { + id: '2', + name: 'Previous Conversation 2', + timestamp: new Date(now - 172800000).toISOString(), + }, + { + id: '3', + name: 'Previous Conversation 3', + timestamp: new Date(now - 259200000).toISOString(), + }, + ]; +} + +/* ------------------------------------------------------------------ */ +/* Messages and payloads */ +/* ------------------------------------------------------------------ */ + +export const SAMPLE_CONVERSATION: AutopilotChatMessage[] = [ + { + id: '1', + role: AutopilotChatRole.User, + content: 'How do I create a workflow that extracts data from invoices?', + created_at: '22-10-2022', + widget: '', + }, + { + id: '2', + role: AutopilotChatRole.Assistant, + content: + 'To extract data from invoices, you can use UiPath Document Understanding. Here\'s a step-by-step approach:\n\n1. Install the Document Understanding package\n2. Create a new workflow and add the "Digitize Document" activity\n3. Configure it to use ML skills\n4. Use the "Data Extraction" activity\n5. Validate the extracted data', + created_at: '23-10-2022', + widget: '', + }, +]; + +export const STREAMING_REPLY = + 'Here is a streamed reply. It arrives **word by word**, the way a model response does, so you can watch the renderer follow the stream.\n\n- First point\n- Second point\n\nDone.'; + +export const CODE_BLOCK_MESSAGE = `Here's a TypeScript example with long lines to test wrapping: + +\`\`\`typescript +// This is a long comment line that should wrap properly when displayed in the code block to test the wrapping functionality +interface UserProfile { + id: number; + username: string; + email: string; + firstName: string; + lastName: string; + createdAt: Date; + updatedAt: Date; + preferences: { theme: 'light' | 'dark'; language: string; notifications: boolean }; +} + +function processUserData(user: UserProfile, additionalMetadata: Record): string { + const fullName = \`\${user.firstName} \${user.lastName}\`; + const metadataString = JSON.stringify(additionalMetadata, null, 2); + + return \`User: \${fullName} (\${user.email}) - Created: \${user.createdAt.toISOString()} - Metadata: \${metadataString}\`; +} + +const exampleUser: UserProfile = { + id: 12345, + username: 'john_doe_with_a_very_long_username_that_should_test_wrapping', + email: 'john.doe.with.a.very.long.email.address@example-domain-name.com', + firstName: 'John', + lastName: 'Doe', + createdAt: new Date(), + updatedAt: new Date(), + preferences: { theme: 'dark', language: 'en-US', notifications: true } +}; + +console.log(processUserData(exampleUser, { source: 'web', ipAddress: '192.168.1.1', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' })); +\`\`\``; + +export const HTML_PREVIEW_MESSAGE = `Here's an HTML example with live preview: + +\`\`\`html + + + + Hello World + + + +

Hello, World!

+

This is an HTML preview example.

+ + +\`\`\``; + +export const MARKDOWN_CORPUS = `# Heading 1 +## Heading 2 +### Heading 3 + +A paragraph with **bold**, _italic_, ~~strikethrough~~, \`inline code\` and a [link](https://www.uipath.com). + +> A blockquote that spans a couple of lines to check wrapping and the quote rule. + +1. Ordered item +2. Another item + - Nested bullet + - Another nested bullet + +| Column | Value | +| --- | --- | +| Robots | 12 | +| Queues | 3 | + +Inline math such as $$E = mc^2$$ and a block: + +$$ +\\int_0^1 x^2 \\, dx = \\frac{1}{3} +$$ + +![An image stays a link](https://www.uipath.com/favicon.ico) + +--- + +Footnote reference[^1]. + +[^1]: The footnote text.`; + +export const RESPONSE_ACTIONS: AutopilotChatMessageAction[] = [ + { + name: 'copy-action', + label: 'Copy to Clipboard', + icon: 'content_copy', + eventName: 'copy-custom', + }, + { + name: 'regenerate-action', + label: 'Regenerate', + icon: 'refresh', + eventName: 'regenerate-custom', + }, +]; + +export const CITATION_RESPONSE = { + content: + 'According to research, the sky is blue due to Rayleigh scattering. See references below for more information.', + widget: 'default', + contentParts: [ + { + text: 'According to research, the sky is blue due to Rayleigh scattering. ', + citations: [{ id: 1, title: 'Why is the Sky Blue?', url: 'https://example.com/sky' }], + }, + { + text: 'See references below for more information.', + citations: [{ id: 2, title: 'Atmospheric Science', url: 'https://example.com/atmosphere' }], + }, + ], +} satisfies Partial; + +/** Streamed with `contentPartChunk`, one entry per chunk. */ +export const STREAMED_CITATION_CHUNKS: ContentPartChunk[] = [ + { index: 0, text: 'Based on the search results' }, + { index: 0, text: ", I'll" }, + { index: 0, text: ' create a comprehensive' }, + { index: 0, text: ' summary about UiPath with' }, + { index: 0, text: ' citations:\n\n##' }, + { index: 0, text: ' Company Background' }, + { index: 0, text: '\n- ' }, + { index: 1, text: '**UiPath Inc**' }, + { index: 1, text: '. is a global' }, + { index: 1, text: ' software company that was' }, + { index: 1, text: ' founded in ' }, + { index: 1, text: '_Bucharest, Romania_' }, + { index: 1, text: ', by' }, + { index: 1, text: ' **Daniel Dines**' }, + { + index: 1, + text: '', + citation: { id: 1, title: 'UiPath - Wikipedia', url: 'https://en.wikipedia.org/wiki/UiPath' }, + }, + { index: 2, text: '. ' }, + { index: 3, text: 'The' }, + { index: 3, text: ' company starte' }, + { index: 3, text: 'd its journey' }, + { index: 3, text: ' in 2005' }, + { index: 3, text: ' and has' }, + { index: 3, text: ' grown to become a leading' }, + { index: 3, text: ' enterprise automation software ven' }, + { + index: 3, + text: 'dor', + citation: { id: 1, title: 'UiPath - Wikipedia', url: 'https://en.wikipedia.org/wiki/UiPath' }, + }, + { + index: 3, + text: '', + citation: { id: 2, title: 'UiPath, Inc. (PATH)', url: 'https://ir.uipath.com/' }, + }, + { index: 4, text: '. \n\n## Core' }, + { index: 4, text: ' Business' }, + { index: 4, text: '\nU' }, + { index: 4, text: 'iPath special' }, + { index: 4, text: 'izes in several' }, + { index: 4, text: ' key areas:\n\n1' }, + { index: 4, text: '. **Rob' }, + { index: 4, text: 'otic Process Automation' }, + { index: 4, text: ':**' }, + { index: 4, text: '\n' }, + { index: 5, text: 'R' }, + { index: 5, text: 'PA has' }, + { index: 5, text: ' revolutionized how work' }, + { index: 5, text: ' gets done globally' }, + { index: 5, text: ' by eliminating time' }, + { index: 5, text: '-consuming, repet' }, + { index: 5, text: 'itive tasks from employees' }, + { index: 5, text: "' worklo" }, + { + index: 5, + text: 'ads', + citation: { + id: 3, + title: 'Discovery Phase Guide', + download_url: 'https://ontheline.trincoll.edu/images/bookdown/sample-local-pdf.pdf', + page_number: 2, + }, + }, + { index: 6, text: '.' }, + { index: 6, text: '\n\n2. **' }, + { index: 6, text: 'Business Automation Platform**' }, + { index: 6, text: '\n' }, + { index: 7, text: 'The platform' }, + { index: 7, text: ' offers en' }, + { index: 7, text: 'd-to-en' }, + { index: 7, text: 'd process transformation' }, + { index: 7, text: ' capabilities on' }, + { index: 7, text: ' a single platform,' }, + { index: 7, text: ' featuring fully' }, + { index: 7, text: ' governed agentic' }, + { index: 7, text: ' automation that integ' }, + { index: 7, text: 'rates with existing systems' }, + { + index: 7, + text: '', + citation: { + id: 4, + title: 'UiPath Business Automation Platform | UiPath', + url: 'https://www.uipath.com/product', + }, + }, +]; + +export const RESOURCE_TOKEN_MESSAGE = `I will update [[resource-token:{"id":"var-email","type":"variable","icon":"mail","displayName":"email"}]] and [[resource-token:{"id":"var-long","type":"variable","icon":"data_object","displayName":"this_is_a_very_long_variable_name_that_should_test_truncation"}]] in the system, then notify [[resource-token:{"id":"var-username","type":"variable","icon":"person","displayName":"username"}]] when complete.`; + +/** Streamed with `content` chunks; tokens split across chunks exercise incomplete-token buffering. */ +export const RESOURCE_TOKEN_CHUNKS = [ + 'I will update the ', + '[[resource-token:', + '{"id":"var-', + 'email","type":"variable",', + '"icon":"mail","displayName":"email"}]]', + ' variable in the system.\n\n', + "Then I'll notify ", + '[[resource-token:{', + '"id":"var-username",', + '"type":"variable","icon":"person",', + '"displayName":"username"}]]', + ' when the process is complete.\n\n', + "I'll also update ", + '[[resource-token:{', + '"id":"var-long","type":"variable",', + '"icon":"data_object","displayName":', + '"this_is_a_very_long_variable_name_that_should_test_truncation"}]]', + ' for testing.\n\n', + "Finally, I'll log the result to ", + '[[resource-token:{"id":"var-log","type":"variable","icon":"description","displayName":"activity_log"}]]', + '.', +]; + +export type StoryToolCallDisplayMode = 'FullTrace' | 'InputsAndOutputs' | 'ToolNameOnly'; + +/** A nested agent trace, used by the FullTrace tool call and the tree message. */ +export function storyTraceSpan(now = Date.now()) { + const end = new Date(now).toISOString(); + + return { + key: 'multi-web-search', + name: 'Tool call - Multi_Web_Search', + data: { + id: 'root-span', + name: 'Tool call - Multi_Web_Search', + startTime: new Date(now - 25000).toISOString(), + endTime: end, + status: 'ok', + type: 'toolCall', + attributes: { + toolName: 'Multi_Web_Search', + toolType: 'Agent', + arguments: { query: 'what are trending right now' }, + result: { result: 'Multi web search completed successfully.' }, + }, + }, + children: [ + { + key: 'autonomous-web-search', + name: 'Autonomous Web Search', + data: { + id: 'agentTool-1', + name: 'Autonomous Web Search', + startTime: new Date(now - 25000).toISOString(), + endTime: end, + status: 'ok', + type: 'agentTool', + }, + children: [ + { + key: 'agent-run-1', + name: 'Agent run - Autonomous Web Search', + data: { + id: 'agentRun-1', + name: 'Agent run - Autonomous Web Search', + startTime: new Date(now - 23000).toISOString(), + endTime: end, + status: 'ok', + type: 'agentRun', + }, + children: [ + { + key: 'tool-call-web-search-1', + name: 'Tool call - Web_Search', + data: { + id: 'ws1', + name: 'Tool call - Web_Search', + type: 'toolCall', + status: 'ok', + startTime: new Date(now - 21000).toISOString(), + endTime: new Date(now - 17000).toISOString(), + }, + children: [], + }, + { + key: 'tool-call-web-search-2', + name: 'Tool call - Web_Search', + data: { + id: 'ws2', + name: 'Tool call - Web_Search', + type: 'toolCall', + status: 'ok', + startTime: new Date(now - 16000).toISOString(), + endTime: new Date(now - 10000).toISOString(), + }, + children: [], + }, + ], + }, + ], + }, + ], + }; +} + +/** `meta` for an `apollo-agents-tool-call` message. */ +export function storyToolCallMeta(displayMode: StoryToolCallDisplayMode, now = Date.now()) { + if (displayMode === 'FullTrace') { + return { displayMode, span: storyTraceSpan(now) }; + } + + return { + displayMode, + input: { + provider: 'GoogleCustomSearch', + query: 'most interesting scientific fact discovered recently 2025', + num: 5, + }, + output: { + results: [ + { + snippet: 'Breaking science news and articles...', + title: 'ScienceDaily: Latest Research News', + url: 'https://www.sciencedaily.com/', + }, + ], + }, + isError: false, + startTime: new Date(now - 2500).toISOString(), + endTime: new Date(now).toISOString(), + toolName: 'Web_Search', + }; +} + +/** Older pages for the paginated history scenario, newest last. */ +export function storyHistoryPage(page: number, pageSize = 6): AutopilotChatMessage[] { + return Array.from({ length: pageSize }, (_, i) => { + const n = page * pageSize + (pageSize - i); + const role = n % 2 === 0 ? AutopilotChatRole.Assistant : AutopilotChatRole.User; + + return { + id: `history-${n}`, + role, + content: + role === AutopilotChatRole.User + ? `Question ${n}: what happened in run ${n}?` + : `Answer ${n}: run ${n} finished in ${(n % 7) + 1} minutes with no faulted jobs.`, + created_at: new Date(Date.now() - n * 60000).toISOString(), + widget: 'apollo-markdown-renderer', + }; + }).reverse(); +} + +/* ------------------------------------------------------------------ */ +/* Mock integrations */ +/* ------------------------------------------------------------------ */ + +export interface StoryResourceManagerOptions { + paginationEnabled?: boolean; + pageSize?: number; +} + +/** In-memory resource manager with variables and files, optionally paginated. */ +export function createStoryResourceManager({ + paginationEnabled = true, + pageSize = 10, +}: StoryResourceManagerOptions = {}): AutopilotChatResourceManager { + const topLevelItems = [ + { + id: 'variables', + type: 'category', + displayName: 'Variables', + icon: 'data_object', + hasNestedResources: true, + }, + { + id: 'files', + type: 'category', + displayName: 'This is an intentionally very very long category name', + icon: 'folder', + hasNestedResources: true, + }, + { + id: 'current-user', + type: 'context', + displayName: 'This is an intentionally very very long resource name', + icon: 'person', + tooltip: 'The currently logged in user context', + hasNestedResources: false, + }, + { + id: 'current-date', + type: 'context', + displayName: 'Current Date', + icon: 'calendar_today', + tooltip: "Today's date in ISO format", + hasNestedResources: false, + }, + ]; + + const variableIcons = [ + 'text_fields', + 'mail', + 'numbers', + 'toggle_on', + 'settings', + 'person', + 'calendar_today', + 'list', + 'data_object', + 'code', + ] as const; + const variableItems = Array.from({ length: 50 }, (_, i) => ({ + id: `var-${i}`, + type: 'variable', + displayName: `variable_${i.toString().padStart(2, '0')}`, + icon: variableIcons[i % variableIcons.length] as string, + tooltip: `Variable ${i} - This is a sample variable for testing pagination`, + })); + + const fileIcons = ['description', 'table_chart', 'picture_as_pdf', 'image', 'code', 'folder']; + const fileExtensions = ['.json', '.csv', '.pdf', '.png', '.ts', '.txt', '.xml', '.yaml']; + const fileItems = Array.from({ length: 30 }, (_, i) => ({ + id: `file-${i}`, + type: 'file', + displayName: `document_${i.toString().padStart(2, '0')}${fileExtensions[i % fileExtensions.length]}`, + icon: fileIcons[i % fileIcons.length] as string, + tooltip: `File ${i} - Sample file for testing pagination`, + })); + + const paginate = (items: T[], skip: number) => { + if (!paginationEnabled) { + return { items, done: true }; + } + + return { items: items.slice(skip, skip + pageSize), done: skip + pageSize >= items.length }; + }; + + const delay = () => new Promise((resolve) => setTimeout(resolve, 300)); + + return { + getTopLevelResources: () => topLevelItems, + getNestedResources: async (resourceId, options) => { + await delay(); + + let items = + resourceId === 'variables' ? variableItems : resourceId === 'files' ? fileItems : []; + + if (options?.searchText) { + const search = options.searchText.toLowerCase(); + items = items.filter((item) => item.displayName.toLowerCase().includes(search)); + } + + const result = paginate(items, options?.skip ?? 0); + console.log( + `[ResourceManager] getNestedResources: resourceId=${resourceId}, skip=${options?.skip ?? 0}, returned=${result.items.length}, done=${result.done}` + ); + + return result; + }, + globalSearch: async (options) => { + await delay(); + + const skip = options.skip ?? 0; + let allItems: AutopilotChatResourceItem[] = [...variableItems, ...fileItems]; + + if (options.searchText) { + const search = options.searchText.toLowerCase(); + + if (skip === 0) { + allItems = [ + ...topLevelItems.filter((item) => item.displayName.toLowerCase().includes(search)), + ...allItems, + ]; + } + allItems = allItems.filter((item) => item.displayName.toLowerCase().includes(search)); + } + + const result = paginate(allItems, skip); + console.log( + `[ResourceManager] globalSearch: searchText="${options.searchText ?? ''}", skip=${skip}, returned=${result.items.length}, done=${result.done}` + ); + + return result; + }, + onResourceSelected: (item) => { + console.log('Resource selected:', item.displayName); + }, + }; +} + +/** A plain DOM settings page for `settingsRenderer`. */ +export function createStorySettingsRenderer() { + return (container: HTMLElement) => { + // Create custom settings panel + const settingsDiv = document.createElement('div'); + settingsDiv.style.padding = '24px'; + settingsDiv.style.color = 'var(--color-foreground)'; + + settingsDiv.innerHTML = ` +

Chat Settings

+ +
+

Appearance

+ + +
+ +
+

Behavior

+ + +
+ +
+

Advanced

+ + +
+ +
+ + +
+ `; + + // Add event listeners + const darkModeCheckbox = settingsDiv.querySelector('#darkMode') as HTMLInputElement; + darkModeCheckbox?.addEventListener('change', (e) => { + console.log('Dark mode:', (e.target as HTMLInputElement).checked); + }); + + const saveButton = settingsDiv.querySelector('#saveSettings'); + saveButton?.addEventListener('click', () => { + console.log('Settings saved:', { + darkMode: (settingsDiv.querySelector('#darkMode') as HTMLInputElement).checked, + compactMode: (settingsDiv.querySelector('#compactMode') as HTMLInputElement).checked, + autoScroll: (settingsDiv.querySelector('#autoScroll') as HTMLInputElement).checked, + soundEnabled: (settingsDiv.querySelector('#soundEnabled') as HTMLInputElement).checked, + maxMessages: (settingsDiv.querySelector('#maxMessages') as HTMLInputElement).value, + responseDelay: (settingsDiv.querySelector('#responseDelay') as HTMLInputElement).value, + }); + console.log('Settings saved successfully!'); + }); + + const resetButton = settingsDiv.querySelector('#resetSettings'); + resetButton?.addEventListener('click', () => { + (settingsDiv.querySelector('#darkMode') as HTMLInputElement).checked = false; + (settingsDiv.querySelector('#compactMode') as HTMLInputElement).checked = true; + (settingsDiv.querySelector('#autoScroll') as HTMLInputElement).checked = true; + (settingsDiv.querySelector('#soundEnabled') as HTMLInputElement).checked = false; + (settingsDiv.querySelector('#maxMessages') as HTMLInputElement).value = '100'; + (settingsDiv.querySelector('#responseDelay') as HTMLInputElement).value = '0'; + console.log('Settings reset to defaults'); + }); + + container.appendChild(settingsDiv); + }; +} diff --git a/packages/apollo-react/src/chat/stories/chat-story-frame.ts b/packages/apollo-react/src/chat/stories/chat-story-frame.ts new file mode 100644 index 0000000000..655c4606f3 --- /dev/null +++ b/packages/apollo-react/src/chat/stories/chat-story-frame.ts @@ -0,0 +1,46 @@ +import type React from 'react'; + +/** + * The Embedded framing shared by the chat stories: a popup panel docked to the bottom right of the + * frame, as a host application would place it. `slot` docks further panels to the left. + */ +export const EMBEDDED_PANEL = { width: 400, height: 600, inset: 24, gap: 24 } as const; + +const panelRight = (slot: number) => + EMBEDDED_PANEL.inset + slot * (EMBEDDED_PANEL.width + EMBEDDED_PANEL.gap); + +export function embeddedPanelStyle(slot = 0): React.CSSProperties { + return { + position: 'fixed', + bottom: EMBEDDED_PANEL.inset, + right: panelRight(slot), + width: EMBEDDED_PANEL.width, + height: EMBEDDED_PANEL.height, + boxShadow: 'var(--shadow-xl, 0 12px 32px rgba(0, 0, 0, 0.35))', + borderRadius: 12, + overflow: 'hidden', + zIndex: 1000, + background: 'var(--color-background)', + }; +} + +/** A caption just above a docked panel, used when several renderers are compared. */ +export function embeddedPanelLabelStyle(slot = 0): React.CSSProperties { + return { + position: 'fixed', + bottom: EMBEDDED_PANEL.inset + EMBEDDED_PANEL.height + 8, + right: panelRight(slot), + width: EMBEDDED_PANEL.width, + zIndex: 1000, + }; +} + +/** Where a host's "open chat" launcher sits for a docked panel: its bottom-right corner. */ +export function embeddedLauncherStyle(slot = 0): React.CSSProperties { + return { + position: 'fixed', + bottom: EMBEDDED_PANEL.inset, + right: panelRight(slot), + zIndex: 1000, + }; +} diff --git a/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts b/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts new file mode 100644 index 0000000000..60f591eb47 --- /dev/null +++ b/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts @@ -0,0 +1,352 @@ +/** + * Scripted service traffic for the chat playground. Each scenario appends to the current + * transcript unless it declares `emptyTranscript`, and is replayed into every chat on the page on + * the same timers so renderers can be compared under identical input. + */ +import type { SupportedLocale } from '../../i18n'; +import { + AGENTS_TOOL_CALL_RENDERER, + APOLLO_CHAT_TREE_RENDERER, + type ApChatTheme, + type AutopilotChatConfiguration, + AutopilotChatEvent, + type AutopilotChatFileInfo, + type AutopilotChatMessage, + type AutopilotChatService, + type ContentPartChunk, + DEFAULT_MESSAGE_RENDERER, +} from '../service'; +import { + CITATION_RESPONSE, + CODE_BLOCK_MESSAGE, + HTML_PREVIEW_MESSAGE, + MARKDOWN_CORPUS, + RESOURCE_TOKEN_CHUNKS, + RESOURCE_TOKEN_MESSAGE, + RESPONSE_ACTIONS, + SAMPLE_CONVERSATION, + STREAMED_CITATION_CHUNKS, + STREAMING_REPLY, + type StoryToolCallDisplayMode, + storyHistoryPage, + storyToolCallMeta, + storyTraceSpan, +} from './chat-story-fixtures'; + +export interface ScenarioContext { + service: AutopilotChatService; + /** Delay between streamed chunks and scripted turns. */ + delayMs: number; + /** Sends a user turn that neither triggers the auto reply nor is mirrored to the other chat. */ + sendScriptedRequest: (request: { + content: string; + attachments?: AutopilotChatFileInfo[]; + }) => void; + /** `setTimeout` that is cancelled when the scenario changes or the story unmounts. */ + schedule: (run: () => void, delayMs: number) => void; + onCleanup: (cleanup: () => void) => void; + log: (event: string, data?: unknown) => void; +} + +export interface ChatStoryScenario { + label: string; + description: string; + /** Starts a new chat first; only for scenarios that need an empty transcript. */ + emptyTranscript?: boolean; + run: (context: ScenarioContext) => void; +} + +type Response = Parameters[0]; + +/** + * `patchConfig` re-runs `initialize`, which re-applies the initial theme, locale, model and agent + * mode; pass the current ones so a config patch does not reset what the user picked. + */ +export function patchConfigInPlace( + service: AutopilotChatService, + config: Partial +) { + service.patchConfig({ + theme: service.getTheme() as ApChatTheme, + locale: service.getLocale() as SupportedLocale, + selectedModel: service.getSelectedModel(), + selectedAgentMode: service.getAgentMode(), + ...config, + }); +} + +const respond = (service: AutopilotChatService, response: Partial) => + service.sendResponse({ + widget: DEFAULT_MESSAGE_RENDERER, + created_at: new Date().toISOString(), + ...response, + }); + +/** Streams `chunks` into one assistant message, one chunk per `delayMs`. */ +export function streamChunks( + context: ScenarioContext, + chunks: Array, + startAfterMs = 0 +) { + const id = `stream-${crypto.randomUUID()}`; + + chunks.forEach((chunk, index) => { + context.schedule( + () => + respond(context.service, { + id, + stream: true, + done: index === chunks.length - 1, + ...(typeof chunk === 'string' ? { content: chunk } : { contentPartChunk: chunk }), + }), + startAfterMs + index * context.delayMs + ); + }); + + return startAfterMs + chunks.length * context.delayMs; +} + +/** Splits text into word chunks that keep their trailing space, for streaming. */ +export const splitWords = (text: string) => text.split(/(?<= )/); + +const STORY_ATTACHMENTS: AutopilotChatFileInfo[] = [ + { + name: 'invoices-week-27.pdf', + type: 'application/pdf', + size: 182_400, + lastModified: Date.now(), + content: { text: null, binary: null, base64: null }, + }, + { + name: 'vendors.csv', + type: 'text/csv', + size: 2_048, + lastModified: Date.now(), + content: { text: 'vendor,amount\nAcme,1240', binary: null, base64: null }, + }, +]; + +const TOOL_CALL_MODES: StoryToolCallDisplayMode[] = [ + 'ToolNameOnly', + 'InputsAndOutputs', + 'FullTrace', +]; + +export const CHAT_STORY_SCENARIOS = { + none: { + label: 'None', + description: 'No scripted traffic.', + run: () => {}, + }, + 'first-run': { + label: 'First-run experience', + description: + 'Starts a new chat so the first-run experience shows (turn on the firstRunExperience arg).', + emptyTranscript: true, + run: () => {}, + }, + conversation: { + label: 'Short conversation', + description: 'Appends one user turn and one assistant reply.', + run: (context) => { + const [question, answer] = SAMPLE_CONVERSATION as [ + AutopilotChatMessage, + AutopilotChatMessage, + ]; + context.sendScriptedRequest({ content: question.content }); + context.schedule( + () => respond(context.service, { content: answer.content }), + context.delayMs + ); + }, + }, + streaming: { + label: 'Streaming reply', + description: 'Appends a user turn, then streams the reply word by word.', + run: (context) => { + context.sendScriptedRequest({ content: 'Stream me a reply, please.' }); + streamChunks(context, splitWords(STREAMING_REPLY), context.delayMs); + }, + }, + 'long-history': { + label: 'Long history with pagination', + description: + 'Starts a new chat with paginatedMessages on and the latest page loaded. Scrolling to the top publishes ConversationLoadMore, answered with three older pages.', + emptyTranscript: true, + run: (context) => { + const { service } = context; + let page = 1; + patchConfigInPlace(service, { paginatedMessages: true }); + service.setConversation(storyHistoryPage(0)); + const unsubscribe = service.on(AutopilotChatEvent.ConversationLoadMore, () => { + const current = page++; + context.schedule( + () => service.prependOlderMessages(storyHistoryPage(current), current >= 3), + 600 + ); + }); + context.onCleanup(() => { + unsubscribe(); + patchConfigInPlace(service, { paginatedMessages: false }); + }); + }, + }, + citations: { + label: 'Citations', + description: + 'Appends a reply with content-part citations, then a streamed reply with citations.', + run: (context) => { + context.sendScriptedRequest({ content: 'Why is the sky blue? And tell me about UiPath.' }); + context.schedule(() => respond(context.service, CITATION_RESPONSE), context.delayMs); + streamChunks(context, STREAMED_CITATION_CHUNKS, context.delayMs * 3); + }, + }, + markdown: { + label: 'Markdown corpus', + description: + 'Appends a reply covering headings, lists, tables, quotes, math, images and footnotes.', + run: (context) => { + context.sendScriptedRequest({ content: 'Show me every markdown feature.' }); + context.schedule( + () => respond(context.service, { content: MARKDOWN_CORPUS }), + context.delayMs + ); + }, + }, + code: { + label: 'Code and HTML preview', + description: 'Appends a long TypeScript block and an HTML block with preview.', + run: (context) => { + context.sendScriptedRequest({ content: 'Show me some code.' }); + context.schedule( + () => respond(context.service, { content: CODE_BLOCK_MESSAGE }), + context.delayMs + ); + context.schedule( + () => respond(context.service, { content: HTML_PREVIEW_MESSAGE }), + context.delayMs * 2 + ); + }, + }, + 'resource-tokens': { + label: 'Resource tokens', + description: 'Appends a reply with resource tokens, then streams tokens split across chunks.', + run: (context) => { + context.sendScriptedRequest({ content: 'Which variables will you touch?' }); + context.schedule( + () => respond(context.service, { content: RESOURCE_TOKEN_MESSAGE }), + context.delayMs + ); + streamChunks(context, RESOURCE_TOKEN_CHUNKS, context.delayMs * 3); + }, + }, + 'tool-call': { + label: 'Tool calls', + description: + 'Appends one tool call per display mode: tool name only, inputs and outputs, full trace.', + run: (context) => { + context.sendScriptedRequest({ content: 'Search the web for me.' }); + TOOL_CALL_MODES.forEach((displayMode, index) => { + context.schedule( + () => + respond(context.service, { + content: 'Tool call response', + widget: AGENTS_TOOL_CALL_RENDERER, + meta: storyToolCallMeta(displayMode), + }), + context.delayMs * (index + 1) + ); + }); + }, + }, + tree: { + label: 'Tree message', + description: 'Appends a trace rendered as a tree.', + run: (context) => { + context.sendScriptedRequest({ content: 'Show me the agent trace.' }); + context.schedule( + () => + respond(context.service, { + content: 'Trace', + widget: APOLLO_CHAT_TREE_RENDERER, + meta: { span: storyTraceSpan() }, + }), + context.delayMs + ); + }, + }, + attachments: { + label: 'Attachments', + description: 'Appends a user turn with two attached files and a reply.', + run: (context) => { + context.sendScriptedRequest({ + content: "Here's the invoice batch for this week. Which ones are ready?", + attachments: STORY_ATTACHMENTS, + }); + context.schedule( + () => + respond(context.service, { + content: 'Two of the three invoices are ready for approval.', + }), + context.delayMs + ); + }, + }, + actions: { + label: 'Message actions', + description: + 'Appends a reply with custom actions (logged as copy-custom and regenerate-custom) and a reply with actions disabled.', + run: (context) => { + const { service } = context; + const unsubscribes = RESPONSE_ACTIONS.map((action) => + service.on(action.eventName as AutopilotChatEvent, (data) => + context.log(action.eventName ?? '', data) + ) + ); + context.onCleanup(() => { + for (const unsubscribe of unsubscribes) unsubscribe(); + }); + respond(service, { + content: 'Here is a response with custom actions. Try the buttons below!', + actions: RESPONSE_ACTIONS, + }); + context.schedule( + () => respond(service, { content: 'Message with no actions', disableActions: true }), + context.delayMs + ); + }, + }, + loading: { + label: 'Loading message', + description: + 'Appends a user turn and shows a loading message for four seconds before replying.', + run: (context) => { + const { service } = context; + context.sendScriptedRequest({ content: 'Take your time.' }); + service.setLoadingMessage('Searching the knowledge base...'); + context.onCleanup(() => service.setLoadingMessage('')); + context.schedule(() => { + service.setLoadingMessage(''); + respond(service, { content: 'Found it.' }); + }, 4000); + }, + }, + error: { + label: 'Error', + description: 'Shows a chat error (markdown supported) until the scenario changes.', + run: (context) => { + context.service.setError('An error occurred. **Please try again.**'); + context.onCleanup(() => context.service.clearError()); + }, + }, + warning: { + label: 'Warning', + description: 'Shows a chat warning until the scenario changes.', + run: (context) => { + context.service.setError('This is a warning message.', 'warn'); + context.onCleanup(() => context.service.clearError()); + }, + }, +} satisfies Record; + +export type ChatStoryScenarioId = keyof typeof CHAT_STORY_SCENARIOS; diff --git a/packages/apollo-react/src/chat/styles/tailwind.chat.css b/packages/apollo-react/src/chat/styles/tailwind.chat.css index 53943f7743..03451cc04f 100644 --- a/packages/apollo-react/src/chat/styles/tailwind.chat.css +++ b/packages/apollo-react/src/chat/styles/tailwind.chat.css @@ -9,8 +9,11 @@ @import "@uipath/apollo-wind/tailwind.css"; -/* Scan chat source files for inline Tailwind class usage */ +/* Scan chat source files for inline Tailwind class usage, but not the stories and tests, which + are not published */ @source "../../chat/**/*.{ts,tsx}"; +@source not "../../chat/stories"; +@source not "../../chat/**/*.test.{ts,tsx}"; /* Scan apollo-wind compiled components for CVA class strings */ @source "../../../node_modules/@uipath/apollo-wind/dist/**/*.js"; diff --git a/packages/apollo-react/src/chat/wind/WindChatPlaceholder.tsx b/packages/apollo-react/src/chat/wind/WindChatPlaceholder.tsx deleted file mode 100644 index 58ecd6bf02..0000000000 --- a/packages/apollo-react/src/chat/wind/WindChatPlaceholder.tsx +++ /dev/null @@ -1,10 +0,0 @@ -// Stands in for the Wind renderer until it ships, so `renderer: 'wind'` fails visibly rather than -// rendering nothing. -export function WindChatPlaceholder() { - return ( -
- The Wind chat renderer is not available yet. Set renderer to 'material' in the chat - configuration to use the Material renderer. -
- ); -} diff --git a/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx b/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx new file mode 100644 index 0000000000..2e88489487 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx @@ -0,0 +1,207 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; + +import { resolveChatRenderer } from '../headless/renderer-registry'; +// The package entry, so the renderer registrations run as they do for consumers. +import { + ApChat, + type AutopilotChatConfiguration, + AutopilotChatEvent, + AutopilotChatMode, + AutopilotChatPreHookAction, + AutopilotChatService, +} from '../index'; +import { WindChatRenderer } from './WindChatRenderer'; + +let instanceCounter = 0; +const createService = (config: Partial = {}) => + AutopilotChatService.Instantiate({ + instanceName: `wind-renderer-test-${++instanceCounter}`, + config: { mode: AutopilotChatMode.FullScreen, ...config }, + }); + +const getRoot = () => document.querySelector('[data-slot="wind-chat"]'); + +describe('WindChatRenderer', () => { + it('is the renderer registered as wind and the /chat default', () => { + expect(resolveChatRenderer({ renderer: 'wind' }, 'material')).toBe(WindChatRenderer); + expect(resolveChatRenderer(undefined, 'wind')).toBe(WindChatRenderer); + }); + + it.each([ + 'light', + 'dark', + 'light-hc', + 'dark-hc', + 'future-light', + 'future-dark', + ] as const)('sets the %s theme class', (theme) => { + render(); + + expect(getRoot()).toHaveClass(theme); + }); + + it('follows theme changes without remounting', () => { + const service = createService(); + const { rerender } = render(); + const root = getRoot(); + + rerender(); + + expect(getRoot()).toBe(root); + expect(root).toHaveClass('dark-hc'); + expect(root).not.toHaveClass('light'); + }); + + it('maps spacing.compactMode to data-density and a shorter header', () => { + const service = createService(); + render(); + + expect(getRoot()).not.toHaveAttribute('data-density'); + expect(screen.getByRole('banner')).toHaveClass('min-h-12'); + + act(() => service.patchConfig({ spacing: { compactMode: true } })); + + expect(getRoot()).toHaveAttribute('data-density', 'compact'); + expect(screen.getByRole('banner')).toHaveClass('min-h-10'); + }); + + it('renders the title, preview badge and override labels', () => { + const service = createService(); + render(); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Autopilot'); + expect(screen.getByText('Preview')).toBeInTheDocument(); + + act(() => { + service.setOverrideLabels({ title: 'Agent' }); + service.setDisabledFeatures({ preview: true }); + }); + + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Agent'); + expect(screen.queryByText('Preview')).toBeNull(); + }); + + it('renders nothing while closed and returns when opened', async () => { + const service = createService({ mode: AutopilotChatMode.Closed }); + render(); + + expect(getRoot()).toBeNull(); + + act(() => service.setChatMode(AutopilotChatMode.Embedded)); + // Embedded portals into config.embeddedContainer; without one it renders in place. + await waitFor(() => expect(getRoot()).toHaveAttribute('data-state', 'open')); + + act(() => service.setChatMode(AutopilotChatMode.Closed)); + expect(getRoot()).toHaveAttribute('data-state', 'closed'); + await waitFor(() => expect(getRoot()).toBeNull()); + }); + + it.each([ + [AutopilotChatMode.Embedded, { close: false, logo: false }], + [AutopilotChatMode.FullScreen, { close: true, logo: true }], + ])('keeps the %s header unchanged while it fades out', async (mode, expected) => { + const service = createService({ mode }); + render(); + const header = () => ({ + close: !!screen.queryByTestId('autopilot-chat-close'), + logo: !!document.querySelector('[data-slot="autopilot-logo"]'), + }); + expect(header()).toEqual(expected); + + act(() => service.setChatMode(AutopilotChatMode.Closed)); + + expect(getRoot()).toHaveAttribute('data-state', 'closed'); + expect(getRoot()).toHaveAttribute('inert'); + expect(header()).toEqual(expected); + await waitFor(() => expect(getRoot()).toBeNull()); + }); + + it('shows an explicit placeholder for SideBySide and no expand action', () => { + render(); + + expect(document.querySelector('[data-slot="wind-chat-side-by-side"]')).not.toBeNull(); + expect(screen.queryByTestId('autopilot-chat-toggle-fullscreen')).toBeNull(); + }); + + it('hides the composer region when read-only', () => { + const service = createService(); + render(); + + expect(document.querySelector('[data-slot="wind-chat-composer"]')).not.toBeNull(); + + act(() => service.setReadOnly(true)); + + expect(document.querySelector('[data-slot="wind-chat-composer"]')).toBeNull(); + expect(screen.queryByTestId('autopilot-chat-new-chat')).toBeNull(); + }); + + it('skips a header action when its pre-hook declines', async () => { + const service = createService({ + preHooks: { [AutopilotChatPreHookAction.NewChat]: () => Promise.resolve(false) }, + }); + const events: string[] = []; + service.on(AutopilotChatEvent.NewChat, () => events.push('newChat')); + render(); + + await userEvent.click(screen.getByTestId('autopilot-chat-new-chat')); + + expect(events).toEqual([]); + }); + + it('publishes leaf custom header actions and resolves their lucide icons', async () => { + const service = createService(); + const clicked: unknown[] = []; + service.on(AutopilotChatEvent.CustomHeaderActionClicked, (action) => clicked.push(action)); + service.setCustomHeaderActions([ + { id: 'share', name: 'Share', icon: 'Share2' }, + { id: 'pdf', name: 'PDF', icon: 'picture_as_pdf' }, + { id: 'export', name: 'Export', children: [{ id: 'export-pdf', name: 'PDF export' }] }, + ]); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'More actions' })); + const share = await screen.findByRole('menuitem', { name: 'Share' }); + await waitFor(() => expect(share.querySelector('svg.lucide')).not.toBeNull()); + expect( + screen + .getByRole('menuitem', { name: 'PDF' }) + .querySelector('[data-slot="service-icon-fallback"]') + ).not.toBeNull(); + await userEvent.click(share); + + expect(clicked).toEqual([{ id: 'share', name: 'Share', icon: 'Share2' }]); + }); + + it('does not publish a parent with an empty children list, like Material', async () => { + const service = createService(); + const clicked: unknown[] = []; + service.on(AutopilotChatEvent.CustomHeaderActionClicked, (action) => clicked.push(action)); + service.setCustomHeaderActions([{ id: 'empty', name: 'Empty parent', children: [] }]); + render(); + + await userEvent.click(screen.getByRole('button', { name: 'More actions' })); + await userEvent.click(await screen.findByRole('menuitem', { name: 'Empty parent' })); + + expect(clicked).toEqual([]); + }); + + it('shows the configured placeholder and disclaimer in the composer region', () => { + const service = createService(); + render(); + + act(() => + service.setOverrideLabels({ inputPlaceholder: 'Ask the agent', footerDisclaimer: 'Be kind' }) + ); + + expect(document.querySelector('[data-slot="wind-chat-composer"]')).toHaveTextContent( + 'Ask the agent' + ); + expect(screen.getByText('Be kind')).toBeInTheDocument(); + + act(() => service.setDisabledFeatures({ footer: true })); + + expect(screen.queryByText('Be kind')).toBeNull(); + }); +}); diff --git a/packages/apollo-react/src/chat/wind/WindChatRenderer.tsx b/packages/apollo-react/src/chat/wind/WindChatRenderer.tsx new file mode 100644 index 0000000000..02227fcb8b --- /dev/null +++ b/packages/apollo-react/src/chat/wind/WindChatRenderer.tsx @@ -0,0 +1,106 @@ +import { PortalContainerProvider, TooltipProvider } from '@uipath/apollo-wind'; +import React from 'react'; +import { createPortal } from 'react-dom'; + +import { type ChatLayout, resolveChatLayout } from '../headless/layout/chat-layout'; +import { useChatState } from '../headless/providers/chat-state-provider'; +import { useChatWidth } from '../headless/providers/chat-width-provider'; +import { useTheme } from '../headless/providers/theme-provider'; +import { AutopilotChatMode, CHAT_CONTAINER_ANIMATION_DURATION } from '../service'; +import { useOpenTransition } from './hooks/use-open-transition'; +import { EmbeddedLayout } from './layout/embedded-layout'; +import { FullScreenLayout } from './layout/full-screen-layout'; +import { SideBySidePlaceholder } from './layout/side-by-side-placeholder'; + +// Same footprint as the Material renderer per mode, so switching renderers does not move the host. +const ROOT_SIZE: Record, string> = { + [AutopilotChatMode.Embedded]: 'absolute inset-0', + [AutopilotChatMode.FullScreen]: + 'relative w-screen border-r border-b border-border-de-emp h-[calc(100vh-48px)] data-full-height:h-screen', + [AutopilotChatMode.SideBySide]: + 'relative w-[var(--chat-width)] border-r border-b border-border-de-emp h-[calc(100vh-48px)] data-full-height:h-screen', +}; + +function LayoutForMode({ layout }: { layout: ChatLayout }) { + switch (layout.mode) { + case AutopilotChatMode.FullScreen: + return ; + case AutopilotChatMode.SideBySide: + return ; + default: + return ; + } +} + +/** + * The Wind chat renderer. Mounted by `ChatRoot`, which provides the headless state. + */ +export function WindChatRenderer() { + const { theme } = useTheme(); + const { chatMode, disabledFeatures, readOnly, spacing, portalContainer } = useChatState(); + const { width } = useChatWidth(); + const { present, state } = useOpenTransition( + chatMode !== AutopilotChatMode.Closed, + CHAT_CONTAINER_ANIMATION_DURATION + ); + + // Keep showing the last open layout while the exit animation runs. + const lastOpenMode = React.useRef(chatMode); + if (chatMode !== AutopilotChatMode.Closed) { + lastOpenMode.current = chatMode; + } + const renderedMode = + lastOpenMode.current === AutopilotChatMode.Closed + ? AutopilotChatMode.Embedded + : lastOpenMode.current; + const layout = resolveChatLayout({ chatMode: renderedMode, disabledFeatures, readOnly }); + + // `ApChatTheme` values are the apollo-core / apollo-wind theme class names (future-light and + // future-dark included), so the chat themes itself whatever the host's body class. + const themeClass = theme; + const density = spacing.compactMode ? 'compact' : undefined; + const rootRef = React.useRef(null); + + // Not focusable or clickable while it fades out; Material hides instantly. + React.useLayoutEffect(() => { + rootRef.current?.toggleAttribute('inert', state === 'closed'); + }, [state]); + // A host-provided portal target (e.g. inside a shadow root) is not under the themed root, so + // overlays go into a themed node placed there instead. + const [hostPortalNode, setHostPortalNode] = React.useState(null); + + if (!present) { + return null; + } + + return ( +
+ + + + + + {portalContainer && + createPortal( +
, + portalContainer + )} +
+ ); +} + +export default WindChatRenderer; diff --git a/packages/apollo-react/src/chat/wind/common/lucide-icon.test.ts b/packages/apollo-react/src/chat/wind/common/lucide-icon.test.ts new file mode 100644 index 0000000000..14efab5285 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/common/lucide-icon.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveLucideIconName } from './lucide-icon'; + +describe('resolveLucideIconName', () => { + it.each([ + ['file-text', 'file-text'], + ['FileText', 'file-text'], + ['Grid2x2', 'grid-2x2'], + ])('resolves %s to %s', (icon, expected) => { + expect(resolveLucideIconName(icon)).toBe(expected); + }); + + it('resolves PascalCase names with digit runs to a name lucide knows', () => { + // lucide lists both "arrow-down-0-1" and its alias "arrow-down-01" + expect(['arrow-down-0-1', 'arrow-down-01']).toContain(resolveLucideIconName('ArrowDown01')); + }); + + it('returns undefined for names lucide does not have', () => { + expect(resolveLucideIconName('picture_as_pdf')).toBeUndefined(); + expect(resolveLucideIconName('')).toBeUndefined(); + }); +}); diff --git a/packages/apollo-react/src/chat/wind/common/lucide-icon.tsx b/packages/apollo-react/src/chat/wind/common/lucide-icon.tsx new file mode 100644 index 0000000000..c19d6ffda6 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/common/lucide-icon.tsx @@ -0,0 +1,43 @@ +import { DynamicIcon, type IconName, iconNames } from 'lucide-react/dynamic'; + +import { IconSlot, ServiceIconFallback } from './service-icon-fallback'; + +let pascalToKebab: Map | undefined; +const known = new Set(iconNames); + +/** + * Resolves a consumer `icon` to a lucide name: kebab-case (`message-circle-plus`) or the + * PascalCase export name (`MessageCirclePlus`). + */ +export function resolveLucideIconName(icon: string): IconName | undefined { + if (known.has(icon)) { + return icon as IconName; + } + + // Built from the names so digit runs match lucide's own split ("ArrowDown01" is "arrow-down-0-1"). + pascalToKebab ??= new Map( + iconNames.map((name) => [ + name + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''), + name, + ]) + ); + + return pascalToKebab.get(icon); +} + +/** + * Loaded on demand by `ServiceIcon`: it carries lucide's name-to-module map, which is too large + * for the chat's initial chunk. + */ +export default function LucideIcon({ icon }: { icon: string }) { + const name = resolveLucideIconName(icon); + + if (!name) { + return ; + } + + return
@@ -1535,11 +1545,7 @@ function createUI() { }); document.getElementById('theme-select')?.addEventListener('change', (e) => { - const newTheme = (e.target as HTMLSelectElement).value as - | 'light' - | 'dark' - | 'light-hc' - | 'dark-hc'; + const newTheme = (e.target as HTMLSelectElement).value as ApChatTheme; applyTheme(newTheme); });