Просмотр исходного кода

feat(ui): preview Mermaid diagrams in Chat code blocks

fz 4 дней назад
Родитель
Сommit
ee35e40bc8
30 измененных файлов с 697 добавлено и 13 удалено
  1. 6 0
      .agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml
  2. 31 0
      .agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md
  3. 31 0
      .agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md
  4. 1 1
      THIRD_PARTY_NOTICES.md
  5. 53 0
      apps/web/tests/expected/markdown-mermaid/ui.expected.md
  6. 53 0
      apps/web/tests/expected/markdown-mermaid/zh.expected.md
  7. 127 0
      apps/web/tests/markdown-mermaid.e2e.ts
  8. 1 0
      apps/web/tsconfig.json
  9. 5 0
      packages/client/locale/src/locales/en.ts
  10. 5 0
      packages/client/locale/src/locales/zh.ts
  11. 2 2
      packages/client/ui-chat/README.i18n.yaml
  12. 8 0
      packages/client/ui-chat/README.md
  13. 8 0
      packages/client/ui-chat/README.zh.md
  14. 7 0
      packages/client/ui-chat/src/client/markdown-labels.ts
  15. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  16. 4 0
      packages/client/ui-primitives/README.md
  17. 4 0
      packages/client/ui-primitives/README.zh.md
  18. 2 1
      packages/client/ui-primitives/package.json
  19. 2 0
      packages/client/ui-primitives/src/index.ts
  20. 1 0
      packages/client/ui-primitives/src/markdown/CodeBlock.module.css
  21. 13 6
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  22. 26 0
      packages/client/ui-primitives/src/markdown/MermaidPreview.module.css
  23. 42 0
      packages/client/ui-primitives/src/markdown/MermaidPreview.tsx
  24. 59 0
      packages/client/ui-primitives/src/markdown/mermaid.ts
  25. 9 0
      packages/client/ui-primitives/src/markdown/render.tsx
  26. 1 1
      packages/client/ui-primitives/tests/code-block.client.spec.tsx
  27. 107 0
      packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx
  28. 83 0
      packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts
  29. 3 0
      pnpm-lock.yaml
  30. 1 0
      tsconfig.host.json

+ 6 - 0
.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md
+2026-09-07-web-mermaid-preview.md: c891b4133e48a555de945c250679555f91f44c2f
+2026-09-07-web-mermaid-preview.zh.md: a4c79c430a07b2655c85354a4406da3914bbd4c4

+ 31 - 0
.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md

@@ -0,0 +1,31 @@
+# Agent Note: Mermaid previews in Chat code fences
+
+Status: implemented
+
+English | [中文](2026-09-07-web-mermaid-preview.zh.md)
+
+## Problem
+
+Assistant replies can describe diagrams in Mermaid code fences, but readers must interpret the source or copy it to another renderer. Rendering must handle incomplete streaming input and untrusted diagram content while remaining reusable outside Chat.
+
+## Decision
+
+Chat enables settled fence previews through `MarkdownLabels.mermaid`. The shared Markdown renderer uses the parsed fence language; streaming fences and consumers without those labels retain code display. The static [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `MermaidPreview`, which accepts source and localized labels without Session, file, or Cordis dependencies. `CodeBlock.preview` reuses the banner and copy action; copying always reads the source prop.
+
+Mermaid loads on demand. Its public render API serializes diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the neutral theme, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing diagram links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas stays light in both application themes.
+
+Source replacement and unmounting cancel result publication. Cancellation before runtime loading completes prevents rendering; an active Mermaid render finishes and releases its DOM but cannot publish to a cancelled component. Failures show a localized error and the original source. Replacing invalid source with a valid diagram recovers the preview.
+
+## Alternatives considered
+
+**Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. The existing message-settlement boundary provides a complete source value.
+
+**Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md).
+
+**Insert interactive SVG into the message.** The requested preview needs diagram display and source access. Displaying the result as an image keeps diagram links and event handlers inactive.
+
+## Consequences
+
+The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. Mermaid adds lazily loaded browser assets. Rendering still runs on the browser thread, and work already handed to the library cannot be interrupted. The initial feature has no editing, export, zoom controls, or interactive diagram links.
+
+Component tests cover delayed completion, stale success and failure, unmounting, source fallback, and copying. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) exercises the assembled Chat with Chinese flowcharts, sequence diagrams, malformed source, configuration overrides, and English/Chinese UI snapshots.

