Ver código fonte

feat(ui-conversation): fold turn process before final answer (#2547)

* feat(ui-conversation): fold turn process before final answer

* fix(ui-chat): polish turn-process control row from review

* test(web): drive preset slash catalog with gestures

* fix(ui-chat): keep turn process order stable

* fix(ui-chat): preserve prompt order after pagination

Co-authored-by: Yif <877193178@qq.com>
lsdsjy 1 mês atrás
pai
commit
8b09a0be52
100 arquivos alterados com 3564 adições e 219 exclusões
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.i18n.yaml
  2. 29 0
      .agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md
  3. 29 0
      .agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.i18n.yaml
  5. 16 0
      .agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md
  6. 43 0
      .agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md
  7. 8 4
      apps/web/tests/chat-continuous-conversation.e2e.ts
  8. 2 1
      apps/web/tests/chat-long-interactions.e2e.ts
  9. 12 3
      apps/web/tests/chat-scroll-contract.e2e.ts
  10. 10 4
      apps/web/tests/code-mode-round.e2e.ts
  11. 1 1
      apps/web/tests/composer-tab-geometry.e2e.ts
  12. 5 1
      apps/web/tests/cordis-tool-round.e2e.ts
  13. 56 0
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  14. 2 7
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  15. 5 1
      apps/web/tests/expected/settings-chrome/dialog-en.expected.md
  16. 5 1
      apps/web/tests/expected/settings-chrome/dialog.expected.md
  17. 49 0
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  18. 2 7
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  19. 59 0
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  20. 2 11
      apps/web/tests/expected/steer-all/settled.expected.md
  21. 12 2
      apps/web/tests/feedback-command.e2e.ts
  22. 10 2
      apps/web/tests/feedback-release.e2e.ts
  23. 10 0
      apps/web/tests/github-ready-review.e2e.ts
  24. 17 6
      apps/web/tests/goal-multi-turn-actions.e2e.ts
  25. 12 2
      apps/web/tests/lifecycle-chrome.e2e.ts
  26. 19 3
      apps/web/tests/live-interactions.e2e.ts
  27. 6 0
      apps/web/tests/minimal-preset.snapshot.ts
  28. 3 1
      apps/web/tests/navigation-panes.e2e.ts
  29. 11 2
      apps/web/tests/plan-review.e2e.ts
  30. 13 5
      apps/web/tests/question-composer.e2e.ts
  31. 12 2
      apps/web/tests/queue-actions.e2e.ts
  32. 18 3
      apps/web/tests/replay-round-trip.e2e.ts
  33. 33 0
      apps/web/tests/scaffold.ts
  34. 25 3
      apps/web/tests/seeded-history.e2e.ts
  35. 30 0
      apps/web/tests/settings-chrome.e2e.ts
  36. 4 2
      apps/web/tests/skill-tool-row.e2e.ts
  37. 20 9
      apps/web/tests/skill-user-invoke.e2e.ts
  38. 21 3
      apps/web/tests/steering.e2e.ts
  39. 12 2
      apps/web/tests/subagent-conversation.e2e.ts
  40. 30 1
      apps/web/tests/support.ts
  41. 94 7
      apps/web/tests/turn-tail-actions.e2e.ts
  42. 6 2
      apps/web/tests/web-search-round.e2e.ts
  43. 3 1
      apps/web/tests/workflow-run.e2e.ts
  44. 2 2
      docs/module-graph.i18n.yaml
  45. 3 1
      docs/module-graph.md
  46. 3 1
      docs/module-graph.zh.md
  47. 2 2
      packages/client/ui-chat/README.i18n.yaml
  48. 4 0
      packages/client/ui-chat/README.md
  49. 4 0
      packages/client/ui-chat/README.zh.md
  50. 8 0
      packages/client/ui-chat/package.json
  51. 29 0
      packages/client/ui-chat/src/chat-settings.ts
  52. 20 1
      packages/client/ui-chat/src/client/apply.ts
  53. 7 0
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css
  54. 27 2
      packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx
  55. 10 2
      packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx
  56. 162 5
      packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx
  57. 14 1
      packages/client/ui-chat/src/client/chat/ChatView.module.css
  58. 16 7
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  59. 48 0
      packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css
  60. 60 0
      packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx
  61. 4 1
      packages/client/ui-chat/src/client/chat/register-node-renderers.ts
  62. 31 0
      packages/client/ui-chat/src/client/chat/searchable-hidden.ts
  63. 15 0
      packages/client/ui-chat/src/client/contract/assistant-content.ts
  64. 13 0
      packages/client/ui-chat/src/client/contract/chat-nodes.ts
  65. 17 0
      packages/client/ui-chat/src/client/contract/slots.ts
  66. 9 0
      packages/client/ui-chat/src/client/contract/store.ts
  67. 100 0
      packages/client/ui-chat/src/client/contract/turn-process.ts
  68. 99 6
      packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts
  69. 1 0
      packages/client/ui-chat/src/client/conversation-nodes/common.ts
  70. 2 0
      packages/client/ui-chat/src/client/conversation-nodes/register.ts
  71. 22 3
      packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts
  72. 283 0
      packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts
  73. 10 3
      packages/client/ui-chat/src/client/index.ts
  74. 24 0
      packages/client/ui-chat/src/client/locale.ts
  75. 56 0
      packages/client/ui-chat/src/client/settings/TranscriptViewRow.module.css
  76. 79 0
      packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx
  77. 32 2
      packages/client/ui-chat/src/client/stores.ts
  78. 39 0
      packages/client/ui-chat/src/client/transcript-view.ts
  79. 19 3
      packages/client/ui-chat/src/index.ts
  80. 2 2
      packages/client/ui-chat/tests/approval-command.client.spec.tsx
  81. 29 3
      packages/client/ui-chat/tests/chat-apply.client.spec.tsx
  82. 37 0
      packages/client/ui-chat/tests/chat-settings.client.spec.ts
  83. 125 11
      packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts
  84. 24 1
      packages/client/ui-chat/tests/chat-store.client.spec.ts
  85. 578 27
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  86. 408 7
      packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
  87. 1 1
      packages/client/ui-chat/tests/selection-survival.client.spec.tsx
  88. 47 0
      packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts
  89. 64 0
      packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx
  90. 6 0
      packages/client/ui-chat/tsconfig.json
  91. 2 1
      packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx
  92. 11 8
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  93. 10 0
      pnpm-lock.yaml
  94. 3 0
      snapshots/web/code-mode-round/ui.expected.md
  95. 8 3
      snapshots/web/cordis-tool-round/ui.expected.md
  96. 53 0
      snapshots/web/feedback-command/ack-expanded.expected.md
  97. 2 7
      snapshots/web/feedback-command/ack.expected.md
  98. 53 0
      snapshots/web/feedback-release/ack-expanded.expected.md
  99. 2 7
      snapshots/web/feedback-release/ack.expected.md
  100. 57 0
      snapshots/web/fresh-round-trip/ui-expanded.expected.md

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.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-08-26-stable-turn-process-order.md
+2026-08-26-stable-turn-process-order.md: 7122b901c8d24abc10c0bea51dcfc7a815b7aeeb
+2026-08-26-stable-turn-process-order.zh.md: 574855fd4c9f923dfc3a2a80b815ffed3b2d7422

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.md

@@ -0,0 +1,29 @@
+# Agent Note: Stable Turn-process ordering
+
+Status: implemented
+
+English | [中文](2026-08-26-stable-turn-process-order.zh.md)
+
+## Problem
+
+Turn-process eligibility changes as Assistant output streams, becomes a final answer, or is invalidated by a Tool call, Retry, or later Step. Ordering existing Chat Nodes from that mutable range moved the initial System prompt and pre-User Context across the opening User, so one logical row appeared at different transcript positions during a Turn.
+
+## Decision
+
+Existing Chat Nodes keep one presentation order throughout a page lifetime. Their positions depend on durable anchors, node kinds, and the opening human input, never on the mutable process start or answer boundary. Dependency replay after loading older history retains an already projected System prompt's anchor. A newly projected process control may be inserted between existing rows, while completion, Retry, later Steps, pagination completion, and manual disclosure only change visibility or add new evidence.
+
+System prompt is independent of Turn Process: the initial prompt remains visible above the opening User and never receives process-member or process-hidden state. A later prompt first projected from a partial window retains that position when earlier request history loads. Context injection remains process content. When a Context or another potential process row has an event anchor before the opening User, Chat places it after that User from its first projection; once available, the process control occupies the stable position between the User and those rows. Without opening human input, the control stays before the earliest process candidate from its first appearance.
+
+The existing [Turn-process folding decision](../feature/2026-08-14-web-turn-process-folding.md) continues to own membership, completion, persistence, focus, and pagination behavior; this note supersedes only its earlier decision to fold System prompt and to defer pre-User process ordering until a mutable range included those rows.
+
+## Alternatives considered
+
+**Keep System prompt inside Process but preserve its original position.** Rejected because a disclosure below the opening User would control content above itself, and collapsing would remove the request-wide instruction that visually frames that User message.
+
+**Exempt only System prompt.** Rejected because pre-User Context could still move when answer qualification changed, preserving the same class of visual discontinuity.
+
+**Reparent process rows under the disclosure.** Rejected because moving keyed rows across React parents remounts stateful renderers.
+
+## Consequences
+
+The stable first-Turn presentation is `System prompt → User → Process → Context and other process rows → final Assistant`. Pre-User injected Context can therefore differ from raw event order, but it uses that semantic position from its first render. Tests cover the initial state, process appearance, completion collapse, manual expansion, and content-only answer-boundary changes.

+ 29 - 0
.agents/notes/implemented/bug-fix/2026-08-26-stable-turn-process-order.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: 稳定的轮次过程排序
+
+Status: implemented
+
+[English](2026-08-26-stable-turn-process-order.md) | 中文
+
+## 问题
+
+Assistant 输出流式生成、成为最终正文,或因工具调用、Retry、后续步骤而失去正文资格时,轮次过程范围会变化。若既有 Chat Node 的排序依赖这段可变范围,首轮系统提示词和位于 User 之前的上下文会跨过开场 User,导致同一个逻辑行在轮次期间出现在不同位置。
+
+## 决策
+
+既有 Chat Node 在同一页面生命周期内保持同一展示顺序。其位置只依赖持久锚点、节点种类和开场人工输入,不依赖可变的过程起点或正文边界。加载更早历史触发依赖重放时,已经投影出的系统提示词保留原有锚点。新投影出的过程控件可以插入既有行之间;完成状态、Retry、后续步骤、分页完成与手动展开只改变可见性或增加新证据。
+
+系统提示词独立于轮次过程:初始提示词始终显示在开场 User 上方,并且不会获得过程成员或过程隐藏状态。后续提示词若首次从不完整历史窗口投影,加载更早的请求历史后仍保留该位置。上下文注入仍属于过程内容。若上下文或其它潜在过程行的事件锚点早于开场 User,Chat 从首次投影起就将其展示在该 User 之后;过程控件出现后占据 User 与这些过程行之间的稳定位置。没有开场人工输入时,控件从首次出现起就位于最早的过程候选之前。
+
+现有的[轮次过程折叠决策](../feature/2026-08-14-web-turn-process-folding.zh.md)继续负责成员关系、完成状态、持久化、焦点与分页行为;本记录仅取代其中“折叠系统提示词”以及“等可变范围纳入行后才调整 User 前过程顺序”的旧决定。
+
+## 曾考虑的替代方案
+
+**让系统提示词继续属于 Process,但保留原位置。** 不采用:位于开场 User 下方的 disclosure 会控制自身上方的内容,而且收起后会隐藏用于界定该 User 请求的整段指令。
+
+**只排除系统提示词。** 不采用:位于 User 之前的上下文仍会随正文资格变化而移动,保留了同类视觉跳动。
+
+**把过程行重新挂接到 disclosure 下。** 不采用:跨 React 父节点移动 keyed 行会重挂载有状态 renderer。
+
+## 后果
+
+稳定的首轮展示顺序为「系统提示词 → User → Process → 上下文及其它过程行 → 最终 Assistant」。因此,位于 User 之前的注入上下文展示顺序可能不同于原始事件顺序,但从首次渲染起保持不变。测试覆盖初始状态、过程出现、完成后默认收起、手动展开与仅正文边界变化的场景。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.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-14-web-turn-process-folding.md
+2026-08-14-web-turn-process-folding.md: fd0015dc915a296725344cd3f4ae05124089adf6
+2026-08-14-web-turn-process-folding.zh.md: 47b1a36bd75695a2f04dfed654d45ec81d2b8bc9

Diferenças do arquivo suprimidas por serem muito extensas
+ 16 - 0
.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.md


+ 43 - 0
.agents/notes/implemented/feature/2026-08-14-web-turn-process-folding.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: Web 轮次过程折叠
+
+Status: implemented
+
+[English](2026-08-14-web-turn-process-folding.md) | 中文
+
+## 问题
+
+一个模型轮次可能会在最终正文之前展示系统提示词、上下文注入、推理、多条 Assistant 回复、工具调用与重试行。完整展示整条轨迹会淹没正文,而把独立 Chat Node 移入一个父级 disclosure 会重挂载有状态的工具 renderer,并扰乱按时间排列的证据。紧凑视图必须收起已完成的过程工作,同时在轮次仍处于推理、使用工具、重试或没有正文便结束时,保留当下唯一可用的证据。
+
+## 决策
+
+由 Host 支撑的 `ui-chat.transcriptView` 偏好提供 `normal` 与 `compact` 两种模式,默认值为 `compact`。Normal 保持所有过程行可见且不渲染轮次过程控件;Compact 应用下述 disclosure 规则。切换模式只改变 wrapper 可见性,不会重新挂接或卸载 Chat Node renderer;该偏好不写入 Session log。
+
+在 Compact 模式下,轮次打开期间始终完整展开。到 `turn/end` 时,只有当最近步骤包含面向用户的 Assistant 回复内容——非空文本、图片或未知可见块——并且不含工具调用块时,该步骤才成为最终正文边界。正文保持可见。边界之前的上下文注入、推理、较早 Assistant 内容、工具行与重试行统一进入一条过程 disclosure。系统提示词、用户消息与 steering 消息保持独立,绝不加入过程组。已完成、已取消、已中断、失败和达到最大 token 的轮次使用同一终态投影;错误、最大 token 与 turn-tail 行留在过程组外。关闭时没有最终正文的轮次会保留全部过程证据。
+
+轮次作用域的 `turn-process` Definition 根据日志事件与步骤 Location data 推导首条模型或工具证据、最近步骤的已定稿正文边界、该正文之前带回复内容的持久 Assistant 消息数和工具调用计数。随产品交付的 subagent 委派名称(`subagent` 与 `subagent_*`)只增加 subagent 计数,不增加普通工具调用计数,因此两类不会重叠。上下文注入仍是过程证据但不增加摘要计数;系统提示词保持独立、持续可见,并始终位于开场 User 上方。它发布编码后的标量,使未变化的事实在流式期间保持值相等,并贡献一个稳定控制 Chat Node。Chat target 从首次投影起就把开场 User 或 steering 输入放在过程候选之前,再把合成控制行插入该输入与过程行之间。没有开场人工输入时,控制行从首次出现起就位于最早的过程候选之前。因此正文定稿、Retry、后续步骤、完成状态与手动展开只改变可见性,不改变既有节点的相对顺序,具体规则由[稳定的轮次过程排序](../bug-fix/2026-08-26-stable-turn-process-order.zh.md)说明。每个过程成员根据轮次规格判断自身归属。只有控制 Seat 与正文 Seat 订阅所属轮次在内容变化时更新的 Location key 列表,并在这份受限列表内推导外部过程证据和独立输入间距,因此纯数据更新会刷新布局,而不必重建全局顺序。轮次状态与已加载窗口是否完整随后共同决定能否折叠:打开中的轮次绝不折叠,历史不完整时也既不显示控件又不隐藏成员。控制 Node 从首条过程证据出现起一直存在,但其 Seat 会保持隐藏,直至关闭的轮次拥有最终正文且历史完整;显示后,它会省略每个值为 0 的分段,三项全为 0 时使用「已思考」(英文为 `Thought for a while`),并在摘要下方绘制通栏分隔线。
+
+Chat target 通过共享 settings scope 绑定持久化的 transcript 偏好,并把逐轮交互状态保存在会话作用域的 Chat store 中。`ChatView` 通过稳定的 keyed `ChatNodeSeat` 直接渲染每个业务 Node;加入过程控件不会重排既有 key,Compact 模式只改变 Seat wrapper 的 `hidden` 属性,不会重新挂接或卸载工具、Assistant、上下文或重试 renderer。Seat 通过 `ChatNodeOwnerProps` 传递同一份过程状态,因此最终 Assistant renderer 会隐藏自身步骤中的推理块,同时保留回复块;wrapper 可见性与行内推理共用一个 UI 状态真源。
+
+收起的过程成员使用 `hidden="until-found"`。在支持该能力的浏览器中,任一成员触发 `beforematch` 都会打开共享过程组。由于 hidden-until-found 成员会保留可搜索的零高度 box,Chat 列只在可见的相邻成员之间设置间距;控件分隔线横跨内容宽度,只有中间没有独立输入时,收起的过程控件才与正文相隔 8px,展开后恢复普通的 16px 行间距。在 Compact 模式下,不持久化的会话 store 只保存用户手动展开的「轮次 + 正文步骤」generation;没有记录即为收起,不同正文 generation 默认收起。因此,每个合格的已关闭轮次都使用相同默认状态,不区分实时完成、在「加载更早」后出现,或在读者离开尾部时结束。这可能在轮次关闭或历史变完整时让读者上方的内容重排。若自动收起会隐藏过程成员中的键盘焦点,则改为打开共享过程组并把焦点留在原处;手动收起会先把焦点移到过程控件,再隐藏成员。存在「加载更早」时,每个过程保持展开且控件隐藏;历史加载完整后,合格过程立即使用默认收起状态。页面重新加载会恢复持久化的 Normal 或 Compact 偏好;逐轮手动展开只在同一页面生命周期内的 view remount 之间保留。切换到 Normal 会显示所有过程行,切回 Compact 时会在默认收起状态上重新应用当前页面生命周期内的手动展开记录。
+
+这项展示与 [Conversation Node 组装](../architecture/2026-08-09-client-conversation-node-assembly.zh.md)共同成立:Definition 持有确定性的过程事实,Seat 持有共享交互状态,keyed renderer 保持独立。[按日志顺序投影的人工 transcript](../bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md)保持完整,因为折叠不改变任何会话事件或模型输入。
+
+## 曾考虑的替代方案
+
+**只折叠较早的 Assistant 回复。** 不采用:常见的 `Think → Tool → 正文` 轮次只有一个回复步骤,不会出现紧凑控件,用户要求收起的过程内容仍会完整展开。
+
+**把上下文注入留在过程组外。** 不采用:注入的运行时上下文属于正文之前的轨迹,并不是一条新的人类指令;展开过程后,其自身的 disclosure 与标签仍完整保留。系统提示词则留在过程组外,因为移动或隐藏整次请求使用的指令会改变开场 User 周围的可见框架。
+
+**把整个轮次重新挂接到一条摘要行下。** 不采用:折叠资格会在轮次运行期间变化,而把既有 Chat Node 移过 React 父节点会重挂载有状态工具视图。稳定 Seat 可以提供一个 disclosure,同时不移动其子节点。
+
+**把手动展开状态存入轮次 Location data。** 不采用:Location data 是会话事件的确定性投影,不存在浏览器动作写入路径。UI 手势属于已声明且不持久化的 store。
+
+**复用 `DisclosureRow` 并卸载收起成员。** 不采用:浏览器查找无法发现其文本,重新打开也会重建有状态 renderer 子树。
+
+**在 `turn/end` 前折叠实时正文候选。** 不采用:流式文本后仍可能出现工具调用、重试或后续仅推理步骤。等待终态边界可以避免自动收起—展开—再次收起及其造成的布局跳动。
+
+**读者离开尾部时暂缓刚获得资格的收起。** 不采用:该方案需要瞬时完成检测与 deferred 状态,并使相同的已关闭轮次根据进入视口的路径获得不同初始状态。已关闭轮次统一使用确定性的默认值;滚动锚定负责保持位置,焦点保护负责保留正在进行的交互。
+
+## 后果
+
+Compact 模式会在轮次正文前只有注入上下文、推理或工具时仍突出最终正文,展开后每条过程行按原顺序恢复;Normal 模式则保留完整 transcript 且不展示轮次过程控件。隐藏的 wrapper 与 Markdown 子树保持挂载,用一定浏览器内存换取稳定工具状态、跨视图重挂载的手动展开状态与浏览器查找恢复。只有 Compact 模式下的普通跨消息选择会排除仍收起的成员;用户可以先展开过程组,或切换到 Normal。不支持 `hidden="until-found"` 与 `beforematch` 的浏览器仍可手动展开,却无法通过页内查找揭示收起的过程文本。单元覆盖固定已定稿正文边界、重试与中断、跨 kind 共享展开、最终步骤推理、模式切换、手动展开、历史补全后立即收起、离尾收起、焦点保留与最终分页的纯数据更新;组装后的浏览器快照固定运行中、已取消、已完成、分页历史与持久化设置轨迹。

+ 8 - 4
apps/web/tests/chat-continuous-conversation.e2e.ts

@@ -18,7 +18,9 @@ import {
   webSnapshotMode,
   type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts'
+import {
+  connectFreshWorkspace, conversationContextKey, expandOwningTurnProcess, newEnglishPage, saveFailureShot,
+} from './support.ts'
 
 const MODE = webSnapshotMode()
 const TURN_COUNT = 12
@@ -315,6 +317,7 @@ describe('web e2e: continuous conversation grown through the composer', () => {
       const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
       await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1)
       expect(await toolRow.textContent()).toContain(spec.toolResultMarker)
+      await expandOwningTurnProcess(page, toolRow)
       const disclosure = toolRow.locator('[data-sample="bash"]')
       expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
       await disclosure.click()
@@ -332,9 +335,10 @@ describe('web e2e: continuous conversation grown through the composer', () => {
     ))).toHaveLength(TURN_COUNT)
     expect(sessionEvents.flatMap(event =>
       event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
-    await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), {
-      timeout: 10_000,
-    }).toBe(1)
+    expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(1)
+    expect(await page.locator(
+      '[data-chat-flow-kind="system-prompt"][hidden="until-found"]',
+    ).count()).toBe(0)
     expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
     expect(sessionEvents.filter(event => (
       event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT

+ 2 - 1
apps/web/tests/chat-long-interactions.e2e.ts

@@ -19,7 +19,7 @@ import {
   webSnapshotMode,
   type WebScaffold,
 } from './scaffold.ts'
-import { conversationContextKey, newEnglishPage, saveFailureShot } from './support.ts'
+import { conversationContextKey, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const MODE = webSnapshotMode()
 const SESSION_ID = 'chat-long-interactions-e2e'
@@ -278,6 +278,7 @@ describe('web e2e: long Chat interaction contract', () => {
 
     const summary1 = call1.locator('[data-sample="bash"]')
     const summary2 = call2.locator('[data-sample="bash"]')
+    await expandOwningTurnProcess(page, call2)
     expect(await summary1.getAttribute('aria-expanded')).toBe('false')
     expect(await summary2.getAttribute('aria-expanded')).toBe('false')
     await summary2.focus()

+ 12 - 3
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -20,7 +20,7 @@ import {
   webSnapshotMode,
   type WebScaffold,
 } from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
+import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const MODE = webSnapshotMode()
 const HISTORY_SESSION_ID = 'chat-scroll-history-e2e'
@@ -355,7 +355,7 @@ async function wheelUntilVisible(page: Page, selector: string, deltaY: number):
 
 function visibleFlowAnchor(page: Page): Promise<FlowAnchor> {
   return page.locator('[data-conversation-scroll]').evaluate((host) => {
-    const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
+    const rows = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]:not([hidden])')]
     const viewport = host.getBoundingClientRect()
     const composer = host.querySelector<HTMLElement>('[data-composer-seat]')
     const visibleBottom = composer?.getBoundingClientRect().top ?? viewport.bottom
@@ -431,12 +431,19 @@ async function expectMarkerAboveComposer(page: Page, marker: string): Promise<vo
 async function loadEarlierWithAnchor(page: Page): Promise<void> {
   await wheelToHistoryStart(page)
   const older = page.getByRole('button', { name: 'Load earlier', exact: true })
+  const loading = page.getByRole('button', { name: 'Loading…', exact: true })
   await older.waitFor({ timeout: 10_000 })
   const anchor = await visibleFlowAnchor(page)
   const before = await loadedFlowRows(page)
   await older.click()
-  await expect.poll(() => loadedFlowRows(page), { timeout: 30_000 }).toBeGreaterThan(before)
+  await expect.poll(async () => (
+    await loadedFlowRows(page) > before && await loading.count() === 0
+  ), { timeout: 30_000 }).toBe(true)
   await nextPaint(page)
+  if (await page.getByRole('button', { name: 'Load earlier', exact: true }).count() === 0) {
+    expect(await page.locator('[data-turn-process][aria-expanded="false"]').count()).toBeGreaterThan(0)
+    return
+  }
   await expectSameFlowTop(page, anchor)
 }
 
@@ -614,6 +621,7 @@ describe('web e2e: long Chat scroll contract', () => {
 
       const liveRowSelector = `[data-chat-call-id="${LIVE_TOOL_CALL_ID}"] [data-sample="bash"]`
       const liveRow = world.page.locator(liveRowSelector)
+      await expandOwningTurnProcess(world.page, liveRow)
       await wheelUntilVisible(world.page, liveRowSelector, -300)
       const toolAnchor = await liveRow.evaluate((row) => {
         const flow = row.closest<HTMLElement>('[data-chat-anchor-key]')
@@ -762,6 +770,7 @@ describe('web e2e: long Chat scroll contract', () => {
       const lastToolRow = world.page.locator(
         `[data-chat-call-id="chat-scroll-${String(INPUTS_FIXTURE.turns).padStart(3, '0')}-1"] [data-sample="bash"]`,
       )
+      await expandOwningTurnProcess(world.page, lastToolRow)
       await lastToolRow.focus()
       await world.page.keyboard.press('End')
       await expectBottom(world.page)

+ 10 - 4
apps/web/tests/code-mode-round.e2e.ts

@@ -8,10 +8,10 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  captureExpandedTurnProcessAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/session.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/ui.expected.md', import.meta.url))
@@ -94,6 +94,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
     // The parent run_code row wears the code variant with the model-authored
     // description as its summary (the presentCall contract).
     const codeRow = page.locator('[data-variant="code"]').first()
+    await expandOwningTurnProcess(page, codeRow)
     await codeRow.waitFor({ timeout: 10_000 })
     const nest = page.locator('[data-subcalls]').first()
     await nest.waitFor({ timeout: 10_000 })
@@ -106,13 +107,18 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => {
     const nest = page.locator('[data-subcalls]').first()
     const frame = page.locator('[style*="grid-template-columns"]').first()
     expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
+    await expandOwningTurnProcess(page, nest)
     await nest.locator('[data-sample="bash"]').first().click()
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
   })
 
-  it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => {
+  it.skipIf(MODE === 'record')('matches the expanded conversation aria golden with stable anchors', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-aria'))
-    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+    const snapshot = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
   })
 

+ 1 - 1
apps/web/tests/composer-tab-geometry.e2e.ts

@@ -130,7 +130,7 @@ function measureTab(page: Page): Promise<TabMetrics> {
 async function showTab(page: Page, tab: 'Chat' | 'Trajectory'): Promise<void> {
   await page.getByRole('tab', { name: tab, exact: true }).click()
   if (tab === 'Trajectory') await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 })
-  else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]').first().waitFor({ timeout: 30_000 })
+  else await page.locator('[data-conversation-scroll] [data-chat-anchor-key]:visible').first().waitFor({ timeout: 30_000 })
   // Both measurements are taken after a paint, so a rectangle read mid-transition
   // cannot be reported as a shift the cascade did not cause.
   await page.evaluate(() => new Promise<void>((settle) => {

+ 5 - 1
apps/web/tests/cordis-tool-round.e2e.ts

@@ -18,7 +18,7 @@ import {
   captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/session.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/cordis-tool-round/ui.expected.md', import.meta.url))
@@ -162,6 +162,7 @@ describe('web e2e: Cordis tools use their owned cards', () => {
       .toBeGreaterThanOrEqual(1)
 
     const inspectRow = page.locator('[data-tool="cordis_inspect_self"]').filter({ hasText: 'Inspect' }).first()
+    await expandOwningTurnProcess(page, inspectRow)
     await inspectRow.waitFor({ timeout: 10_000 })
 
     // cordis_define does NOT go through the generic row: ui-cordis registers a
@@ -169,6 +170,7 @@ describe('web e2e: Cordis tools use their owned cards', () => {
     // title here is the CARD's ("Cordis Plugin"), and the expanded body is the
     // card's own two code sections rather than a generic args dump.
     const defineRow = page.locator('[data-tool="cordis_define"]').filter({ hasText: 'Cordis Plugin' }).first()
+    await expandOwningTurnProcess(page, defineRow)
     await defineRow.waitFor({ timeout: 10_000 })
     // The whole summary row is the expand toggle (unified tool-row interaction).
     await defineRow.locator('[aria-expanded]').first().click()
@@ -177,10 +179,12 @@ describe('web e2e: Cordis tools use their owned cards', () => {
     await expect.poll(() => defineRow.textContent()).toContain(PACKAGE_CODE)
 
     const runRow = page.locator('[data-tool="cordis_run"]').filter({ hasText: 'Run Cordis Plugin' }).first()
+    await expandOwningTurnProcess(page, runRow)
     await runRow.waitFor({ timeout: 10_000 })
     await expect.poll(() => runRow.textContent()).toContain('snap-')
 
     const stopRow = page.locator('[data-tool="cordis_stop"]').filter({ hasText: 'Stop Cordis Plugin' }).first()
+    await expandOwningTurnProcess(page, stopRow)
     await stopRow.waitFor({ timeout: 10_000 })
     await expect.poll(() => stopRow.textContent()).toContain('snap-')
     await expect(stopRow.getAttribute('data-state')).resolves.toBe('ok')

+ 56 - 0
apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md

@@ -0,0 +1,56 @@
+- tree "Sessions":
+  - treeitem "{{workspace}}" [expanded]:
+    - img
+    - text: {{workspace}}
+  - treeitem "Review deepseek-harness/deepseek-harness#314 Session actions for Review deepseek-harness/deepseek-harness#314" [selected]:
+    - text: Review deepseek-harness/deepseek-harness#314
+    - button "Session actions for Review deepseek-harness/deepseek-harness#314":
+      - img
+
+---
+
+- banner:
+  - navigation "Session hierarchy":
+    - button "Review deepseek-harness/deepseek-harness#314" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- button "Thought for a while" [expanded]:
+  - text: Thought for a while
+  - img
+- button "Context injection webhook github webhook handled by review-pr-when-ready":
+  - img
+  - img
+  - text: Context injection webhook github webhook handled by review-pr-when-ready
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- paragraph: "Review complete: no actionable findings."
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: {{clock}} Ran for {{duration}}
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Read Only"': Read Only
+- button "Select model, current github-webhook-review-test/reply":
+  - text: github-webhook-review-test/reply
+  - img
+- button "Send message" [disabled]
+- text: 1 turns · 1 steps LLM {{duration}}

+ 2 - 7
apps/web/tests/expected/github-ready-review/conversation.expected.md

@@ -24,14 +24,9 @@
   - img
   - img
   - text: System prompt
-- button "Context injection webhook github webhook handled by review-pr-when-ready":
+- button "Thought for a while":
+  - text: Thought for a while
   - img
-  - img
-  - text: Context injection webhook github webhook handled by review-pr-when-ready
-- button "Context injection @deepseek-ai/dsh-system-prompt":
-  - img
-  - img
-  - text: Context injection @deepseek-ai/dsh-system-prompt
 - paragraph: "Review complete: no actionable findings."
 - button "Copy":
   - img

+ 5 - 1
apps/web/tests/expected/settings-chrome/dialog-en.expected.md

@@ -44,7 +44,11 @@
     - img
   - button "Decrease font size":
     - img
-  - text: px Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior
+  - text: px Conversation display Controls process content in completed turns
+  - button "Compact":
+    - text: Compact
+    - img
+  - text: Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior
   - button "Queue":
     - text: Queue
     - img

+ 5 - 1
apps/web/tests/expected/settings-chrome/dialog.expected.md

@@ -44,7 +44,11 @@
     - img
   - button "减小字号":
     - img
-  - text: px 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为
+  - text: px 对话显示 控制已完成轮次的过程内容
+  - button "Compact":
+    - text: Compact
+    - img
+  - text: 繁忙时 Enter 键行为 仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为
   - button "排队发送":
     - text: 排队发送
     - img

+ 49 - 0
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -0,0 +1,49 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "/user-invoke-demo and confirm the fixtur" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: /user-invoke-demo and confirm the fixture wiring {{clock}}
+- button "Copy":
+  - img
+- button "Thought for a while" [expanded]:
+  - text: Thought for a while
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- button "Context injection user-invoke-demo":
+  - img
+  - img
+  - text: Context injection user-invoke-demo
+- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "0% of context used"
+- button "Send message" [disabled]
+- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok

+ 2 - 7
apps/web/tests/expected/skill-user-invoke/ui.expected.md

@@ -16,14 +16,9 @@
 - text: /user-invoke-demo and confirm the fixture wiring {{clock}}
 - button "Copy":
   - img
-- button "Context injection @deepseek-ai/dsh-system-prompt":
+- button "Thought for a while":
+  - text: Thought for a while
   - img
-  - img
-  - text: Context injection @deepseek-ai/dsh-system-prompt
-- button "Context injection user-invoke-demo":
-  - img
-  - img
-  - text: Context injection user-invoke-demo
 - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
 - button "Copy":
   - img

+ 59 - 0
apps/web/tests/expected/steer-all/settled-expanded.expected.md

@@ -0,0 +1,59 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Use the ask_user_question tool to" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
+- button "Copy":
+  - img
+- button "1 tool call" [expanded]:
+  - text: 1 tool call
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.":
+  - img
+  - img
+  - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.
+- button "Ask question 1/1 answered":
+  - img
+  - img
+  - text: Ask question 1/1 answered
+- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
+- button "Copy":
+  - img
+- text: "Interjection: include the word ORANGE in your final reply. {{clock}}"
+- button "Copy":
+  - img
+- paragraph: "Got it: BANANA and ORANGE."
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "0% of context used"
+- button "Send message" [disabled]
+- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok

+ 2 - 11
apps/web/tests/expected/steer-all/settled.expected.md

@@ -16,18 +16,9 @@
 - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
 - button "Copy":
   - img
-- button "Context injection @deepseek-ai/dsh-system-prompt":
+- button "1 tool call":
+  - text: 1 tool call
   - img
-  - img
-  - text: Context injection @deepseek-ai/dsh-system-prompt
-- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.":
-  - img
-  - img
-  - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.
-- button "Ask question 1/1 answered":
-  - img
-  - img
-  - text: Ask question 1/1 answered
 - text: "Interjection: include the word BANANA in your final reply. {{clock}}"
 - button "Copy":
   - img

+ 12 - 2
apps/web/tests/feedback-command.e2e.ts

@@ -15,7 +15,8 @@ import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -23,6 +24,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-command', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
+const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md')
 const MODE = webSnapshotMode()
 // Discard port: loopback listener never binds, so FULL telemetry discloses
 // the shipped default policy without any record reaching a collector.
@@ -91,12 +93,20 @@ describe('web e2e: /feedback command acknowledgement', () => {
     expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(ACK_EXPANDED_EXPECTED, expanded, MODE)
 
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 60_000)
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'session.jsonl', 'ack.expected.md', 'ack-expanded.expected.md',
+    ])
   })
 })

+ 10 - 2
apps/web/tests/feedback-release.e2e.ts

@@ -16,7 +16,8 @@ import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -27,6 +28,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-rele
 // manifest's `session.source`) instead of recording a duplicate.
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.jsonl', import.meta.url))
 const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
+const ACK_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ack-expanded.expected.md')
 const MODE = webSnapshotMode()
 
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
@@ -115,6 +117,12 @@ describe('web e2e: feedback-gated release under the shipped default mode', () =>
 
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(ACK_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 60_000)
@@ -132,6 +140,6 @@ describe('web e2e: feedback-gated release under the shipped default mode', () =>
   }, 60_000)
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md', 'ack-expanded.expected.md'])
   })
 })

+ 10 - 0
apps/web/tests/github-ready-review.e2e.ts

@@ -11,6 +11,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
 import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-webhook'
 import {
+  captureExpandedTurnProcessAria,
   captureStableAria,
   compareOrRefreshGolden,
   launchWebScaffold,
@@ -23,6 +24,9 @@ import { saveFailureShot } from './support.ts'
 const MODE = webSnapshotMode()
 const OVERLAY = fileURLToPath(new URL('../../cli/config/examples/github-review/cordis.yml', import.meta.url))
 const EXPECTED = fileURLToPath(new URL('./expected/github-ready-review/conversation.expected.md', import.meta.url))
+const EXPANDED_EXPECTED = fileURLToPath(
+  new URL('./expected/github-ready-review/conversation-expanded.expected.md', import.meta.url),
+)
 const PROVIDER = 'github-webhook-review-test'
 const MODEL = 'reply'
 const SECRET = 'github-webhook-review-secret'
@@ -157,6 +161,12 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => {
     const tree = await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd)
     const conversation = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(EXPECTED, `${tree}\n\n---\n\n${conversation}`, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(EXPANDED_EXPECTED, `${tree}\n\n---\n\n${expanded}`, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 60_000)

+ 17 - 6
apps/web/tests/goal-multi-turn-actions.e2e.ts

@@ -11,7 +11,7 @@ import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-goal'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -20,6 +20,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/goal-multi-tu
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md')
 const MODE = webSnapshotMode()
 
 const PROMPT = '做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的'
@@ -29,7 +30,7 @@ const PACKAGE_FILES: Readonly<Record<string, string>> = {
   'packages/client/ui-conversation/README.md': '# UI conversation\n',
   'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n',
   'packages/client/ui-conversation/src/client.ts': 'export {}\n',
-  'packages/client/ui-conversation/tests/chat-view.client.spec.tsx': 'export {}\n',
+  'packages/client/ui-chat/tests/chat-view.client.spec.tsx': 'export {}\n',
   'packages/context/session-reference/README.md': '# Session reference\n',
   'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n',
   'packages/context/session-reference/src/index.ts': 'export {}\n',
@@ -153,9 +154,11 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
     expect(goalRounds(sessionEvents)).toEqual([1, 2])
     expect(sessionEvents.flatMap(event =>
       event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
-    await expect.poll(() => page.getByRole('button', { name: 'System prompt' }).count(), {
-      timeout: 15_000,
-    }).toBe(2)
+    await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2)
+    expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(2)
+    expect(await page.locator(
+      '[data-chat-flow-kind="system-prompt"][hidden="until-found"]',
+    ).count()).toBe(0)
     const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
     await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
     expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))))
@@ -163,11 +166,19 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
     await branchButtons.last().focus()
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold!.workspaceCwd,
+    )
+    await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 140_000)
 
   it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['replay.override.json', 'session.jsonl', 'ui.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'replay.override.json', 'session.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
+    ])
   })
 })

+ 12 - 2
apps/web/tests/lifecycle-chrome.e2e.ts

@@ -18,7 +18,8 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria,
+  captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
@@ -33,6 +34,7 @@ const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
 // Post-reload golden: the same settled conversation rebuilt purely from
 // persistence + history — byte-equal rendering is exactly the recovery claim.
 const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
+const RELOADED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded-expanded.expected.md')
 const MODE = webSnapshotMode()
 
 const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
@@ -239,6 +241,12 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
     // must render the same settled transcript the live turn produced.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(RELOADED_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(RELOADED_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
 
@@ -277,7 +285,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.jsonl', 'replay.override.json', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
+      'session.jsonl', 'replay.override.json', 'command-menu.expected.md',
+      'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md',
+      'reloaded.expected.md', 'reloaded-expanded.expected.md',
     ])
   })
 })

+ 19 - 3
apps/web/tests/live-interactions.e2e.ts

@@ -23,7 +23,8 @@ import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay
 import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -34,10 +35,12 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 // state, and the other four capture what remains after cancel, after a
 // non-retryable failure, after retry recovery, and after retry exhaustion.
 const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md')
+const CANCEL_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'cancel-expanded.expected.md')
 const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md')
 const RUNNING_DRAFT_EXPECTED = join(SNAPSHOT_DIR, 'running-draft.expected.md')
 const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md')
 const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md')
+const RETRY_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'retry-expanded.expected.md')
 const RETRY_EXHAUSTED_EXPECTED = join(SNAPSHOT_DIR, 'retry-exhausted.expected.md')
 const MODE = webSnapshotMode()
 const AUTH_PROVIDER_MESSAGE = 'Authentication Fails, Your api key: sk-preview-secret is invalid'
