Explorar el Código

fix(web): render Thinking with compact Markdown

Tianyi Cui hace 5 días
padre
commit
e5038fcf9e
Se han modificado 37 ficheros con 754 adiciones y 75 borrados
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.i18n.yaml
  2. 29 0
      .agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md
  3. 29 0
      .agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.i18n.yaml
  5. 1 1
      .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md
  6. 1 1
      .agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml
  8. 1 0
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md
  9. 1 0
      .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md
  10. 61 0
      apps/web/tests/expected/thinking-markdown/ui.expected.md
  11. 37 1
      apps/web/tests/seeded-history.e2e.ts
  12. 208 0
      apps/web/tests/thinking-markdown.e2e.ts
  13. 1 0
      apps/web/tsconfig.json
  14. 2 2
      docs/web-styling.i18n.yaml
  15. 1 1
      docs/web-styling.md
  16. 1 1
      docs/web-styling.zh.md
  17. 2 2
      packages/client/ui-chat/README.i18n.yaml
  18. 1 1
      packages/client/ui-chat/README.md
  19. 1 1
      packages/client/ui-chat/README.zh.md
  20. 3 14
      packages/client/ui-chat/src/client/chat/ReasoningRow.module.css
  21. 10 6
      packages/client/ui-chat/src/client/chat/ReasoningRow.tsx
  22. 9 6
      packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts
  23. 57 3
      packages/client/ui-chat/tests/reasoning-row.client.spec.tsx
  24. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  25. 2 0
      packages/client/ui-primitives/README.md
  26. 2 0
      packages/client/ui-primitives/README.zh.md
  27. 129 2
      packages/client/ui-primitives/src/markdown/MarkdownText.module.css
  28. 10 3
      packages/client/ui-primitives/src/markdown/MarkdownText.tsx
  29. 73 0
      packages/client/ui-primitives/tests/markdown-compact.client.spec.tsx
  30. 2 2
      packages/client/ui-trajectory/README.i18n.yaml
  31. 2 0
      packages/client/ui-trajectory/README.md
  32. 2 0
      packages/client/ui-trajectory/README.zh.md
  33. 21 21
      packages/client/ui-trajectory/src/client/TrajectoryTable.module.css
  34. 4 1
      packages/client/ui-trajectory/src/client/TrajectoryTable.tsx
  35. 30 0
      packages/client/ui-trajectory/tests/table.client.spec.tsx
  36. 8 0
      snapshots/web/seeded-history/thinking-expanded.expected.md
  37. 1 0
      tsconfig.host.json

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.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/bug-fix/2026-09-17-thinking-markdown.md
+2026-09-17-thinking-markdown.md: 91ad8a3cbb4a0a2e0e4c931642cce7341f77a7df
+2026-09-17-thinking-markdown.zh.md: 0af53506795b8f597455a344043902708a5ec824

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md

@@ -0,0 +1,29 @@
+# Agent Note: Compact Markdown for Thinking
+
+Status: implemented
+
+English | [中文](2026-09-17-thinking-markdown.zh.md)
+
+## Problem
+
+Chat Thinking contains model-authored Markdown, but literal rendering exposes headings, emphasis markers, and code fences. The shared answer typography makes headings larger and more prominent than the secondary reasoning text. Trajectory applies that answer typography to the same reasoning.
+
+## Decision
+
+[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) owns a compact presentation variant used by Chat Thinking and Trajectory thinking details. It keeps the secondary content font size, line height, and tertiary color throughout. Heading levels retain semantic elements but share the same 600 weight and size; paragraphs, lists, quotes, and code use compact spacing. Links keep a resting dotted underline with tertiary color as a secondary-content exception to the [shared link styling](../feature/2026-09-04-web-clickable-link-styles.md); code retains its monospace font and background.
+
+Tables and formulas remain enabled through the existing parser. Their containers constrain horizontal overflow, and formula text inherits the secondary font size. Compact code banners stay in normal flow, keeping the [Thinking disclosure header](../feature/2026-08-03-web-sticky-collapsible-headers.md) above the scrolling content without a second sticky band.
+
+Chat passes its running state to the existing incremental Markdown renderer. The collapsed summary remains a separate single-line text projection. The parser, frozen-block cache, stored reasoning, and Session format do not change. Trajectory's answer output retains its existing typography and its [Thinking disclosure behavior](../feature/2026-09-09-ptc-trajectory-code-inspection.md).
+
+## Alternatives considered
+
+**Use answer typography unchanged.** Large headings and generous block spacing give reasoning more visual weight than the answer.
+
+**Style Markdown separately in each consumer.** Chat and Trajectory would need to track the same renderer elements independently; the primitive owns those elements and their compact presentation.
+
+**Disable tables and formulas.** The existing renderer already supports them. Bounded overflow and inherited font sizing preserve useful content without another parsing mode.
+
+## Consequences
+
+Thinking supports structured reading while retaining its secondary emphasis. Semantic headings remain available to assistive technology even though their visible hierarchy is flattened. The shared variant applies to both views; future Markdown element changes must preserve its typography as well as the default answer typography.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: Thinking 的紧凑 Markdown
+
+Status: implemented
+
+[English](2026-09-17-thinking-markdown.md) | 中文
+
+## 问题
+
+Chat Thinking 包含模型编写的 Markdown,但纯文本渲染直接显示标题、强调标记和代码围栏。共享回答排版会让标题比次级推理文本更大、更醒目。Trajectory 对同一推理内容使用了回答排版。
+
+## 决策
+
+[MarkdownText](../../../../packages/client/ui-primitives/src/markdown/MarkdownText.tsx) 拥有紧凑展示变体,供 Chat Thinking 和 Trajectory 思考详情使用。内容沿用次级字号、行高和 tertiary 颜色。各级标题保留语义元素,但统一使用 600 字重和相同字号;段落、列表、引用和代码采用紧凑间距。链接保留默认点状下划线与 tertiary 颜色,作为[共享链接样式](../feature/2026-09-04-web-clickable-link-styles.zh.md)的次级内容例外;代码保留等宽字体和底色。
+
+表格和公式继续通过既有解析器启用。其容器限制横向溢出,公式文本继承次级字号。紧凑代码栏保持正常文档流,让 [Thinking 折叠标题](../feature/2026-08-03-web-sticky-collapsible-headers.zh.md) 位于滚动内容上方,无需第二条 sticky 栏。
+
+Chat 将运行状态传给既有增量 Markdown 渲染器。折叠摘要仍是独立的单行文本投影。解析器、冻结块缓存、存储的推理内容和 Session 格式均保持不变。Trajectory 的回答输出保留既有排版及其 [Thinking 折叠行为](../feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。
+
+## 曾考虑的替代方案
+
+**直接使用回答排版。** 大标题和宽松块间距让推理比回答更醒目。
+
+**由每个使用方单独设置 Markdown 样式。** Chat 和 Trajectory 将需要分别跟踪相同的渲染元素;基元拥有这些元素及其紧凑展示。
+
+**禁用表格和公式。** 既有渲染器已支持二者。限制溢出和继承字号可以保留有用内容,无需增加另一种解析模式。
+
+## 后果
+
+Thinking 支持结构化阅读,同时保持次级强调。虽然视觉层级被统一,辅助技术仍可使用语义标题。共享变体同时用于两个视图;后续 Markdown 元素变更必须同时保留紧凑排版与默认回答排版。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.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-08-03-web-sticky-collapsible-headers.md
-2026-08-03-web-sticky-collapsible-headers.md: 6e8b4b94c9c6aa40bc7190e6705b84caa8189c52
-2026-08-03-web-sticky-collapsible-headers.zh.md: d4e1f69539db6870ae60d5c4b05cd52c7d129360
+2026-08-03-web-sticky-collapsible-headers.md: 5bbe91681e656a8278b9d65f12e20a4d9dc779d0
+2026-08-03-web-sticky-collapsible-headers.zh.md: c75846fa7f5ca1088dab530b63243553e50f9579

+ 1 - 1
.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.md

@@ -14,7 +14,7 @@ The disclosure header of each uncapped block sticks to the conversation scroll c
 
 Both blocks already scroll against the shared conversation scroll container (`[data-conversation-scroll]`), not an inner box, so `position: sticky; top: 0` on the header pins it against that container. A base-token background masks the prose that scrolls under the pinned header.
 
-The pinned header's stacking rank differs by block, because their bodies differ. The Think body is plain text with no sticky descendant, so `z-index: 1` suffices. The compaction body renders markdown, and a fenced code block in the summary pins its own banner at `z-index: 6` (`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`) with a Copy control inside it; the compaction header therefore uses `z-index: 7` and holds that banner below its own band, so the header never covers the Copy control and the banner never covers the toggle. The band's height is one component-local measurement (`--dsh-compaction-header-height` on `.compactionRow`) shared by the toggle's `height` and the banner's `top`; the banner rule wins over CodeBlock's `top: 0` by specificity, because the two sheets load in their own packages. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token and squares its corners for as long as the row is open: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle, and the base 6px radius would leave the same prose visible at the corners. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. Two other elements take `z-index: 7` to clear the same code banners: the composer seat and the turn-navigation rail slot (`TurnNavigator.module.css`, ui-chat). Their order among equal ranks is stated once, in the composer seat's comment (`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css`).
+The pinned header's stacking rank differs by block, because their bodies differ. The Think body uses [compact Markdown](../bug-fix/2026-09-17-thinking-markdown.md) with code banners in normal flow, so `z-index: 1` suffices. The compaction body renders markdown, and a fenced code block in the summary pins its own banner at `z-index: 6` (`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`) with a Copy control inside it; the compaction header therefore uses `z-index: 7` and holds that banner below its own band, so the header never covers the Copy control and the banner never covers the toggle. The band's height is one component-local measurement (`--dsh-compaction-header-height` on `.compactionRow`) shared by the toggle's `height` and the banner's `top`; the banner rule wins over CodeBlock's `top: 0` by specificity, because the two sheets load in their own packages. The pinned header also overrides its hover fill to the opaque `--dsw-alias-interactive-bg-hover-solid` token and squares its corners for as long as the row is open: the default translucent hover token would let the scrolling prose show through the moment the pointer lands on the toggle, and the base 6px radius would leave the same prose visible at the corners. `:hover` raises the rule's specificity above the later base hover rule, so declaration order does not decide the winner. Two other elements take `z-index: 7` to clear the same code banners: the composer seat and the turn-navigation rail slot (`TurnNavigator.module.css`, ui-chat). Their order among equal ranks is stated once, in the composer seat's comment (`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css`).
 
 The rules are scoped so only the two uncapped blocks are affected; the capped tool rows keep their existing behavior, since stacking sticky headers across a run of tool rows would pile them at the top. The Think rule is `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` `.root[data-expanded] [data-open] [data-disclosure-row]` — gated on `DisclosureRow`'s `data-open` so a collapsed Think row never sticks, and scoped under the Think row's own root so no tool-call variant is touched. The compaction rule is `packages/client/ui-chat/src/client/chat/MessageItem.module.css` `.compactionRow:has(.compactionBody) .compactionButton` — the body sibling exists in the DOM only while open, so `:has()` gates the stick on the open state.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-03-web-sticky-collapsible-headers.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 这两个块本来就是相对共享的会话滚动容器(`[data-conversation-scroll]`)滚动,而非某个内层框,所以在标题上加 `position: sticky; top: 0` 就把它钉在该容器上。一个 base token 背景遮住在钉住的标题下方滚过的正文。
 
-钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文是纯文本、无 sticky 后代,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`),banner 里带一个 Copy 控件;因此压缩标题用 `z-index: 7`,并让那个 banner 停在自己的标题带下方,这样标题永远不会盖住 Copy 控件,banner 也不会盖住折叠按钮。标题带高度是一个组件局部量(`.compactionRow` 上的 `--dsh-compaction-header-height`),由折叠按钮的 `height` 与 banner 的 `top` 共用;banner 规则靠特异性压过 CodeBlock 的 `top: 0`,因为两张样式表分属不同的包。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token,并在整行展开期间都保持直角:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出,而基础的 6px 圆角会让同样的正文在四角露出来。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。另外还有两个元素为了避开同一批代码 banner 取 `z-index: 7`:输入框座与轮次导航轨道槽(`TurnNavigator.module.css`,ui-chat)。同级之间的先后顺序只写在一处:`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css` 中输入框座的注释。
+钉住的标题的层叠级别按块而异,因为两者正文不同。Think 正文使用[紧凑 Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md),代码栏处于正常文档流中,`z-index: 1` 就够。压缩正文渲染 markdown,摘要里的围栏代码块会把自己的 banner 钉在 `z-index: 6`(`packages/client/ui-primitives/src/markdown/CodeBlock.module.css`),banner 里带一个 Copy 控件;因此压缩标题用 `z-index: 7`,并让那个 banner 停在自己的标题带下方,这样标题永远不会盖住 Copy 控件,banner 也不会盖住折叠按钮。标题带高度是一个组件局部量(`.compactionRow` 上的 `--dsh-compaction-header-height`),由折叠按钮的 `height` 与 banner 的 `top` 共用;banner 规则靠特异性压过 CodeBlock 的 `top: 0`,因为两张样式表分属不同的包。钉住的标题还把 hover 底覆盖为不透明的 `--dsw-alias-interactive-bg-hover-solid` token,并在整行展开期间都保持直角:默认的半透明 hover token 会在指针落到折叠按钮准备折叠的瞬间让滚动的正文透出,而基础的 6px 圆角会让同样的正文在四角露出来。`:hover` 把该规则的特异性抬到文件更靠后的 hover 基础规则之上,所以胜负不由声明顺序决定。另外还有两个元素为了避开同一批代码 banner 取 `z-index: 7`:输入框座与轮次导航轨道槽(`TurnNavigator.module.css`,ui-chat)。同级之间的先后顺序只写在一处:`packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css` 中输入框座的注释。
 
 规则被限定作用域,只影响这两个不封顶的块;封顶的工具行保持原有行为,因为让一连串工具行的 sticky 标题层层堆叠会把它们全挤在顶部。Think 规则是 `packages/client/ui-chat/src/client/chat/ReasoningRow.module.css` 的 `.root[data-expanded] [data-open] [data-disclosure-row]`,用 `DisclosureRow` 的 `data-open` 门控,折叠的 Think 行绝不钉住,并限定在 Think 行自己的 root 之下,不触及任何工具调用 variant。压缩规则是 `packages/client/ui-chat/src/client/chat/MessageItem.module.css` 的 `.compactionRow:has(.compactionBody) .compactionButton`,正文兄弟节点只在展开时存在于 DOM,所以 `:has()` 就以展开状态门控钉住。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.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-09-04-web-clickable-link-styles.md
-2026-09-04-web-clickable-link-styles.md: fc383387cede1517981358fd31a08f47c2d76c02
-2026-09-04-web-clickable-link-styles.zh.md: 7336a740e2ba93fe67a504745dcf770cab3c803d
+2026-09-04-web-clickable-link-styles.md: c4e506294c87c6b959cb449403edce09daa11062
+2026-09-04-web-clickable-link-styles.zh.md: 01258674b4b8194ad5e0597cde61cc2186406d49

+ 1 - 0
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md

@@ -15,6 +15,7 @@ One link language across the transcript's clickable-link surfaces — markdown a
 - Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus.
 - A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center.
 - Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show.
+- [Compact Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.md) retains tertiary text color and a resting dotted underline to preserve secondary emphasis.
 - Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style).
 - In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border.
 

+ 1 - 0
.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md

@@ -15,6 +15,7 @@ Status: implemented
 - 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。
 - 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。
 - 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。
+- [紧凑 Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md) 保留 tertiary 文字色和默认点状下划线,以保持次级强调。
 - 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。
 - 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。
 

+ 61 - 0
apps/web/tests/expected/thinking-markdown/ui.expected.md

@@ -0,0 +1,61 @@
+- button "Think" [expanded]:
+  - img
+  - text: Think
+- heading "Compact reasoning with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary" [level=2]
+- paragraph:
+  - text: A paragraph with
+  - strong: strong text
+  - text: ","
+  - emphasis: emphasis
+  - text: ","
+  - link "reference":
+    - /url: https://example.com/
+  - text: ", and"
+  - code: inline_code
+  - text: .
+- heading "Level 1" [level=1]
+- heading "Level 2" [level=2]
+- heading "Level 3" [level=3]
+- heading "Level 4" [level=4]
+- heading "Level 5" [level=5]
+- heading "Level 6" [level=6]
+- list:
+  - listitem: Unordered item
+  - listitem: Second item
+- list:
+  - listitem: Ordered item
+  - listitem: Another item
+- list:
+  - listitem:
+    - paragraph: Loose first paragraph.
+    - paragraph: Loose middle paragraph.
+    - paragraph: Loose last paragraph.
+- blockquote:
+  - paragraph: Quoted reasoning.
+- separator
+- table:
+  - rowgroup:
+    - row "First Second Third Fourth Fifth Sixth":
+      - columnheader "First"
+      - columnheader "Second"
+      - columnheader "Third"
+      - columnheader "Fourth"
+      - columnheader "Fifth"
+      - columnheader "Sixth"
+  - rowgroup:
+    - row "long_table_cell_0_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx long_table_cell_1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx long_table_cell_2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx long_table_cell_3_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx long_table_cell_4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx long_table_cell_5_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx":
+      - cell "long_table_cell_0_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+      - cell "long_table_cell_1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+      - cell "long_table_cell_2_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+      - cell "long_table_cell_3_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+      - cell "long_table_cell_4_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+      - cell "long_table_cell_5_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+- paragraph:
+  - text: "Inline math:"
+  - math: a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + z
+  - text: .
+- math: a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + 1 1 + 1 x
+- text: typescript
+- button "Copy"
+- code: const value = "reasoning code"
+- paragraph: {{longToken}}

+ 37 - 1
apps/web/tests/seeded-history.e2e.ts

@@ -36,6 +36,7 @@ const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history
 const UI_EXPANDED_EXPECTED = fileURLToPath(
   new URL('../../../snapshots/web/seeded-history/ui-expanded.expected.md', import.meta.url),
 )
+const THINKING_EXPECTED = join(SNAPSHOT_DIR, 'thinking-expanded.expected.md')
 // Command-row goldens over the same conversation after direct host commands.
 const COMMAND_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/command-row.expected.md', import.meta.url))
 const FEEDBACK_ROW_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/feedback-row.expected.md', import.meta.url))
@@ -367,6 +368,40 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
   })
 
+  it.skipIf(MODE === 'record')('renders recorded reasoning as secondary Markdown when expanded', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-thinking'))
+    const turnProcess = page.locator('[data-turn-process]').first()
+    const wasExpanded = await turnProcess.getAttribute('aria-expanded') === 'true'
+    if (!wasExpanded) await turnProcess.click()
+    const thinking = page.locator('[data-variant="think"]').first()
+    const toggle = thinking.getByRole('button').first()
+    const secondarySize = await thinking.locator('[class*="summaryText"]').evaluate(element => getComputedStyle(element).fontSize)
+    await toggle.click()
+    try {
+      await thinking.locator('p').waitFor({ timeout: 10_000 })
+      const snapshot = await captureStableAria(page, '[data-variant="think"][data-expanded]', scaffold.workspaceCwd)
+      const typography = await thinking.locator('p').evaluate((paragraph, expectedSize) => {
+        const style = getComputedStyle(paragraph)
+        return {
+          secondarySize: style.fontSize === expectedSize,
+          wraps: style.whiteSpace === 'normal',
+          contained: paragraph.scrollWidth <= paragraph.clientWidth + 1,
+        }
+      }, secondarySize)
+      expect(typography).toEqual({ secondarySize: true, wraps: true, contained: true })
+      await compareOrRefreshGolden(THINKING_EXPECTED, [
+        snapshot,
+        '',
+        `- Secondary font size: ${String(typography.secondarySize)}`,
+        `- Markdown paragraph wrapping: ${String(typography.wraps)}`,
+        `- Content stays within the reasoning column: ${String(typography.contained)}`,
+      ].join('\n'), MODE)
+    } finally {
+      await toggle.click()
+      if (!wasExpanded) await turnProcess.click()
+    }
+  })
+
   it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection'))
     const disclosure = page.getByRole('button', { name: 'Context injection AGENTS.md', exact: true })
@@ -780,7 +815,8 @@ describe('web e2e: seeded history renders through cold resume', () => {
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
       'command-row.expected.md', 'feedback-row.expected.md', 'file-preview.expected.md',
-      'session.v3.jsonl', 'sticky-geometry.expected.md', 'ui.expected.md', 'ui-expanded.expected.md',
+      'session.v3.jsonl', 'sticky-geometry.expected.md', 'thinking-expanded.expected.md',
+      'ui.expected.md', 'ui-expanded.expected.md',
     ])
   })
 })

+ 208 - 0
apps/web/tests/thinking-markdown.e2e.ts

@@ -0,0 +1,208 @@
+/** Expanded reasoning keeps Markdown semantics at the secondary typography tier. */
+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 { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
+import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-session-title'
+import {
+  assertFixtureInventory,
+  captureStableAria,
+  compareOrRefreshGolden,
+  launchWebScaffold,
+  seedSession,
+  watchConsole,
+  webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
+
+const EXPECTED_DIR = fileURLToPath(new URL('./expected/thinking-markdown', import.meta.url))
+const UI_EXPECTED = fileURLToPath(new URL('./expected/thinking-markdown/ui.expected.md', import.meta.url))
+const MODE = webSnapshotMode()
+const SEED_ID = 'thinking-markdown-web-e2e'
+const DONE = 'THINKING_MARKDOWN_DONE'
+const LONG_TOKEN = 'workspace/packages/client/secondary-markdown/'.repeat(12)
+
+/** Closed Session using semantic Markdown and content wider than the message column. */
+function thinkingFixture(): string {
+  const session = Session.create(SessionId('thinking-markdown-source'))
+  session.append('turn/start', { turn: 1 })
+  const user = session.append('user/message', createUserMessage({
+    content: [{ type: 'text', text: 'Show reasoning and a main answer.' }],
+    source: { kind: 'user' },
+  }), { surfaceOp: 'append' })
+  session.append('session/title', {
+    title: 'Thinking Markdown', messageSeqs: [user.seq], source: { kind: 'fallback' },
+  })
+  session.append('step/start', { turn: 1, step: 1 })
+  session.append('assistant/message', {
+    stream: [], turn: 1, step: 1,
+    message: createMessage({
+      role: 'assistant',
+      source: { kind: 'model', provider: 'fixture', model: 'fixture' },
+      content: [{
+        type: 'reasoning',
+        text: [
+          `## Compact reasoning ${'with a deliberately long summary '.repeat(8)}`,
+          '',
+          'A paragraph with **strong text**, *emphasis*, [reference](https://example.com/), and `inline_code`.',
+          '',
+          ...[1, 2, 3, 4, 5, 6].flatMap(level => [`${'#'.repeat(level)} Level ${String(level)}`, '']),
+          '- Unordered item',
+          '- Second item',
+          '',
+          '1. Ordered item',
+          '2. Another item',
+          '',
+          '- Loose first paragraph.',
+          '',
+          '  Loose middle paragraph.',
+          '',
+          '  Loose last paragraph.',
+          '',
+          '> Quoted reasoning.',
+          '',
+          '---',
+          '',
+          '| First | Second | Third | Fourth | Fifth | Sixth |',
+          '| --- | --- | --- | --- | --- | --- |',
+          `| ${Array.from({ length: 6 }, (_, index) => `long_table_cell_${String(index)}_${'x'.repeat(40)}`).join(' | ')} |`,
+          '',
+          `Inline math: $${'a+'.repeat(80)}z$.`,
+          '',
+          '$$',
+          `${'a+'.repeat(80)}\\frac{1}{1+\\frac{1}{x}}`,
+          '$$',
+          '',
+          '```typescript',
+          'const value = "reasoning code"',
+          '```',
+          '',
+          LONG_TOKEN,
+        ].join('\n'),
+      }, { type: 'text', text: `# Main answer\n\n${DONE}` }],
+    }),
+  }, { surfaceOp: 'append' })
+  session.append('step/end', { turn: 1, step: 1 })
+  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  return [JSON.stringify({
+    type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
+    createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
+  }), ...session.snapshotEvents().map(({ seq, time: _time, ...event }) => JSON.stringify({
+    ...event, seq, time: new Date().setHours(12, 0, 0, 0) + seq * 1_000,
+  })), ''].join('\n')
+}
+
+describe('web e2e: secondary Thinking Markdown', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    await seedSession(scaffold, thinkingFixture(), SEED_ID)
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await page.locator('[role="treeitem"]').first().click()
+    await page.locator('[role="treeitem"]').nth(1).click()
+    await page.getByText(DONE, { exact: true }).waitFor({ timeout: 15_000 })
+    await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
+    await page.waitForFunction(() => {
+      const frame = document.querySelector('[data-sidebar-collapsed]')
+      return frame !== null && getComputedStyle(frame).gridTemplateColumns.split(' ')[0] === '56px'
+        && frame.getAnimations().every(animation => animation.playState === 'finished' || animation.playState === 'idle')
+    }, undefined, { timeout: 10_000 })
+    await expandTurnProcesses(page)
+  })
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it.skipIf(MODE === 'record')('renders semantic blocks without promoting headings or overflowing the column', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-thinking-markdown'))
+    const thinking = page.locator('[data-variant="think"]')
+    const toggle = thinking.getByRole('button').first()
+    const summary = thinking.locator('[class*="summaryText"]')
+    const summaryStyle = await summary.evaluate((element) => {
+      const style = getComputedStyle(element)
+      return { fontSize: style.fontSize, lineHeight: style.lineHeight, color: style.color }
+    })
+    expect(await summary.evaluate(element => element.getBoundingClientRect().height))
+      .toBeLessThanOrEqual(Number.parseFloat(summaryStyle.lineHeight) + 1)
+    await toggle.click()
+    const markdown = thinking.locator('[data-markdown-variant="compact"]')
+    await markdown.locator('h1').waitFor({ timeout: 10_000 })
+    expect(await markdown.locator('h1,h2,h3,h4,h5,h6').count()).toBe(7)
+    expect(await markdown.locator('ul').count()).toBe(2)
+    expect(await markdown.locator('ol').count()).toBe(1)
+    expect(await markdown.locator('strong').textContent()).toBe('strong text')
+    expect(await markdown.locator('em').textContent()).toBe('emphasis')
+    expect(await markdown.getByRole('link', { name: 'reference' }).getAttribute('href')).toBe('https://example.com/')
+    expect(await markdown.locator('pre code').textContent()).toContain('const value = "reasoning code"')
+    expect(await markdown.locator('hr').count()).toBe(1)
+    expect(await markdown.locator('table').count()).toBe(1)
+    expect(await markdown.locator('.katex').count()).toBe(2)
+    await markdown.locator('pre').scrollIntoViewIfNeeded()
+    await expect.poll(() => markdown.locator('pre.shiki').count(), { timeout: 15_000 }).toBe(1)
+    const snapshot = (await captureStableAria(page, '[data-variant="think"][data-expanded]', scaffold.workspaceCwd))
+      .split(LONG_TOKEN).join('{{longToken}}')
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+
+    for (const width of [1680, 640]) {
+      await page.setViewportSize({ width, height: 1000 })
+      await page.evaluate(async () => { await document.fonts.ready })
+      const styles = await markdown.evaluate((root, secondary) => {
+        const elements = [...root.querySelectorAll<HTMLElement>('h1,h2,h3,h4,h5,h6,p,li,a,strong,em,pre,pre code,pre span,th,td,.katex')]
+        const scrollers = [...root.querySelectorAll<HTMLElement>('[class*="tableScroll"],.katex')]
+        const banner = root.querySelector('[data-code-block-banner]')?.parentElement
+        const loose = [...root.querySelectorAll('li p')].find(element => element.textContent === 'Loose middle paragraph.')
+        return {
+          secondarySize: elements.every(element => getComputedStyle(element).fontSize === secondary.fontSize),
+          secondaryLine: elements.every(element => getComputedStyle(element).lineHeight === secondary.lineHeight),
+          tertiaryColor: elements.every(element => getComputedStyle(element).color === secondary.color),
+          tertiaryMarkers: [...root.querySelectorAll('li')]
+            .every(element => getComputedStyle(element, '::marker').color === secondary.color),
+          contained: root.scrollWidth <= root.clientWidth + 1,
+          bannerStatic: banner !== undefined && banner !== null && getComputedStyle(banner).position === 'static',
+          looseSpacing: loose !== undefined && getComputedStyle(loose).marginTop === '4px' && getComputedStyle(loose).marginBottom === '4px',
+          wideContentBounded: scrollers.length === 3 && scrollers.every(element => element.clientWidth <= root.clientWidth + 1),
+          displayMathScrolls: [...root.querySelectorAll<HTMLElement>('.katex-display .katex')]
+            .every(element => element.scrollWidth > element.clientWidth),
+        }
+      }, summaryStyle)
+      expect(styles, `Thinking typography at ${String(width)}px`).toEqual({
+        secondarySize: true, secondaryLine: true, tertiaryColor: true, tertiaryMarkers: true,
+        contained: true, bannerStatic: true, looseSpacing: true,
+        wideContentBounded: true, displayMathScrolls: true,
+      })
+    }
+    const answerSize = await page.getByRole('heading', { name: 'Main answer', exact: true })
+      .evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))
+    expect(answerSize).toBeGreaterThan(Number.parseFloat(summaryStyle.fontSize))
+    await toggle.click()
+    expect(await summary.evaluate(element => element.getBoundingClientRect().height))
+      .toBeLessThanOrEqual(Number.parseFloat(summaryStyle.lineHeight) + 1)
+    expect(await summary.evaluate(element => getComputedStyle(element).textOverflow)).toBe('ellipsis')
+
+    await page.setViewportSize({ width: 1680, height: 1000 })
+    await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+    await page.locator('tr[data-trajectory-row-key]', { hasText: DONE }).click()
+    const details = page.getByRole('tabpanel')
+    const trajectoryToggle = details.getByRole('button', { name: 'Thinking', exact: true })
+    if (await trajectoryToggle.getAttribute('aria-expanded') !== 'true') await trajectoryToggle.click()
+    const trajectoryHeading = details.locator('[data-markdown-variant="compact"] h1')
+    await trajectoryHeading.waitFor({ timeout: 10_000 })
+    expect(await trajectoryHeading.evaluate(element => getComputedStyle(element).fontSize)).toBe(summaryStyle.fontSize)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await assertFixtureInventory(EXPECTED_DIR, ['ui.expected.md'])
+  })
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -88,6 +88,7 @@
     "tests/markdown-images.e2e.ts",
     "tests/reference-composer.e2e.ts",
     "tests/markdown-wide-table.e2e.ts",
