Skip to content

Commit 732748c

Browse files
committed
perf(ui): skip markdown parsing while chat streams
1 parent fda5254 commit 732748c

5 files changed

Lines changed: 76 additions & 12 deletions

File tree

ui/src/styles/chat/text.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@
2929
margin: 0;
3030
}
3131

32+
.markdown-plain-text-fallback {
33+
display: block;
34+
white-space: pre-wrap;
35+
overflow-wrap: anywhere;
36+
word-break: break-word;
37+
font: inherit;
38+
}
39+
3240
.chat-text :where(table) {
3341
display: block;
3442
max-width: 100%;

ui/src/ui/chat/grouped-render.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const localStorageValues = vi.hoisted(() => new Map<string, string>());
1515
const markdownRenderMock = vi.hoisted(() =>
1616
vi.fn((value: string, _options?: { codeBlockChrome?: "copy" | "none" }) => value),
1717
);
18+
const streamingTextRenderMock = vi.hoisted(() =>
19+
vi.fn((value: string) => `<div class="markdown-plain-text-fallback">${value}</div>`),
20+
);
1821

1922
vi.mock("../../local-storage.ts", () => ({
2023
getSafeLocalStorage: () => ({
@@ -26,6 +29,7 @@ vi.mock("../../local-storage.ts", () => ({
2629

2730
vi.mock("../markdown.ts", () => ({
2831
toSanitizedMarkdownHtml: markdownRenderMock,
32+
toStreamingPlainTextHtml: streamingTextRenderMock,
2933
}));
3034

3135
vi.mock("../icons.ts", () => ({
@@ -887,6 +891,19 @@ describe("grouped chat rendering", () => {
887891
expect(bubble?.classList.contains("streaming")).toBe(false);
888892
});
889893

894+
it("renders streaming text without markdown sanitization", () => {
895+
const container = document.createElement("div");
896+
markdownRenderMock.mockClear();
897+
streamingTextRenderMock.mockClear();
898+
899+
render(renderStreamingGroup("**live**\nreply", 1), container);
900+
901+
expect(markdownRenderMock).not.toHaveBeenCalled();
902+
expect(streamingTextRenderMock).toHaveBeenCalledWith("**live**\nreply");
903+
const text = container.querySelector(".markdown-plain-text-fallback");
904+
expect(text?.textContent).toBe("**live**\nreply");
905+
});
906+
890907
it("renders configured local user names", () => {
891908
const renderUser = (opts: Partial<RenderMessageGroupOptions>) => {
892909
const container = document.createElement("div");

ui/src/ui/chat/grouped-render.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { getSafeLocalStorage } from "../../local-storage.ts";
55
import type { AssistantIdentity } from "../assistant-identity.ts";
66
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
77
import { icons } from "../icons.ts";
8-
import { toSanitizedMarkdownHtml } from "../markdown.ts";
8+
import { toSanitizedMarkdownHtml, toStreamingPlainTextHtml } from "../markdown.ts";
99
import { openExternalUrlSafe } from "../open-external-url.ts";
1010
import type { SidebarContent } from "../sidebar-content.ts";
1111
import { detectTextDirection } from "../text-direction.ts";
@@ -1813,11 +1813,7 @@ function renderGroupedMessage(
18131813
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
18141814
</details>`
18151815
: markdown
1816-
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">
1817-
${unsafeHTML(
1818-
toSanitizedMarkdownHtml(markdown, markdownRenderOptions),
1819-
)}
1820-
</div>`
1816+
? renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions)
18211817
: nothing}
18221818
${hasToolCards
18231819
? singleToolCard && !markdown && !hasImages
@@ -1880,9 +1876,7 @@ function renderGroupedMessage(
18801876
<pre class="chat-json-content"><code>${jsonResult.pretty}</code></pre>
18811877
</details>`
18821878
: markdown
1883-
? html`<div class="chat-text" dir="${detectTextDirection(markdown)}">
1884-
${unsafeHTML(toSanitizedMarkdownHtml(markdown, markdownRenderOptions))}
1885-
</div>`
1879+
? renderMarkdownText(markdown, opts.isStreaming, markdownRenderOptions)
18861880
: nothing}
18871881
${hasToolCards
18881882
? renderInlineToolCards(toolCards, {
@@ -1910,3 +1904,22 @@ function renderGroupedMessage(
19101904
</div>
19111905
`;
19121906
}
1907+
1908+
function renderMarkdownText(
1909+
markdown: string,
1910+
isStreaming: boolean,
1911+
markdownRenderOptions?: { codeBlockChrome: "copy" | "none" },
1912+
) {
1913+
if (isStreaming) {
1914+
return html`
1915+
<div class="chat-text" dir="${detectTextDirection(markdown)}">
1916+
${unsafeHTML(toStreamingPlainTextHtml(markdown))}
1917+
</div>
1918+
`;
1919+
}
1920+
return html`
1921+
<div class="chat-text" dir="${detectTextDirection(markdown)}">
1922+
${unsafeHTML(toSanitizedMarkdownHtml(markdown, markdownRenderOptions))}
1923+
</div>
1924+
`;
1925+
}

ui/src/ui/markdown.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { render } from "lit";
22
import { describe, expect, it, vi } from "vitest";
33
import { i18n } from "../i18n/index.ts";
4-
import { md, toSanitizedMarkdownHtml } from "./markdown.ts";
4+
import { md, toSanitizedMarkdownHtml, toStreamingPlainTextHtml } from "./markdown.ts";
55
import { renderMarkdownSidebar } from "./views/markdown-sidebar.ts";
66

77
function htmlFragment(html: string): HTMLElement {
@@ -685,6 +685,20 @@ PY
685685
});
686686
});
687687

688+
describe("toStreamingPlainTextHtml", () => {
689+
it("strips unsupported citation control markers before escaping streaming text", () => {
690+
const html = toStreamingPlainTextHtml(
691+
"v2026.5.20 release note citeturn2view0\n\nStill readable.",
692+
);
693+
694+
expect(html).toBe(
695+
'<div class="markdown-plain-text-fallback">v2026.5.20 release note\n\nStill readable.</div>',
696+
);
697+
expect(html).not.toContain("cite");
698+
expect(html).not.toContain("turn2view0");
699+
});
700+
});
701+
688702
describe("renderMarkdownSidebar", () => {
689703
it("renders sanitized markdown content", () => {
690704
const container = document.createElement("div");

ui/src/ui/markdown.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,7 @@ export function toSanitizedMarkdownHtml(
634634
// Large plain-text replies should stay readable without inheriting the
635635
// capped code-block chrome, while still preserving whitespace for logs
636636
// and other structured text that commonly trips the parse guard.
637-
const html = renderEscapedPlainTextHtml(`${truncated.text}${suffix}`);
637+
const html = toEscapedPlainTextHtml(`${truncated.text}${suffix}`);
638638
const sanitized = DOMPurify.sanitize(html, sanitizeOptions);
639639
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
640640
setCachedMarkdown(cacheKey, sanitized);
@@ -657,6 +657,18 @@ export function toSanitizedMarkdownHtml(
657657
return sanitized;
658658
}
659659

660-
function renderEscapedPlainTextHtml(value: string): string {
660+
export function toEscapedPlainTextHtml(value: string): string {
661661
return `<div class="markdown-plain-text-fallback">${escapeHtml(value.replace(/\r\n?/g, "\n"))}</div>`;
662662
}
663+
664+
export function toStreamingPlainTextHtml(markdownLocal: string): string {
665+
const input = stripUnsupportedCitationControlMarkers(markdownLocal).trim();
666+
if (!input) {
667+
return "";
668+
}
669+
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
670+
const suffix = truncated.truncated
671+
? `\n\n… truncated (${truncated.total} chars, showing first ${truncated.text.length}).`
672+
: "";
673+
return toEscapedPlainTextHtml(`${truncated.text}${suffix}`);
674+
}

0 commit comments

Comments
 (0)