@@ -185,6 +188,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     // partial ('partial' is the hang entry's replayed prefix) and no more.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     await compareOrRefreshGolden(CANCEL_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold!.workspaceCwd,
+    )
+    await compareOrRefreshGolden(CANCEL_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 120_000)
@@ -268,6 +277,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     // while the settled retry row remains as durable recovery context.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold!.workspaceCwd,
+    )
+    await compareOrRefreshGolden(RETRY_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 120_000)
@@ -307,8 +322,9 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'running-draft.expected.md',
-      'error-auth.expected.md', 'retry.expected.md', 'retry-exhausted.expected.md',
+      'session.jsonl', 'cancel.expected.md', 'cancel-expanded.expected.md',
+      'loading.expected.md', 'running-draft.expected.md', 'error-auth.expected.md',
+      'retry.expected.md', 'retry-expanded.expected.md', 'retry-exhausted.expected.md',
     ])
   })
 })

+ 6 - 0
apps/web/tests/minimal-preset.snapshot.ts

@@ -154,6 +154,12 @@ describe('minimal agent preset', () => {
     await sessionRow.click()
     await page.getByText('MINIMAL_PRESET_REQUEST_OK', { exact: true }).waitFor({ timeout: 15_000 })
 
+    const process = page.locator('[data-turn-process]')
+    await process.waitFor({ timeout: 15_000 })
+    await expect.poll(() => process.getAttribute('aria-expanded')).toBe('false')
+    await process.click()
+    await expect.poll(() => process.getAttribute('aria-expanded')).toBe('true')
+
     const row = page.locator('[data-sample="bash"]').first()
     await row.waitFor({ timeout: 15_000 })
     await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')

+ 3 - 1
apps/web/tests/navigation-panes.e2e.ts

@@ -19,7 +19,7 @@ import {
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
+import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url))
 const SEED = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -390,6 +390,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-details'))
     await ensureSeedOpen(page)
     const bashRow = page.locator('[data-sample="bash"]').first()
+    await expandOwningTurnProcess(page, bashRow)
     await bashRow.waitFor({ timeout: 15_000 })
     const frame = page.locator('[style*="grid-template-columns"]').first()
     expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
@@ -425,6 +426,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     // Expanded, the recorded command's own output sits in the message flow,
     // derived from the logged call/result presentations alone.
     const bashRow = page.locator('[data-sample="bash"]').first()
+    await expandOwningTurnProcess(page, bashRow)
     await bashRow.waitFor({ timeout: 15_000 })
     if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
     const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first()

+ 11 - 2
apps/web/tests/plan-review.e2e.ts

@@ -15,7 +15,8 @@ import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -27,6 +28,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
 const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
 const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
+const APPROVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'approved-expanded.expected.md')
 const MODE = webSnapshotMode()
 
 // One command line: /plan enters plan mode and submits the rest as the turn's
@@ -111,13 +113,20 @@ describe('web e2e: plan review takeover round trip', () => {
     await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(APPROVED_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 200_000)
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'session.jsonl', 'review.expected.md', 'sidebar.expected.md', 'approved.expected.md',
+      'session.jsonl', 'review.expected.md', 'sidebar.expected.md',
+      'approved.expected.md', 'approved-expanded.expected.md',
     ])
   })
 })

+ 13 - 5
apps/web/tests/question-composer.e2e.ts

@@ -19,7 +19,9 @@ import {
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import {
+  connectFreshWorkspace, expandTurnProcesses, newEnglishPage, saveFailureShot,
+} from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/question-composer', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
@@ -30,6 +32,7 @@ const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md')
 // round trip and the final reply, the state the composer goldens cannot see.
 const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md')
 const CANCELLED_EXPECTED = join(SNAPSHOT_DIR, 'cancelled.expected.md')
+const ANSWERED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'answered-expanded.expected.md')
 const MODE = webSnapshotMode()
 const CANCELLED_SEED_ID = 'ask-question-cancelled-row-web-e2e'
 
@@ -289,16 +292,20 @@ describe('web e2e: resident question composer round trip', () => {
     expect(await page.locator('[data-question-key]').count()).toBe(0)
     expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
     await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
-    // Golden of the answered transcript: the ask_user_question round trip
-    // rendered as history (expanded readable answers + DONE), composer takeover gone.
+    // The default golden pins Compact mode before process disclosure.
+    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE)
+    // Keep the ask_user_question card's readable answer in the expanded golden
+    // even though Compact mode hides the process by default.
+    await expandTurnProcesses(page)
     const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true })
     await answeredRow.click()
     await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 })
     expect(await page.getByText('Blue', { exact: true }).count()).toBeGreaterThanOrEqual(1)
     expect(await page.getByText('Include accessibility notes', { exact: true }).count()).toBe(1)
     expect(await page.getByText(/"answers"/).count()).toBe(0)
-    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
-    await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE)
+    const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(ANSWERED_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 200_000)
@@ -410,6 +417,7 @@ describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', ()
       'composed.expected.md',
       'answered.expected.md',
       'cancelled.expected.md',
+      'answered-expanded.expected.md',
     ])
   })
 })

+ 12 - 2
apps/web/tests/queue-actions.e2e.ts

@@ -13,7 +13,7 @@ import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
 import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden,
   launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -24,6 +24,7 @@ const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md')
 const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md')
 const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
 const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
+const PRESERVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'preserved-expanded.expected.md')
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
 const MODE = webSnapshotMode()
 
@@ -168,6 +169,12 @@ describe('web e2e: queue row actions', () => {
 
     const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(PRESERVED_EXPANDED_EXPECTED, expanded, MODE)
 
     const settled = scaffold.whenTurnSettled()
     await input.fill(WAKE)
@@ -272,7 +279,10 @@ describe('web e2e: queue row actions', () => {
   it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
     await assertFixtureInventory(
       SNAPSHOT_DIR,
-      ['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'],
+      [
+        'collapsed.expected.md', 'editing.expected.md', 'layout.expected.md',
+        'preserved.expected.md', 'preserved-expanded.expected.md', 'ui.expected.md',
+      ],
     )
   })
 })

+ 18 - 3
apps/web/tests/replay-round-trip.e2e.ts

@@ -17,15 +17,21 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { ToolCallId } from '@deepseek-ai/dsh-llm'
 import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot } from './support.ts'
+import {
+  connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
+} from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url))
 const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url))
+const UI_EXPANDED_EXPECTED = fileURLToPath(
+  new URL('../../../snapshots/web/fresh-round-trip/ui-expanded.expected.md', import.meta.url),
+)
 const WEB_CONTEXT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/web-context.expected.md', import.meta.url))
 const MODE = webSnapshotMode()
 
@@ -163,10 +169,17 @@ describe('web e2e: fresh round trip through the real assembly', () => {
     }).waitFor({ timeout: 10_000 })
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
   })
 
-  it.skipIf(MODE === 'record')('renders the system prompt as a collapsed expandable disclosure', async () => {
+  it.skipIf(MODE === 'record')('renders the system prompt disclosure inside the expanded Turn process', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-system-prompt'))
+    await expandTurnProcesses(page)
     const disclosure = page.getByRole('button', { name: 'System prompt', exact: true })
     const body = page.locator('[data-system-prompt-body]')
     await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1)
@@ -190,6 +203,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
     // tier pins the same gesture against FixtureApiClient; this one runs on
     // follow-stream-fed state). Runs after the golden capture so the committed
     // aria surface stays the untouched settled state.
+    await expandTurnProcesses(page)
     const think = page.getByRole('button', { name: /^Think/ }).first()
     expect(await think.getAttribute('aria-expanded')).toBe('false')
     await think.click()
@@ -208,6 +222,7 @@ describe('web e2e: fresh round trip through the real assembly', () => {
       'tool-schemas.expected.json',
       'web-context.expected.md',
       'ui.expected.md',
+      'ui-expanded.expected.md',
     ])
   })
 })

+ 33 - 0
apps/web/tests/scaffold.ts

@@ -1169,6 +1169,39 @@ export async function captureStableAria(
   return previous
 }
 
+/**
+ * Capture a stable aria snapshot with every eligible Turn process expanded,
+ * then restore the controls that were closed before the capture.
+ * @param page - the page under test.
+ * @param selector - the region locator selector.
+ * @param workspaceCwd - normalization input.
+ * @returns the stable normalized expanded snapshot.
+ */
+export async function captureExpandedTurnProcessAria(
+  page: Page,
+  selector: string,
+  workspaceCwd: string,
+): Promise<string> {
+  const controls = page.locator('[data-turn-process]')
+  const count = await controls.count()
+  expect(count).toBeGreaterThan(0)
+  const opened: number[] = []
+  for (let index = 0; index < count; index++) {
+    const control = controls.nth(index)
+    if (!await control.isVisible() || await control.getAttribute('aria-expanded') === 'true') continue
+    await control.click()
+    opened.push(index)
+  }
+  try {
+    return await captureStableAria(page, selector, workspaceCwd)
+  } finally {
+    for (const index of opened.reverse()) {
+      const control = controls.nth(index)
+      if (await control.getAttribute('aria-expanded') === 'true') await control.click()
+    }
+  }
+}
+
 /**
  * Compare a normalized golden, or rewrite it under refresh. Refresh is the
  * ONLY writer: a missing golden in replay mode fails with the healing command

+ 25 - 3
apps/web/tests/seeded-history.e2e.ts

@@ -22,15 +22,19 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { TokenMeter } from '@deepseek-ai/dsh-token-meter'
 import { join } from 'node:path'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, parseSeedFixture, realizeSeedFixture, recordFixture, renderSeedFixture, seedSession, watchConsole,
   webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
+import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/seeded-history', import.meta.url))
 const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url))
 const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/ui.expected.md', import.meta.url))
+const UI_EXPANDED_EXPECTED = fileURLToPath(
+  new URL('../../../snapshots/web/seeded-history/ui-expanded.expected.md', import.meta.url),
+)
 // 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))
@@ -278,6 +282,14 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await expect.poll(() => page.getByText(/^Compacted \d+ history items \(~\d+ tokens\)$/).count(), {
       timeout: 10_000,
     }).toBe(1)
+    const process = page.locator('[data-turn-process="1"]')
+    await process.waitFor({ state: 'visible', timeout: 10_000 })
+    expect(await process.getAttribute('aria-expanded')).toBe('false')
+    const processBottom = await process.evaluate(element => element.getBoundingClientRect().bottom)
+    const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element =>
+      element.getBoundingClientRect().top)
+    // Collapsed control row keeps its own 8px margin plus the 8px flow gap.
+    expect(answerTop).toBe(processBottom + 16)
     expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0)
     // Tool cards render from logged tool/call + tool/result alone (views are
     // host-recomputed per page; the generic card is the documented default).
@@ -331,6 +343,12 @@ describe('web e2e: seeded history renders through cold resume', () => {
     const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+    const expanded = (await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )).split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
   })
 
   it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
@@ -395,6 +413,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // file links (not expand-in-place / not details). Runs after the golden
     // capture; still zero model calls.
     const fileLink = page.locator('[data-variant="read"] button').first()
+    await expandOwningTurnProcess(page, fileLink)
     await fileLink.waitFor({ timeout: 10_000 })
     const frame = page.locator('[style*="grid-template-columns"]').first()
     expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
@@ -552,6 +571,9 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // stream would have failed the turn loudly. Cleanliness pins the wire.
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
-    await assertFixtureInventory(SNAPSHOT_DIR, ['command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md', 'session.jsonl', 'ui.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'command-row.expected.md', 'feedback-row.expected.md', 'file-open-failure.expected.md',
+      'session.jsonl', 'ui.expected.md', 'ui-expanded.expected.md',
+    ])
   })
 })

+ 30 - 0
apps/web/tests/settings-chrome.e2e.ts

@@ -408,6 +408,36 @@ describe('web e2e: settings modal and General preferences', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 90_000)
 
+  it('persists the completed-Turn transcript mode across reload', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-transcript-view'))
+    await page.getByRole('button', { name: '设置', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    await dialog.waitFor({ timeout: 10_000 })
+    await dialog.getByText('对话显示', { exact: true }).waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: 'Compact', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'Normal', exact: true }).click()
+    await dialog.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 })
+    await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
+      .toMatch(/ui-chat:\n\s+transcriptView: normal/)
+    await page.keyboard.press('Escape')
+
+    const warningStart = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    await page.getByRole('button', { name: '设置', exact: true }).click()
+    const reloaded = page.getByRole('dialog', { name: '设置' })
+    await reloaded.getByRole('button', { name: 'Normal', exact: true }).waitFor({ timeout: 10_000 })
+
+    await reloaded.getByRole('button', { name: 'Normal', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
+    await reloaded.getByRole('button', { name: 'Compact', exact: true }).waitFor({ timeout: 10_000 })
+    await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
+      .toMatch(/ui-chat:\n\s+transcriptView: compact/)
+    await page.keyboard.press('Escape')
+    expect(tripwire.pageErrors).toEqual([])
+  }, 90_000)
+
   it('persists the busy-state Enter behavior across reload and a distinct port', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-enter-behavior'))
     await page.getByRole('button', { name: '设置', exact: true }).click()

+ 4 - 2
apps/web/tests/skill-tool-row.e2e.ts

@@ -10,7 +10,7 @@ import {
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
+import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.jsonl', import.meta.url))
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row', import.meta.url))
@@ -42,7 +42,9 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => {
     const sessionRow = page.locator('[role="treeitem"]').nth(1)
     await sessionRow.waitFor({ timeout: 10_000 })
     await sessionRow.click()
-    await page.locator('[data-tool="skill"]').waitFor({ timeout: 15_000 })
+    const skillRow = page.locator('[data-tool="skill"]')
+    await expandOwningTurnProcess(page, skillRow)
+    await skillRow.waitFor({ timeout: 15_000 })
   }, 120_000)
 
   afterAll(async () => {

+ 20 - 9
apps/web/tests/skill-user-invoke.e2e.ts

@@ -14,6 +14,7 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
 import {
   assertFixtureInventory,
+  captureExpandedTurnProcessAria,
   captureStableAria,
   compareOrRefreshGolden,
   launchWebScaffold,
@@ -21,10 +22,11 @@ import {
   webSnapshotMode,
   type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/skill-user-invoke', import.meta.url))
 const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const UI_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'ui-expanded.expected.md')
 const MODE = webSnapshotMode()
 
 const SKILL_NAME = 'user-invoke-demo'
@@ -120,10 +122,16 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
     expect(await bubble.textContent()).toBe(`/${SKILL_NAME}`)
 
     // The rendered body arrives as a context-injection row named after the
-    // skill; expanding it reveals the canonical <skill_content> block, and
-    // the user's text is NOT folded into it.
+    // skill. Context plus the final answer contributes no summary count, so
+    // the Turn uses the fallback title while the row's own disclosure remains usable.
+    const injectionFlow = page.locator('[data-chat-flow-kind="context"]').filter({ hasText: SKILL_NAME })
+    await injectionFlow.waitFor({ state: 'attached', timeout: 15_000 })
+    await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
+    await settled
+    const process = page.getByRole('button', { name: 'Thought for a while', exact: true })
+    await process.waitFor({ state: 'visible', timeout: 10_000 })
+    await expandOwningTurnProcess(page, injectionFlow)
     const injectionRow = page.getByRole('button', { name: `Context injection ${SKILL_NAME}` })
-    await injectionRow.waitFor({ timeout: 15_000 })
     await injectionRow.click()
     const injectionBody = page
       .locator('[data-context-injection-body]')
@@ -133,18 +141,21 @@ describe.skipIf(MODE === 'record')('web e2e: user-explicit skill invocation thro
     expect(injected).toContain('Reply with the fixture acknowledgement line.')
     expect(injected).not.toContain(ARGS_TEXT)
     await injectionRow.click()
-
-    // The injection started a turn; the replay adapter answers it.
-    await page.getByText('USER_INVOKE_REPLY', { exact: false }).first().waitFor({ timeout: 20_000 })
-    await settled
+    await process.click()
 
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(UI_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 60_000)
 
   it('keeps its snapshot inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'ui-expanded.expected.md'])
   })
 })

+ 21 - 3
apps/web/tests/steering.e2e.ts

@@ -11,7 +11,8 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
+  assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -24,6 +25,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
 // from user/message beside the reply that obeys it.
 const MID_EXPECTED = join(SNAPSHOT_DIR, 'mid-steer.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
+const SETTLED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'settled-expanded.expected.md')
 const MODE = webSnapshotMode()
 // The question composer replaces the textarea, so fill → Queue row → Steer
 // starts only after request/context and must finish before the first replay
@@ -43,6 +45,7 @@ const STEER_ALL_FIXTURE = join(STEER_ALL_DIR, 'session.jsonl')
 const STEER_ALL_OVERRIDE = join(STEER_ALL_DIR, 'replay.override.json')
 const STEER_ALL_MID = join(STEER_ALL_DIR, 'mid-steer.expected.md')
 const STEER_ALL_SETTLED = join(STEER_ALL_DIR, 'settled.expected.md')
+const STEER_ALL_SETTLED_EXPANDED = join(STEER_ALL_DIR, 'settled-expanded.expected.md')
 const STEER_ONE = 'Interjection: include the word BANANA in your final reply.'
 const STEER_TWO = 'Interjection: include the word ORANGE in your final reply.'
 
@@ -171,12 +174,20 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
     // obeying reply, composer takeover gone.
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(SETTLED_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 200_000)
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'mid-steer.expected.md', 'settled.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, [
+      'session.jsonl', 'mid-steer.expected.md', 'settled.expected.md', 'settled-expanded.expected.md',
+    ])
   })
 })
 
@@ -392,13 +403,20 @@ describe('web e2e: empty-draft Cmd+Enter steers the whole queue', () => {
     expect(await page.locator('[data-pending-steering]').count()).toBe(0)
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(STEER_ALL_SETTLED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(STEER_ALL_SETTLED_EXPANDED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   }, 200_000)
 
   it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
     await assertFixtureInventory(STEER_ALL_DIR, [
-      'replay.override.json', 'mid-steer.expected.md', 'settled.expected.md',
+      'replay.override.json', 'mid-steer.expected.md',
+      'settled.expected.md', 'settled-expanded.expected.md',
     ])
   })
 })

+ 12 - 2
apps/web/tests/subagent-conversation.e2e.ts

@@ -11,7 +11,8 @@ import {
 import type {} from '@deepseek-ai/dsh-agent'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
 import {
-  acknowledgeReloadConnectionLoss, captureStableAria, compareOrRefreshGolden,
+  acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria,
+  compareOrRefreshGolden,
   launchWebScaffold, watchConsole,
   webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
@@ -19,6 +20,9 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 
 const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.jsonl', import.meta.url))
 const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url))
+const AVAILABLE_CHILD_EXPANDED_EXPECTED = fileURLToPath(
+  new URL('../../../snapshots/web/subagent-conversation/ui-expanded.expected.md', import.meta.url),
+)
 const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/tree.expected.md', import.meta.url))
 const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url))
 const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/stale-catalog.expected.md', import.meta.url))
@@ -243,7 +247,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     const warningStart = tripwire.warnings.length
     await page.reload({ waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
-    const catalogButton = page.getByRole('button', { name: /subagents/ })
+    const catalogButton = page.getByRole('button', { name: '3 subagents', exact: true })
     await catalogButton.waitFor({ timeout: 15_000 })
     await catalogButton.hover()
     const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' })
@@ -434,6 +438,12 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-aria'))
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(AVAILABLE_CHILD_EXPECTED, snapshot, MODE)
+    const expanded = await captureExpandedTurnProcessAria(
+      page,
+      '[class*="centerCol"]',
+      scaffold.workspaceCwd,
+    )
+    await compareOrRefreshGolden(AVAILABLE_CHILD_EXPANDED_EXPECTED, expanded, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   })

+ 30 - 1
apps/web/tests/support.ts

@@ -3,7 +3,7 @@ import { existsSync, mkdirSync } from 'node:fs'
 import { createServer } from 'node:net'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import type { Browser, Page } from 'playwright'
+import type { Browser, Locator, Page } from 'playwright'
 
 /** The built page under test; `pnpm run test:web` rebuilds it before running. */
 export const DIST_INDEX = fileURLToPath(new URL('../dist/index.html', import.meta.url))
