feat(ui): render streaming chat markdown live instead of plain text (#106062)

Streaming assistant replies in the Control UI now render markdown live: the in-flight tail is repaired with remend (zero-dep, Apache-2.0, extracted from Vercel's Streamdown) before the existing markdown-it + DOMPurify pipeline, and open code fences stream as live-highlighted code blocks. Deletes the plain-text streaming tail path (toStreamingPlainTextHtml) and extracts an uncached render core so tail deltas stop churning the markdown LRU cache. Closes #106053
This commit is contained in:
Peter Steinberger
2026-07-12 23:11:45 -07:00
committed by GitHub
parent 0633afe6a5
commit fcb45b4400
7 changed files with 168 additions and 167 deletions

68
pnpm-lock.yaml generated
View File

@@ -596,19 +596,6 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/workspaces:
dependencies:
typebox:
specifier: 1.3.3
version: 1.3.3
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
openclaw:
specifier: workspace:*
version: link:../..
extensions/crabbox:
devDependencies:
'@openclaw/plugin-sdk':
@@ -1845,6 +1832,19 @@ importers:
specifier: workspace:*
version: link:../..
extensions/workspaces:
dependencies:
typebox:
specifier: 1.3.3
version: 1.3.3
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
openclaw:
specifier: workspace:*
version: link:../..
extensions/xai:
dependencies:
typebox:
@@ -2009,12 +2009,12 @@ importers:
packages/normalization-core: {}
packages/retry: {}
packages/plugin-package-contract: {}
packages/plugin-sdk: {}
packages/retry: {}
packages/sdk:
dependencies:
'@openclaw/gateway-client':
@@ -2115,6 +2115,9 @@ importers:
marked:
specifier: 18.0.5
version: 18.0.5
remend:
specifier: 1.3.0
version: 1.3.0
devDependencies:
'@types/markdown-it':
specifier: 14.1.2
@@ -7081,6 +7084,9 @@ packages:
remark-rehype@11.1.2:
resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==}
remend@1.3.0:
resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==}
repeat-string@1.6.1:
resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==}
engines: {node: '>=0.10'}
@@ -7291,14 +7297,14 @@ packages:
resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==}
engines: {node: '>=20'}
smol-toml@1.7.0:
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
engines: {node: '>=18'}
smart-buffer@4.2.0:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
smol-toml@1.7.0:
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
engines: {node: '>= 18'}
socks-proxy-agent@8.0.5:
resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==}
engines: {node: '>= 14'}
@@ -9454,6 +9460,15 @@ snapshots:
- bufferutil
- utf-8-validate
'@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3)':
dependencies:
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
'@standard-schema/spec': 1.1.0
zod: 4.4.3
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)':
dependencies:
'@hono/node-server': 1.19.14(hono@4.12.25)
@@ -9476,15 +9491,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@modelcontextprotocol/ext-apps@1.7.4(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.4.3)':
dependencies:
'@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3)
'@standard-schema/spec': 1.1.0
zod: 4.4.3
optionalDependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@mozilla/readability@0.6.0': {}
'@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)':
@@ -13588,6 +13594,8 @@ snapshots:
unified: 11.0.5
vfile: 6.0.3
remend@1.3.0: {}
repeat-string@1.6.1: {}
require-directory@2.1.1: {}
@@ -13845,10 +13853,10 @@ snapshots:
is-fullwidth-code-point: 5.1.0
optional: true
smol-toml@1.7.0: {}
smart-buffer@4.2.0: {}
smol-toml@1.7.0: {}
socks-proxy-agent@8.0.5:
dependencies:
agent-base: 7.1.4

View File

@@ -4566,7 +4566,6 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [
"ui/src/components/markdown.ts: encodeBlockArtCodeBlockCopyPayload",
"ui/src/components/markdown.ts: highlightCode",
"ui/src/components/markdown.ts: md",
"ui/src/components/markdown.ts: toStreamingPlainTextHtml",
"ui/src/components/mcp-app-view.ts: buildMcpAppHostCapabilities",
"ui/src/components/mcp-app-view.ts: McpAppView",
"ui/src/components/mcp-app-view.ts: resolveMcpAppSandboxUrl",

View File

@@ -1181,7 +1181,7 @@
"ui/src/components/github-link-hovercard.ts": 582,
"ui/src/components/icons.ts": 701,
"ui/src/components/lobster-pet.ts": 1790,
"ui/src/components/markdown.ts": 1493,
"ui/src/components/markdown.ts": 1489,
"ui/src/components/terminal/terminal-panel.ts": 1140,
"ui/src/components/tooltip.ts": 502,
"ui/src/lib/agents/display.ts": 661,

View File

@@ -31,7 +31,8 @@
"lit": "3.3.3",
"markdown-it": "14.3.0",
"markdown-it-task-lists": "2.1.1",
"marked": "18.0.5"
"marked": "18.0.5",
"remend": "1.3.0"
},
"devDependencies": {
"@types/markdown-it": "14.1.2",

View File

@@ -9,7 +9,6 @@ import {
md,
toSanitizedMarkdownHtml,
toStreamingMarkdownHtml,
toStreamingPlainTextHtml,
} from "./markdown.ts";
function htmlFragment(html: string): HTMLElement {
@@ -943,28 +942,6 @@ 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(
'<div class="markdown-plain-text-fallback">v2026.5.20 release note\n\nStill readable.</div>',
);
expect(html).not.toContain("cite");
expect(html).not.toContain("turn2view0");
});
it("normalizes Unicode and CR line breaks before escaping streaming text", () => {
const html = toStreamingPlainTextHtml("first\u2028second\u2029third\rfourth\r\nfifth");
expect(html).toBe(
'<div class="markdown-plain-text-fallback">first\nsecond\nthird\nfourth\nfifth</div>',
);
});
});
describe("toStreamingMarkdownHtml", () => {
it("renders streaming raw block art without collapsing quiet-zone spaces", () => {
const blockArt = " ▀▀▀▀ \n ▄▄▄▄ \n ████ ";
@@ -994,40 +971,43 @@ describe("toStreamingMarkdownHtml", () => {
);
});
it("renders completed block prefixes as markdown and keeps the open tail plain", () => {
it("renders completed block prefixes as markdown and closes the streaming tail", () => {
const html = toStreamingMarkdownHtml("## Done\n\nworking **tail");
expect(html).toBe(
'<h2>Done</h2>\n<div class="markdown-plain-text-fallback">working **tail</div>',
);
expect(html).toBe("<h2>Done</h2>\n<p>working <strong>tail</strong></p>\n");
});
it("uses Unicode separators as stable markdown boundaries", () => {
const html = toStreamingMarkdownHtml("## Done\u2028\u2028working **tail");
expect(html).toBe(
'<h2>Done</h2>\n<div class="markdown-plain-text-fallback">working **tail</div>',
);
expect(html).toBe("<h2>Done</h2>\n<p>working <strong>tail</strong></p>\n");
});
it("keeps a single open paragraph as escaped text", () => {
it("renders a single open paragraph as markdown with closed formatting", () => {
const html = toStreamingMarkdownHtml("**still streaming");
expect(html).toBe('<div class="markdown-plain-text-fallback">**still streaming</div>');
expect(html).toBe("<p><strong>still streaming</strong></p>\n");
});
it("does not invoke the markdown parser before a stable block boundary exists", () => {
const renderSpy = vi.spyOn(md, "render");
try {
const html = toStreamingMarkdownHtml("**still streaming parser sentinel");
it("renders half-written links as text only while streaming", () => {
const html = toStreamingMarkdownHtml("see [Streamdown](https://strea");
expect(html).toBe(
'<div class="markdown-plain-text-fallback">**still streaming parser sentinel</div>',
);
expect(renderSpy).not.toHaveBeenCalled();
} finally {
renderSpy.mockRestore();
}
expect(html).toBe("<p>see Streamdown</p>\n");
});
it("streams tables as markdown before the closing row arrives", () => {
const html = toStreamingMarkdownHtml("| left | right |\n| --- | --- |\n| 1 | 2");
const fragment = htmlFragment(html);
expect(fragment.querySelector("table")).not.toBeNull();
expect(fragment.querySelector("th")?.textContent).toBe("left");
expect(html).not.toContain("markdown-plain-text-fallback");
});
it("leaves dollar amounts alone while streaming", () => {
const html = toStreamingMarkdownHtml("prices are $$50 and");
expect(html).toBe("<p>prices are $$50 and</p>\n");
});
it("reuses the rendered stable prefix while only the streaming tail changes", () => {
@@ -1037,35 +1017,58 @@ describe("toStreamingMarkdownHtml", () => {
const second = toStreamingMarkdownHtml("## Streaming cache sentinel\n\nsecond **tail");
expect(first).toContain("<h2>Streaming cache sentinel</h2>");
expect(first).toContain("<p>first <strong>tail</strong></p>");
expect(second).toContain("<h2>Streaming cache sentinel</h2>");
expect(renderSpy).toHaveBeenCalledTimes(1);
expect(second).toContain("<p>second <strong>tail</strong></p>");
// Stable prefix parses once (second call hits the cache); each tail parses fresh.
const stableParses = renderSpy.mock.calls.filter(
([input]) => input === "## Streaming cache sentinel",
);
expect(stableParses).toHaveLength(1);
expect(renderSpy).toHaveBeenCalledTimes(3);
} finally {
renderSpy.mockRestore();
}
});
it("does not parse an open code fence while streaming", () => {
it("streams an open code fence as a live-highlighted code block", () => {
const html = toStreamingMarkdownHtml("Intro\n\n```ts\nconst x = 1 < 2");
const fragment = htmlFragment(html);
expect(html).toBe(
'<p>Intro</p>\n<div class="markdown-plain-text-fallback">```ts\nconst x = 1 &lt; 2</div>',
);
expect(fragment.querySelector("p")?.textContent).toBe("Intro");
expect(fragment.querySelector("code.language-ts")?.textContent).toContain("const x = 1 < 2");
expect(html).not.toContain("markdown-plain-text-fallback");
});
it("keeps an open list code fence streaming through blank lines", () => {
it("streams an open list code fence through blank lines", () => {
const html = toStreamingMarkdownHtml("- ```ts\n const x = 1;\n\n const y = 2;");
const fragment = htmlFragment(html);
const code = fragment.querySelector("li code");
expect(html).toBe(
'<div class="markdown-plain-text-fallback">- ```ts\n const x = 1;\n\n const y = 2;</div>',
);
expect(code?.textContent).toContain("const x = 1;");
expect(code?.textContent).toContain("const y = 2;");
expect(html).not.toContain("markdown-plain-text-fallback");
});
it("keeps an open blockquote code fence streaming through blank lines", () => {
const html = toStreamingMarkdownHtml("> ```ts\n> const x = 1;\n>\n> const y = 2;");
it("keeps completed tilde-fence code out of the remend tail", () => {
// remend only understands ``` fences; a closed ~~~ block must land in the
// stable prefix so its raw markers are never "completed" as inline markdown.
const html = toStreamingMarkdownHtml('~~~ts\nconst s = "**open";\n~~~\ncontinuing **bold');
const fragment = htmlFragment(html);
expect(html).toBe(
'<div class="markdown-plain-text-fallback">&gt; ```ts\n&gt; const x = 1;\n&gt;\n&gt; const y = 2;</div>',
);
expect(fragment.querySelector("code")?.textContent).toContain('const s = "**open";');
expect(fragment.querySelector("code strong")).toBeNull();
expect(fragment.querySelector("p strong")?.textContent).toBe("bold");
});
it("streams an open blockquote code fence through blank lines", () => {
const html = toStreamingMarkdownHtml("> ```ts\n> const x = 1;\n>\n> const y = 2;");
const fragment = htmlFragment(html);
const code = fragment.querySelector("blockquote code");
expect(code?.textContent).toContain("const x = 1;");
expect(code?.textContent).toContain("const y = 2;");
expect(html).not.toContain("markdown-plain-text-fallback");
});
it("renders a completed code fence once the closing fence arrives", () => {

View File

@@ -17,6 +17,7 @@ import xml from "highlight.js/lib/languages/xml";
import yaml from "highlight.js/lib/languages/yaml";
import MarkdownIt from "markdown-it";
import markdownItTaskLists from "markdown-it-task-lists";
import remend, { type RemendOptions } from "remend";
import { stripUnsupportedCitationControlMarkers } from "../../../src/shared/text/citation-control-markers.js";
import { routeIdFromPath } from "../app-route-paths.ts";
import { resolveControlUiBasePath } from "../app/browser.ts";
@@ -689,16 +690,6 @@ function normalizeMarkdownLineBreaks(value: string): string {
return value.replace(/\r\n?|[\u2028\u2029]/g, "\n");
}
function normalizeMarkdownInput(markdownLocal: string): string {
const input = normalizeMarkdownLineBreaks(
stripUnsupportedCitationControlMarkers(markdownLocal),
).trim();
if (!input) {
return "";
}
return formatTruncatedMarkdownInput(input);
}
function formatTruncatedMarkdownInput(input: string): string {
const truncated = truncateText(input, MARKDOWN_CHAR_LIMIT);
return appendMarkdownTruncationNotice(truncated);
@@ -776,7 +767,14 @@ function isFenceClose(line: string, fence: { marker: "`" | "~"; length: number }
return trimmed.slice(match[0].length).trim() === "";
}
function findStableStreamingMarkdownBoundary(markdownLocal: string): number {
type StreamingMarkdownSplit = {
/** Offset just past the last blank line outside a code fence; the prefix is block-stable. */
boundary: number;
/** True when the text after the boundary contains a code fence that has not closed yet. */
tailHasOpenFence: boolean;
};
function splitStableStreamingMarkdown(markdownLocal: string): StreamingMarkdownSplit {
let boundary = 0;
let index = 0;
let openFence: { marker: "`" | "~"; length: number } | null = null;
@@ -808,7 +806,7 @@ function findStableStreamingMarkdownBoundary(markdownLocal: string): number {
index = lineEnd;
}
return boundary;
return { boundary, tailHasOpenFence: openFence !== null };
}
for (const [language, definition, aliases] of [
@@ -1380,6 +1378,36 @@ md.renderer.rules.code_block = (tokens, idx, _options, env) => {
});
};
// Uncached render core shared by the static and streaming paths. The streaming
// tail changes on every delta, so routing it through here (instead of the cached
// wrapper) keeps per-message churn out of the LRU cache.
function renderSanitizedMarkdown(renderInput: string, renderOptions: MarkdownRenderEnv): string {
installHooks();
const truncated = truncateText(renderInput, MARKDOWN_CHAR_LIMIT);
const input = appendMarkdownTruncationNotice(truncated);
if (isMarkdownBlockArtText(truncated.text)) {
return DOMPurify.sanitize(
renderCodeBlock(input, "", renderOptions, { blockArt: true }),
sanitizeOptions,
);
}
if (truncated.text.length > MARKDOWN_PARSE_LIMIT) {
// 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.
return DOMPurify.sanitize(toEscapedPlainTextHtml(input), sanitizeOptions);
}
let rendered: string;
try {
rendered = md.render(input, renderOptions);
} catch (err) {
// Fall back to escaped plain text when md.render() throws (#36213).
console.warn("[markdown] md.render failed, falling back to plain text:", err);
rendered = `<pre class="code-block">${escapeHtml(input)}</pre>`;
}
return DOMPurify.sanitize(rendered, sanitizeOptions);
}
export function toSanitizedMarkdownHtml(
markdownLocal: string,
options: MarkdownRenderOptions = {},
@@ -1392,48 +1420,17 @@ export function toSanitizedMarkdownHtml(
if (!input) {
return "";
}
installHooks();
const renderInput = isMarkdownBlockArtText(rawInput) ? rawInput : input;
const cacheable = input.length <= MARKDOWN_CACHE_MAX_CHARS;
const cacheKey = `${i18n.getLocale()}\0${renderOptions.codeBlockChrome}\0${renderOptions.fileLinks}\0${renderInput}`;
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
if (cacheable) {
const cached = getCachedMarkdown(cacheKey);
if (cached !== null) {
return cached;
}
}
const truncated = truncateText(renderInput, MARKDOWN_CHAR_LIMIT);
if (isMarkdownBlockArtText(truncated.text)) {
const rendered = renderCodeBlock(appendMarkdownTruncationNotice(truncated), "", renderOptions, {
blockArt: true,
});
const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions);
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(cacheKey, sanitized);
}
return sanitized;
}
if (truncated.text.length > MARKDOWN_PARSE_LIMIT) {
// 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 = toEscapedPlainTextHtml(appendMarkdownTruncationNotice(truncated));
const sanitized = DOMPurify.sanitize(html, sanitizeOptions);
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
setCachedMarkdown(cacheKey, sanitized);
}
return sanitized;
}
let rendered: string;
try {
rendered = md.render(appendMarkdownTruncationNotice(truncated), renderOptions);
} catch (err) {
// Fall back to escaped plain text when md.render() throws (#36213).
console.warn("[markdown] md.render failed, falling back to plain text:", err);
const escaped = escapeHtml(appendMarkdownTruncationNotice(truncated));
rendered = `<pre class="code-block">${escaped}</pre>`;
}
const sanitized = DOMPurify.sanitize(rendered, sanitizeOptions);
if (input.length <= MARKDOWN_CACHE_MAX_CHARS) {
const sanitized = renderSanitizedMarkdown(renderInput, renderOptions);
if (cacheable) {
setCachedMarkdown(cacheKey, sanitized);
}
return sanitized;
@@ -1443,33 +1440,33 @@ function toEscapedPlainTextHtml(value: string): string {
return `<div class="markdown-plain-text-fallback">${escapeHtml(normalizeMarkdownLineBreaks(value))}</div>`;
}
export function toStreamingPlainTextHtml(markdownLocal: string): string {
const input = normalizeMarkdownInput(markdownLocal);
if (!input) {
return "";
}
return toEscapedPlainTextHtml(input);
// Streaming-tail repair config: math is not rendered by this pipeline, so
// completing `$$` would inject visible characters into ordinary prose.
const streamingRemendOptions = { katex: false, linkMode: "text-only" } satisfies RemendOptions;
// Renders the in-flight block live. remend closes/strips unterminated inline
// constructs (`**bold`, half links, …) so partially streamed markup styles
// immediately instead of flashing raw markers. Inside an open code fence the
// tail is code, not prose: skip remend (it only understands top-level ```
// fences) and let markdown-it auto-close the fence at end of input (CommonMark
// allows unterminated fences), so code streams with live highlighting.
// Invariant: the tail never contains a *closed* fence — the split boundary
// advances past every fence close — so remend (which cannot see ~~~ fences)
// never runs across completed fenced code.
function toStreamingTailHtml(tail: string, renderOptions: MarkdownRenderEnv): string {
return renderSanitizedMarkdown(remend(tail, streamingRemendOptions), renderOptions);
}
export function toStreamingMarkdownHtml(
markdownLocal: string,
options: MarkdownRenderOptions = {},
): string {
const renderOptions = normalizeMarkdownRenderOptions(options);
const rawInput = normalizeMarkdownLineBreaks(
stripUnsupportedCitationControlMarkers(markdownLocal),
);
if (isMarkdownBlockArtText(rawInput)) {
const truncated = truncateText(rawInput, MARKDOWN_CHAR_LIMIT);
installHooks();
return DOMPurify.sanitize(
renderCodeBlock(
appendMarkdownTruncationNotice(truncated),
"",
normalizeMarkdownRenderOptions(options),
{ blockArt: true },
),
sanitizeOptions,
);
return renderSanitizedMarkdown(rawInput, renderOptions);
}
const trimmedInput = rawInput.trim();
@@ -1478,16 +1475,15 @@ export function toStreamingMarkdownHtml(
}
const input = formatTruncatedMarkdownInput(trimmedInput);
const boundary = findStableStreamingMarkdownBoundary(input);
if (boundary <= 0) {
return toEscapedPlainTextHtml(input);
}
const { boundary, tailHasOpenFence } = splitStableStreamingMarkdown(input);
const stableMarkdown = input.slice(0, boundary);
const streamingTail = input.slice(boundary);
const stableHtml = toSanitizedMarkdownHtml(stableMarkdown, options);
const stableHtml = boundary > 0 ? toSanitizedMarkdownHtml(stableMarkdown, options) : "";
if (!streamingTail.trim()) {
return stableHtml;
}
return `${stableHtml}${toEscapedPlainTextHtml(streamingTail)}`;
const tailHtml = tailHasOpenFence
? renderSanitizedMarkdown(streamingTail, renderOptions)
: toStreamingTailHtml(streamingTail, renderOptions);
return `${stableHtml}${tailHtml}`;
}

View File

@@ -19,9 +19,6 @@ const markdownRenderMock = vi.hoisted(() =>
(value: string, _options?: { codeBlockChrome?: "copy" | "none"; fileLinks?: boolean }) => value,
),
);
const streamingTextRenderMock = vi.hoisted(() =>
vi.fn((value: string) => `<div class="markdown-plain-text-fallback">${value}</div>`),
);
const streamingMarkdownRenderMock = vi.hoisted(() =>
vi.fn(
(value: string, _options?: { codeBlockChrome?: "copy" | "none"; fileLinks?: boolean }) =>
@@ -43,7 +40,6 @@ vi.mock("../../../components/markdown.ts", async (importOriginal) => {
...actual,
toSanitizedMarkdownHtml: markdownRenderMock,
toStreamingMarkdownHtml: streamingMarkdownRenderMock,
toStreamingPlainTextHtml: streamingTextRenderMock,
};
});
@@ -1005,7 +1001,6 @@ describe("grouped chat rendering", () => {
const container = document.createElement("div");
markdownRenderMock.mockClear();
streamingMarkdownRenderMock.mockClear();
streamingTextRenderMock.mockClear();
render(
renderStreamGroup([
@@ -1021,7 +1016,6 @@ describe("grouped chat rendering", () => {
);
expect(markdownRenderMock).not.toHaveBeenCalled();
expect(streamingTextRenderMock).not.toHaveBeenCalled();
expect(streamingMarkdownRenderMock).toHaveBeenCalledWith("**live**\nreply", {
codeBlockChrome: "copy",
fileLinks: true,