From 047ea509873aa2b891deb701924c19d7b35ec107 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:46:52 -0700 Subject: [PATCH 01/27] feat(web): add preview badge to empty hero --- .../lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/plan-active.expected.md | 2 +- .../ui-conversation/src/client/locales.ts | 2 ++ .../src/client/skeleton/EmptyHero.tsx | 3 +- .../src/client/skeleton/HeroShell.module.css | 31 ++++++++++++++++--- .../ui-conversation/tests/skeleton.spec.tsx | 11 ++++++- 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md index 8611ac5c0d..bdb07876a3 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building +- text: Let's start building Preview - button "Choose workspace": - img - text: workspace diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md index a9fb7901d7..8c5cf915dc 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md @@ -20,7 +20,7 @@ - button "Settings": - img - text: Settings -- text: Let's start building +- text: Let's start building Preview - button "Choose workspace": - img - text: workspace diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 7a38aaa0f8..2f05223e5d 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -33,6 +33,7 @@ export const zh = { 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', 'hero.headline': '开始构建吧', + 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', 'details.title': '详情', @@ -144,6 +145,7 @@ export const en = { 'access.confirm.cancel': 'Cancel', 'access.confirm.enable': 'Enable Full access', 'hero.headline': 'Let\'s start building', + 'hero.preview': 'Preview', 'hero.chooseWorkspace': 'Choose workspace', 'session.hierarchy': 'Session hierarchy', 'details.title': 'Details', diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 0c1b31bbb7..4b491e1f7f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -119,7 +119,8 @@ export function HeroShell({ t, children }: HeroShellProps) {
{href === undefined ? children : renderSafeLink(href, children)}
},
img: ({ alt = '', src = '' }) => {
const imageSrc = remoteImageUrl(src)
@@ -90,10 +106,11 @@ function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): C
),
// Fenced blocks route through the shared CodeBlock (shiki for registered
// grammars, identical-geometry plain fallback for unknown/absent
- // languages); inline code keeps the default path (the :not(pre)
- // rule styles it). While the message streams, the fence renders the
- // plain arm — retokenizing a growing fence on every chunk is quadratic
- // main-thread work; the finalize swap highlights it once.
+ // languages); inline code keeps the path (the :not(pre) rule
+ // styles it), with a safe anchor only for complete HTTP(S) values. While
+ // the message streams, the fence renders the plain arm — retokenizing a
+ // growing fence on every chunk is quadratic main-thread work; the
+ // finalize swap highlights it once.
pre: ({ children }) => {
// The markdown pipeline always hands `pre` its single `code` element;
// the undefined arm guards a react-markdown representation change.
@@ -128,7 +145,8 @@ const streamingComponents = buildComponents(true)
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
- * relative links, and unsafe protocols are disabled, while absolute HTTP(S)
+ * relative links, and unsafe protocols are disabled; complete HTTP(S)
+ * inline-code values become safe external links, while absolute HTTP(S)
* images render directly.
*/
export function MarkdownText({ text, streaming = false, codeLabels }: {
diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx
index 6b50994c79..e67302a305 100644
--- a/packages/client/ui-primitives/tests/markdown.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.spec.tsx
@@ -115,6 +115,40 @@ describe('MarkdownText', () => {
expect(container.textContent).toContain('**注意:**内容')
})
+ it('links complete HTTP(S) inline code without promoting commands, unsafe schemes, or fences', () => {
+ const localUrl = 'http://127.0.0.1:3199/?demo=1'
+ const remoteUrl = 'https://example.com/preview?q=one%20two#result'
+ const source = [
+ `\`${localUrl}\``,
+ `\`${remoteUrl}\``,
+ '`curl http://127.0.0.1:3199/?demo=1`',
+ '`javascript:alert(1)`',
+ '`mailto:dev@example.com`',
+ `\` ${localUrl} \``,
+ '```',
+ localUrl,
+ '```',
+ ].join('\n\n')
+ const { container } = render( )
+
+ const links = screen.getAllByRole('link')
+ expect(links.map(link => link.getAttribute('href'))).toEqual([localUrl, remoteUrl])
+ for (const link of links) {
+ expect(link.closest('code')).not.toBeNull()
+ expect(link.getAttribute('target')).toBe('_blank')
+ expect(link.getAttribute('rel')).toBe('noopener noreferrer')
+ }
+ links[0]?.focus()
+ expect(document.activeElement).toBe(links[0])
+ expect(screen.getByText('curl http://127.0.0.1:3199/?demo=1').closest('a')).toBeNull()
+ expect(screen.getByText('javascript:alert(1)').closest('a')).toBeNull()
+ expect(screen.getByText('mailto:dev@example.com').closest('a')).toBeNull()
+ const paddedCode = [...container.querySelectorAll('code')]
+ .find(code => code.textContent === ` ${localUrl} `)
+ expect(paddedCode?.querySelector('a')).toBeNull()
+ expect(container.querySelector('pre code a')).toBeNull()
+ })
+
it('registers the CJK strong extension and rejects a parser without CommonMark attention markers', () => {
const data: { micromarkExtensions?: Extension[] } = {}
const processor = { data: () => data }
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 9f7b167e21..4fcf71b680 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -39,6 +39,7 @@
"apps/web/tests/markdown-images.e2e.ts",
"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/queue-actions.e2e.ts",
"apps/web/tests/skill-invocation-policy.e2e.ts",
"apps/web/tests/permission-policy-context.e2e.ts",
From 8d6824a84be0ec4b1dd515cd3bb3b84d78707d54 Mon Sep 17 00:00:00 2001
From: 07akioni <07akioni2@gmail.com>
Date: Thu, 6 Aug 2026 13:56:43 +0800
Subject: [PATCH 16/27] =?UTF-8?q?feat:=20markdown=20=E5=A2=9E=E9=87=8F?=
=?UTF-8?q?=E8=A7=A3=E6=9E=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...arkdown-incremental-ast-renderer.i18n.yaml | 6 +
...6-web-markdown-incremental-ast-renderer.md | 33 ++
...eb-markdown-incremental-ast-renderer.zh.md | 33 ++
...026-07-23-web-assistant-markdown.i18n.yaml | 4 +-
.../2026-07-23-web-assistant-markdown.md | 8 +-
.../2026-07-23-web-assistant-markdown.zh.md | 8 +-
THIRD_PARTY_NOTICES.md | 8 +-
.../client/ui-primitives/README.i18n.yaml | 4 +-
packages/client/ui-primitives/README.md | 3 +-
packages/client/ui-primitives/README.zh.md | 3 +-
packages/client/ui-primitives/package.json | 7 +-
.../src/markdown/MarkdownText.tsx | 281 +++++-----
.../ui-primitives/src/markdown/incremental.ts | 121 +++++
.../ui-primitives/src/markdown/katex.tsx | 84 +++
...hCompatibility.ts => mathCompatibility.ts} | 18 +-
.../ui-primitives/src/markdown/parse.ts | 41 ++
.../ui-primitives/src/markdown/render.tsx | 512 ++++++++++++++++++
.../blockquote-nested.settled.txt | 12 +
.../blockquote-nested.streaming.txt | 12 +
.../markdown-dom/code-fences.settled.txt | 78 +++
.../markdown-dom/code-fences.streaming.txt | 53 ++
.../markdown-dom/definition-only.settled.txt | 1 +
.../definition-only.streaming.txt | 1 +
.../entities-and-escapes.settled.txt | 3 +
.../entities-and-escapes.streaming.txt | 3 +
.../markdown-dom/footnotes.settled.txt | 29 +
.../markdown-dom/footnotes.streaming.txt | 29 +
...gfm-strikethrough-and-literals.settled.txt | 12 +
...m-strikethrough-and-literals.streaming.txt | 12 +
.../hard-breaks-and-hr.settled.txt | 12 +
.../hard-breaks-and-hr.streaming.txt | 12 +
.../heading-tight-against-list.settled.txt | 15 +
.../heading-tight-against-list.streaming.txt | 15 +
.../headings-and-paragraphs.settled.txt | 33 ++
.../headings-and-paragraphs.streaming.txt | 33 ++
.../fixtures/markdown-dom/images.settled.txt | 14 +
.../markdown-dom/images.streaming.txt | 14 +
.../inline-code-with-newline.settled.txt | 6 +
.../inline-code-with-newline.streaming.txt | 6 +
.../links-and-autolinks.settled.txt | 25 +
.../links-and-autolinks.streaming.txt | 25 +
.../lists-tight-loose-nested.settled.txt | 44 ++
.../lists-tight-loose-nested.streaming.txt | 44 ++
.../markdown-dom/math-edge-cases.settled.txt | 125 +++++
.../math-edge-cases.streaming.txt | 29 +
.../math-inline-and-display.settled.txt | 320 +++++++++++
.../math-inline-and-display.streaming.txt | 9 +
.../markdown-dom/raw-html-dropped.settled.txt | 7 +
.../raw-html-dropped.streaming.txt | 7 +
.../reference-links-and-images.settled.txt | 16 +
.../reference-links-and-images.streaming.txt | 16 +
.../streaming-typical-partial.settled.txt | 8 +
.../streaming-typical-partial.streaming.txt | 8 +
.../table-with-alignment.settled.txt | 35 ++
.../table-with-alignment.streaming.txt | 35 ++
.../markdown-dom/task-lists.settled.txt | 19 +
.../markdown-dom/task-lists.streaming.txt | 19 +
.../tests/markdown-dom-parity.spec.tsx | 232 ++++++++
.../tests/markdown-incremental.spec.tsx | 419 ++++++++++++++
.../tests/markdown-render-units.spec.tsx | 225 ++++++++
.../ui-primitives/tests/markdown.spec.tsx | 11 +-
pnpm-lock.yaml | 406 +-------------
62 files changed, 3090 insertions(+), 573 deletions(-)
create mode 100644 .agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml
create mode 100644 .agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md
create mode 100644 .agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md
create mode 100644 packages/client/ui-primitives/src/markdown/incremental.ts
create mode 100644 packages/client/ui-primitives/src/markdown/katex.tsx
rename packages/client/ui-primitives/src/markdown/{remarkMathCompatibility.ts => mathCompatibility.ts} (95%)
create mode 100644 packages/client/ui-primitives/src/markdown/parse.ts
create mode 100644 packages/client/ui-primitives/src/markdown/render.tsx
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/definition-only.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/entities-and-escapes.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/footnotes.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/gfm-strikethrough-and-literals.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/gfm-strikethrough-and-literals.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/hard-breaks-and-hr.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/heading-tight-against-list.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/heading-tight-against-list.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/headings-and-paragraphs.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/images.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/images.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/inline-code-with-newline.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/inline-code-with-newline.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/links-and-autolinks.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/lists-tight-loose-nested.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/math-inline-and-display.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/math-inline-and-display.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/raw-html-dropped.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/reference-links-and-images.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/streaming-typical-partial.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.settled.txt
create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/task-lists.streaming.txt
create mode 100644 packages/client/ui-primitives/tests/markdown-dom-parity.spec.tsx
create mode 100644 packages/client/ui-primitives/tests/markdown-incremental.spec.tsx
create mode 100644 packages/client/ui-primitives/tests/markdown-render-units.spec.tsx
diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.i18n.yaml
new file mode 100644
index 0000000000..c5634f42ee
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.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/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md
+2026-08-06-web-markdown-incremental-ast-renderer.md: 98d28fa27f8e6e4b5f2ac21831c8f80f8c0c3631
+2026-08-06-web-markdown-incremental-ast-renderer.zh.md: c2c19fe36b8290c1802f6e2b321336e1a68d686f
diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md
new file mode 100644
index 0000000000..98d28fa27f
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md
@@ -0,0 +1,33 @@
+# Agent Note: Incremental streaming markdown through a direct mdast renderer
+
+Status: implemented
+
+English | [中文](2026-08-06-web-markdown-incremental-ast-renderer.zh.md)
+
+## Problem
+
+`MarkdownText` re-parsed the whole accumulated reply on every streaming publish: react-markdown's string-only API builds a fresh unified processor per render and runs micromark → mdast → hast → React over the full text, so per-chunk main-thread work grew linearly with the reply and the stream's cumulative cost grew quadratically. The existing mitigations (frame batching, the isolated streaming tail, the plain fence arm) bounded how often and how widely that work ran, never how much text each run re-parsed. Fixing it needs AST-level input — freezing settled blocks and re-parsing only the source tail — which the string-only wrapper structurally cannot express.
+
+## Decision
+
+`MarkdownText` renders mdast directly and parses incrementally while streaming:
+
+- **Grammars** ([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)): `parseGfm` (streaming arm and `extractMarkdownPlainText`) and `parseGfmWithMath` (settled arm) call `mdast-util-from-markdown` with the same micromark extensions the replaced remark plugins wrapped, so block boundaries are identical everywhere. `mathCompatibility` (ex `remarkMathCompatibility`) now exports its micromark extension directly.
+- **Incremental parsing** ([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)): CommonMark block parsing is line-based, so appended text reshapes only the parse frontier. `IncrementalMarkdownParser` keeps the trailing two blocks unstable (the last block is the frontier; the second-to-last is safety margin), freezes everything before them, and re-parses only the source tail from the last frozen block's `position.end.offset` — the parser's own offsets, no bespoke source scanning. Each source region parses O(1) times per stream instead of once per chunk; a single giant block (an unclosed fence) degrades to the old full-reparse cost and no worse. Non-append input resets the state under a bumped generation.
+- **Rendering** ([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx), [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)): one switch over mdast node types replaces remark-rehype + react-markdown, reproducing the replaced pipeline's DOM byte-for-byte — table alignment as `text-align` styles, tight-list paragraph unwrapping, task-list classes and checkbox spacing, the footnote section (whose in-page anchors the protocol allowlist already reduced to plain text), literal raw HTML, the separator newlines that surface next to literal HTML text, and rehype-katex's three-arm error chain with KaTeX HTML mapped to React through the browser's own `DOMParser` (no wrapper element, so first/last-child margin rules still reach `.katex-display`). Frozen blocks cache their React elements and keep source-offset keys, so crossing the freeze boundary reconciles instead of remounting; `MarkdownText` is memoized.
+
+The DOM is pinned by `tests/fixtures/markdown-dom`: fixtures recorded from the react-markdown implementation before the swap, which the new renderer must reproduce under a whitespace-normalizing serializer. A fixture diff is a user-visible markdown style change to review, never to re-record for a refactor. `tests/markdown-incremental.spec.tsx` holds the equivalence property — at every appended prefix, chunked at 1/3/7/16 bytes, the live component's DOM equals a fresh mount's — plus freeze-boundary DOM-node identity and reset behavior.
+
+This reverses the [assistant-markdown note](../feature/2026-07-23-web-assistant-markdown.md)'s rejected alternative ("maintain a custom React walker"): the incremental requirement is new evidence, the walker's security-sensitive branches (URL allowlist, image policy, inert HTML) were already product-owned functions, and the dependency no longer deleted owned code — it blocked the architecture. That note's untrusted-output policy and renderer selection are unchanged.
+
+## Alternatives considered
+
+**Keep react-markdown and split the source into per-segment `` instances.** Zero renderer ownership, but each frame parses the tail twice (boundary detection + render), settled math still re-parses everything, hast construction and the per-render processor remain, and blocks remount when crossing the freeze boundary because element trees cannot be cached across instances.
+
+**Render cached mdast through `mdast-util-to-hast` + `hast-util-to-jsx-runtime`.** Keeps upstream's node mappings for free, but retains the hast intermediate per frame and two new direct dependencies for a pipeline whose mapping surface is small, closed, and now pinned by fixtures.
+
+**Parse KaTeX output with `hast-util-from-html-isomorphic` (as rehype-katex does).** Pulls a parse5-based HTML parser into the bundle to parse trusted, vocabulary-constrained KaTeX output the browser's `DOMParser` (with the spec's SVG/MathML attribute adjustments) already parses identically.
+
+## Consequences
+
+Streaming per-chunk work now tracks the unstable tail instead of the whole reply, and react-markdown, remark-gfm, remark-math, rehype-katex, unified, and the hast chain left the browser bundle (`mdast-util-math` and `micromark-util-sanitize-uri` became direct dependencies; both were already transitive). The package owns ~25 node mappings, their tests, and the KaTeX DOM conversion — priced against the fixture contract that freezes their output. Two behavioral deviations, both healed by the settled full parse at finalize: a reference-style link or footnote whose definition lands on the other side of a freeze boundary renders literally while streaming, and a footnote reference can flash back to literal text when its definition freezes while the referencing block is still unstable. This module and KaTeX conversion assume a browser DOM (`DOMParser`), which the client-only package already did.
diff --git a/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md
new file mode 100644
index 0000000000..c2c19fe36b
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 经由直接 mdast 渲染器的增量流式 Markdown
+
+Status: implemented
+
+[English](2026-08-06-web-markdown-incremental-ast-renderer.md) | 中文
+
+## Problem
+
+`MarkdownText` 在每次流式发布时都重新解析整个已累积的回复:react-markdown 的纯字符串 API 每次渲染都新建 unified processor,并对全文跑完 micromark → mdast → hast → React,因此每个 chunk 的主线程工作量随回复长度线性增长,整个流的累计成本随之二次增长。既有缓解手段(帧级合并、隔离的流式尾部、围栏 plain 臂)约束的是这份工作跑多频繁、波及多广,从未约束每次重新解析多少文本。修复它需要 AST 级输入——冻结已定型的块、只重新解析源文本尾部——这是纯字符串封装在结构上无法表达的。
+
+## Decision
+
+`MarkdownText` 直接渲染 mdast,并在流式期间增量解析:
+
+- **语法**([parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts)):`parseGfm`(流式臂与 `extractMarkdownPlainText`)和 `parseGfmWithMath`(定稿臂)以被替换的 remark 插件所包装的同一组 micromark 扩展调用 `mdast-util-from-markdown`,因此各处块边界完全一致。`mathCompatibility`(原 `remarkMathCompatibility`)现在直接导出其 micromark 扩展。
+- **增量解析**([incremental.ts](../../../../packages/client/ui-primitives/src/markdown/incremental.ts)):CommonMark 块解析按行推进,追加文本只会重塑解析前沿。`IncrementalMarkdownParser` 保留末尾两个块不稳定(最后一块是前沿;倒数第二块是安全裕量),冻结其前的所有块,只从最后一个冻结块的 `position.end.offset` 起重新解析源尾部——用的是解析器自己的偏移量,没有任何自制源扫描。每个源区间在整个流中解析 O(1) 次而非每 chunk 一次;单个巨型块(未闭合围栏)退化为旧的全量重解析成本,不会更差。非追加输入在递增的 generation 下重置状态。
+- **渲染**([render.tsx](../../../../packages/client/ui-primitives/src/markdown/render.tsx)、[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)):一个对 mdast 节点类型的 switch 取代 remark-rehype + react-markdown,逐字节复刻被替换管线的 DOM——表格对齐渲染为 `text-align` 样式、紧凑列表段落解包、任务列表类名与复选框空格、脚注区(其页内锚点本就被协议白名单降为纯文本)、字面 raw HTML、会与字面 HTML 文本相邻显形的分隔换行,以及 rehype-katex 的三臂容错链,KaTeX HTML 经浏览器自带的 `DOMParser` 映射为 React(无包裹元素,首/末子元素的 margin 规则仍能作用于 `.katex-display`)。冻结块缓存其 React 元素并保持源偏移 key,跨过冻结边界时走 reconcile 而非重挂载;`MarkdownText` 已 memo 化。
+
+DOM 由 `tests/fixtures/markdown-dom` 钉死:fixture 录制自替换前的 react-markdown 实现,新渲染器必须在空白规整序列化器下复现。fixture 差异即用户可见的 markdown 样式变更,必须按此评审,绝不能为重构而重录。`tests/markdown-incremental.spec.tsx` 承载等价性性质——以 1/3/7/16 字节分块,在每个追加前缀处,常驻组件的 DOM 都等于全新挂载——外加冻结边界的 DOM 节点同一性与重置行为。
+
+这推翻了[助手 Markdown Note](../feature/2026-07-23-web-assistant-markdown.md) 中被否决的备选("维护一个自定义 React walker"):增量需求是当时不存在的新证据,walker 的安全敏感分支(URL 白名单、图片策略、惰性 HTML)本就是产品自有函数,而该依赖不再删减自有代码——它阻塞了架构。该 Note 的不可信输出策略与渲染器选型不变。
+
+## Alternatives considered
+
+**保留 react-markdown,把源文本切成逐段 `` 实例。** 渲染器零自有成本,但每帧对尾部解析两次(边界检测 + 渲染),定稿数学仍要全量重解析,hast 构建与逐渲染 processor 依旧存在,且块跨过冻结边界时会重挂载——元素树无法跨实例缓存。
+
+**用 `mdast-util-to-hast` + `hast-util-to-jsx-runtime` 渲染缓存的 mdast。** 白拿上游节点映射,但每帧保留 hast 中间层,并为一个映射面小、封闭、且已被 fixture 钉死的管线引入两个新直接依赖。
+
+**用 `hast-util-from-html-isomorphic` 解析 KaTeX 输出(rehype-katex 的做法)。** 为解析可信、词汇受限的 KaTeX 输出把基于 parse5 的 HTML 解析器拉进 bundle,而浏览器自带的 `DOMParser`(带规范的 SVG/MathML 属性调整)解析结果完全相同。
+
+## Consequences
+
+流式的每 chunk 工作量现在跟随不稳定尾部而非整个回复,react-markdown、remark-gfm、remark-math、rehype-katex、unified 及 hast 链退出浏览器 bundle(`mdast-util-math` 与 `micromark-util-sanitize-uri` 成为直接依赖;两者原本就是传递依赖)。包自有约 25 个节点映射、其测试以及 KaTeX DOM 转换——代价由冻结其输出的 fixture 契约对冲。两个行为偏差,均在定稿的全量解析处自愈:定义落在冻结边界另一侧的引用式链接或脚注在流式期间渲染为字面文本;当脚注定义先冻结而引用块仍不稳定时,脚注引用可能闪回字面文本。本模块与 KaTeX 转换假定浏览器 DOM(`DOMParser`),这个 client-only 包本就如此。
diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml
index 2a56348913..833ccb9886 100644
--- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.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 .agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
-2026-07-23-web-assistant-markdown.md: 8a8778351911bcb3448366c718aa124c4a89de58
-2026-07-23-web-assistant-markdown.zh.md: 2ac024e24ff95b4eb296112562c93f343b832187
+2026-07-23-web-assistant-markdown.md: 61fdac4d3276044d28a6b12759e81de9ff95b63a
+2026-07-23-web-assistant-markdown.zh.md: 9f618ee83e7597884895548cd16c4b2e7f1bd57c
diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
index 8a87783519..61fdac4d32 100644
--- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
+++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.md
@@ -12,9 +12,9 @@ The Web conversation preserves assistant Markdown source through session events,
`@deepseek-ai/dsh-client-ui-primitives` exports `MarkdownText` as the untrusted assistant-text renderer, and `ui-conversation` selects it only for assistant `text` blocks. Finalized history, the streaming tail, and interrupted partials already share `AssistantMarkdown`, so they receive the same renderer without changing events or snapshots. User and steering messages keep `MessageText` and remain literal.
-`MarkdownText` uses `react-markdown` with `remark-gfm` to build React elements from an AST. It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk.
+`MarkdownText` parses with `mdast-util-from-markdown` plus the GFM micromark extensions and renders the mdast tree through the package's own renderer, parsing incrementally while a turn streams (the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that mechanism and its DOM-parity contract). It covers CommonMark blocks plus GFM tables, task lists, strikethrough, and autolinks without raw-HTML parsing. Fenced code routes through the shared `CodeBlock`, which highlights registered grammars with the client's shiki singleton (`--shiki-*` tokens) and falls back to plain monospace otherwise. While a turn streams, fences stay on the plain arm so growing fences are not retokenized every chunk.
-Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through `remark-math` and `rehype-katex`; `remarkMathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes.
+Visual spacing, tables, links, blockquotes, inline code, and code-block chrome follow deepsuite `@deepseek/md` (`markdown.css` / `code-block.css`) and the same `--dsw-alias-markdown-*`, `--dsw-font-markdown-*`, `--dsw-alias-border-l*`, and `--dsw-alias-label-*` tokens. Links use `--dsw-alias-state-business-primary` (deepsuite's sheet uses `--dsw-alias-brand-text`, which is blue only under newDesign; design-platform keeps brand-text near-black and is not retuned here). `CodeBlock` ships a language banner and a copy control (`复制` / `复制成功`). Finalized text renders KaTeX through the settled grammar's math extensions; `mathCompatibility` maps `\(...\)`, `\[...\]`, and block-level same-line `$$...$$` to the same standard math AST nodes. This is a narrow parser compatibility layer, not a regex rewrite or malformed-model-output repair. Streaming stays literal until finalization so incomplete formulae do not flash errors. Citation pills, heading anchors, the thinking-small markdown variant, and custom □/☑ task markers remain out of scope; GFM task lists keep native checkboxes.
The dependency is explicit in `ui-primitives`; because that pure library is seeded by the Web shell, the parser and highlighter are part of the initial browser bundle.
@@ -26,7 +26,7 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
## Alternatives considered
-**Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path.
+**Promote the existing mdast and micromark development dependencies and maintain a custom React walker.** This avoids a new parser family but makes the product own every node mapping, GFM extension, and security-sensitive rendering branch. The dedicated React renderer keeps that traversal upstream while preserving an AST-to-React path. *Later reversed on new evidence — incremental streaming parsing needs AST-level input the string-only wrapper cannot provide; the [incremental AST renderer note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) owns that decision.*
**Replace `MessageText` with Markdown rendering.** This formats user prompts and steering as a side effect. Those authored surfaces remain literal until the product chooses that behavior explicitly.
@@ -38,4 +38,4 @@ Fenced code and GFM tables own horizontal overflow so long content cannot widen
## Consequences
-Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses the current text after each accumulated update; incomplete Markdown can temporarily change structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred.
+Assistant replies render semantic Markdown consistently during streaming and replay, while tool cards, reasoning rows, interactions, user bubbles, and the host protocol remain unchanged. Streaming reparses only the unstable tail after each accumulated update; incomplete Markdown can temporarily change the tail's structure, but the isolated tail bounds React invalidation and the final event does not switch renderers. Code fences share one chrome and copy path with tool and details surfaces. The initial Web shell includes the Markdown parser, GFM runtime, KaTeX, and shiki allowlist; citation, anchor, and thinking-small surfaces remain deferred.
diff --git a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md
index 2ac024e24f..9f618ee83e 100644
--- a/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md
+++ b/.agents/notes/implemented/feature/2026-07-23-web-assistant-markdown.zh.md
@@ -12,9 +12,9 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd
`@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。
-`MarkdownText` 使用 `react-markdown` 与 `remark-gfm`,从 AST 构建 React 元素。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。
+`MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有该机制及其 DOM 一致性契约)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。
-视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过 `remark-math` 和 `rehype-katex` 渲染 KaTeX;`remarkMathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。
+视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 deepsuite `@deepseek/md`(`markdown.css` / `code-block.css`),并使用同一套 `--dsw-alias-markdown-*`、`--dsw-font-markdown-*`、`--dsw-alias-border-l*` 与 `--dsw-alias-label-*` token。链接使用 `--dsw-alias-state-business-primary`(deepsuite 的样式表使用 `--dsw-alias-brand-text`,仅在 newDesign 下为蓝色;design-platform 将 brand-text 保持为近黑色,此处不做重新调色)。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过定稿语法的数学扩展渲染 KaTeX;`mathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。
该依赖在 `ui-primitives` 中显式声明;由于这一纯库由 Web shell 预置,解析器与高亮器会成为初始浏览器 bundle 的一部分。
@@ -26,7 +26,7 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
## 考虑过的替代方案
-**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。
+**将现有的 mdast 与 micromark 开发依赖提升为正式依赖,并维护自定义 React walker。**此方案避免引入新的解析器体系,但产品需要自行负责每种节点映射、GFM 扩展和安全敏感的渲染分支。专用 React 渲染器将这套遍历交由上游维护,同时保留 AST 到 React 的处理路径。*后因新证据被推翻——增量流式解析需要纯字符串封装无法提供的 AST 级输入;该决策由[增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) 拥有。*
**将 `MessageText` 替换为 Markdown 渲染。**这会产生格式化用户提示词与 steering 的副作用。在产品明确选择此行为之前,这两类输入内容仍按字面渲染。
@@ -38,4 +38,4 @@ assistant 生成的链接目标地址仅限绝对 HTTP、HTTPS 与 mailto URL。
## 后果
-assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出都会重新解析当前文本;未完成的 Markdown 可能暂时改变结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchor 和 thinking-small 表层仍暂缓。
+assistant 回复在流式输出与回放期间都会一致地渲染为语义化 Markdown,而工具卡片、推理行、交互、用户气泡和宿主协议保持不变。每次累积更新后,流式输出只重新解析不稳定的尾部;未完成的 Markdown 可能暂时改变尾部结构,但独立的尾部会限定 React 失效范围,最终事件也不会切换渲染器。代码围栏与工具及详情表层共用同一外框与复制路径。初始 Web shell 包含 Markdown 解析器、GFM 运行时、KaTeX 与 shiki 允许列表;citation、anchor 和 thinking-small 表层仍暂缓。
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 7d2e257ea9..e9da980aab 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -47,6 +47,7 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
+| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
| [`anser`](https://github.com/IonicaBizau/anser) | MIT |
| [`chokidar`](https://github.com/paulmillr/chokidar) | MIT |
@@ -63,10 +64,12 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`koffi`](https://github.com/Koromix/koffi) | MIT |
| [`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 |
| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT |
| [`micromark-factory-space`](https://github.com/micromark/micromark/tree/main/packages/micromark-factory-space) | MIT |
| [`micromark-util-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-character) | MIT |
+| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
@@ -75,10 +78,6 @@ External packages that a workspace package resolves at runtime. `scripts/install
| [`pnpm`](https://github.com/pnpm/pnpm) | MIT |
| [`react`](https://github.com/facebook/react) | MIT |
| [`react-dom`](https://github.com/facebook/react) | MIT |
-| [`react-markdown`](https://github.com/remarkjs/react-markdown) | MIT |
-| [`rehype-katex`](https://github.com/remarkjs/remark-math/tree/main/packages/rehype-katex) | MIT |
-| [`remark-gfm`](https://github.com/remarkjs/remark-gfm) | MIT |
-| [`remark-math`](https://github.com/remarkjs/remark-math/tree/main/packages/remark-math) | MIT |
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
| [`supports-color`](https://github.com/chalk/supports-color) | MIT |
| [`tsx`](https://github.com/privatenumber/tsx) | MIT |
@@ -109,7 +108,6 @@ External packages **directly declared** only by repository tooling, test infrast
| [`@types/babel__code-frame`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
-| [`@types/mdast`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
| [`@types/react`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT |
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 68f2f87258..f0030907b2 100644
--- a/packages/client/ui-primitives/README.i18n.yaml
+++ b/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: 03e7e3649fd0913fb48579aa87634153f67f5baf
-README.zh.md: 090ecc34e8d514e38853de8ed52e82d3bf019b43
+README.md: 04fb50eedb59f1028145b4985a0cb7560d388492
+README.zh.md: 753905915f6ac501ade9e8473ad747c1356e1556
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index 03e7e3649f..04fb50eedb 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
## Markdown rendering
-`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
+`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
## Terminal output
@@ -42,6 +42,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
+- **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. Inline links and references resolved within one parse are unaffected.
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
- **No `Active` StateDot variant** — the supported states are done, warning, ongoing, and error.
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 090ecc34e8..753905915f 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -10,7 +10,7 @@
## Markdown 渲染
-`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
+`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
## 终端输出
@@ -42,6 +42,7 @@
## 已知限制与暂缓事项
+- **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。内联链接以及在同一次解析内完成解析的引用不受影响。
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
- **StateDot 没有 `Active` 变体**:支持的状态为 done、warning、ongoing 和 error。
diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json
index 683ca7a93b..c871d46ab4 100644
--- a/packages/client/ui-primitives/package.json
+++ b/packages/client/ui-primitives/package.json
@@ -21,23 +21,22 @@
"license": "BSD-3-Clause",
"dependencies": {
"@shikijs/langs": "^4.3.1",
+ "@types/mdast": "^4.0.4",
"anser": "^2.3.5",
"clsx": "^2.0.0",
"katex": "^0.16.47",
"mdast-util-from-markdown": "^2.0.3",
"mdast-util-gfm": "^3.1.0",
+ "mdast-util-math": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"micromark-extension-math": "^3.1.0",
"micromark-factory-space": "^2.0.1",
"micromark-util-character": "^2.1.1",
+ "micromark-util-sanitize-uri": "^2.0.1",
"micromark-util-symbol": "^2.0.1",
"micromark-util-types": "^2.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-markdown": "^10.1.0",
- "rehype-katex": "^7.0.1",
- "remark-gfm": "^4.0.1",
- "remark-math": "^6.0.0",
"shiki": "^4.3.1"
},
"devDependencies": {
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
index 6450de55e2..bb4b62514c 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
@@ -1,155 +1,164 @@
-import { isValidElement, useMemo } from 'react'
-import ReactMarkdown from 'react-markdown'
-import type { Components, UrlTransform } from 'react-markdown'
-import rehypeKatex from 'rehype-katex'
-import remarkGfm from 'remark-gfm'
-import remarkMath from 'remark-math'
-import { CodeBlock } from './CodeBlock.tsx'
-import { remarkMathCompatibility } from './remarkMathCompatibility.ts'
+/**
+ * Untrusted assistant-Markdown renderer over the direct mdast pipeline:
+ * `parse.ts` grammars, the incremental streaming parser, and `render.tsx`.
+ * While a message streams, all but the trailing two blocks freeze as cached
+ * React elements and only the source tail behind them re-parses per chunk,
+ * so per-chunk work tracks the tail size instead of the whole reply. Frozen
+ * blocks keep their source-offset keys when they cross the freeze boundary,
+ * so React reconciles instead of remounting. Known deviation while
+ * streaming: a reference-style link or footnote whose definition sits on the
+ * other side of the freeze boundary renders literally until the settled
+ * full parse self-heals it.
+ */
+
+import { memo, useMemo, useRef } from 'react'
+import type { ReactNode } from 'react'
+import { IncrementalMarkdownParser } from './incremental.ts'
+import { parseGfm, parseGfmWithMath } from './parse.ts'
+import {
+ collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
+ wrapBlockChildren,
+} from './render.tsx'
+import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
-const streamingRemarkPlugins = [remarkGfm]
-const settledRemarkPlugins = [
- remarkGfm,
- remarkMathCompatibility,
- remarkMath,
-]
-const settledRehypePlugins = [rehypeKatex]
+export type { MarkdownCodeLabels } from './render.tsx'
-function sanitizeUrl(url: string): string {
- try {
- switch (new URL(url).protocol) {
- case 'http:':
- case 'https:':
- case 'mailto:':
- return url
- default:
- return ''
+/** One settled full render: parse with math, resolve references, append the footnote section. */
+function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] {
+ const root = parseGfmWithMath(text)
+ const targets = createReferenceTargets()
+ collectReferenceTargets(root.children, targets)
+ const context: MarkdownRenderContext = {
+ streaming: false,
+ codeLabels,
+ targets,
+ footnoteOrder: [],
+ footnoteCounts: new Map(),
+ }
+ const blocks = wrapBlockChildren(
+ renderBlocks(root.children.map((node, index) => ({ node, key: index })), context),
+ false,
+ )
+ const section = renderFootnoteSection(context)
+ return section === null ? blocks : [...blocks, '\n', section]
+}
+
+/**
+ * Streaming render state for one growing message: the incremental parser,
+ * the frozen blocks' cached elements, and the reference/footnote state their
+ * rendering consumed (footnote numbering assigned to frozen references is
+ * final, so the tail continues from a copy of it each frame).
+ */
+class StreamingRenderer {
+ private readonly parser = new IncrementalMarkdownParser(parseGfm)
+ private generation = -1
+ private frozenCount = 0
+ private frozenElements: ReactNode[] = []
+ private frozenTargets: ReferenceTargets = createReferenceTargets()
+ private frozenFootnoteOrder: string[] = []
+ private frozenFootnoteCounts = new Map()
+ private lastText: string | null = null
+ private lastRendered: ReactNode[] = []
+
+ /** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */
+ constructor(private readonly codeLabels: MarkdownCodeLabels | undefined) {}
+
+ /**
+ * Render the current accumulated text. Idempotent per text value, so React
+ * may re-execute the calling render freely.
+ * @param text - The full accumulated markdown source.
+ * @returns Frozen elements, re-rendered tail, and the footnote section.
+ */
+ render(text: string): ReactNode[] {
+ if (text === this.lastText) return this.lastRendered
+ const { frozen, tail, generation } = this.parser.update(text)
+ if (generation !== this.generation) {
+ this.generation = generation
+ this.frozenCount = 0
+ this.frozenElements = []
+ this.frozenTargets = createReferenceTargets()
+ this.frozenFootnoteOrder = []
+ this.frozenFootnoteCounts = new Map()
}
- } catch {
- return ''
+ const newlyFrozen = frozen.slice(this.frozenCount)
+ collectReferenceTargets(newlyFrozen.map(block => block.node), this.frozenTargets)
+ // Targets visible this frame: everything frozen so far plus the current
+ // tail parse — a newly frozen block's references resolved against the
+ // same parse tree its definitions came from.
+ const frameTargets: ReferenceTargets = {
+ definitions: new Map(this.frozenTargets.definitions),
+ footnotes: new Map(this.frozenTargets.footnotes),
+ }
+ collectReferenceTargets(tail.map(block => block.node), frameTargets)
+ if (newlyFrozen.length > 0) {
+ const frozenContext: MarkdownRenderContext = {
+ streaming: true,
+ codeLabels: this.codeLabels,
+ targets: frameTargets,
+ footnoteOrder: this.frozenFootnoteOrder,
+ footnoteCounts: this.frozenFootnoteCounts,
+ }
+ // Separator newlines are cached alongside the elements so the
+ // assembled children match the settled pipeline's block wrapping.
+ const batch = [...this.frozenElements]
+ for (const element of renderBlocks(newlyFrozen, frozenContext)) {
+ if (batch.length > 0) batch.push('\n')
+ batch.push(element)
+ }
+ this.frozenElements = batch
+ this.frozenCount = frozen.length
+ }
+ const tailContext: MarkdownRenderContext = {
+ streaming: true,
+ codeLabels: this.codeLabels,
+ targets: frameTargets,
+ footnoteOrder: [...this.frozenFootnoteOrder],
+ footnoteCounts: new Map(this.frozenFootnoteCounts),
+ }
+ const children = [...this.frozenElements]
+ for (const element of renderBlocks(tail, tailContext)) {
+ if (children.length > 0) children.push('\n')
+ children.push(element)
+ }
+ const section = renderFootnoteSection(tailContext)
+ if (section !== null) children.push('\n', section)
+ this.lastText = text
+ this.lastRendered = children
+ return this.lastRendered
}
}
-const safeUrl: UrlTransform = url => sanitizeUrl(url)
-
-/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
-export interface MarkdownCodeLabels {
- /** Copy-button idle label. */
- copyLabel?: string | undefined
- /** Copy-button label during the post-copy confirmation window. */
- copiedLabel?: string | undefined
-}
-
-function remoteImageUrl(url: string): string | undefined {
- try {
- const protocol = new URL(url).protocol
- return protocol === 'http:' || protocol === 'https:' ? url : undefined
- } catch {
- return undefined
- }
-}
-
-/** Build the component table; while `streaming`, fences render the plain arm (see CodeBlock). */
-function buildComponents(streaming: boolean, codeLabels?: MarkdownCodeLabels): Components {
- return {
- a: ({ href = '', children }) => {
- const safeHref = sanitizeUrl(href)
- if (safeHref === '') return <>{children}>
- const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
- return (
-
- {children}
-
- )
- },
- img: ({ alt = '', src = '' }) => {
- const imageSrc = remoteImageUrl(src)
- if (imageSrc === undefined) return {alt}
- return (
-
- )
- },
- table: ({ children }) => (
-
- {children}
-
- ),
- // Fenced blocks route through the shared CodeBlock (shiki for registered
- // grammars, identical-geometry plain fallback for unknown/absent
- // languages); inline code keeps the default path (the :not(pre)
- // rule styles it). While the message streams, the fence renders the
- // plain arm — retokenizing a growing fence on every chunk is quadratic
- // main-thread work; the finalize swap highlights it once.
- pre: ({ children }) => {
- // The markdown pipeline always hands `pre` its single `code` element;
- // the undefined arm guards a react-markdown representation change.
- /* v8 ignore next 2 */
- const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined
- const raw = child?.props.children
- // A fence whose content isn't one plain string (e.g. an empty fence)
- // keeps the stock rather than guessing.
- if (typeof raw !== 'string') return {children}
- const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1]
- return (
-
- )
- },
- }
-}
-
-const staticComponents = buildComponents(false)
-const streamingComponents = buildComponents(true)
-
/**
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
- * `streaming` renders fences and TeX plain (highlighting and KaTeX land on the finalize swap);
- * `codeLabels` forwards localized copy-button labels to fence CodeBlocks —
- * pass a reference-stable object (memoized per locale revision), because the
- * component table memoizes on its identity and a fresh literal per render
- * would rebuild it every streaming chunk.
+ * `streaming` renders fences and TeX plain (highlighting and KaTeX land on
+ * the finalize swap) and parses incrementally across chunks; `codeLabels`
+ * forwards localized copy-button labels to fence CodeBlocks — pass a
+ * reference-stable object (memoized per locale revision), because a new
+ * identity discards the streaming render cache mid-message.
* @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
* images render directly.
*/
-export function MarkdownText({ text, streaming = false, codeLabels }: {
+export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: {
text: string
streaming?: boolean
codeLabels?: MarkdownCodeLabels | undefined
}) {
- // The label-free tables stay module-level singletons so the common case
- // keeps referential stability across renders without a hook.
- const components = useMemo(() => {
- if (codeLabels === undefined) return streaming ? streamingComponents : staticComponents
- return buildComponents(streaming, codeLabels)
- }, [streaming, codeLabels])
- return (
-
-
- {text}
-
-
- )
-}
+ const streamRef = useRef(null)
+ const streamLabelsRef = useRef(codeLabels)
+ const children = useMemo(() => {
+ if (!streaming) {
+ streamRef.current = null
+ return renderSettled(text, codeLabels)
+ }
+ if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
+ streamRef.current = new StreamingRenderer(codeLabels)
+ streamLabelsRef.current = codeLabels
+ }
+ return streamRef.current.render(text)
+ }, [text, streaming, codeLabels])
+ return {children}
+})
diff --git a/packages/client/ui-primitives/src/markdown/incremental.ts b/packages/client/ui-primitives/src/markdown/incremental.ts
new file mode 100644
index 0000000000..af56232e3a
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/incremental.ts
@@ -0,0 +1,121 @@
+/**
+ * Incremental block-level markdown parsing for an append-only text stream.
+ *
+ * Re-parsing the whole accumulated document on every streaming chunk is
+ * quadratic in the final reply length. CommonMark block parsing is line-based
+ * and appended text can only reshape the parse frontier — the last top-level
+ * block (a paragraph becoming a setext heading or a table, a list continuing
+ * after a blank line, an unclosed fence swallowing lines) — so earlier blocks
+ * are final. This parser therefore freezes all but the trailing
+ * {@link UNSTABLE_TAIL_BLOCKS} blocks and re-parses only the source tail
+ * behind them: each source region is parsed O(1) times over the stream
+ * instead of once per chunk.
+ *
+ * The freeze boundary comes from the parser's own `position` offsets, never
+ * from custom source scanning. The cut sits at the *end offset* of the last
+ * frozen block (not the next block's start): a following block's start offset
+ * excludes up to three spaces of insignificant leading indentation, which is
+ * harmless to drop, but cutting at the previous end also keeps the
+ * inter-block blank lines in the tail so the sliced source stays verbatim.
+ *
+ * Known deviation, shared with any prefix-freeze scheme: micromark resolves
+ * reference-style links and footnotes document-wide at parse time, so a
+ * reference whose definition lands on the other side of the freeze boundary
+ * renders literally until the settled full parse self-heals it.
+ */
+
+import type { Root, RootContent } from 'mdast'
+
+/**
+ * Trailing blocks kept unstable. Appended text reshapes at most the last
+ * block; the second-to-last is retained as safety margin so a freeze decision
+ * never has to reason about the parse frontier.
+ */
+const UNSTABLE_TAIL_BLOCKS = 2
+
+/** A top-level mdast block plus a render key that is stable across chunks. */
+export interface PositionedBlock {
+ /** The parsed block. Positions inside it are relative to its parse slice. */
+ readonly node: RootContent
+ /**
+ * The block's start offset in the full source text. Stable from the frame
+ * a block first appears through freezing, so React reconciles rather than
+ * remounts when a block crosses the freeze boundary.
+ */
+ readonly key: number
+}
+
+/** One {@link IncrementalMarkdownParser.update} result. */
+export interface IncrementalBlocks {
+ /** Blocks that can no longer change; grows monotonically per generation. */
+ readonly frozen: readonly PositionedBlock[]
+ /** The re-parsed unstable tail (at most {@link UNSTABLE_TAIL_BLOCKS} blocks plus growth). */
+ readonly tail: readonly PositionedBlock[]
+ /** Bumped whenever non-append input discards the frozen prefix; callers drop caches keyed on it. */
+ readonly generation: number
+}
+
+/**
+ * A block's render key: its absolute source start offset. A position-less
+ * node (a grammar is free to omit positions) falls back to a negative
+ * list-index key, which keeps sibling keys unique without inventing offsets.
+ */
+function blockKey(node: RootContent, base: number, index: number): number {
+ const offset = node.position?.start.offset
+ return offset === undefined ? -(index + 1) : base + offset
+}
+
+/**
+ * Append-only incremental parser over a caller-supplied grammar. One instance
+ * accumulates one streaming document; non-append input resets it.
+ */
+export class IncrementalMarkdownParser {
+ private prevText = ''
+ private tailStart = 0
+ private frozen: PositionedBlock[] = []
+ private generation = 0
+ private cached: IncrementalBlocks | null = null
+
+ /** @param parse - Grammar shared with whatever renders the blocks, so boundaries agree. */
+ constructor(private readonly parse: (text: string) => Root) {}
+
+ /**
+ * Fold the current accumulated text and return the frozen/tail split.
+ * Idempotent for identical input (the previous result is returned as-is),
+ * so callers may invoke it from render paths that re-execute.
+ * @param text - The full accumulated markdown source.
+ * @returns Frozen and tail blocks with stream-stable render keys.
+ */
+ update(text: string): IncrementalBlocks {
+ if (this.cached !== null && text === this.prevText) return this.cached
+ if (!text.startsWith(this.prevText)) {
+ this.prevText = ''
+ this.tailStart = 0
+ this.frozen = []
+ this.generation += 1
+ }
+ this.prevText = text
+ const base = this.tailStart
+ const blocks = this.parse(text.slice(base)).children
+ let firstUnstable = Math.max(0, blocks.length - UNSTABLE_TAIL_BLOCKS)
+ if (firstUnstable > 0) {
+ const cutEnd = blocks[firstUnstable - 1]?.position?.end.offset
+ if (cutEnd === undefined) {
+ // A grammar that omits positions leaves nothing to cut at; keep the
+ // whole parse in the tail rather than guessing a boundary.
+ firstUnstable = 0
+ } else {
+ for (const node of blocks.slice(0, firstUnstable)) {
+ this.frozen.push({ node, key: blockKey(node, base, this.frozen.length) })
+ }
+ this.tailStart = base + cutEnd
+ }
+ }
+ const tail = blocks.slice(firstUnstable).map((node, index) => ({
+ node,
+ key: blockKey(node, base, index),
+ }))
+ this.cached = { frozen: [...this.frozen], tail, generation: this.generation }
+ return this.cached
+ }
+}
diff --git a/packages/client/ui-primitives/src/markdown/katex.tsx b/packages/client/ui-primitives/src/markdown/katex.tsx
new file mode 100644
index 0000000000..bae1aa5104
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/katex.tsx
@@ -0,0 +1,84 @@
+/**
+ * TeX-to-React via KaTeX, replicating the rehype-katex pipeline this renderer
+ * replaced: the same three-arm error chain (strict render, `strict: 'ignore'`
+ * retry, error span) and a DOM-identical element tree, so settled math keeps
+ * its exact markup. KaTeX emits an HTML string; the browser's own HTML parser
+ * (`DOMParser`, applying the spec's SVG/MathML foreign-content attribute
+ * adjustments KaTeX output relies on) turns it into a tree this module maps
+ * onto React elements — KaTeX output is a static span/MathML/SVG vocabulary
+ * with no raw user HTML, the same trust shiki's tree gets in CodeBlock.
+ */
+
+import { createElement } from 'react'
+import type { CSSProperties, ReactNode } from 'react'
+import katex from 'katex'
+
+/**
+ * Convert one inline `style` attribute string into React's style object.
+ * KaTeX emits only plain kebab-case declarations (no custom properties and no
+ * nameless declarations), so camel-casing the property is the whole mapping.
+ */
+function styleObject(css: string): CSSProperties {
+ const style: Record = {}
+ for (const declaration of css.split(';')) {
+ const colon = declaration.indexOf(':')
+ if (colon === -1) continue
+ const name = declaration.slice(0, colon).trim()
+ const key = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
+ style[key] = declaration.slice(colon + 1).trim()
+ }
+ return style
+}
+
+/** Map one parsed DOM node onto a React element (text nodes pass through). */
+function domToReact(node: ChildNode, key: number): ReactNode {
+ if (node.nodeType === Node.TEXT_NODE) return node.textContent
+ /* v8 ignore next 2 -- KaTeX output holds only elements and text; other
+ node kinds cannot appear in its serialized vocabulary. */
+ if (node.nodeType !== Node.ELEMENT_NODE) return null
+ const element = node as Element
+ const props: Record = { key }
+ for (const attribute of element.attributes) {
+ if (attribute.name === 'class') props['className'] = attribute.value
+ else if (attribute.name === 'style') props['style'] = styleObject(attribute.value)
+ else props[attribute.name] = attribute.value
+ }
+ const children = [...element.childNodes].map(domToReact)
+ return children.length === 0
+ ? createElement(element.localName, props)
+ : createElement(element.localName, props, ...children)
+}
+
+/**
+ * Render TeX source to React elements through KaTeX.
+ * @param value - The TeX source (math node value; fenced `math` blocks append
+ * their trailing newline to match the replaced pipeline's text extraction).
+ * @param displayMode - Display (block) versus inline rendering.
+ * @returns KaTeX's element tree, or the error span when the source does not
+ * parse (colored with KaTeX's stock `errorColor`, matching rehype-katex).
+ */
+export function renderTexToReact(value: string, displayMode: boolean): ReactNode {
+ let html: string
+ try {
+ html = katex.renderToString(value, { displayMode, throwOnError: true })
+ } catch (error) {
+ try {
+ html = katex.renderToString(value, { displayMode, strict: 'ignore', throwOnError: false })
+ } catch {
+ // KaTeX renders ParseErrors itself under throwOnError: false; only its
+ // internal errors reach here, so mirror rehype-katex's manual span.
+ /* v8 ignore next 8 */
+ return (
+
+ {value}
+
+ )
+ }
+ }
+ const parsed = new DOMParser().parseFromString(html, 'text/html')
+ return [...parsed.body.childNodes].map(domToReact)
+}
diff --git a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts
similarity index 95%
rename from packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts
rename to packages/client/ui-primitives/src/markdown/mathCompatibility.ts
index dcd8c32362..3edd9d1e63 100644
--- a/packages/client/ui-primitives/src/markdown/remarkMathCompatibility.ts
+++ b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts
@@ -8,10 +8,6 @@ import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark
// oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback.
-interface RemarkProcessor {
- data(): { micromarkExtensions?: Extension[] }
-}
-
const previousBackslash: Previous = function (code) {
if (code !== codes.backslash) return true
const tail = this.events.at(-1)
@@ -342,12 +338,12 @@ const backslashMath: Extension = {
}
/**
- * Add TeX backslash delimiters and same-line display-dollar blocks for remark-math.
- * The same processor must register remark-math to compile the emitted math tokens.
- * @returns Nothing.
+ * TeX backslash delimiters and same-line display-dollar blocks as a micromark
+ * syntax extension reusing `micromark-extension-math`'s token vocabulary; the
+ * caller must also register `math()` on the same parse so the emitted tokens
+ * compile to standard math nodes.
+ * @returns The micromark syntax extension.
*/
-export function remarkMathCompatibility(this: RemarkProcessor): undefined {
- const data = this.data()
- const extensions = data.micromarkExtensions ?? (data.micromarkExtensions = [])
- extensions.push(backslashMath)
+export function mathCompatibility(): Extension {
+ return backslashMath
}
diff --git a/packages/client/ui-primitives/src/markdown/parse.ts b/packages/client/ui-primitives/src/markdown/parse.ts
new file mode 100644
index 0000000000..98482a809d
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/parse.ts
@@ -0,0 +1,41 @@
+/**
+ * The markdown renderer's two mdast grammars, one per rendering arm. Both are
+ * built from the same micromark extensions, so block boundaries and inline
+ * semantics are identical wherever a document (or a document tail) is parsed:
+ * the incremental streaming path, the settled path, and the plain-text
+ * projection all agree on where blocks start and end.
+ */
+
+import type { Root } from 'mdast'
+import { fromMarkdown } from 'mdast-util-from-markdown'
+import { gfmFromMarkdown } from 'mdast-util-gfm'
+import { mathFromMarkdown } from 'mdast-util-math'
+import { gfm } from 'micromark-extension-gfm'
+import { math } from 'micromark-extension-math'
+import { mathCompatibility } from './mathCompatibility.ts'
+
+/**
+ * Parse GFM markdown (the streaming arm's grammar: no math, so incomplete
+ * TeX never flashes KaTeX errors mid-stream).
+ * @param text - Markdown source.
+ * @returns The mdast root.
+ */
+export function parseGfm(text: string): Root {
+ return fromMarkdown(text, {
+ extensions: [gfm()],
+ mdastExtensions: [gfmFromMarkdown()],
+ })
+}
+
+/**
+ * Parse GFM markdown plus TeX math with the compatibility delimiters
+ * (the settled arm's grammar).
+ * @param text - Markdown source.
+ * @returns The mdast root.
+ */
+export function parseGfmWithMath(text: string): Root {
+ return fromMarkdown(text, {
+ extensions: [gfm(), mathCompatibility(), math()],
+ mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()],
+ })
+}
diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx
new file mode 100644
index 0000000000..786a1a4636
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/render.tsx
@@ -0,0 +1,512 @@
+/**
+ * Direct mdast→React markdown renderer. Replaces the react-markdown /
+ * remark-rehype pipeline with one switch over parsed nodes so streaming can
+ * cache frozen blocks as React elements; the rendered DOM is pinned
+ * byte-for-byte by `tests/fixtures/markdown-dom` and must not drift.
+ *
+ * Untrusted-output policy (unchanged from the replaced pipeline): link and
+ * image destinations pass a protocol allowlist, images additionally require
+ * absolute HTTP(S), raw HTML renders as literal text (no HTML enters the
+ * DOM), and KaTeX runs without trusted commands. Fragment-anchor URLs fail
+ * the allowlist, so footnote references and back-references render as plain
+ * text rather than in-page links.
+ *
+ * Merge-extensible node unions fall through the documented default (render
+ * nothing) rather than ending in assertNever: grammars registered elsewhere
+ * may add node types this renderer has no mapping for.
+ */
+
+import { Fragment, createElement } from 'react'
+import type { Key, ReactNode } from 'react'
+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 { renderTexToReact } from './katex.tsx'
+import type { PositionedBlock } from './incremental.ts'
+import css from './MarkdownText.module.css'
+
+/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
+export interface MarkdownCodeLabels {
+ /** Copy-button idle label. */
+ copyLabel?: string | undefined
+ /** Copy-button label during the post-copy confirmation window. */
+ copiedLabel?: string | undefined
+}
+
+function sanitizeUrl(url: string): string {
+ try {
+ switch (new URL(url).protocol) {
+ case 'http:':
+ case 'https:':
+ case 'mailto:':
+ return url
+ default:
+ return ''
+ }
+ } catch {
+ // Relative and otherwise unparsable destinations are disallowed alongside
+ // disallowed protocols; new URL() has no other failure mode for strings.
+ return ''
+ }
+}
+
+function remoteImageUrl(url: string): string | undefined {
+ try {
+ const protocol = new URL(url).protocol
+ return protocol === 'http:' || protocol === 'https:' ? url : undefined
+ } catch {
+ // Same single failure mode as above: not an absolute URL.
+ return undefined
+ }
+}
+
+/** Link/image reference targets collected from a document (first definition per identifier wins, as in CommonMark). */
+export interface ReferenceTargets {
+ /** Link/image definitions keyed by upper-cased identifier. */
+ definitions: Map
+ /** Footnote definitions keyed by upper-cased identifier. */
+ footnotes: Map
+}
+
+/**
+ * Create an empty {@link ReferenceTargets}.
+ * @returns Fresh empty maps.
+ */
+export function createReferenceTargets(): ReferenceTargets {
+ return { definitions: new Map(), footnotes: new Map() }
+}
+
+/**
+ * Record every definition and footnote definition under `nodes` into
+ * `targets`, depth-first, keeping the first definition per identifier.
+ * @param nodes - Subtrees to walk (top-level blocks or any nested children).
+ * @param targets - Accumulator, typically shared across incremental segments.
+ */
+export function collectReferenceTargets(
+ nodes: readonly Md.RootContent[],
+ targets: ReferenceTargets,
+): void {
+ for (const node of nodes) {
+ if (node.type === 'definition') {
+ const id = node.identifier.toUpperCase()
+ if (!targets.definitions.has(id)) targets.definitions.set(id, node)
+ } else if (node.type === 'footnoteDefinition') {
+ const id = node.identifier.toUpperCase()
+ if (!targets.footnotes.has(id)) targets.footnotes.set(id, node)
+ }
+ if ('children' in node) collectReferenceTargets(node.children, targets)
+ }
+}
+
+/**
+ * One render pass's state: immutable options and targets plus the footnote
+ * numbering accumulated in document order while references render.
+ */
+export interface MarkdownRenderContext {
+ /** Streaming arm: fences render plain and TeX stays literal. */
+ readonly streaming: boolean
+ /** Localized fence copy-button labels. */
+ readonly codeLabels: MarkdownCodeLabels | undefined
+ /** Reference targets visible to this pass. */
+ readonly targets: ReferenceTargets
+ /** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */
+ readonly footnoteOrder: string[]
+ /** References rendered per identifier; drives the section's back-reference count. */
+ readonly footnoteCounts: Map
+}
+
+/**
+ * Render top-level blocks. Nodes that render nothing (definitions, unmapped
+ * types) are dropped rather than kept as null placeholders, matching the
+ * replaced pipeline's child lists so separator newlines land identically.
+ * @param blocks - Blocks with their stream-stable render keys.
+ * @param context - The pass state; footnote numbering mutates in document order.
+ * @returns One React node per rendered block.
+ */
+export function renderBlocks(
+ blocks: readonly PositionedBlock[],
+ context: MarkdownRenderContext,
+): ReactNode[] {
+ return blocks
+ .map(block => renderNode(block.node, block.key, context))
+ .filter(element => element !== null)
+}
+
+/**
+ * Interleave the newline text nodes the replaced pipeline emitted between
+ * block-level children. They are invisible between elements but coalesce
+ * into adjacent literal raw-HTML text, where the DOM parity fixtures pin
+ * them.
+ * @param elements - Rendered block children with empty renders already dropped.
+ * @param edges - Also emit the leading and trailing newline (hast's loose wrap).
+ * @returns The interleaved children.
+ */
+export function wrapBlockChildren(elements: readonly ReactNode[], edges: boolean): ReactNode[] {
+ const wrapped: ReactNode[] = []
+ for (const element of elements) {
+ if (edges || wrapped.length > 0) wrapped.push('\n')
+ wrapped.push(element)
+ }
+ if (edges && elements.length > 0) wrapped.push('\n')
+ return wrapped
+}
+
+/**
+ * A block child rendered for a parent that must tell paragraphs apart from
+ * other blocks (list items unwrap them when tight; footnote bodies receive
+ * their back-references inside the trailing paragraph).
+ */
+type BlockEntry = { paragraph: ReactNode[] } | { element: ReactNode }
+
+/** Render container children into {@link BlockEntry} values, dropping empty renders. */
+function renderBlockEntries(
+ blocks: readonly Md.RootContent[],
+ context: MarkdownRenderContext,
+): BlockEntry[] {
+ const entries: BlockEntry[] = []
+ for (const [index, block] of blocks.entries()) {
+ if (block.type === 'paragraph') {
+ entries.push({ paragraph: renderChildren(block.children, context) })
+ } else {
+ const element = renderNode(block, index, context)
+ if (element !== null) entries.push({ element })
+ }
+ }
+ return entries
+}
+
+function renderChildren(
+ nodes: readonly Md.RootContent[],
+ context: MarkdownRenderContext,
+): ReactNode[] {
+ return nodes.map((node, index) => renderNode(node, index, context))
+}
+
+function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderContext): ReactNode {
+ switch (node.type) {
+ case 'text':
+ return node.value
+ case 'paragraph':
+ return {renderChildren(node.children, context)}
+ case 'heading':
+ return createElement(`h${node.depth}`, { key }, ...renderChildren(node.children, context))
+ case 'blockquote':
+ return (
+
+ {wrapBlockChildren(renderChildren(node.children, context).filter(child => child !== null), true)}
+
+ )
+ case 'thematicBreak':
+ return
+ case 'break':
+ // The replaced pipeline emitted a newline text node after each
.
+ return
{'\n'}
+ case 'strong':
+ return {renderChildren(node.children, context)}
+ case 'emphasis':
+ return {renderChildren(node.children, context)}
+ case 'delete':
+ return {renderChildren(node.children, context)}
+ case 'inlineCode':
+ // Parity with mdast-util-to-hast: inline code renders line endings as spaces.
+ return {node.value.replace(/\r?\n|\r/g, ' ')}
+ case 'html':
+ // No HTML parser enters the pipeline: raw HTML stays literal text.
+ return node.value
+ case 'code':
+ return renderCode(node, key, context)
+ case 'math':
+ return {renderTexToReact(node.value, true)}
+ case 'inlineMath':
+ return {renderTexToReact(node.value, false)}
+ case 'list':
+ return renderList(node, key, context)
+ case 'listItem':
+ // Reachable only in hand-built trees: the grammar emits items inside lists.
+ return renderListItem(node, listItemLoose(node), key, context)
+ case 'table':
+ return renderTable(node, key, context)
+ case 'link':
+ return renderAnchor(node.url, renderChildren(node.children, context), key)
+ case 'linkReference':
+ return renderLinkReference(node, key, context)
+ case 'image':
+ return renderImage(node.url, node.alt ?? '', key)
+ case 'imageReference':
+ return renderImageReference(node, key, context)
+ case 'footnoteReference':
+ return renderFootnoteReference(node, key, context)
+ case 'definition':
+ case 'footnoteDefinition':
+ // Targets render elsewhere: definitions resolve references in place;
+ // footnote bodies render in the trailing section.
+ return null
+ default:
+ // Documented default for the merge-extensible union: node types without
+ // a mapping (tableRow/tableCell outside a table, frontmatter, future
+ // grammar contributions) render nothing.
+ return null
+ }
+}
+
+function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): ReactNode {
+ const language = node.lang ?? undefined
+ if (node.value === '') {
+ // Parity: the replaced pipeline kept the stock for an empty fence.
+ return (
+
+
+
+ )
+ }
+ // The replaced pipeline recovered the grammar id from the hast class with
+ // /language-([\w-]+)/, which truncates at the first non-word character.
+ const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0]
+ if (!context.streaming && lang === 'math') {
+ // ```math fences render as display TeX once settled (rehype-katex parity);
+ // its text extraction saw the code block's trailing newline.
+ return {renderTexToReact(`${node.value}\n`, true)}
+ }
+ return (
+
+ )
+}
+
+/** A list is loose when it or any of its items is spread; every item then keeps its paragraphs. */
+function listLoose(list: Md.List): boolean {
+ return (list.spread ?? false) || list.children.some(listItemLoose)
+}
+
+function listItemLoose(item: Md.ListItem): boolean {
+ return item.spread ?? item.children.length > 1
+}
+
+function renderList(node: Md.List, key: Key, context: MarkdownRenderContext): ReactNode {
+ const loose = listLoose(node)
+ const properties: { start?: number; className?: string } = {}
+ if (typeof node.start === 'number' && node.start !== 1) properties.start = node.start
+ if (node.children.some(item => typeof item.checked === 'boolean')) {
+ properties.className = 'contains-task-list'
+ }
+ return createElement(
+ node.ordered === true ? 'ol' : 'ul',
+ { key, ...properties },
+ ...node.children.map((item, index) => renderListItem(item, loose, index, context)),
+ )
+}
+
+function renderListItem(
+ item: Md.ListItem,
+ loose: boolean,
+ key: Key,
+ context: MarkdownRenderContext,
+): ReactNode {
+ const entries = renderBlockEntries(item.children, context)
+ const task = typeof item.checked === 'boolean'
+ if (task) {
+ const checkbox =
+ const head = entries[0]
+ if (head !== undefined && 'paragraph' in head) {
+ head.paragraph = head.paragraph.length > 0 ? [checkbox, ' ', ...head.paragraph] : [checkbox]
+ } else {
+ entries.unshift({ paragraph: [checkbox] })
+ }
+ }
+ // Newline placement and tight-paragraph unwrapping mirror
+ // mdast-util-to-hast's list-item handler: a newline before every child
+ // except a tight leading paragraph, and after a trailing non-paragraph
+ // (or any trailing child when loose).
+ const parts: ReactNode[] = []
+ for (const [index, entry] of entries.entries()) {
+ const isParagraph = 'paragraph' in entry
+ if (loose || index !== 0 || !isParagraph) parts.push('\n')
+ if (!isParagraph) parts.push(entry.element)
+ else if (loose) parts.push({entry.paragraph}
)
+ else parts.push({entry.paragraph} )
+ }
+ const tail = entries[entries.length - 1]
+ if (tail !== undefined && (loose || !('paragraph' in tail))) parts.push('\n')
+ return (
+
+ {parts}
+
+ )
+}
+
+function renderTable(node: Md.Table, key: Key, context: MarkdownRenderContext): ReactNode {
+ const align = node.align ?? null
+ const [headRow, ...bodyRows] = node.children
+ return (
+
+
+ {headRow !== undefined && {renderTableRow(headRow, 'th', align, 0, context)}}
+ {bodyRows.length > 0 && (
+
+ {bodyRows.map((row, index) => renderTableRow(row, 'td', align, index + 1, context))}
+
+ )}
+
+
+ )
+}
+
+function renderTableRow(
+ row: Md.TableRow,
+ cellTag: 'th' | 'td',
+ align: readonly Md.AlignType[] | null,
+ key: Key,
+ context: MarkdownRenderContext,
+): ReactNode {
+ // With column alignment present, every row renders exactly one cell per
+ // column, padding or truncating the row (mdast-util-to-hast parity).
+ const length = align === null ? row.children.length : align.length
+ const cells: ReactNode[] = []
+ for (let index = 0; index < length; index++) {
+ const cell = row.children[index]
+ const alignValue = align?.[index]
+ cells.push(createElement(
+ cellTag,
+ // hast-util-to-jsx-runtime's default tableCellAlignToStyle turned the
+ // deprecated align attribute into an inline style; keep that DOM.
+ { key: index, style: alignValue == null ? undefined : { textAlign: alignValue } },
+ ...(cell === undefined ? [] : renderChildren(cell.children, context)),
+ ))
+ }
+ return {cells}
+}
+
+function renderAnchor(url: string, children: ReactNode[], key: Key): ReactNode {
+ const safeHref = sanitizeUrl(normalizeUri(url))
+ if (safeHref === '') return {children}
+ const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
+ return (
+
+ {children}
+
+ )
+}
+
+function renderImage(url: string, alt: string, key: Key): ReactNode {
+ const imageSrc = remoteImageUrl(sanitizeUrl(normalizeUri(url)))
+ if (imageSrc === undefined) {
+ return {alt}
+ }
+ return (
+
+ )
+}
+
+/** The bracketed source text a reference reverts to when its definition is missing. */
+function referenceSuffix(node: Md.LinkReference | Md.ImageReference): string {
+ if (node.referenceType === 'collapsed') return '][]'
+ if (node.referenceType === 'full') return `][${node.label ?? node.identifier}]`
+ return ']'
+}
+
+function renderLinkReference(
+ node: Md.LinkReference,
+ key: Key,
+ context: MarkdownRenderContext,
+): ReactNode {
+ const definition = context.targets.definitions.get(node.identifier.toUpperCase())
+ const children = renderChildren(node.children, context)
+ if (definition === undefined) {
+ // The grammar only emits references whose definitions exist somewhere in
+ // the same parse, but incremental segments and hand-built trees may still
+ // present unresolved ones: revert to the bracketed source text.
+ return {'['}{children}{referenceSuffix(node)}
+ }
+ return renderAnchor(definition.url, children, key)
+}
+
+function renderImageReference(
+ node: Md.ImageReference,
+ key: Key,
+ context: MarkdownRenderContext,
+): ReactNode {
+ const definition = context.targets.definitions.get(node.identifier.toUpperCase())
+ if (definition === undefined) return `![${node.alt ?? ''}${referenceSuffix(node)}`
+ return renderImage(definition.url, node.alt ?? '', key)
+}
+
+function renderFootnoteReference(
+ node: Md.FootnoteReference,
+ key: Key,
+ context: MarkdownRenderContext,
+): ReactNode {
+ const id = node.identifier.toUpperCase()
+ const seen = context.footnoteCounts.get(id)
+ if (seen === undefined) context.footnoteOrder.push(id)
+ context.footnoteCounts.set(id, (seen ?? 0) + 1)
+ // The in-page anchor fails the protocol allowlist, so only the numbered
+ // superscript renders (matching the replaced pipeline's unwrapped link).
+ return {String(context.footnoteOrder.indexOf(id) + 1)}
+}
+
+/**
+ * Render the trailing footnote section for every footnote referenced during
+ * the pass, in first-reference order, with one plain-text back-reference
+ * marker per rendered reference.
+ * @param context - The pass state after all blocks rendered.
+ * @returns The section, or null when no referenced footnote has a definition.
+ */
+export function renderFootnoteSection(context: MarkdownRenderContext): ReactNode | null {
+ const items: ReactNode[] = []
+ for (const id of context.footnoteOrder) {
+ const definition = context.targets.footnotes.get(id)
+ if (definition === undefined) continue
+ const count = context.footnoteCounts.get(id) ?? 0
+ const backrefs: ReactNode[] = []
+ for (let reference = 1; reference <= count; reference++) {
+ if (backrefs.length > 0) backrefs.push(' ')
+ backrefs.push('↩')
+ if (reference > 1) backrefs.push({String(reference)})
+ }
+ const entries = renderBlockEntries(definition.children, context)
+ const tail = entries[entries.length - 1]
+ const body: ReactNode[] = entries.map((entry, index) => (
+ 'paragraph' in entry
+ ? (
+
+ {entry.paragraph}
+ {entry === tail && <>{' '}{backrefs}>}
+
+ )
+ : entry.element
+ ))
+ // Without a trailing paragraph the back-references join the block list
+ // itself (and pick up the wrap newlines), as in the replaced pipeline.
+ if (tail === undefined || !('paragraph' in tail)) body.push(...backrefs)
+ items.push(
+
+ {wrapBlockChildren(body, true)}
+ ,
+ )
+ }
+ if (items.length === 0) return null
+ return (
+
+ Footnotes
+ {items}
+
+ )
+}
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt
new file mode 100644
index 0000000000..d213078f94
--- /dev/null
+++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.settled.txt
@@ -0,0 +1,12 @@
+
+
+
+ #text "level one\nstill one"
+
+
+ #text "nested"
+
+ -
+ #text "quoted list"
+
+ #text "after"
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt
new file mode 100644
index 0000000000..d213078f94
--- /dev/null
+++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/blockquote-nested.streaming.txt
@@ -0,0 +1,12 @@
+
+
+
+ #text "level one\nstill one"
+
+
+ #text "nested"
+
+ -
+ #text "quoted list"
+
+ #text "after"
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
new file mode 100644
index 0000000000..a0a11e1254
--- /dev/null
+++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.settled.txt
@@ -0,0 +1,78 @@
+
+
+