@@ -31,6 +31,35 @@ export async function newEnglishPage(browser: Browser, height = 1000): Promise<P
   return await browser.newPage({ viewport: { width: 1680, height }, locale: 'en-US' })
 }
 
+/**
+ * Expand every currently eligible Turn-process group so a Tool-focused
+ * scenario can exercise the original row contract beneath product-default
+ * compact Chat presentation.
+ * @param page - page containing the Chat view.
+ */
+export async function expandTurnProcesses(page: Page): Promise<void> {
+  const controls = page.locator('[data-turn-process]')
+  await controls.first().waitFor({ state: 'visible', timeout: 10_000 })
+  const count = await controls.count()
+  for (let index = 0; index < count; index++) {
+    const control = controls.nth(index)
+    if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
+  }
+}
+
+/**
+ * Expand the Turn-process group containing one possibly hidden descendant.
+ * @param page - page containing the Chat view.
+ * @param target - descendant whose owning Turn process should open.
+ */
+export async function expandOwningTurnProcess(page: Page, target: Locator): Promise<void> {
+  const turn = await target.evaluate(element => element.closest<HTMLElement>('[data-chat-turn]')?.dataset.chatTurn)
+  if (turn === undefined || await target.isVisible()) return
+  const control = page.locator(`[data-turn-process="${turn}"]`)
+  await control.waitFor({ state: 'visible', timeout: 10_000 })
+  if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
+}
+
 /** Fail loud on a stale checkout instead of testing yesterday's bundle. */
 export function requireDist(): void {
   if (!existsSync(DIST_INDEX)) {

+ 94 - 7
apps/web/tests/turn-tail-actions.e2e.ts

@@ -24,10 +24,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url))
 const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
-// Two goldens for the same message: parked mid-turn, then settled.
+// Three goldens for the same message: parked mid-turn, aborted, and completed.
 const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
 const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
 const USAGE_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'usage-expanded.expected.md')
+const COMPLETED_EXPECTED = join(SNAPSHOT_DIR, 'completed.expected.md')
+const FOCUSED_EXPECTED = join(SNAPSHOT_DIR, 'focused.expected.md')
 const MODE = webSnapshotMode()
 
 // The recording must carry text in the SAME assistant message as the tool
@@ -60,7 +62,10 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
   })
 
   /** Boot scaffold + page, materializing the sidecar before the replay row installs. */
-  async function launch(buildOverride?: (sidecarHome: string) => ReplayOverrideDoc): Promise<void> {
+  async function launch(
+    buildOverride?: (sidecarHome: string) => ReplayOverrideDoc,
+    paceMs?: number,
+  ): Promise<void> {
     sessionEvents = []
     let overridePath: string | undefined
     if (buildOverride !== undefined) {
@@ -75,6 +80,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
           replayFixture: FIXTURE,
           ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
           compareReplaySession: overridePath === undefined,
+          ...(paceMs === undefined ? {} : { paceMs }),
         },
     )
     scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
@@ -122,13 +128,13 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions'))
     // The barrier is armed before the park and awaited only after the stop
     // click, so its budget must cover the whole parked phase: marker poll,
-    // three UI polls, and two captures with their stability windows. The
+    // live-state polls, and two captures with their stability windows. The
     // replay default (30s) leaves no headroom on a slow runner.
     const { settled } = await sendPrompt(120_000)
     // The marker IS the synchronization: the second call is provably parked,
     // so the first step's message and tool result are already durable.
     await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true)
-    await expect.poll(() => page.getByText(NARRATION, { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+    expect(await page.locator('[data-turn-process]').count()).toBe(0)
     await expect.poll(
       () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
       { timeout: 10_000 },
@@ -148,6 +154,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     await page.getByRole('button', { name: 'Stop generating' }).click()
     await settled
     expect(sessionEvents.filter(e => e.type === 'turn/end').map(e => e.data.reason.kind)).toEqual(['aborted'])
+    await page.locator('[data-turn-process]').waitFor({ timeout: 10_000 })
     await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2)
     await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
     await copyButtons.last().focus()
@@ -182,9 +189,89 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     expect(tripwire.warnings).toEqual([])
   }, 120_000)
 
+  it.skipIf(MODE === 'record')('folds the Turn process after the completed reply becomes the answer', async () => {
+    await launch()
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-completed'))
+    const { settled } = await sendPrompt()
+    await settled
+    await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+    const process = page.locator('[data-turn-process]')
+    await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1)
+    expect(await process.getAttribute('aria-expanded')).toBe('false')
+    expect(await process.evaluate(element => getComputedStyle(element).borderBottomWidth)).toBe('1px')
+    const processBottom = await process.evaluate(element =>
+      element.closest<HTMLElement>('[data-chat-flow-kind="turn-process"]')?.getBoundingClientRect().bottom)
+    const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element =>
+      element.closest<HTMLElement>('[data-chat-flow-kind="assistant-step"]')?.getBoundingClientRect().top)
+    expect(answerTop).toBe((processBottom ?? 0) + 8)
+    await process.focus()
+    const completed = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+    await compareOrRefreshGolden(COMPLETED_EXPECTED, completed, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('switches a completed Turn between Compact and Normal', async () => {
+    await launch()
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-process-setting'))
+    const { settled } = await sendPrompt()
+    await settled
+    const process = page.locator('[data-turn-process]')
+    const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' })
+    await process.waitFor({ timeout: 10_000 })
+    expect(await process.getAttribute('aria-expanded')).toBe('false')
+    expect(await tool.isVisible()).toBe(false)
+
+    await page.getByRole('button', { name: 'Settings', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: 'Settings' })
+    await dialog.getByRole('button', { name: 'Compact', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'Normal', exact: true }).click()
+    await page.keyboard.press('Escape')
+
+    await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(0)
+    await tool.waitFor({ state: 'visible', timeout: 10_000 })
+    await expect.poll(async () => readFile(join(scaffold!.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 })
+      .toMatch(/ui-chat:\n\s+transcriptView: normal/)
+
+    await page.getByRole('button', { name: 'Settings', exact: true }).click()
+    const restored = page.getByRole('dialog', { name: 'Settings' })
+    await restored.getByRole('button', { name: 'Normal', exact: true }).click()
+    await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
+    await page.keyboard.press('Escape')
+    await process.waitFor({ timeout: 10_000 })
+    expect(await process.getAttribute('aria-expanded')).toBe('false')
+    expect(await tool.isVisible()).toBe(false)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 60_000)
+
+  it.skipIf(MODE === 'record')('keeps a focused process member open when the completed reply arrives', async () => {
+    await launch(undefined, 200)
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-turn-tail-actions-focused'))
+    const { settled } = await sendPrompt()
+    const tool = page.getByRole('button', { name: 'Bash Print alpha to stdout' })
+    await tool.waitFor({ timeout: 30_000 })
+    await tool.focus()
+    expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true)
+    await settled
+    await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 10_000 }).toBe(1)
+    const process = page.locator('[data-turn-process]')
+    await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1)
+    expect(await process.getAttribute('aria-expanded')).toBe('true')
+    expect(await tool.evaluate(element => element.ownerDocument.activeElement === element)).toBe(true)
+    const focused = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+    await compareOrRefreshGolden(FOCUSED_EXPECTED, focused, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, [
-      'running.expected.md', 'session.jsonl', 'settled.expected.md', 'usage-expanded.expected.md',
-    ])
+    await assertFixtureInventory(
+      SNAPSHOT_DIR,
+      [
+        'completed.expected.md', 'focused.expected.md', 'running.expected.md', 'session.jsonl',
+        'settled.expected.md', 'usage-expanded.expected.md',
+      ],
+    )
   })
 })

+ 6 - 2
apps/web/tests/web-search-round.e2e.ts

@@ -16,7 +16,7 @@ import {
   assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
   launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
-import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/web-search-round', import.meta.url))
 const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/web-search-round/session.jsonl', import.meta.url))
@@ -263,7 +263,9 @@ describe('web e2e: shipped default web search', () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-search-aria'))
     await expect.poll(() => page.getByText('SEARCH_DONE', { exact: true }).count(), { timeout: 15_000 })
       .toBeGreaterThanOrEqual(1)
-    await page.locator('[data-tool="web_search"]').waitFor({ timeout: 10_000 })
+    const searchTool = page.locator('[data-tool="web_search"]')
+    await expandOwningTurnProcess(page, searchTool)
+    await searchTool.waitFor({ timeout: 10_000 })
     const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
   })
