From 732748c8c5cbf4ac930fd2e9375d43fa343b1afd Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 01:59:59 +0100 Subject: [PATCH] perf(ui): skip markdown parsing while chat streams --- ui/src/styles/chat/text.css | 8 +++++++ ui/src/ui/chat/grouped-render.test.ts | 17 +++++++++++++++ ui/src/ui/chat/grouped-render.ts | 31 +++++++++++++++++++-------- ui/src/ui/markdown.test.ts | 16 +++++++++++++- ui/src/ui/markdown.ts | 16 ++++++++++++-- 5 files changed, 76 insertions(+), 12 deletions(-) diff --git a/ui/src/styles/chat/text.css b/ui/src/styles/chat/text.css index 03de2876f95..3b57db75119 100644 --- a/ui/src/styles/chat/text.css +++ b/ui/src/styles/chat/text.css @@ -29,6 +29,14 @@ margin: 0; } +.markdown-plain-text-fallback { + display: block; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + font: inherit; +} + .chat-text :where(table) { display: block; max-width: 100%; diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 93eeb023109..e0d3c94a0f8 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -15,6 +15,9 @@ const localStorageValues = vi.hoisted(() => new Map()); const markdownRenderMock = vi.hoisted(() => vi.fn((value: string, _options?: { codeBlockChrome?: "copy" | "none" }) => value), ); +const streamingTextRenderMock = vi.hoisted(() => + vi.fn((value: string) => `
${value}
`), +); vi.mock("../../local-storage.ts", () => ({ getSafeLocalStorage: () => ({ @@ -26,6 +29,7 @@ vi.mock("../../local-storage.ts", () => ({ vi.mock("../markdown.ts", () => ({ toSanitizedMarkdownHtml: markdownRenderMock, + toStreamingPlainTextHtml: streamingTextRenderMock, })); vi.mock("../icons.ts", () => ({ @@ -887,6 +891,19 @@ describe("grouped chat rendering", () => { expect(bubble?.classList.contains("streaming")).toBe(false); }); + it("renders streaming text without markdown sanitization", () => { + const container = document.createElement("div"); + markdownRenderMock.mockClear(); + streamingTextRenderMock.mockClear(); + + render(renderStreamingGroup("**live**\nreply", 1), container); + + expect(markdownRenderMock).not.toHaveBeenCalled(); + expect(streamingTextRenderMock).toHaveBeenCalledWith("**live**\nreply"); + const text = container.querySelector(".markdown-plain-text-fallback"); + expect(text?.textContent).toBe("**live**\nreply"); + }); + it("renders configured local user names", () => { const renderUser = (opts: Partial) => { const container = document.createElement("div"); diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index af97824e28a..1097537f92a 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -5,7 +5,7 @@ import { getSafeLocalStorage } from "../../local-storage.ts"; import type { AssistantIdentity } from "../assistant-identity.ts"; import type { EmbedSandboxMode } from "../embed-sandbox.ts"; import { icons } from "../icons.ts"; -import { toSanitizedMarkdownHtml } from "../markdown.ts"; +import { toSanitizedMarkdownHtml, toStreamingPlainTextHtml } from "../markdown.ts"; import { openExternalUrlSafe } from "../open-external-url.ts"; import type { SidebarContent } from "../sidebar-content.ts"; import { detectTextDirection } from "../text-direction.ts"; @@ -1813,11 +1813,7 @@ function renderGroupedMessage(
${jsonResult.pretty}
` : markdown - ? html`
- ${unsafeHTML( - toSanitizedMarkdownHtml(markdown, markdownRenderOptions), - )} -
` + ? renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions) : nothing} ${hasToolCards ? singleToolCard && !markdown && !hasImages @@ -1880,9 +1876,7 @@ function renderGroupedMessage(
${jsonResult.pretty}
` : markdown - ? html`
- ${unsafeHTML(toSanitizedMarkdownHtml(markdown, markdownRenderOptions))} -
` + ? renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions) : nothing} ${hasToolCards ? renderInlineToolCards(toolCards, { @@ -1910,3 +1904,22 @@ function renderGroupedMessage( `; } + +function renderMarkdownText( + markdown: string, + isStreaming: boolean, + markdownRenderOptions?: { codeBlockChrome: "copy" | "none" }, +) { + if (isStreaming) { + return html` +
+ ${unsafeHTML(toStreamingPlainTextHtml(markdown))} +
+ `; + } + return html` +
+ ${unsafeHTML(toSanitizedMarkdownHtml(markdown, markdownRenderOptions))} +
+ `; +} diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index cac1c20b829..976c2489941 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -1,7 +1,7 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import { i18n } from "../i18n/index.ts"; -import { md, toSanitizedMarkdownHtml } from "./markdown.ts"; +import { md, toSanitizedMarkdownHtml, toStreamingPlainTextHtml } from "./markdown.ts"; import { renderMarkdownSidebar } from "./views/markdown-sidebar.ts"; function htmlFragment(html: string): HTMLElement { @@ -685,6 +685,20 @@ PY }); }); +describe("toStreamingPlainTextHtml", () => { + it("strips unsupported citation control markers before escaping streaming text", () => { + const html = toStreamingPlainTextHtml( + "v2026.5.20 release note citeturn2view0\n\nStill readable.", + ); + + expect(html).toBe( + '
v2026.5.20 release note\n\nStill readable.
', + ); + expect(html).not.toContain("cite"); + expect(html).not.toContain("turn2view0"); + }); +}); + describe("renderMarkdownSidebar", () => { it("renders sanitized markdown content", () => { const container = document.createElement("div"); diff --git a/ui/src/ui/markdown.ts b/ui/src/ui/markdown.ts index 0adf2d02fbd..01cef0ebf79 100644 --- a/ui/src/ui/markdown.ts +++ b/ui/src/ui/markdown.ts @@ -634,7 +634,7 @@ export function toSanitizedMarkdownHtml( // Large plain-text replies should stay readable without inheriting the // capped code-block chrome, while still preserving whitespace for logs // and other structured text that commonly trips the parse guard. - const html = renderEscapedPlainTextHtml(`${truncated.text}${suffix}`); + const html = toEscapedPlainTextHtml(`${truncated.text}${suffix}`); const sanitized = DOMPurify.sanitize(html, sanitizeOptions); if (input.length <= MARKDOWN_CACHE_MAX_CHARS) { setCachedMarkdown(cacheKey, sanitized); @@ -657,6 +657,18 @@ export function toSanitizedMarkdownHtml( return sanitized; } -function renderEscapedPlainTextHtml(value: string): string { +export function toEscapedPlainTextHtml(value: string): string { return `
${escapeHtml(value.replace(/\r\n?/g, "\n"))}
`; } + +export function toStreamingPlainTextHtml(markdownLocal: string): string { + const input = stripUnsupportedCitationControlMarkers(markdownLocal).trim(); + if (!input) { + return ""; + } + const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT); + const suffix = truncated.truncated + ? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).` + : ""; + return toEscapedPlainTextHtml(`${truncated.text}${suffix}`); +}