From e6a58d369da3de7b118c0b7f79ad04014cfdc406 Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:26:00 +0200 Subject: [PATCH 1/6] fix(web): render completed Mermaid blocks while streaming --- apps/web/src/components/ChatMarkdown.test.tsx | 68 +++++++++++++++++-- apps/web/src/components/ChatMarkdown.tsx | 12 ++-- .../src/components/chat/MermaidDiagram.tsx | 2 +- apps/web/src/index.css | 3 +- 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index d55986a9589e..fe752f6bf2e2 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -18,11 +18,22 @@ import { Button } from "./ui/button"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; vi.mock("../hooks/useFileMetadata", () => ({ useFileMetadata: () => null })); -vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); -vi.mock("./chat/MermaidDiagram", () => ({ - // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG. - MermaidDiagram: () => , +const { renderDiagram } = vi.hoisted(() => ({ + renderDiagram: vi.fn<(source: string) => Promise | undefined>(), })); +vi.mock("./chat/MermaidDiagram", async () => { + const { use } = await import("react"); + return { + // Real Mermaid needs layout APIs jsdom lacks; a rendered diagram is an SVG. + // Tests that return a promise from renderDiagram control when it appears. + MermaidDiagram({ source }: { source: string }) { + const pending = renderDiagram(source); + return ; + }, + }; +}); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => null })); vi.mock("../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); vi.mock("../hooks/useSettings", async (importOriginal) => { const actual = await importOriginal(); @@ -171,6 +182,55 @@ describe("ChatMarkdown bare anchor placeholders", () => { }); }); +describe("ChatMarkdown Mermaid streaming", () => { + it.each([ + ["```mermaid\nflowchart LR\nA --> B", "\n```"], + ["~~~~mermaid\nflowchart LR\nA --> B\n~~~", "\n~~~~"], + ["> ```mermaid\n> flowchart LR\n> A --> B", "\n> ```"], + ["- Diagram\n\n ```mermaid\n flowchart LR\n A --> B", "\n ```"], + ])("reveals a finished fence before the message finishes: %s", async (open, closing) => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + let finishRender!: (label: string) => void; + const pending = new Promise((resolve) => { + finishRender = resolve; + }); + renderDiagram.mockReset().mockReturnValue(pending); + const message = (text: string, isStreaming = true) => ( + + ); + const closed = `${open}${closing}\n\n`; + try { + await act(async () => { + renderer = create(message(open)); + }); + expect(renderDiagram).not.toHaveBeenCalled(); + await act(async () => { + renderer!.update(message(closed)); + }); + expect(renderDiagram).toHaveBeenCalled(); + expect(renderer!.root.findAllByProps({ role: "img" })).toHaveLength(0); + expect(renderer!.root.findAllByProps({ className: "chat-markdown-shiki" })).toHaveLength(0); + expect(renderer!.root.findByType("pre").props["aria-hidden"]).toBe(true); + + await act(async () => { + finishRender("Completed diagram"); + }); + const diagram = renderer!.root.findByProps({ role: "img" }); + for (const streaming of [true, false]) { + await act(async () => { + renderer!.update(message(`${closed}The remaining explanation.`, streaming)); + }); + expect(renderer!.root.findByProps({ role: "img" })).toBe(diagram); + } + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + renderDiagram.mockReset(); + } + }); +}); + describe("ChatMarkdown context references", () => { it("renders text and image references through the chip renderer, with readable fallback", async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index a7a2b0c4eba2..e4ab9dbec675 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1008,8 +1008,8 @@ function MarkdownCodeBlock({ } /** - * Mermaid fences render as a diagram once the response settles; streaming and - * the code toggle keep the highlighted source. + * Completed Mermaid fences render while the response continues. Unfinished + * fences and the code toggle keep the highlighted source. */ function MarkdownMermaidCodeBlock({ code, @@ -1066,9 +1066,9 @@ function MarkdownMermaidCodeBlock({ - Rendering diagram - + + {code} + } > @@ -3370,7 +3370,7 @@ const CHAT_MARKDOWN_COMPONENTS = { code={codeBlock.code} fenceTitle={fenceTitle} theme={resolvedTheme} - isStreaming={isStreaming} + isStreaming={isStreaming && !isClosedCodeFence(node, text)} onExpand={(src) => expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })} > {highlightedCode} diff --git a/apps/web/src/components/chat/MermaidDiagram.tsx b/apps/web/src/components/chat/MermaidDiagram.tsx index dd03bdc4ea10..c633cf7d9869 100644 --- a/apps/web/src/components/chat/MermaidDiagram.tsx +++ b/apps/web/src/components/chat/MermaidDiagram.tsx @@ -204,7 +204,7 @@ export function MermaidDiagram({ } return ( - + *, - .chat-markdown[data-streaming] .chat-markdown-shiki { + .chat-markdown[data-streaming] .chat-markdown-shiki, + .chat-markdown[data-streaming] .chat-markdown-mermaid { transition: opacity 600ms ease-out; @starting-style { From 4ad7ab4fdb097415db5aa265aed00c9b1d09623f Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:49:28 +0200 Subject: [PATCH 2/6] fix(web): keep indented Mermaid fence markers in source --- apps/web/src/components/ChatMarkdown.test.tsx | 4 ++++ apps/web/src/components/ChatMarkdown.tsx | 15 +++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index fe752f6bf2e2..3a1098db2e67 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -188,6 +188,10 @@ describe("ChatMarkdown Mermaid streaming", () => { ["~~~~mermaid\nflowchart LR\nA --> B\n~~~", "\n~~~~"], ["> ```mermaid\n> flowchart LR\n> A --> B", "\n> ```"], ["- Diagram\n\n ```mermaid\n flowchart LR\n A --> B", "\n ```"], + ["```mermaid\nflowchart LR\nA --> B\n ```", "\n```"], + ["> ```mermaid\n> flowchart LR\n> A --> B\n> ```", "\n> ```"], + ["- Diagram\n\n ```mermaid\n flowchart LR\n A --> B\n ```", "\n ```"], + ["```mermaid\r\nflowchart LR\r\nA --> B\r\n\t```", "\r\n```"], ])("reveals a finished fence before the message finishes: %s", async (open, closing) => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); let renderer: ReactTestRenderer | undefined; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index e4ab9dbec675..c4266cdd7d7d 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -477,7 +477,11 @@ function extractPreCodeMeta(node: unknown): string | undefined { return typeof meta === "string" && meta.trim().length > 0 ? meta.trim() : undefined; } -function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): boolean { +function isClosedCodeFence( + node: ReactMarkdownExtraProps["node"], + text: string, + code: string, +): boolean { const start = node?.position?.start.offset; const end = node?.position?.end.offset; if (start === undefined || end === undefined) return false; @@ -490,7 +494,10 @@ function isClosedCodeFence(node: ReactMarkdownExtraProps["node"], text: string): opening !== undefined && closing !== undefined && opening[0] === closing[0] && - closing.length >= opening.length + closing.length >= opening.length && + // A real closer is excluded from the parsed code. Invalid, overindented + // markers remain in it, so the source must have an extra fence line. + source.split(/\r\n|\r|\n/).length > code.split(/\r\n|\r|\n/).length ); } @@ -3370,7 +3377,7 @@ const CHAT_MARKDOWN_COMPONENTS = { code={codeBlock.code} fenceTitle={fenceTitle} theme={resolvedTheme} - isStreaming={isStreaming && !isClosedCodeFence(node, text)} + isStreaming={isStreaming && !isClosedCodeFence(node, text, codeBlock.code)} onExpand={(src) => expandMedia({ images: [{ src, name: "Mermaid diagram" }], index: 0 })} > {highlightedCode} @@ -3384,7 +3391,7 @@ const CHAT_MARKDOWN_COMPONENTS = { fenceTitle={fenceTitle} theme={resolvedTheme} onRunShellCommand={ - onRunShellCommand && !isStreaming && isClosedCodeFence(node, text) + onRunShellCommand && !isStreaming && isClosedCodeFence(node, text, codeBlock.code) ? onRunShellCommand : undefined } From 37a959faf5b355c46744533e0e493c255c9d5cc8 Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:57:04 +0200 Subject: [PATCH 3/6] test(web): verify text after streaming Mermaid blocks --- apps/web/src/components/ChatMarkdown.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 3a1098db2e67..ed6e9de1464d 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -226,6 +226,9 @@ describe("ChatMarkdown Mermaid streaming", () => { renderer!.update(message(`${closed}The remaining explanation.`, streaming)); }); expect(renderer!.root.findByProps({ role: "img" })).toBe(diagram); + expect( + renderer!.root.findAll((node) => node.children.includes("The remaining explanation.")), + ).not.toHaveLength(0); } } finally { await act(async () => renderer?.unmount()); From 4b20037e43448225e0105de81c52151a680d295e Mon Sep 17 00:00:00 2001 From: NikitaMGrimm <65914808+NikitaMGrimm@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:18:19 +0200 Subject: [PATCH 4/6] fix(web): ignore a split CRLF when checking for a closed Mermaid fence --- apps/web/src/components/ChatMarkdown.test.tsx | 1 + apps/web/src/components/ChatMarkdown.tsx | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index ed6e9de1464d..f5c113761d38 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -192,6 +192,7 @@ describe("ChatMarkdown Mermaid streaming", () => { ["> ```mermaid\n> flowchart LR\n> A --> B\n> ```", "\n> ```"], ["- Diagram\n\n ```mermaid\n flowchart LR\n A --> B\n ```", "\n ```"], ["```mermaid\r\nflowchart LR\r\nA --> B\r\n\t```", "\r\n```"], + ["```mermaid\r\nflowchart LR\r\nA --> B\r\n\t```\r", "\n```"], ])("reveals a finished fence before the message finishes: %s", async (open, closing) => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); let renderer: ReactTestRenderer | undefined; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c4266cdd7d7d..58d32428cafa 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -496,8 +496,9 @@ function isClosedCodeFence( opening[0] === closing[0] && closing.length >= opening.length && // A real closer is excluded from the parsed code. Invalid, overindented - // markers remain in it, so the source must have an extra fence line. - source.split(/\r\n|\r|\n/).length > code.split(/\r\n|\r|\n/).length + // markers remain in it, so the source must have an extra fence line. A + // stream cut inside a CRLF leaves a lone CR that is not a line of its own. + source.replace(/\r$/, "").split(/\r\n|\r|\n/).length > code.split(/\r\n|\r|\n/).length ); } From f1aa4f7adf081e807e63f0d8fe5a108ab27c2d86 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:47:10 +0000 Subject: [PATCH 5/6] test(web): supply the thread details shortcut flag --- .../web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx index 60bc23b71e42..b36dc779935c 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.browser.test.tsx @@ -244,6 +244,7 @@ it("keeps workspace controls after expanding and collapsing 70 linked pull reque keybindings={[]} availableEditors={[]} showOpenInPicker + openInShortcutEnabled={false} gitCwd="/tmp/project" isGitRepo envLocked={false} From 1987ee2a01e6bea46aa0299ba24f859608c7bbeb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 20:47:11 +0000 Subject: [PATCH 6/6] fix(web): keep Mermaid streaming coverage focused --- .../components/ChatMarkdown.browser.test.tsx | 126 ++++++++++++------ apps/web/src/components/ChatMarkdown.tsx | 5 +- 2 files changed, 90 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index 1f5e3266618f..58a4c556685e 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -52,8 +52,7 @@ vi.mock(import("../remoteOpen"), async (original) => ({ const { renderDiagram } = vi.hoisted(() => ({ renderDiagram: vi.fn<(source: string) => Promise | undefined>(), })); -// A rendered diagram is an SVG; real Mermaid layout is not what these tests exercise. -// Tests that return a promise from renderDiagram control when it appears. +// Control rendering latency without running Mermaid layout. vi.mock(import("./chat/MermaidDiagram"), async (original) => { const { use } = await import("react"); return { @@ -70,6 +69,7 @@ import ChatMarkdown from "./ChatMarkdown"; const roots: Root[] = []; beforeEach(() => { + renderDiagram.mockReset(); workspaceSearch.mockReset().mockResolvedValue(AsyncResult.success({ entries: [] })); filesystemMetadata.mockReset().mockResolvedValue(AsyncResult.success({ entries: [null] })); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); @@ -163,47 +163,95 @@ describe("ChatMarkdown bare anchor placeholders", () => { }); describe("ChatMarkdown Mermaid streaming", () => { - it.each([ - ["```mermaid\nflowchart LR\nA --> B", "\n```"], - ["~~~~mermaid\nflowchart LR\nA --> B\n~~~", "\n~~~~"], - ["> ```mermaid\n> flowchart LR\n> A --> B", "\n> ```"], - ["- Diagram\n\n ```mermaid\n flowchart LR\n A --> B", "\n ```"], - ["```mermaid\nflowchart LR\nA --> B\n ```", "\n```"], - ["> ```mermaid\n> flowchart LR\n> A --> B\n> ```", "\n> ```"], - ["- Diagram\n\n ```mermaid\n flowchart LR\n A --> B\n ```", "\n ```"], - ["```mermaid\r\nflowchart LR\r\nA --> B\r\n\t```", "\r\n```"], - ["```mermaid\r\nflowchart LR\r\nA --> B\r\n\t```\r", "\n```"], - ])("reveals a finished fence before the message finishes: %s", async (open, closing) => { + it("reveals a completed fence while streaming and preserves it through later prose and stream end", async () => { let finishRender!: (label: string) => void; - const pending = new Promise((resolve) => { - finishRender = resolve; - }); - renderDiagram.mockReset().mockReturnValue(pending); + renderDiagram.mockReturnValue( + new Promise((resolve) => { + finishRender = resolve; + }), + ); + const source = "flowchart LR\nA --> B"; + const open = `\`\`\`mermaid\n${source}`; + const closed = `${open}\n\`\`\`\n\n`; const message = (text: string, isStreaming = true) => ( ); - const closed = `${open}${closing}\n\n`; - try { - const { container, rerender } = await render(message(open)); - expect(renderDiagram).not.toHaveBeenCalled(); - - await rerender(message(closed)); - expect(renderDiagram).toHaveBeenCalled(); - expect(container.querySelector('svg[role="img"]')).toBeNull(); - expect(container.querySelector(".chat-markdown-shiki")).toBeNull(); - expect(container.querySelector("pre")?.getAttribute("aria-hidden")).toBe("true"); - - await act(async () => finishRender("Completed diagram")); - const diagram = container.querySelector('svg[role="img"]'); - expect(diagram?.getAttribute("aria-label")).toBe("Completed diagram"); - for (const streaming of [true, false]) { - await rerender(message(`${closed}The remaining explanation.`, streaming)); - expect(container.querySelector('svg[role="img"]')).toBe(diagram); - expect(container.textContent).toContain("The remaining explanation."); - } - } finally { - renderDiagram.mockReset(); + const { container, rerender } = await render(message(open)); + expect(container.querySelector("pre")?.textContent).toContain(source); + expect(renderDiagram).not.toHaveBeenCalled(); + + await rerender(message(closed)); + expect(renderDiagram).toHaveBeenCalledWith(expect.stringContaining(source)); + expect(container.querySelector('svg[role="img"]')).toBeNull(); + expect(container.querySelector(".chat-markdown-shiki")).toBeNull(); + const placeholder = container.querySelector("pre"); + expect(placeholder?.getAttribute("aria-hidden")).toBe("true"); + expect(placeholder?.textContent).toContain(source); + + await act(async () => finishRender("Completed diagram")); + const diagram = container.querySelector('svg[role="img"]'); + expect(diagram?.getAttribute("aria-label")).toBe("Completed diagram"); + for (const streaming of [true, false]) { + await rerender(message(`${closed}The remaining explanation.`, streaming)); + expect(container.querySelector('svg[role="img"]')).toBe(diagram); + expect(container.textContent).toContain("The remaining explanation."); } + + await act(async () => buttonsLabelled(container, "Show code")[0]!.click()); + expect(container.querySelector('svg[role="img"]')).toBeNull(); + expect(container.querySelector("pre")?.textContent).toContain(source); + await act(async () => buttonsLabelled(container, "Show diagram")[0]!.click()); + expect(container.querySelector('svg[role="img"]')?.getAttribute("aria-label")).toBe( + "Completed diagram", + ); + }); + + it.each([ + ["short tilde marker", "~~~~mermaid\nflowchart LR\nA --> B\n~~~", "\n~~~~"], + [ + "overindented blockquote marker", + "> ```mermaid\n> flowchart LR\n> A --> B\n> ```", + "\n> ```", + ], + [ + "overindented list marker", + "- Diagram\n\n ```mermaid\n flowchart LR\n A --> B\n ```", + "\n ```", + ], + ])("keeps a %s in source until a real closer arrives", async (_, open, closing) => { + const message = (text: string) => ; + const { container, rerender } = await render(message(open)); + expect(renderDiagram).not.toHaveBeenCalled(); + expect(container.querySelector("pre")?.textContent).toContain("A --> B"); + + await rerender(message(`${open}${closing}`)); + expect(container.querySelector('svg[role="img"]')?.getAttribute("aria-label")).toBe("Diagram"); + }); + + it("does not mistake a CRLF stream cut after CR for a closed fence", async () => { + const open = "```mermaid\r\nflowchart LR\r\nA --> B\r\n\t```"; + const message = (text: string) => ; + const { container, rerender } = await render(message(open)); + expect(renderDiagram).not.toHaveBeenCalled(); + await rerender(message(`${open}\r`)); + expect(renderDiagram).not.toHaveBeenCalled(); + expect(container.querySelector("pre")?.textContent).toContain("A --> B"); + + await rerender(message(`${open}\r\n\`\`\``)); + expect(container.querySelector('svg[role="img"]')?.getAttribute("aria-label")).toBe("Diagram"); + }); + + it("falls back to source when a completed diagram fails during streaming", async () => { + await getSyntaxHighlighterPromise("mermaid"); + renderDiagram.mockImplementation(() => { + throw new Error("Invalid Mermaid diagram"); + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + const container = await renderText("```mermaid\ninvalid diagram\n```", { isStreaming: true }); + expect(renderDiagram).toHaveBeenCalledWith(expect.stringContaining("invalid diagram")); + expect(container.querySelector('svg[role="img"]')).toBeNull(); + expect(container.querySelector("pre")?.textContent).toContain("invalid diagram"); + expect(container.querySelector("pre")?.getAttribute("aria-hidden")).not.toBe("true"); }); }); @@ -290,6 +338,8 @@ describe("ChatMarkdown streaming", () => { "~~~bash\necho tilde\n~~~", "> ```bash\n> echo quote\n> ```", "````bash\necho four\n````", + "- Command\n\n ```bash\n echo list\n ```", + "```bash\r\necho crlf\r\n```\r", ]) { await rerender(message(text)); expect(runButtons()).toHaveLength(1); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 448ebb609545..51a0048f5f47 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -489,9 +489,8 @@ function isClosedCodeFence( closing !== undefined && opening[0] === closing[0] && closing.length >= opening.length && - // A real closer is excluded from the parsed code. Invalid, overindented - // markers remain in it, so the source must have an extra fence line. A - // stream cut inside a CRLF leaves a lone CR that is not a line of its own. + // Only a real closer is omitted from parsed code. A CRLF stream cut after + // CR must not count as an extra source line. source.replace(/\r$/, "").split(/\r\n|\r|\n/).length > code.split(/\r\n|\r|\n/).length ); }
+ {code} +