@@ -271,6 +273,7 @@ describe('web e2e: shipped default web search', () => {
   it.skipIf(MODE === 'record')('scrolls the capped source list inside the fixed-height container', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-search-sources-scroll'))
     const row = page.locator('[data-tool="web_search"] [data-expandable]').first()
+    await expandOwningTurnProcess(page, row)
     await row.click()
     await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true')
 
@@ -299,6 +302,7 @@ describe('web e2e: shipped default web search', () => {
 
   it.skipIf(MODE === 'record')('reserves marker room a scroll container cannot clip back', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-search-marker-room'))
+    await expandOwningTurnProcess(page, page.locator('[data-tool="web_search"]'))
     // `overflow-y: auto` clips inline-start overflow with no way to scroll it
     // back, and markers are right-aligned to the content edge, so a marker wider
     // than `padding-left` silently loses its leading digits. `searchMaxResults`

+ 3 - 1
apps/web/tests/workflow-run.e2e.ts

@@ -15,7 +15,7 @@ import {
   type WebScaffold,
 } from './scaffold.ts'
 import {
-  connectFreshWorkspace, newEnglishPage, REPO_ROOT, saveFailureShot,
+  connectFreshWorkspace, expandTurnProcesses, newEnglishPage, REPO_ROOT, saveFailureShot,
 } from './support.ts'
 
 const MODE = webSnapshotMode()
@@ -161,6 +161,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
     const sessions = page.getByRole('tree', { name: 'Sessions' })
     await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click()
     await settled
+    await expandTurnProcesses(page)
     await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor()
 
     expect(await page.locator('[data-chat-flow-kind="tool-call"]').count()).toBeGreaterThanOrEqual(1)
@@ -186,6 +187,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
     onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-history'))
     await page.reload({ waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await expandTurnProcesses(page)
     const workflow = page.getByRole('button', { name: /^snapshot-flow/ })
     await workflow.waitFor({ timeout: 15_000 })
     expect(await workflow.getAttribute('aria-expanded')).toBe('false')

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: c54901d6951908eaef728b16d295c1f749e7fe22
-module-graph.zh.md: 67e8c8227035f0bbe411c557aba7ce3fe7b60e03
+module-graph.md: a6f70aa1ff1616acf305ae386b148c56d8ebcf85
+module-graph.zh.md: 3b33bc68e67c19c03ec4d212c864371b27b31278

+ 3 - 1
docs/module-graph.md

@@ -1529,6 +1529,7 @@ flowchart TD
   pkg_client_ui_chat --> pkg_client_ui_layout
   pkg_client_ui_chat --> pkg_client_ui_renderer
   pkg_client_ui_chat --> pkg_client_ui_session
+  pkg_client_ui_chat --> pkg_client_ui_settings
   pkg_client_ui_chat --> pkg_client_ui_workspace
   pkg_client_ui_chat --> pkg_commands
   pkg_client_ui_chat --> pkg_compaction
@@ -1537,6 +1538,7 @@ flowchart TD
   pkg_client_ui_chat --> pkg_llm_retry
   pkg_client_ui_chat --> pkg_session
   pkg_client_ui_chat --> pkg_session_stats
+  pkg_client_ui_chat --> pkg_settings
   pkg_client_ui_chat --> pkg_token_meter
   pkg_client_ui_chat --> pkg_tools
   pkg_client_ui_chat --> pkg_util_workspace_path
@@ -1931,7 +1933,7 @@ flowchart TD
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
 | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
-| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
+| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |

+ 3 - 1
docs/module-graph.zh.md

@@ -1531,6 +1531,7 @@ flowchart TD
   pkg_client_ui_chat --> pkg_client_ui_layout
   pkg_client_ui_chat --> pkg_client_ui_renderer
   pkg_client_ui_chat --> pkg_client_ui_session
+  pkg_client_ui_chat --> pkg_client_ui_settings
   pkg_client_ui_chat --> pkg_client_ui_workspace
   pkg_client_ui_chat --> pkg_commands
   pkg_client_ui_chat --> pkg_compaction
@@ -1539,6 +1540,7 @@ flowchart TD
   pkg_client_ui_chat --> pkg_llm_retry
   pkg_client_ui_chat --> pkg_session
   pkg_client_ui_chat --> pkg_session_stats
+  pkg_client_ui_chat --> pkg_settings
   pkg_client_ui_chat --> pkg_token_meter
   pkg_client_ui_chat --> pkg_tools
   pkg_client_ui_chat --> pkg_util_workspace_path
@@ -1933,7 +1935,7 @@ flowchart TD
 | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
 | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
 | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
-| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
+| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) |

+ 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: cd69eb7fdf704bb0e138e1f2aad88782e26aeeff
-README.zh.md: 3e9d1ccab71f2516f92ec5402eac49baaaccdf77
+README.md: 5c7549da07e8101c6b08b4cad57afd5158980b7f
+README.zh.md: a558bf94f5436eebdf035ca7507ecd25ec3c8d8a

Diferenças do arquivo suprimidas por serem muito extensas
+ 4 - 0
packages/client/ui-chat/README.md


Diferenças do arquivo suprimidas por serem muito extensas
+ 4 - 0
packages/client/ui-chat/README.zh.md


+ 8 - 0
packages/client/ui-chat/package.json

@@ -39,6 +39,7 @@
         "@deepseek-ai/dsh-client-ui-layout",
         "@deepseek-ai/dsh-client-ui-renderer",
         "@deepseek-ai/dsh-client-ui-session",
+        "@deepseek-ai/dsh-client-ui-settings",
         "@deepseek-ai/dsh-client-ui-workspace"
       ],
       "platform": "web"
@@ -62,6 +63,7 @@
     "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-compaction": "workspace:^",
@@ -70,6 +72,7 @@
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^"
@@ -89,6 +92,7 @@
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
     "@deepseek-ai/dsh-commands": "workspace:^",
@@ -98,12 +102,16 @@
     "@deepseek-ai/dsh-llm-retry": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@types/react": "~18.3.1",
     "react": "^18.2.0"
   },
+  "dependencies": {
+    "@deepseek-ai/schemastery": "workspace:^"
+  },
   "files": [
     "lib/index.js",
     "lib/invariant.js",

+ 29 - 0
packages/client/ui-chat/src/chat-settings.ts

@@ -0,0 +1,29 @@
+/** Chat transcript preferences stored in the Host user-settings document. */
+
+import z from '@deepseek-ai/schemastery'
+
+/** Settings namespace owned by the Chat target. */
+export const CHAT_SETTINGS_NAMESPACE = 'ui-chat'
+
+/** Field carrying the completed-Turn transcript presentation mode. */
+export const TRANSCRIPT_VIEW_FIELD = 'transcriptView'
+
+/** Transcript presentation modes accepted at settings boundaries. */
+export const TRANSCRIPT_VIEW_MODES = ['normal', 'compact'] as const
+
+/** Completed-Turn transcript presentation. */
+export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number]
+
+/** Default preserves the compact process disclosure introduced by Chat. */
+export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact'
+
+/** Durable Chat section shared by the Host schema and browser scope. */
+export interface ChatSettings {
+  /** Presentation mode for completed Turn process content. */
+  transcriptView: TranscriptViewMode
+}
+
+/** Durable Chat schema; also the wire envelope the browser scope validates against. */
+export const ChatSettingsSchema: z<ChatSettings> = z.object({
+  [TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE),
+})

+ 20 - 1
packages/client/ui-chat/src/client/apply.ts

@@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
 import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
 import type {
   ChatNodeTurnDataInjected, ChatScrollPosition, ChatViewInjected, DetailsInjected,
@@ -25,7 +26,10 @@ import { StatsLine } from './chat/StatsLine.tsx'
 import { registerConversationNodes } from './conversation-nodes/register.ts'
 import { DetailsPanel } from './details/DetailsPanel.tsx'
 import { en, NS, zh } from './locale.ts'
+import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx'
 import { createChatStore } from './stores.ts'
+import { TranscriptViewPolicy } from './transcript-view.ts'
+import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts'
 
 const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
   hooks: {
@@ -42,7 +46,7 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
 
 /** Services required by the Chat target and its presentation registrations. */
 export const inject = [
-  'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale',
+  'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale', 'settingsScope',
 ]
 
 /**
@@ -74,6 +78,20 @@ export function apply(ctx: Context): void {
   const t = ctx.locale.bind(NS)
   const chatStore = createChatStore()
   const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
+  const transcriptView = new TranscriptViewPolicy(
+    ctx.settingsScope.bind<ChatSettings>({ namespace: CHAT_SETTINGS_NAMESPACE }),
+  )
+
+  ctx.slots.inject('settings.general.item', () => ctx.slots.register({
+    name: 'settings.general.item',
+    id: 'transcript-view',
+    order: 12,
+    locale: NS,
+    inject: (): TranscriptViewRowInjected => ({
+      hooks: { transcriptView: transcriptView.mode },
+      setTranscriptView: (mode) => { transcriptView.setMode(mode) },
+    }),
+  }, TranscriptViewRow))
 
   ctx.slots.inject('conversation.view', () => {
     const disposeView = ctx.slots.register({
@@ -91,6 +109,7 @@ export function apply(ctx: Context): void {
         const session = ctx.sessions.binding(sessionId)?.session
         if (session === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`)
         return {
+          hooks: { transcriptView: transcriptView.mode },
           openDetails: (target) => {
             actions.select(target)
             ctx.layout.openDetails()

+ 7 - 0
packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css

@@ -42,6 +42,13 @@
   padding-left: var(--dsh-table-lead);
 }
 
+/* hidden="until-found" keeps a zero-height reasoning box in flex layout.
+   Cancel the one gap that box would otherwise leave before the visible reply;
+   visible Assistant blocks retain the ordinary 16px rhythm. */
+.body > [data-turn-process-inline][hidden] {
+  margin-bottom: -16px;
+}
+
 /* Interrupted-turn terminal marker: quiet inline tag, no animation. Fixed
    size like the small/code token variants — 11px is dense secondary text
    that would fall to an illegible 9px at the 12px floor. */

+ 27 - 2
packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx

@@ -6,6 +6,7 @@ import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts
 import type { AssistantBlock } from '../contract/snapshot.ts'
 import { markdownLabels } from '../markdown-labels.ts'
 import { ReasoningRow } from './ReasoningRow.tsx'
+import { useSearchableHidden } from './searchable-hidden.ts'
 import css from './AssistantMarkdown.module.css'
 
 export interface AssistantMarkdownProps {
@@ -15,6 +16,10 @@ export interface AssistantMarkdownProps {
   interrupted?: boolean | undefined
   /** Render consecutive image blocks through the attachment slot. */
   renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
+  /** Hide reasoning that belongs to the Turn-level process disclosure. */
+  reasoningHidden?: boolean | undefined
+  /** Reveal the owning Turn-level process disclosure. */
+  revealProcess?: (() => void) | undefined
   /** Resolved prose file mentions for this Assistant's closing turn. */
   mentions?: MarkdownFileMentions | undefined
   /** The owning view's locale seat, passed down as a plain prop. */
@@ -23,7 +28,8 @@ export interface AssistantMarkdownProps {
 
 /** Reasoning block as the Think variant summary row (figma 39:28304). */
 export const AssistantMarkdown = memo(function AssistantMarkdown({
-  blocks, streaming, interrupted, renderMessageImages, mentions, t,
+  blocks, streaming, interrupted, renderMessageImages,
+  reasoningHidden = false, revealProcess, mentions, t,
 }: AssistantMarkdownProps) {
   // Stable per locale revision (t identity changes on switch): a fresh object
   // per render would rebuild MarkdownText's component table every chunk.
@@ -53,7 +59,15 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
         )
         break
       case 'reasoning':
-        rendered.push(<ReasoningRow key={i} text={block.text} running={streaming && i === last} t={t} />)
+        rendered.push(
+          <ProcessReasoning
+            key={i}
+            hidden={reasoningHidden}
+            reveal={revealProcess}
+          >
+            <ReasoningRow text={block.text} running={streaming && i === last} t={t} />
+          </ProcessReasoning>,
+        )
         break
       case 'image': {
         // Consecutive image blocks share one gallery so several images tile
@@ -102,3 +116,14 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
     </div>
   )
 })
+
+function ProcessReasoning({ hidden, reveal, children }: {
+  hidden: boolean
+  reveal?: (() => void) | undefined
+  children: ReactNode
+}) {
+  const ref = useSearchableHidden(hidden, reveal ?? NOOP)
+  return <div ref={ref} data-turn-process-inline={hidden || undefined}>{children}</div>
+}
+
+const NOOP = (): void => {}

+ 10 - 2
packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx

@@ -1,10 +1,10 @@
-import { memo, useMemo } from 'react'
+import { memo, useCallback, useMemo } from 'react'
 import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
 import { AssistantMarkdown } from './AssistantMarkdown.tsx'
 
 /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */
 export const AssistantNodeView = memo(function AssistantNodeView({
-  node, useTurnData, openFile, renderMessageImages, fileMentions, t,
+  node, useTurnData, turnProcess, openFile, renderMessageImages, fileMentions, t,
 }: ChatNodeViewProps<'assistant-step'>) {
   const data = node.data
   const turn = node.location.kind === 'turn' || node.location.kind === 'step'
@@ -20,12 +20,20 @@ export const AssistantNodeView = memo(function AssistantNodeView({
     () => owner === undefined ? undefined : fileMentions(owner),
     [fileMentions, owner],
   )
+  const reasoningHidden = turnProcess !== undefined
+    && turnProcess.foldable
+    && turnProcess.spec.answerStep === data.step
+    && turnProcess.spec.inlineReasoning
+    && !turnProcess.open
+  const revealProcess = useCallback(() => { turnProcess?.setOpen(true) }, [turnProcess])
   return (
     <AssistantMarkdown
       blocks={data.blocks}
       streaming={data.status === 'running'}
       interrupted={data.status === 'interrupted'}
       renderMessageImages={renderMessageImages}
+      reasoningHidden={reasoningHidden}
+      revealProcess={revealProcess}
       mentions={mentions}
       t={t}
     />

+ 162 - 5
packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx

@@ -1,12 +1,23 @@
-import { memo, useMemo } from 'react'
+import { memo, useCallback, useMemo } from 'react'
 import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts'
 import type { ChatNode } from '../contract/chat-nodes.ts'
+import type { ChatNodeStore } from '../contract/snapshot.ts'
+import {
+  decodeTurnProcess, TURN_PROCESS_INDEPENDENT_KINDS, turnProcessGeneration,
+  type TurnProcessSpec,
+} from '../contract/turn-process.ts'
+import { storedTurnProcessEntry } from '../stores.ts'
+import { useSearchableHidden } from './searchable-hidden.ts'
 import css from './ChatView.module.css'
 
 interface ChatNodeSeatProps extends ChatNodeOwnerProps {
   readonly nodeKey: string
+  readonly historyIncomplete: boolean
+  readonly compactTranscript: boolean
   readonly useChat: ChatViewSlotProps['useChat']
+  readonly useStore: ChatViewSlotProps['useStore']
+  readonly actions: ChatViewSlotProps['actions']
   readonly renderSlot: ChatViewSlotProps['renderSlot']
   readonly t: ChatViewSlotProps['t']
 }
@@ -15,13 +26,153 @@ type RoutedChatNodeOwner = {
   [Kind in ChatNode['kind']]: ChatNodeOwnerProps & { readonly node: ChatNode<Kind> }
 }[ChatNode['kind']]
 
-/** Subscribe and dispatch one stable Context key without observing sibling Nodes. */
+const EMPTY_PROCESS_KEYS: readonly string[] = []
+
+interface TurnProcessLayout {
+  readonly hasExternalProcess: boolean
+  readonly compactAnswer: boolean
+}
+
+function turnProcessOpeningHumanAnchor(
+  keys: readonly string[],
+  nodes: ChatNodeStore,
+  spec: TurnProcessSpec,
+): number | undefined {
+  let anchor: number | undefined
+  for (const key of keys) {
+    const node = nodes.get(key) as ChatNode | undefined
+    if ((node?.kind === 'user' || node?.kind === 'steering')
+      && node.anchorSeq < spec.controlAnchorSeq) {
+      anchor = Math.min(anchor ?? node.anchorSeq, node.anchorSeq)
+    }
+  }
+  return anchor
+}
+
+/** Derive disclosure facts from one content-revisioned Turn index. */
+function turnProcessLayout(
+  keys: readonly string[],
+  nodes: ChatNodeStore,
+  spec: TurnProcessSpec,
+): TurnProcessLayout {
+  let hasExternalProcess = false
+  let compactAnswer = true
+  const openingHumanAnchor = turnProcessOpeningHumanAnchor(keys, nodes, spec)
+  for (const key of keys) {
+    const node = nodes.get(key) as ChatNode | undefined
+    if (node === undefined || node.kind === 'turn-process') continue
+    if ((node.kind === 'user' || node.kind === 'steering')
+      && (openingHumanAnchor === undefined || node.anchorSeq > openingHumanAnchor)
+      && (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) {
+      compactAnswer = false
+    }
+    if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)
+      || node.anchorSeq < spec.processStartSeq
+      || (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue
+    if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) {
+      hasExternalProcess = true
+    }
+  }
+  return { hasExternalProcess, compactAnswer }
+}
+
+/** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */
 export const ChatNodeSeat = memo(function ChatNodeSeat({
-  nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt,
-  renderMessageImages, fileMentions, useChat, renderSlot, t,
+  nodeKey, historyIncomplete, compactTranscript,
+  selectedCallId, cwd, openFile, inspectCall, forkAt,
+  renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t,
 }: ChatNodeSeatProps) {
   const node = useChat(snapshot => snapshot.nodes.get(nodeKey))
+  const processSignature = useChat((snapshot) => {
+    const current = snapshot.nodes.get(nodeKey)
+    const location = current?.location
+    return location?.kind === 'turn' || location?.kind === 'step'
+      ? location.turn.data.get('turn-process')
+      : undefined
+  })
+  const processSpec = useMemo(
+    () => processSignature === undefined ? undefined : decodeTurnProcess(processSignature),
+    [processSignature],
+  )
+  const nodeStore = useChat(snapshot => snapshot.nodes)
+  const processLayoutKeys = useChat((snapshot) => {
+    if (!compactTranscript || historyIncomplete || processSpec === undefined) return EMPTY_PROCESS_KEYS
+    const current = snapshot.nodes.get(nodeKey) as ChatNode | undefined
+    const location = current?.location
+    if (current === undefined
+      || (location?.kind !== 'turn' && location?.kind !== 'step')
+      || location.turn.status !== 'closed'
+      || location.turn.turn !== processSpec.turn) return EMPTY_PROCESS_KEYS
+    const ownsLayout = current.kind === 'turn-process'
+      || (current.kind === 'assistant-step' && current.data.step === processSpec.answerStep)
+    return ownsLayout ? snapshot.locations.getTurn(processSpec.turn) : EMPTY_PROCESS_KEYS
+  })
+  const processLayout = useMemo(
+    () => processSpec === undefined || processLayoutKeys.length === 0
+      ? undefined
+      : turnProcessLayout(processLayoutKeys, nodeStore, processSpec),
+    [nodeStore, processLayoutKeys, processSpec],
+  )
+  const processGeneration = useMemo(
+    () => processSpec === undefined ? undefined : turnProcessGeneration(processSpec),
+    [processSpec],
+  )
+  const storedEntry = useStore(state => processSpec === undefined
+    ? undefined
+    : storedTurnProcessEntry(state, processSpec.turn))
+  const processEntry = storedEntry?.generation === processGeneration ? storedEntry : undefined
+  const processOpen = processEntry !== undefined
+  const setOpen = useCallback((open: boolean) => {
+    if (processGeneration !== undefined && processSpec !== undefined) {
+      actions.setTurnProcessOpen(processSpec.turn, processGeneration, open)
+    }
+  }, [actions, processGeneration, processSpec])
   const routedNode = node as ChatNode | undefined
+  const sameTurn = routedNode !== undefined
+    && processSpec !== undefined
+    && (routedNode.location.kind === 'turn' || routedNode.location.kind === 'step')
+    && routedNode.location.turn.turn === processSpec.turn
+  const turnClosed = sameTurn
+    && routedNode.location.turn.status === 'closed'
+  const processWindowReady = processSpec !== undefined
+    && compactTranscript
+    && processSpec.answerAnchorSeq !== null
+    && turnClosed
+    && !historyIncomplete
+  const processMember = sameTurn
+    && processWindowReady
+    && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind)
+    && routedNode.anchorSeq >= processSpec.processStartSeq
+    && routedNode.anchorSeq < processSpec.answerAnchorSeq
+  const processAnswer = sameTurn
+    && processWindowReady
+    && routedNode.kind === 'assistant-step'
+    && routedNode.data.step === processSpec.answerStep
+  const ownsDisclosure = routedNode?.kind === 'turn-process' || processAnswer
+  const foldable = processWindowReady
+    && (processMember || (ownsDisclosure
+      && ((processLayout?.hasExternalProcess ?? false) || processSpec.inlineReasoning)))
+  const turnProcess = useMemo(() => processGeneration === undefined || processSpec === undefined
+    ? undefined
+    : {
+      spec: processSpec,
+      foldable,
+      open: processOpen,
+      setOpen,
+    }, [
+    foldable, processGeneration, processOpen, processSpec, setOpen,
+  ])
+  const controllerInactive = routedNode?.kind === 'turn-process'
+    && !foldable
+  const compactAnswer = processAnswer
+    && foldable
+    && processLayout?.compactAnswer === true
+    && !processOpen
+  const processHidden = controllerInactive || (foldable && processMember && !processOpen)
+  const revealProcess = useCallback(() => {
+    if (processMember) setOpen(true)
+  }, [processMember, setOpen])
+  const wrapperRef = useSearchableHidden(processHidden, revealProcess)
   const owner = useMemo<ChatNodeOwnerProps | null>(() => node === undefined
     ? null
     : {
@@ -32,8 +183,10 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
       forkAt,
       renderMessageImages,
       fileMentions,
+      turnProcess,
     }, [
-    node, selectedCallId, cwd, openFile, inspectCall, forkAt, renderMessageImages, fileMentions,
+    node, selectedCallId, cwd, openFile, inspectCall, forkAt,
+    renderMessageImages, fileMentions, turnProcess,
   ])
   if (routedNode === undefined || owner === null) return null
   const location = routedNode.location
@@ -46,11 +199,15 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
   const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner
   return (
     <div
+      ref={wrapperRef}
       className={css.flowItem}
       data-chat-anchor-key={routedNode.key}
       data-chat-flow-key={routedNode.key}
       data-chat-flow-kind={routedNode.kind}
       data-chat-turn={turn}
+      data-turn-process-member={processMember || undefined}
+      data-turn-process-hidden={processHidden || undefined}
+      data-turn-process-answer={compactAnswer || undefined}
     >
       {renderSlot('conversation.chat.node', routedOwner, {
         entryKey: routedNode.kind,

+ 14 - 1
packages/client/ui-chat/src/client/chat/ChatView.module.css

@@ -41,7 +41,14 @@
   margin: 0 auto;
   display: flex;
   flex-direction: column;
-  gap: 16px;
+}
+
+/* `hidden="until-found"` retains a zero-height box so browser find can reveal
+   its subtree. Direct Chat Node Seats and auxiliary rows share one flow;
+   hidden and empty Seats do not contribute spacing. */
+.column > :not([hidden]):not(.flowItem:empty)
+  ~ :not([hidden]):not(.flowItem:empty) {
+  margin-top: var(--dsh-chat-flow-gap, 16px);
 }
 
 /* Settled-flow identity boundary. It is neutral until it becomes the natural
@@ -50,6 +57,12 @@
   min-width: 0;
 }
 
+/* A closed process reads as one summary immediately followed by its answer.
+   Expanded process rows return to the ordinary 16px rhythm. */
+.flowItem[data-turn-process-answer] {
+  --dsh-chat-flow-gap: 8px;
+}
+
 /* A keyed renderer may intentionally decline its row after dispatch (the
    completed-turn tail does this when it owns neither actions nor extensions).
    An empty flex item must not consume the column gap. */

+ 16 - 7
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -30,7 +30,7 @@ interface PagingAnchor {
 
 /** Find an already-rendered row without interpolating a selector. */
 function anchorElement(list: HTMLElement, key: string): HTMLElement | null {
-  for (const row of list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')) {
+  for (const row of list.querySelectorAll<HTMLElement>('[data-chat-anchor-key]:not([hidden])')) {
     if (row.dataset.chatAnchorKey === key) return row
   }
   return null
@@ -87,7 +87,9 @@ function pagingAnchor(list: HTMLElement, scrollport: HTMLElement): HTMLElement |
       if (row !== null && list.contains(row)) return row
     }
   }
-  const rows = list.querySelectorAll<HTMLElement>('[data-chat-flow] > [data-chat-flow-key]:not(:empty)')
+  const rows = list.querySelectorAll<HTMLElement>(
+    '[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])',
+  )
   let low = 0
   let high = rows.length
   while (low < high) {
@@ -154,7 +156,7 @@ function observedRpcIds(
 function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null {
   let latest: number | null = null
   for (const turn of timeline.turns.values()) {
-    if (turn.status === 'open' && turn.start !== undefined) latest = turn.start.time
+    if (turn.status === 'open') latest = turn.start?.time ?? null
   }
   return latest
 }
@@ -200,8 +202,8 @@ function TurnStatus({ startTime, t }: {
  * ordered business Node crosses the keyed renderer seat.
  */
 export function ChatView({
-  useSession, useChat, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt,
-  fileMentions, t,
+  useSession, useChat, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadImage, openView, chatScroll, forkAt,
+  fileMentions, useTranscriptView, t,
 }: ChatViewSlotProps) {
   const order = useChat(s => s.order)
   const nodeStore = useChat(s => s.nodes)
@@ -219,6 +221,7 @@ export function ChatView({
   const hasMore = useSession(s => s.hasMore)
   const loadingOlder = useSession(s => s.loadingOlder)
   const selectedCallId = useStore(s => s.selection?.callId)
+  const compactTranscript = useTranscriptView(mode => mode === 'compact')
   const inspectCall = useCallback((callId: string) => {
     openView('trajectory', callId)
   }, [openView])
@@ -278,8 +281,10 @@ export function ChatView({
 
   const listRef = useRef<HTMLDivElement | null>(null)
   const columnRef = useRef<HTMLDivElement | null>(null)
-  const atBottomRef = useRef(true)
-  const [atBottom, setAtBottom] = useState(true)
+  // A saved position starts disarmed; the first layout effect synchronously
+  // restores it and normalizes a floor-clamped position back to following.
+  const [atBottom, setAtBottom] = useState(() => chatScroll.read() === null)
+  const atBottomRef = useRef(atBottom)
   const [activeTurn, setActiveTurn] = useState<number | null>(
     () => turnNavigationItems.at(-1)?.turn ?? null,
   )
@@ -591,7 +596,11 @@ export function ChatView({
             <ChatNodeSeat
               key={nodeKey}
               nodeKey={nodeKey}
+              historyIncomplete={hasMore}
+              compactTranscript={compactTranscript}
               useChat={useChat}
+              useStore={useStore}
+              actions={actions}
               selectedCallId={selectedCallId}
               cwd={cwd}
               openFile={requestOpenFile}

+ 48 - 0
packages/client/ui-chat/src/client/chat/TurnProcessNodeView.module.css

@@ -0,0 +1,48 @@
+.root {
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+  width: 100%;
+  min-width: 0;
+  height: 33px;
+  padding: 0 0 8px;
+  border: none;
+  border-bottom: 1px solid var(--dsw-alias-border-l2);
+  background: none;
+  color: var(--dsw-alias-label-secondary);
+  cursor: pointer;
+  text-align: left;
+}
+
+.root:not([data-open]) {
+  margin-bottom: 8px;
+}
+
+.chevron {
+  flex: none;
+  width: 16px;
+  height: 16px;
+  margin-left: 6px;
+  color: var(--dsw-alias-label-tertiary);
+  transform: rotate(-90deg);
+  transition: transform 100ms ease;
+}
+
+.root[data-open] .chevron {
+  transform: rotate(0deg);
+}
+
+.label {
+  min-width: 0;
+  overflow: hidden;
+  font-size: 14px;
+  line-height: 24px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .chevron {
+    transition: none;
+  }
+}

+ 60 - 0
packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx

@@ -0,0 +1,60 @@
+import { memo } from 'react'
+import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ChatNodeViewProps } from '../contract/slots.ts'
+import css from './TurnProcessNodeView.module.css'
+
+/** Turn-level process disclosure controller. */
+export const TurnProcessNodeView = memo(function TurnProcessNodeView({
+  node, turnProcess, t,
+}: ChatNodeViewProps<'turn-process'>) {
+  if (turnProcess === undefined) throw new Error('turn-process node requires Turn process owner state')
+  if (!turnProcess.foldable) return null
+  const open = turnProcess.open
+  const labels: string[] = []
+  if (node.data.toolCallCount > 0) {
+    labels.push(t(
+      node.data.toolCallCount === 1
+        ? 'message.turnProcess.toolCalls.one'
+        : 'message.turnProcess.toolCalls.other',
+      { count: node.data.toolCallCount },
+    ))
+  }
+  if (node.data.messageCount > 0) {
+    labels.push(t(
+      node.data.messageCount === 1
+        ? 'message.turnProcess.messages.one'
+        : 'message.turnProcess.messages.other',
+      { count: node.data.messageCount },
+    ))
+  }
+  if (node.data.subagentCount > 0) {
+    labels.push(t(
+      node.data.subagentCount === 1
+        ? 'message.turnProcess.subagents.one'
+        : 'message.turnProcess.subagents.other',
+      { count: node.data.subagentCount },
+    ))
+  }
+  const label = labels.length === 0
+    ? t('message.turnProcess.thoughtForAWhile')
+    : labels.join(t('message.turnProcess.separator'))
+  return (
+    <button
+      type="button"
+      className={css.root}
+      data-open={open || undefined}
+      data-turn-process={node.data.turn}
+      data-turn-process-messages={node.data.messageCount}
+      data-turn-process-tool-calls={node.data.toolCallCount}
+      data-turn-process-subagents={node.data.subagentCount}
+      aria-expanded={open}
+      onClick={(event) => {
+        event.currentTarget.focus()
+        turnProcess.setOpen(!open)
+      }}
+    >
+      <span className={css.label}>{label}</span>
+      <IconChevronDownOutline14 className={css.chevron} />
+    </button>
+  )
+})

+ 4 - 1
packages/client/ui-chat/src/client/chat/register-node-renderers.ts

@@ -6,8 +6,9 @@ import {
   CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView,
   TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
 } from './MessageItem.tsx'
-import { TurnTailNodeView } from './TurnTailNodeView.tsx'
 import { SystemPromptNodeView } from './SystemPromptRow.tsx'
+import { TurnProcessNodeView } from './TurnProcessNodeView.tsx'
+import { TurnTailNodeView } from './TurnTailNodeView.tsx'
 
 /**
  * Register this package's business renderers behind the keyed Chat Node seat.
@@ -40,6 +41,8 @@ export function registerChatNodeRenderers(ctx: Context): void {
     { name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView))
   ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
     { name: 'conversation.chat.node', key: 'turn-max-tokens', locale: NS }, TurnMaxTokensNodeView))
+  ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
+    { name: 'conversation.chat.node', key: 'turn-process', locale: NS }, TurnProcessNodeView))
   ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({
     name: 'conversation.chat.node',
     key: 'turn-tail',

+ 31 - 0
packages/client/ui-chat/src/client/chat/searchable-hidden.ts

@@ -0,0 +1,31 @@
+import { useEffect, useLayoutEffect, useRef, type RefObject } from 'react'
+
+/**
+ * Apply searchable hidden state without unmounting a stable subtree.
+ * @param hidden - whether the subtree is currently hidden.
+ * @param reveal - callback for browser find's `beforematch` reveal.
+ * @returns ref for the stable subtree root.
+ */
+export function useSearchableHidden(
+  hidden: boolean,
+  reveal: () => void,
+): RefObject<HTMLDivElement> {
+  const ref = useRef<HTMLDivElement>(null)
+  useLayoutEffect(() => {
+    const element = ref.current
+    if (element === null) return
+    if (hidden && element.contains(element.ownerDocument.activeElement)) {
+      reveal()
+      return
+    }
+    if (hidden) element.setAttribute('hidden', 'until-found')
+    else element.removeAttribute('hidden')
+  }, [hidden, reveal])
+  useEffect(() => {
+    const element = ref.current
+    if (element === null) return
+    element.addEventListener('beforematch', reveal)
+    return () => { element.removeEventListener('beforematch', reveal) }
+  }, [reveal])
+  return ref
+}

+ 15 - 0
packages/client/ui-chat/src/client/contract/assistant-content.ts

@@ -0,0 +1,15 @@
+import type { AssistantBlock } from './snapshot.ts'
+
+/**
+ * Test whether Assistant blocks contain a user-facing reply rather than only
+ * reasoning or Tool-call protocol material.
+ * @param blocks - Assistant content blocks.
+ * @returns whether the blocks contain visible reply content.
+ */
+export function hasAssistantReplyContent(blocks: readonly AssistantBlock[]): boolean {
+  return blocks.some((block) => {
+    if (block.kind === 'reasoning' || block.kind === 'tool-call') return false
+    if (block.kind === 'text') return block.text.trim() !== ''
+    return true
+  })
+}

+ 13 - 0
packages/client/ui-chat/src/client/contract/chat-nodes.ts

@@ -97,6 +97,19 @@ export interface TurnTailChatData {
   readonly tokenUsage?: TurnTokenUsage
 }
 
+/** Turn-level process disclosure projected before the finalized answer. */
+export interface TurnProcessChatData {
+  readonly turn: number
+  readonly controlAnchorSeq: number
+  readonly processStartSeq: number
+  readonly answerAnchorSeq: number | null
+  readonly answerStep: number | null
+  readonly inlineReasoning: boolean
+  readonly messageCount: number
+  readonly toolCallCount: number
+  readonly subagentCount: number
+}
+
 /**
  * Test whether a Tool root has settled.
  * @param block - Tool root lifecycle value.

+ 17 - 0
packages/client/ui-chat/src/client/contract/slots.ts

@@ -7,12 +7,15 @@ import type {
   InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
   SnapshotSelectorHook,
 } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
 import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
 import type { createChatStore } from '../stores.ts'
 import type { ToolCallId, SelectionTarget } from './store.ts'
 import type { ChatNode, ChatNodeKind } from './chat-nodes.ts'
 import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } from './snapshot.ts'
+import type { TurnProcessSpec } from './turn-process.ts'
+import type { TranscriptViewMode } from '../../chat-settings.ts'
 
 /** Selector hook over the current Conversation binding's Chat target. */
 export type UseChat = SnapshotSelectorHook<ChatSnapshot>
@@ -65,6 +68,16 @@ export interface ChatNodeOwnerProps {
   forkAt: (seq: number) => void
   renderMessageImages: RenderMessageImages
   fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined
+  /** Turn-process state when this Node belongs to a projected Turn. */
+  turnProcess?: TurnProcessOwnerProps | undefined
+}
+
+/** Shared presentation state for one Turn-process answer generation. */
+export interface TurnProcessOwnerProps {
+  readonly spec: TurnProcessSpec
+  readonly foldable: boolean
+  readonly open: boolean
+  setOpen(open: boolean): void
 }
 
 /** Full props of one keyed Chat renderer. */
@@ -98,6 +111,10 @@ export interface ChatScrollPosition {
 
 /** Business callbacks injected into the Chat view. */
 export interface ChatViewInjected {
+  hooks: {
+    /** Persisted completed-Turn transcript presentation. */
+    transcriptView: SnapshotStore<TranscriptViewMode>
+  }
   openDetails: (target: SelectionTarget) => void
   openFile: (path: string) => Promise<void>
   loadOlder: () => void

+ 9 - 0
packages/client/ui-chat/src/client/contract/store.ts

@@ -1,5 +1,7 @@
 /** Chat-owned selection state shared by the transcript and details panel. */
 
+import type { TurnProcessGeneration } from './turn-process.ts'
+
 /** Tool call identity as carried by Chat nodes. */
 export type ToolCallId = string
 
@@ -11,7 +13,14 @@ export interface SelectionTarget {
   toolName?: string
 }
 
+/** One manually expanded Turn answer generation. */
+export interface TurnProcessViewEntry {
+  readonly turn: number
+  readonly generation: TurnProcessGeneration
+}
+
 /** Per-Session state shared only by the Chat view and details surface. */
 export interface ChatStoreState {
   selection: SelectionTarget | null
+  turnProcesses: TurnProcessViewEntry[]
 }

+ 100 - 0
packages/client/ui-chat/src/client/contract/turn-process.ts

@@ -0,0 +1,100 @@
+import type { ChatNode } from './chat-nodes.ts'
+
+/** Turn-local process window encoded as a reference-stable Location-data scalar. */
+export type TurnProcessSignature = string
+
+/** Stable identity of one finalized answer generation, independent of its exact ordering anchor. */
+export type TurnProcessGeneration = string
+
+/** Current process range and finalized answer boundary derived from one Turn. */
+export interface TurnProcessSpec {
+  readonly turn: number
+  /** Stable control-node anchor source, including currently ineligible evidence. */
+  readonly controlAnchorSeq: number
+  readonly processStartSeq: number
+  readonly answerAnchorSeq: number | null
+  readonly answerStep: number | null
+  readonly inlineReasoning: boolean
+  /** Reply-bearing durable Assistant messages before the final answer. */
+  readonly messageCount: number
+  /** Durable non-subagent Tool calls recorded by this Turn. */
+  readonly toolCallCount: number
+  /** Tool calls whose configured name identifies a subagent delegation. */
+  readonly subagentCount: number
+}
+
+const TURN_PROCESS_INDEPENDENT_KIND_LIST = [
+  'system-prompt',
+  'user',
+  'steering',
+  'turn-process',
+  'turn-error',
+  'turn-max-tokens',
+  'turn-tail',
+] as const satisfies readonly ChatNode['kind'][]
+
+/** Chat Node kinds that remain independent of a Turn's process disclosure. */
+export const TURN_PROCESS_INDEPENDENT_KINDS: ReadonlySet<string> = new Set(
+  TURN_PROCESS_INDEPENDENT_KIND_LIST,
+)
+
+/**
+ * Identify one finalized answer generation without using its ordering anchor.
+ * @param spec - current Turn process specification.
+ * @returns stable identity until the finalized answer Step is withdrawn or replaced.
+ */
+export function turnProcessGeneration(spec: TurnProcessSpec): TurnProcessGeneration {
+  return `${String(spec.turn)}|${spec.answerStep === null ? '' : String(spec.answerStep)}`
+}
+
+/**
+ * Encode one process specification as a primitive Location-data value.
+ * @param spec - current Turn process specification.
+ * @returns reference-stable scalar for equal specifications.
+ */
+export function encodeTurnProcess(spec: TurnProcessSpec): TurnProcessSignature {
+  return [
+    spec.turn,
+    spec.controlAnchorSeq,
+    spec.processStartSeq,
+    spec.answerAnchorSeq ?? '',
+    spec.answerStep ?? '',
+    spec.inlineReasoning ? 1 : 0,
+    spec.messageCount,
+    spec.toolCallCount,
+    spec.subagentCount,
+  ].join('|')
+}
+
+/**
+ * Decode a same-process signature produced by {@link encodeTurnProcess}.
+ * @param signature - encoded Turn process value.
+ * @returns decoded process specification.
+ */
+export function decodeTurnProcess(signature: TurnProcessSignature): TurnProcessSpec {
+  const [
+    turn, controlAnchorSeq, processStartSeq, answerAnchorSeq, answerStep, inlineReasoning,
+    messageCount, toolCallCount, subagentCount,
+  ] = signature.split('|')
+  return {
+    turn: Number(turn),
+    controlAnchorSeq: Number(controlAnchorSeq),
+    processStartSeq: Number(processStartSeq),
+    answerAnchorSeq: answerAnchorSeq === '' ? null : Number(answerAnchorSeq),
+    answerStep: answerStep === '' ? null : Number(answerStep),
+    inlineReasoning: inlineReasoning === '1',
+    messageCount: Number(messageCount),
+    toolCallCount: Number(toolCallCount),
+    subagentCount: Number(subagentCount),
+  }
+}
+
+/**
+ * Recognize the shipped subagent delegation name and its configured variants.
+ * Control tools use distinct names such as `send_message` and `list_agents`.
+ * @param name - durable Tool-call name.
+ * @returns whether the call creates or forks a subagent.
+ */
+export function isSubagentDelegationTool(name: string): boolean {
+  return name === 'subagent' || name.startsWith('subagent_')
+}

+ 99 - 6
packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts

@@ -9,6 +9,7 @@ import type {
   ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ChatTurnNavigationIndex, ConversationNode,
   LegacyConversationSlice, PartialAssistant, RunningToolCall, TurnNavigationItem,
 } from '../contract/snapshot.ts'
+import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
 import { sessionRecallLabels } from './event-projection.ts'
 import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts'
 
@@ -192,10 +193,101 @@ function locationCoordinates(location: ConversationLocation): { turn?: number; s
   return {}
 }
 
-function orderedVisible(nodes: readonly ChatConversationViewNode[]): ChatConversationViewNode[] {
-  return nodes
-    .filter(node => node.visibility === 'visible')
-    .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
+interface TurnProcessPresentation {
+  readonly control?: ChatNode<'turn-process'>
+  readonly openingHumanAnchor?: number
+  readonly earliestProcessAnchor?: number
+}
+
+function turnProcessPresentations(
+  nodes: readonly ChatConversationViewNode[],
+): ReadonlyMap<number, TurnProcessPresentation> {
+  const presentations = new Map<number, TurnProcessPresentation>()
+  for (const raw of nodes) {
+    const node = raw as ChatNode
+    if (node.kind === 'turn-process') {
+      presentations.set(node.data.turn, { ...presentations.get(node.data.turn), control: node })
+    }
+  }
+  for (const raw of nodes) {
+    const node = raw as ChatNode
+    const location = node.location
+    if (location.kind !== 'turn' && location.kind !== 'step') continue
+    const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {}
+    if ((node.kind === 'user' || node.kind === 'steering')
+      && node.anchorSeq < (current.control?.data.controlAnchorSeq ?? Number.POSITIVE_INFINITY)) {
+      presentations.set(location.turn.turn, {
+        ...current,
+        openingHumanAnchor: Math.min(current.openingHumanAnchor ?? node.anchorSeq, node.anchorSeq),
+      })
+      continue
+    }
+    if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) continue
+    presentations.set(location.turn.turn, {
+      ...current,
+      earliestProcessAnchor: Math.min(current.earliestProcessAnchor ?? node.anchorSeq, node.anchorSeq),
+    })
+  }
+  return presentations
+}
+
+interface PresentationPosition {
+  readonly anchor: number
+  readonly rank: number
+  readonly originalAnchor: number
+}
+
+function presentationPosition(
+  raw: ChatConversationViewNode,
+  presentations: ReadonlyMap<number, TurnProcessPresentation>,
+): PresentationPosition {
+  const node = raw as ChatNode
+  const location = node.location
+  if (location.kind !== 'turn' && location.kind !== 'step') {
+    return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
+  }
+  const presentation = presentations.get(location.turn.turn)
+  if (presentation === undefined) {
+    return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
+  }
+  const openingHumanAnchor = presentation.openingHumanAnchor
+  if (openingHumanAnchor !== undefined
+    && node.anchorSeq < openingHumanAnchor
+    && !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) {
+    return { anchor: openingHumanAnchor, rank: 2, originalAnchor: node.anchorSeq }
+  }
+  if (presentation.control !== undefined && node.key === presentation.control.key) {
+    return openingHumanAnchor === undefined
+      ? {
+        anchor: presentation.earliestProcessAnchor ?? node.anchorSeq,
+        rank: -1,
+        originalAnchor: node.anchorSeq,
+      }
+      : { anchor: openingHumanAnchor, rank: 1, originalAnchor: node.anchorSeq }
+  }
+  return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
+}
+
+/**
+ * Order visible Chat Nodes without changing existing relative order as process
+ * eligibility changes. Opening human input precedes process candidates, while
+ * each synthetic process control sits between them.
+ * @param nodes - currently materialized Chat Nodes.
+ * @returns visible Nodes in presentation order.
+ */
+export function orderedVisibleChatNodes(
+  nodes: readonly ChatConversationViewNode[],
+): ChatConversationViewNode[] {
+  const visible = nodes.filter(node => node.visibility === 'visible')
+  const presentations = turnProcessPresentations(visible)
+  return visible.sort((left, right) => {
+    const leftPosition = presentationPosition(left, presentations)
+    const rightPosition = presentationPosition(right, presentations)
+    return leftPosition.anchor - rightPosition.anchor
+      || leftPosition.rank - rightPosition.rank
+      || leftPosition.originalAnchor - rightPosition.originalAnchor
+      || left.key.localeCompare(right.key)
+  })
 }
 
 function referenceMessageSeq(node: ChatConversationViewNode): number | undefined {
@@ -556,7 +648,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
   }): ChatSnapshot {
     const nodes = this.referenceLabels.replace(input.nodes)
     this.store.replace(nodes)
-    this.order = orderedVisible(nodes).map(node => node.key)
+    this.order = orderedVisibleChatNodes(nodes).map(node => node.key)
     this.locations.rebuild(this.order, this.store)
     this.navigation.rebuild(input.timeline, this.locations, this.store)
     this.timeline = input.timeline
@@ -573,6 +665,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
     for (const node of upserts) {
       const previous = this.store.get(node.key)
       const nodeStructural = previous === undefined
+        || previous.kind !== node.kind
         || previous.anchorSeq !== node.anchorSeq
         || previous.visibility !== node.visibility
         || locationIdentity(previous.location) !== locationIdentity(node.location)
@@ -581,7 +674,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
     }
     this.store.upsert(upserts)
     if (structural) {
-      const next = orderedVisible(this.store.values()).map(node => node.key)
+      const next = orderedVisibleChatNodes(this.store.values()).map(node => node.key)
       this.order = sameReferences(this.order, next) ? this.order : next
       this.locations.rebuild(this.order, this.store)
     }

+ 1 - 0
packages/client/ui-chat/src/client/conversation-nodes/common.ts

@@ -14,6 +14,7 @@ import type {
 export const CHAT_SYNTHETIC_SEQ_OFFSETS = {
   interruptedAssistant: -0.9,
   interruptedFollowup: -0.8,
+  processControl: -0.1,
   maxTokensNotice: 0.05,
   finalizedFollowup: 0.1,
 } as const

+ 2 - 0
packages/client/ui-chat/src/client/conversation-nodes/register.ts

@@ -11,6 +11,7 @@ import { registerRetryConversationNode } from './retry.ts'
 import { registerToolConversationNode } from './tool.ts'
 import { registerTurnErrorConversationNode } from './turn-error.ts'
 import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts'
+import { registerTurnProcess } from './turn-process.ts'
 import { registerTurnTailConversationNode } from './turn-tail.ts'
 
 /**
@@ -22,6 +23,7 @@ export function registerConversationNodes(ctx: Context): void {
   registerMessageConversationNode(ctx)
   registerRequestPromptConversationNode(ctx)
   registerAssistantConversationNode(ctx)
+  registerTurnProcess(ctx)
   registerToolConversationNode(ctx)
   registerCommandConversationNode(ctx)
   registerCompactionConversationNode(ctx)

+ 22 - 3
packages/client/ui-chat/src/client/conversation-nodes/request-prompt.ts

@@ -1,7 +1,8 @@
 import type { Context } from '@deepseek-ai/cordis'
 import type {
-  ConversationMatch, ConversationNodeDefinition, RequestPromptInspector,
+  ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RequestPromptInspector,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { ChatNode } from '../contract/chat-nodes.ts'
 import { chatNode } from './common.ts'
 
 declare module '../contract/chat-nodes.ts' {
@@ -33,6 +34,19 @@ function requestPromptAnchor(
     : match.location.step.start?.seq ?? match.event.seq
 }
 
+/** Keep an already rendered prompt at its page-lifetime presentation anchor. */
+function stableRequestPromptAnchor(
+  context: ConversationNodeContext<RequestPromptState>,
+  match: ConversationMatch,
+  previous: Readonly<RequestPromptState> | undefined,
+  isInitial: boolean,
+): number {
+  const current = context.current.get('chat') as ChatNode | null | undefined
+  return current?.kind === 'system-prompt'
+    ? current.anchorSeq
+    : requestPromptAnchor(match, previous, isInitial)
+}
+
 /**
  * Request-header prompt Definition for the Chat target.
  * @param inspect - the shared prompt interpretation, supplied by the
@@ -46,7 +60,7 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver
     match: event => event.type === 'request/header'
       ? { id: String(event.seq), role: 'start' }
       : null,
-    start: (_context, match, reader) => {
+    start: (context, match, reader) => {
       if (match.event.type !== 'request/header') {
         throw new Error('request-prompt start requires request/header')
       }
@@ -57,7 +71,12 @@ export function requestPromptDefinition(inspect: RequestPromptInspector): Conver
       const inspection = inspect(previous?.prompt, match.event)
       const change = inspection.change?.kind
       return {
-        anchorSeq: requestPromptAnchor(match, previous, match.event.data.reason === 'initial'),
+        anchorSeq: stableRequestPromptAnchor(
+          context,
+          match,
+          previous,
+          match.event.data.reason === 'initial',
+        ),
         showsPrompt: previous === undefined
           || match.event.data.reason !== 'change'
           || match.event.data.startsSeries === true

+ 283 - 0
packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts

@@ -0,0 +1,283 @@
+import type { Context } from '@deepseek-ai/cordis'
+import type { ChunkRowEvent } from '@deepseek-ai/dsh-api-session-controller/types'
+import type {
+  ConversationLocation, ConversationNodeContext, ConversationNodeDefinition, TurnLocation,
+} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type {} from '@deepseek-ai/dsh-llm-retry/types'
+import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
+import type {} from '@deepseek-ai/dsh-tools/types'
+import { hasAssistantReplyContent } from '../contract/assistant-content.ts'
+import type { AssistantChatData, FinalAssistantChatData } from '../contract/chat-nodes.ts'
+import {
+  decodeTurnProcess, encodeTurnProcess, isSubagentDelegationTool,
+  type TurnProcessSignature, type TurnProcessSpec,
+} from '../contract/turn-process.ts'
+import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
+import { toAssistantBlocks } from './event-projection.ts'
+
+declare module '../contract/chat-nodes.ts' {
+  interface ChatNodeDataMap {
+    /** Turn-level disclosure controlling process rows before the finalized answer. */
+    'turn-process': import('../contract/chat-nodes.ts').TurnProcessChatData
+  }
+}
+
+declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
+  interface ConversationTurnDataMap {
+    /** Encoded process range and finalized answer boundary for this Turn. */
+    'turn-process': TurnProcessSignature
+  }
+}
+
+interface TurnProcessState {
+  readonly turn: number
+  readonly assistantStartByStep: ReadonlyMap<number, number>
+  readonly messageCountByStep: ReadonlyMap<number, number>
+  readonly otherStartSeq?: number
+  readonly toolCallCount: number
+  readonly subagentCount: number
+}
+
+type ConversationEvent = Parameters<ConversationNodeDefinition['match']>[0]
+
+function isChunkRunEvent(event: ConversationEvent): event is ChunkRowEvent {
+  return event.type === 'chunkrow/text-chunks'
+    || event.type === 'chunkrow/reasoning-chunks'
+    || event.type === 'chunkrow/tool-call-chunks'
+}
+
+function eventTurn(event: ConversationEvent): number | undefined {
+  const data = event.data as unknown as { turn?: unknown }
+  return typeof data.turn === 'number' ? data.turn : undefined
+}
+
+function visibleAssistantEvent(event: ConversationEvent): boolean {
+  if (event.type === 'assistant/chunk') {
+    const chunk = event.data.chunk
+    if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') return chunk.text.trim() !== ''
+    if (chunk.type === 'block-start') {
+      return chunk.blockType !== 'text'
+        && chunk.blockType !== 'reasoning'
+        && chunk.blockType !== 'tool-call'
+    }
+    if (chunk.type !== 'block-end') return false
+    const block = chunk.block
+    if (block.type === 'tool-call') return false
+    if (block.type === 'text' || block.type === 'reasoning') return block.text.trim() !== ''
+    return true
+  }
+  return event.type === 'assistant/message'
+    && isAppendSurfaceEvent(event)
+    && toAssistantBlocks(event.data.message.content).some((block) => {
+      if (block.kind === 'tool-call') return false
+      if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== ''
+      return true
+    })
+}
+
+type ProcessEvidence =
+  | { readonly kind: 'assistant'; readonly seq: number; readonly step: number }
+  | { readonly kind: 'other'; readonly seq: number }
+
+function processEvidence(event: ConversationEvent): ProcessEvidence | undefined {
+  if (isChunkRunEvent(event)) {
+    if (event.type === 'chunkrow/tool-call-chunks') return undefined
+    const firstVisible = event.data.texts.findIndex(text => text.trim() !== '')
+    return firstVisible < 0
+      ? undefined
+      : { kind: 'assistant', seq: event.seq + firstVisible, step: event.data.step }
+  }
+  if (visibleAssistantEvent(event)) {
+    if (event.type !== 'assistant/chunk' && event.type !== 'assistant/message') return undefined
+    return { kind: 'assistant', seq: event.seq, step: event.data.step }
+  }
+  if (event.type === 'tool/call'
+    || (event.type === 'tool/result' && isAppendSurfaceEvent(event))
+    || event.type === 'llm/retry') return { kind: 'other', seq: event.seq }
+  return undefined
+}
+
+function turnLocation(context: ConversationNodeContext<TurnProcessState>): TurnLocation | undefined {
+  const location: ConversationLocation | undefined = context.start?.location ?? context.matches.at(-1)?.location
+  return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined
+}
+
+function fallbackState(context: ConversationNodeContext<TurnProcessState>): TurnProcessState | undefined {
+  const turn = context.matches.map(match => eventTurn(match.event)).find(candidate => candidate !== undefined)
+  if (turn === undefined) return undefined
+  let state: TurnProcessState = {
+    turn,
+    assistantStartByStep: new Map(),
+    messageCountByStep: new Map(),
+    toolCallCount: 0,
+    subagentCount: 0,
+  }
+  for (const match of context.matches) state = updateProcessState(state, match.event)
+  return state
+}
+
+function isFinalAssistant(
+  data: Readonly<AssistantChatData> | undefined,
+): data is Readonly<FinalAssistantChatData> {
+  return data?.finalNode !== undefined
+}
+
+function latestAnswer(turn: TurnLocation): Readonly<FinalAssistantChatData> | null {
+  const latestStep = turn.steps.at(-1)
+  const data: Readonly<AssistantChatData> | undefined = latestStep?.data.get('assistant-step')
+  if (!isFinalAssistant(data) || !hasAssistantReplyContent(data.blocks)) return null
+  return data.blocks.some(block => block.kind === 'tool-call') ? null : data
+}
+
+function processSpec(state: TurnProcessState, turn: TurnLocation): TurnProcessSpec | null {
+  const controlAnchorSeq = Math.min(
+    state.otherStartSeq ?? Number.POSITIVE_INFINITY,
+    ...state.assistantStartByStep.values(),
+  )
+  if (!Number.isFinite(controlAnchorSeq)) return null
+  const answer = latestAnswer(turn)
+  const counts = {
+    messageCount: answer === null
+      ? [...state.messageCountByStep.values()].reduce((total, count) => total + count, 0)
+      : [...state.messageCountByStep]
+        .filter(([step]) => step < answer.step)
+        .reduce((total, [, count]) => total + count, 0),
+    toolCallCount: state.toolCallCount,
+    subagentCount: state.subagentCount,
+  }
+  if (answer === null) {
+    return {
+      turn: turn.turn,
+      controlAnchorSeq,
+      processStartSeq: controlAnchorSeq,
+      answerAnchorSeq: null,
+      answerStep: null,
+      inlineReasoning: false,
+      ...counts,
+    }
+  }
+  const inlineReasoning = answer.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '')
+  const earlierAssistantSeq = Math.min(
+    ...[...state.assistantStartByStep]
+      .filter(([step]) => step < answer.step)
+      .map(([, seq]) => seq),
+  )
+  const externalProcessSeq = Math.min(
+    state.otherStartSeq ?? Number.POSITIVE_INFINITY,
+    earlierAssistantSeq,
+  )
+  return {
+    turn: turn.turn,
+    controlAnchorSeq,
+    processStartSeq: turn.start?.seq
+      ?? (Number.isFinite(externalProcessSeq) ? externalProcessSeq : answer.finalNode.seq),
+    answerAnchorSeq: answer.finalNode.seq,
+    answerStep: answer.step,
+    inlineReasoning,
+    ...counts,
+  }
+}
+
+function updateProcessState(state: TurnProcessState, event: ConversationEvent): TurnProcessState {
+  let current = state
+  if (event.type === 'assistant/message'
+    && isAppendSurfaceEvent(event)
+    && hasAssistantReplyContent(toAssistantBlocks(event.data.message.content))) {
+    const messageCountByStep = new Map(current.messageCountByStep)
+    messageCountByStep.set(event.data.step, (messageCountByStep.get(event.data.step) ?? 0) + 1)
+    current = { ...current, messageCountByStep }
+  }
+  if (event.type === 'tool/call') {
+    const subagent = isSubagentDelegationTool(event.data.name)
+    current = {
+      ...current,
+      toolCallCount: current.toolCallCount + (subagent ? 0 : 1),
+      subagentCount: current.subagentCount + (subagent ? 1 : 0),
+    }
+  }
+  const evidence = processEvidence(event)
+  if (evidence === undefined) return current
+  if (evidence.kind === 'other') {
+    return current.otherStartSeq === undefined ? { ...current, otherStartSeq: evidence.seq } : current
+  }
+  if (current.assistantStartByStep.has(evidence.step)) return current
+  const assistantStartByStep = new Map(current.assistantStartByStep)
+  assistantStartByStep.set(evidence.step, evidence.seq)
+  return { ...current, assistantStartByStep }
+}
+
+/** Turn-scoped process range and answer-boundary Definition. */
+export const turnProcessDefinition: ConversationNodeDefinition<TurnProcessState> = {
+  kind: 'turn-process',
+  target: 'chat',
+  match: (event) => {
+    if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
+    const turn = eventTurn(event)
+    if (turn === undefined) return null
+    if (event.type === 'assistant/chunk'
+      || event.type === 'assistant/message'
+      || isChunkRunEvent(event)
+      || event.type === 'tool/call'
+      || event.type === 'tool/result'
+      || event.type === 'llm/retry'
+      || event.type === 'step/start'
+      || event.type === 'step/end'
+      || event.type === 'turn/end') {
+      return { id: String(turn), role: 'update' }
+    }
+    return null
+  },
+  start: (_context, match) => {
+    if (match.event.type !== 'turn/start') throw new Error('turn-process start requires turn/start')
+    return {
+      turn: match.event.data.turn,
+      assistantStartByStep: new Map(),
+      messageCountByStep: new Map(),
+      toolCallCount: 0,
+      subagentCount: 0,
+    }
+  },
+  update: (context, match) => updateProcessState(context.state, match.event),
+  publication: (match) => {
+    if (isChunkRunEvent(match.event)) return 'animation-frame'
+    if (match.event.type === 'assistant/chunk') {
+      const type = match.event.data.chunk.type
+      return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'
+    }
+    return 'immediate'
+  },
+  buildLocationData: (context, scope) => {
+    if (scope !== 'turn') return null
+    const state = context.state ?? fallbackState(context)
+    if (state === undefined) return null
+    const turn = turnLocation(context)
+    if (turn === undefined) return null
+    const spec = processSpec(state, turn)
+    return spec === null ? null : {
+      kind: 'turn',
+      turn: turn.turn,
+      key: 'turn-process',
+      value: encodeTurnProcess(spec),
+    }
+  },
+  buildViewNode: (context) => {
+    const turn = turnLocation(context)
+    const signature = turn?.data.get('turn-process')
+    if (turn === undefined || signature === undefined) return null
+    const data = decodeTurnProcess(signature)
+    return chatNode(
+      context,
+      'turn-process',
+      data.controlAnchorSeq + CHAT_SYNTHETIC_SEQ_OFFSETS.processControl,
+      data,
+    )
+  },
+}
+
+/**
+ * Register the Turn-scoped process disclosure projection.
+ * @param ctx - owning UI Conversation context.
+ */
+export function registerTurnProcess(ctx: Context): void {
+  ctx.uiConversation.events.register(turnProcessDefinition)
+}

+ 10 - 3
packages/client/ui-chat/src/client/index.ts

@@ -10,6 +10,7 @@ export type {} from './conversation-nodes/retry.ts'
 export type {} from './conversation-nodes/tool.ts'
 export type {} from './conversation-nodes/turn-error.ts'
 export type {} from './conversation-nodes/turn-max-tokens.ts'
+export type {} from './conversation-nodes/turn-process.ts'
 export type {} from './conversation-nodes/turn-tail.ts'
 
 export type {
@@ -23,15 +24,21 @@ export type {
 export type {
   AssistantChatData, ChatConversationViewNode, ChatNode, ChatNodeKind,
   FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData,
-  TurnTailChatData,
+  TurnProcessChatData, TurnTailChatData,
 } from './contract/chat-nodes.ts'
-export type { ToolCallId, ChatStoreState, SelectionTarget } from './contract/store.ts'
+export type { ChatStoreState, SelectionTarget, ToolCallId, TurnProcessViewEntry } from './contract/store.ts'
+export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx'
+export type { TranscriptViewMode } from '../chat-settings.ts'
 export type {
   AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected,
   ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps,
   CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps,
-  DetailsToolOwnerProps, MessageImagesProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
+  DetailsToolOwnerProps, MessageImagesProps,
+  TurnProcessOwnerProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData,
 } from './contract/slots.ts'
+export type {
+  TurnProcessGeneration, TurnProcessSignature, TurnProcessSpec,
+} from './contract/turn-process.ts'
 export type { ChatKey } from './locale.ts'
 export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts'
 export type {

+ 24 - 0
packages/client/ui-chat/src/client/locale.ts

@@ -32,6 +32,10 @@ export const zh = {
   'chat.turnNavigation.label': '轮次导航',
   'chat.turnNavigation.jump': '跳转到第 {turn} 轮',
   'chat.turnNavigation.turn': '第 {turn} 轮',
+  'settings.transcript.title': '对话显示',
+  'settings.transcript.description': '控制已完成轮次的过程内容',
+  'settings.transcript.normal': 'Normal',
+  'settings.transcript.compact': 'Compact',
   'fileOpen.title': '无法打开文件',
   'fileOpen.unknown': '无法打开此文件',
   'fileOpen.folderTitle': '无法打开文件夹',
@@ -61,6 +65,14 @@ export const zh = {
   'message.think': '思考',
   'message.unknownSurface': '未知 surface 事件:{type}',
   'message.unknownBlock': '未知内容块',
+  'message.turnProcess.toolCalls.one': '{count} 次工具调用',
+  'message.turnProcess.toolCalls.other': '{count} 次工具调用',
+  'message.turnProcess.messages.one': '{count} 条消息',
+  'message.turnProcess.messages.other': '{count} 条消息',
+  'message.turnProcess.subagents.one': '{count} 个 subagent',
+  'message.turnProcess.subagents.other': '{count} 个 subagent',
+  'message.turnProcess.thoughtForAWhile': '已思考',
+  'message.turnProcess.separator': ' · ',
   'message.stopped': '已停止',
   'message.branch': '在新对话中分支',
   'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支',
@@ -133,6 +145,10 @@ export const en = {
   'chat.turnNavigation.label': 'Turn navigation',
   'chat.turnNavigation.jump': 'Jump to turn {turn}',
   'chat.turnNavigation.turn': 'Turn {turn}',
+  'settings.transcript.title': 'Conversation display',
+  'settings.transcript.description': 'Controls process content in completed turns',
+  'settings.transcript.normal': 'Normal',
+  'settings.transcript.compact': 'Compact',
   'fileOpen.title': 'Couldn’t open file',
   'fileOpen.unknown': 'Couldn’t open this file',
   'fileOpen.folderTitle': 'Couldn’t open folder',
@@ -162,6 +178,14 @@ export const en = {
   'message.think': 'Think',
   'message.unknownSurface': 'Unknown surface event: {type}',
   'message.unknownBlock': 'Unknown content block',
+  'message.turnProcess.toolCalls.one': '{count} tool call',
+  'message.turnProcess.toolCalls.other': '{count} tool calls',
+  'message.turnProcess.messages.one': '{count} message',
+  'message.turnProcess.messages.other': '{count} messages',
+  'message.turnProcess.subagents.one': '{count} subagent',
+  'message.turnProcess.subagents.other': '{count} subagents',
+  'message.turnProcess.thoughtForAWhile': 'Thought for a while',
+  'message.turnProcess.separator': ' · ',
   'message.stopped': 'Stopped',
   'message.branch': 'Branch into a new conversation',
   'message.branchUnavailable': 'Available only on the last message of a completed turn',

+ 56 - 0
packages/client/ui-chat/src/client/settings/TranscriptViewRow.module.css

@@ -0,0 +1,56 @@
+/* Completed-Turn transcript preference row: label plus selector pill. */
+
+.row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 16px 0;
+  border-bottom: 1px solid var(--dsw-alias-border-l2);
+}
+
+.rowText {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  padding-right: 48px;
+}
+
+.title {
+  font-size: 14px;
+  font-weight: 400;
+  line-height: 22px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.desc {
+  font-size: 12px;
+  font-weight: 400;
+  line-height: 18px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.selector {
+  display: inline-flex;
+  align-items: center;
+  gap: 12px;
+  height: 36px;
+  padding: 0 14px;
+  border: none;
+  border-radius: 18px;
+  background: var(--dsw-alias-bg-module-platform);
+  font: inherit;
+  font-size: 14px;
+  line-height: 22px;
+  color: var(--dsw-alias-label-primary);
+  cursor: pointer;
+}
+
+.selector:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.chevron {
+  flex: none;
+}

+ 79 - 0
packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx

@@ -0,0 +1,79 @@
+/** General Settings row for completed-Turn transcript presentation. */
+
+import { useState } from 'react'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { TranscriptViewMode } from '../../chat-settings.ts'
+import type { ChatKey } from '../locale.ts'
+import css from './TranscriptViewRow.module.css'
+
+/** Registration-side transcript preference face. */
+export interface TranscriptViewRowInjected {
+  hooks: {
+    /** Persisted transcript preference bound as useTranscriptView. */
+    transcriptView: SnapshotStore<TranscriptViewMode>
+  }
+  /** Change the completed-Turn transcript presentation. */
+  setTranscriptView: (mode: TranscriptViewMode) => void
+}
+
+/** Full Settings-row props. */
+export type TranscriptViewRowProps =
+  PropsRuntime<'settings.general.item'>
+  & PropsLocale<'chat'>
+  & InjectFace<TranscriptViewRowInjected>
+
+const OPTIONS: readonly { id: TranscriptViewMode; label: ChatKey }[] = [
+  { id: 'normal', label: 'settings.transcript.normal' },
+  { id: 'compact', label: 'settings.transcript.compact' },
+]
+
+/**
+ * Render the completed-Turn transcript mode selector.
+ * @param props - composed Settings slot props.
+ * @returns the preference row.
+ */
+export function TranscriptViewRow({ useTranscriptView, setTranscriptView, t }: TranscriptViewRowProps) {
+  const mode = useTranscriptView(value => value)
+  const [open, setOpen] = useState(false)
+  const selectedLabel = mode === 'normal'
+    ? 'settings.transcript.normal'
+    : 'settings.transcript.compact'
+  const closeMenu = () => { setOpen(false) }
+  const selectMode = (id: string) => {
+    closeMenu()
+    setTranscriptView(id as TranscriptViewMode)
+  }
+  const selector = (
+    <button
+      type="button"
+      className={css.selector}
+      aria-haspopup="menu"
+      aria-expanded={open}
+      onClick={() => { setOpen(value => !value) }}
+    >
+      {t(selectedLabel)}
+      <IconChevronDownOutline14 className={css.chevron} />
+    </button>
+  )
+
+  return (
+    <div className={css.row}>
+      <div className={css.rowText}>
+        <div className={css.title}>{t('settings.transcript.title')}</div>
+        <div className={css.desc}>{t('settings.transcript.description')}</div>
+      </div>
+      <Menu
+        open={open}
+        onClose={closeMenu}
+        items={OPTIONS.map(option => ({ id: option.id, label: t(option.label) }))}
+        selectedId={mode}
+        onSelect={selectMode}
+        align="end"
+        portal
+        anchor={selector}
+      />
+    </div>
+  )
+}

+ 32 - 2
packages/client/ui-chat/src/client/stores.ts

@@ -1,9 +1,29 @@
 /** Per-Session Chat selection store shared by the transcript and details panel. */
 import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
-import type { ChatStoreState, SelectionTarget } from './contract/store.ts'
+import type { TurnProcessGeneration } from './contract/turn-process.ts'
+import type { ChatStoreState, SelectionTarget, TurnProcessViewEntry } from './contract/store.ts'
 
 type ChatActions = {
   select: (draft: ChatStoreState, target: SelectionTarget | null) => void
+  setTurnProcessOpen: (
+    draft: ChatStoreState,
+    turn: number,
+    generation: TurnProcessGeneration,
+    open: boolean,
+  ) => void
+}
+
+/**
+ * Resolve any stored generation for one Turn.
+ * @param state - Chat store snapshot.
+ * @param turn - owning Turn.
+ * @returns the Turn's stored entry, when present.
+ */
+export function storedTurnProcessEntry(
+  state: Readonly<ChatStoreState>,
+  turn: number,
+): Readonly<TurnProcessViewEntry> | undefined {
+  return state.turnProcesses.find(entry => entry.turn === turn)
 }
 
 /**
@@ -12,9 +32,19 @@ type ChatActions = {
  */
 export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
   return defineStore({
-    init: (): ChatStoreState => ({ selection: null }),
+    init: (): ChatStoreState => ({ selection: null, turnProcesses: [] }),
     actions: {
       select: (draft, target: SelectionTarget | null) => { draft.selection = target },
+      setTurnProcessOpen: (draft, turn, generation, open) => {
+        const index = draft.turnProcesses.findIndex(entry => entry.turn === turn)
+        if (!open) {
+          if (index >= 0) draft.turnProcesses.splice(index, 1)
+          return
+        }
+        const next = { turn, generation } satisfies TurnProcessViewEntry
+        if (index < 0) draft.turnProcesses.push(next)
+        else draft.turnProcesses[index] = next
+      },
     },
   })
 }

+ 39 - 0
packages/client/ui-chat/src/client/transcript-view.ts

@@ -0,0 +1,39 @@
+/** Host-backed completed-Turn transcript presentation policy. */
+
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
+import {
+  DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
+  type ChatSettings, type TranscriptViewMode,
+} from '../chat-settings.ts'
+
+/** Live transcript preference consumed by Chat and its Settings row. */
+export class TranscriptViewPolicy {
+  /** Reactive current mode; defaults to Compact before Host settings arrive. */
+  readonly mode: SnapshotStore<TranscriptViewMode> = createSnapshotStore(DEFAULT_TRANSCRIPT_VIEW_MODE)
+
+  /**
+   * @param host - durable Chat settings scope.
+   */
+  constructor(private readonly host: SettingsScope<ChatSettings>) {
+    host.subscribe(() => { this.adopt() })
+    this.adopt()
+  }
+
+  /**
+   * Publish and persist one explicit user choice.
+   * @param mode - Normal or Compact transcript presentation.
+   */
+  setMode(mode: TranscriptViewMode): void {
+    if (this.mode.getSnapshot() === mode) return
+    this.mode.set(mode)
+    void this.host.set(TRANSCRIPT_VIEW_FIELD, mode)
+  }
+
+  /** Adopt the latest accepted Host section without writing it back. */
+  private adopt(): void {
+    const section = this.host.getSnapshot().value
+    if (section === undefined || this.mode.getSnapshot() === section.transcriptView) return
+    this.mode.set(section.transcriptView)
+  }
+}

+ 19 - 3
packages/client/ui-chat/src/index.ts

@@ -1,4 +1,20 @@
-/** Host loader entry for the browser-only Chat UI target. */
+/** Host registration for browser Chat preferences. */
 
-/** Provides no Host-side behavior. */
-export function apply(): void {}
+import type { Context } from '@deepseek-ai/cordis'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
+import { CHAT_SETTINGS_NAMESPACE, ChatSettingsSchema } from './chat-settings.ts'
+
+export {
+  CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, TRANSCRIPT_VIEW_FIELD,
+  TRANSCRIPT_VIEW_MODES, type ChatSettings, type TranscriptViewMode,
+} from './chat-settings.ts'
+
+/** Register the durable Chat settings section when a provider exists. */
+export function apply(ctx: Context): void {
+  ctx.inject(['settings'], (settingsCtx) => {
+    settingsCtx.settings.register(
+      settingsNamespace(CHAT_SETTINGS_NAMESPACE),
+      ChatSettingsSchema,
+    )
+  })
+}

+ 2 - 2
packages/client/ui-chat/tests/approval-command.client.spec.tsx

@@ -61,9 +61,9 @@ describe('ApprovalCommand', () => {
 })
 
 describe('ui-chat package entries', () => {
-  it('keeps the Host half inert and registers the invariant companion', async () => {
-    expect(() => { nodeApply() }).not.toThrow()
+  it('keeps the Host half optional and registers the invariant companion', async () => {
     const ctx = new Context()
+    expect(() => { nodeApply(ctx) }).not.toThrow()
     await ctx.plugin(InvariantRegistry, { enabled: true })
 
     await expect(ctx.plugin(ChatInvariant).await()).resolves.toBeDefined()

+ 29 - 3
packages/client/ui-chat/tests/chat-apply.client.spec.tsx

@@ -15,8 +15,9 @@ import {
   apply as applyChat, EMPTY_CHAT_SNAPSHOT, inject as injectChat,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {
-  ChatNodeTurnDataInjected, ChatSnapshot, UseChat,
+  ChatNodeTurnDataInjected, ChatSnapshot, TranscriptViewRowInjected, UseChat,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
+import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
   interface ConversationTurnDataMap {
@@ -30,7 +31,12 @@ const SID = 'session-1' as SessionId
 
 async function bench() {
   const runtime = await SlotTestRuntime.create()
-  runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
+  const chatSettings = stubSettingsScope<ChatSettings>()
+  runtime.ctx.provide('settingsScope', {
+    bind: ({ namespace }: { namespace: string }) => namespace === CHAT_SETTINGS_NAMESPACE
+      ? chatSettings.scope
+      : stubSettingsScope().scope,
+  } as never)
   runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() } as never)
   runtime.ctx.provide('uiWorkspace', {
     connectWorkspace: vi.fn(async () => SID),
@@ -53,7 +59,7 @@ async function bench() {
   const chat = await runtime.mount({ inject: [...injectChat], apply: applyChat })
   const sourceDescriptor = provide.mock.calls[0]?.[0]
   if (sourceDescriptor === undefined) throw new Error('ui-chat did not provide its standard source')
-  return { runtime, conversation, chat, sourceDescriptor }
+  return { runtime, conversation, chat, chatSettings, sourceDescriptor }
 }
 
 function storeOf(runtime: SlotTestRuntime, key: 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') {
@@ -70,10 +76,30 @@ describe('Chat apply wiring', () => {
       .toMatchObject({ kind: 'keyed', scope: 'session' })
     expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id))
       .toEqual(['stats'])
+    expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id))
+      .toEqual(['transcript-view', 'composer-enter'])
     expect(b.runtime.slots.entries('details')).toHaveLength(1)
     await b.runtime.dispose()
   })
 
+  it('mirrors the Host transcript preference into its Settings row', async () => {
+    const b = await bench()
+    const row = b.runtime.slots.entries('settings.general.item')
+      .find(entry => entry.options.id === 'transcript-view')!
+    const face = (row.inject as unknown as () => TranscriptViewRowInjected)()
+
+    expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
+    face.setTranscriptView('normal')
+    expect(face.hooks.transcriptView.getSnapshot()).toBe('normal')
+    expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'normal')
+
+    b.chatSettings.publish({
+      status: 'ready', value: { transcriptView: 'compact' }, revision: 1, writable: true,
+    })
+    expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
+    await b.runtime.dispose()
+  })
+
   it('shares one Chat store while keeping it distinct from Conversation state', async () => {
     const b = await bench()
     const conversationStore = storeOf(b.runtime, 'conversation.session')

+ 37 - 0
packages/client/ui-chat/tests/chat-settings.client.spec.ts

@@ -0,0 +1,37 @@
+import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, it } from 'vitest'
+import { SettingsProvider, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
+import {
+  CHAT_SETTINGS_NAMESPACE, DEFAULT_TRANSCRIPT_VIEW_MODE, apply,
+} from '../src/index.ts'
+
+class MemorySettings extends SettingsProvider {
+  readonly writable = true
+  protected load(): Promise<Record<string, unknown>> { return Promise.resolve({}) }
+  protected persist(_ns: SettingsNamespace, _section: Record<string, unknown>): Promise<void> {
+    return Promise.resolve()
+  }
+}
+
+describe('ui-chat Host settings', () => {
+  it('registers, validates, and disposes the transcript-view namespace', async () => {
+    const ctx = new Context()
+    await ctx.plugin(MemorySettings).await()
+    const fiber = ctx.plugin({ apply })
+    await fiber.await()
+    const ns = settingsNamespace(CHAT_SETTINGS_NAMESPACE)
+
+    expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE })
+    await ctx.settings.update(ns, { transcriptView: 'normal' })
+    expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal' })
+    await expect(ctx.settings.update(ns, { transcriptView: 'dense' })).rejects.toThrow()
+
+    await fiber.dispose()
+    expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
+  })
+
+  it('loads without a settings provider', async () => {
+    const ctx = new Context()
+    await expect(ctx.plugin({ apply }).await()).resolves.toBeDefined()
+  })
+})

+ 125 - 11
packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts

@@ -1,7 +1,7 @@
 import type {
-  AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
-  ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, LegacyConversationSlice,
-  PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem,
+  AssistantChatData, AssistantMessageNode, ChatConversationViewNode, ChatSnapshot, ConversationNode,
+  ChatLocationNodeIndex, ChatNodeStore, CompactionSummaryNode, FinalAssistantChatData,
+  LegacyConversationSlice, PartialAssistant, RunningToolCall, ToolCallBlock, TurnNavigationItem,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {
   ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation,
@@ -10,6 +10,12 @@ import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts'
 import {
   sameTurnNavigationItem, turnNavigationItem,
 } from '../src/client/conversation-nodes/turn-navigation.ts'
+import { orderedVisibleChatNodes } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
+import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
+import {
+  encodeTurnProcess, isSubagentDelegationTool, TURN_PROCESS_INDEPENDENT_KINDS,
+  type TurnProcessSpec,
+} from '../src/client/contract/turn-process.ts'
 
 const EMPTY: readonly never[] = []
 
@@ -17,17 +23,45 @@ function sameValues<T>(left: readonly T[], right: readonly T[]): boolean {
   return left.length === right.length && left.every((value, index) => value === right[index])
 }
 
+function sameFixtureLocation(
+  left: ChatConversationViewNode['location'],
+  right: ChatConversationViewNode['location'],
+): boolean {
+  if (left.kind !== right.kind) return false
+  if (left.kind === 'session' || left.kind === 'unresolved') return true
+  if (right.kind === 'session' || right.kind === 'unresolved') return false
+  if (left.turn.turn !== right.turn.turn
+    || left.turn.status !== right.turn.status
+    || left.turn.start !== right.turn.start
+    || left.turn.end !== right.turn.end
+    || left.turn.data !== right.turn.data) return false
+  if (left.kind === 'turn' || right.kind === 'turn') return left.kind === right.kind
+  return left.step.step === right.step.step
+    && left.step.status === right.step.status
+    && left.step.start === right.step.start
+    && left.step.end === right.step.end
+    && left.step.data === right.step.data
+}
+
 function nodeSource(node: ChatConversationViewNode): unknown {
   if (node.kind === 'assistant-step') {
-    const data = node.data as ReturnType<typeof assistantData>
+    const data = node.data as AssistantChatData
     return data.finalNode ?? data.blocks
   }
   if (node.kind === 'tool-call') return (node.data as { readonly root: ToolCallBlock }).root
   if (node.kind === 'model-retry') return (node.data as { readonly current: unknown }).current
   if (node.kind === 'turn-tail') return (node.data as { readonly seq: number }).seq
+  if (node.kind === 'turn-process') {
+    const data = node.data as TurnProcessSpec
+    return encodeTurnProcess(data)
+  }
   return node.data
 }
 
+function toolCallName(call: ToolCallBlock): string | null {
+  return 'name' in call ? call.name : call.call?.name ?? null
+}
+
 class FixtureNodeStore implements ChatNodeStore {
   private byKey = new Map<string, ChatConversationViewNode>()
   private list: readonly ChatConversationViewNode[] = EMPTY
@@ -47,6 +81,7 @@ class FixtureNodeStore implements ChatNodeStore {
       const node = previous !== undefined
         && previous.kind === candidate.kind
         && previous.anchorSeq === candidate.anchorSeq
+        && sameFixtureLocation(previous.location, candidate.location)
         && previous.visibility === candidate.visibility
         && nodeSource(previous) === nodeSource(candidate)
         ? previous
@@ -97,7 +132,7 @@ class FixtureTurnDataStore implements ConversationLocationDataStore<Conversation
   }
 }
 
-function assistantData(node: AssistantMessageNode) {
+function assistantData(node: AssistantMessageNode): FinalAssistantChatData {
   return {
     status: node.interrupted === true ? 'interrupted' as const : 'settled' as const,
     turn: node.turn,
@@ -111,8 +146,10 @@ function assistantData(node: AssistantMessageNode) {
 function settledNode(
   node: ConversationNode,
   turns: ReadonlyMap<number, TurnLocation>,
+  inferredTurn?: number,
 ): ChatConversationViewNode {
-  const turn = 'turn' in node && typeof node.turn === 'number' ? turns.get(node.turn) : undefined
+  const ownTurn = 'turn' in node && typeof node.turn === 'number' ? node.turn : inferredTurn
+  const turn = ownTurn === undefined ? undefined : turns.get(ownTurn)
   const base = {
     key: `fixture:${node.kind}:${node.seq}`,
     id: String(node.seq),
@@ -161,7 +198,8 @@ export function chatSnapshotFixture(input: {
   for (const turn of [...turnNumbers].sort((left, right) => left - right)) {
     const timing = legacy.turnTimings.get(turn)
     const endSeq = legacy.turnEnds.get(turn)
-    const data = new FixtureTurnDataStore()
+    const previousData = previous?.timeline.turns.get(turn)?.data
+    const data = previousData instanceof FixtureTurnDataStore ? previousData : new FixtureTurnDataStore()
     turnData.set(turn, data)
     turns.set(turn, {
       turn,
@@ -177,7 +215,7 @@ export function chatSnapshotFixture(input: {
     })
   }
   const linkedCompactions = new Set<CompactionSummaryNode>()
-  const nodes = legacy.nodes.flatMap((node): ChatConversationViewNode[] => {
+  const nodes = legacy.nodes.flatMap((node, index): ChatConversationViewNode[] => {
     if (node.kind === 'command' && node.name === 'compact') {
       const sourceSeq = node.outcome?.kind === 'success' ? node.outcome.sourceEventSeq : undefined
       const candidates = sourceSeq === undefined
@@ -198,7 +236,12 @@ export function chatSnapshotFixture(input: {
       }
     }
     if (node.kind === 'compaction' && linkedCompactions.has(node)) return []
-    return [settledNode(node, turns)]
+    const inferredTurn = node.kind === 'tool-result'
+      ? legacy.nodes.slice(0, index).findLast(
+        (candidate): candidate is AssistantMessageNode => candidate.kind === 'assistant',
+      )?.turn
+      : undefined
+    return [settledNode(node, turns, inferredTurn)]
   })
   if (legacy.partial !== null) {
     const turn = turns.get(legacy.partial.turn)
@@ -232,6 +275,75 @@ export function chatSnapshotFixture(input: {
       data: { root: call },
     })
   }
+  for (const [turnNumber, dataStore] of turnData) {
+    const inTurn = nodes.filter((candidate) => {
+      const location = candidate.location
+      return (location.kind === 'turn' || location.kind === 'step') && location.turn.turn === turnNumber
+    })
+    const assistants = inTurn
+      .filter(candidate => candidate.kind === 'assistant-step')
+      .map(candidate => candidate.data as AssistantChatData)
+    const toolCalls = inTurn
+      .filter(candidate => candidate.kind === 'tool-call')
+      .map(candidate => (candidate.data as { readonly root: ToolCallBlock }).root)
+    const latestStep = Math.max(
+      0,
+      ...assistants.map(candidate => candidate.step),
+      ...inTurn.flatMap((candidate) => {
+        if (candidate.kind !== 'tool-call') return []
+        const root = (candidate.data as { root: ToolCallBlock }).root as ToolCallBlock & { step?: unknown }
+        const step: unknown = root.step
+        return typeof step === 'number' ? [step] : []
+      }),
+    )
+    const answer = assistants.findLast((candidate): candidate is FinalAssistantChatData =>
+      candidate.step === latestStep
+      && candidate.finalNode !== undefined
+      && hasAssistantReplyContent(candidate.blocks)
+      && !candidate.blocks.some(block => block.kind === 'tool-call'))
+    const controlAnchor = inTurn.find(candidate => candidate.kind === 'assistant-step'
+      || candidate.kind === 'tool-call'
+      || candidate.kind === 'model-retry')
+    if (controlAnchor === undefined) continue
+    const processStart = inTurn.find(candidate => !TURN_PROCESS_INDEPENDENT_KINDS.has(candidate.kind))
+      ?? controlAnchor
+    const inlineReasoning = answer?.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '') === true
+    const spec: TurnProcessSpec = {
+      turn: turnNumber,
+      controlAnchorSeq: controlAnchor.anchorSeq,
+      processStartSeq: processStart.anchorSeq,
+      answerAnchorSeq: answer?.finalNode.seq ?? null,
+      answerStep: answer?.step ?? null,
+      inlineReasoning: answer !== undefined && inlineReasoning,
+      messageCount: answer === undefined
+        ? assistants.filter(candidate => hasAssistantReplyContent(candidate.blocks)).length
+        : assistants.filter(candidate => candidate.step < answer.step
+          && hasAssistantReplyContent(candidate.blocks)).length,
+      toolCallCount: toolCalls.filter((call) => {
+        const name = toolCallName(call)
+        return name === null || !isSubagentDelegationTool(name)
+      }).length,
+      subagentCount: toolCalls.filter((call) => {
+        const name = toolCallName(call)
+        return name !== null && isSubagentDelegationTool(name)
+      }).length,
+    }
+    dataStore.set('turn-process', encodeTurnProcess(spec))
+    const turn = turns.get(turnNumber)
+    if (turn !== undefined) {
+      nodes.push({
+        key: `fixture:turn-process:${String(turnNumber)}`,
+        id: String(turnNumber),
+        target: 'chat',
+        kind: 'turn-process',
+        anchorSeq: spec.controlAnchorSeq - 0.1,
+        location: { kind: 'turn', turn },
+        visibility: 'visible',
+        data: spec,
+      })
+    }
+  }
+  nodes.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
   for (const [turnNumber, endSeq] of legacy.turnEnds) {
     const turn = turns.get(turnNumber)
     const dataStore = turnData.get(turnNumber)
@@ -271,10 +383,12 @@ export function chatSnapshotFixture(input: {
       data: tailData,
     })
   }
+  nodes.sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key))
+  const ordered = orderedVisibleChatNodes(nodes)
   const store = previous?.nodes instanceof FixtureNodeStore ? previous.nodes : new FixtureNodeStore()
-  store.replace(nodes)
+  store.replace(ordered)
   const byKey = new Map(store.values().map(node => [node.key, node]))
-  const nextOrder = nodes.map(node => node.key)
+  const nextOrder = ordered.map(node => node.key)
   const order = previous !== undefined && sameValues(previous.order, nextOrder) ? previous.order : nextOrder
   const byTurn = new Map<number, readonly string[]>()
   for (const turn of turns.keys()) {

+ 24 - 1
packages/client/ui-chat/tests/chat-store.client.spec.ts

@@ -4,7 +4,7 @@ import { createChatStore } from '../src/client/stores.ts'
 describe('createChatStore', () => {
   it('starts without a selected Chat target', () => {
     const store = createChatStore().create()
-    expect(store.store.getSnapshot()).toEqual({ selection: null })
+    expect(store.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] })
   })
 
   it('selects and clears one Chat details target', () => {
@@ -23,4 +23,27 @@ describe('createChatStore', () => {
     first.actions.select({ turnSeq: 1 })
     expect(second.store.getSnapshot().selection).toBeNull()
   })
+
+  it('stores only manually expanded Turn-process generations', () => {
+    const store = createChatStore().create()
+    store.actions.setTurnProcessOpen(2, '2|3', true)
+    expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, generation: '2|3' }])
+
+    store.actions.setTurnProcessOpen(2, '2|4', true)
+    expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 2, generation: '2|4' }])
+
+    store.actions.setTurnProcessOpen(2, '2|4', false)
+    expect(store.store.getSnapshot().turnProcesses).toEqual([])
+  })
+
+  it('closes only the requested Turn-process entry', () => {
+    const store = createChatStore().create()
+    store.actions.setTurnProcessOpen(2, '2|3', true)
+    store.actions.setTurnProcessOpen(3, '3|4', true)
+
+    store.actions.setTurnProcessOpen(2, '2|3', false)
+    store.actions.setTurnProcessOpen(9, '9|10', false)
+
+    expect(store.store.getSnapshot().turnProcesses).toEqual([{ turn: 3, generation: '3|4' }])
+  })
 })

+ 578 - 27
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -5,10 +5,10 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi
 import { useEffect } from 'react'
 import type {
   AssistantMessageNode, ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatSnapshot,
-  ChatViewSlotProps, CommandNode, CompactionSummaryNode, ConversationNode,
-  LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget,
-  ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode,
-  UseChatNodeTurnData, UserMessageNode,
+  ChatViewSlotProps, CommandNode, CompactionSummaryNode, ContextMessageNode, ConversationNode,
+  LegacyConversationSlice, ModelRetryNode, RunningToolCall, SelectionTarget, SteeringMessageNode,
+  ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, UseChatNodeTurnData,
+  TranscriptViewMode, UserMessageNode,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {
   SessionListState, SessionSnapshot,
@@ -23,6 +23,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
 import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
 import { createChatStore } from '../src/client/stores.ts'
 import { ChatView } from '../src/client/chat/ChatView.tsx'
+import { ChatNodeSeat } from '../src/client/chat/ChatNodeSeat.tsx'
 import { zh } from '../src/client/locale.ts'
 import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx'
 import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx'
@@ -31,7 +32,11 @@ import {
   TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView,
 } from '../src/client/chat/MessageItem.tsx'
 import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx'
+import { TurnProcessNodeView } from '../src/client/chat/TurnProcessNodeView.tsx'
+import { SystemPromptNodeView } from '../src/client/chat/SystemPromptRow.tsx'
 import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
+import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
+import { encodeTurnProcess } from '../src/client/contract/turn-process.ts'
 import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
 
 afterEach(() => {
@@ -88,6 +93,7 @@ function makeSessionSource(init: Partial<SessionSnapshot> = {}) {
 }
 
 type ChatSlice = Partial<LegacyConversationSlice>
+type HarnessUpdate = ChatSlice & Partial<SessionSnapshot> & { readonly chat?: ChatSnapshot }
 
 /** Scripted Chat target source, independent from Session lifecycle state. */
 function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) {
@@ -98,6 +104,10 @@ function makeChatSource(init: ChatSlice = {}, snapshot?: ChatSnapshot) {
       snap = chatSnapshotFixture({ ...snap.legacy, ...next }, snap)
       for (const fn of [...subs]) fn()
     },
+    replace: (next: ChatSnapshot) => {
+      snap = next
+      for (const fn of [...subs]) fn()
+    },
     source: {
       getSnapshot: () => snap,
       subscribe: (fn: () => void) => {
@@ -121,8 +131,20 @@ const userInTurn = (seq: number, text: string, turn: number): ConversationNode =
   // accepts the extra coordinate so component tests can build the same view.
   turn,
 } as unknown as ConversationNode)
-const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({
-  kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }],
+const assistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({
+  kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'text', text }],
+})
+const reasoningAssistant = (seq: number, text: string, turn = 1, step = 1): AssistantMessageNode => ({
+  kind: 'assistant', seq, time: seq * 1_000, turn, step, blocks: [{ kind: 'reasoning', text }],
+})
+const context = (seq: number, text: string, turn?: number): ContextMessageNode & { turn?: number } => ({
+  kind: 'context', seq, time: seq * 1_000, content: [{ type: 'text', text }], source: null,
+  provenance: { role: 'inject', label: null }, form: null,
+  ...(turn === undefined ? {} : { turn }),
+})
+const steering = (seq: number, text: string, turn: number): SteeringMessageNode & { turn: number } => ({
+  kind: 'steering', messageId: `steering-${String(seq)}` as SteeringMessageNode['messageId'],
+  seq, time: seq * 1_000, turn, content: [{ type: 'text', text }], source: null,
 })
 const retry = (seq: number): ModelRetryNode => ({
   kind: 'model-retry', retryId: 'chat-view-retry' as ModelRetryNode['retryId'],
@@ -178,12 +200,23 @@ function emptyWorkspaces() {
 }
 
 function makeHarness(
-  chatSlice: ChatSlice = {},
-  sessionInit: Partial<SessionSnapshot> = {},
+  init: HarnessUpdate = {},
+  sessionOverrides: Partial<SessionSnapshot> = {},
   chatSnapshot?: ChatSnapshot,
 ) {
-  const session = makeSessionSource(sessionInit)
-  const chatSource = makeChatSource(chatSlice, chatSnapshot)
+  const {
+    chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds,
+    ...sessionInit
+  } = init
+  const chatSlice: ChatSlice = {
+    ...(nodes === undefined ? {} : { nodes }),
+    ...(partial === undefined ? {} : { partial }),
+    ...(runningCalls === undefined ? {} : { runningCalls }),
+    ...(turnTimings === undefined ? {} : { turnTimings }),
+    ...(turnEnds === undefined ? {} : { turnEnds }),
+  }
+  const session = makeSessionSource({ ...sessionInit, ...sessionOverrides })
+  const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot)
   const openDetails = vi.fn<(t: SelectionTarget) => void>()
   const openFile = vi.fn<(path: string) => Promise<void>>().mockResolvedValue(undefined)
   const loadOlder = vi.fn()
@@ -197,6 +230,7 @@ function makeHarness(
   const forkAt = vi.fn()
   // Rows and the harness must observe the same chat-store instance.
   const chat = createChatStore().create()
+  const transcriptView = createSnapshotStore<TranscriptViewMode>('compact')
   const t = makeTranslate(zh, commonZh)
   const toolOwners: Array<{
     callId: string
@@ -212,10 +246,12 @@ function makeHarness(
     React.ComponentProps<typeof TurnTailNodeView>['renderSlotChain']
   const renderTurnTailSlot = (() => null) as unknown as
     React.ComponentProps<typeof TurnTailNodeView>['renderSlot']
-  const renderSlot = ((key: string, owner: object, opts?: {
+  let nodeSlotOverride: React.ComponentProps<typeof ChatNodeSeat>['renderSlot'] | undefined
+  const renderNodeSlot = ((key: string, owner: object, opts?: {
     fallback?: React.ReactNode
     hookContext?: unknown
   }) => {
+    if (nodeSlotOverride !== undefined) return nodeSlotOverride(key as never, owner as never, opts as never)
     if (key !== 'conversation.chat.node') return opts?.fallback ?? null
     const nodeOwner = owner as RoutedChatNodeOwner
     const nodeKey = opts?.hookContext as string | undefined
@@ -254,6 +290,10 @@ function makeHarness(
         return <TurnErrorNodeView {...nodeProps<'turn-error'>()} />
       case 'turn-max-tokens':
         return <TurnMaxTokensNodeView {...nodeProps<'turn-max-tokens'>()} />
+      case 'turn-process':
+        return <TurnProcessNodeView {...nodeProps<'turn-process'>()} />
+      case 'system-prompt':
+        return <SystemPromptNodeView {...nodeProps<'system-prompt'>()} />
       case 'turn-tail':
         return (
           <TurnTailNodeView
@@ -290,7 +330,8 @@ function makeHarness(
       default:
         return opts?.fallback ?? null
     }
-  }) as unknown as ChatViewSlotProps['renderSlot']
+  }) as unknown as React.ComponentProps<typeof ChatNodeSeat>['renderSlot']
+  const renderSlot = renderNodeSlot
   // SessionProvider seat arrives with the session-scope child declaration;
   // ChatView never invokes it (pass-through stub).
   const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}</>
@@ -316,6 +357,7 @@ function makeHarness(
     },
     useStore: bindSnapshotSelector(chat),
     actions: chat.actions,
+    useTranscriptView: bindSnapshotSelector(transcriptView),
     renderSlot,
     SessionProvider: SessionProviderStub,
     viewRequest: null,
@@ -331,11 +373,33 @@ function makeHarness(
     fileMentions: () => undefined,
     t,
   }
+  const set = (next: HarnessUpdate): void => {
+    const {
+      chat: explicitChat, nodes, partial, runningCalls, turnTimings, turnEnds,
+      ...sessionUpdate
+    } = next
+    if (explicitChat !== undefined) chatSource.replace(explicitChat)
+    else if (nodes !== undefined || partial !== undefined || runningCalls !== undefined
+      || turnTimings !== undefined || turnEnds !== undefined) {
+      chatSource.set({
+        ...(nodes === undefined ? {} : { nodes }),
+        ...(partial === undefined ? {} : { partial }),
+        ...(runningCalls === undefined ? {} : { runningCalls }),
+        ...(turnTimings === undefined ? {} : { turnTimings }),
+        ...(turnEnds === undefined ? {} : { turnEnds }),
+      })
+    }
+    session.set(sessionUpdate)
+  }
   const setSelection = (next: SelectionTarget | null): void => { chat.actions.select(next) }
   return {
-    setSession: session.set, setChat: chatSource.set, ChatView, props,
+    set, setSession: session.set, setChat: chatSource.set, ChatView, props,
     openDetails, openFile, loadOlder, openView,
     chatScroll, forkAt, setSelection, toolOwners,
+    setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) },
+    setNodeRenderer: (renderer: React.ComponentProps<typeof ChatNodeSeat>['renderSlot']) => {
+      nodeSlotOverride = renderer
+    },
   }
 }
 
@@ -346,6 +410,34 @@ function readerScroll(element: HTMLElement, top: number): void {
   fireEvent.scroll(element)
 }
 
+function turnProcessControl(container: HTMLElement): HTMLButtonElement | null {
+  return container.querySelector<HTMLButtonElement>('[data-turn-process]')
+}
+
+function withSystemPrompt(snapshot: ChatSnapshot, text = '# System'): ChatSnapshot {
+  const turn = snapshot.timeline.turns.get(1)
+  if (turn === undefined) throw new Error('fixture lacks Turn 1')
+  const prompt: ChatNode<'system-prompt'> = {
+    key: 'fixture:system-prompt:1',
+    id: '1',
+    target: 'chat',
+    kind: 'system-prompt',
+    anchorSeq: 1,
+    location: { kind: 'turn', turn },
+    visibility: 'visible',
+    data: { text },
+  }
+  return new ChatSnapshotBuilder().replace({
+    nodes: [prompt, ...snapshot.nodes.values()],
+    timeline: snapshot.timeline,
+  })
+}
+
+function renderedFlowKinds(container: HTMLElement): Array<string | undefined> {
+  return [...container.querySelectorAll<HTMLElement>('[data-chat-flow-kind]')]
+    .map(row => row.dataset.chatFlowKind)
+}
+
 function installScrollMetrics(element: HTMLElement, initialHeight: number, clientHeight: number) {
   let scrollHeight = initialHeight
   let scrollTop = 0
@@ -650,6 +742,7 @@ describe('ChatView', () => {
       kind: row.getAttribute('data-chat-flow-kind'),
     }))).toEqual([
       { key: 'fixture:user:1', kind: 'user' },
+      { key: 'fixture:turn-process:1', kind: 'turn-process' },
       { key: 'fixture:assistant:2', kind: 'assistant-step' },
       { key: 'fixture:tool:a', kind: 'tool-call' },
       { key: 'fixture:tool:b', kind: 'tool-call' },
@@ -658,7 +751,7 @@ describe('ChatView', () => {
       .toEqual(['a', 'b'])
     expect([...view.container.querySelectorAll('[data-chat-anchor-key]')].map(row => row.getAttribute('data-chat-anchor-key')))
       .toEqual([
-        'fixture:user:1', 'fixture:assistant:2',
+        'fixture:user:1', 'fixture:turn-process:1', 'fixture:assistant:2',
         'fixture:tool:a', 'call:a', 'fixture:tool:b', 'call:b',
       ])
   })
@@ -927,9 +1020,9 @@ describe('ChatView', () => {
     const h = makeHarness({
       nodes: [
         user(1, 'hi'),
-        assistant(2, 'mid-turn text'),
+        assistant(2, 'mid-turn text', 1, 1),
         toolResult(3, 'a'),
-        assistant(4, 'final answer'),
+        assistant(4, 'final answer', 1, 2),
         user(5, 'next'),
         assistant(6, 'second turn', 2),
       ],
@@ -943,6 +1036,464 @@ describe('ChatView', () => {
     expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null])
   })
 
+  it('folds Think and Tool rows before the final answer without unmounting them', () => {
+    const first = {
+      ...assistant(2, 'earlier reply', 1, 1),
+      blocks: [
+        { kind: 'reasoning' as const, text: 'inspect the repository' },
+        { kind: 'text' as const, text: 'earlier reply' },
+      ],
+    }
+    const second = assistant(5, 'final answer', 1, 2)
+    const h = makeHarness({
+      nodes: [
+        user(1, 'question'),
+        first,
+        toolResult(3, 'a'),
+        toolResult(4, 'b', 'subagent'),
+        second,
+      ],
+      turnTimings: new Map([[1, { startTime: 1_000, endTime: 5_000 }]]),
+      turnEnds: new Map([[1, 6]]),
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    const toggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' })
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('1')
+    expect(toggle.getAttribute('data-turn-process-messages')).toBe('1')
+    expect(toggle.getAttribute('data-turn-process-subagents')).toBe('1')
+    const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
+    expect(members).toHaveLength(3)
+    expect(members.map(member => member.getAttribute('hidden')))
+      .toEqual(['until-found', 'until-found', 'until-found'])
+    expect(members[0]?.textContent).toContain('inspect the repository')
+    expect(members[1]?.textContent).toContain('bash:a')
+    expect(members[2]?.textContent).toContain('subagent:b')
+    expect(view.getByText('final answer')).toBeTruthy()
+
+    fireEvent.click(toggle)
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+    expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
+
+    fireEvent.click(toggle)
+    expect(members.map(member => member.getAttribute('hidden')))
+      .toEqual(['until-found', 'until-found', 'until-found'])
+    fireEvent(members[1]!, new Event('beforematch'))
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+    expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
+
+    act(() => { h.set({ nodes: [user(1, 'question'), first] }) })
+    expect(view.getByRole('button', { name: '已思考' }).getAttribute('aria-expanded')).toBe('false')
+    expect(members[0]?.getAttribute('hidden')).toBeNull()
+    act(() => { h.set({
+      nodes: [user(1, 'question'), first, toolResult(3, 'a'), toolResult(4, 'b', 'subagent'), second],
+    }) })
+    const renewedToggle = view.getByRole('button', { name: '1 次工具调用 · 1 条消息 · 1 个 subagent' })
+    expect(renewedToggle.getAttribute('aria-expanded')).toBe('true')
+    expect(members[0]?.getAttribute('hidden')).toBeNull()
+  })
+
+  it('folds injected Context in place with the rest of the Turn process', () => {
+    const h = makeHarness({
+      nodes: [
+        user(1, 'question'),
+        context(2, 'runtime policy changed', 1),
+        reasoningAssistant(3, 'inspect the repository', 1, 1),
+        toolResult(4, 'a'),
+        assistant(5, 'final answer', 1, 2),
+      ],
+      turnEnds: new Map([[1, 6]]),
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
+    const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
+
+    expect(members).toHaveLength(3)
+    expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step', 'tool-call'])
+    expect(contextRow).not.toBeNull()
+    expect(contextRow?.getAttribute('hidden')).toBe('until-found')
+    fireEvent(contextRow!, new Event('beforematch'))
+    expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
+  })
+
+  it('keeps the first System prompt above User and outside Process through completion and expansion', () => {
+    const initial = withSystemPrompt(chatSnapshotFixture({
+      nodes: [userInTurn(2, 'question', 1), context(3, 'runtime policy', 1)],
+    }))
+    const running = withSystemPrompt(chatSnapshotFixture({
+      nodes: [
+        userInTurn(2, 'question', 1),
+        context(3, 'runtime policy', 1),
+        reasoningAssistant(4, 'inspect', 1, 1),
+      ],
+    }))
+    const completed = withSystemPrompt(chatSnapshotFixture({
+      nodes: [
+        userInTurn(2, 'question', 1),
+        context(3, 'runtime policy', 1),
+        reasoningAssistant(4, 'inspect', 1, 1),
+        assistant(6, 'final answer', 1, 2),
+      ],
+      turnEnds: new Map([[1, 7]]),
+    }))
+    const h = makeHarness({ chat: initial }, { running: true })
+    const view = render(<h.ChatView {...h.props} />)
+    const promptRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="system-prompt"]')!
+
+    expect(renderedFlowKinds(view.container)).toEqual(['system-prompt', 'user', 'context'])
+    expect(promptRow.getAttribute('hidden')).toBeNull()
+    expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
+
+    act(() => { h.set({ chat: running, running: true }) })
+    expect(renderedFlowKinds(view.container)).toEqual([
+      'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
+    ])
+    expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBe(promptRow)
+    expect(promptRow.getAttribute('hidden')).toBeNull()
+
+    act(() => { h.set({ chat: completed, running: false }) })
+    const toggle = turnProcessControl(view.container)!
+    const members = [...view.container.querySelectorAll<HTMLElement>('[data-turn-process-member]')]
+    expect(renderedFlowKinds(view.container)).toEqual([
+      'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
+    ])
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(promptRow.getAttribute('hidden')).toBeNull()
+    expect(promptRow.hasAttribute('data-turn-process-member')).toBe(false)
+    expect(members.map(member => member.dataset.chatFlowKind)).toEqual(['context', 'assistant-step'])
+    expect(members.map(member => member.getAttribute('hidden'))).toEqual(['until-found', 'until-found'])
+
+    fireEvent.click(toggle)
+    expect(renderedFlowKinds(view.container)).toEqual([
+      'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
+    ])
+    expect(promptRow.getAttribute('hidden')).toBeNull()
+    expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null])
+  })
+
+  it('folds Context under the fallback title when every summary count is zero', () => {
+    const h = makeHarness({
+      nodes: [user(1, 'question'), context(2, 'runtime policy', 1), assistant(3, 'final answer', 1, 1)],
+      turnEnds: new Map([[1, 4]]),
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    const toggle = view.getByRole('button', { name: '已思考' })
+    const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
+
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('0')
+    expect(toggle.getAttribute('data-turn-process-messages')).toBe('0')
+    expect(toggle.getAttribute('data-turn-process-subagents')).toBe('0')
+    expect(contextRow?.getAttribute('hidden')).toBe('until-found')
+    fireEvent.click(toggle)
+    expect(contextRow?.getAttribute('hidden')).toBeNull()
+  })
+
+  it('keeps ordinary spacing when steering separates the process control from its answer', () => {
+    const h = makeHarness({
+      nodes: [
+        user(1, 'question'),
+        reasoningAssistant(2, 'inspect', 1, 1),
+        steering(3, 'also mention safety', 1),
+        assistant(4, 'final answer', 1, 2),
+      ],
+      turnEnds: new Map([[1, 5]]),
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    const answer = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="assistant-step"]:not([hidden])')
+
+    expect(view.getByText('also mention safety')).toBeTruthy()
+    expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
+  })
+
+  it('keeps ordinary spacing when steering precedes the first process evidence', () => {
+    const h = makeHarness({
+      nodes: [
+        steering(1, 'question', 1),
+        steering(2, 'also mention safety', 1),
+        reasoningAssistant(3, 'inspect', 1, 1),
+        assistant(4, 'final answer', 1, 2),
+      ],
+      turnEnds: new Map([[1, 5]]),
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    const answer = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="assistant-step"]:not([hidden])')
+
+    expect(view.getByText('also mention safety')).toBeTruthy()
+    expect(answer?.hasAttribute('data-turn-process-answer')).toBe(false)
+  })
+
+  it('keeps a live Turn expanded and folds it once at turn/end', () => {
+    const process = assistant(2, 'inspect', 1, 1)
+    const h = makeHarness({
+      nodes: [user(1, 'question'), process],
+      partial: { turn: 1, step: 2, blocks: [{ kind: 'text', text: 'streaming answer' }] },
+      running: true,
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    expect(turnProcessControl(view.container)).toBeNull()
+    const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
+
+    act(() => {
+      h.set({
+        nodes: [user(1, 'question'), process, assistant(4, 'settled answer', 1, 2)],
+        partial: null,
+        running: false,
+        turnEnds: new Map([[1, 5]]),
+      })
+    })
+    const toggle = turnProcessControl(view.container)!
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(processRow.getAttribute('hidden')).toBe('until-found')
+  })
+
+  it('switches completed Turns between the persisted Normal and Compact modes', () => {
+    const process = assistant(2, 'inspect', 1, 1)
+    const h = makeHarness({
+      nodes: [user(1, 'question'), process, assistant(4, 'final answer', 1, 2)],
+      turnEnds: new Map([[1, 5]]),
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
+
+    expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
+    expect(processRow.getAttribute('hidden')).toBe('until-found')
+
+    act(() => { h.setTranscriptView('normal') })
+    expect(turnProcessControl(view.container)).toBeNull()
+    expect(processRow.getAttribute('hidden')).toBeNull()
+
+    act(() => { h.setTranscriptView('compact') })
+    expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
+    expect(processRow.getAttribute('hidden')).toBe('until-found')
+  })
+
+  it('folds final-step reasoning under the fallback title when every summary count is zero', () => {
+    const final = {
+      ...assistant(3, 'final answer', 1, 1),
+      blocks: [
+        { kind: 'reasoning' as const, text: 'private analysis' },
+        { kind: 'text' as const, text: 'final answer' },
+      ],
+    }
+    const h = makeHarness({ nodes: [user(1, 'question'), final], turnEnds: new Map([[1, 4]]) })
+    const view = render(<h.ChatView {...h.props} />)
+    const toggle = view.getByRole('button', { name: '已思考' })
+    const reasoning = view.container.querySelector<HTMLElement>('[data-turn-process-inline]')
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(reasoning?.getAttribute('hidden')).toBe('until-found')
+    expect(view.getByText('final answer')).toBeTruthy()
+    fireEvent.click(toggle)
+    expect(view.getByText('private analysis')).toBeTruthy()
+  })
+
+  it('folds a completed Turn even while the reader is away from the tail', () => {
+    const first = assistant(2, 'first answer', 1, 1)
+    const h = makeHarness({ nodes: [user(1, 'question'), first], running: true })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
+    Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
+    const firstRow = view.getByText('first answer').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
+    readerScroll(scroller, 100)
+
+    act(() => { h.set({
+      nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)],
+      turnEnds: new Map([[1, 5]]),
+    }) })
+    const toggle = turnProcessControl(view.container)!
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(firstRow.getAttribute('hidden')).toBe('until-found')
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+  })
+
+  it('folds when the process controller first appears off-tail', () => {
+    const h = makeHarness({
+      nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
+      running: true,
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    const scroller = view.container.querySelector('[class*="scroll"]') as HTMLDivElement
+    Object.defineProperty(scroller, 'scrollHeight', { value: 1_000, writable: true })
+    Object.defineProperty(scroller, 'clientHeight', { value: 300, writable: true })
+    const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
+    readerScroll(scroller, 100)
+
+    act(() => { h.set({
+      nodes: [
+        user(1, 'question'),
+        context(2, 'runtime policy', 1),
+        assistant(3, 'final answer', 1, 1),
+      ],
+      running: false,
+      turnEnds: new Map([[1, 4]]),
+    }) })
+    const toggle = turnProcessControl(view.container)!
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(contextRow?.getAttribute('hidden')).toBe('until-found')
+    expect(view.getByLabelText('回到底部')).toBeTruthy()
+  })
+
+  it('keeps a focused process row visible when a live Turn completes', () => {
+    const h = makeHarness({
+      nodes: [user(1, 'question'), context(2, 'runtime policy', 1)],
+      running: true,
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    const contextToggle = view.getByRole('button', { name: '上下文注入' })
+    const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
+    contextToggle.focus()
+    expect(document.activeElement).toBe(contextToggle)
+
+    act(() => { h.set({
+      nodes: [
+        user(1, 'question'),
+        context(2, 'runtime policy', 1),
+        assistant(3, 'final answer', 1, 1),
+      ],
+      running: false,
+      turnEnds: new Map([[1, 4]]),
+    }) })
+    const processToggle = turnProcessControl(view.container)!
+    expect(processToggle.getAttribute('aria-expanded')).toBe('true')
+    expect(contextRow?.getAttribute('hidden')).toBeNull()
+    expect(document.activeElement).toBe(contextToggle)
+
+    fireEvent.click(processToggle)
+    expect(document.activeElement).toBe(processToggle)
+    expect(processToggle.getAttribute('aria-expanded')).toBe('false')
+    expect(contextRow?.getAttribute('hidden')).toBe('until-found')
+  })
+
+  it('keeps a foldable closed Turn fully visible while history is partial', () => {
+    const h = makeHarness({
+      nodes: [
+        user(1, 'question'),
+        context(2, 'runtime policy', 1),
+        assistant(3, 'working', 1, 1),
+        assistant(4, 'final answer', 1, 2),
+      ],
+      turnEnds: new Map([[1, 5]]),
+      hasMore: true,
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
+
+    expect(turnProcessControl(view.container)).toBeNull()
+    expect(contextRow?.getAttribute('hidden')).toBeNull()
+    expect(contextRow?.hasAttribute('data-turn-process-member')).toBe(false)
+
+    act(() => { h.set({ hasMore: false }) })
+    const toggle = turnProcessControl(view.container)!
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(contextRow?.getAttribute('hidden')).toBe('until-found')
+  })
+
+  it('withholds process controls for partial history and folds final-page groups', () => {
+    const h = makeHarness({
+      nodes: [user(9, 'visible question'), assistant(10, 'visible answer', 2)],
+      hasMore: true,
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    expect(turnProcessControl(view.container)).toBeNull()
+
+    act(() => {
+      h.set({
+        nodes: [
+          user(1, 'older question'),
+          assistant(2, 'older first answer', 1, 1),
+          assistant(4, 'older final answer', 1, 2),
+          user(9, 'visible question'),
+          assistant(10, 'visible answer', 2),
+        ],
+        turnEnds: new Map([[1, 5]]),
+        hasMore: false,
+      })
+    })
+
+    const toggle = turnProcessControl(view.container)!
+    const member = view.container.querySelector<HTMLElement>('[data-turn-process-member]')
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(member?.getAttribute('hidden')).toBe('until-found')
+
+    fireEvent.click(toggle)
+    expect(toggle.getAttribute('aria-expanded')).toBe('true')
+    expect(member?.getAttribute('hidden')).toBeNull()
+  })
+
+  it('refreshes process layout without reordering when the final page only changes Turn data', () => {
+    const source = chatSnapshotFixture({
+      nodes: [
+        user(1, 'question'),
+        context(2, 'runtime policy', 1),
+        assistant(3, 'working', 1, 1),
+        assistant(4, 'final answer', 1, 2),
+      ],
+      turnEnds: new Map([[1, 5]]),
+    })
+    const process = source.nodes.values()
+      .find((candidate): candidate is ChatNode<'turn-process'> => candidate.kind === 'turn-process')
+    if (process === undefined
+      || (process.location.kind !== 'turn' && process.location.kind !== 'step')
+      || process.data.answerAnchorSeq === null) throw new Error('fixture lacks a completed Turn process')
+    const turnData = process.location.turn.data as typeof process.location.turn.data & {
+      set(key: 'turn-process', value: ReturnType<typeof encodeTurnProcess>): void
+    }
+    const partialSpec = { ...process.data, processStartSeq: process.data.answerAnchorSeq }
+    turnData.set('turn-process', encodeTurnProcess(partialSpec))
+    const partialProcess = { ...process, data: partialSpec }
+    const builder = new ChatSnapshotBuilder()
+    const partial = builder.replace({
+      nodes: source.nodes.values().map(node => node.key === process.key ? partialProcess : node),
+      timeline: source.timeline,
+    })
+    const h = makeHarness({ chat: partial, hasMore: true })
+    const view = render(<h.ChatView {...h.props} />)
+    expect(turnProcessControl(view.container)).toBeNull()
+
+    const beforeKeys = partial.locations.getTurn(1)
+    const completeSpec = { ...partialSpec, processStartSeq: 2 }
+    turnData.set('turn-process', encodeTurnProcess(completeSpec))
+    const complete = builder.apply({
+      upserts: [{ ...partialProcess, data: completeSpec }],
+      timeline: source.timeline,
+    })
+    expect(complete.order).toBe(partial.order)
+    expect(complete.nodes).toBe(partial.nodes)
+    expect(complete.locations.getTurn(1)).not.toBe(beforeKeys)
+    expect(complete.order.map(key => complete.nodes.get(key)?.kind)).toEqual([
+      'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
+    ])
+
+    act(() => { h.set({ chat: complete, hasMore: false }) })
+    expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false')
+  })
+
+  it('keeps a manual expansion when the reader returns from another view', () => {
+    const host = document.createElement('div')
+    host.setAttribute('data-conversation-scroll', '')
+    Object.defineProperty(host, 'scrollHeight', { value: 2_000, writable: true, configurable: true })
+    Object.defineProperty(host, 'clientHeight', { value: 500, writable: true, configurable: true })
+    Object.defineProperty(host, 'scrollTop', { value: 0, writable: true, configurable: true })
+    document.body.appendChild(host)
+    try {
+      const first = assistant(2, 'first answer', 1, 1)
+      const h = makeHarness({
+        nodes: [user(1, 'question'), first, assistant(4, 'new answer', 1, 2)],
+        turnEnds: new Map([[1, 5]]),
+      })
+      const view = render(<h.ChatView {...h.props} />, { container: host })
+      fireEvent.click(turnProcessControl(view.container)!)
+      expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true')
+
+      view.rerender(<div />)
+      view.rerender(<h.ChatView {...h.props} />)
+      expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('true')
+    } finally {
+      host.remove()
+    }
+  })
+
   it('withholds assistant IconActions while the turn is still running', () => {
     const h = makeHarness({
       runningCalls: [runningCall('a')],
@@ -973,8 +1524,8 @@ describe('ChatView', () => {
     const h = makeHarness({
       nodes: [
         user(1, 'hi'), // time 1_000
-        assistant(2, 'mid-turn text'),
-        assistant(16, 'final answer'),
+        assistant(2, 'mid-turn text', 1, 1),
+        assistant(16, 'final answer', 1, 2),
         toolResult(18, 'trailing'),
       ],
       turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
@@ -982,7 +1533,7 @@ describe('ChatView', () => {
     })
     const view = render(<h.ChatView {...h.props} />)
     // The exact turn/end includes trailing tool activity after the final text.
-    expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
+    expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒')
   })
 
   it('the settled footer appends first-step ttft and turn decode throughput', () => {
@@ -1003,7 +1554,7 @@ describe('ChatView', () => {
     })
     const view = render(<h.ChatView {...h.props} />)
     // First-step ttft (1.2s) plus 100 tokens over 5s of decode.
-    expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
+    expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒')
     expect(view.getAllByText(/首 token 1\.2秒/)).toHaveLength(1)
     expect(view.getAllByText(/20 tok\/s/)).toHaveLength(1)
   })
@@ -1126,8 +1677,8 @@ describe('ChatView', () => {
       h.setChat({
         nodes: [
           user(1, markdown),
-          assistant(2, markdown),
-          { ...assistant(3, markdown), interrupted: true },
+          assistant(2, markdown, 1, 1),
+          { ...assistant(3, markdown, 1, 2), interrupted: true },
         ],
       })
     })
@@ -1160,12 +1711,12 @@ describe('ChatView', () => {
     // Count renderSlot invocations: the memo boundary holds when CallRow does
     // not re-render, so the row's renderSlot call count freezes during chunks.
     let rowRenders = 0
-    h.props.renderSlot = ((key: string, owner: object) => {
+    h.setNodeRenderer(((key: string, owner: object) => {
       if (key !== 'conversation.chat.node'
         || (owner as RoutedChatNodeOwner).node.kind !== 'tool-call') return null
       rowRenders += 1
       return <div data-testid="counting-row" />
-    })
+    }) as React.ComponentProps<typeof ChatNodeSeat>['renderSlot'])
     const view = render(<h.ChatView {...h.props} />)
     expect(view.getByTestId('counting-row')).toBeTruthy()
     const afterMount = rowRenders
@@ -1219,7 +1770,7 @@ describe('ChatView', () => {
       return key === 'conversation.chat.node' && routed.node.kind === 'tool-call'
         ? <StatefulToolNode node={routed.node} />
         : opts?.fallback ?? null
-    }) as ChatViewSlotProps['renderSlot']
+    }) as React.ComponentProps<typeof ChatNodeSeat>['renderSlot']
     const view = render(<h.ChatView {...h.props} />)
     const tool = view.getByTestId('stateful-tool')
     const row = view.container.querySelector('[data-chat-flow-key="fixture:tool:r1"]')
@@ -1270,10 +1821,10 @@ describe('ChatView', () => {
     const block = toolResult(3, 'a')
     const h = makeHarness({ nodes: [block] })
     const calls: { key: string; owner: object; entryKey?: string }[] = []
-    h.props.renderSlot = ((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
+    h.setNodeRenderer(((key: string, owner: object, opts?: { entryKey?: string; fallback?: React.ReactNode }) => {
       calls.push({ key, owner, ...(opts?.entryKey !== undefined ? { entryKey: opts.entryKey } : {}) })
       return opts?.fallback ?? null
-    })
+    }) as React.ComponentProps<typeof ChatNodeSeat>['renderSlot'])
     render(<h.ChatView {...h.props} />)
     expect(calls).toHaveLength(1)
     expect(calls[0]).toMatchObject({

+ 408 - 7
packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts

@@ -15,6 +15,8 @@ import {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import { hasAssistantReplyContent } from '../src/client/contract/assistant-content.ts'
+import { decodeTurnProcess } from '../src/client/contract/turn-process.ts'
 import { assistantDefinition } from '../src/client/conversation-nodes/assistant.ts'
 import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapshot-builder.ts'
 import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
@@ -29,6 +31,7 @@ import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
 import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
 import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts'
 import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
+import { turnProcessDefinition } from '../src/client/conversation-nodes/turn-process.ts'
 import type {
   AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
 } from '../src/client/contract/chat-nodes.ts'
@@ -39,6 +42,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [
   messageDefinition,
   requestPromptDefinition(inspectRequestPrompt),
   assistantDefinition,
+  turnProcessDefinition,
   toolDefinition,
   commandDefinition,
   compactionDefinition,
@@ -221,6 +225,350 @@ describe('built-in conversation node Definitions', () => {
     expect(items[0]?.prompt.length).toBe(160)
   })
 
+  it('classifies reply content separately from reasoning and Tool protocol blocks', () => {
+    expect(hasAssistantReplyContent([{ kind: 'text', text: '  ' }])).toBe(false)
+    expect(hasAssistantReplyContent([{ kind: 'reasoning', text: 'thinking' }])).toBe(false)
+    expect(hasAssistantReplyContent([{ kind: 'tool-call', callId: 'c', name: 'read', argsRaw: '{}' }])).toBe(false)
+    expect(hasAssistantReplyContent([{ kind: 'text', text: 'answer' }])).toBe(true)
+    expect(hasAssistantReplyContent([{ kind: 'image', attachment: {} as never }])).toBe(true)
+    expect(hasAssistantReplyContent([{ kind: 'other', block: { type: 'future' } }])).toBe(true)
+  })
+
+  it('projects one reversible process window before the finalized answer', () => {
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'step/start', { turn: 1, step: 1 }),
+      at(3, 'user/message', {
+        ...textMessage('context-1', 'workspace context'),
+        turn: 1,
+        step: 1,
+        source: { kind: 'plugin', plugin: 'context' },
+      }, { surfaceOp: 'append' }),
+      at(4, 'assistant/chunk', {
+        turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
+      }),
+      at(5, 'assistant/chunk', {
+        turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'checking' },
+      }),
+      at(6, 'assistant/chunk', {
+        turn: 1,
+        step: 1,
+        chunk: { type: 'tool-call-delta', index: 2, id: 'call-1', name: 'read', argumentsDelta: '{}' },
+      }),
+    ])
+    const process = () => {
+      const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
+      return signature === undefined ? undefined : decodeTurnProcess(signature)
+    }
+    expect(process()).toMatchObject({ processStartSeq: 4, answerAnchorSeq: null, answerStep: null })
+    expect(node(snapshot(value), 'turn-process')?.data).toMatchObject({ answerAnchorSeq: null })
+
+    value.append(at(7, 'tool/call', {
+      turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}',
+    }))
+    value.append(at(8, 'tool/result', {
+      turn: 1, step: 1, message: toolResult('call-1', 'done'),
+    }, { surfaceOp: 'append' }))
+    value.append(at(9, 'step/end', { turn: 1, step: 1 }))
+    value.append(at(10, 'step/start', { turn: 1, step: 2 }))
+    value.append(at(11, 'assistant/chunk', {
+      turn: 1, step: 2, chunk: { type: 'reasoning-delta', index: 0, text: 'final thinking' },
+    }))
+    value.append(at(12, 'assistant/chunk', {
+      turn: 1, step: 2, chunk: { type: 'text-delta', index: 1, text: 'final reply' },
+    }))
+    value.flush()
+    expect(process()).toMatchObject({
+      processStartSeq: 4,
+      answerAnchorSeq: null,
+      answerStep: null,
+      inlineReasoning: false,
+    })
+
+    value.append(at(13, 'llm/retry', {
+      retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal',
+      policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
+      failure: { code: 'TRANSPORT', message: 'temporary' },
+    }))
+    value.flush()
+    expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null })
+
+    value.append(at(14, 'assistant/chunk', {
+      turn: 1,
+      step: 2,
+      chunk: { type: 'text-delta', index: 0, text: 'replacement reply' },
+    }))
+    value.flush()
+    expect(process()).toMatchObject({ answerAnchorSeq: null, answerStep: null })
+
+    value.append(at(15, 'step/end', { turn: 1, step: 2 }))
+    value.append(at(16, 'turn/end', {
+      turn: 1,
+      reason: { kind: 'aborted', reason: { kind: 'user' } },
+    }))
+    value.flush()
+    expect(process()).toMatchObject({ answerAnchorSeq: 14.1, answerStep: 2 })
+
+    const recovered = assembler([
+      at(20, 'turn/start', { turn: 2 }),
+      at(21, 'step/start', { turn: 2, step: 1 }),
+      at(22, 'assistant/message', {
+        turn: 2, step: 1, message: assistantMessage('recovered-1', 'settled reply'),
+      }, { surfaceOp: 'append' }),
+      at(23, 'step/end', { turn: 2, step: 1 }),
+      at(24, 'step/start', { turn: 2, step: 2 }),
+      at(25, 'assistant/chunk', {
+        turn: 2, step: 2, chunk: { type: 'text-delta', index: 0, text: 'crash partial' },
+      }),
+      at(26, 'turn/end', { turn: 2, reason: { kind: 'interrupted' } }),
+    ])
+    const recoveredSignature = snapshot(recovered).timeline.turns.get(2)?.data.get('turn-process')
+    expect(recoveredSignature === undefined ? undefined : decodeTurnProcess(recoveredSignature))
+      .toMatchObject({ answerStep: 2, answerAnchorSeq: 25.1 })
+
+    const partialWindow = assembler([
+      at(30, 'assistant/chunk', {
+        turn: 3, step: 4, chunk: { type: 'text-delta', index: 0, text: 'loaded tail' },
+      }),
+      at(31, 'step/end', { turn: 3, step: 4 }),
+    ], true)
+    const partialSignature = snapshot(partialWindow).timeline.turns.get(3)?.data.get('turn-process')
+    expect(partialSignature === undefined ? undefined : decodeTurnProcess(partialSignature))
+      .toMatchObject({ processStartSeq: 30.1, answerAnchorSeq: 30.1, answerStep: 4 })
+  })
+
+  it('counts Assistant messages, Tool calls, and subagent delegations per Turn', () => {
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'step/start', { turn: 1, step: 1 }),
+      at(3, 'assistant/message', {
+        turn: 1, step: 1, message: assistantMessage('message-1', 'checking'),
+      }, { surfaceOp: 'append' }),
+      at(4, 'tool/call', {
+        turn: 1, step: 1, callId: 'call-read', name: 'read', arguments: '{}',
+      }),
+      at(5, 'tool/result', {
+        turn: 1, step: 1, message: toolResult('call-read', 'read done'),
+      }, { surfaceOp: 'append' }),
+      at(6, 'tool/call', {
+        turn: 1, step: 1, callId: 'call-subagent', name: 'subagent_fork', arguments: '{}',
+      }),
+      at(7, 'tool/result', {
+        turn: 1, step: 1, message: toolResult('call-subagent', 'delegation done'),
+      }, { surfaceOp: 'append' }),
+      at(8, 'step/end', { turn: 1, step: 1 }),
+      at(9, 'step/start', { turn: 1, step: 2 }),
+      at(10, 'assistant/message', {
+        turn: 1, step: 2, message: assistantMessage('message-2', 'final answer'),
+      }, { surfaceOp: 'append' }),
+      at(11, 'step/end', { turn: 1, step: 2 }),
+      at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ])
+    const signature = snapshot(value).timeline.turns.get(1)?.data.get('turn-process')
+    expect(signature === undefined ? undefined : decodeTurnProcess(signature)).toMatchObject({
+      messageCount: 1,
+      toolCallCount: 1,
+      subagentCount: 1,
+    })
+  })
+
+  it('orders the opening User before its process control and later steering', () => {
+    const steering = textMessage('steer-1', 'change direction')
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'user/message', {
+        ...textMessage('context-1', 'runtime context'),
+        source: { kind: 'plugin', plugin: 'context' },
+      }, { surfaceOp: 'append' }),
+      at(3, 'user/message', textMessage('user-1', 'question'), { surfaceOp: 'append' }),
+      at(4, 'step/start', { turn: 1, step: 1 }),
+    ])
+    const opening = snapshot(value)
+    expect(opening.order.map(key => opening.nodes.get(key)?.kind)).toEqual([
+      'user', 'context',
+    ])
+
+    value.append(at(5, 'assistant/chunk', {
+      turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
+    }))
+    value.flush()
+    const running = snapshot(value)
+    expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([
+      'user', 'turn-process', 'context', 'assistant-step',
+    ])
+
+    value.append(at(6, 'agent/inbox/spliced', {
+      target: 'next-step', start: 0, inserted: [steering],
+    }))
+    value.append(at(7, 'agent/inbox/spliced', {
+      target: 'next-step', start: 0, removedCount: 1, inserted: [],
+    }))
+    value.append(at(8, 'user/message', steering, { surfaceOp: 'append' }))
+    value.append(at(9, 'step/end', { turn: 1, step: 1 }))
+    value.append(at(10, 'step/start', { turn: 1, step: 2 }))
+    value.append(at(11, 'assistant/message', {
+      turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
+    }, { surfaceOp: 'append' }))
+    value.append(at(12, 'step/end', { turn: 1, step: 2 }))
+    value.append(at(13, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
+    value.flush()
+    const current = snapshot(value)
+
+    expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
+      'user', 'turn-process', 'context', 'steering', 'assistant-step', 'assistant-step', 'turn-tail',
+    ])
+  })
+
+  it('orders a command-started Turn first steering before its process control', () => {
+    const steering = textMessage('command-task', 'plan this change')
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'agent/inbox/spliced', {
+        target: 'next-step', start: 0, inserted: [steering],
+      }),
+      at(3, 'agent/inbox/spliced', {
+        target: 'next-step', start: 0, removedCount: 1, inserted: [],
+      }),
+      at(4, 'user/message', steering, { surfaceOp: 'append' }),
+      at(5, 'step/start', { turn: 1, step: 1 }),
+      at(6, 'assistant/chunk', {
+        turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
+      }),
+      at(7, 'step/end', { turn: 1, step: 1 }),
+      at(8, 'step/start', { turn: 1, step: 2 }),
+      at(9, 'assistant/message', {
+        turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
+      }, { surfaceOp: 'append' }),
+      at(10, 'step/end', { turn: 1, step: 2 }),
+      at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ])
+    const current = snapshot(value)
+
+    expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
+      'steering', 'turn-process', 'assistant-step', 'assistant-step', 'turn-tail',
+    ])
+  })
+
+  it('keeps a first human message after process evidence at its event position', () => {
+    const steering = textMessage('late-steering', 'change direction')
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'step/start', { turn: 1, step: 1 }),
+      at(3, 'tool/call', {
+        turn: 1, step: 1, callId: 'call-1', name: 'read', arguments: '{}',
+      }),
+      at(4, 'tool/result', {
+        turn: 1, step: 1, message: toolResult('call-1', 'done'),
+      }, { surfaceOp: 'append' }),
+      at(5, 'agent/inbox/spliced', {
+        target: 'next-step', start: 0, inserted: [steering],
+      }),
+      at(6, 'agent/inbox/spliced', {
+        target: 'next-step', start: 0, removedCount: 1, inserted: [],
+      }),
+      at(7, 'user/message', steering, { surfaceOp: 'append' }),
+      at(8, 'step/end', { turn: 1, step: 1 }),
+      at(9, 'step/start', { turn: 1, step: 2 }),
+      at(10, 'assistant/message', {
+        turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
+      }, { surfaceOp: 'append' }),
+      at(11, 'step/end', { turn: 1, step: 2 }),
+      at(12, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
+    ])
+    const current = snapshot(value)
+
+    expect(current.order.map(key => current.nodes.get(key)?.kind)).toEqual([
+      'turn-process', 'tool-call', 'steering', 'assistant-step', 'turn-tail',
+    ])
+  })
+
+  it('keeps Process before pre-User Context as answer eligibility changes', () => {
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'user/message', {
+        ...textMessage('context-1', 'runtime context'),
+        source: { kind: 'plugin', plugin: 'context' },
+      }, { surfaceOp: 'append' }),
+      at(3, 'step/start', { turn: 1, step: 1 }),
+      at(4, 'assistant/chunk', {
+        turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
+      }),
+    ])
+    const running = snapshot(value)
+    expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([
+      'turn-process', 'context', 'assistant-step',
+    ])
+
+    value.append(at(5, 'step/end', { turn: 1, step: 1 }))
+    value.append(at(6, 'step/start', { turn: 1, step: 2 }))
+    value.append(at(7, 'assistant/message', {
+      turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
+    }, { surfaceOp: 'append' }))
+    value.flush()
+    const answered = snapshot(value)
+    expect(answered.order.map(key => answered.nodes.get(key)?.kind)).toEqual([
+      'turn-process', 'context', 'assistant-step', 'assistant-step',
+    ])
+
+    value.append(at(8, 'llm/retry', {
+      retryId: 'retry-tail', turn: 1, step: 2, provider: 'fake', mode: 'normal',
+      policyKey: 'fake-normal', retry: 1, maxRetries: 2, delayMs: 10,
+      failure: { code: 'TRANSPORT', message: 'temporary' },
+    }))
+    value.flush()
+    const retried = snapshot(value)
+    expect(retried.order.map(key => retried.nodes.get(key)?.kind)).toEqual([
+      'turn-process', 'context', 'assistant-step', 'model-retry',
+    ])
+  })
+
+  it('establishes the answer boundary only when a streamed answer finalizes', () => {
+    const value = assembler([
+      at(40, 'turn/start', { turn: 4 }),
+      at(41, 'step/start', { turn: 4, step: 1 }),
+      at(42, 'assistant/chunk', {
+        turn: 4, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
+      }),
+      at(43, 'assistant/chunk', {
+        turn: 4, step: 1, chunk: { type: 'text-delta', index: 1, text: 'answer' },
+      }),
+    ])
+    const read = () => {
+      const signature = snapshot(value).timeline.turns.get(4)?.data.get('turn-process')
+      if (signature === undefined) throw new Error('turn-process signature is unavailable')
+      return decodeTurnProcess(signature)
+    }
+    const streaming = read()
+    value.append(at(44, 'assistant/message', {
+      turn: 4, step: 1, message: assistantMessage('settled-4', 'answer'),
+    }, { surfaceOp: 'append' }))
+    value.flush()
+    const settled = read()
+
+    expect(streaming).toMatchObject({ answerAnchorSeq: null, answerStep: null })
+    expect(settled.answerAnchorSeq).toBe(44)
+    expect(settled.answerStep).toBe(1)
+  })
+
+  it('anchors a streamed non-text answer from its block start', () => {
+    const value = assembler([
+      at(50, 'turn/start', { turn: 5 }),
+      at(51, 'step/start', { turn: 5, step: 1 }),
+      at(52, 'assistant/chunk', {
+        turn: 5, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'image' },
+      }),
+    ])
+    const current = snapshot(value)
+    const process = node(current, 'turn-process')
+    const answer = node(current, 'assistant-step')
+    const signature = current.timeline.turns.get(5)?.data.get('turn-process')
+
+    expect(process?.anchorSeq).toBe(51.9)
+    expect(answer?.anchorSeq).toBe(52)
+    expect(signature === undefined ? undefined : decodeTurnProcess(signature))
+      .toMatchObject({ answerAnchorSeq: null, answerStep: null })
+  })
+
   it('keeps one keyed Assistant node while streaming settles and materializes interruption from Location', () => {
     const value = assembler([
       at(1, 'turn/start', { turn: 1 }),
@@ -638,10 +986,10 @@ describe('built-in conversation node Definitions', () => {
     const after = snapshot(value)
     expect(after.nodes).toBe(store)
     expect(after.nodes.get(existing?.key ?? '')).toBe(existing)
-    expect(after.order).toHaveLength(before.order.length + 3)
+    expect(after.order).toHaveLength(before.order.length + 4)
     expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
-      'user', 'assistant-step', 'turn-tail',
-      'user', 'assistant-step', 'turn-tail',
+      'user', 'turn-process', 'assistant-step', 'turn-tail',
+      'user', 'turn-process', 'assistant-step', 'turn-tail',
     ])
   })
 
@@ -671,7 +1019,7 @@ describe('built-in conversation node Definitions', () => {
     expect(after.order.slice(0, oldOrder.length)).toEqual(oldOrder)
     expect(oldOrder.map(key => after.nodes.get(key))).toEqual(oldNodes)
     expect(after.order.map(key => after.nodes.get(key)?.kind)).toEqual([
-      'user', 'assistant-step', 'turn-tail', 'user',
+      'user', 'turn-process', 'assistant-step', 'turn-tail', 'user',
     ])
   })
 
@@ -929,6 +1277,51 @@ describe('built-in conversation node Definitions', () => {
     expect(node(current, 'system-prompt')?.anchorSeq).toBe(1)
   })
 
+  it('keeps the initial system prompt before the opening User as Turn process state changes', () => {
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'step/start', { turn: 1, step: 1 }),
+      at(3, 'user/message', textMessage('direct-user', 'prompt'), { surfaceOp: 'append' }),
+      at(4, 'user/message', {
+        ...textMessage('runtime-context', 'runtime facts'),
+        source: { kind: 'plugin', plugin: 'context' },
+      }, { surfaceOp: 'append' }),
+      at(5, 'request/header', {
+        reason: 'initial',
+        header: { config: { provider: 'fake', model: 'fake' }, system: '# System' },
+      }),
+    ])
+    const kinds = () => {
+      const current = snapshot(value)
+      return current.order.map(key => current.nodes.get(key)?.kind)
+    }
+    const promptKey = node(snapshot(value), 'system-prompt')?.key
+
+    expect(kinds()).toEqual(['system-prompt', 'user', 'context'])
+
+    value.append(at(6, 'assistant/chunk', {
+      turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
+    }))
+    value.flush()
+    expect(kinds()).toEqual([
+      'system-prompt', 'user', 'turn-process', 'context', 'assistant-step',
+    ])
+
+    value.append(at(7, 'step/end', { turn: 1, step: 1 }))
+    value.append(at(8, 'step/start', { turn: 1, step: 2 }))
+    value.append(at(9, 'assistant/message', {
+      turn: 1, step: 2, message: assistantMessage('answer-1', 'answer'),
+    }, { surfaceOp: 'append' }))
+    value.append(at(10, 'step/end', { turn: 1, step: 2 }))
+    value.append(at(11, 'turn/end', { turn: 1, reason: { kind: 'completed' } }))
+    value.flush()
+
+    expect(kinds()).toEqual([
+      'system-prompt', 'user', 'turn-process', 'context', 'assistant-step', 'assistant-step', 'turn-tail',
+    ])
+    expect(node(snapshot(value), 'system-prompt')?.key).toBe(promptKey)
+  })
+
   it('keeps an append-only later user turn in the existing system-prompt series', () => {
     const value = assembler([
       at(1, 'turn/start', { turn: 1 }),
@@ -953,7 +1346,7 @@ describe('built-in conversation node Definitions', () => {
     expect(ordered.map(candidate => candidate.kind)).toEqual(['system-prompt', 'user', 'user'])
   })
 
-  it('keeps windowed non-initial headers at their event until prepend supplies the preceding header', () => {
+  it('keeps a windowed System prompt in place when prepend supplies the preceding header', () => {
     const reasons = ['change', 'resume', 'series'] as const
     for (const reason of reasons) {
       const windowedSystem = reason === 'series' ? '# Original' : '# Windowed'
@@ -967,7 +1360,13 @@ describe('built-in conversation node Definitions', () => {
         }),
       ], true)
 
-      expect(node(snapshot(windowed), 'system-prompt')?.anchorSeq).toBe(8)
+      const before = snapshot(windowed)
+      const prompt = node(before, 'system-prompt')
+      const user = node(before, 'user')
+      if (prompt === undefined || user === undefined) throw new Error('windowed prompt fixture is incomplete')
+      const stableOrder = [user.key, prompt.key]
+      expect(prompt.anchorSeq).toBe(8)
+      expect(before.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder)
 
       windowed.prepend([
         at(1, 'turn/start', { turn: 1 }),
@@ -985,7 +1384,9 @@ describe('built-in conversation node Definitions', () => {
         const candidate = restored.nodes.get(key)
         return candidate?.kind === 'system-prompt' ? [candidate] : []
       })
-      expect(prompts.map(prompt => prompt.anchorSeq)).toEqual([1, 5])
+      expect(prompts.map(candidate => candidate.anchorSeq)).toEqual([1, 8])
+      expect(restored.nodes.get(prompt.key)?.anchorSeq).toBe(8)
+      expect(restored.order.filter(key => stableOrder.includes(key))).toEqual(stableOrder)
     }
   })
 

+ 1 - 1
packages/client/ui-chat/tests/selection-survival.client.spec.tsx

@@ -73,7 +73,7 @@ describe('Chat selection survives on its store seat', () => {
     await b.runtime.sessions.add({ id: 's1' })
     const reborn = storeFor(b, 'conversation.view', sid('s1'))
     expect(reborn).not.toBe(doomed)
-    expect(reborn.store.getSnapshot()).toEqual({ selection: null })
+    expect(reborn.store.getSnapshot()).toEqual({ selection: null, turnProcesses: [] })
     await b.runtime.dispose()
   })
 })

+ 47 - 0
packages/client/ui-chat/tests/transcript-view-policy.client.spec.ts

@@ -0,0 +1,47 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest'
+import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
+import type { ChatSettings } from '../src/chat-settings.ts'
+import { TranscriptViewPolicy } from '../src/client/transcript-view.ts'
+
+describe('TranscriptViewPolicy', () => {
+  it('defaults to Compact and publishes explicit choices before persistence settles', () => {
+    const host = stubSettingsScope<ChatSettings>()
+    const observed: string[] = []
+    let current = (): string => 'unconstructed'
+    const scope: typeof host.scope = {
+      ...host.scope,
+      set: (field, value) => {
+        observed.push(`${field}=${String(value)}:${current()}`)
+        return host.scope.set(field, value)
+      },
+    }
+    const policy = new TranscriptViewPolicy(scope)
+    current = () => policy.mode.getSnapshot()
+
+    expect(policy.mode.getSnapshot()).toBe('compact')
+    policy.setMode('normal')
+    expect(policy.mode.getSnapshot()).toBe('normal')
+    expect(observed).toEqual(['transcriptView=normal:normal'])
+    expect(host.set).toHaveBeenCalledWith('transcriptView', 'normal')
+  })
+
+  it('adopts Host state and ignores identical writes', () => {
+    const host = stubSettingsScope<ChatSettings>()
+    const policy = new TranscriptViewPolicy(host.scope)
+
+    host.publish({ status: 'ready', value: { transcriptView: 'normal' }, revision: 1, writable: true })
+    expect(policy.mode.getSnapshot()).toBe('normal')
+    policy.setMode('normal')
+    expect(host.set).not.toHaveBeenCalled()
+
+    host.publish({ value: { transcriptView: 'compact' }, revision: 2 })
+    expect(policy.mode.getSnapshot()).toBe('compact')
+  })
+
+  it('adopts an accepted section standing at construction', () => {
+    const host = stubSettingsScope<ChatSettings>()
+    host.publish({ status: 'ready', value: { transcriptView: 'normal' }, revision: 1, writable: true })
+    expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal')
+  })
+})

+ 64 - 0
packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx

@@ -0,0 +1,64 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen } from '@testing-library/react'
+import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
+import type { SessionPendingInteractionSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx'
+import { en } from '../src/client/locale.ts'
+
+afterEach(cleanup)
+
+function emptySessions() {
+  return bindSnapshotSelector(createSnapshotStore<SessionListState>({
+    ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined,
+  }))
+}
+
+function emptyWorkspaces() {
+  return bindSnapshotSelector(createSnapshotStore<WorkspaceSnapshot>({
+    items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
+  }))
+}
+
+function noPendingInteraction() {
+  return bindSnapshotSelector(createSnapshotStore<SessionPendingInteractionSnapshot>(new Map()))
+}
+
+function mount(mode: 'normal' | 'compact' = 'compact') {
+  const source = createSnapshotStore(mode)
+  const setTranscriptView = vi.fn((next: 'normal' | 'compact') => { source.set(next) })
+  const props: TranscriptViewRowProps = {
+    useSessions: emptySessions(),
+    useSessionPendingInteraction: noPendingInteraction(),
+    useWorkspaces: emptyWorkspaces(),
+    useTranscriptView: bindSnapshotSelector(source),
+    setTranscriptView,
+    t: makeTranslate(en),
+  }
+  render(<TranscriptViewRow {...props} />)
+  return { setTranscriptView }
+}
+
+describe('TranscriptViewRow', () => {
+  it('explains the preference and shows Compact by default', () => {
+    mount()
+    expect(screen.getByText('Conversation display')).toBeDefined()
+    expect(screen.getByText('Controls process content in completed turns')).toBeDefined()
+    expect(screen.getByRole('button', { name: /Compact/ }).getAttribute('aria-expanded')).toBe('false')
+  })
+
+  it('selects Normal and follows the mirrored value', () => {
+    const b = mount()
+    fireEvent.click(screen.getByRole('button', { name: /Compact/ }))
+    fireEvent.click(screen.getByRole('menuitem', { name: 'Normal' }))
+    expect(b.setTranscriptView).toHaveBeenCalledWith('normal')
+    const trigger = screen.getByRole('button', { name: /Normal/ })
+    fireEvent.click(trigger)
+    expect(screen.getByRole('menuitem', { name: 'Compact' })).toBeDefined()
+    fireEvent.pointerDown(document.body)
+    expect(screen.queryByRole('menuitem', { name: 'Compact' })).toBeNull()
+  })
+})

+ 6 - 0
packages/client/ui-chat/tsconfig.json

@@ -56,6 +56,9 @@
     {
       "path": "../../session/session-stats"
     },
+    {
+      "path": "../../settings/settings"
+    },
     {
       "path": "../locale"
     },
@@ -80,6 +83,9 @@
     {
       "path": "../ui-session"
     },
+    {
+      "path": "../ui-settings"
+    },
     {
       "path": "../ui-slots"
     },

+ 2 - 1
packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx

@@ -7,7 +7,7 @@ import type {
   ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode,
 } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
+import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
 import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import {
@@ -103,6 +103,7 @@ async function bench(snapshot: ChatSnapshot) {
   const chat = createSnapshotStore(snapshot)
   const events = new ConversationEventRegistry(ctx)
   const views = new ConversationViewRegistry(ctx)
+  ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   ctx.provide('uiConversation', {
     events,
     views,

+ 11 - 8
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n      { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:185',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:202',
   },
   {
     key: 'conversation.chat.commandview',
@@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n      { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:173',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:190',
   },
   {
     key: 'conversation.chat.node',
@@ -266,11 +266,12 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       },
     ],
     ownerProps: [
-      '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n  selectedCallId?: ToolCallId | undefined\n  cwd?: string | undefined\n  openFile: (path: string) => void\n  inspectCall: (callId: ToolCallId) => void\n  forkAt: (seq: number) => void\n  renderMessageImages: RenderMessageImages\n  fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n}',
+      '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n  selectedCallId?: ToolCallId | undefined\n  cwd?: string | undefined\n  openFile: (path: string) => void\n  inspectCall: (callId: ToolCallId) => void\n  forkAt: (seq: number) => void\n  renderMessageImages: RenderMessageImages\n  fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n  /** Turn-process state when this Node belongs to a projected Turn. */\n  turnProcess?: TurnProcessOwnerProps | undefined\n}',
     ],
     ownerPropsReferences: [
       'MarkdownFileMentions',
       'RenderMessageImages',
+      'TurnProcessOwnerProps',
       'TurnTailOwnerProps',
     ],
     standardProps: [
@@ -287,7 +288,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'useProjection: UseProjection',
       'useTrajectory: UseTrajectory',
     ],
-    keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-tail, unknown, user, workflow-run',
+    keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode<Kind> } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run',
     hookContext: 'string',
     slotInject: 'ChatNodeTurnDataInjected',
     declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted',
@@ -303,6 +304,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'client-ui-chat RetryNodeView key \'model-retry\'',
       'client-ui-chat TurnErrorNodeView key \'turn-error\'',
       'client-ui-chat TurnMaxTokensNodeView key \'turn-max-tokens\'',
+      'client-ui-chat TurnProcessNodeView key \'turn-process\'',
       'client-ui-chat TurnTailNodeView key \'turn-tail\'',
       'client-ui-chat UnknownNodeView key \'unknown\'',
       'client-ui-goal GoalCommandInputView key \'command-input\'',
@@ -311,7 +313,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n      { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:154',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:171',
   },
   {
     key: 'conversation.chat.turnTail',
@@ -356,7 +358,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n      { name: \'conversation.chat.turnTail\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:179',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:196',
   },
   {
     key: 'conversation.composer',
@@ -535,7 +537,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n      { name: \'conversation.details.tool\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:191',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:208',
   },
   {
     key: 'conversation.hero.agentPreset',
@@ -1025,7 +1027,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n      { name: \'conversation.message.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:167',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:184',
   },
   {
     key: 'conversation.session',
@@ -1525,6 +1527,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [
       'client-locale LanguageRow id \'language\'',
       'client-ui-agent-preset AgentPresetRow id \'agent-preset\'',
+      'client-ui-chat TranscriptViewRow id \'transcript-view\'',
       'client-ui-conversation EnterBehaviorRow id \'composer-enter\'',
       'client-ui-permission-presets PermissionRow id \'permission\'',
       'client-ui-theme AppearanceRow id \'appearance\'',

+ 10 - 0
pnpm-lock.yaml

@@ -1961,6 +1961,10 @@ importers:
         version: 18.3.1(react@18.3.1)
 
   packages/client/ui-chat:
+    dependencies:
+      '@deepseek-ai/schemastery':
+        specifier: link:../../../vendor/schemastery
+        version: link:../../../vendor/schemastery
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -2004,6 +2008,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-session':
         specifier: workspace:^
         version: link:../ui-session
+      '@deepseek-ai/dsh-client-ui-settings':
+        specifier: workspace:^
+        version: link:../ui-settings
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
@@ -2031,6 +2038,9 @@ importers:
       '@deepseek-ai/dsh-session-stats':
         specifier: workspace:^
         version: link:../../session/session-stats
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       '@deepseek-ai/dsh-token-meter':
         specifier: workspace:^
         version: link:../../llm/token-meter

+ 3 - 0
snapshots/web/code-mode-round/ui.expected.md

@@ -16,6 +16,9 @@
 - text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}"
 - button "Copy":
   - img
+- button "1 tool call" [expanded]:
+  - text: 1 tool call
+  - img
 - button "Context injection @deepseek-ai/dsh-system-prompt":
   - img
   - img

+ 8 - 3
snapshots/web/cordis-tool-round/ui.expected.md

@@ -20,6 +20,9 @@
 - text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. {{clock}}"
 - button "Copy":
   - img
+- button "3 tool calls" [expanded]:
+  - text: 3 tool calls
+  - img
 - button "Context injection @deepseek-ai/dsh-system-prompt":
   - img
   - img
@@ -70,10 +73,9 @@
 - button "Branch into a new conversation":
   - img
 - text: {{clock}} Ran for {{duration}}
-- button "Context injection cordis-host-runner":
-  - img
+- button "Thought for a while":
+  - text: Thought for a while
   - img
-  - text: Context injection cordis-host-runner
 - paragraph: The Cordis Plugin is running.
 - button "Copy":
   - img
@@ -86,6 +88,9 @@
 - text: {{clock}} Ran for {{duration}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. {{clock}}
 - button "Copy":
   - img
+- button "1 tool call" [expanded]:
+  - text: 1 tool call
+  - img
 - img
 - text: Stop Cordis Plugin snap-1
 - button "Inspect"

+ 53 - 0
snapshots/web/feedback-command/ack-expanded.expected.md

@@ -0,0 +1,53 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Reply with the single word" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
+- button "Copy":
+  - img
+- button "Thought for a while" [expanded]:
+  - text: Thought for a while
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- button "Think The user wants me to reply with a single word. Let me comply.":
+  - img
+  - img
+  - text: Think The user wants me to reply with a single word. Let me comply.
+- paragraph: LIGHTHOUSE
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."':
+  - img
+  - img
+  - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "6% of context used"
+- button "Send message" [disabled]
+- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok

+ 2 - 7
snapshots/web/feedback-command/ack.expected.md

@@ -16,14 +16,9 @@
 - text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
 - button "Copy":
   - img
-- button "Context injection @deepseek-ai/dsh-system-prompt":
+- button "Thought for a while":
+  - text: Thought for a while
   - img
-  - img
-  - text: Context injection @deepseek-ai/dsh-system-prompt
-- button "Think The user wants me to reply with a single word. Let me comply.":
-  - img
-  - img
-  - text: Think The user wants me to reply with a single word. Let me comply.
 - paragraph: LIGHTHOUSE
 - button "Copy":
   - img

+ 53 - 0
snapshots/web/feedback-release/ack-expanded.expected.md

@@ -0,0 +1,53 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Reply with the single word" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
+- button "Copy":
+  - img
+- button "Thought for a while" [expanded]:
+  - text: Thought for a while
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- button "Think The user wants me to reply with a single word. Let me comply.":
+  - img
+  - img
+  - text: Think The user wants me to reply with a single word. Let me comply.
+- paragraph: LIGHTHOUSE
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."':
+  - img
+  - img
+  - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "6% of context used"
+- button "Send message" [disabled]
+- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok

+ 2 - 7
snapshots/web/feedback-release/ack.expected.md

@@ -16,14 +16,9 @@
 - text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
 - button "Copy":
   - img
-- button "Context injection @deepseek-ai/dsh-system-prompt":
+- button "Thought for a while":
+  - text: Thought for a while
   - img
-  - img
-  - text: Context injection @deepseek-ai/dsh-system-prompt
-- button "Think The user wants me to reply with a single word. Let me comply.":
-  - img
-  - img
-  - text: Think The user wants me to reply with a single word. Let me comply.
 - paragraph: LIGHTHOUSE
 - button "Copy":
   - img

+ 57 - 0
snapshots/web/fresh-round-trip/ui-expanded.expected.md

@@ -0,0 +1,57 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Use the bash tool to" [disabled]
+  - img
+  - text: Standard mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
+- button "Copy":
+  - img
+- button "1 tool call" [expanded]:
+  - text: 1 tool call
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
+  - img
+  - img
+  - text: Think The user wants me to run a simple bash command and reply with "DONE".
+- button "Bash Echo the test string":
+  - img
+  - img
+  - text: Bash Echo the test string
+- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
+  - img
+  - img
+  - text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
+- paragraph: DONE
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- textbox "Message or run a task... / commands, @ files or sessions"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "6% of context used"
+- button "Send message" [disabled]
+- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.7K tok · Output 111 tok

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff