From 1825cb4657061ffe70d9ebd11c3e3575329fc21b Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Tue, 25 Aug 2026 12:42:08 +0800 Subject: [PATCH] feat(client): highlight streaming fences incrementally Keep recognized code fences syntax-highlighted while assistant text streams. Preserve completed Shiki token lines across chunks, mirror token styles and CRLF handling, and retain plain rendering for unsupported or math-like fences. Add unit, DOM-parity, and keyless assembled-Web coverage for the streaming-to-settled transition. Closes #1499 --- ...026-07-23-web-assistant-markdown.i18n.yaml | 4 +- .../2026-07-23-web-assistant-markdown.md | 2 +- .../2026-07-23-web-assistant-markdown.zh.md | 2 +- ...20-web-streaming-fence-highlight.i18n.yaml | 6 + ...026-08-20-web-streaming-fence-highlight.md | 37 +++ ...-08-20-web-streaming-fence-highlight.zh.md | 37 +++ .../mid-stream.expected.md | 97 ++++++++ .../tests/streaming-fence-highlight.e2e.ts | 146 +++++++++++ apps/web/tsconfig.json | 1 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/markdown/CodeBlock.tsx | 88 ++++++- .../src/markdown/MarkdownText.tsx | 7 +- .../ui-primitives/src/markdown/highlight.ts | 147 ++++++++++- .../ui-primitives/src/markdown/render.tsx | 10 +- .../markdown-dom/code-fences.streaming.txt | 31 ++- .../fence-trailing-blank-lines.streaming.txt | 17 +- .../math-edge-cases.streaming.txt | 1 + .../tests/markdown-dom-parity.client.spec.tsx | 4 + .../tests/markdown.client.spec.tsx | 42 +++- .../streaming-code-block.client.spec.tsx | 229 ++++++++++++++++++ tsconfig.host.json | 1 + 23 files changed, 877 insertions(+), 40 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md create mode 100644 .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md create mode 100644 apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md create mode 100644 apps/web/tests/streaming-fence-highlight.e2e.ts create mode 100644 packages/client/ui-primitives/tests/streaming-code-block.client.spec.tsx 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 303caf020b..671ca8f796 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: 0ad74546b9a54d5eadfc3e7991efa4e0d9b7bc77 -2026-07-23-web-assistant-markdown.zh.md: 9349847228b10c13ecead4374ad2fc9210f5e9f1 +2026-07-23-web-assistant-markdown.md: d2b8e30d779656636f70b05524c96796a254b57b +2026-07-23-web-assistant-markdown.zh.md: c1542d75faf1b484160f98b4217164b5df4e4b99 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 0ad74546b9..d2b8e30d77 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,7 +12,7 @@ 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` 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. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream 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. A micromark attention extension reuses the CommonMark resolver while letting runs of at least two asterisks close after Unicode punctuation when followed immediately by CJK text. This exception covers punctuation-terminated strong emphasis in whitespace-free CJK prose during streaming and after settlement; single-asterisk emphasis, non-CJK adjacency, escaped source, code, and math retain upstream 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 highlight incrementally: each chunk tokenizes newly completed text from a saved grammar state plus the still-growing last line, excluding the completed prefix from repeated work (the [streaming fence-highlight note](2026-08-20-web-streaming-fence-highlight.md) owns that mechanism). 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). When one inline-code token consists entirely of an absolute HTTP(S) URL, its code chrome contains the same keyboard-focusable safe external anchor as an ordinary link; port, path, and query text remain unchanged, while commands, partial URLs, other schemes, and fenced code stay inert. `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. 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 9349847228..c1542d75fa 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,7 +12,7 @@ Web 对话通过会话事件、历史回放与流式累积保留 assistant Markd `@deepseek-ai/dsh-client-ui-primitives` 导出 `MarkdownText`,用作不受信任的 assistant 文本渲染器;`ui-conversation` 仅为 assistant `text` 块选择该渲染器。已完成的历史消息、流式输出尾部与被中断的部分输出已经共用 `AssistantMarkdown`,因此无需更改事件或快照,它们便会采用同一渲染器。用户消息与 steering 消息继续使用 `MessageText`,并保持按字面渲染。 -`MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏停留在纯文本分支,以免每收到一个分片就对增长中的围栏重新分词。 +`MarkdownText` 以 `mdast-util-from-markdown` 加 GFM micromark 扩展解析,并经包内自有渲染器渲染 mdast 树,轮次流式输出期间增量解析([增量 AST 渲染器 Note](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md) 拥有该机制及其 DOM 一致性约定)。它覆盖 CommonMark 块,以及 GFM 表格、任务列表、删除线与自动链接,且不解析原始 HTML。一个 micromark attention 扩展复用 CommonMark resolver,同时允许至少两个星号组成的连续序列在 Unicode 标点后闭合,前提是其后紧邻 CJK 文本。这一例外涵盖流式输出期间与完成后无空格 CJK 文本中以标点结尾的粗体;单星号强调、紧邻非 CJK 文本的情况、已转义源文本、代码与数学公式仍沿用上游解析行为。围栏代码经共享的 `CodeBlock` 路由;该组件用客户端的 shiki 单例(`--shiki-*` token)高亮已注册语法,否则回退为纯等宽文本。轮次流式输出期间,围栏增量高亮:每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,不重复处理已完成的前缀([流式围栏高亮 Note](2026-08-20-web-streaming-fence-highlight.zh.md) 拥有该机制)。 视觉间距、表格、链接、引用块、行内代码与代码块外框遵循 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 保持为近黑色,此处不做重新调色)。当单个行内代码 token 完全由绝对 HTTP(S) URL 构成时,其代码外框会包含一个与普通链接相同、可通过键盘聚焦的安全外链锚点;端口、路径与查询文本保持不变,而命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。`CodeBlock` 提供语言横幅与复制控件(`复制` / `复制成功`)。已完成的文本通过定稿语法的数学扩展渲染 KaTeX;`mathCompatibility` 将 `\(...\)`、`\[...\]` 和块级同一行 `$$...$$` 映射为同一套标准数学 AST 节点。这是一层小范围的解析器兼容层,不是正则重写,也不修复格式错误的模型输出。流式输出在完成前保持按字面渲染,避免不完整公式闪现错误。引用胶囊、标题锚点、thinking-small markdown 变体,以及自定义 □/☑ 任务标记仍不在范围内;GFM 任务列表继续使用原生复选框。 diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml new file mode 100644 index 0000000000..2139112dec --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md +2026-08-20-web-streaming-fence-highlight.md: ccc961da1febba3611e3e558087b586c6f1f474b +2026-08-20-web-streaming-fence-highlight.zh.md: e5d29545bf659ed572d052f1212d68d344d49b5a diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md new file mode 100644 index 0000000000..ccc961da1f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md @@ -0,0 +1,37 @@ +# Agent Note: Streaming fences highlight incrementally + +Status: implemented + +English | [中文](2026-08-20-web-streaming-fence-highlight.zh.md) + +## Problem + +While a reply streamed, `MarkdownText` stripped the fence language before `CodeBlock` saw it, so code rendered as plain monospace with an empty language banner until the finalize swap recolored the whole reply at once ([#1499](https://github.com/deepseek-harness/deepseek-harness/issues/1499)). The plain arm was a deliberate cost guard, recorded in the [assistant-markdown note](2026-07-23-web-assistant-markdown.md): shiki tokenizes a document from the top, so highlighting a growing fence naively re-tokenizes the whole fence on every chunk — quadratic in fence length over the stream, the same cost class the [incremental markdown parser](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) removes for block parsing. The fix has to deliver highlighting during streaming without reintroducing that cost, without transiently coloring under a wrong grammar while the info string is still mid-chunk, and without changing the settled render. + +## Decision + +Streaming fences highlight incrementally through grammar-state resumption; the settled arm is unchanged. + +- **`StreamingHighlightSession`** (`packages/client/ui-primitives/src/markdown/highlight.ts`) exploits that TextMate tokenization is line-based and forward-only: a line's tokens depend only on its own text and the grammar state entering it, so appended text never changes a completed line's tokens. The session caches completed lines' spans plus shiki's `GrammarState` after them (`getLastGrammarState`), and each update tokenizes newly completed text via `codeToTokensBase(…, { grammarState })` plus the still-growing last line. Per-chunk cost excludes the completed prefix; the result is token-identical to a from-scratch tokenization. Non-append input and a resolved-grammar change reset the cache and re-tokenize fully. Each run carries the style shiki's HTML arm would assign it — the css-variables color plus the markup font-style bits the theme lets through (bold/italic/underline; markdown fences carry them); whitespace-only runs fold into their following token as shiki's default `mergeWhitespaces` does (its underlined/struck-whitespace exemption cannot occur under this theme, whose only underline rule styles inline-link scopes that tokenize spaced text as one run); and a CRLF cut never leaks its `\r` into the last completed line, matching shiki's own line splitting — so the streaming spans and the settled `codeToHtml` swap render one identical span tree. +- **`CodeBlock`** gains a `streaming` prop: it renders the session's spans as a `pre.shiki.css-variables` React tree with the same attributes shiki's HTML emits, holds the session and per-line elements in refs, and reuses a retained line's element identity so React leaves that line's DOM untouched. Unknown or absent languages keep the identical-geometry plain arm; a lazy grammar renders plain until it registers, then the existing `useSyncExternalStore` load signal re-renders into highlight — one plain→highlighted transition, no flicker back. +- **`render.tsx`** passes `lang` and `context.streaming` to fences. Wrong-grammar transients are structurally impossible: a fence whose info string is still mid-chunk (`` ```py `` completing to `` ```python ``) has no content yet — content only exists after the info line's newline, which finalizes the language — and the empty-value fence keeps the stock `
`. The streaming CodeBlock instance survives every chunk because streaming render keys are source offsets. `` ```math `` fences and TeX stay literal until the settled pass; the language banner shows the fence language during streaming.
+
+The settle swap re-renders through `highlightToHtml`: same tokens, same span tree, so the swap is visually invisible and never touches the code content.
+
+## Testing
+
+Package tests cover incremental/from-scratch equivalence across multiline grammar state, blank lines, CRLF, and markup styles; cache identity and reset/lazy paths; streaming/settled token-tree parity; DOM retention; and plain or math fallbacks. The assembled Web browser snapshot boots the real Web composition, streams a TypeScript fence through the Host and SSE path, pauses the deterministic LLM adapter while the reply is still active, and snapshots Chromium's Shiki token tree before verifying that settlement preserves it. The `tests/fixtures/markdown-dom/*.streaming.txt` fixtures pin the intentional streaming divergence from their react-markdown origin: the Shiki span tree and visible language banner replace the plain arm.
+
+## Alternatives considered
+
+**Pass `lang` through and re-tokenize the whole fence per chunk.** One-line fix, but it reverses the recorded plain-arm rationale without addressing it: a long streaming fence pays quadratic tokenization over the stream, janking exactly on the replies where highlighting matters most.
+
+**Highlight only frozen (closed, settled-position) fences during streaming.** Bounded cost, but an unclosed fence pins the incremental parser's tail, so the actively growing fence — the one on screen — would stay plain until the reply finishes, failing the issue's "识别语言后即可增量高亮".
+
+**Move highlighting to a worker or async pass.** Rejected when shiki was adopted ([synchronous highlighting note](../process/2026-07-26-web-syntax-highlighting-shiki.md)); an async swap also reintroduces the plain→colored→plain flicker class this change must avoid.
+
+**Build the settled HTML string incrementally and keep `dangerouslySetInnerHTML`.** Exact settled parity for free, but React replaces the whole `innerHTML` per chunk, so the browser re-parses and rebuilds every line's DOM each time — O(fence) DOM churn that forfeits the token-level win the session provides.
+
+## Consequences
+
+Streaming code is readable as it arrives: tokens color as soon as the language is known, completed lines never re-tokenize or re-render, and the finalize swap is invisible for fences. The package owns a small mirror of shiki's HTML-arm conventions — the `pre` attributes and the whitespace fold — pinned by the arm-parity test, so a shiki upgrade that changes either fails loud there instead of drifting the two arms apart. The streaming DOM-parity fixtures pin Shiki span trees as an intentional divergence from their react-markdown origin. The still-growing last line re-tokenizes per chunk (bounded by one line), and a pathological single-line fence still degrades to full re-tokenization per chunk — the same degradation class the incremental block parser accepts for a single giant block.
diff --git a/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
new file mode 100644
index 0000000000..e5d29545bf
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md
@@ -0,0 +1,37 @@
+# Agent Note: 流式围栏代码增量高亮
+
+Status: implemented
+
+[English](2026-08-20-web-streaming-fence-highlight.md) | 中文
+
+## Problem
+
+回复流式输出期间,`MarkdownText` 在 `CodeBlock` 看到围栏语言之前就把它剥掉,代码因此以无高亮的等宽纯文本呈现、语言横幅为空,直到定稿切换一次性重新着色整个回复([#1499](https://github.com/deepseek-harness/deepseek-harness/issues/1499))。纯文本臂是一道刻意的成本防线,记录于 [assistant-markdown 笔记](2026-07-23-web-assistant-markdown.zh.md):shiki 从文档顶部开始 tokenize,朴素地高亮一个增长中的围栏意味着每个分片都重新 tokenize 整个围栏——随流式过程对围栏长度呈平方级,与[增量 markdown 解析器](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md)为块解析消除的是同一类成本。修复必须在流式期间给出高亮,同时不重新引入该成本、不在 info string 尚在分片中途时以错误语法短暂着色、也不改变定稿渲染。
+
+## Decision
+
+流式围栏通过 grammar state 续接实现增量高亮;定稿臂保持不变。
+
+- **`StreamingHighlightSession`**(`packages/client/ui-primitives/src/markdown/highlight.ts`)利用 TextMate tokenize 按行、且只向前推进的性质:一行的 token 只取决于该行文本与进入该行时的 grammar state,因此追加的文本永远不会改变已完成行的 token。会话缓存已完成行的 span 以及其后的 shiki `GrammarState`(`getLastGrammarState`),每次更新通过 `codeToTokensBase(…, { grammarState })` tokenize 新完成的文本,外加仍在增长的最后一行。每分片成本不包含已完成的前缀;结果与从头 tokenize 逐 token 一致。非追加输入与解析后语法变化会重置缓存并完整重新 tokenize。每个 run 携带 shiki HTML 臂会赋予它的样式——css-variables 颜色加上主题放行的 markup 字体位(bold/italic/underline;markdown 围栏会携带它们);纯空白 run 并入其后的 token,与 shiki 默认的 `mergeWhitespaces` 一致(其对带下划线/删除线空白的豁免在该主题下不可能出现:主题唯一的 underline 规则作用于 inline-link scope,其含空格文本整体成一个 run);CRLF 切割点的 `\r` 绝不进入最后一个已完成行,与 shiki 自身的行切分一致——因此流式 span 与定稿 `codeToHtml` 换入的 span 树完全一致。
+- **`CodeBlock`** 新增 `streaming` prop:把会话的 span 渲染为带有 shiki HTML 同款属性的 `pre.shiki.css-variables` React 树,用 ref 持有会话与逐行元素,并复用保留行的元素标识,让 React 完全不触碰该行的 DOM。未知或缺失语言保持几何一致的纯文本臂;懒加载语法在注册前渲染纯文本,注册后由既有的 `useSyncExternalStore` 加载信号触发重渲染进入高亮——只有一次纯文本→高亮的转换,不会闪回。
+- **`render.tsx`** 向围栏传递 `lang` 与 `context.streaming`。错误语法的瞬时着色在结构上不可能出现:info string 尚在分片中途的围栏(`` ```py `` 补全为 `` ```python ``)还没有内容——内容只在 info 行的换行之后才存在,而该换行恰恰定格了语言——空值围栏保持原生 ``。流式渲染 key 是源偏移,围栏的 CodeBlock 实例因此跨分片存活。`` ```math `` 围栏与 TeX 在定稿前保持字面量;语言横幅在流式期间显示围栏语言。
+
+定稿切换经 `highlightToHtml` 重渲染:token 相同、span 树相同,切换在视觉上不可见,也绝不触碰代码内容。
+
+## Testing
+
+包测试覆盖跨多行 grammar state、空行、CRLF 与 markup 样式的增量/从头等价性,缓存标识与重置/懒加载路径,流式/定稿 token 树一致性,DOM 保留,以及纯文本和 math 回退。组装后的 Web 浏览器快照会启动真实 Web 组合,让 TypeScript 围栏经过 Host 与 SSE 路径流式传输,在回复仍活跃时暂停确定性 LLM 适配器并对 Chromium 中的 Shiki token 树做快照,然后验证定稿保留该 token 树。`tests/fixtures/markdown-dom/*.streaming.txt` fixture 锁定相对 react-markdown 来源的一项有意分叉:Shiki span 树与可见语言横幅取代纯文本臂。
+
+## Alternatives considered
+
+**直接透传 `lang`,每个分片重新 tokenize 整个围栏。** 一行改动,但在不回应的情况下推翻了已记录的纯文本臂理由:长流式围栏在整个流式过程付出平方级 tokenize 成本,恰恰在高亮最有价值的长代码回复上产生卡顿。
+
+**流式期间只高亮已冻结(闭合且位置定格)的围栏。** 成本有界,但未闭合围栏会钉住增量解析器的尾部,于是正在增长的围栏——屏幕上的那个——要等回复结束才高亮,不满足 issue 的"识别语言后即可增量高亮"。
+
+**把高亮移到 worker 或异步流程。** 采纳 shiki 时已否决([同步高亮笔记](../process/2026-07-26-web-syntax-highlighting-shiki.zh.md));异步换入还会重新引入本变更必须避免的纯文本→彩色→纯文本闪烁类问题。
+
+**增量拼接定稿 HTML 字符串并继续使用 `dangerouslySetInnerHTML`。** 白得定稿一致性,但 React 每个分片都会整体替换 `innerHTML`,浏览器每次重新解析并重建所有行的 DOM——O(围栏) 的 DOM 翻搅,抵消了会话在 token 层的收益。
+
+## Consequences
+
+流式代码随到达即可读:语言一经识别 token 即着色,已完成行绝不重新 tokenize 或重渲染,定稿切换对围栏而言不可见。该包持有一小份 shiki HTML 臂约定的镜像——`pre` 属性与空白折叠——由双臂一致性测试锁定,shiki 升级若改变任一处会在该测试处响亮失败,而不是让两臂悄然漂移。流式 DOM 一致性 fixture 锁定 Shiki span 树,这是相对其 react-markdown 来源的一项有意分叉。仍在增长的最后一行每分片重新 tokenize(以一行为界);病态的单行超长围栏仍退化为每分片完整重新 tokenize——与增量块解析器对单个巨型块接受的是同一退化类。
diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
new file mode 100644
index 0000000000..7207de464b
--- /dev/null
+++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
@@ -0,0 +1,97 @@
+- banner:
+ - navigation "Session hierarchy":
+ - button "Stream one TypeScript fence for" [disabled]
+ - img
+ - text: Standard mode
+ - button "Session log":
+ - text: Session log
+ - img
+ - tablist:
+ - tab "Chat" [selected]
+ - tab "Trajectory"
+- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
+- button "Copy":
+ - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+ - img
+ - img
+ - text: Context injection @deepseek-ai/dsh-system-prompt
+- text: ts
+- button "Copy"
+- code: "const first: number = 1 const second = \"two\" let tail"
+- status: Deep diving...
+- textbox "Message the agent"
+- button "Commands":
+ - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current streaming-fence-highlight-test/streaming-fence":
+ - text: streaming-fence-highlight-test/streaming-fence
+ - img
+- button "Stop generating"
+
+---
+
+{
+ "language": "ts",
+ "pre": {
+ "className": "shiki css-variables",
+ "style": "background-color: var(--shiki-background); color: var(--shiki-foreground);",
+ "tabIndex": "0"
+ },
+ "lines": [
+ [
+ {
+ "text": "const",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " first",
+ "style": "color: var(--shiki-token-constant);"
+ },
+ {
+ "text": ":",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " number",
+ "style": "color: var(--shiki-token-constant);"
+ },
+ {
+ "text": " =",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " 1",
+ "style": "color: var(--shiki-token-constant);"
+ }
+ ],
+ [
+ {
+ "text": "const",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " second",
+ "style": "color: var(--shiki-token-constant);"
+ },
+ {
+ "text": " =",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " \"two\"",
+ "style": "color: var(--shiki-token-string-expression);"
+ }
+ ],
+ [
+ {
+ "text": "let",
+ "style": "color: var(--shiki-token-keyword);"
+ },
+ {
+ "text": " tail",
+ "style": "color: var(--shiki-foreground);"
+ }
+ ]
+ ]
+}
diff --git a/apps/web/tests/streaming-fence-highlight.e2e.ts b/apps/web/tests/streaming-fence-highlight.e2e.ts
new file mode 100644
index 0000000000..594d51c949
--- /dev/null
+++ b/apps/web/tests/streaming-fence-highlight.e2e.ts
@@ -0,0 +1,146 @@
+/** Keyless assembled-Web evidence for syntax highlighting during a streamed code fence. */
+
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
+import { LlmAdapter } from '@deepseek-ai/dsh-llm'
+import type {} from '@deepseek-ai/dsh-agent-default-model'
+import {
+ assertFixtureInventory,
+ captureStableAria,
+ compareOrRefreshGolden,
+ launchWebScaffold,
+ watchConsole,
+ webSnapshotMode,
+ type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/streaming-fence-highlight', import.meta.url))
+const MID_EXPECTED = fileURLToPath(new URL('./snapshots/streaming-fence-highlight/mid-stream.expected.md', import.meta.url))
+const MODE = webSnapshotMode()
+const PROVIDER = 'streaming-fence-highlight-test'
+const MODEL = 'streaming-fence'
+const PROMPT = 'Stream one TypeScript fence for the highlighting snapshot.'
+const OPEN_REPLY = '```ts\nconst first: number = 1\nconst second = "two"\nlet tail'
+const REPLY = `${OPEN_REPLY}\n\`\`\``
+
+/** Deterministic model response held after the visible fence body arrives. */
+class StreamingFenceAdapter extends LlmAdapter {
+ private resolvePaused!: () => void
+ private resolveContinuation!: () => void
+ private continued = false
+ readonly paused = new Promise((resolve) => { this.resolvePaused = resolve })
+ private readonly continuation = new Promise((resolve) => { this.resolveContinuation = resolve })
+
+ continue(): void {
+ if (this.continued) return
+ this.continued = true
+ this.resolveContinuation()
+ }
+
+ override async *stream(options: GenerateOptions): AsyncIterable {
+ yield { type: 'block-start', index: 0, blockType: 'text' }
+ yield { type: 'text-delta', index: 0, text: OPEN_REPLY }
+ this.resolvePaused()
+ await this.continuation
+ if (options.signal?.aborted === true) throw options.signal.reason
+ yield { type: 'text-delta', index: 0, text: '\n```' }
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }
+ yield { type: 'finish', reason: { kind: 'stop' } }
+ }
+}
+
+interface FenceTree {
+ language: string
+ pre: { className: string; style: string | null; tabIndex: string | null }
+ lines: { text: string; style: string | null }[][]
+}
+
+/** Read the stable, user-visible subset of one rendered code fence. */
+async function fenceTree(block: ReturnType): Promise {
+ return await block.evaluate((element) => {
+ const pre = element.querySelector('pre.shiki')
+ if (pre === null) throw new Error('streaming fence did not render through the shiki arm')
+ return {
+ language: element.querySelector('[class*="infostring"]')?.textContent ?? '',
+ pre: {
+ className: pre.className,
+ style: pre.style.cssText,
+ tabIndex: pre.getAttribute('tabindex'),
+ },
+ lines: [...pre.querySelectorAll('.line')].map(line =>
+ [...line.querySelectorAll('span')].map(span => ({
+ text: span.textContent ?? '',
+ style: span.style.cssText,
+ })),
+ ),
+ }
+ })
+}
+
+describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+ const adapter = new StreamingFenceAdapter()
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold()
+ scaffold.ctx.effect(
+ () => scaffold.ctx.llm.registerAdapter([PROVIDER], adapter),
+ 'streaming fence highlight adapter',
+ )
+ await scaffold.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL })
+ browser = await chromium.launch()
+ page = await newEnglishPage(browser)
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ await connectFreshWorkspace(page, scaffold.workspaceCwd)
+ }, 120_000)
+
+ afterAll(async () => {
+ adapter.continue()
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('renders the growing fence through shiki and preserves its token tree when the turn settles', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-streaming-fence-highlight'))
+ const input = page.locator('textarea').first()
+ const settled = scaffold.whenTurnSettled(30_000)
+ await input.fill(PROMPT)
+ await input.press('Enter')
+ await adapter.paused
+
+ const streaming = page.locator('[data-streaming="true"]')
+ await streaming.waitFor({ timeout: 10_000 })
+ const block = streaming.locator('.md-code-block').filter({ hasText: 'const first' })
+ await block.locator('pre.shiki span[style]').first().waitFor({ timeout: 10_000 })
+ const midTree = await fenceTree(block)
+ expect(midTree.language).toBe('ts')
+ expect(midTree.lines).toHaveLength(3)
+ expect(midTree.lines.flat().map(span => span.style)).toContain('color: var(--shiki-token-keyword);')
+
+ const aria = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(
+ MID_EXPECTED,
+ `${aria}\n\n---\n\n${JSON.stringify(midTree, null, 2)}`,
+ MODE,
+ )
+
+ adapter.continue()
+ await settled
+ await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
+ const settledBlock = page.locator('.md-code-block').filter({ hasText: 'const first' })
+ await settledBlock.locator('pre.shiki').waitFor({ timeout: 10_000 })
+ expect(await fenceTree(settledBlock)).toEqual(midTree)
+ expect(tripwire.pageErrors).toEqual([])
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md'])
+ }, 60_000)
+})
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 386615f059..c14855169f 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -43,6 +43,7 @@
"tests/models-settings.e2e.ts",
"tests/default-model.e2e.ts",
"tests/github-ready-review.e2e.ts",
+ "tests/streaming-fence-highlight.e2e.ts",
"tests/declared-reasoning.e2e.ts",
"tests/onboarding-deepseek-config.e2e.ts",
"tests/onboarding-usable-provider.e2e.ts",
diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml
index 720136b270..e800ed34d7 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: c1c40e39710d46fae240f0b3281c36d660855010
-README.zh.md: 631936ad658c147923e5f80f2d7445cac93d6b36
+README.md: 3b7b37cf59aafc7292f75e9e4fb1513b93b32c1c
+README.zh.md: aee1a2ac842b34b284a0be5589b6dd801498e76b
diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md
index c1c40e3971..3b7b37cf59 100644
--- a/packages/client/ui-primitives/README.md
+++ b/packages/client/ui-primitives/README.md
@@ -14,7 +14,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P
## 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{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. 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. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. 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)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); a wide table's horizontal bar reveals on hover or keyboard focus (the wrapper carries `tabindex="0"`) instead of staying painted ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.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).
+`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{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. 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. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. 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)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); a wide table's horizontal bar reveals on hover or keyboard focus (the wrapper carries `tabindex="0"`) instead of staying painted ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.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); while a reply streams, a fence highlights incrementally as it grows — each chunk tokenizes newly completed text from the saved grammar state plus the still-growing last line, while completed lines keep their DOM — and the settled render swaps in shiki's HTML with an identical span tree ([decision record](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.md)).
## Terminal output
diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md
index 631936ad65..aee1a2ac84 100644
--- a/packages/client/ui-primitives/README.zh.md
+++ b/packages/client/ui-primitives/README.zh.md
@@ -14,7 +14,7 @@
## Markdown 渲染
-`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);宽表的横向滚动条在悬停或键盘聚焦(包裹层带 `tabindex="0"`)时才出现、不再常驻([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
+`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.zh.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);宽表的横向滚动条在悬停或键盘聚焦(包裹层带 `tabindex="0"`)时才出现、不再常驻([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki);回复流式输出期间,围栏随内容增长而增量高亮——每个分片从保存的 grammar state 出发 tokenize 新完成的文本以及仍在增长的最后一行,已完成的行保持其 DOM 不变——定稿渲染换入的 shiki HTML 具有完全一致的 span 树([决策记录](../../../.agents/notes/implemented/feature/2026-08-20-web-streaming-fence-highlight.zh.md))。
## 终端输出
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
index cd109874cb..ba9cd43392 100644
--- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -1,7 +1,11 @@
-import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
+import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
+import type { ReactNode } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
-import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
+import {
+ StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
+} from './highlight.ts'
+import type { HighlightSpan } from './highlight.ts'
import css from './CodeBlock.module.css'
export interface CodeBlockProps {
@@ -9,6 +13,14 @@ export interface CodeBlockProps {
code: string
/** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
lang?: string | undefined
+ /**
+ * The code is still growing (a streaming markdown fence): highlight through
+ * a per-instance {@link StreamingHighlightSession}, which re-tokenizes only
+ * appended text and keeps completed lines' elements (and DOM) untouched.
+ * The caller must keep the component instance stable across growth (a
+ * stream-stable React key); settled callers omit this and get shiki's HTML.
+ */
+ streaming?: boolean | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
/** Copy-button idle label; the owner passes localized copy (this package is cordis-free, so copy arrives via props). */
@@ -17,13 +29,61 @@ export interface CodeBlockProps {
copiedLabel: string
}
-export function CodeBlock({ code, lang, className, copyLabel, copiedLabel }: CodeBlockProps) {
+/**
+ * The `pre` attributes shiki's HTML arm emits for the css-variables theme,
+ * mirrored so the streaming arm's tree is interchangeable with the settled
+ * swap (`tests/streaming-code-block.client.spec.tsx` pins the two arms'
+ * parity).
+ */
+const SHIKI_PRE_PROPS = {
+ className: 'shiki css-variables',
+ style: { backgroundColor: 'var(--shiki-background)', color: 'var(--shiki-foreground)' },
+ tabIndex: 0,
+} as const
+
+export function CodeBlock({ code, lang, streaming, className, copyLabel, copiedLabel }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
// text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
- const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded])
+ const html = useMemo(
+ () => (streaming === true ? undefined : highlightToHtml(trimmed, lang)),
+ [streaming, trimmed, lang, loaded],
+ )
+ // Streaming state lives in refs mutated inside the memo (the MarkdownText
+ // streaming-cache pattern): the session's caches carry across chunks only
+ // because the owner keys this instance stably while the fence grows.
+ const sessionRef = useRef(null)
+ const lineCacheRef = useRef<{ lines: readonly HighlightSpan[][]; elements: ReactNode[] } | null>(null)
+ const streamedBody = useMemo(() => {
+ if (streaming !== true) {
+ sessionRef.current = null
+ lineCacheRef.current = null
+ return undefined
+ }
+ sessionRef.current ??= new StreamingHighlightSession()
+ const lines = sessionRef.current.update(trimmed, lang)
+ if (lines === undefined) {
+ lineCacheRef.current = null
+ return undefined
+ }
+ // A retained line keeps its span-array identity across chunks, so its
+ // cached element is reused and React leaves that line's DOM untouched.
+ const previous = lineCacheRef.current
+ const elements = lines.map((line, index) => previous !== null && previous.lines[index] === line
+ ? previous.elements[index]
+ : (
+
+ {index > 0 && '\n'}
+
+ {line.map((span, spanIndex) => {span.text})}
+
+
+ ))
+ lineCacheRef.current = { lines, elements }
+ return {elements}
+ }, [streaming, trimmed, lang, loaded])
const rootRef = useRef(null)
const [copied, setCopied] = useState(false)
@@ -39,16 +99,18 @@ export function CodeBlock({ code, lang, className, copyLabel, copiedLabel }: Cod
})
}, [copied, trimmed])
- const body = html === undefined
- ? (
- {trimmed}
- )
- : (
- // shiki's output is a static span tree it generated from `code` (no user
- // HTML passes through), the sanctioned innerHTML consumption path per
+ // shiki's HTML output is a static span tree it generated from `code` (no
+ // user HTML passes through), the sanctioned innerHTML consumption path per
// shiki's own docs.
-
- )
+ const body = streamedBody !== undefined
+ ? streamedBody
+ : html === undefined
+ ? (
+ {trimmed}
+ )
+ : (
+
+ )
return (
diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
index b4fc2d4678..3a26bac424 100644
--- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
+++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx
@@ -140,9 +140,10 @@ class StreamingRenderer {
/**
* 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) and parses incrementally across chunks; `labels`
- * forwards localized fence and footnote chrome — pass a
+ * `streaming` parses incrementally across chunks and highlights fences as
+ * they grow (each fence re-tokenizes only appended text; TeX stays literal
+ * until the finalize swap so incomplete formulae never flash errors);
+ * `labels` forwards localized fence and footnote chrome — pass a
* reference-stable object (memoized per locale revision), because a new
* identity discards the streaming render cache mid-message. `fileMentions`
* links inline-code tokens its resolver recognizes as real files; this is
diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts
index 16fb544dc0..047f9d1139 100644
--- a/packages/client/ui-primitives/src/markdown/highlight.ts
+++ b/packages/client/ui-primitives/src/markdown/highlight.ts
@@ -23,7 +23,7 @@ import { createJavaScriptRegexEngine, defaultJavaScriptRegexConstructor } from '
import langTs from '@shikijs/langs/typescript'
import langBash from '@shikijs/langs/shellscript'
import langJson from '@shikijs/langs/json'
-import type { HighlighterCore } from 'shiki/core'
+import type { GrammarState, HighlighterCore, ThemedToken } from 'shiki/core'
import type { CSSProperties } from 'react'
/** A shiki grammar module's default export (a `LanguageRegistration[]`), taken
@@ -278,6 +278,146 @@ export interface HighlightSpan {
style: CSSProperties
}
+/** vscode-textmate FontStyle bits shiki folds into `text-decoration` values. */
+const DECORATION_BITS: readonly (readonly [number, string])[] = [[4, 'underline'], [8, 'line-through']]
+
+/**
+ * The inline style shiki's HTML arm assigns one token (`getTokenStyleObject`
+ * mirrored onto React style keys): the css-variables color plus the
+ * vscode-textmate font-style bits the theme lets through — italic (1), bold
+ * (2), and the {@link DECORATION_BITS} decorations (the theme injects bold,
+ * italic, and underline rules for markup scopes, so markdown fences carry
+ * them). The theme has no per-scope backgrounds, so `background-color` never
+ * occurs; the arm-parity tests fail loud if a shiki upgrade changes that.
+ */
+function spanStyle(token: ThemedToken): CSSProperties {
+ const style: CSSProperties = { color: token.color }
+ /* v8 ignore next -- fontStyle is optional in ThemedToken's type; tokenizeWithTheme always stamps it. */
+ const bits = token.fontStyle ?? 0
+ if ((bits & 1) !== 0) style.fontStyle = 'italic'
+ if ((bits & 2) !== 0) style.fontWeight = 'bold'
+ const decorations = DECORATION_BITS.filter(([bit]) => (bits & bit) !== 0)
+ if (decorations.length > 0) style.textDecoration = decorations.map(([, value]) => value).join(' ')
+ return style
+}
+
+/**
+ * Narrow one tokenized line to the runs a `` renders, folding a
+ * whitespace-only run into the token that follows it — shiki's default
+ * `mergeWhitespaces` HTML behavior — with each run styled through
+ * {@link spanStyle}, so the streaming spans and the settled `codeToHtml`
+ * swap render one identical span tree. shiki exempts underlined/struck
+ * whitespace from the fold; under the css-variables theme that case cannot
+ * occur — its only underline rule styles inline-link scopes, whose spaced
+ * text tokenizes as one run, and it injects no strikethrough rule — so the
+ * unconditional fold here stays equivalent (the markdown arm-parity test
+ * pins it). A line-trailing whitespace-only run has no follower and keeps
+ * its own span, as in shiki.
+ */
+function lineSpans(line: ThemedToken[]): HighlightSpan[] {
+ const spans: HighlightSpan[] = []
+ let pendingWhitespace = ''
+ for (const [index, token] of line.entries()) {
+ if (/^\s+$/.test(token.content) && index + 1 < line.length) {
+ pendingWhitespace += token.content
+ continue
+ }
+ spans.push({ text: pendingWhitespace + token.content, style: spanStyle(token) })
+ pendingWhitespace = ''
+ }
+ return spans
+}
+
+/**
+ * Incremental highlighter for one growing streaming fence. TextMate
+ * tokenization is line-based and forward-only — a line's tokens depend only on
+ * its own text and the grammar state entering it — so appended text never
+ * changes a completed line's tokens. The session caches the spans of every
+ * completed line together with the grammar state after them; each
+ * {@link update} tokenizes newly completed text from that state, plus the
+ * still-growing last line. Per-call cost therefore excludes the completed
+ * prefix, and the result equals a from-scratch tokenization of the same code.
+ * Non-append input and a change of resolved grammar reset the cache and
+ * re-tokenize fully, so any input stays correct.
+ */
+export class StreamingHighlightSession {
+ /** Grammar id the cache was built with; a different resolution resets it. */
+ private resolved: string | undefined
+ /** Newline-terminated source prefix covered by {@link spans}. */
+ private prefix = ''
+ /** Cached spans, one entry per completed line of {@link prefix}. */
+ private spans: HighlightSpan[][] = []
+ /** Grammar state after {@link prefix}; undefined = the grammar's initial state. */
+ private state: GrammarState | undefined
+ private lastCode: string | undefined
+ private lastLang: string | undefined
+ private lastResult: HighlightSpan[][] | undefined
+
+ private reset(resolved: string | undefined): void {
+ this.resolved = resolved
+ this.prefix = ''
+ this.spans = []
+ this.state = undefined
+ }
+
+ /** Tokenize `text` with `resolved`, resuming from the cached grammar state when one exists. */
+ private tokenize(resolved: string, text: string): ThemedToken[][] {
+ return highlighter().codeToTokensBase(text, {
+ lang: resolved,
+ theme: 'css-variables',
+ ...(this.state === undefined ? {} : { grammarState: this.state }),
+ })
+ }
+
+ /**
+ * Tokenize the fence's current text into per-line highlighted runs;
+ * `undefined` means the caller renders its plain fallback. Idempotent per
+ * (`code`, `lang`) input — repeated calls return the identical result array —
+ * and a retained line keeps its span-array identity across growing calls, so
+ * a React caller can reuse cached line elements. A lazy grammar not yet
+ * loaded returns `undefined` and loads in the background exactly as
+ * {@link highlightToHtml} does; the next call after it registers highlights.
+ * @param code - the fence text accumulated so far (display-trimmed, no synthetic trailing newline).
+ * @param lang - the language hint (a markdown fence info string).
+ * @returns one entry per line of `code` (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
+ */
+ update(code: string, lang: string | undefined): readonly HighlightSpan[][] | undefined {
+ if (code === this.lastCode && lang === this.lastLang && this.lastResult !== undefined) {
+ return this.lastResult
+ }
+ this.lastCode = code
+ this.lastLang = lang
+ const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
+ if (resolved === undefined || !ensureGrammar(resolved)) {
+ this.reset(undefined)
+ this.lastResult = undefined
+ return undefined
+ }
+ if (resolved !== this.resolved || !code.startsWith(this.prefix)) this.reset(resolved)
+ const rest = code.slice(this.prefix.length)
+ const lastNewline = rest.lastIndexOf('\n')
+ // Everything before the last newline is newly completed lines: tokenize
+ // them once from the cached state and retain their spans. What follows is
+ // the still-growing line, re-tokenized per call but never retained.
+ if (lastNewline >= 0) {
+ // Tokenize what shiki's own line splitting would see: splitLines strips
+ // the \r of a \r\n terminator (interior pairs are shiki's to split), so
+ // a CRLF cut must not leak its \r into the last completed line — a bash
+ // continuation's grammar state, for example, differs with it.
+ const grownEnd = rest[lastNewline - 1] === '\r' ? lastNewline - 1 : lastNewline
+ const tokens = this.tokenize(resolved, rest.slice(0, grownEnd))
+ // Per-line push, not one spread call: a reconnect can deliver the whole
+ // accumulated fence as one update, and spreading tens of thousands of
+ // lines into arguments can exceed the engine's argument limit.
+ for (const line of tokens) this.spans.push(lineSpans(line))
+ this.state = highlighter().getLastGrammarState(tokens)
+ this.prefix = code.slice(0, this.prefix.length + lastNewline + 1)
+ }
+ this.lastResult = [...this.spans, ...this.tokenize(resolved, rest.slice(lastNewline + 1)).map(lineSpans)]
+ return this.lastResult
+ }
+}
+
/**
* Tokenize `code` into per-line highlighted runs when `lang` maps to a
* registered grammar; `undefined` means the caller renders its plain fallback.
@@ -286,8 +426,9 @@ export interface HighlightSpan {
* so this returns shiki's own 2D line/token structure narrowed to what a run
* renders. Each run's color is a `--shiki-*` custom property, keeping token
* colors on the theme package's sheets exactly as the HTML path does; the
- * css-variables theme carries no font-style bits, matching that path's
- * color-only output. The trailing newline shiki appends as a final empty line
+ * markup font-style bits the theme lets through (bold/italic/underline in
+ * markdown scopes) are dropped — the line-numbered file view renders
+ * color-only runs. The trailing newline shiki appends as a final empty line
* is dropped so the run count matches the caller's own line array.
* @param code - the source text.
* @param lang - the language hint (a file-extension-derived language id).
diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx
index 55a9dadc42..950d85be52 100644
--- a/packages/client/ui-primitives/src/markdown/render.tsx
+++ b/packages/client/ui-primitives/src/markdown/render.tsx
@@ -126,7 +126,7 @@ export interface MarkdownFileMentions {
* numbering accumulated in document order while references render.
*/
export interface MarkdownRenderContext {
- /** Streaming arm: fences render plain and TeX stays literal. */
+ /** Streaming arm: fences highlight incrementally as they grow; TeX (including ```math fences) stays literal until the settled pass. */
readonly streaming: boolean
/** Localized fence copy-button labels. */
readonly labels: MarkdownLabels
@@ -334,7 +334,13 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
// CodeBlock's display trim removes; feeding the bare value would make
// that trim eat a REAL trailing blank line inside the fence instead.
code={`${node.value}\n`}
- lang={context.streaming ? undefined : lang}
+ lang={lang}
+ // Streaming keys are source offsets, stable while the fence grows, so
+ // the CodeBlock instance (and its incremental highlight session)
+ // survives every chunk. A fence whose info string is still mid-chunk
+ // has no content yet and took the empty-fence arm above, so `lang`
+ // here is final: it can never re-resolve to a different grammar.
+ streaming={context.streaming}
copyLabel={context.labels.code.copyLabel}
copiedLabel={context.labels.code.copiedLabel}
/>
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
index 2047d32215..078279374b 100644
--- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
+++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/code-fences.streaming.txt
@@ -3,12 +3,25 @@