diff --git a/packages/webui/package.json b/packages/webui/package.json
index be09c874b..1f0729cd1 100644
--- a/packages/webui/package.json
+++ b/packages/webui/package.json
@@ -36,6 +36,7 @@
"katex": "0.18.7",
"lottie-web": "^5.13.0",
"marked": "18.0.12",
+ "mermaid": "12.1.0",
"node-pty": "1.1.0",
"react": "18.3.1",
"react-dom": "18.3.1",
diff --git a/packages/webui/src/client/features/transcript/ui/markdown.public.tsx b/packages/webui/src/client/features/transcript/ui/markdown.public.tsx
index 8d235e64a..f798af6d9 100644
--- a/packages/webui/src/client/features/transcript/ui/markdown.public.tsx
+++ b/packages/webui/src/client/features/transcript/ui/markdown.public.tsx
@@ -1,5 +1,6 @@
import { marked, type Token } from "marked";
import katex from "katex";
+import { isWebuiMermaidFence, WebuiMermaid } from "./webui-mermaid.js";
import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import c from "highlight.js/lib/languages/c";
@@ -267,6 +268,14 @@ function blocks(tokens: readonly Token[] | undefined, onOpenFile?: (reference: N
return
{token.text}
;
}
}
+ // Mermaid fences branch off before the generic code path: the diagram
+ // renders to SVG through `WebuiMermaid`, and every failure there lands
+ // back on the plain fence. `codeFenceLanguage` strips the tag's trailing
+ // attributes, so `mermaid title="a.mmd"` is recognised the same as a
+ // bare `mermaid` tag. No other fence is affected by this branch.
+ if (token.type === "code" && isWebuiMermaidFence(codeFenceLanguage(token.lang))) {
+ return ;
+ }
if (token.type === "code") {
const language = codeFenceLanguage(token.lang);
const highlighted = highlightCodeFence(token.text, language);
diff --git a/packages/webui/src/client/features/transcript/ui/transcript.css b/packages/webui/src/client/features/transcript/ui/transcript.css
index 44d1e838f..18382395e 100644
--- a/packages/webui/src/client/features/transcript/ui/transcript.css
+++ b/packages/webui/src/client/features/transcript/ui/transcript.css
@@ -788,6 +788,32 @@
.webui-code-block .hljs-title, .webui-code-block .hljs-title.function_, .webui-code-block .hljs-function, .webui-code-block .hljs-section { color: var(--text_label_accent_default); }
.webui-code-block .hljs-type, .webui-code-block .hljs-built_in, .webui-code-block .hljs-title.class_ { color: var(--text_label_danger_secondary_default); }
.webui-code-block .hljs-attr, .webui-code-block .hljs-variable, .webui-code-block .hljs-property { color: var(--text_default_secondary); }
+ /* Mermaid diagrams: the container the rendered SVG lands in, and the raw
+ diagram text before the render settles. The surface tokens mirror
+ `.webui-code-block` so a diagram and a fence read as one panel. The SVG
+ itself carries Mermaid's own inline styles — drawn with the app's
+ current theme, so a diagram on a dark page is a dark diagram — and only
+ its scaling is pinned here. The `pre` covers the pending state, which
+ shows the diagram source while the library chunk loads. */
+ .webui-mermaid {
+ background-color: var(--bg_grouped_tertiary);
+ border-radius: var(--radius_16);
+ overflow: hidden;
+ padding: var(--spacing_12) var(--spacing_16);
+ }
+ .webui-mermaid pre {
+ margin: 0;
+ max-height: 400px;
+ overflow: auto;
+ font-size: 13px;
+ line-height: 20px;
+ }
+ .webui-mermaid svg {
+ display: block;
+ max-width: 100%;
+ height: auto;
+ }
+
.webui-table-shell {
max-width: 100%;
overflow-x: auto;
diff --git a/packages/webui/src/client/features/transcript/ui/webui-mermaid.tsx b/packages/webui/src/client/features/transcript/ui/webui-mermaid.tsx
new file mode 100644
index 000000000..d5fbfa0b7
--- /dev/null
+++ b/packages/webui/src/client/features/transcript/ui/webui-mermaid.tsx
@@ -0,0 +1,293 @@
+// webui-mermaid.tsx — Mermaid diagram rendering for the transcript.
+//
+// The acceptance table carried a "markdown-图表(mmd) 通过" row while the
+// source had zero Mermaid support: a ```mermaid fence fell through to the
+// plain code path, so a model-drawn flowchart or sequence diagram arrived as
+// an unreadable blob of text. This file closes that row the same way the
+// renderer's other two languages already work — katex owns the math fences,
+// highlight.js owns the registered code languages, and Mermaid owns its own
+// fence word.
+//
+// Four deliberate choices, all driven by the acceptance contract:
+//
+// 1. `mermaid` is loaded with a dynamic `import()` and never referenced
+// statically. The package is large, and the transcript shows a Mermaid
+// fence only occasionally, so it must not sit in the main chunk. The
+// import happens on the first fence that actually needs it.
+// 2. Rendering is imperative (`mermaid.render`), not declarative
+// (`
` + `mermaid.run`). `run()` swaps the DOM
+// itself and renders its own error box on a bad diagram; `render()`
+// rejects, which is what lets every failure — invalid syntax, an empty
+// body, a thrown render, a hung render — fall back to the plain fence
+// this component would have produced before Mermaid existed.
+// 3. The component is split into a pure presentational block
+// (`WebuiMermaidBlock`) and a thin effectful wrapper (`WebuiMermaid`).
+// The webui suite runs in Node with `renderToStaticMarkup`, which never
+// runs effects, so the block carries each state as a plain prop and the
+// wrapper only wires the async render into it.
+// 4. The rendered SVG is sanitized before it is injected, and the diagram is
+// drawn with the theme the app is actually showing. Both are load-bearing
+// — see the two sections below.
+
+import { useEffect, useState, type ReactElement } from "react";
+
+import { useWebuiBrowserCapabilities } from "../../../bindings/browser-capabilities.js";
+
+/** The fence language words that select the Mermaid branch, compared lower-cased. */
+const MERMAID_FENCE_LANGUAGES = new Set(["mermaid", "mmd"]);
+
+/**
+ * How long a single `mermaid.render` may take before the diagram is abandoned
+ * for the plain-fence fallback. Generous on purpose: the browser suite renders
+ * the real library on CI hardware, and a tight budget turns a slow machine
+ * into a red build rather than a diagram.
+ */
+export const WEBUI_MERMAID_RENDER_TIMEOUT_MS = 10_000;
+
+/**
+ * Whether a fence tag names the Mermaid diagram language. The caller passes
+ * the fence's first language word (see `codeFenceLanguage` in
+ * `markdown.public.tsx`, which already strips trailing attributes such as
+ * `title="a.mmd"`), so a ```mermaid title="a.mmd"``` fence is recognised the
+ * same as a bare one. Comparison is case-insensitive because fence tags are
+ * conventionally lower case but nothing enforces it.
+ */
+export function isWebuiMermaidFence(language: string | undefined): boolean {
+ return language !== undefined && MERMAID_FENCE_LANGUAGES.has(language.toLowerCase());
+}
+
+/* Theme ------------------------------------------------------------------ */
+
+/** The two themes the app can be showing. */
+export type WebuiMermaidTheme = "light" | "dark";
+
+/**
+ * The theme the app is currently showing.
+ *
+ * Read through the injected DOM adapter, not from `document`: the dependency
+ * rules confine the ambient document to the platform layer, and a component
+ * that reaches for it directly is a violation the gate reports. The adapter
+ * reads the `dark` class the app's own appearance effect puts on the
+ * document element — the theme the reader is looking at, including a
+ * preference of "system" resolved at this moment.
+ */
+export function readWebuiDocumentTheme(
+ readTheme: () => "dark" | "light",
+): WebuiMermaidTheme {
+ try {
+ return readTheme();
+ } catch {
+ // No DOM (SSR, a Node test): light is the library's own default.
+ return "light";
+ }
+}
+
+/* Sanitization ----------------------------------------------------------- */
+
+/**
+ * Strip the markup that must never reach the DOM from a rendered diagram.
+ *
+ * Mermaid runs with `securityLevel: "strict"`, which is its own sanitizing
+ * mode and the primary control: labels are escaped and the library emits no
+ * scripts. This filter is the second layer, and it exists because the input
+ * is model output — a diagram body can be steered by whatever the model read
+ * (a repository file, a web page) — and because a rendered SVG is injected
+ * with `dangerouslySetInnerHTML`, where a ``));
await writeFile(path.join(clientDir, name), body);
}
+// The split bundle's chunks live under assets/ — the entry imports them, so a
+// copy without the directory cannot boot at all (#webui-root never appears).
+// Copy it wholesale, matching what the loopback resolver serves and the
+// packaged release ships.
+await cp(path.join(root, "dist-webui/client/assets"), path.join(clientDir, "assets"), { recursive: true });
const assembled = await createWebuiRuntimeHost({
dataDir: directory, appVersion: "webui-native-acceptance",
mcodeTools: { prepare: async () => ({ requested: false, ready: false, category: "disabled" as const,
diff --git a/packages/webui/test/browser/transcript.spec.mjs b/packages/webui/test/browser/transcript.spec.mjs
index fcd9e8bac..0c7e2a270 100644
--- a/packages/webui/test/browser/transcript.spec.mjs
+++ b/packages/webui/test/browser/transcript.spec.mjs
@@ -236,3 +236,115 @@ test("a session switch reads the page once per need, not once per reader", async
await page.waitForTimeout(400);
expect(await reads()).toBe(1);
});
+
+/* Mermaid fences in the transcript, against the real library.
+ *
+ * The unit suite mocks `mermaid` (the Node environment has no DOM for it), so
+ * this is where the library actually runs: the diagram renders to SVG, the
+ * chunk loads on demand, and the failure path falls back to the plain fence.
+ * The theme case matters because Mermaid bakes its palette into the SVG — a
+ * diagram drawn with the light palette is a light diagram even on a dark
+ * page, so the renderer has to follow the app's theme. */
+const MERMAID_DIAGRAM = "graph TD;\n A[开始] --> B[结束];";
+
+async function stageTranscript(page, messages) {
+ // `addInitScript` with an argument, not `configureFixture`: the fixture
+ // setup is serialized with `toString()`, so it cannot close over the
+ // messages.
+ await page.addInitScript((value) => {
+ window.__WEBUI_FIXTURE_SETUP__ ??= [];
+ window.__WEBUI_FIXTURE_SETUP__.push(() => window.__fixture.setPage("A", { messages: value, hasMore: false }));
+ }, messages);
+}
+
+test("a mermaid fence renders as a diagram, not as text", async ({ page }) => {
+ await stageTranscript(page, [
+ { msgId: "m-1", role: "assistant", msgContent: "```mermaid\n" + MERMAID_DIAGRAM + "\n```", timestamp: 1_700_000_000_001 },
+ ]);
+ await openApp(page, "#session=A");
+
+ // The real library, in a real browser: the fence becomes an SVG.
+ const diagram = page.locator(".webui-mermaid svg");
+ await expect(diagram).toBeVisible({ timeout: 20_000 });
+ // …and the diagram is the fence's own content, not the source text.
+ await expect(page.locator(".webui-mermaid pre")).toHaveCount(0);
+ await expect(diagram.locator("text").first()).toContainText("开始");
+});
+
+test("an invalid diagram falls back to the plain fence", async ({ page }) => {
+ await stageTranscript(page, [
+ { msgId: "m-2", role: "assistant", msgContent: "```mermaid\nthis is not a diagram at all {{{\n```", timestamp: 1_700_000_000_001 },
+ ]);
+ await openApp(page, "#session=A");
+
+ // The failure path: no SVG, and the original text back in a plain fence —
+ // the markup the fence had before Mermaid existed.
+ await expect(page.locator(".webui-mermaid svg")).toHaveCount(0);
+ const fallback = page.locator(".webui-code-block code");
+ await expect(fallback).toBeVisible();
+ await expect(fallback).toContainText("this is not a diagram at all");
+});
+
+test("a diagram follows the app's theme", async ({ page }) => {
+ await stageTranscript(page, [
+ { msgId: "m-3", role: "assistant", msgContent: "```mermaid\n" + MERMAID_DIAGRAM + "\n```", timestamp: 1_700_000_000_001 },
+ ]);
+ await openApp(page, "#session=A");
+ await expect(page.locator(".webui-mermaid svg")).toBeVisible({ timeout: 20_000 });
+
+ // Mermaid bakes its palette into the SVG it returns, so a diagram drawn
+ // with the light palette is a light diagram even on a dark page. The
+ // comparison is between two runs rather than against a literal colour, so
+ // it survives a palette change in the library.
+ const readPalette = () =>
+ page.locator(".webui-mermaid svg").evaluate((node) => node.querySelector("style")?.textContent ?? "");
+ const lightPalette = await readPalette();
+
+ // The app's own preference — not a hand-set class: the app applies the
+ // stored theme to `` at boot, and the renderer reads that class when
+ // it draws.
+ await page.addInitScript(() => {
+ window.localStorage.setItem("webui-theme", "dark");
+ });
+ await page.reload();
+ await expect(page.locator(".webui-mermaid svg")).toBeVisible({ timeout: 20_000 });
+ await expect(page.locator("html")).toHaveClass(/dark/);
+
+ expect(await readPalette()).not.toBe(lightPalette);
+});
+
+test("a wide diagram fits its container", async ({ page }) => {
+ // A wide diagram must not push the transcript sideways: the SVG scales with
+ // the container (`max-width: 100%`, `height: auto`).
+ await stageTranscript(page, [
+ {
+ msgId: "m-4",
+ role: "assistant",
+ msgContent: "```mermaid\ngraph LR;\n" + Array.from({ length: 12 }, (_, index) => ` N${index}[节点${index}]`).join("\n") + ";\n```",
+ timestamp: 1_700_000_000_001,
+ },
+ ]);
+ await openApp(page, "#session=A");
+ const diagram = page.locator(".webui-mermaid svg");
+ await expect(diagram).toBeVisible({ timeout: 20_000 });
+ const sizes = await diagram.evaluate((node) => ({
+ svg: node.getBoundingClientRect().width,
+ container: node.parentElement?.getBoundingClientRect().width ?? 0,
+ }));
+ expect(sizes.svg).toBeLessThanOrEqual(sizes.container + 1);
+});
+
+test("a half-streamed fence falls back instead of breaking the transcript", async ({ page }) => {
+ // A fence that has not finished arriving (the model is still streaming) is
+ // an incomplete code block: the diagram cannot render, and the fence must
+ // fall back to the plain source rather than leave the transcript broken.
+ await stageTranscript(page, [
+ { msgId: "m-5", role: "assistant", msgContent: "```mermaid\ngraph TD;\n A[开始] --> B[结", timestamp: 1_700_000_000_001 },
+ ]);
+ await openApp(page, "#session=A");
+
+ await expect(page.locator(".webui-mermaid svg")).toHaveCount(0);
+ await expect(page.locator(".webui-code-block code")).toBeVisible();
+ // The transcript is still alive: the rest of the message is on screen.
+ await expect(page.getByPlaceholder("输入消息…(输入 / 唤起命令)")).toBeVisible();
+});
diff --git a/packages/webui/test/gates/fixtures/path-metadata-f00.json b/packages/webui/test/gates/fixtures/path-metadata-f00.json
index 6a887e75b..44c982299 100644
--- a/packages/webui/test/gates/fixtures/path-metadata-f00.json
+++ b/packages/webui/test/gates/fixtures/path-metadata-f00.json
@@ -2397,6 +2397,15 @@
"business": "transcript",
"home": "target"
},
+ {
+ "path": "client/features/transcript/ui/webui-mermaid.tsx",
+ "layers": [
+ "bindings"
+ ],
+ "role": "feature-ui",
+ "business": "transcript",
+ "home": "target"
+ },
{
"path": "client/features/turn/application/composer-submission.ts",
"layers": [
@@ -2920,7 +2929,7 @@
"home": "target"
},
{
- "path": "client/features/workspace/ui/WorkspaceProgressPanel.tsx",
+ "path": "client/features/workspace/ui/WorkspacePanels.tsx",
"layers": [
"bindings"
],
@@ -2929,7 +2938,7 @@
"home": "target"
},
{
- "path": "client/features/workspace/ui/WorkspacePanels.tsx",
+ "path": "client/features/workspace/ui/WorkspaceProgressPanel.tsx",
"layers": [
"bindings"
],
diff --git a/packages/webui/test/unit/markdown-mermaid.test.tsx b/packages/webui/test/unit/markdown-mermaid.test.tsx
new file mode 100644
index 000000000..d69fed57a
--- /dev/null
+++ b/packages/webui/test/unit/markdown-mermaid.test.tsx
@@ -0,0 +1,296 @@
+// Mermaid fences in the transcript renderer — the "markdown-图表(mmd)" row of
+// SPEC-C's acceptance table.
+//
+// The row said "通过" while the source had zero Mermaid support; this file
+// pins the behaviour that closes it. `markdown-codeblock-c1-highlight.test.tsx`
+// and `markdown-codeblock-c2-line-numbers.test.tsx` are the regression
+// guardrails for every other fence language — their exact-literal assertions
+// on the plain and highlighted paths are what proves a Mermaid fence did not
+// disturb them — so this file covers the Mermaid surface itself:
+//
+// 1. `mermaid` and `mmd` fences (with or without tag attributes, in any
+// case) take the Mermaid branch and produce a `webui-mermaid` container,
+// never a `webui-code-block`.
+// 2. An empty body, invalid syntax, a thrown render and a render that
+// outlives the timeout all reject out of `renderWebuiMermaidSvg`, and
+// the failed state renders the plain fence with the original text one
+// character unchanged.
+// 3. Other languages stay byte-identical, asserted here against the same
+// literal the pre-Mermaid renderer produced.
+//
+// On the environment: the webui suite runs in Node with no jsdom, and
+// `renderToStaticMarkup` renders once without running effects. So the async
+// pipeline is driven directly against a stubbed `mermaid` module, the three
+// states are rendered as plain props of `WebuiMermaidBlock`, and the effect
+// that wires them together in a browser is covered by
+// `test/webui-browser/transcript.spec.mjs` — the same split
+// `review-panel.test.tsx` documents for its component.
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { WebuiMarkdown } from "../../src/client/features/transcript/ui/markdown.public.js";
+import {
+ isWebuiMermaidFence,
+ readWebuiDocumentTheme,
+ renderWebuiMermaidSvg,
+ sanitizeWebuiMermaidSvg,
+ WebuiMermaidBlock,
+ type WebuiMermaidState,
+} from "../../src/client/features/transcript/ui/webui-mermaid.js";
+import mermaid from "mermaid";
+
+// The real library needs a browser; every test below drives the stub. The
+// default export is the documented surface (`initialize` + `render`).
+vi.mock("mermaid", () => ({
+ default: {
+ initialize: vi.fn(),
+ render: vi.fn(),
+ },
+}));
+
+const renderMock = vi.mocked(mermaid.render);
+
+function render(source: string): string {
+ return renderToStaticMarkup(createElement(WebuiMarkdown, { source }));
+}
+
+function renderBlock(state: WebuiMermaidState, text: string, lang?: string): string {
+ return renderToStaticMarkup(createElement(WebuiMermaidBlock, { state, text, lang }));
+}
+
+/** The `` element's inner HTML — the part a fallback must preserve. */
+function codeInnerHtml(markup: string): string {
+ const open = markup.indexOf("", open) + 1;
+ const end = markup.indexOf("", start);
+ return markup.slice(start, end);
+}
+
+function decodeEntities(value: string): string {
+ return value
+ .replace(/</gu, "<")
+ .replace(/>/gu, ">")
+ .replace(/"/gu, '"')
+ .replace(/'/gu, "'")
+ .replace(/&/gu, "&");
+}
+
+/** The document `WebuiMarkdown` produced for a plain fence before Mermaid existed. */
+function expectedPlainDocument(lang: string, body: string): string {
+ return (
+ `
` +
+ `
${body}
` +
+ `
`
+ );
+}
+
+afterEach(() => {
+ renderMock.mockReset();
+});
+
+describe("which fences take the Mermaid branch", () => {
+ it("recognises mermaid and mmd in any letter case", () => {
+ expect(isWebuiMermaidFence("mermaid")).toBe(true);
+ expect(isWebuiMermaidFence("mmd")).toBe(true);
+ expect(isWebuiMermaidFence("Mermaid")).toBe(true);
+ expect(isWebuiMermaidFence("MMD")).toBe(true);
+ });
+
+ it("recognises a tag whose attributes were stripped to its first word", () => {
+ // marked puts everything after the backticks in `token.lang`, so
+ // ```mermaid title="a.mmd"``` arrives here as the bare language word.
+ expect(isWebuiMermaidFence("mermaid")).toBe(true);
+ });
+
+ it("leaves every other fence language alone", () => {
+ expect(isWebuiMermaidFence("ts")).toBe(false);
+ expect(isWebuiMermaidFence("math")).toBe(false);
+ expect(isWebuiMermaidFence("notalanguage")).toBe(false);
+ expect(isWebuiMermaidFence(undefined)).toBe(false);
+ });
+});
+
+describe("renderWebuiMermaidSvg", () => {
+ it("returns the library's SVG for a valid diagram", async () => {
+ renderMock.mockResolvedValue({ svg: '' } as never);
+ await expect(renderWebuiMermaidSvg("graph TD; A-->B;")).resolves.toContain("