+    "tests/thinking-markdown.e2e.ts",
     "tests/math-rendering.e2e.ts",
     "tests/markdown-cjk-strong.e2e.ts",
     "tests/markdown-inline-code-links.e2e.ts",

+ 2 - 2
docs/web-styling.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 docs/web-styling.md
-web-styling.md: c158562f0c862e883514c9d4057c87d4df65119e
-web-styling.zh.md: 7086bab6ff65c5a4415795aebd04da1e86e68c4f
+web-styling.md: 1be0b629d2ccf54c5a00cfc8091a01e3d4d09bfd
+web-styling.zh.md: 106880e964a399cba825e889b5e93048589d00e4

+ 1 - 1
docs/web-styling.md

@@ -23,7 +23,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi
 - Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing.
 - Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders.
 - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders.
-- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Text-leading anchors also lead with the ui-primitives `LinkIcon` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md), [known-site mark Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md)).
+- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md)). Text-leading anchors also lead with the ui-primitives `LinkIcon` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md), [known-site mark Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md)).
 
 ## Changing the system
 

+ 1 - 1
docs/web-styling.zh.md

@@ -23,7 +23,7 @@
 - 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。
 - 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。
 - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。
-- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。带文字的锚点另以 ui-primitives 的 `LinkIcon` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md)、[已知站点标记 Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md))。
+- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md))。带文字的锚点另以 ui-primitives 的 `LinkIcon` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md)、[已知站点标记 Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md))。
 
 ## 变更系统
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: c7c9fb7cb2a9a149fae9b27db78e019d821dfb95
-README.zh.md: 386d7aae91b157a7b09fcd0515bfd8d075a39638
+README.md: 9217ff678690f6eb659bb6b7c3a808d97a26171b
+README.zh.md: 1ac5159a466c61ddada5b55e62354d6bfb1e6652

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
packages/client/ui-chat/README.md


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 1 - 1
packages/client/ui-chat/README.zh.md


