Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/webui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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",
Expand Down
9 changes: 9 additions & 0 deletions packages/webui/src/client/components/transcript/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type ReactNode,
} from "react";
import { parseWebuiMessageFileReference } from "../../projection/transcript/message-file-reference.js";
import { isWebuiMermaidFence, WebuiMermaid } from "./webui-mermaid.js";

// The same `highlight.js/lib/core` registration `components/workspace/WorkspacePanels.tsx`
// performs for the file viewer — the full `highlight.js` bundle is never
Expand Down Expand Up @@ -259,6 +260,14 @@ function blocks(tokens: readonly Token[] | undefined, onOpenFile?: (reference: N
return <pre key={key}>{token.text}</pre>;
}
}
// 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 <WebuiMermaid key={key} text={token.text} lang={token.lang} />;
}
if (token.type === "code") {
const language = codeFenceLanguage(token.lang);
const highlighted = highlightCodeFence(token.text, language);
Expand Down
201 changes: 201 additions & 0 deletions packages/webui/src/client/components/transcript/webui-mermaid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// webui-mermaid.tsx — Mermaid diagram rendering for the transcript.
//
// SPEC-C's acceptance table carries a "markdown-图表(mmd) 通过" row, but 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.
//
// Three 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
// (`<pre class="mermaid">` + `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. The same split
// `components/workspace/ReviewSettingsPage.tsx` uses.
//
// Security: the SVG is injected with `dangerouslySetInnerHTML`, as katex and
// highlight.js output already are in `markdown.tsx`. Mermaid is initialised
// with `securityLevel: "strict"`, which is also its default, so the markup it
// returns carries no external references.

import { useEffect, useState, type ReactElement } from "react";

/** 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.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());
}

/** Mermaid is configured once per document; `initialize` is not cheap to repeat. */
let mermaidConfigured = false;

/** Mermaid hands every render a DOM id; a monotonic counter keeps them unique. */
let diagramSequence = 0;

type MermaidModule = typeof import("mermaid");

async function loadMermaid(): Promise<MermaidModule> {
const module = await import("mermaid");
if (!mermaidConfigured) {
// `startOnLoad: false` matters: the default scans the document for
// `.mermaid` elements on import, which would race the first explicit
// render. Nothing in this component uses that class, so the scan is
// disabled outright.
module.default.initialize({ startOnLoad: false, securityLevel: "strict" });
mermaidConfigured = true;
}
return module;
}

export interface WebuiMermaidRenderOptions {
/**
* Override for {@link WEBUI_MERMAID_RENDER_TIMEOUT_MS}. Exists so a test can
* drive the timeout path with a real clock instead of faking one; production
* callers never pass it.
*/
readonly timeoutMs?: number;
}

/**
* Render one Mermaid diagram body to an SVG string.
*
* Rejects — rather than returning a fallback — on every failure so the caller
* decides what the user sees: an empty body, invalid syntax, a thrown render
* and a render that outlives the timeout all end here. The empty-body check
* runs before the dynamic import so an empty fence never pulls the library
* down the wire.
*/
export async function renderWebuiMermaidSvg(text: string, options?: WebuiMermaidRenderOptions): Promise<string> {
if (text.trim() === "") throw new Error("webui-mermaid: empty diagram body");
const mermaid = (await loadMermaid()).default;
diagramSequence += 1;
const diagramId = `webui-mermaid-${diagramSequence}`;
const timeoutMs = options?.timeoutMs ?? WEBUI_MERMAID_RENDER_TIMEOUT_MS;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const result = await Promise.race([
mermaid.render(diagramId, text),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new Error(`webui-mermaid: render timed out after ${timeoutMs}ms`)),
timeoutMs,
);
}),
]);
return result.svg;
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}

/** What the fence currently shows. See {@link WebuiMermaidBlock}. */
export type WebuiMermaidState =
| { readonly status: "pending" }
| { readonly status: "rendered"; readonly svg: string }
| { readonly status: "failed" };

export interface WebuiMermaidBlockProps {
readonly state: WebuiMermaidState;
/** The fence body, verbatim. */
readonly text: string;
/** The fence tag as marked reported it, attributes included. */
readonly lang: string | undefined;
}

