diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 90eb3e3bee..0e91afcc07 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -44,7 +44,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, strea
{blocks.map((block, i) => { switch (block.kind) { - case 'text': return + case 'text': return case 'reasoning': return // Tool-call heads render as tool rows in the chat view's grouping pass. case 'tool-call': return null diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index f74e939246..79ebc5f1b2 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -24,7 +24,9 @@ function sanitizeUrl(url: string): string { const safeUrl: UrlTransform = url => sanitizeUrl(url) -const components: Components = { +/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */ +function buildComponents(streaming: boolean): Components { + return { a: ({ href = '', children }) => { const safeHref = sanitizeUrl(href) if (safeHref === '') return <>{children} @@ -44,32 +46,40 @@ const components: Components = { {children}
), - // Fenced blocks route through the shared CodeBlock (shiki for registered - // grammars, identical-geometry plain fallback for unknown/absent languages); - // inline code keeps the default path (the :not(pre) rule styles it). - pre: ({ children }) => { - const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined - const raw = child?.props.children - const text = typeof raw === 'string' ? raw : Array.isArray(raw) && typeof raw[0] === 'string' ? raw[0] : undefined - // A fence whose content isn't one plain string (never produced by the - // markdown pipeline) keeps the stock
 rather than guessing.
-    if (text === undefined) return 
{children}
- const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1] - return - }, + // Fenced blocks route through the shared CodeBlock (shiki for registered + // grammars, identical-geometry plain fallback for unknown/absent + // languages); inline code keeps the default path (the :not(pre) + // rule styles it). While the message streams, the fence renders the + // plain arm — retokenizing a growing fence on every chunk is quadratic + // main-thread work; the finalize swap highlights it once. + pre: ({ children }) => { + const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined + const raw = child?.props.children + const text = typeof raw === 'string' ? raw : Array.isArray(raw) && typeof raw[0] === 'string' ? raw[0] : undefined + // A fence whose content isn't one plain string (never produced by the + // markdown pipeline) keeps the stock
 rather than guessing.
+      if (text === undefined) return 
{children}
+ const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1] + return + }, + } } +const staticComponents = buildComponents(false) +const streamingComponents = buildComponents(true) + /** * Render untrusted assistant-authored Markdown as semantic React elements. - * @param props - Markdown source text preserved by the session projection. + * @param props - Markdown source text preserved by the session projection; + * `streaming` renders fences plain (highlighting lands on the finalize swap). * @returns A GFM document with raw HTML, relative links, unsafe protocols, and remote images disabled. */ -export function MarkdownText({ text }: { text: string }) { +export function MarkdownText({ text, streaming = false }: { text: string; streaming?: boolean }) { return (
{text} diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index dcbf613005..1bd629a7d0 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -64,6 +64,16 @@ describe('MarkdownText', () => { expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy() }) + it('streaming renders fences plain; the finalize swap highlights them', () => { + const fence = '```ts\nconst answer = 42\n```' + const live = render() + expect(live.container.querySelector('pre.shiki')).toBeNull() + expect(live.container.querySelector('pre code')?.textContent).toContain('const answer = 42') + live.unmount() + const done = render() + expect(done.container.querySelector('pre.shiki')).not.toBeNull() + }) + it('neutralizes raw HTML, unsafe or relative links, and remote images', () => { const markdown = [ '',