From 2b33ccbe18e8b1e27213ed1037a28b1f51602eff Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Wed, 14 Oct 2026 13:03:47 -0700 Subject: [PATCH] feat(apollo-react): add the Wind chat renderer shell Replace the Wind placeholder with WindChatRenderer, registered as 'wind' in @uipath/apollo-react/chat. The root carries the ApChatTheme class and data-density="compact" from spacing.compactMode; overlays portal inside it. Embedded and FullScreen layouts match Material's footprint, Closed animates out (reduced-motion aware), and SideBySide renders an explicit placeholder. The header has title, logo (FullScreen), preview badge, new chat, settings, history, close and the custom actions menu. Action icons are lucide names loaded on demand through lucide-react/dynamic; unknown names show a neutral box. Message list, composer, history and settings are placeholder regions for the following tickets; the composer region already shows the configured placeholder and disclaimer. The renderers are registered by the module that defines ApChat rather than the package entry, whose statements bundlers drop under sideEffects when only re-exports are used; that module is listed in sideEffects. During the exit fade the header keeps the layout's mode and the panel is inert. The chat CSS no longer scans stories and tests. Hoisted into chat/headless and adopted by Material: - useChatHeaderActions / getChatHeaderActionVisibility: visibility rules and pre-hooked handlers for the header actions - resolveChatLayout: header, composer, history, settings and fullHeight rules per mode - useChatLabels: title, input placeholder and footer disclaimer with their localized defaults - publishChatEvent: the single wrapper around the private event bus - the Autopilot logo, now a component under chat/assets Add the Apollo React/Chat playground (Embedded, FullScreen, Compare renderers) driven by args applied in place through the service setters, with scripted scenarios replayed into both renderers. Its fixtures are shared with the Material showcase, which keeps all of its stories and controls. Both renderers share the docked Embedded panel frame. Story folders are excluded from the published build. Material's tiptap value sync now skips an editor that was destroyed when its extensions changed, which threw in production builds, and its scroll container only narrows its padding for a scrollbar that takes width: with overlay scrollbars the compensation widened the content, ended the overflow and flipped back on every resize. MST-16562 Co-Authored-By: Claude Opus 5.5 --- packages/apollo-react/package.json | 4 +- packages/apollo-react/rslib.config.ts | 3 + packages/apollo-react/src/chat/README.md | 11 +- .../assets/autopilot-logo.tsx} | 36 +- .../hooks/use-chat-header-actions.test.ts | 62 ++ .../headless/hooks/use-chat-header-actions.ts | 154 +++ .../chat/headless/hooks/use-chat-labels.ts | 34 + .../chat/headless/layout/chat-layout.test.ts | 58 + .../src/chat/headless/layout/chat-layout.ts | 41 + .../providers/attachments-provider.tsx | 12 +- .../providers/chat-scroll-provider.tsx | 3 +- .../headless/providers/picker-provider.tsx | 5 +- .../providers/theme-provider.test.tsx | 51 + .../headless/providers/theme-provider.tsx | 2 +- .../chat/headless/utils/publish-chat-event.ts | 15 + packages/apollo-react/src/chat/index.ts | 12 +- .../src/chat/root/ApChat.registration.test.ts | 16 + .../src/chat/root/ApChat.test.tsx | 6 +- .../apollo-react/src/chat/root/ApChat.tsx | 11 + .../src/chat/service/ChatModel.ts | 10 +- .../src/chat/service/ChatService.ts | 2 +- .../chat/stories/chat-playground.stories.tsx | 193 ++++ .../src/chat/stories/chat-playground.test.tsx | 141 +++ .../src/chat/stories/chat-playground.tsx | 556 ++++++++++ .../src/chat/stories/chat-story-fixtures.ts | 915 ++++++++++++++++ .../src/chat/stories/chat-story-frame.ts | 46 + .../src/chat/stories/chat-story-scenarios.ts | 352 +++++++ .../src/chat/styles/tailwind.chat.css | 5 +- .../src/chat/wind/WindChatPlaceholder.tsx | 10 - .../src/chat/wind/WindChatRenderer.test.tsx | 207 ++++ .../src/chat/wind/WindChatRenderer.tsx | 106 ++ .../src/chat/wind/common/lucide-icon.test.ts | 23 + .../src/chat/wind/common/lucide-icon.tsx | 43 + .../wind/common/service-icon-fallback.tsx | 23 + .../src/chat/wind/common/service-icon.tsx | 22 + .../src/chat/wind/header/chat-header.tsx | 110 ++ .../chat/wind/header/custom-actions-menu.tsx | 118 +++ .../chat/wind/header/header-action-button.tsx | 45 + .../chat/wind/header/header-parity.test.tsx | 84 ++ .../chat/wind/hooks/use-open-transition.ts | 43 + .../wind/hooks/use-prefers-reduced-motion.ts | 25 + .../src/chat/wind/layout/embedded-layout.tsx | 41 + .../chat/wind/layout/full-screen-layout.tsx | 44 + .../chat/wind/layout/placeholder-region.tsx | 33 + .../src/chat/wind/layout/regions.tsx | 66 ++ .../wind/layout/side-by-side-placeholder.tsx | 16 + .../src/material/components/ap-chat/CLAUDE.md | 1 - .../src/material/components/ap-chat/DOCS.md | 2 +- .../ap-chat/MaterialChatRenderer.test.tsx | 26 + .../ap-chat/MaterialChatRenderer.tsx | 31 +- .../components/header/header-actions.tsx | 151 +-- .../ap-chat/components/header/header.tsx | 9 +- .../components/input/chat-input-footer.tsx | 15 +- .../ap-chat/components/input/chat-input.tsx | 12 +- .../input/tiptap/tiptap-editor.test.tsx | 27 + .../components/input/tiptap/tiptap-editor.tsx | 4 +- .../message/chat-scroll-container.test.tsx | 52 + .../message/chat-scroll-container.tsx | 14 +- .../material/stories/chat-story-support.tsx | 996 ++---------------- web-packages/ap-chat/README.md | 2 +- web-packages/ap-chat/src/dev.ts | 24 +- 61 files changed, 4029 insertions(+), 1152 deletions(-) rename packages/apollo-react/src/{material/components/ap-chat/assets/autopilot-logo.svg => chat/assets/autopilot-logo.tsx} (63%) create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-chat-header-actions.test.ts create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-chat-header-actions.ts create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-chat-labels.ts create mode 100644 packages/apollo-react/src/chat/headless/layout/chat-layout.test.ts create mode 100644 packages/apollo-react/src/chat/headless/layout/chat-layout.ts create mode 100644 packages/apollo-react/src/chat/headless/providers/theme-provider.test.tsx create mode 100644 packages/apollo-react/src/chat/headless/utils/publish-chat-event.ts create mode 100644 packages/apollo-react/src/chat/root/ApChat.registration.test.ts create mode 100644 packages/apollo-react/src/chat/stories/chat-playground.stories.tsx create mode 100644 packages/apollo-react/src/chat/stories/chat-playground.test.tsx create mode 100644 packages/apollo-react/src/chat/stories/chat-playground.tsx create mode 100644 packages/apollo-react/src/chat/stories/chat-story-fixtures.ts create mode 100644 packages/apollo-react/src/chat/stories/chat-story-frame.ts create mode 100644 packages/apollo-react/src/chat/stories/chat-story-scenarios.ts delete mode 100644 packages/apollo-react/src/chat/wind/WindChatPlaceholder.tsx create mode 100644 packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx create mode 100644 packages/apollo-react/src/chat/wind/WindChatRenderer.tsx create mode 100644 packages/apollo-react/src/chat/wind/common/lucide-icon.test.ts create mode 100644 packages/apollo-react/src/chat/wind/common/lucide-icon.tsx create mode 100644 packages/apollo-react/src/chat/wind/common/service-icon-fallback.tsx create mode 100644 packages/apollo-react/src/chat/wind/common/service-icon.tsx create mode 100644 packages/apollo-react/src/chat/wind/header/chat-header.tsx create mode 100644 packages/apollo-react/src/chat/wind/header/custom-actions-menu.tsx create mode 100644 packages/apollo-react/src/chat/wind/header/header-action-button.tsx create mode 100644 packages/apollo-react/src/chat/wind/header/header-parity.test.tsx create mode 100644 packages/apollo-react/src/chat/wind/hooks/use-open-transition.ts create mode 100644 packages/apollo-react/src/chat/wind/hooks/use-prefers-reduced-motion.ts create mode 100644 packages/apollo-react/src/chat/wind/layout/embedded-layout.tsx create mode 100644 packages/apollo-react/src/chat/wind/layout/full-screen-layout.tsx create mode 100644 packages/apollo-react/src/chat/wind/layout/placeholder-region.tsx create mode 100644 packages/apollo-react/src/chat/wind/layout/regions.tsx create mode 100644 packages/apollo-react/src/chat/wind/layout/side-by-side-placeholder.tsx create mode 100644 packages/apollo-react/src/material/components/ap-chat/MaterialChatRenderer.test.tsx create mode 100644 packages/apollo-react/src/material/components/ap-chat/components/input/tiptap/tiptap-editor.test.tsx create mode 100644 packages/apollo-react/src/material/components/ap-chat/components/message/chat-scroll-container.test.tsx 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); });