From 8d4d96bbdf7776783d31d7c52a8122832210b95b Mon Sep 17 00:00:00 2001 From: stevenjj33 <75509501+stevenjj33@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:47:55 +0800 Subject: [PATCH 1/2] feat(webui): render Mermaid fences in the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resubmission of #125 at the relocated paths, with the review's defects addressed. 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 arrived as an unreadable blob of text. The fence branch sits in markdown.public.tsx between the math and generic code branches and fires only for the mermaid/mmd language words; every other fence keeps its untouched path. Three defects from the review, fixed here: - Theme. 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 renderer now draws with the app's own theme, read through the injected DOM adapter (readDocumentTheme) — the dependency rules confine the ambient document to the platform layer, so the component must not touch it. The library is re-initialised when the theme changes, so the next diagram follows a switch. - Untrusted content. The diagram body is model output and the rendered SVG is injected with dangerouslySetInnerHTML, so the SVG is filtered before it reaches the DOM: scripts, event handlers, script-ish URLs and foreign documents are removed, and the drawing itself is left alone. Mermaid's own strict mode stays the primary control; the filter is the second layer. htmlLabels is off, so the diagram is pure SVG and the labels are escaped text rather than HTML in a foreignObject. - Tests. The unit suite mocks the library (Node has no DOM for it), so the browser cases run the real one: a diagram renders to SVG, an invalid diagram falls back to the plain fence, the diagram follows the app's theme, a wide diagram fits its container, and a half-streamed fence falls back instead of breaking the transcript. The unit suite covers the sanitizer and the theme read. Build: the client bundle is split, so a dynamic import is a chunk instead of inlined source. Measured on the built artifact: the entry goes 4,647,161 B to 4,456,586 B (shared code moves into chunks) and mermaid's ~12 MB lands in 109 lazy chunks under assets/. The browser test server now serves that prefix, matching the loopback service's static resolver; the composer's slash palette — already a dynamic import — is chunked too, and its browser case covers the regression. --- packages/webui/package.json | 1 + .../transcript/ui/markdown.public.tsx | 9 + .../features/transcript/ui/transcript.css | 26 + .../features/transcript/ui/webui-mermaid.tsx | 293 ++++++ .../webui/src/client/platform/browser-dom.ts | 12 + packages/webui/test/browser/server.mjs | 12 +- .../webui/test/browser/transcript.spec.mjs | 112 +++ .../gates/fixtures/path-metadata-f00.json | 13 +- .../webui/test/unit/markdown-mermaid.test.tsx | 296 ++++++ pnpm-lock.yaml | 853 ++++++++++++++++++ release/public-source.json | 2 + scripts/build-webui.mjs | 11 + scripts/package-webui-npm.mjs | 11 + test/vitest-suites.json | 1 + 14 files changed, 1647 insertions(+), 5 deletions(-) create mode 100644 packages/webui/src/client/features/transcript/ui/webui-mermaid.tsx create mode 100644 packages/webui/test/unit/markdown-mermaid.test.tsx 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 ` + `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 `',
+ '',
+ 'x',
+ ' ',
+ "",
+ ].join("");
+ const clean = sanitizeWebuiMermaidSvg(hostile);
+ expect(clean).not.toContain("