+ 31 - 0
.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: Chat 代码 fence 中的 Mermaid 预览
+
+Status: implemented
+
+[English](2026-09-07-web-mermaid-preview.md) | 中文
+
+## 问题
+
+Assistant 回复可以在 Mermaid 代码 fence 中描述图表,但读者必须理解源码或把它复制到其他渲染器。渲染需要处理不完整的流式输入与不可信图表内容,同时保持在 Chat 之外的可复用性。
+
+## 决策
+
+Chat 通过 `MarkdownLabels.mermaid` 启用已定稿 fence 预览。共享 Markdown 渲染器使用解析后的 fence 语言;流式 fence 和未传入这些 label 的调用方保留代码显示。静态 [UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `MermaidPreview`,它接收源码与本地化 label,不依赖 Session、文件或 Cordis。`CodeBlock.preview` 复用标题栏和复制操作;复制始终读取源码 prop。
+
+Mermaid 按需加载。它的公开 render API 串行执行图表工作,每次调用在 `finally` 中移除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML label、中性主题和错误渲染策略。生成的 SVG 以图片显示,不安装图内链接或脚本。固有尺寸取自 SVG viewBox;大图缩小以适应宽度,画布在两种应用主题下均保持浅色。
+
+替换源码与卸载组件会取消结果发布。在运行时加载完成前取消可阻止渲染;已经开始的 Mermaid 渲染会完成并释放 DOM,但无法向已取消的组件发布结果。失败时显示本地化错误和原始源码。用有效图表替换非法源码可恢复预览。
+
+## 考虑过的替代方案
+
+**渲染每个流式分片。** 不完整的图表经常无效,反复布局也会与文本流式输出竞争。已有的消息定稿边界提供完整源码值。
+
+**把渲染放在 Chat 内或增加通用预览注册表。** 接收普通 prop 的共享原语无需另一套注册表或功能插件依赖即可满足复用。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。
+
+**把可交互 SVG 插入消息。** 此次预览需求只需要图表显示与源码访问。将结果显示为图片后,图内链接和事件处理器不会生效。
+
+## 后果
+
+功能只改变展示,不改变持久化消息、提供者请求、工具或 Host API。Mermaid 增加按需加载的浏览器资源。渲染仍在浏览器线程上执行,已交给库的工作无法中断。首版不包含编辑、导出、缩放控件或可交互图内链接。
+
+组件测试覆盖延迟完成、过期成功与失败、卸载、源码回退和复制。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)通过完整 Chat 组合验证中文流程图、时序图、非法源码、配置覆盖以及英文/中文 UI 快照。

+ 1 - 1
THIRD_PARTY_NOTICES.md

