From ef15b14e463c29e03151ce6a4fb83e205dba2dad Mon Sep 17 00:00:00 2001 From: BenGSchulz Date: Thu, 15 Oct 2026 11:58:46 -0700 Subject: [PATCH] feat(apollo-react): add the Wind chat markdown renderer Render message bodies in the Wind chat with the apollo-wind Markdown primitive: citations as Citation pills with hover preview, cited-range highlighting and the citation-click pre-hook, resource tokens as inline chips, a Sources list under replies, and code blocks with expand and an opt-in sandboxed HTML preview. Hoist the renderer-agnostic markdown logic into src/chat/headless and switch Material to it: streamed content with chunk scheduling, incomplete-token buffering and fake streams, the cited-range highlighter, citation opening and the group sources. MST-16564 Co-Authored-By: Claude Opus 5.5 --- packages/apollo-react/src/chat/README.md | 2 +- .../headless/hooks/use-citations.test.tsx | 155 +++++++++ .../src/chat/headless/hooks/use-citations.ts | 82 +++++ .../use-streamed-message-content.test.tsx | 111 ++++++ .../hooks/use-streamed-message-content.ts | 151 ++++++++ .../markdown/citation-highlight.test.ts | 33 ++ .../headless/markdown/citation-highlight.ts | 99 ++++++ .../chat/headless/markdown/citations.test.ts | 85 +++++ .../src/chat/headless/markdown/citations.ts | 70 ++++ .../headless/markdown/html-preview.test.ts | 21 ++ .../chat/headless/markdown/html-preview.ts | 21 ++ .../markdown/incomplete-tokens.test.ts | 19 + .../headless/markdown/incomplete-tokens.ts | 43 +++ .../apollo-react/src/chat/locales/en.json | 7 +- .../chat/stories/chat-playground.stories.tsx | 8 + .../src/chat/stories/chat-playground.test.tsx | 45 ++- .../src/chat/stories/chat-playground.tsx | 58 ++- .../src/chat/stories/chat-story-fixtures.ts | 108 ++++++ .../src/chat/stories/chat-story-scenarios.ts | 31 +- .../src/chat/wind/WindChatRenderer.test.tsx | 54 +++ .../src/chat/wind/WindChatRenderer.tsx | 33 +- .../src/chat/wind/common/dialog-container.ts | 10 + .../src/chat/wind/messages/chat-message.tsx | 2 + .../messages/message-list-scroll.test.tsx | 8 +- .../chat/wind/messages/message-sources.tsx | 55 +++ .../wind/renderers/citation-highlights.ts | 48 +++ .../wind/renderers/code-block-actions.tsx | 140 ++++++++ .../src/chat/wind/renderers/index.ts | 6 +- .../chat/wind/renderers/markdown-citation.tsx | 68 ++++ .../wind/renderers/markdown-renderer.test.tsx | 329 ++++++++++++++++++ .../chat/wind/renderers/markdown-renderer.tsx | 80 +++++ .../wind/renderers/markdown-resource-chip.tsx | 16 + .../src/chat/wind/renderers/text-renderer.tsx | 30 -- .../message/chat-message-content.tsx | 4 +- .../components/message/markdown/citation.tsx | 174 ++------- .../components/message/markdown/code.tsx | 7 +- .../components/message/markdown/markdown.tsx | 178 +--------- .../message/sources/chat-sources.tsx | 99 +----- 38 files changed, 2013 insertions(+), 477 deletions(-) create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-citations.test.tsx create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-citations.ts create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-streamed-message-content.test.tsx create mode 100644 packages/apollo-react/src/chat/headless/hooks/use-streamed-message-content.ts create mode 100644 packages/apollo-react/src/chat/headless/markdown/citation-highlight.test.ts create mode 100644 packages/apollo-react/src/chat/headless/markdown/citation-highlight.ts create mode 100644 packages/apollo-react/src/chat/headless/markdown/citations.test.ts create mode 100644 packages/apollo-react/src/chat/headless/markdown/citations.ts create mode 100644 packages/apollo-react/src/chat/headless/markdown/html-preview.test.ts create mode 100644 packages/apollo-react/src/chat/headless/markdown/html-preview.ts create mode 100644 packages/apollo-react/src/chat/headless/markdown/incomplete-tokens.test.ts create mode 100644 packages/apollo-react/src/chat/headless/markdown/incomplete-tokens.ts create mode 100644 packages/apollo-react/src/chat/wind/common/dialog-container.ts create mode 100644 packages/apollo-react/src/chat/wind/messages/message-sources.tsx create mode 100644 packages/apollo-react/src/chat/wind/renderers/citation-highlights.ts create mode 100644 packages/apollo-react/src/chat/wind/renderers/code-block-actions.tsx create mode 100644 packages/apollo-react/src/chat/wind/renderers/markdown-citation.tsx create mode 100644 packages/apollo-react/src/chat/wind/renderers/markdown-renderer.test.tsx create mode 100644 packages/apollo-react/src/chat/wind/renderers/markdown-renderer.tsx create mode 100644 packages/apollo-react/src/chat/wind/renderers/markdown-resource-chip.tsx delete mode 100644 packages/apollo-react/src/chat/wind/renderers/text-renderer.tsx diff --git a/packages/apollo-react/src/chat/README.md b/packages/apollo-react/src/chat/README.md index 0e53708e2b..6a47cb39b3 100644 --- a/packages/apollo-react/src/chat/README.md +++ b/packages/apollo-react/src/chat/README.md @@ -31,7 +31,7 @@ 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. +- The root element carries the chat theme as a class (`light`, `dark`, `light-hc`, `dark-hc`, `future-light`, `future-dark`), so it themes itself regardless of the host's body class. Menus, tooltips and hover cards portal inside that root, or into a themed node inside `portalContainer` when the host passes one. Modal dialogs (code block expand and HTML preview) always portal to that themed node, placed in `portalContainer` or else `document.body` at z-index 1300 (Material's modal level), so they cover the page rather than the chat panel. - `spacing.compactMode` sets `data-density="compact"` on the root. - `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. diff --git a/packages/apollo-react/src/chat/headless/hooks/use-citations.test.tsx b/packages/apollo-react/src/chat/headless/hooks/use-citations.test.tsx new file mode 100644 index 0000000000..118c730e06 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-citations.test.tsx @@ -0,0 +1,155 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + type AutopilotChatMessage, + AutopilotChatPreHookAction, + AutopilotChatService, +} from '../../service'; +import { AutopilotChatServiceProvider } from '../providers/chat-service.provider'; +import { useMessageSources, useOpenCitation } from './use-citations'; + +let instanceCounter = 0; + +const createService = () => + AutopilotChatService.Instantiate({ instanceName: `citations-${++instanceCounter}` }); + +const wrap = (service: AutopilotChatService, children: React.ReactNode) => ( + + {children} + +); + +describe('useOpenCitation', () => { + afterEach(() => vi.restoreAllMocks()); + + it('asks the citation-click pre-hook, then opens the citation unless it declines', async () => { + const service = createService(); + const open = vi.spyOn(window, 'open').mockReturnValue(null); + const hook = vi.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false); + service.setPreHook(AutopilotChatPreHookAction.CitationClick, hook); + let openCitation: ReturnType | undefined; + const Probe = () => { + openCitation = useOpenCitation(); + return null; + }; + render(wrap(service, )); + const pdf = { id: 2, title: 'Guide', download_url: 'https://a.test/g.pdf', page_number: 3 }; + + await act(async () => openCitation?.(pdf)); + await act(async () => openCitation?.(pdf)); + + expect(hook).toHaveBeenCalledWith({ citation: pdf }); + expect(open).toHaveBeenCalledTimes(1); + expect(open).toHaveBeenCalledWith( + 'https://a.test/g.pdf#page=3', + '_blank', + 'noopener,noreferrer' + ); + }); +}); + +describe('useOpenCitation with unsafe URLs', () => { + afterEach(() => vi.restoreAllMocks()); + + it('still asks the pre-hook with the unchanged citation but never opens a javascript: URL', async () => { + const service = createService(); + const open = vi.spyOn(window, 'open').mockReturnValue(null); + const hook = vi.fn().mockResolvedValue(true); + service.setPreHook(AutopilotChatPreHookAction.CitationClick, hook); + let openCitation: ReturnType | undefined; + const Probe = () => { + openCitation = useOpenCitation(); + return null; + }; + render(wrap(service, )); + const unsafe = { id: 1, title: 'Bad', url: 'javascript:alert(document.domain)' }; + + await act(async () => openCitation?.(unsafe)); + + expect(hook).toHaveBeenCalledWith({ citation: unsafe }); + expect(open).not.toHaveBeenCalled(); + }); + + it('opens an http URL and a relative PDF at its page', async () => { + const service = createService(); + const open = vi.spyOn(window, 'open').mockReturnValue(null); + let openCitation: ReturnType | undefined; + const Probe = () => { + openCitation = useOpenCitation(); + return null; + }; + render(wrap(service, )); + + await act(async () => openCitation?.({ id: 1, title: 'Web', url: 'http://a.test/page' })); + await act(async () => + openCitation?.({ id: 2, title: 'Guide', download_url: '/files/g.pdf', page_number: 4 }) + ); + + expect(open.mock.calls).toEqual([ + ['http://a.test/page', '_blank', 'noopener,noreferrer'], + ['/files/g.pdf#page=4', '_blank', 'noopener,noreferrer'], + ]); + }); +}); + +describe('useMessageSources', () => { + it('follows the group as streamed content parts add citations', () => { + const service = createService(); + service.sendRequest({ content: 'q' }); + service.sendResponse({ + id: 's1', + stream: true, + widget: 'apollo-markdown-renderer', + created_at: '', + contentPartChunk: { index: 0, text: 'A' }, + }); + const message = service.getConversation().at(-1) as AutopilotChatMessage; + let sources: ReturnType | undefined; + const Probe = () => { + sources = useMessageSources(message); + return null; + }; + render(wrap(service, )); + expect(sources?.sources).toEqual([]); + + act(() => + service.sendResponse({ + id: 's1', + stream: true, + done: true, + widget: 'apollo-markdown-renderer', + created_at: '', + contentPartChunk: { + index: 0, + text: '', + citation: { id: 1, title: 'Web', url: 'https://a.test' }, + }, + }) + ); + + expect(sources?.sources).toEqual([{ id: 1, title: 'Web', url: 'https://a.test' }]); + }); + + it('waits for the rest of the group when the reply itself says more is coming', () => { + const service = createService(); + service.sendRequest({ content: 'q' }); + const cited = { + widget: 'apollo-markdown-renderer', + created_at: '', + contentParts: [{ text: 'A', citations: [{ id: 1, title: 'Web', url: 'https://a.test' }] }], + }; + service.sendResponse({ ...cited, id: 'w1', shouldWaitForMoreMessages: true }); + const message = service.getConversation().at(-1) as AutopilotChatMessage; + let sources: ReturnType | undefined; + const Probe = () => { + sources = useMessageSources(message); + return null; + }; + render(wrap(service, )); + expect(sources?.isWaitingForMoreMessages).toBe(true); + + act(() => service.sendResponse({ ...cited, id: 'w2' })); + expect(sources?.isWaitingForMoreMessages).toBe(false); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/hooks/use-citations.ts b/packages/apollo-react/src/chat/headless/hooks/use-citations.ts new file mode 100644 index 0000000000..b581320088 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-citations.ts @@ -0,0 +1,82 @@ +import React from 'react'; + +import { + AutopilotChatEvent, + type AutopilotChatMessage, + AutopilotChatPreHookAction, + type PdfCitation, + type UrlCitation, +} from '../../service'; +import { getCitationUrl, getGroupCitations } from '../markdown/citations'; +import { useChatService } from '../providers/chat-service.provider'; + +/** + * Opens a citation as both renderers do: the `citation-click` pre-hook gets `{ citation }` and, if + * it allows, the citation's URL opens in a new tab. A URL that is not http(s) or relative is never + * opened, but the hook still runs with the unchanged citation so a host can handle it. + */ +export function useOpenCitation() { + const chatService = useChatService(); + + return React.useCallback( + (citation: UrlCitation | PdfCitation) => { + if (!chatService) { + return; + } + + chatService + .getPreHook(AutopilotChatPreHookAction.CitationClick)({ citation }) + .then((proceed) => { + const url = getCitationUrl(citation); + if (proceed && url) { + window.open(url, '_blank', 'noopener,noreferrer'); + } + }); + }, + [chatService] + ); +} + +/** + * The sources of a message's group, read from the service so streamed content parts count, and + * whether the host said more messages of the group are coming (the list waits for them). + */ +export function useMessageSources(message: AutopilotChatMessage) { + const chatService = useChatService(); + const groupId = message.groupId ?? ''; + const [sources, setSources] = React.useState>(() => + getGroupCitations(chatService?.getMessagesInGroup(groupId) ?? []) + ); + // From the message too: it can mount from the very Response that carries the flag. + const [isWaitingForMoreMessages, setIsWaitingForMoreMessages] = React.useState( + !!message.shouldWaitForMoreMessages + ); + + React.useEffect(() => { + if (!chatService) { + return; + } + + const refresh = () => setSources(getGroupCitations(chatService.getMessagesInGroup(groupId))); + const unsubscribes = [ + chatService.on(AutopilotChatEvent.Response, (response: AutopilotChatMessage) => { + if (response.groupId === groupId || response.id === message.id) { + refresh(); + setIsWaitingForMoreMessages(!!response.shouldWaitForMoreMessages); + } + }), + chatService.on(AutopilotChatEvent.SendChunk, (chunk: AutopilotChatMessage) => { + if (chunk.groupId === groupId || chunk.id === message.id) { + refresh(); + } + }), + chatService.on(AutopilotChatEvent.StopResponse, refresh), + ]; + + return () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + }; + }, [chatService, message.id, groupId]); + + return { sources, isWaitingForMoreMessages }; +} diff --git a/packages/apollo-react/src/chat/headless/hooks/use-streamed-message-content.test.tsx b/packages/apollo-react/src/chat/headless/hooks/use-streamed-message-content.test.tsx new file mode 100644 index 0000000000..cab0cdf2d9 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-streamed-message-content.test.tsx @@ -0,0 +1,111 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { type AutopilotChatMessage, AutopilotChatService } from '../../service'; +import { AutopilotChatServiceProvider } from '../providers/chat-service.provider'; +import { AutopilotStreamingProvider } from '../providers/streaming-provider'; +import { useStreamedMessageContent } from './use-streamed-message-content'; + +let instanceCounter = 0; +let latest: ReturnType | undefined; + +function Probe({ message }: { message: AutopilotChatMessage }) { + latest = useStreamedMessageContent(message); + return null; +} + +const reply = (extra: Partial = {}) => ({ + widget: 'apollo-markdown-renderer', + created_at: '', + ...extra, +}); + +function setup(first: Partial) { + const service = AutopilotChatService.Instantiate({ + instanceName: `streamed-content-${++instanceCounter}`, + }); + service.sendResponse(reply(first)); + const message = service.getConversation()[0] as AutopilotChatMessage; + render( + + + + + + ); + return service; +} + +describe('useStreamedMessageContent', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('renders content parts as citation markup', () => { + setup({ + id: 'p1', + content: 'Sky', + contentParts: [{ text: 'Sky', citations: [{ id: 1, title: 'Web', url: 'https://a.test' }] }], + }); + + expect(latest?.content).toMatch(/^\[\[cite-start:.+\]\]Sky\[\[cite-end\]\]$/); + }); + + it('holds back a resource token until it closes, and flushes on done', () => { + const now = vi.spyOn(Date, 'now'); + const service = setup({ id: 's1', content: 'Use ', stream: true }); + + now.mockReturnValue(1_000); + act(() => + service.sendResponse( + reply({ id: 's1', content: 'the [[resource-token:{"id":', stream: true }) + ) + ); + expect(latest?.content).toBe('Use the '); + + now.mockReturnValue(2_000); + act(() => + service.sendResponse( + reply({ id: 's1', content: '"a","type":"v","displayName":"A"}]] now', stream: true }) + ) + ); + expect(latest?.content).toBe( + 'Use the [[resource-token:{"id":"a","type":"v","displayName":"A"}]] now' + ); + + // Within 50ms of the last update a chunk waits; the closing chunk flushes it. + act(() => service.sendResponse(reply({ id: 's1', content: ' and', stream: true }))); + expect(latest?.content).toMatch(/now$/); + act(() => + service.sendResponse(reply({ id: 's1', content: ' done', stream: true, done: true })) + ); + expect(latest?.content).toMatch(/now and done$/); + }); + + it('reveals a fake stream a few characters at a time', () => { + vi.useFakeTimers(); + setup({ id: 'f1', content: 'A fake streamed reply', fakeStream: true }); + + expect(latest).toEqual({ content: '', fakeStreaming: true }); + act(() => vi.advanceTimersByTime(50)); + expect(latest?.content).toBe('A fake str'); + act(() => vi.advanceTimersByTime(200)); + expect(latest).toEqual({ content: 'A fake streamed reply', fakeStreaming: false }); + }); + + it('shows the whole reply and stops revealing when the response is stopped', () => { + vi.useFakeTimers(); + const clearInterval = vi.spyOn(globalThis, 'clearInterval'); + const service = setup({ id: 'f2', content: 'A fake streamed reply', fakeStream: true }); + act(() => vi.advanceTimersByTime(50)); + expect(latest?.content).toBe('A fake str'); + + act(() => service.stopResponse()); + + expect(latest).toEqual({ content: 'A fake streamed reply', fakeStreaming: false }); + expect(clearInterval).toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(500)); + expect(latest?.content).toBe('A fake streamed reply'); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/hooks/use-streamed-message-content.ts b/packages/apollo-react/src/chat/headless/hooks/use-streamed-message-content.ts new file mode 100644 index 0000000000..de8d161b98 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/hooks/use-streamed-message-content.ts @@ -0,0 +1,151 @@ +import React from 'react'; + +import { AutopilotChatEvent, type AutopilotChatMessage } from '../../service'; +import { contentPartsToMarkdown } from '../markdown/citation-parser'; +import { extractRenderableContent } from '../markdown/incomplete-tokens'; +import { useChatService } from '../providers/chat-service.provider'; +import { useStreaming } from '../providers/streaming-provider'; + +const FAKE_STREAM_CHARS_COUNT = 10; +const FAKE_STREAM_INTERVAL = 50; +const CHUNK_QUEUE_PROCESS_INTERVAL = 50; + +/** + * The markdown source of a message as it streams: `contentParts` become citation markup, streamed + * chunks are applied at most every 50ms with incomplete tokens held back, and a `fakeStream` + * message is revealed a few characters at a time. Only the calling component re-renders on + * chunks, so a transcript is not re-rendered per chunk. + * + * @returns the source to render, and whether a fake stream is still revealing it + */ +export function useStreamedMessageContent(message: AutopilotChatMessage) { + // Only store message ID and content separately to minimize re-renders + const messageId = React.useRef(message.id); + + // Generate initial content from contentParts if available, otherwise use message.content + const getInitialContent = React.useCallback(() => { + if (message.contentParts && message.contentParts.length > 0) { + return contentPartsToMarkdown(messageId.current, message.contentParts); + } + return message.content || ''; + }, [message.contentParts, message.content]); + + const [content, setContent] = React.useState(message.fakeStream ? '' : getInitialContent()); + const [fakeStreaming, setFakeStreaming] = React.useState(!!message.fakeStream); + const chatService = useChatService(); + const { setStreaming } = useStreaming(); + const chunkQueue = React.useRef([]); + const lastChunkQueueProcessedTime = React.useRef(0); + const incompleteTokenBuffer = React.useRef(''); + + // Update the message ID ref if a new message is passed + React.useEffect(() => { + let unsubscribeStopResponse: (() => void) | undefined; + let fakeStreamInterval: ReturnType | undefined; + + if (message.id !== messageId.current || !message.stream) { + messageId.current = message.id; + setContent(message.fakeStream ? '' : getInitialContent()); + } + + if (message.fakeStream) { + unsubscribeStopResponse = chatService.on(AutopilotChatEvent.StopResponse, () => { + clearInterval(fakeStreamInterval); + setStreaming(false); + setFakeStreaming(false); + setContent(getInitialContent()); + chunkQueue.current = []; + incompleteTokenBuffer.current = ''; + lastChunkQueueProcessedTime.current = 0; + }); + + const characters = getInitialContent().split(''); + let charIndex = 0; + + setStreaming(true); + setFakeStreaming(true); + + fakeStreamInterval = setInterval(() => { + if (charIndex < characters.length) { + const chunkSize = FAKE_STREAM_CHARS_COUNT; + const endIndex = Math.min(charIndex + chunkSize, characters.length); + const chunk = characters.slice(charIndex, endIndex).join(''); + + setContent((prevContent) => `${prevContent}${chunk}`); + + charIndex = endIndex; + } else { + clearInterval(fakeStreamInterval); + setStreaming(false); + setFakeStreaming(false); + } + }, FAKE_STREAM_INTERVAL); + } + + return () => { + unsubscribeStopResponse?.(); + + if (fakeStreamInterval) { + clearInterval(fakeStreamInterval); + setFakeStreaming(false); + } + }; + }, [message, chatService, setStreaming, getInitialContent]); + + const handleSendChunk = React.useCallback((msg: AutopilotChatMessage) => { + if (msg.id !== messageId.current) { + return; + } + + chunkQueue.current.push(msg.content); + + if (Date.now() - lastChunkQueueProcessedTime.current > CHUNK_QUEUE_PROCESS_INTERVAL) { + lastChunkQueueProcessedTime.current = Date.now(); + + if (msg.contentParts) { + const fullContent = contentPartsToMarkdown(messageId.current, msg.contentParts); + const { renderable } = extractRenderableContent(fullContent); + setContent(renderable); + return; + } + + // Combine buffered incomplete token with new chunks + const accumulated = incompleteTokenBuffer.current + chunkQueue.current.join(''); + const { renderable, buffered } = extractRenderableContent(accumulated); + + incompleteTokenBuffer.current = buffered; + chunkQueue.current = []; + + if (renderable) { + setContent((prevContent) => `${prevContent}${renderable}`); + } + } else if (msg.done) { + lastChunkQueueProcessedTime.current = 0; + + if (msg.contentParts) { + setContent(contentPartsToMarkdown(messageId.current, msg.contentParts)); + return; + } + + // Stream complete - flush everything including any incomplete tokens + const remaining = incompleteTokenBuffer.current + chunkQueue.current.join(''); + incompleteTokenBuffer.current = ''; + chunkQueue.current = []; + setContent((prevContent) => `${prevContent}${remaining}`); + } + }, []); + + React.useEffect(() => { + if (!chatService) { + return; + } + + const unsubscribe = chatService.on(AutopilotChatEvent.SendChunk, handleSendChunk); + + return () => { + unsubscribe(); + }; + }, [chatService, handleSendChunk]); + + return { content, fakeStreaming }; +} diff --git a/packages/apollo-react/src/chat/headless/markdown/citation-highlight.test.ts b/packages/apollo-react/src/chat/headless/markdown/citation-highlight.test.ts new file mode 100644 index 0000000000..9a968d414a --- /dev/null +++ b/packages/apollo-react/src/chat/headless/markdown/citation-highlight.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; + +import { CHAT_CITATION_START } from '../../service'; +import { highlightCitedRange } from './citation-highlight'; + +describe('highlightCitedRange', () => { + it('paints the cited text up to the citation and restores it after', () => { + const container = document.createElement('div'); + container.innerHTML = `

Before cited text1 after

`; + const original = container.innerHTML; + const citation = container.querySelector('#c1') as HTMLElement; + + const clear = highlightCitedRange(1, citation, container, 'yellow'); + + const painted = [...container.querySelectorAll('[style*="yellow"]')].map( + (element) => element.textContent + ); + expect(painted).toEqual(['cited ', 'text', 'text']); + expect(citation.style.backgroundColor).toBe(''); + + clear?.(); + expect(container.innerHTML).toBe(original); + }); + + it('does nothing without a start marker for the id', () => { + const container = document.createElement('div'); + container.innerHTML = '

text2

'; + + expect( + highlightCitedRange(2, container.querySelector('#c2') as HTMLElement, container, 'yellow') + ).toBeUndefined(); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/markdown/citation-highlight.ts b/packages/apollo-react/src/chat/headless/markdown/citation-highlight.ts new file mode 100644 index 0000000000..574bb3a86a --- /dev/null +++ b/packages/apollo-react/src/chat/headless/markdown/citation-highlight.ts @@ -0,0 +1,99 @@ +import { CHAT_CITATION_START } from '../../service'; + +// `citationPlugin` leaves a hidden start marker where each cited range begins; the citation +// element ends it. Several ranges can cite the same id, so take the last start before this one. +const getNearestStartMarker = ( + id: number, + container: Element, + endEl: Element +): HTMLElement | null => { + const candidates = Array.from( + container.querySelectorAll(`.${CHAT_CITATION_START}[data-citation-ids~="${id}"]`) + ) as HTMLElement[]; + let nearest: HTMLElement | null = null; + for (const marker of candidates) { + const rel = marker.compareDocumentPosition(endEl); + const isBefore = + !!(rel & Node.DOCUMENT_POSITION_FOLLOWING) || !!(rel & Node.DOCUMENT_POSITION_CONTAINS); + if (isBefore) { + nearest = marker; + } + } + return nearest; +}; + +/** + * Paints the text a citation supports, from its start marker up to the citation element, inside + * `container` (the message). Elements in between get a background and bare text nodes are + * wrapped in spans; anything inside a `[data-citation-sup="true"]` element is left alone. + * + * @returns a function that removes the highlight, or `undefined` when there is no start marker + */ +export function highlightCitedRange( + id: number, + citation: HTMLElement, + container: Element, + color: string +): (() => void) | undefined { + const startMarker = getNearestStartMarker(id, container, citation); + if (!startMarker) { + return undefined; + } + + const highlightedElements: HTMLElement[] = []; + const wrappedTextSpans: HTMLElement[] = []; + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT + ); + + while (walker.nextNode()) { + if (walker.currentNode === startMarker) { + break; + } + } + + while (walker.nextNode()) { + const node = walker.currentNode; + if (node === citation) { + break; + } + if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as HTMLElement; + if (el === startMarker || el.closest('[data-citation-sup="true"]')) { + continue; + } + el.style.transition = 'background-color 0.2s'; + el.style.backgroundColor = color; + highlightedElements.push(el); + } else if (node.nodeType === Node.TEXT_NODE) { + const text = node as Text; + if ( + text.textContent && + text.textContent.trim().length > 0 && + text.parentElement && + !text.parentElement.closest('[data-citation-sup="true"]') + ) { + const span = document.createElement('span'); + span.style.backgroundColor = color; + text.parentElement.insertBefore(span, text); + span.appendChild(text); + wrappedTextSpans.push(span); + } + } + } + + return () => { + for (const el of highlightedElements) { + el.style.backgroundColor = ''; + el.style.transition = ''; + } + for (const span of wrappedTextSpans) { + const parent = span.parentNode; + while (span.firstChild) { + parent?.insertBefore(span.firstChild, span); + } + parent?.removeChild(span); + } + }; +} diff --git a/packages/apollo-react/src/chat/headless/markdown/citations.test.ts b/packages/apollo-react/src/chat/headless/markdown/citations.test.ts new file mode 100644 index 0000000000..4074c805eb --- /dev/null +++ b/packages/apollo-react/src/chat/headless/markdown/citations.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { type AutopilotChatMessage, AutopilotChatRole } from '../../service'; +import { citationFromMarker, getCitationUrl, getGroupCitations } from './citations'; + +describe('citation helpers', () => { + it('builds the pre-hook citation from a marker as Material does', () => { + expect( + citationFromMarker({ id: 1, title: 'Web', url: 'https://a.test', messageId: 'm' }) + ).toEqual({ id: 1, title: 'Web', url: 'https://a.test' }); + expect( + citationFromMarker({ + id: 2, + title: 'Guide', + download_url: 'https://a.test/g.pdf', + page_number: 3, + messageId: 'm', + }) + ).toEqual({ id: 2, title: 'Guide', download_url: 'https://a.test/g.pdf', page_number: 3 }); + }); + + it('opens http(s) and relative URLs, and PDFs at the cited page', () => { + expect(getCitationUrl({ id: 1, title: 'Web', url: 'https://a.test' })).toBe('https://a.test'); + expect(getCitationUrl({ id: 1, title: 'Web', url: 'http://a.test/page' })).toBe( + 'http://a.test/page' + ); + expect( + getCitationUrl({ id: 3, title: 'Local', download_url: '/files/guide.pdf', page_number: 7 }) + ).toBe('/files/guide.pdf#page=7'); + expect( + getCitationUrl({ + id: 2, + title: 'Guide', + download_url: 'https://a.test/g.pdf', + page_number: 3, + }) + ).toBe('https://a.test/g.pdf#page=3'); + }); + + it('refuses URLs that are not http(s) or relative', () => { + for (const url of [ + 'javascript:alert(1)', + ' JavaScript:alert(1)', + 'java\tscript:alert(1)', + 'data:text/html,', + 'blob:https://a.test/1', + 'vbscript:msgbox(1)', + 'file:///etc/passwd', + ]) { + expect(getCitationUrl({ id: 1, title: 'Bad', url })).toBeUndefined(); + } + expect( + getCitationUrl({ id: 2, title: 'Bad', download_url: 'javascript:alert(1)', page_number: 1 }) + ).toBeUndefined(); + }); + + it('pins how protocol-relative, backslash and look-alike URLs resolve', () => { + // Protocol-relative and backslash hosts resolve to the page's scheme; an entity-encoded colon + // is a relative path. All stay on http(s), so they may open. + for (const url of ['//host.test/a', '\\\\host.test\\a', 'javascript:alert(1)']) { + expect(getCitationUrl({ id: 1, title: 'Edge', url })).toBe(url); + } + // `https:` with no slashes takes "javascript" as the host and "alert(1)" as the port, which + // does not parse, so it is refused. + expect( + getCitationUrl({ id: 1, title: 'Edge', url: 'https:javascript:alert(1)' }) + ).toBeUndefined(); + }); + + it('lists the citations of a group once each, in order', () => { + const message = (citations: Array<{ id: number; title: string; url: string }>) => + ({ + id: crypto.randomUUID(), + role: AutopilotChatRole.Assistant, + content: '', + created_at: '', + widget: 'apollo-markdown-renderer', + contentParts: [{ text: 'x', citations }], + }) as AutopilotChatMessage; + const a = { id: 1, title: 'A', url: 'https://a.test' }; + const b = { id: 2, title: 'B', url: 'https://b.test' }; + + expect(getGroupCitations([message([a, b]), message([a])])).toEqual([a, b]); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/markdown/citations.ts b/packages/apollo-react/src/chat/headless/markdown/citations.ts new file mode 100644 index 0000000000..8c12dc6831 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/markdown/citations.ts @@ -0,0 +1,70 @@ +import type { AutopilotChatMessage, PdfCitation, UrlCitation } from '../../service'; + +/** The props `citationPlugin` gives each `citation` node. */ +export interface CitationMarker { + id: number; + title: string; + url?: string; + page_number?: number; + download_url?: string; + messageId: string; +} + +/** The `citation-click` pre-hook's citation for a marker in the text. */ +export function citationFromMarker({ + id, + title, + url, + download_url, + page_number, +}: CitationMarker): UrlCitation | PdfCitation { + return url + ? { id, title, url } + : { id, title, download_url: download_url as string, page_number: page_number as number }; +} + +const OPENABLE_PROTOCOLS = new Set(['http:', 'https:']); + +/** + * Whether a citation URL may be opened. Citations come from the model or the host's backend, so + * a `javascript:`, `data:`, `blob:` or `vbscript:` URL must not reach `window.open`. The parser + * lowercases the scheme and drops the whitespace and control characters that defeat a substring + * check; relative URLs resolve against the page. + */ +export function isOpenableCitationUrl(href: string): boolean { + try { + return OPENABLE_PROTOCOLS.has(new URL(href, window.location.href).protocol); + } catch { + return false; + } +} + +/** + * Where a citation opens: its URL, or its PDF at the cited page. `undefined` when there is none + * or it is not an http(s) or relative URL. + */ +export function getCitationUrl(citation: UrlCitation | PdfCitation): string | undefined { + let href: string | undefined; + if ('url' in citation && citation.url) { + href = citation.url; + } else if ('download_url' in citation && citation.download_url) { + href = `${citation.download_url}${citation.page_number ? `#page=${citation.page_number}` : ''}`; + } + return href && isOpenableCitationUrl(href) ? href : undefined; +} + +/** Every citation in a group's content parts, first occurrence of each id only. */ +export function getGroupCitations(group: readonly AutopilotChatMessage[]) { + const allCitations = group + .flatMap((message) => message.contentParts?.flatMap((part) => part.citations) ?? []) + .filter((citation): citation is UrlCitation | PdfCitation => citation !== undefined); + + const seenIds = new Set(); + return allCitations.filter((citation) => { + if (seenIds.has(citation.id)) { + return false; + } + seenIds.add(citation.id); + return true; + }); +} diff --git a/packages/apollo-react/src/chat/headless/markdown/html-preview.test.ts b/packages/apollo-react/src/chat/headless/markdown/html-preview.test.ts new file mode 100644 index 0000000000..d7196f3081 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/markdown/html-preview.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { buildHtmlPreviewDocument } from './html-preview'; + +describe('buildHtmlPreviewDocument', () => { + it('adds a no-referrer policy before the snippet', () => { + expect(buildHtmlPreviewDocument('

Hi

')).toBe( + '

Hi

' + ); + }); + + it('keeps a doctype first and adds the CSP when given', () => { + expect( + buildHtmlPreviewDocument('\n', { + contentSecurityPolicy: "default-src 'none'", + }) + ).toBe( + `\n` + ); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/markdown/html-preview.ts b/packages/apollo-react/src/chat/headless/markdown/html-preview.ts new file mode 100644 index 0000000000..7da392d473 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/markdown/html-preview.ts @@ -0,0 +1,21 @@ +const DOCTYPE = /^\s*]*>/i; + +/** + * The document an HTML code block previews in a sandboxed iframe. An `about:srcdoc` frame takes + * the host page as its referrer, so a `no-referrer` policy keeps the host URL from leaking to + * anything the snippet loads; an optional Content-Security-Policy narrows what it may load. The + * tags go after a doctype, if any, so the snippet keeps its rendering mode. + */ +export function buildHtmlPreviewDocument( + html: string, + { contentSecurityPolicy }: { contentSecurityPolicy?: string } = {} +) { + const head = `${ + contentSecurityPolicy + ? `` + : '' + }`; + const doctype = html.match(DOCTYPE)?.[0] ?? ''; + + return `${doctype}${head}${html.slice(doctype.length)}`; +} diff --git a/packages/apollo-react/src/chat/headless/markdown/incomplete-tokens.test.ts b/packages/apollo-react/src/chat/headless/markdown/incomplete-tokens.test.ts new file mode 100644 index 0000000000..976c121125 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/markdown/incomplete-tokens.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; + +import { extractRenderableContent } from './incomplete-tokens'; + +describe('extractRenderableContent', () => { + it('renders everything when no token is open', () => { + expect(extractRenderableContent('Use [[resource-token:{"id":"a"}]] now')).toEqual({ + renderable: 'Use [[resource-token:{"id":"a"}]] now', + buffered: '', + }); + }); + + it('holds back an unclosed resource token', () => { + expect(extractRenderableContent('Use the [[resource-token:{"id":"va')).toEqual({ + renderable: 'Use the ', + buffered: '[[resource-token:{"id":"va', + }); + }); +}); diff --git a/packages/apollo-react/src/chat/headless/markdown/incomplete-tokens.ts b/packages/apollo-react/src/chat/headless/markdown/incomplete-tokens.ts new file mode 100644 index 0000000000..cdc5bce922 --- /dev/null +++ b/packages/apollo-react/src/chat/headless/markdown/incomplete-tokens.ts @@ -0,0 +1,43 @@ +/** + * Patterns for tokens that should be buffered when incomplete during streaming. + * Each pattern should match the START of an incomplete token (opened but not yet closed). + * Add new patterns here as needed. + * + * Pattern format: Match the opening sequence with a negative lookahead for the closing sequence. + * Example: /\[\[my-token:(?![^\]]*\]\])/ matches `[[my-token:` not followed by `]]` + */ +export const INCOMPLETE_TOKEN_PATTERNS: RegExp[] = [ + /\[\[resource-token:(?!.*\]\])/, // [[resource-token:{...}]] +]; + +/** + * Extracts content that is safe to render, buffering any incomplete tokens. + * This prevents flickering during streaming when tokens arrive in chunks. + * + * @param content - The accumulated content string + * @returns An object with `renderable` (safe to display) and `buffered` (held back for next chunk) + */ +export function extractRenderableContent(content: string): { + renderable: string; + buffered: string; +} { + let earliestIndex = -1; + + for (const pattern of INCOMPLETE_TOKEN_PATTERNS) { + const match = content.match(pattern); + if (match?.index !== undefined) { + if (earliestIndex === -1 || match.index < earliestIndex) { + earliestIndex = match.index; + } + } + } + + if (earliestIndex === -1) { + return { renderable: content, buffered: '' }; + } + + return { + renderable: content.slice(0, earliestIndex), + buffered: content.slice(earliestIndex), + }; +} diff --git a/packages/apollo-react/src/chat/locales/en.json b/packages/apollo-react/src/chat/locales/en.json index 2cd98b61ae..600c13a4ec 100644 --- a/packages/apollo-react/src/chat/locales/en.json +++ b/packages/apollo-react/src/chat/locales/en.json @@ -86,5 +86,10 @@ "autopilot-chat.message.response-received": "New response", "autopilot-chat.message.actions.copied": "Copied", "autopilot-chat.message.actions.feedback": "Rate this response", - "autopilot-chat.message.loading-older": "Loading older messages" + "autopilot-chat.message.loading-older": "Loading older messages", + "autopilot-chat.message.html-preview": "HTML preview", + "autopilot-chat.message.dialog-close": "Close", + "autopilot-chat.message.footnote-back": "Back to content", + "autopilot-chat.message.task-done": "Done", + "autopilot-chat.message.task-open": "Not done" } diff --git a/packages/apollo-react/src/chat/stories/chat-playground.stories.tsx b/packages/apollo-react/src/chat/stories/chat-playground.stories.tsx index 57220ab57d..ec9331f0c4 100644 --- a/packages/apollo-react/src/chat/stories/chat-playground.stories.tsx +++ b/packages/apollo-react/src/chat/stories/chat-playground.stories.tsx @@ -51,6 +51,8 @@ Controls apply to the running chat through the service setters, without a new ch Closing a chat leaves an **Open chat** button where the host would put its launcher; it reopens in the selected \`mode\`. +**Clear chat** is a host-side button beside the panels (in the host bar in FullScreen). It stops the running scenario (what it still has scheduled, and its cleanup: listeners, loading message, error, config it changed) and calls \`newChat()\` on every chat in the story, without a remount. It is a button because args cannot trigger an action. + 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. @@ -198,3 +200,9 @@ export const CompareRenderers: Story = { name: 'Compare renderers', args: { renderer: 'both' }, }; + +/** Code blocks with HTML preview turned on, compared across both renderers. */ +export const HtmlPreview: Story = { + name: 'HTML preview', + args: { renderer: 'both', scenario: 'code', disableHtmlPreview: false }, +}; diff --git a/packages/apollo-react/src/chat/stories/chat-playground.test.tsx b/packages/apollo-react/src/chat/stories/chat-playground.test.tsx index 704eabbceb..a3f2014ab7 100644 --- a/packages/apollo-react/src/chat/stories/chat-playground.test.tsx +++ b/packages/apollo-react/src/chat/stories/chat-playground.test.tsx @@ -1,7 +1,8 @@ import { act, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { AutopilotChatMode, AutopilotChatService } from '../index'; +import { publishChatEvent } from '../headless/utils/publish-chat-event'; +import { AutopilotChatEvent, AutopilotChatMode, AutopilotChatService } from '../index'; import { ChatPlayground, type ChatPlaygroundProps, @@ -98,6 +99,48 @@ describe('ChatPlayground', () => { } }); + it('clears every chat and stops the running scenario without a remount', async () => { + render( + + ); + await flush(); + const wind = slotService('primary'); + const material = slotService('compare'); + const root = document.querySelector('[data-slot="wind-chat"]'); + expect(wind?.getConversation().length).toBeGreaterThan(0); + + act(() => screen.getByRole('button', { name: 'Clear chat' }).click()); + await act(() => new Promise((resolve) => setTimeout(resolve, 300))); + + expect(wind?.getConversation()).toHaveLength(0); + expect(material?.getConversation()).toHaveLength(0); + expect(document.querySelector('[data-slot="wind-chat"]')).toBe(root); + }); + + it('runs the scenario cleanups on Clear chat', async () => { + const props = baseProps(); + const { rerender } = render(); + await flush(); + const service = slotService('primary'); + expect(service?.getLoadingMessage()).toBe('Searching the knowledge base...'); + + act(() => screen.getByRole('button', { name: 'Clear chat' }).click()); + expect(service?.getLoadingMessage()).toBe(''); + + rerender(); + await flush(); + expect(service?.getConfig().paginatedMessages).toBe(true); + act(() => screen.getByRole('button', { name: 'Clear chat' }).click()); + expect(service?.getConfig().paginatedMessages).toBe(false); + + // The history scenario's load-more listener is gone, so no older page arrives. + act(() => { + if (service) publishChatEvent(service, AutopilotChatEvent.ConversationLoadMore); + }); + await act(() => new Promise((resolve) => setTimeout(resolve, 700))); + expect(service?.getConversation()).toHaveLength(0); + }); + it('appends a scenario to the current transcript', async () => { const props = baseProps(); const { rerender } = render(); diff --git a/packages/apollo-react/src/chat/stories/chat-playground.tsx b/packages/apollo-react/src/chat/stories/chat-playground.tsx index 59c08beecb..60709dec87 100644 --- a/packages/apollo-react/src/chat/stories/chat-playground.tsx +++ b/packages/apollo-react/src/chat/stories/chat-playground.tsx @@ -180,6 +180,11 @@ interface SlotHandle { quietIds: Set; /** Turns mirrored from the other chat: auto reply, no mirroring back. */ mirroredIds: Set; + /** + * Stops what is still running for this chat: scenario steps, streams and auto replies, and the + * scenario's own cleanup (listeners, loading message, error, config it changed). + */ + cancelScheduled: Set<() => void>; } type SlotRegistry = Map; @@ -315,9 +320,14 @@ function ChatSlot({ // 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 handle: SlotHandle = { + service, + quietIds: new Set(), + mirroredIds: new Set(), + cancelScheduled: new Set([timers.clear]), + }; + registry.set(id, handle); const unsubscribes = Object.values(AutopilotChatEvent).map((event) => service.on(event, (data) => log(event, data)) @@ -435,6 +445,15 @@ function ChatSlot({ const scenario: ChatStoryScenario = CHAT_STORY_SCENARIOS[args.scenario]; const timers = createTimers(); const cleanups: Array<() => void> = []; + // Stops the scenario once, whether Clear chat or a scenario change comes first. + let stopped = false; + const stop = () => { + if (stopped) return; + stopped = true; + timers.clear(); + for (const cleanup of cleanups) cleanup(); + }; + handle?.cancelScheduled.add(stop); const context: ScenarioContext = { service, renderer: rendererRef.current, @@ -455,8 +474,8 @@ function ChatSlot({ scenario.run(context); return () => { - timers.clear(); - for (const cleanup of cleanups) cleanup(); + handle?.cancelScheduled.delete(stop); + stop(); }; }, [args.scenario, id, registry, service, log]); @@ -531,6 +550,25 @@ export function ChatPlayground({ theme, instanceKey, ...args }: ChatPlaygroundPr const primaryRenderer: AutopilotChatRenderer = args.renderer === 'both' ? 'wind' : args.renderer; const fullScreen = args.mode === AutopilotChatMode.FullScreen; + // An action, which args cannot express: stop the scenario and start every chat over, in place. + const clearChats = () => { + for (const handle of registry.values()) { + for (const cancel of handle.cancelScheduled) cancel(); + handle.service.newChat(); + } + }; + const clearButton = ( + + ); + return (
- {!fullScreen && Host application} + {fullScreen ? ( +
+ Host application + {clearButton} +
+ ) : ( + <> + Host application + {clearButton} + + )} and the vendor record | +| Approve | Finance | Over $5,000 needs a manager, and a single dollar sign stays literal | + +> A quote with a nested list: +> - first +> - second + Footnote reference[^1]. [^1]: The footnote text.`; +/** Replies the markdown scenario streams, so incomplete markdown is seen mid-stream. */ +export const STREAMED_MARKDOWN = `### Streamed summary + +1. **Open** the queue and check the _oldest_ item. +2. Retry it with \`Invoke Workflow\`. + +| Queue | Pending | +| --- | --- | +| Invoices | 12 | + +\`\`\`json +{ "queue": "Invoices", "retry": true } +\`\`\` + +Done.`; + +/** Several languages, inline code and a plain block, for code block chrome. */ +export const CODE_LANGUAGES_MESSAGE = `Inline \`code\` sits in the sentence. A few blocks: + +\`\`\`python +def retry(item, attempts=3): + return any(run(item) for _ in range(attempts)) +\`\`\` + +\`\`\`bash +uip robot start --queue Invoices +\`\`\` + +\`\`\` +A block with no language. +\`\`\``; + export const RESPONSE_ACTIONS: AutopilotChatMessageAction[] = [ { name: 'copy-action', @@ -453,6 +497,45 @@ export const CITATION_RESPONSE = { ], } satisfies Partial; +/** PDF and URL sources, more than fit the collapsed list, and a range spanning formatting. */ +export const PDF_CITATION_RESPONSE = { + widget: 'default', + contentParts: [ + { text: 'Invoices over $5,000 need ' }, + { + text: 'a **finance manager** to approve them', + citations: [ + { + id: 1, + title: 'Approval Policy', + download_url: 'https://ontheline.trincoll.edu/images/bookdown/sample-local-pdf.pdf', + page_number: 4, + }, + ], + }, + { text: ', and ' }, + { + text: 'every approval is logged with a reason', + citations: [ + { id: 2, title: 'Audit Requirements', url: 'https://example.com/audit' }, + { id: 3, title: 'Finance Handbook', url: 'https://example.com/handbook' }, + ], + }, + { text: '. ' }, + { + text: 'Rejected invoices go back to the vendor.', + citations: [ + { + id: 4, + title: 'Vendor Guide', + download_url: 'https://ontheline.trincoll.edu/images/bookdown/sample-local-pdf.pdf', + page_number: 9, + }, + ], + }, + ], +} satisfies Partial; + /** Streamed with `contentPartChunk`, one entry per chunk. */ export const STREAMED_CITATION_CHUNKS: ContentPartChunk[] = [ { index: 0, text: 'Based on the search results' }, @@ -547,6 +630,31 @@ export const STREAMED_CITATION_CHUNKS: ContentPartChunk[] = [ 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.`; +/** Tokens inside a list, for chips in block content. */ +export const RESOURCE_TOKEN_LIST_MESSAGE = `Inputs for the run: + +- [[resource-token:{"id":"q-invoices","type":"queue","icon":"inbox","displayName":"Invoices"}]] queue +- [[resource-token:{"id":"b-archive","type":"bucket","icon":"folder","displayName":"Archive"}]] bucket`; + +// Resource icons are Material Icons names; the Wind renderer takes lucide names. +const WIND_RESOURCE_ICONS: Record = { + data_object: 'braces', + description: 'file-text', + folder: 'folder', + inbox: 'inbox', + mail: 'mail', + person: 'user', +}; + +/** `text` with its resource token icons named for `renderer`. */ +export function withResourceIcons(text: string, renderer: 'wind' | 'material') { + return renderer === 'wind' + ? text.replace(/"icon":"(\w+)"/g, (match, icon: string) => + WIND_RESOURCE_ICONS[icon] ? `"icon":"${WIND_RESOURCE_ICONS[icon]}"` : match + ) + : text; +} + /** Streamed with `content` chunks; tokens split across chunks exercise incomplete-token buffering. */ export const RESOURCE_TOKEN_CHUNKS = [ 'I will update the ', diff --git a/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts b/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts index 0a9fc36166..b9a93d4e3d 100644 --- a/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts +++ b/packages/apollo-react/src/chat/stories/chat-story-scenarios.ts @@ -20,20 +20,25 @@ import { import { CITATION_RESPONSE, CODE_BLOCK_MESSAGE, + CODE_LANGUAGES_MESSAGE, HTML_PREVIEW_MESSAGE, MARKDOWN_CORPUS, overflowResponseAction, + PDF_CITATION_RESPONSE, RESOURCE_TOKEN_CHUNKS, + RESOURCE_TOKEN_LIST_MESSAGE, RESOURCE_TOKEN_MESSAGE, RESPONSE_ACTIONS, SAMPLE_CONVERSATION, STREAMED_CITATION_CHUNKS, + STREAMED_MARKDOWN, STREAMING_REPLY, type StoryToolCallDisplayMode, storyHistoryPage, storyToolCallMeta, storyTraceSpan, WIND_RESPONSE_ACTIONS, + withResourceIcons, } from './chat-story-fixtures'; export interface ScenarioContext { @@ -199,28 +204,31 @@ export const CHAT_STORY_SCENARIOS = { citations: { label: 'Citations', description: - 'Appends a reply with content-part citations, then a streamed reply with citations.', + 'Appends a reply with URL citations, a reply with PDF citations (page numbers, a range spanning bold text, more sources than the collapsed list shows), then a streamed reply with citations. Hover a citation to highlight what it supports; clicking runs the citation-click pre-hook.', 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); + context.schedule(() => respond(context.service, PDF_CITATION_RESPONSE), context.delayMs * 2); + streamChunks(context, STREAMED_CITATION_CHUNKS, context.delayMs * 4); }, }, markdown: { label: 'Markdown corpus', description: - 'Appends a reply covering headings, lists, tables, quotes, math, images and footnotes.', + 'Appends a reply covering headings, lists, task lists, tables with line breaks, quotes, math, images (shown as links) and footnotes, then streams a shorter reply so partial markdown is seen mid-stream.', run: (context) => { context.sendScriptedRequest({ content: 'Show me every markdown feature.' }); context.schedule( () => respond(context.service, { content: MARKDOWN_CORPUS }), context.delayMs ); + streamChunks(context, splitWords(STREAMED_MARKDOWN), context.delayMs * 3); }, }, code: { label: 'Code and HTML preview', - description: 'Appends a long TypeScript block and an HTML block with preview.', + description: + 'Appends a long TypeScript block, blocks in other languages with inline code, then streams an HTML block. HTML preview is off by default (disabledFeatures.htmlPreview); turn off the disableHtmlPreview control, or use the HTML preview story, to get the preview action. It stays disabled while the block streams.', run: (context) => { context.sendScriptedRequest({ content: 'Show me some code.' }); context.schedule( @@ -228,21 +236,28 @@ export const CHAT_STORY_SCENARIOS = { context.delayMs ); context.schedule( - () => respond(context.service, { content: HTML_PREVIEW_MESSAGE }), + () => respond(context.service, { content: CODE_LANGUAGES_MESSAGE }), context.delayMs * 2 ); + streamChunks(context, HTML_PREVIEW_MESSAGE.split(/(?<=\n)/), context.delayMs * 3); }, }, 'resource-tokens': { label: 'Resource tokens', - description: 'Appends a reply with resource tokens, then streams tokens split across chunks.', + description: + 'Appends a reply with resource tokens and one with tokens in a list, then streams tokens split across chunks, which are held back until complete.', run: (context) => { + const icons = (text: string) => withResourceIcons(text, context.renderer); context.sendScriptedRequest({ content: 'Which variables will you touch?' }); context.schedule( - () => respond(context.service, { content: RESOURCE_TOKEN_MESSAGE }), + () => respond(context.service, { content: icons(RESOURCE_TOKEN_MESSAGE) }), context.delayMs ); - streamChunks(context, RESOURCE_TOKEN_CHUNKS, context.delayMs * 3); + context.schedule( + () => respond(context.service, { content: icons(RESOURCE_TOKEN_LIST_MESSAGE) }), + context.delayMs * 2 + ); + streamChunks(context, RESOURCE_TOKEN_CHUNKS.map(icons), context.delayMs * 4); }, }, 'tool-call': { diff --git a/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx b/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx index a22cdc87e2..c1bca42f6f 100644 --- a/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx +++ b/packages/apollo-react/src/chat/wind/WindChatRenderer.test.tsx @@ -205,3 +205,57 @@ describe('WindChatRenderer', () => { expect(screen.queryByText('Be kind')).toBeNull(); }); }); + +describe('Wind overlay placement', () => { + const codeReply = { + id: 'code', + content: '```ts\nconst a = 1;\n```', + widget: 'apollo-markdown-renderer', + created_at: '', + }; + + it('portals modal dialogs to a themed node on the body, and keeps menus in the root', async () => { + const service = createService(); + service.setCustomHeaderActions([{ id: 'share', name: 'Share' }]); + const { unmount } = render(); + act(() => service.sendResponse(codeReply)); + + await userEvent.click(screen.getByRole('button', { name: 'More actions' })); + expect(getRoot()?.contains(await screen.findByRole('menu'))).toBe(true); + await userEvent.keyboard('{Escape}'); + + const expand = screen.getByRole('button', { name: 'Expand' }); + await userEvent.click(expand); + const dialog = await screen.findByRole('dialog'); + const overlays = dialog.closest('[data-slot="wind-chat-overlays"]'); + expect(overlays?.parentElement).toBe(document.body); + expect(overlays).toHaveClass('dark'); + expect(getRoot()?.contains(dialog)).toBe(false); + // Focus moves into the dialog and stays trapped there. + await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true)); + await userEvent.tab(); + expect(dialog.contains(document.activeElement)).toBe(true); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(document.activeElement).toBe(expand)); + + unmount(); + expect(document.querySelector('[data-slot="wind-chat-overlays"]')).toBeNull(); + }); + + it('puts every overlay in the host portal container when one is given', async () => { + const portalContainer = document.createElement('div'); + document.body.appendChild(portalContainer); + const service = createService(); + service.setCustomHeaderActions([{ id: 'share', name: 'Share' }]); + render(); + act(() => service.sendResponse(codeReply)); + + await userEvent.click(screen.getByRole('button', { name: 'More actions' })); + expect(portalContainer.contains(await screen.findByRole('menu'))).toBe(true); + await userEvent.keyboard('{Escape}'); + + await userEvent.click(screen.getByRole('button', { name: 'Expand' })); + expect(portalContainer.contains(await screen.findByRole('dialog'))).toBe(true); + portalContainer.remove(); + }); +}); diff --git a/packages/apollo-react/src/chat/wind/WindChatRenderer.tsx b/packages/apollo-react/src/chat/wind/WindChatRenderer.tsx index 02227fcb8b..cd24a91964 100644 --- a/packages/apollo-react/src/chat/wind/WindChatRenderer.tsx +++ b/packages/apollo-react/src/chat/wind/WindChatRenderer.tsx @@ -7,6 +7,7 @@ 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 { DialogContainerContext } from './common/dialog-container'; import { useOpenTransition } from './hooks/use-open-transition'; import { EmbeddedLayout } from './layout/embedded-layout'; import { FullScreenLayout } from './layout/full-screen-layout'; @@ -65,9 +66,13 @@ export function WindChatRenderer() { 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); + // A themed node outside the root, in the host's portal target (e.g. inside a shadow root) or + // the body. With a host target every overlay goes there, as before; otherwise only modal + // dialogs do, and menus and tooltips keep the in-root boundary. + const [overlayNode, setOverlayNode] = React.useState(null); + // `document` is read after mount, so a server render never touches it. + const [mounted, setMounted] = React.useState(false); + React.useEffect(() => setMounted(true), []); if (!present) { return null; @@ -89,15 +94,23 @@ export function WindChatRenderer() { } className={`${themeClass} ${ROOT_SIZE[renderedMode]} flex flex-col overflow-hidden bg-background text-foreground transition-[opacity,translate] duration-(--chat-transition-duration) ease-out data-[state=closed]:translate-y-2 data-[state=closed]:opacity-0 motion-reduce:transition-none`} > - - - - + + + + + + - {portalContainer && + {mounted && createPortal( -
, - portalContainer +
, + portalContainer ?? document.body )}
); diff --git a/packages/apollo-react/src/chat/wind/common/dialog-container.ts b/packages/apollo-react/src/chat/wind/common/dialog-container.ts new file mode 100644 index 0000000000..b461277598 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/common/dialog-container.ts @@ -0,0 +1,10 @@ +import React from 'react'; + +/** + * Where Wind's modal dialogs portal: a node carrying the chat's theme class, in the host's + * `portalContainer` or else `document.body`, so a modal covers the page like Material's instead of + * sitting in the chat root's stacking context. Menus, tooltips and hover cards stay in the root. + */ +export const DialogContainerContext = React.createContext(null); + +export const useDialogContainer = () => React.useContext(DialogContainerContext); diff --git a/packages/apollo-react/src/chat/wind/messages/chat-message.tsx b/packages/apollo-react/src/chat/wind/messages/chat-message.tsx index fffcfd75ab..afdfc60800 100644 --- a/packages/apollo-react/src/chat/wind/messages/chat-message.tsx +++ b/packages/apollo-react/src/chat/wind/messages/chat-message.tsx @@ -8,6 +8,7 @@ import { WIND_MESSAGE_RENDERERS } from '../renderers'; import { InjectedMessage } from './injected-message'; import { MessageActionBar } from './message-action-bar'; import { MessageAttachments } from './message-attachments'; +import { MessageSources } from './message-sources'; // Material's user bubble colour (with the host override it honours) and the leading inset it keeps // from the panel edge; the padding stays the Bubble primitive's. @@ -85,6 +86,7 @@ function ChatMessageComponent({
)) )} + {isLastInGroup && !isUser && } {showActions && ( )} diff --git a/packages/apollo-react/src/chat/wind/messages/message-list-scroll.test.tsx b/packages/apollo-react/src/chat/wind/messages/message-list-scroll.test.tsx index 971edf4801..97c6ec88b5 100644 --- a/packages/apollo-react/src/chat/wind/messages/message-list-scroll.test.tsx +++ b/packages/apollo-react/src/chat/wind/messages/message-list-scroll.test.tsx @@ -111,12 +111,16 @@ afterEach(() => { resizeCallbacks.clear(); }); +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + // Lets the primitive's observers and frames run: mutation callbacks are microtasks, resize -// handling is coalesced into a frame. +// handling is coalesced into a frame. Waits on frames, not wall time, so a busy runner cannot +// cut the chain short. async function settle() { await act(async () => { for (const callback of resizeCallbacks) callback([], {} as ResizeObserver); - await new Promise((resolve) => setTimeout(resolve, 40)); + for (let frame = 0; frame < 4; frame++) await nextFrame(); + await new Promise((resolve) => setTimeout(resolve, 20)); }); } diff --git a/packages/apollo-react/src/chat/wind/messages/message-sources.tsx b/packages/apollo-react/src/chat/wind/messages/message-sources.tsx new file mode 100644 index 0000000000..5d687e2489 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/messages/message-sources.tsx @@ -0,0 +1,55 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { SourceItem, Sources, SourcesList } from '@uipath/apollo-wind'; +import { FileText, Globe } from 'lucide-react'; + +import { useMessageSources, useOpenCitation } from '../../headless/hooks/use-citations'; +import { useIsStreamingMessage } from '../../headless/hooks/use-is-streaming-message'; +import type { AutopilotChatMessage } from '../../service'; + +/** + * The cited sources of a reply's group, collapsed under the reply. Hidden while it streams or + * while the host says more of the group is coming, as in Material. + */ +export function MessageSources({ message }: { message: AutopilotChatMessage }) { + const { _ } = useLingui(); + const { sources, isWaitingForMoreMessages } = useMessageSources(message); + const { isStreaming } = useIsStreamingMessage(message); + const openCitation = useOpenCitation(); + + if (isWaitingForMoreMessages || isStreaming || sources.length === 0) { + return null; + } + + const label = _(msg({ id: 'autopilot-chat.message.sources', message: `Sources` })); + + return ( + label }} + className="mt-2" + > + + {sources.map((source) => { + const page_number = 'page_number' in source ? source.page_number : undefined; + const page = page_number + ? _(msg({ id: 'autopilot-chat.message.page-number', message: `Page ${page_number}` })) + : undefined; + + return ( + : } + onSelect={() => openCitation(source)} + /> + ); + })} + + + ); +} diff --git a/packages/apollo-react/src/chat/wind/renderers/citation-highlights.ts b/packages/apollo-react/src/chat/wind/renderers/citation-highlights.ts new file mode 100644 index 0000000000..51d9f6463a --- /dev/null +++ b/packages/apollo-react/src/chat/wind/renderers/citation-highlights.ts @@ -0,0 +1,48 @@ +import React from 'react'; + +/** + * Cited-range highlights of one markdown message. The highlighter moves React-owned text nodes + * into spans, so a highlight must never be on screen while React re-renders that text: none start + * while the message streams, and any active one is removed before new content renders. + * Post-parity, the CSS Custom Highlight API (see apollo-wind's citation stories) would paint the + * range without touching the DOM and make this unnecessary. + */ +export interface CitationHighlights { + streaming: boolean; + /** Tracks an active highlight's cleanup; returns a function that forgets it. */ + track: (clear: () => void) => () => void; +} + +export const CitationHighlightsContext = React.createContext({ + streaming: false, + track: () => () => {}, +}); + +/** Owns the highlights of a message whose markdown source is `content`. */ +export function useCitationHighlights(content: string, streaming: boolean): CitationHighlights { + const active = React.useRef(new Set<() => void>()); + const clearAll = React.useCallback(() => { + for (const clear of active.current) clear(); + active.current.clear(); + }, []); + + // During render, so the DOM is restored before React commits the new content against it. + const renderedContent = React.useRef(content); + if (renderedContent.current !== content) { + renderedContent.current = content; + clearAll(); + } + + React.useEffect(() => clearAll, [clearAll]); + + return React.useMemo( + () => ({ + streaming, + track: (clear) => { + active.current.add(clear); + return () => active.current.delete(clear); + }, + }), + [streaming] + ); +} diff --git a/packages/apollo-react/src/chat/wind/renderers/code-block-actions.tsx b/packages/apollo-react/src/chat/wind/renderers/code-block-actions.tsx new file mode 100644 index 0000000000..579dc3d6c0 --- /dev/null +++ b/packages/apollo-react/src/chat/wind/renderers/code-block-actions.tsx @@ -0,0 +1,140 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { + Button, + CodeBlock, + Dialog, + DialogClose, + DialogContent, + DialogTitle, + type MarkdownCodeBlock, +} from '@uipath/apollo-wind'; +import { Code, Eye, Maximize2, X } from 'lucide-react'; +import React from 'react'; + +import { buildHtmlPreviewDocument } from '../../headless/markdown/html-preview'; +import { useDialogContainer } from '../common/dialog-container'; + +type View = 'code' | 'preview'; + +// Inline styles and scripts, and images and fonts from data: or https:, cover ordinary snippets; +// nothing else loads (no fetch, no external scripts or stylesheets, no nested frames). +const PREVIEW_CSP = + "default-src 'none'; img-src data: https:; font-src data: https:; style-src 'unsafe-inline'; script-src 'unsafe-inline'"; + +interface CodeBlockActionsProps { + block: MarkdownCodeBlock; + /** `disabledFeatures.htmlPreview` is off, so HTML blocks offer a preview. */ + htmlPreview: boolean; + /** The message is still streaming: the block may be incomplete, so it is not previewed yet. */ + streaming: boolean; +} + +/** + * Header actions for a fenced code block, as in Material: expand to a large view and, for HTML + * when enabled, a preview. The preview is a sandboxed iframe that may run scripts but never shares + * the host's origin. + */ +export function CodeBlockActions({ block, htmlPreview, streaming }: CodeBlockActionsProps) { + const { _ } = useLingui(); + const [view, setView] = React.useState(null); + const dialogContainer = useDialogContainer(); + // Two buttons open the dialog and neither is a DialogTrigger, so focus returns by hand. + const opener = React.useRef(null); + const openAs = (next: View) => (event: React.MouseEvent) => { + opener.current = event.currentTarget; + setView(next); + }; + const previewable = htmlPreview && block.language?.trim().toLowerCase() === 'html'; + // Falls back to the code if the flag is turned off or the block streams again while open. + const showPreview = view === 'preview' && previewable && !streaming; + const codeLabel = _(msg({ id: 'autopilot-chat.message.code-block-code', message: `Show code` })); + const previewLabel = _( + msg({ id: 'autopilot-chat.message.code-block-preview', message: `Preview` }) + ); + const previewTitle = _( + msg({ id: 'autopilot-chat.message.html-preview', message: `HTML preview` }) + ); + const expandLabel = _(msg({ id: 'autopilot-chat.message.expand', message: `Expand` })); + const closeLabel = _(msg({ id: 'autopilot-chat.message.dialog-close', message: `Close` })); + + return ( + <> + {previewable && ( + + )} + + !open && setView(null)}> + { + event.preventDefault(); + opener.current?.focus(); + }} + showCloseButton={false} + aria-describedby={undefined} + className="flex h-[90vh] max-h-[900px] w-[95vw] max-w-[1400px] flex-col gap-3 p-4 sm:max-w-[1400px]" + > +
+ + {showPreview ? previewTitle : block.language?.trim() || codeLabel} + + {previewable && ( + + )} + + + +
+
+ {showPreview ? ( +