+ 3 - 14
packages/client/ui-chat/src/client/chat/ReasoningRow.module.css

@@ -13,15 +13,8 @@
   overflow: hidden;
 }
 
-/* Think's body flows uncapped with the page (unlike the capped tool cards,
-   which scroll inside their own card), so a long reasoning chain carries the
-   disclosure header off the top of the viewport and buries the collapse toggle.
-   Pin the header to the conversation scroll container's top while the row is
-   open so the toggle stays reachable; the base background masks the prose
-   scrolling under it. Gated on DisclosureRow's `data-open` so a collapsed row
-   scrolls away normally. The body is plain text with no sticky descendant, so
-   any positive z-index clears the flow; the compaction marker, whose markdown
-   body can pin a code-block banner, needs a higher rank. */
+/* The uncapped body scrolls with the conversation; keep its collapse toggle
+   reachable. Compact Markdown code banners stay in flow below this header. */
 .root[data-expanded] [data-open] [data-disclosure-row] {
   position: sticky;
   top: 0;
@@ -107,11 +100,7 @@
 .thinkBody {
   /* Indent under the title, which starts at leading (16 + delta) + gap 6. */
   padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px));
-  color: var(--dsw-alias-label-tertiary);
-  font-size: var(--dsh-content-font-size-secondary, 13px);
-  line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
-  white-space: pre-wrap;
-  word-break: break-word;
+  min-width: 0;
 }
 
 @media (prefers-reduced-motion: reduce) {

+ 10 - 6
packages/client/ui-chat/src/client/chat/ReasoningRow.tsx

@@ -1,7 +1,8 @@
 /** Assistant reasoning disclosure, independent of Tool-call presentation. */
-import { useState } from 'react'
-import { DisclosureRow, IconThinkOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import { useMemo, useState } from 'react'
+import { DisclosureRow, IconThinkOutline14, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
+import { markdownLabels } from '../markdown-labels.ts'
 import a11yCss from './accessibility.module.css'
 import css from './ReasoningRow.module.css'
 
@@ -18,15 +19,16 @@ function latestLine(text: string): string {
 
 /**
  * Render one assistant reasoning block collapsed until the reader opens it. The
- * collapsed summary omits double-asterisk markers; expanded content preserves
- * the complete text.
+ * collapsed summary omits double-asterisk markers; expanded content renders
+ * the complete Markdown with secondary typography.
  * @param props.text - complete or streaming reasoning text.
  * @param props.running - whether this block is the streaming tail.
- * @param props.t - conversation locale seat for the running status.
+ * @param props.t - conversation locale seat for status and Markdown actions.
  * @returns the reasoning disclosure.
  */
 export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) {
   const [expanded, setExpanded] = useState(false)
+  const labels = useMemo(() => markdownLabels(t), [t])
   const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '')
 
   return (
@@ -57,7 +59,9 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool
           </>
         )}
       >
-        <div className={css.thinkBody}>{text}</div>
+        <div className={css.thinkBody}>
+          <MarkdownText text={text} streaming={running} labels={labels} variant="compact" />
+        </div>
       </DisclosureRow>
     </div>
   )

+ 9 - 6
packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts

@@ -21,12 +21,15 @@ function declarationsFrom(source: string, selector: string): string[] {
 describe('chat flow font-size axis', () => {
   it('think text reads the secondary tier (one step under the body size)', () => {
     const css = read('ReasoningRow.module.css')
-    for (const selector of ['.summary', '.thinkBody']) {
-      expect(declarationsFrom(css, selector)).toEqual(expect.arrayContaining([
-        'font-size: var(--dsh-content-font-size-secondary, 13px)',
-        'line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px))',
-      ]))
-    }
+    expect(declarationsFrom(css, '.summary')).toEqual(expect.arrayContaining([
+      'font-size: var(--dsh-content-font-size-secondary, 13px)',
+      'line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px))',
+      'white-space: nowrap',
+    ]))
+    expect(declarationsFrom(css, '.summaryText')).toEqual(expect.arrayContaining([
+      'overflow: hidden',
+      'text-overflow: ellipsis',
+    ]))
   })
 
   it('command and context summaries read the secondary tier on the shared row line', () => {

+ 57 - 3
packages/client/ui-chat/tests/reasoning-row.client.spec.tsx

@@ -106,7 +106,7 @@ describe('ReasoningRow', () => {
       text: 'Inspect the session\n**Comparing checkout and merge bases**',
       streaming: true,
     },
-  ])('strips double-asterisk markers from the $label summary without changing the reasoning body', ({ text, streaming }) => {
+  ])('strips double-asterisk markers from the $label summary and renders body emphasis', ({ text, streaming }) => {
     const view = render(
       <AssistantMarkdown
         t={t}
@@ -120,10 +120,64 @@ describe('ReasoningRow', () => {
     expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull()
 
     fireEvent.click(view.getByText('思考'))
-    expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text)
+    expect(view.getByText('Comparing checkout and merge bases').tagName).toBe('STRONG')
+    expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).not.toContain('**')
   })
 
-  it('expanded Think drops the inline summary and renders plain prose, no IN card', () => {
+  it('keeps heading syntax in the collapsed summary and renders compact headings when expanded', () => {
+    const text = Array.from({ length: 6 }, (_, index) => `${'#'.repeat(index + 1)} Section ${index + 1}`)
+      .join('\n\n') + '\n\nReasoning body.'
+    const view = render(
+      <AssistantMarkdown
+        t={t}
+        blocks={[{ kind: 'reasoning', text }]}
+        streaming={false}
+        renderMessageImages={renderMessageImages}
+      />,
+    )
+    const summary = view.getByText('# Section 1')
+    expect(summary.tagName).toBe('SPAN')
+    expect(view.queryByRole('heading')).toBeNull()
+
+    fireEvent.click(summary)
+    const compact = view.container.querySelector('[data-markdown-variant="compact"]')
+    expect(compact).not.toBeNull()
+    expect(compact?.querySelectorAll('h1, h2, h3, h4, h5, h6')).toHaveLength(6)
+    expect(compact?.querySelector('p')?.textContent).toBe('Reasoning body.')
+
+    fireEvent.click(view.getByText('思考'))
+    expect(view.getByText('# Section 1').tagName).toBe('SPAN')
+    expect(view.queryByRole('heading')).toBeNull()
+  })
+
+  it('keeps completed reasoning blocks mounted while the open streaming tail grows', () => {
+    const first = '## Investigation\n\n**Check persistence**\n\n'
+    const view = render(
+      <AssistantMarkdown
+        t={t}
+        blocks={[{ kind: 'reasoning', text: first }]}
+        streaming
+        renderMessageImages={renderMessageImages}
+      />,
+    )
+    fireEvent.click(view.getByText('思考'))
+    const heading = view.getByRole('heading', { name: 'Investigation' })
+    const emphasis = view.getByText('Check persistence')
+    const text = first + Array.from({ length: 8 }, (_, index) => `Paragraph ${index}.`).join('\n\n')
+    view.rerender(
+      <AssistantMarkdown
+        t={t}
+        blocks={[{ kind: 'reasoning', text }]}
+        streaming
+        renderMessageImages={renderMessageImages}
+      />,
+    )
+    expect(view.getByRole('heading', { name: 'Investigation' })).toBe(heading)
+    expect(view.getByText('Check persistence')).toBe(emphasis)
+    expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).not.toContain('##')
+  })
+
+  it('expanded Think drops the inline summary and renders prose without an IN card', () => {
     const view = render(
       <AssistantMarkdown
         t={t}

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 6863c40d7d5111bb1db7e71436a795eac462e0bc
-README.zh.md: 28beb2ecb36eca6c8c759b97b600ef1496bbcbe4
+README.md: 2abb32906841f0d112896acb242d0e4ab0557856
+README.zh.md: 340d239cac3a72f9769a29f3f772794f4ca9fa7f

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

@@ -74,6 +74,8 @@ The catalog above lists what each export is for; this section covers the behavio
 
 `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
 
+`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.
+
 `DiffBlock` compares the old and new content by line. It shows actual additions and deletions with up to three neutral context lines on each side, separates distant changes with `⋯`, and excludes shared context from both summary and footer totals. Search stops beyond 256 line additions/deletions per fragment; those fragments display and count the complete old and new contents as a coarse replacement, including shared lines. Copy includes the full displayed diff with its prefixes. A final newline is treated as a terminator; differences only in the presence of a final newline are not displayed.
 
 `JsonTree` clamps collapsed strings to `collapsedStringLines` (three by default). Expanded strings show raw text, retain sibling commas, and fit within the window and outer scrolling containers. Resize and ancestor-scroll events update that limit. Row copy feedback updates independently of JSON value rendering; pending clipboard writes cannot update a different row or an unmounted tree.

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

@@ -74,6 +74,8 @@ kind: "package-library"
 
 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 
+`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
+
 `DiffBlock` 按行比较新旧内容。它显示实际增删行及两侧最多三行中性上下文,用 `⋯` 分隔远距离改动,摘要和底部统计都不计入共享上下文。若一个片段需要超过 256 次行新增或删除,则停止精确比较;该片段按完整新旧内容显示和统计为粗粒度替换,包含共享行。复制包含完整显示 diff 及其前缀。末尾换行视为行终止符;仅末尾换行不同不会显示为改动。
 
 `JsonTree` 把折叠字符串限制为 `collapsedStringLines` 行(默认三行)。展开后显示原始文本、保留同级逗号,并限制在窗口与外层滚动容器内;尺寸变化和祖先滚动事件会更新此限制。行复制反馈独立于 JSON 值渲染更新;尚未完成的剪贴板写入不会更新另一行或已卸载的树。

+ 129 - 2
packages/client/ui-primitives/src/markdown/MarkdownText.module.css

@@ -1,6 +1,6 @@
 /* Visual baseline: deepsuite `@deepseek/md` markdown.css, adapted to CSS
-   Modules. Cite pills, header anchors, and thinking-small variants are
-   intentionally absent (no matching DOM). Token names match that sheet. */
+   Modules. Cite pills and header anchors are absent (no matching DOM).
+   Token names match that sheet. */
 
 .markdown {
   min-width: 0;
@@ -320,3 +320,130 @@
   text-decoration: underline dotted var(--dsw-alias-link);
   text-underline-offset: 3px;
 }
+
+.compact {
+  max-width: 100%;
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.compact :is(h1, h2, h3, h4, h5, h6) {
+  margin: 8px 0 4px;
+  font: inherit;
+  font-weight: 600;
+  color: inherit;
+}
+
+.compact :is(p, ul, ol, blockquote) {
+  margin: 4px 0;
+}
+
+.compact li:not(:first-child) {
+  margin-top: 2px;
+}
+
+.compact li > p {
+  margin: 4px 0;
+}
+
+.compact li::marker {
+  line-height: inherit;
+  color: inherit;
+}
+
+.compact blockquote {
+  padding-left: 8px;
+}
+
+.compact hr {
+  margin: 8px 0;
+}
+
+.compact a,
+.compact .fileMention {
+  font-weight: inherit;
+  color: inherit;
+  text-decoration: underline dotted;
+}
+
+.compact a:hover,
+.compact a:focus,
+.compact .fileMention:hover,
+.compact .fileMention:focus {
+  text-decoration-color: currentColor;
+}
+
+.compact :not(pre) > code {
+  display: inline;
+  font: inherit;
+  font-family: var(--ds-font-family-code);
+  font-size: 1em !important;
+  border-radius: 4px;
+  padding: 0 3px;
+  box-decoration-break: clone;
+}
+
+.markdown.compact :global(.md-code-block) {
+  margin: 4px 0;
+  color: inherit;
+}
+
+.compact :global(.md-code-block) > :has(> [data-code-block-banner]) {
+  position: static;
+  z-index: auto;
+}
+
+.compact [data-code-block-banner] {
+  padding: 4px 8px;
+  font: inherit;
+}
+
+.compact [data-code-block-banner] > :first-child {
+  font-size: inherit;
+  line-height: inherit;
+  color: inherit;
+}
+
+.compact pre {
+  margin: 4px 0;
+  padding: 8px;
+  font-size: inherit;
+  line-height: inherit;
+}
+
+/* Shiki writes token colors inline; compact code uses the surrounding text color. */
+.compact pre,
+.compact pre :is(code, span) {
+  color: inherit !important;
+}
+
+.markdown.compact .tableScroll {
+  width: 100%;
+  max-width: 100%;
+  margin: 4px 0;
+  padding-left: 0;
+}
+
+.compact :is(th, td) {
+  padding: 4px 8px;
+  font: inherit;
+}
+
+.compact th {
+  font-weight: 600;
+}
+
+.compact :global(.katex) {
+  display: inline-block;
+  max-width: 100%;
+  overflow-x: auto;
+  overflow-y: hidden;
+  font-size: 1em;
+  line-height: inherit;
+  vertical-align: middle;
+}
+
+.compact :global(.katex-display) {
+  margin: 4px 0;
+}

+ 10 - 3
packages/client/ui-primitives/src/markdown/MarkdownText.tsx

@@ -13,6 +13,7 @@
 
 import { memo, useMemo, useRef } from 'react'
 import type { ReactNode } from 'react'
+import clsx from 'clsx'
 import { IncrementalMarkdownParser } from './incremental.ts'
 import { parseGfm, parseGfmWithMath } from './parse.ts'
 import {
@@ -159,17 +160,22 @@ class StreamingRenderer {
  * displayable URLs its resolver vouches for; both vocabularies are the
  * single streaming gate — they apply to settled renders only, because a
  * streaming message's vocabulary is not final and frozen cached elements
- * must not bake in handlers that could go stale.
+ * must not bake in handlers that could go stale. `variant="compact"` uses
+ * secondary text sizing, uniform bold headings, and tight block spacing;
+ * the default `body` variant uses the full document typography.
  * @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 const MarkdownText = memo(function MarkdownText({ text, streaming = false, labels, fileMentions, pathImages }: {
+export const MarkdownText = memo(function MarkdownText({
+  text, streaming = false, labels, fileMentions, pathImages, variant = 'body',
+}: {
   text: string
   streaming?: boolean
   labels: MarkdownLabels
   fileMentions?: MarkdownFileMentions | undefined
   pathImages?: MarkdownPathImages | undefined
+  variant?: 'body' | 'compact'
 }) {
   const streamRef = useRef<StreamingRenderer | null>(null)
   const streamLabelsRef = useRef<MarkdownLabels>(labels)
@@ -184,5 +190,6 @@ export const MarkdownText = memo(function MarkdownText({ text, streaming = false
     }
     return streamRef.current.render(text)
   }, [text, streaming, labels, fileMentions, pathImages])
-  return <div className={css.markdown}>{children}</div>
+  return <div className={clsx(css.markdown, variant === 'compact' && css.compact)}
+    data-markdown-variant={variant === 'compact' ? variant : undefined}>{children}</div>
 })

+ 73 - 0
packages/client/ui-primitives/tests/markdown-compact.client.spec.tsx

@@ -0,0 +1,73 @@
+// @vitest-environment jsdom
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { MarkdownText } from './markdown-test-components.tsx'
+
+afterEach(cleanup)
+
+describe('compact MarkdownText', () => {
+  it('retains the body variant semantics for headings, lists, quotes, links, code, tables, and math', () => {
+    const text = [
+      '# First', '## Second', '### Third', '#### Fourth', '##### Fifth', '###### Sixth',
+      'Paragraph with **strong** and `inline`.',
+      '- one\n- two\n  - nested',
+      '1. ordered\n2. next',
+      '> quoted',
+      '[reference](https://example.com)',
+      '```ts\nconst answer = 42\n```',
+      '---',
+      '| Name | Value |\n| --- | --- |\n| answer | 42 |',
+      '$x^2$',
+      '$$\nx + y = z\n$$',
+    ].join('\n\n')
+    const body = render(<MarkdownText text={text} />)
+    const compact = render(<MarkdownText text={text} variant="compact" />)
+    const bodyRoot = body.container.firstElementChild!
+    const compactRoot = compact.container.firstElementChild!
+
+    expect(bodyRoot.hasAttribute('data-markdown-variant')).toBe(false)
+    expect(compactRoot.getAttribute('data-markdown-variant')).toBe('compact')
+    expect(compactRoot.innerHTML).toBe(bodyRoot.innerHTML)
+    expect(compactRoot.querySelectorAll('h1, h2, h3, h4, h5, h6')).toHaveLength(6)
+    expect(compactRoot.querySelectorAll('ul')).toHaveLength(2)
+    expect(compactRoot.querySelector('ol')).not.toBeNull()
+    expect(compactRoot.querySelector('strong')?.textContent).toBe('strong')
+    expect(compactRoot.querySelector('p > code')?.textContent).toBe('inline')
+    expect(compactRoot.querySelector('blockquote')?.textContent?.trim()).toBe('quoted')
+    expect(compactRoot.querySelector('a')?.getAttribute('href')).toBe('https://example.com')
+    expect(compactRoot.querySelector('pre code')?.textContent).toBe('const answer = 42')
+    expect(compactRoot.querySelector('hr')).not.toBeNull()
+    expect(compactRoot.querySelector('table')).not.toBeNull()
+    expect(compactRoot.querySelectorAll('.katex')).toHaveLength(2)
+  })
+
+  it('keeps frozen blocks and highlighted lines mounted across growth and variant changes', () => {
+    const prefix = '# Heading\n\n**Completed** paragraph.\n\n- First\n\n'
+    const initial = `${prefix}\`\`\`ts\nconst first = 1\nlet tail`
+    const grown = `${initial} = 2\n\`\`\``
+    const live = render(<MarkdownText text={initial} streaming variant="compact" />)
+    const heading = live.container.querySelector('h1')
+    const paragraph = live.container.querySelector('p')
+    const firstLine = live.container.querySelector('pre .line')
+
+    expect(heading?.textContent).toBe('Heading')
+    expect(paragraph?.querySelector('strong')?.textContent).toBe('Completed')
+    expect(firstLine?.textContent).toBe('const first = 1')
+
+    live.rerender(<MarkdownText text={grown} streaming variant="compact" />)
+    expect(live.container.querySelector('h1')).toBe(heading)
+    expect(live.container.querySelector('p')).toBe(paragraph)
+    expect(live.container.querySelector('pre .line')).toBe(firstLine)
+    expect(live.container.querySelector('pre code')?.textContent).toBe('const first = 1\nlet tail = 2')
+
+    live.rerender(<MarkdownText text={grown} streaming variant="body" />)
+    expect(live.container.firstElementChild?.hasAttribute('data-markdown-variant')).toBe(false)
+    expect(live.container.querySelector('h1')).toBe(heading)
+    expect(live.container.querySelector('pre .line')).toBe(firstLine)
+
+    live.rerender(<MarkdownText text={grown} variant="compact" />)
+    expect(live.container.querySelector('h1')).toBe(heading)
+    expect(live.container.querySelector('pre .line')).toBe(firstLine)
+    expect(live.container.textContent).not.toContain('**Completed**')
+  })
+})

+ 2 - 2
packages/client/ui-trajectory/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-trajectory/README.md
-README.md: 63bd664b902d8d40f8a05153770306d3b03dbe4e
-README.zh.md: 89db03a99ac68065211f92ff7e47307813c1ab76
+README.md: e55816c94298bdb8a6dbdcbb54fd350cdc60ea68
+README.zh.md: 875f67ed8c584e713ff692dcc1d304a0100b23f4

+ 2 - 0
packages/client/ui-trajectory/README.md

@@ -33,6 +33,8 @@ Calls are identified by their recorded tool name. For `run_code`, the row shows
 
 Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn.
 
+Thinking in the inspector uses compact Markdown with secondary typography; headings add bold weight without increasing size or line height. Assistant output keeps its regular Markdown typography.
+
 ### The timing overview
 
 Historical replies retain TTFT, generation duration, and throughput when their recorded streams contain token timestamps. TTFT measures from the Step start to its first token, including output from an earlier retry attempt; an unloaded Step start or a stream without tokens leaves the corresponding metric unavailable.

+ 2 - 0
packages/client/ui-trajectory/README.zh.md

@@ -33,6 +33,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间
 
 选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。
 
+检查器中的思考内容使用次级排版的紧凑 Markdown;标题只加粗,不放大字号或行高。助手输出保留常规 Markdown 排版。
+
 ### 时间概览
 
 当记录的流包含 token 时间戳时,历史回复会保留 TTFT、生成耗时与吞吐量。TTFT 从步骤开始计量到首个 token,包括更早重试尝试中的输出;步骤起点尚未加载或流中没有 token 时,对应指标保持不可用。

+ 21 - 21
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css

@@ -1423,70 +1423,70 @@
   color: var(--dsw-alias-label-primary);
 }
 
-.markdownPreview > div,
-.markdownPayload > div {
+.markdownPreview > div:not([data-markdown-variant='compact']),
+.markdownPayload > div:not([data-markdown-variant='compact']) {
   gap: 8px;
   font: var(--dsw-font-xs-13);
 }
 
-.markdownPreview > div h1,
-.markdownPayload > div h1 {
+.markdownPreview > div:not([data-markdown-variant='compact']) h1,
+.markdownPayload > div:not([data-markdown-variant='compact']) h1 {
   font: 600 16px/22px var(--dsw-font-family);
 }
 
-.markdownPreview > div h2,
-.markdownPayload > div h2 {
+.markdownPreview > div:not([data-markdown-variant='compact']) h2,
+.markdownPayload > div:not([data-markdown-variant='compact']) h2 {
   font: 600 15px/22px var(--dsw-font-family);
 }
 
-.markdownPreview > div :where(h3, h4, h5, h6),
-.markdownPayload > div :where(h3, h4, h5, h6) {
+.markdownPreview > div:not([data-markdown-variant='compact']) :where(h3, h4, h5, h6),
+.markdownPayload > div:not([data-markdown-variant='compact']) :where(h3, h4, h5, h6) {
   font: 600 14px/20px var(--dsw-font-family);
 }
 
-.markdownPreview > div :not(pre) > code,
-.markdownPayload > div :not(pre) > code {
+.markdownPreview > div:not([data-markdown-variant='compact']) :not(pre) > code,
+.markdownPayload > div:not([data-markdown-variant='compact']) :not(pre) > code {
   font: 12px/18px var(--ds-font-family-code);
 }
 
-.markdownPreview > div table,
-.markdownPayload > div table {
+.markdownPreview > div:not([data-markdown-variant='compact']) table,
+.markdownPayload > div:not([data-markdown-variant='compact']) table {
   font: var(--dsw-font-xs-13);
 }
 
-.markdownPreview > div > :first-child {
+.markdownPreview > div:not([data-markdown-variant='compact']) > :first-child {
   margin-top: 0;
 }
 
-.markdownPreview > div > :last-child {
+.markdownPreview > div:not([data-markdown-variant='compact']) > :last-child {
   margin-bottom: 0;
 }
 
-.markdownPreview > div :where(h1, h2, h3) {
+.markdownPreview > div:not([data-markdown-variant='compact']) :where(h1, h2, h3) {
   margin: 12px 0 6px;
 }
 
-.markdownPreview > div :where(h4, h5, h6) {
+.markdownPreview > div:not([data-markdown-variant='compact']) :where(h4, h5, h6) {
   margin: 10px 0 5px;
 }
 
-.markdownPreview > div :where(p, ul, ol) {
+.markdownPreview > div:not([data-markdown-variant='compact']) :where(p, ul, ol) {
   margin: 8px 0;
 }
 
-.markdownPreview > div li:not(:first-child) {
+.markdownPreview > div:not([data-markdown-variant='compact']) li:not(:first-child) {
   margin-top: 3px;
 }
 
-.markdownPreview > div blockquote {
+.markdownPreview > div:not([data-markdown-variant='compact']) blockquote {
   margin-top: 8px;
 }
 
-.markdownPreview > div hr {
+.markdownPreview > div:not([data-markdown-variant='compact']) hr {
   margin: 14px 0;
 }
 
-.markdownPreview > div > :global(.md-code-block) {
+.markdownPreview > div:not([data-markdown-variant='compact']) > :global(.md-code-block) {
   margin: 10px 0;
 }
 

+ 4 - 1
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -1130,18 +1130,20 @@ function MarkdownFragment({
   text,
   rendered,
   preview,
+  variant = 'body',
   t,
 }: {
   text: string
   rendered: boolean
   preview: boolean
+  variant?: 'body' | 'compact'
   t: TrajectoryTranslate
 }) {
   const labels = useMemo(() => markdownLabels(t), [t])
   if (rendered) {
     return (
       <div className={preview ? css.markdownPreview : css.markdownPayload}>
-        <MarkdownText text={text} labels={labels} />
+        <MarkdownText text={text} labels={labels} variant={variant} />
       </div>
     )
   }
@@ -1521,6 +1523,7 @@ function MarkdownRecordContent({
               text={record.cell.thinkingDetail}
               rendered={rendered}
               preview={preview}
+              variant="compact"
               t={t}
             />
           )}

+ 30 - 0
packages/client/ui-trajectory/tests/table.client.spec.tsx

@@ -273,6 +273,36 @@ describe('TrajectoryTable', () => {
     expect(screen.getByRole('button', { name: 'Thinking' }).getAttribute('aria-expanded')).toBe('true')
   })
 
+  it('renders thinking as compact Markdown while keeping answer typography separate', () => {
+    const turns: readonly TrajectoryTurnModel[] = [{
+      turn: 1,
+      groups: [{
+        title: 'Step 1',
+        cells: [{
+          index: 1,
+          kind: 'message',
+          text: 'Answer',
+          outputDetail: '# Answer heading\n\nAnswer body.',
+          thinkingDetail: '# Thinking heading\n\nReasoning **emphasis**.',
+          timeSeconds: 1,
+        }],
+      }],
+    }]
+    render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
+    fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
+    fireEvent.click(screen.getByRole('tab', { name: 'Preview' }))
+
+    expect(screen.getByRole('heading', { name: 'Thinking heading' })
+      .closest('[data-markdown-variant="compact"]')).not.toBeNull()
+    expect(screen.getByText('emphasis').tagName).toBe('STRONG')
+    expect(screen.getByRole('heading', { name: 'Answer heading' })
+      .closest('[data-markdown-variant="compact"]')).toBeNull()
+
+    fireEvent.click(screen.getByRole('button', { name: 'Thinking' }))
+    expect(screen.queryByRole('heading', { name: 'Thinking heading' })).toBeNull()
+    expect(screen.getByRole('heading', { name: 'Answer heading' })).toBeTruthy()
+  })
+
   it('opens thinking on another record after collapsing the selected record', () => {
     const turns: readonly TrajectoryTurnModel[] = [{
       turn: 1,

+ 8 - 0
snapshots/web/seeded-history/thinking-expanded.expected.md

@@ -0,0 +1,8 @@
+- button "Think" [expanded]:
+  - img
+  - text: Think
+- paragraph: The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
+
+- Secondary font size: true
+- Markdown paragraph wrapping: true
+- Content stays within the reasoning column: true

+ 1 - 0
tsconfig.host.json

@@ -79,6 +79,7 @@
     "apps/web/tests/markdown-images.e2e.ts",
     "apps/web/tests/reference-composer.e2e.ts",
     "apps/web/tests/markdown-wide-table.e2e.ts",
+    "apps/web/tests/thinking-markdown.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",

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio