diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 18ed233d80b3..22c8182bf99b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs
index 6c465504fce8..9e1616ac3680 100644
--- a/scripts/deadcode-exports.baseline.mjs
+++ b/scripts/deadcode-exports.baseline.mjs
@@ -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",
diff --git a/scripts/ts-max-loc-baseline-v2.json b/scripts/ts-max-loc-baseline-v2.json
index 221d4f377c0f..726a3ddcbcf3 100644
--- a/scripts/ts-max-loc-baseline-v2.json
+++ b/scripts/ts-max-loc-baseline-v2.json
@@ -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,
diff --git a/ui/package.json b/ui/package.json
index 705cab004465..7c588c165fc2 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -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",
diff --git a/ui/src/components/markdown.test.ts b/ui/src/components/markdown.test.ts
index f22da48d34df..9937ddd52c1e 100644
--- a/ui/src/components/markdown.test.ts
+++ b/ui/src/components/markdown.test.ts
@@ -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 citeturn2view0\n\nStill readable.",
- );
-
- expect(html).toBe(
- '
v2026.5.20 release note\n\nStill readable.
',
- );
- 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(
- 'first\nsecond\nthird\nfourth\nfifth
',
- );
- });
-});
-
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(
- 'Done
\nworking **tail
',
- );
+ expect(html).toBe("Done
\nworking tail
\n");
});
it("uses Unicode separators as stable markdown boundaries", () => {
const html = toStreamingMarkdownHtml("## Done\u2028\u2028working **tail");
- expect(html).toBe(
- 'Done
\nworking **tail
',
- );
+ expect(html).toBe("Done
\nworking tail
\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('**still streaming
');
+ expect(html).toBe("still streaming
\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(
- '**still streaming parser sentinel
',
- );
- expect(renderSpy).not.toHaveBeenCalled();
- } finally {
- renderSpy.mockRestore();
- }
+ expect(html).toBe("see Streamdown
\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("prices are $$50 and
\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("Streaming cache sentinel
");
+ expect(first).toContain("first tail
");
expect(second).toContain("Streaming cache sentinel
");
- expect(renderSpy).toHaveBeenCalledTimes(1);
+ expect(second).toContain("second tail
");
+ // 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(
- 'Intro
\n```ts\nconst x = 1 < 2
',
- );
+ 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(
- '- ```ts\n const x = 1;\n\n const y = 2;
',
- );
+ 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(
- '> ```ts\n> const x = 1;\n>\n> const y = 2;
',
- );
+ 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", () => {
diff --git a/ui/src/components/markdown.ts b/ui/src/components/markdown.ts
index cffa80a46f93..328ca4258058 100644
--- a/ui/src/components/markdown.ts
+++ b/ui/src/components/markdown.ts
@@ -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 = `${escapeHtml(input)}`;
+ }
+ 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 = `${escaped}`;
- }
- 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 `${escapeHtml(normalizeMarkdownLineBreaks(value))}
`;
}
-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}`;
}
diff --git a/ui/src/pages/chat/components/chat-message.test.ts b/ui/src/pages/chat/components/chat-message.test.ts
index 1c9a9888a8c5..459c41c8bd0b 100644
--- a/ui/src/pages/chat/components/chat-message.test.ts
+++ b/ui/src/pages/chat/components/chat-message.test.ts
@@ -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) => `${value}
`),
-);
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,