@@ -79,6 +79,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
 | [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
 | [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
 | [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT |
+| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
 | [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT |
 | [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
 | [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT |
@@ -189,7 +190,6 @@ External packages **directly declared** for development, tests, types, or toolin
 | [`jsdom`](https://github.com/jsdom/jsdom) | MIT |
 | [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
 | [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
-| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
 | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
 | [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT |
 | [`oxlint`](https://github.com/oxc-project/oxc) | MIT |

+ 53 - 0
apps/web/tests/expected/markdown-mermaid/ui.expected.md

@@ -0,0 +1,53 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Mermaid previews" [disabled]
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- text: Preview these Mermaid diagrams. {{clock}}
+- button "Copy":
+  - img
+- heading "Mermaid previews" [level=1]
+- text: mermaid
+- button "Source"
+- button "Copy"
+- img "Mermaid diagram"
+- text: mermaid
+- button "Source"
+- button "Copy"
+- img "Mermaid diagram"
+- text: mermaid
+- button "Source"
+- button "Copy"
+- status: Unable to render this diagram. The source is shown below.
+- code: flowchart LR A[unfinished
+- text: mermaid
+- button "Source"
+- button "Copy"
+- img "Mermaid diagram"
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- button "Add attachment":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "Send message" [disabled]
+- text: 1 turns · 1 steps LLM {{duration}}

+ 53 - 0
apps/web/tests/expected/markdown-mermaid/zh.expected.md

@@ -0,0 +1,53 @@
+- banner:
+  - navigation "会话层级":
+    - button "Mermaid previews" [disabled]
+  - button "Session 日志":
+    - text: Session 日志
+    - img
+  - tablist:
+    - tab "对话" [selected]
+    - tab "轨迹"
+- text: Preview these Mermaid diagrams. {{clock}}
+- button "复制":
+  - img
+- heading "Mermaid previews" [level=1]
+- text: mermaid
+- button "源码"
+- button "复制"
+- img "Mermaid 图表"
+- text: mermaid
+- button "源码"
+- button "复制"
+- img "Mermaid 图表"
+- text: mermaid
+- button "源码"
+- button "复制"
+- status: 无法渲染此图表,源码如下。
+- code: flowchart LR A[unfinished
+- text: mermaid
+- button "源码"
+- button "复制"
+- img "Mermaid 图表"
+- button "复制":
+  - img
+- button "好的回答":
+  - img
+- button "有问题的回答":
+  - img
+- button "在新对话中分支":
+  - img
+- button "用时 {{duration}}":
+  - img
+  - text: 用时 {{duration}}
+- text: {{clock}}
+- textbox "发消息或做任务… / 调用指令 @ 文件或对话"
+- button "指令":
+  - img
+- button "添加附件":
+  - img
+- button "访问模式,当前:工作区内修改": 工作区内修改
+- button "选择模型,当前 DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "发送消息" [disabled]
+- text: 1 轮 · 1 步 LLM {{duration}}

+ 127 - 0
apps/web/tests/markdown-mermaid.e2e.ts

@@ -0,0 +1,127 @@
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-session-title'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold,
+  seedSession, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-mermaid', import.meta.url))
+const MODE = webSnapshotMode()
+const SEED_ID = 'markdown-mermaid-web-e2e'
+const FLOW = 'flowchart LR\n  A[输入] --> B[共享渲染器] --> C[图形预览]'
+const SEQUENCE = 'sequenceDiagram\n  participant U as User\n  participant R as Renderer\n  U->>R: Mermaid source\n  R-->>U: Diagram'
+const INVALID = 'flowchart LR\n  A[unfinished'
+const UNTRUSTED = [
+  '%%{init: {"securityLevel":"loose","htmlLabels":true,"themeCSS":"body {display:none!important}"}}%%',
+  'flowchart LR',
+  '  A["<img src=x onerror=alert(1)>"] --> B[Safe]',
+  '  click B "javascript:alert(1)"',
+].join('\n')
+
+function fixture(): string {
+  const session = Session.create(SessionId('markdown-mermaid-source'))
+  session.append('turn/start', { turn: 1 })
+  const user = session.append('user/message', createUserMessage({
+    content: [{ type: 'text', text: 'Preview these Mermaid diagrams.' }], source: { kind: 'user' },
+  }), { surfaceOp: 'append' })
+  session.append('session/title', { title: 'Mermaid previews', messageSeqs: [user.seq], source: { kind: 'fallback' } })
+  session.append('step/start', { turn: 1, step: 1 })
+  session.append('assistant/message', {
+    stream: [], turn: 1, step: 1,
+    message: createMessage({
+      role: 'assistant',
+      content: [{ type: 'text', text: [
+        '# Mermaid previews',
+        ...[FLOW, SEQUENCE, INVALID, UNTRUSTED].map(code => `\`\`\`mermaid\n${code}\n\`\`\``),
+      ].join('\n\n') }],
+      source: { kind: 'model', provider: 'fixture', model: 'fixture' },
+    }),
+  }, { surfaceOp: 'append' })
+  session.append('step/end', { turn: 1, step: 1 })
+  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  const noon = new Date().setHours(12, 0, 0, 0)
+  return [
+    JSON.stringify({
+      type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0,
+      cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
+    }),
+    ...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: noon + event.seq * 1000 })),
+    '',
+  ].join('\n')
+}
+
+async function openConversation(page: Page, scaffold: WebScaffold): Promise<void> {
+  await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+  await page.getByRole('treeitem').first().click({ timeout: 30_000 })
+  await page.getByRole('treeitem').nth(1).click()
+  await page.getByRole('heading', { name: 'Mermaid previews' }).waitFor()
+}
+
+describe('web e2e: Mermaid chat previews', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    await seedSession(scaffold, fixture(), SEED_ID)
+    browser = await chromium.launch()
+  }, 120_000)
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it.skipIf(MODE === 'record')('renders diagrams, switches to source, copies source, and contains malformed content', async () => {
+    const page = await newEnglishPage(browser)
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-mermaid'))
+    const tripwire = watchConsole(page)
+    const dialogs: string[] = []
+    page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() })
+    await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+    await openConversation(page, scaffold)
+    const images = page.getByRole('img', { name: 'Mermaid diagram' })
+    await expect.poll(() => images.count(), { timeout: 20_000 }).toBe(3)
+    await expect.poll(() => images.evaluateAll(nodes => nodes.every(node => (node as HTMLImageElement).naturalWidth > 0))).toBe(true)
+    expect(await images.evaluateAll(nodes => nodes.every(node =>
+      node.getBoundingClientRect().width <= (node as HTMLImageElement).naturalWidth))).toBe(true)
+    expect(await page.getByText('Unable to render this diagram. The source is shown below.', { exact: true }).count()).toBe(1)
+    expect(await page.locator('pre code').allTextContents()).toContain(INVALID)
+    expect(await images.first().evaluate(node => decodeURIComponent((node as HTMLImageElement).src))).toContain('共享渲染器')
+    const first = page.locator('.md-code-block').first()
+    await first.getByRole('button', { name: 'Copy', exact: true }).click()
+    expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(FLOW)
+    await first.getByRole('button', { name: 'Source', exact: true }).click()
+    expect(await first.locator('pre code').textContent()).toBe(FLOW)
+    await first.getByRole('button', { name: 'Preview', exact: true }).click()
+    await first.getByRole('img').waitFor()
+    await first.getByRole('button', { name: 'Copy', exact: true }).waitFor()
+    expect(await page.locator('body').evaluate(node => getComputedStyle(node).display)).not.toBe('none')
+    expect(await page.locator('[id^="dsh-mermaid-"]').count()).toBe(0)
+    expect(dialogs).toEqual([])
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'), snapshot, MODE)
+    await page.close()
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('localizes the preview and failure states in Chinese', async () => {
+    const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
+    await openConversation(page, scaffold)
+    await expect.poll(() => page.getByRole('img', { name: 'Mermaid 图表' }).count(), { timeout: 20_000 }).toBe(3)
+    expect(await page.getByRole('button', { name: '源码', exact: true }).count()).toBe(4)
+    expect(await page.getByText('无法渲染此图表,源码如下。', { exact: true }).count()).toBe(1)
+    const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh.expected.md'), snapshot, MODE)
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'zh.expected.md'])
+    await page.close()
+  }, 60_000)
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -79,6 +79,7 @@
     "tests/math-rendering.e2e.ts",
     "tests/markdown-cjk-strong.e2e.ts",
     "tests/markdown-inline-code-links.e2e.ts",
+    "tests/markdown-mermaid.e2e.ts",
     "tests/clickable-links-gallery.e2e.ts",
     "tests/queue-actions.e2e.ts",
     "tests/queue-image.e2e.ts",

+ 5 - 0
packages/client/locale/src/locales/en.ts

@@ -38,6 +38,11 @@ export const en = {
   'json.expandNode': 'Expand JSON node',
   'json.label': 'JSON',
   'markdown.footnotes': 'Footnotes',
+  'markdown.mermaid.diagram': 'Mermaid diagram',
+  'markdown.mermaid.preview': 'Preview',
+  'markdown.mermaid.source': 'Source',
+  'markdown.mermaid.loading': 'Rendering diagram…',
+  'markdown.mermaid.error': 'Unable to render this diagram. The source is shown below.',
   'markdown.truncatedCharacters': '… truncated at {total} characters',
   'number.thousand': '{value}K',
   'number.million': '{value}M',

+ 5 - 0
packages/client/locale/src/locales/zh.ts

@@ -36,6 +36,11 @@ export const zh = {
   'json.expandNode': '展开 JSON 节点',
   'json.label': 'JSON',
   'markdown.footnotes': '脚注',
+  'markdown.mermaid.diagram': 'Mermaid 图表',
+  'markdown.mermaid.preview': '预览',
+  'markdown.mermaid.source': '源码',
+  'markdown.mermaid.loading': '正在渲染图表…',
+  'markdown.mermaid.error': '无法渲染此图表,源码如下。',
   'markdown.truncatedCharacters': '… 已截断,共 {total} 字符',
   'number.thousand': '{value}K',
   'number.million': '{value}M',

+ 2 - 2
packages/client/ui-chat/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8
-README.zh.md: 4dd06a16b19b14cacc0ded603cc1cf8869dd1a43
+README.md: 6115e18f303cfa6ecc63379eabf8a7ad6f56b121
+README.zh.md: 3cb069cdf6cedb6d0c5c045f95e106c2c26959b6

+ 8 - 0
packages/client/ui-chat/README.md

@@ -15,6 +15,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 ## Table of Contents
 
 - [System prompt row](#system-prompt-row)
+- [Mermaid previews](#mermaid-previews)
 - [Turn token usage](#turn-token-usage)
 - [Turn Process Folding](#turn-process-folding)
 - [Scroll ownership](#scroll-ownership)
@@ -29,6 +30,13 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 
 Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives.
 
+<a id="mermaid-previews"></a>
+## Mermaid previews
+
+Settled Assistant Markdown shows `mermaid` code fences as diagrams. Each fence offers Source/Preview switching and copies the original Mermaid source in either view. Streaming fences remain code until the message settles; invalid diagrams show an error and their source without disrupting other blocks. Chat enables the shared [Mermaid preview primitive](../ui-primitives/README.md) through localized labels. This is a presentation change and adds no model prompt, tool, or Session event.
+
+-----
+
 <a id="turn-token-usage"></a>
 ## Turn token usage
 

+ 8 - 0
packages/client/ui-chat/README.zh.md

@@ -15,6 +15,7 @@ kind: "package-reference"
 ## 目录
 
 - [系统提示词行](#system-prompt-row)
+- [Mermaid 预览](#mermaid-previews)
 - [轮次 token 用量](#turn-token-usage)
 - [轮次过程折叠](#turn-process-folding)
 - [滚动归属](#scroll-ownership)
@@ -31,6 +32,13 @@ kind: "package-reference"
 
 -----
 
+<a id="mermaid-previews"></a>
+## Mermaid 预览
+
+已定稿的 Assistant Markdown 把 `mermaid` 代码 fence 显示为图表。每个 fence 支持源码/预览切换,在任一视图中复制的都是原始 Mermaid 源码。流式 fence 在消息定稿前保持代码显示;非法图表显示错误与源码,不影响其他块。Chat 通过本地化 label 启用共享的 [Mermaid 预览原语](../ui-primitives/README.zh.md)。这项展示变更不增加模型提示词、工具或 Session 事件。
+
+-----
+
 <a id="turn-token-usage"></a>
 ## 轮次 token 用量
 

+ 7 - 0
packages/client/ui-chat/src/client/markdown-labels.ts

@@ -12,5 +12,12 @@ export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels {
   return {
     code: { copyLabel: t('copy'), copiedLabel: t('copied') },
     footnotes: t('markdown.footnotes'),
+    mermaid: {
+      diagram: t('markdown.mermaid.diagram'),
+      preview: t('markdown.mermaid.preview'),
+      source: t('markdown.mermaid.source'),
+      loading: t('markdown.mermaid.loading'),
+      error: t('markdown.mermaid.error'),
+    },
   }
 }

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73
-README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6
+README.md: 1e9ee01f6c2243c8b980e45cf512dda1af330af0
+README.zh.md: 02c0417c359070c2f4a4ca11a22709c401847cec

+ 4 - 0
packages/client/ui-primitives/README.md

@@ -53,6 +53,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 | `Toast` | Transient top-center banner held for the owner's `holdMs`. |
 | `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
 | `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter. |
+| `MermaidPreview` | Read-only Mermaid diagram image with loading and source fallback states. |
 | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
 | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon`, `DocumentFileIcon` | Glyphs and brand marks, all riding `currentColor`. |
 | `FileTypeIcon` | The coloured file-type sheet (code, html, image, markdown, pdf, sheet, slides, document, other); `classifyFileType` picks the kind from a path's extension. |
@@ -73,6 +74,9 @@ The catalog above lists what each export is for; this section covers the behavio
 
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
 
+`MermaidPreview` renders complete Mermaid source on a light canvas. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width.
+
+Supply `MarkdownLabels.mermaid` to enable `mermaid` fence previews in a Markdown consumer; without it, fences remain code. Previews start after the message settles. `CodeBlock.preview` supplies an optional alternate body and localized Source/Preview switch labels while the existing Copy button always copies the source. [The Mermaid decision](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.md) records the rendering and reuse choices.
 
 ### Localizing copy
 

+ 4 - 0
packages/client/ui-primitives/README.zh.md

@@ -53,6 +53,7 @@ kind: "package-library"
 | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
 | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
 | `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏。 |
+| `MermaidPreview` | 只读 Mermaid 图表图片,包含加载状态与源码回退状态。 |
 | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
 | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon`、`DocumentFileIcon` | 字形与品牌标识,全部随 `currentColor`。 |
 | `FileTypeIcon` | 彩色文件类型纸片(code、html、image、markdown、pdf、sheet、slides、document、other);`classifyFileType` 按路径扩展名选出类型。 |
@@ -73,6 +74,9 @@ kind: "package-library"
 
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
 
+`MermaidPreview` 在浅色画布上渲染完整的 Mermaid 源码。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。
+
+为 Markdown 调用方传入 `MarkdownLabels.mermaid` 即可启用 `mermaid` fence 预览;未传入时,fence 保持代码显示。预览在消息定稿后开始。`CodeBlock.preview` 提供可选的替代正文和本地化的「源码/预览」切换文案,现有「复制」按钮始终复制源码。[Mermaid 决策](../../../.agents/notes/implemented/feature/2026-09-07-web-mermaid-preview.zh.md)记录了渲染与复用选择。
 
 ### 本地化文案
 

+ 2 - 1
packages/client/ui-primitives/package.json

@@ -45,7 +45,8 @@
     "mdast-util-gfm": "^3.1.0",
     "mdast-util-math": "^3.0.0",
     "micromark-extension-gfm": "^3.0.0",
-    "micromark-util-sanitize-uri": "^2.0.1"
+    "micromark-util-sanitize-uri": "^2.0.1",
+    "mermaid": "11.16.0"
   },
   "files": [
     "lib/index.js",

+ 2 - 0
packages/client/ui-primitives/src/index.ts

@@ -64,6 +64,8 @@ export type {
 } from './WebBlock.tsx'
 export { CodeBlock } from './markdown/CodeBlock.tsx'
 export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
+export { MermaidPreview } from './markdown/MermaidPreview.tsx'
+export type { MermaidPreviewLabels } from './markdown/MermaidPreview.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'
 export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx'

+ 1 - 0
packages/client/ui-primitives/src/markdown/CodeBlock.module.css

@@ -56,6 +56,7 @@
   display: flex;
   align-items: center;
   flex-shrink: 0;
+  gap: 12px;
 }
 
 .copyButton {

+ 13 - 6
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -31,6 +31,8 @@ export interface CodeBlockProps {
   copyLabel: string
   /** Copy-button label during the post-copy confirmation window. */
   copiedLabel: string
+  /** Optional settled preview with localized switch labels; copying always retains the source text. */
+  preview?: { content: ReactNode; previewLabel: string; sourceLabel: string } | undefined
 }
 
 /**
@@ -59,7 +61,7 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
   )
 }
 
-export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) {
+export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel, preview }: CodeBlockProps) {
   const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
   const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
   const rootRef = useRef<HTMLDivElement>(null)
@@ -143,13 +145,13 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
     [streaming, highlighting, streamedBody, trimmed, lang, loaded],
   )
   const [copied, setCopied] = useState(false)
+  const [showSource, setShowSource] = useState(false)
+  const previewAvailable = preview !== undefined && streaming !== true
+  const showingPreview = previewAvailable && !showSource
 
   const onCopy = useCallback(() => {
     if (copied) return
-    /* v8 ignore next -- both arms always mount a <pre>; trimmed is the
-       typed fallback if the DOM shape ever diverges. */
-    const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
-    void writeClipboard(text).then((ok) => {
+    void writeClipboard(trimmed).then((ok) => {
       if (!ok) return
       setCopied(true)
       window.setTimeout(() => { setCopied(false) }, 1000)
@@ -181,13 +183,18 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
         <div className={css.banner}>
           <div className={css.infostring}>{lang ?? ''}</div>
           <div className={css.action}>
+            {previewAvailable && (
+              <button type="button" className={css.copyButton} onClick={() => { setShowSource(!showSource) }}>
+                {showingPreview ? preview.sourceLabel : preview.previewLabel}
+              </button>
+            )}
             <button type="button" className={css.copyButton} onClick={onCopy}>
               {copied ? copiedLabel : copyLabel}
             </button>
           </div>
         </div>
       </div>
-      {body}
+      {showingPreview ? preview.content : body}
     </div>
   )
 }

+ 26 - 0
packages/client/ui-primitives/src/markdown/MermaidPreview.module.css

@@ -0,0 +1,26 @@
+.canvas {
+  overflow: auto;
+  padding: 16px;
+  background: var(--dsw-static-neutral-00);
+  border-radius: 0 0 12px 12px;
+}
+
+.diagram {
+  display: block;
+  max-width: 100%;
+  height: auto;
+  margin: 0 auto;
+}
+
+.status {
+  padding: 16px;
+  color: var(--dsw-alias-label-secondary);
+}
+
+.staging {
+  position: fixed;
+  top: 0;
+  left: -100000px;
+  visibility: hidden;
+  pointer-events: none;
+}

+ 42 - 0
packages/client/ui-primitives/src/markdown/MermaidPreview.tsx

@@ -0,0 +1,42 @@
+/** Read-only diagram preview with source fallback and per-source async ownership. */
+
+import { useEffect, useState } from 'react'
+import { renderMermaid } from './mermaid.ts'
+import css from './MermaidPreview.module.css'
+
+/** Localized preview states; the diagram source remains verbatim. */
+export interface MermaidPreviewLabels {
+  diagram: string
+  loading: string
+  error: string
+}
+
+type Result = { code: string; src: string } | { code: string; error: true }
+
+/**
+ * Display a complete Mermaid document on a light diagram canvas, or its source if rendering fails.
+ * @param props - Source and complete localized labels. Changing source discards the previous result.
+ * @returns A loading status, an inert SVG image, or an error with the original source.
+ */
+export function MermaidPreview({ code, labels }: { code: string; labels: MermaidPreviewLabels }) {
+  const [result, setResult] = useState<Result | null>(null)
+  useEffect(() => {
+    const controller = new AbortController()
+    void renderMermaid(code, controller.signal).then(
+      (src) => { if (!controller.signal.aborted) setResult({ code, src }) },
+      () => { if (!controller.signal.aborted) setResult({ code, error: true }) },
+    )
+    return () => { controller.abort() }
+  }, [code])
+
+  if (result?.code !== code) return <div className={css.status} role="status">{labels.loading}</div>
+  if ('error' in result) {
+    return (
+      <div>
+        <div className={css.status} role="status">{labels.error}</div>
+        <pre><code>{code}</code></pre>
+      </div>
+    )
+  }
+  return <div className={css.canvas}><img className={css.diagram} src={result.src} alt={labels.diagram} /></div>
+}

+ 59 - 0
packages/client/ui-primitives/src/markdown/mermaid.ts

@@ -0,0 +1,59 @@
+/** Lazy Mermaid runtime; each render owns and removes its temporary measurement DOM. */
+
+import type { Mermaid } from 'mermaid'
+import clsx from 'clsx'
+import css from './MermaidPreview.module.css'
+
+let runtime: Promise<Mermaid> | undefined
+let nextDiagramId = 0
+
+function loadMermaid(): Promise<Mermaid> {
+  runtime ??= import('mermaid').then(({ default: mermaid }) => {
+    mermaid.initialize({
+      startOnLoad: false,
+      securityLevel: 'strict',
+      suppressErrorRendering: true,
+      theme: 'neutral',
+      htmlLabels: false,
+      secure: [
+        'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges',
+        'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart',
+      ],
+    })
+    return mermaid
+  }).catch((error: unknown) => {
+    runtime = undefined
+    throw error
+  })
+  return runtime
+}
+
+/**
+ * Render untrusted diagram source as an SVG image URL, without installing SVG or link handlers in the UI.
+ * @param code - Complete Mermaid source.
+ * @param signal - Cancels work waiting for the runtime; an active Mermaid render finishes before cleanup.
+ * @returns An SVG data URL. Import, parse, rendering, and cancellation failures reject.
+ */
+export async function renderMermaid(code: string, signal: AbortSignal): Promise<string> {
+  const mermaid = await loadMermaid()
+  signal.throwIfAborted()
+  const stage = document.createElement('div')
+  stage.className = clsx(css.staging)
+  stage.setAttribute('aria-hidden', 'true')
+  document.body.append(stage)
+  try {
+    // Mermaid's public render API serializes diagram work; the lower-level mermaidAPI does not.
+    const { svg } = await mermaid.render(`dsh-mermaid-${nextDiagramId++}`, code, stage)
+    const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
+    const viewBox = root.getAttribute('viewBox')
+    if (viewBox !== null) {
+      // An SVG image needs intrinsic dimensions; Mermaid's percentage width is for inline SVG.
+      const [, , width, height] = viewBox.split(/\s+/) as [string, string, string, string]
+      root.setAttribute('width', width)
+      root.setAttribute('height', height)
+    }
+    return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(root))}`
+  } finally {
+    stage.remove()
+  }
+}

+ 9 - 0
packages/client/ui-primitives/src/markdown/render.tsx

@@ -23,6 +23,8 @@ import type * as Md from 'mdast'
 import type {} from 'mdast-util-math'
 import { normalizeUri } from 'micromark-util-sanitize-uri'
 import { CodeBlock } from './CodeBlock.tsx'
+import { MermaidPreview } from './MermaidPreview.tsx'
+import type { MermaidPreviewLabels } from './MermaidPreview.tsx'
 import { renderTexToReact } from './katex.tsx'
 import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx'
 import type { PositionedBlock } from './incremental.ts'
@@ -40,6 +42,8 @@ export interface MarkdownCodeLabels {
 export interface MarkdownLabels {
   code: MarkdownCodeLabels
   footnotes: string
+  /** Opt into settled Mermaid fence previews by supplying their complete localized chrome. */
+  mermaid?: MermaidPreviewLabels & { preview: string; source: string }
 }
 
 function sanitizeUrl(url: string): string {
@@ -394,6 +398,11 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
       streaming={context.streaming}
       copyLabel={context.labels.code.copyLabel}
       copiedLabel={context.labels.code.copiedLabel}
+      preview={lang === 'mermaid' && !context.streaming && context.labels.mermaid !== undefined ? {
+        content: <MermaidPreview code={node.value} labels={context.labels.mermaid} />,
+        previewLabel: context.labels.mermaid.preview,
+        sourceLabel: context.labels.mermaid.source,
+      } : undefined}
     />
   )
 }

+ 1 - 1
packages/client/ui-primitives/tests/code-block.client.spec.tsx

@@ -122,7 +122,7 @@ describe('CodeBlock', () => {
     expect(view.getByText('plain text')).toBeTruthy()
   })
 
-  it('shows the language banner and copies the pre textContent', async () => {
+  it('shows the language banner and copies the displayed source text', async () => {
     vi.useFakeTimers()
     const writeText = vi.fn().mockResolvedValue(undefined)
     Object.defineProperty(navigator, 'clipboard', {

+ 107 - 0
packages/client/ui-primitives/tests/mermaid-preview.client.spec.tsx

@@ -0,0 +1,107 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { MermaidPreview } from '../src/markdown/MermaidPreview.tsx'
+import { renderMermaid } from '../src/markdown/mermaid.ts'
+import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
+import { markdownLabels } from './labels.client.ts'
+
+vi.mock('../src/markdown/mermaid.ts', () => ({ renderMermaid: vi.fn() }))
+
+const labels = {
+  diagram: 'Mermaid diagram', loading: 'Rendering diagram…', error: 'Unable to render this diagram.',
+  preview: 'Preview', source: 'Source',
+}
+const source = 'flowchart LR\n  A[Input] --> B[Preview]'
+const imageUrl = 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E'
+
+afterEach(() => { cleanup(); vi.unstubAllGlobals() })
+beforeEach(() => { vi.resetAllMocks() })
+
+describe('MermaidPreview', () => {
+  it('shows loading until rendering completes, then displays an image without inserting SVG', async () => {
+    const pending = Promise.withResolvers<string>()
+    vi.mocked(renderMermaid).mockReturnValue(pending.promise)
+    const view = render(<MermaidPreview code={source} labels={labels} />)
+    expect(screen.getByRole('status').textContent).toBe(labels.loading)
+    expect(screen.queryByRole('img')).toBeNull()
+    await act(async () => { pending.resolve(imageUrl) })
+    expect(screen.getByRole('img', { name: labels.diagram }).getAttribute('src')).toBe(imageUrl)
+    expect(view.container.querySelector('svg')).toBeNull()
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
+  it('retains invalid source and recovers when the source changes', async () => {
+    vi.mocked(renderMermaid).mockRejectedValueOnce(new Error('Parse error'))
+    const view = render(<MermaidPreview code="invalid" labels={labels} />)
+    expect((await screen.findByText(labels.error)).getAttribute('role')).toBe('status')
+    expect(view.container.querySelector('pre code')?.textContent).toBe('invalid')
+    const next = Promise.withResolvers<string>()
+    vi.mocked(renderMermaid).mockReturnValue(next.promise)
+    view.rerender(<MermaidPreview code={source} labels={labels} />)
+    expect(screen.getByRole('status').textContent).toBe(labels.loading)
+    expect(view.container.querySelector('pre')).toBeNull()
+    await act(async () => { next.resolve(imageUrl) })
+    expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
+  })
+
+  it.each(['resolve', 'reject'] as const)('ignores a stale %s after a newer source finishes', async (outcome) => {
+    const old = Promise.withResolvers<string>()
+    const next = Promise.withResolvers<string>()
+    vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise)
+    const view = render(<MermaidPreview code="old" labels={labels} />)
+    const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1]
+    view.rerender(<MermaidPreview code={source} labels={labels} />)
+    expect(oldSignal.aborted).toBe(true)
+    await act(async () => { next.resolve(imageUrl) })
+    await act(async () => {
+      if (outcome === 'resolve') old.resolve('obsolete-image')
+      else old.reject(new Error('obsolete-error'))
+    })
+    expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
+  it('cancels the owner when unmounted while loading', async () => {
+    const pending = Promise.withResolvers<string>()
+    vi.mocked(renderMermaid).mockReturnValue(pending.promise)
+    const view = render(<MermaidPreview code={source} labels={labels} />)
+    const signal = vi.mocked(renderMermaid).mock.calls[0]![1]
+    view.unmount()
+    expect(signal.aborted).toBe(true)
+    await act(async () => { pending.reject(new Error('cancelled')) })
+    expect(screen.queryByRole('img')).toBeNull()
+  })
+})
+
+describe('Markdown Mermaid fences', () => {
+  it('keeps streaming source literal, then offers preview, source and source copying', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    vi.stubGlobal('navigator', Object.create(navigator, { clipboard: { value: { writeText } } }))
+    vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
+    const props = { text: `\`\`\`mermaid\n${source}\n\`\`\``, labels: { ...markdownLabels, mermaid: labels } }
+    const view = render(<MarkdownText {...props} streaming />)
+    expect(view.container.querySelector('pre code')?.textContent).toBe(source)
+    expect(renderMermaid).not.toHaveBeenCalled()
+    expect(screen.queryByRole('button', { name: labels.source })).toBeNull()
+    view.rerender(<MarkdownText {...props} />)
+    await screen.findByRole('img', { name: labels.diagram })
+    fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
+    await screen.findByRole('button', { name: markdownLabels.code.copiedLabel })
+    expect(writeText).toHaveBeenCalledWith(source)
+    fireEvent.click(screen.getByRole('button', { name: labels.source }))
+    expect(view.container.querySelector('pre code')?.textContent).toBe(source)
+    expect(screen.queryByRole('img')).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: labels.preview }))
+    await screen.findByRole('img', { name: labels.diagram })
+  })
+
+  it('leaves other languages and consumers without preview labels as code', () => {
+    const view = render(<MarkdownText text={`\`\`\`mermaid\n${source}\n\`\`\``} labels={markdownLabels} />)
+    expect(view.container.querySelector('pre code')?.textContent).toBe(source)
+    view.rerender(<MarkdownText text={'```text\nflowchart LR\n```'} labels={{ ...markdownLabels, mermaid: labels }} />)
+    expect(view.container.querySelector('pre code')?.textContent).toBe('flowchart LR')
+    expect(renderMermaid).not.toHaveBeenCalled()
+  })
+})

+ 83 - 0
packages/client/ui-primitives/tests/mermaid-runtime.client.spec.ts

@@ -0,0 +1,83 @@
+// @vitest-environment jsdom
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const initialize = vi.fn()
+const renderDiagram = vi.fn()
+
+beforeEach(() => {
+  vi.resetModules()
+  vi.resetAllMocks()
+  vi.doMock('mermaid', () => ({ default: { initialize, render: renderDiagram } }))
+})
+afterEach(() => { vi.doUnmock('mermaid') })
+
+describe('Mermaid runtime', () => {
+  it('shares lazy initialization and removes each measurement container after rendering', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    expect(initialize).not.toHaveBeenCalled()
+    const stages: HTMLElement[] = []
+    const ids: string[] = []
+    renderDiagram.mockImplementation(async (id: string, code: string, stage: HTMLElement) => {
+      expect(stage.isConnected).toBe(true)
+      expect(stage.getAttribute('aria-hidden')).toBe('true')
+      stages.push(stage)
+      ids.push(id)
+      return { svg: `<svg>${code}</svg>` }
+    })
+    const results = await Promise.all([
+      renderMermaid('中文', new AbortController().signal),
+      renderMermaid('second', new AbortController().signal),
+    ])
+    expect(initialize).toHaveBeenCalledOnce()
+    expect(initialize).toHaveBeenCalledWith(expect.objectContaining({
+      startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true, htmlLabels: false,
+      secure: [
+        'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges',
+        'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart',
+      ],
+    }))
+    expect(results.map(url => decodeURIComponent(url.split(',')[1]!))).toEqual(['<svg>中文</svg>', '<svg>second</svg>'])
+    expect(new Set(ids).size).toBe(2)
+    expect(stages.every(stage => !stage.isConnected)).toBe(true)
+  })
+
+  it('removes measurement DOM even when Mermaid rejects', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    let stage: HTMLElement | undefined
+    const error = new Error('bad diagram')
+    renderDiagram.mockImplementation(async (_id: string, _code: string, target: HTMLElement) => {
+      stage = target
+      target.innerHTML = '<svg>partial render</svg>'
+      throw error
+    })
+    await expect(renderMermaid('bad', new AbortController().signal)).rejects.toBe(error)
+    expect(stage?.isConnected).toBe(false)
+  })
+
+  it('preserves intrinsic diagram size instead of stretching percentage-width SVG images', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    renderDiagram.mockResolvedValue({ svg: '<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 420 180"/>' })
+    const url = await renderMermaid('flowchart LR', new AbortController().signal)
+    const svg = new DOMParser().parseFromString(decodeURIComponent(url.split(',')[1]!), 'image/svg+xml').documentElement
+    expect(svg.getAttribute('width')).toBe('420')
+    expect(svg.getAttribute('height')).toBe('180')
+  })
+
+  it('does not start cancelled work after loading the runtime', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    const controller = new AbortController()
+    controller.abort()
+    await expect(renderMermaid('unused', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
+    expect(renderDiagram).not.toHaveBeenCalled()
+  })
+
+  it('allows another attempt after runtime initialization fails', async () => {
+    const { renderMermaid } = await import('../src/markdown/mermaid.ts')
+    initialize.mockImplementationOnce(() => { throw new Error('initialization failed') })
+    await expect(renderMermaid('first', new AbortController().signal)).rejects.toThrow('initialization failed')
+    renderDiagram.mockResolvedValue({ svg: '<svg/>' })
+    await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
+    expect(initialize).toHaveBeenCalledTimes(2)
+  })
+})

+ 3 - 0
pnpm-lock.yaml

@@ -3323,6 +3323,9 @@ importers:
       mdast-util-math:
         specifier: ^3.0.0
         version: 3.0.0
+      mermaid:
+        specifier: 11.16.0
+        version: 11.16.0
       micromark-core-commonmark:
         specifier: ^2.0.3
         version: 2.0.3

+ 1 - 0
tsconfig.host.json

@@ -67,6 +67,7 @@
     "apps/web/tests/math-rendering.e2e.ts",
     "apps/web/tests/markdown-cjk-strong.e2e.ts",
     "apps/web/tests/markdown-inline-code-links.e2e.ts",
+    "apps/web/tests/markdown-mermaid.e2e.ts",
     "apps/web/tests/clickable-links-gallery.e2e.ts",
     "apps/web/tests/queue-actions.e2e.ts",
     "apps/web/tests/queue-image.e2e.ts",