diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 0dfdf740a0..059aa30bae 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -172,21 +172,6 @@ const AssistantMessageBody: FC = ({ onBranchInNewChat, on // stable across the 30 Hz delta stream, so this adds no per-token renders). const turnDurationS = useAuiState(s => s.message.metadata?.custom?.durationS as number | undefined) - // Preview targets only materialize once the turn completes — while running - // the selector returns '' (stable), so per-token flushes skip the regex - // scan and the re-render it would cause. - const completedText = useAuiState(s => - s.message.status?.type === 'running' ? '' : messageContentText(s.message.content) - ) - - const previewTargets = useMemo(() => { - if (!completedText || !/(https?:\/\/|file:\/\/)/i.test(completedText)) { - return [] - } - - return pickPrimaryPreviewTarget(extractPreviewTargets(completedText)) - }, [completedText]) - const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime]) // useEnterAnimation consults `enabled` ONLY when its callback ref fires, @@ -219,13 +204,7 @@ const AssistantMessageBody: FC = ({ onBranchInNewChat, on {/* Todos render in the composer status stack now, not inline. */} {MESSAGE_PARTS} - {previewTargets.length > 0 && ( -
- {previewTargets.map(target => ( - - ))} -
- )} + { return } +/** + * PERF leaf: owns the settled-text selector that feeds the link previews. + * + * This was the last status-dependent read at the message root, and the most + * expensive one: the selector flips between '' while running and the full + * `messageContentText(content)` join once settled, so every running <-> settled + * transition re-ran the join for the whole message AND re-rendered the root. + * At stream breadth N that is N joins plus N root re-renders per flip. Reading + * it here confines both to this leaf, which renders nothing at all in the + * common case. + * + * The streaming-side optimization is unchanged and still the point of the '' + * branch: preview targets only materialize once the turn completes, so while + * running the selector returns a stable '' and per-token flushes skip the + * regex scan and the re-render it would cause. + * + * Renders exactly what the root used to render at this position — the same + * wrapper div with the same classes, or nothing when there are no targets — + * so the DOM is byte-identical either way. A component boundary adds no node + * of its own, so unlike StreamingMarker this needs no placement care. + */ +const AssistantPreviewEmbeds: FC = () => { + const completedText = useAuiState(s => + s.message.status?.type === 'running' ? '' : messageContentText(s.message.content) + ) + + const previewTargets = useMemo(() => { + if (!completedText || !/(https?:\/\/|file:\/\/)/i.test(completedText)) { + return [] + } + + return pickPrimaryPreviewTarget(extractPreviewTargets(completedText)) + }, [completedText]) + + if (previewTargets.length === 0) { + return null + } + + return ( +
+ {previewTargets.map(target => ( + + ))} +
+ ) +} + /** * PERF leaf: owns the `settledParts` selector so the tail's settle stops * re-rendering the message root. This is the one status-derived selector that diff --git a/apps/desktop/src/components/assistant-ui/thread/preview-embeds.test.tsx b/apps/desktop/src/components/assistant-ui/thread/preview-embeds.test.tsx new file mode 100644 index 0000000000..6b1a38d105 --- /dev/null +++ b/apps/desktop/src/components/assistant-ui/thread/preview-embeds.test.tsx @@ -0,0 +1,100 @@ +// Link previews moved off the message root into the AssistantPreviewEmbeds +// leaf, because the selector behind them (`'' while running`, the full +// `messageContentText(content)` join once settled) flipped on every +// running <-> settled transition and re-rendered the root with it. +// +// Two things are pinned here. That the embed still renders at all — the move +// was verbatim JSX and had no coverage before. And that it renders ONLY once +// the turn settles: the '' branch is a deliberate streaming optimization (it +// keeps the selector referentially stable so per-token flushes skip the regex +// scan), so a rewrite that drops it would make previews flicker in mid-stream +// with nothing to catch it. +import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react' +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { Thread } from '.' + +const createdAt = new Date('2026-05-01T00:00:00.000Z') + +class TestResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} + +vi.stubGlobal('ResizeObserver', TestResizeObserver) +vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0) +) +vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id)) +vi.stubGlobal('CSS', { escape: (str: string) => str }) + +Element.prototype.scrollTo = function scrollTo() {} + +Element.prototype.animate = function animate() { + return { cancel() {}, finished: Promise.resolve() } as unknown as Animation +} + +afterEach(() => { + cleanup() +}) + +const assistantMetadata = { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} } + +function user(id: string, text: string): ThreadMessage { + return { + id, + role: 'user', + content: [{ type: 'text', text }], + attachments: [], + createdAt, + metadata: { custom: {} } + } as ThreadMessage +} + +function assistant(id: string, text: string, running: boolean): ThreadMessage { + return { + id, + role: 'assistant', + content: text ? [{ type: 'text', text }] : [], + status: running ? { type: 'running' } : { type: 'complete', reason: 'stop' }, + createdAt, + metadata: assistantMetadata + } as ThreadMessage +} + +function Harness({ messages }: { messages: ThreadMessage[] }) { + const runtime = useExternalStoreRuntime({ + messages, + isRunning: messages.at(-1)?.status?.type === 'running', + onNew: async () => {} + }) + + return ( + + + + ) +} + +const TARGET = 'https://example.com/docs' +const WITH_PREVIEW = `Serving now: [Preview: example](#preview/${TARGET})` + +describe('settled-turn link previews', () => { + it('renders the embed once the turn has settled', async () => { + const { container } = render() + + await screen.findByText('Serving now:', { exact: false }) + + expect(container.querySelector(`[title="${TARGET}"]`)).toBeTruthy() + }) + + it('does not render the embed while the turn is still running', async () => { + const { container } = render() + + await screen.findByText('Serving now:', { exact: false }) + + expect(container.querySelector(`[title="${TARGET}"]`)).toBeNull() + }) +})