/**
* The fence's markup for each state, with no effect of its own:
*
* pending — the `webui-mermaid` container holding the raw diagram text. The
* container is present from the first paint, so a slow chunk load shows
* the source rather than an empty box, and the unit suite can assert the
* container without ever running an effect.
* rendered — the same container with Mermaid's SVG inside it.
* failed — the plain fence, byte for byte: a `webui-code-block` wrapper, a
* `pre`, a `code` carrying the original text, and no gutter, because a
* Mermaid fence never shows line numbers in any of its states. This is
* the markup the fence had before Mermaid existed, minus the gutter.
*/
export function WebuiMermaidBlock({ state, text, lang }: WebuiMermaidBlockProps): ReactElement {
if (state.status === "rendered") {
return (
<div className="webui-mermaid" data-webui-mermaid-state="rendered" dangerouslySetInnerHTML={{ __html: state.svg }} />
);
}
if (state.status === "failed") {
return (
<div className="webui-code-block">
<pre>
<code data-language={lang}>{text}</code>
</pre>
</div>
);
}
return (
<div className="webui-mermaid" data-webui-mermaid-state="pending">
<pre>{text}</pre>
</div>
);
}

export interface WebuiMermaidProps {
/** The fence body, verbatim. */
readonly text: string;
/** The fence tag as marked reported it, attributes included. */
readonly lang: string | undefined;
}

/**
* The effectful half: renders the diagram once per body and repaints the block
* with the outcome. `cancelled` guards the state updates so a fence that
* unmounts mid-render — a session switch, a trimmed transcript — writes
* nothing. The dependency is the body alone: a re-render with the same text
* must not re-run the library.
*/
export function WebuiMermaid({ text, lang }: WebuiMermaidProps): ReactElement {
const [state, setState] = useState<WebuiMermaidState>({ status: "pending" });
useEffect(() => {
let cancelled = false;
renderWebuiMermaidSvg(text)
.then((svg) => {
if (!cancelled) setState({ status: "rendered", svg });
})
.catch(() => {
if (!cancelled) setState({ status: "failed" });
});
return () => {
cancelled = true;
};
}, [text]);
return <WebuiMermaidBlock state={state} text={text} lang={lang} />;
}
17 changes: 15 additions & 2 deletions packages/webui/src/client/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import { NotFound } from "./components/shell/NotFound.js";
import { ArchonPage } from "./components/shell/ArchonPage.js";
import { WebuiErrorBoundary } from "./components/shell/WebuiErrorBoundary.js";
import { WebuiStartupFallback } from "./components/shell/StartupFallback.js";
import { WebuiBrowserCapabilitiesProvider } from "./bindings/browser-capabilities.js";
import { WebuiBrowserCapabilitiesProvider, useWebuiBrowserCapabilities } from "./bindings/browser-capabilities.js";
import { createWebuiBrowserDom } from "./infrastructure/browser-dom.js";
import {
createWebuiBrowserStorage,
getWebuiBrowserStorage,
} from "./infrastructure/storage.js";
import { createWebuiApplication } from "./application/create-application.js";
import { createWebuiComposerStore } from "./application/composer/composer-store.js";
import { createWebuiEventEffectsRegistry } from "./application/turn/event-effects-registry.js";
import { createWebuiPortSlices } from "./application/port-slices.js";
import { createWebuiOpenEventChannel } from "./bindings/event-channel-adapter.js";
Expand Down Expand Up @@ -69,6 +70,17 @@ function WebuiClientRoot({
() => createWebuiEventEffectsRegistry(),
[],
);
// The same storage adapter the shell would have built for its own composer
// store. The application layer may not import infrastructure, so the
// composition root injects it — without this the adopted composer store has
// no storage at all and every draft dies with the page (regression from
// e40a63f: the root stopped handing the shell a store to build, and the
// application's own default is storage-less by design).
const { storage } = useWebuiBrowserCapabilities();
const composerStore = React.useMemo(
() => createWebuiComposerStore({ storage }),
[storage],
);
const slices = React.useMemo(
() => createWebuiPortSlices(transport),
[transport],
Expand All @@ -85,6 +97,7 @@ function WebuiClientRoot({
// own view reactions on top and registers them through
// `setEventEffects`, because it renders after this instance exists.
effects: effectsRegistry.asWebuiEventEffects(),
composer: composerStore,
turns: {
resumeSession: transport.resumeSession ?? (async () => {}),
...(transport.loadMessages
Expand All @@ -96,7 +109,7 @@ function WebuiClientRoot({
? { loadMessages: transport.loadMessages }
: {}),
}),
[effectsRegistry, transport],
[composerStore, effectsRegistry, transport],
);
React.useEffect(() => () => application.dispose(), [application]);

Expand Down
25 changes: 25 additions & 0 deletions packages/webui/src/client/styles/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -3551,6 +3551,31 @@
.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 (rendered under
`securityLevel: "strict"`, so no external references); 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;
Expand Down
Loading