From 8644db2c2ad5febc5418a80286f29c90f27a21fc Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:02:06 +1000 Subject: [PATCH] feat(app): render Mermaid code blocks (#43024) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- bun.lock | 9 ++-- packages/app/vite.js | 3 +- packages/session-ui/package.json | 1 + .../src/components/markdown-mermaid.ts | 19 +++++++ .../session-ui/src/components/markdown.css | 27 ++++++++++ .../session-ui/src/components/markdown.tsx | 51 ++++++++++++++++++- .../src/components/markdown.worker.ts | 4 +- packages/ui/package.json | 2 +- 8 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 packages/session-ui/src/components/markdown-mermaid.ts diff --git a/bun.lock b/bun.lock index 4ba724139e3..f9074555a1d 100644 --- a/bun.lock +++ b/bun.lock @@ -700,6 +700,7 @@ "fuzzysort": "catalog:", "luxon": "catalog:", "marked": "catalog:", + "mermaid": "11.16.1", "morphdom": "2.7.8", "motion": "12.34.5", "remeda": "catalog:", @@ -916,7 +917,7 @@ "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", - "katex": "0.16.27", + "katex": "0.16.47", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:", @@ -4523,7 +4524,7 @@ "jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="], - "katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="], + "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], @@ -6787,8 +6788,6 @@ "mermaid/dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="], - "mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], - "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], "micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], @@ -7811,8 +7810,6 @@ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], - "mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], - "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], diff --git a/packages/app/vite.js b/packages/app/vite.js index 7a0e613402f..6569ec2130b 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -32,7 +32,8 @@ export default [ format: "es", }, optimizeDeps: { - exclude: ["@shikijs/stream", "katex", "marked", "marked-shiki", "remend"], + exclude: ["@shikijs/stream", "marked", "marked-shiki", "remend"], + include: ["@opencode-ai/session-ui > mermaid", "@opencode-ai/session-ui > mermaid > katex"], }, } }, diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 87d32d948b7..bc97cbcd47b 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -54,6 +54,7 @@ "fuzzysort": "catalog:", "luxon": "catalog:", "marked": "catalog:", + "mermaid": "11.16.1", "morphdom": "2.7.8", "motion": "12.34.5", "remeda": "catalog:", diff --git a/packages/session-ui/src/components/markdown-mermaid.ts b/packages/session-ui/src/components/markdown-mermaid.ts new file mode 100644 index 00000000000..a0262fdd3cb --- /dev/null +++ b/packages/session-ui/src/components/markdown-mermaid.ts @@ -0,0 +1,19 @@ +let loaded: ReturnType | undefined +let sequence = 0 + +export async function renderMermaidSvg(source: string) { + const mermaid = await (loaded ??= loadMermaid()) + if (!(await mermaid.parse(source, { suppressErrors: true }))) return + return (await mermaid.render(`markdown-mermaid-${sequence++}`, source)).svg +} + +async function loadMermaid() { + const { default: mermaid } = await import("mermaid") + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + theme: document.documentElement.dataset.colorScheme === "light" ? "default" : "dark", + flowchart: { htmlLabels: false }, + }) + return mermaid +} diff --git a/packages/session-ui/src/components/markdown.css b/packages/session-ui/src/components/markdown.css index eac9a7a408a..7adafc5dfe0 100644 --- a/packages/session-ui/src/components/markdown.css +++ b/packages/session-ui/src/components/markdown.css @@ -187,6 +187,33 @@ unicode-bidi: isolate; } + [data-component="markdown-mermaid"] { + min-height: 96px; + margin: 12px 0 24px; + padding: 16px; + overflow: auto; + border: 0.5px solid var(--v2-border-border-base); + border-radius: 6px; + background: var(--v2-background-bg-layer-02); + direction: ltr; + unicode-bidi: isolate; + } + + [data-component="markdown-mermaid"] > svg { + display: block; + width: 100%; + min-width: 480px; + height: auto; + margin: 0 auto; + } + + [data-component="markdown-code"][data-mermaid-pending="true"] > pre, + [data-component="markdown-code"][data-mermaid-pending="true"] > [data-slot="markdown-copy-button"], + [data-component="markdown-code"][data-mermaid-ready="true"] > pre, + [data-component="markdown-code"][data-mermaid-ready="true"] > [data-slot="markdown-copy-button"] { + display: none; + } + [data-component="markdown-code"][data-code-kind="shell"] .shiki { background: var(--markdown-shell-code-background); border-color: var(--markdown-shell-code-border); diff --git a/packages/session-ui/src/components/markdown.tsx b/packages/session-ui/src/components/markdown.tsx index f50420dff33..23aa8aa173d 100644 --- a/packages/session-ui/src/components/markdown.tsx +++ b/packages/session-ui/src/components/markdown.tsx @@ -31,6 +31,7 @@ import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state" import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache" import { inlineCodeKind } from "./markdown-inline-code-kind" +import { renderMermaidSvg } from "./markdown-mermaid" type RenderedBlock = | (MarkdownCacheEntry & { key: string; mode: Exclude }) @@ -229,6 +230,47 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) { } } +function decorateMermaid(wrapper: HTMLElement, code: HTMLElement, complete: boolean) { + if (!code.classList.contains("language-mermaid")) { + clearMermaid(wrapper) + return + } + + const source = code.textContent ?? "" + if (!source) return + const diagram = wrapper.querySelector('[data-component="markdown-mermaid"]') ?? document.createElement("div") + diagram.setAttribute("data-component", "markdown-mermaid") + if (!diagram.parentElement) wrapper.appendChild(diagram) + wrapper.dataset.mermaidPending = "true" + const input = complete ? source : source.slice(0, source.lastIndexOf("\n") + 1) + if (!input) return + const attempt = `${complete ? "complete" : "streaming"}:${input.length}` + if (wrapper.dataset.mermaidAttempt === attempt) return + wrapper.dataset.mermaidAttempt = attempt + void renderMermaidSvg(input) + .then((svg) => { + if (!svg) { + if (complete && code.textContent === source) clearMermaid(wrapper) + return + } + if (!(code.textContent ?? "").startsWith(input)) return + diagram.innerHTML = svg + delete wrapper.dataset.mermaidPending + wrapper.dataset.mermaidReady = "true" + }) + .catch(() => { + if (!complete || code.textContent !== source) return + clearMermaid(wrapper) + }) +} + +function clearMermaid(wrapper: HTMLElement) { + delete wrapper.dataset.mermaidAttempt + delete wrapper.dataset.mermaidPending + delete wrapper.dataset.mermaidReady + wrapper.querySelector('[data-component="markdown-mermaid"]')?.remove() +} + function markCodeLinks(root: HTMLDivElement) { const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code")) for (const code of codeNodes) { @@ -268,10 +310,13 @@ function markInlineCode(root: HTMLDivElement) { } } -function decorate(root: HTMLDivElement, labels: CopyLabels) { +function decorate(root: HTMLDivElement, labels: CopyLabels, complete: boolean) { const blocks = Array.from(root.querySelectorAll("pre")) for (const block of blocks) { ensureCodeWrapper(block, labels) + const wrapper = block.parentElement + const code = block.querySelector("code") + if (wrapper instanceof HTMLElement && code instanceof HTMLElement) decorateMermaid(wrapper, code, complete) } markInlineCode(root) markCodeLinks(root) @@ -598,7 +643,7 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl next.dataset.markdownHash = block.hash next.style.display = "contents" next.innerHTML = block.html - decorate(next, labels) + decorate(next, labels, block.mode === "full") if (!(current instanceof HTMLDivElement)) { container.appendChild(next) @@ -668,6 +713,7 @@ function updateCodeBlock( unstable: block.unstable, raw: block.raw, }) + if (wrapper instanceof HTMLElement) decorateMermaid(wrapper, code, block.complete) return } @@ -682,6 +728,7 @@ function updateCodeBlock( pre.appendChild(codeElement) wrapper.appendChild(pre) wrapper.appendChild(createCopyButton(labels)) + decorateMermaid(wrapper, codeElement, block.complete) next.appendChild(wrapper) renderedCodeTokens.set(next, { language: block.language, diff --git a/packages/session-ui/src/components/markdown.worker.ts b/packages/session-ui/src/components/markdown.worker.ts index fe7187cfd59..7bdabd4f132 100644 --- a/packages/session-ui/src/components/markdown.worker.ts +++ b/packages/session-ui/src/components/markdown.worker.ts @@ -39,7 +39,9 @@ const parser = createMarkdownParser(async (code, language) => { const name = language in bundledLanguages ? language : "text" if (!instance.getLoadedLanguages().includes(name)) await instance.loadLanguage(bundledLanguages[name as BundledLanguage]) - return instance.codeToHtml(code, { lang: name as BundledLanguage, theme: "OpenCode", tabindex: false }) + return instance + .codeToHtml(code, { lang: name as BundledLanguage, theme: "OpenCode", tabindex: false }) + .replace("", ``) }) self.onmessage = (event: MessageEvent) => { diff --git a/packages/ui/package.json b/packages/ui/package.json index 558992cee55..4707337d193 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -89,7 +89,7 @@ "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", - "katex": "0.16.27", + "katex": "0.16.47", "luxon": "catalog:", "marked": "catalog:", "marked-shiki": "catalog:",