Ver Fonte

Merge pull request #3262 from deepseek-harness/feat/session-turn-stats-display

feat(web): turn-tail usage and time stat pills with anchored dialogs
Yifffan há 4 semanas atrás
pai
commit
12d7b4ed0c
79 ficheiros alterados com 1304 adições e 499 exclusões
  1. 6 0
      .agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.i18n.yaml
  2. 27 0
      .agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.md
  3. 27 0
      .agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.zh.md
  4. 4 1
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  5. 4 1
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  6. 4 1
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  7. 4 1
      apps/web/tests/expected/markdown-images/ui.expected.md
  8. 4 1
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  9. 4 1
      apps/web/tests/expected/math-rendering/ui.expected.md
  10. 4 1
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  11. 4 1
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  12. 112 28
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  13. 4 1
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  14. 4 1
      apps/web/tests/expected/steer-all/settled.expected.md
  15. 30 11
      apps/web/tests/turn-tail-actions.e2e.ts
  16. 3 1
      packages/client/ui-chat/package.json
  17. 22 21
      packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
  18. 9 36
      packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
  19. 15 3
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  20. 0 7
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css
  21. 30 18
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
  22. 0 88
      packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css
  23. 0 86
      packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx
  24. 165 0
      packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
  25. 235 0
      packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
  26. 12 6
      packages/client/ui-chat/src/client/locale.ts
  27. 7 1
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  28. 71 10
      packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts
  29. 5 0
      packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts
  30. 73 13
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  31. 0 76
      packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx
  32. 133 0
      packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
  33. 3 3
      packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css
  34. 1 1
      packages/client/ui-message-feedback/tests/styles.client.spec.ts
  35. 17 0
      packages/client/ui-primitives/src/icons/index.tsx
  36. 11 9
      packages/client/ui-primitives/src/useAnchoredPosition.ts
  37. 7 2
      packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts
  38. 2 2
      packages/client/ui-primitives/tests/icons.client.spec.tsx
  39. 41 0
      packages/client/ui-primitives/tests/use-dismiss-on-outside-pointer.client.spec.tsx
  40. 6 0
      pnpm-lock.yaml
  41. 12 3
      snapshots/web/cordis-tool-round/ui.expected.md
  42. 4 1
      snapshots/web/feedback-command/ack-expanded.expected.md
  43. 4 1
      snapshots/web/feedback-command/ack.expected.md
  44. 4 1
      snapshots/web/feedback-release/ack-expanded.expected.md
  45. 4 1
      snapshots/web/feedback-release/ack.expected.md
  46. 4 1
      snapshots/web/fresh-round-trip/ui-expanded.expected.md
  47. 4 1
      snapshots/web/fresh-round-trip/ui.expected.md
  48. 8 2
      snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
  49. 8 2
      snapshots/web/goal-multi-turn-actions/ui.expected.md
  50. 4 1
      snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md
  51. 4 1
      snapshots/web/lifecycle-chrome/reloaded.expected.md
  52. 4 1
      snapshots/web/live-interactions/cancel-expanded.expected.md
  53. 4 1
      snapshots/web/live-interactions/cancel.expected.md
  54. 4 1
      snapshots/web/live-interactions/retry-expanded.expected.md
  55. 4 1
      snapshots/web/live-interactions/retry.expected.md
  56. 9 2
      snapshots/web/message-actions/ui.expected.md
  57. 4 1
      snapshots/web/minimal-preset/ui.expected.md
  58. 4 1
      snapshots/web/plan-review/approved-expanded.expected.md
  59. 4 1
      snapshots/web/plan-review/approved.expected.md
  60. 4 1
      snapshots/web/ptc-round/ui.expected.md
  61. 4 1
      snapshots/web/question-composer/answered-expanded.expected.md
  62. 4 1
      snapshots/web/question-composer/answered.expected.md
  63. 4 1
      snapshots/web/queue-actions/preserved-expanded.expected.md
  64. 4 1
      snapshots/web/queue-actions/preserved.expected.md
  65. 4 1
      snapshots/web/seeded-history/command-row.expected.md
  66. 4 1
      snapshots/web/seeded-history/feedback-row.expected.md
  67. 4 1
      snapshots/web/seeded-history/ui-expanded.expected.md
  68. 4 1
      snapshots/web/seeded-history/ui.expected.md
  69. 4 1
      snapshots/web/skill-tool-row/ui.expected.md
  70. 4 1
      snapshots/web/steering/settled-expanded.expected.md
  71. 4 1
      snapshots/web/steering/settled.expected.md
  72. 8 2
      snapshots/web/subagent-conversation/ui-expanded.expected.md
  73. 8 2
      snapshots/web/subagent-conversation/ui.expected.md
  74. 7 5
      snapshots/web/turn-tail-actions/completed.expected.md
  75. 7 5
      snapshots/web/turn-tail-actions/focused.expected.md
  76. 4 1
      snapshots/web/turn-tail-actions/settled.expected.md
  77. 7 14
      snapshots/web/turn-tail-actions/usage-expanded.expected.md
  78. 4 1
      snapshots/web/web-search-round/ui.expected.md
  79. 4 1
      snapshots/web/workflow-run/ui.expected.md

+ 6 - 0
.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.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-28-web-turn-stat-pills.md
+2026-08-28-web-turn-stat-pills.md: 2661c228b9a5a7b55ba3698cf3ee2b4c76701bbc
+2026-08-28-web-turn-stat-pills.zh.md: 5cc889b9f743eefeac1343b70743c23392efc908

+ 27 - 0
.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.md

@@ -0,0 +1,27 @@
+# Agent Note: Turn-tail stat pills with anchored dialogs
+
+Status: implemented
+
+English | [中文](2026-08-28-web-turn-stat-pills.zh.md)
+
+## Problem
+
+A completed assistant Turn ended with two stacked footer rows: a `Turn usage` DisclosureRow above the icon actions, and a meta line inside the actions row carrying clock, run time, TTFT, and decode speed as plain text. The disclosure expanded inline and shifted the transcript below it, the meta line mixed audience tiers — casual readers want the clock and run time while token buckets and latency percentiles are diagnostic — and the two-row footprint repeated under every Turn of a long transcript.
+
+## Decision
+
+The tail keeps one `MessageIconActions` row. Two stat pills sit right of the branch action: a database pill labelled with the compact Turn total (`Usage 15.8K tok`) and a clock pill labelled with the wall time (`Ran for 19s`); the message clock stays plain text at the row end. Each pill is an `aria-haspopup="dialog"` trigger that portals a fixed-position dialog to `document.body`, placed above the trigger by `useAnchoredPosition` with a 12px viewport clamp and closed by outside pointerdown or Escape (ContextMeter's pattern). The usage dialog holds the exact total, provider/model routes, cache-hit rate, token buckets, and the reasoning subset inline in Output; the time dialog holds total run time, decode TPS, and the Turn's first-token latency (the first step's TTFT). Facts absent from the fold render no row, and a window without publishable Turn usage renders no usage pill; the token-meter fold and `turn/start` gating are unchanged from [exact per-Turn usage](2026-08-24-web-per-turn-token-usage.md).
+
+Row visibility follows recency: turn tails and user rows tag `data-actions-reveal`, the latest of each kind stays `always` visible, earlier rows reveal on hover or focus-within under `@media (hover: hover)`, and no-hover devices keep every row visible. Below 480px the pill labels hide and each pill takes the sibling action-button geometry — 28px width, 6px padding, centered glyph, and no adjacent-pill margin rebate — so the bare icons keep the row's 8px rhythm.
+
+## Alternatives considered
+
+**One flat whole-line trigger.** A TEMPORARY `?usage-variant=flat` switch shipped both layouts to a live A/B session; the flat line exposing TTFT, TPS, and cache hit inline read as plain metadata with a weak click affordance, and its single dialog stacked two unrelated sections. The twin pills won the comparison and the switch, its locale keys, and its tests were deleted.
+
+**Keep the inline disclosure.** Rejected: expansion shifts the transcript, and the summary row spends a permanent second line on diagnostic data under every Turn.
+
+**Hover tooltips instead of dialogs.** Rejected: seven facts need a persistent, focusable surface, and hover cannot serve touch devices that the reveal gate already exempts.
+
+## Consequences
+
+`TurnUsageDisclosure` and its stylesheet are deleted; `TurnUsagePanel` owns both pills and dialogs, and `ui-chat` gains a `react-dom` dependency for the portal. Every web ARIA golden containing an assistant tail changed mechanically from `text: Ran for …` to a labelled button. Component tests pin trigger copy, dialog content, omission of absent facts, and both close paths; style-contract tests pin the secondary-tier pill typography, the recency gate, and the 480px collapse; the turn-tail e2e drives both dialogs on a recorded session and keeps tok/s and TTFT out of the tail row.

+ 27 - 0
.agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.zh.md

@@ -0,0 +1,27 @@
+# Agent Note:Turn 尾部统计 pill 与锚定弹层
+
+状态:已实现
+
+[English](2026-08-28-web-turn-stat-pills.md) | 中文
+
+## 问题
+
+助手 Turn 完成后尾部有上下两行 footer:图标操作行上方的 `本轮用量` DisclosureRow,加上操作行内以纯文字承载时钟、用时、首 token、解码速度的 meta 行。折叠行行内展开会推移下方的对话内容;meta 行混杂了两级受众——普通读者只关心时钟和用时,token 分桶与延迟数据属于诊断信息;长对话里每个 Turn 下都重复这两行占位。
+
+## 决定
+
+尾部只保留一行 `MessageIconActions`。分叉操作右侧放两个统计 pill:数据库图标 pill 标注紧凑的本轮总量(`用量 15.8K tok`),时钟图标 pill 标注墙钟用时(`用时 19秒`);消息时钟保持纯文字置于行尾。每个 pill 是 `aria-haspopup="dialog"` 触发器,把固定定位的弹层 portal 到 `document.body`,由 `useAnchoredPosition` 锚定在触发器上方并保持 12px 视口边距,外部 pointerdown 或 Escape 关闭(沿用 ContextMeter 模式)。用量弹层承载精确总量、提供方/模型路由、缓存命中率、token 分桶及输出内联的推理子集;用时弹层承载本轮总用时、解码 TPS、本轮首 token 用时(取首个 step 的 TTFT)。fold 未产出的事实不渲染行,窗口内无可发布的 Turn 用量则不渲染用量 pill;token-meter fold 与 `turn/start` 门控沿用[精确 per-Turn 用量](2026-08-24-web-per-turn-token-usage.zh.md),未做改动。
+
+行可见性按新近度门控:turn 尾行与用户行标记 `data-actions-reveal`,各自最新一行保持 `always` 常显,更早的行在 `@media (hover: hover)` 下 hover 或 focus-within 才显示,无 hover 设备恒显示。480px 以下 pill 隐藏文字并取同排操作按钮的几何——28px 宽、6px 内边距、图标居中、取消相邻 pill 的边距补偿——让裸图标保持行的 8px 节奏。
+
+## 备选方案
+
+**整行扁平触发器。** TEMPORARY `?usage-variant=flat` 开关曾把两种布局同时交付真实 A/B 会话;扁平行把首 token、TPS、缓存命中率全部外露,读起来像普通元数据、点击暗示弱,且单一弹层堆叠两段无关内容。双 pill 胜出后,开关、其 locale key 与其测试一并删除。
+
+**保留行内折叠行。** 否决:展开推移对话内容,且摘要行让诊断数据在每个 Turn 下永久占据第二行。
+
+**用 hover tooltip 替代弹层。** 否决:七项事实需要可持久、可聚焦的面板,且 hover 无法服务 reveal 门控已豁免的触屏设备。
+
+## 影响
+
+`TurnUsageDisclosure` 及其样式表删除;`TurnUsagePanel` 拥有两个 pill 与弹层,`ui-chat` 为 portal 新增 `react-dom` 依赖。所有含助手尾行的 web ARIA golden 由 `text: Ran for …` 机械变为带标签按钮。组件测试钉住触发器文案、弹层内容、缺失事实的省略与两条关闭路径;样式契约测试钉住 pill 的次级字号、新近度门控与 480px 收缩;turn-tail e2e 在录制会话上驱动两个弹层,并确保 tok/s 与 TTFT 不出现在尾行。

+ 4 - 1
apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md

@@ -44,7 +44,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
apps/web/tests/expected/github-ready-review/conversation.expected.md

@@ -36,7 +36,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
apps/web/tests/expected/markdown-cjk-strong/ui.expected.md

@@ -44,7 +44,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
apps/web/tests/expected/markdown-images/ui.expected.md

@@ -23,7 +23,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
apps/web/tests/expected/markdown-inline-code-links/ui.expected.md

@@ -35,7 +35,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
apps/web/tests/expected/math-rendering/ui.expected.md

@@ -39,7 +39,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md

@@ -36,7 +36,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
apps/web/tests/expected/skill-user-invoke/ui.expected.md

@@ -28,7 +28,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 112 - 28
apps/web/tests/expected/stats-paged-history/ui.expected.md

@@ -48,7 +48,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m2 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m2 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r2
@@ -60,7 +63,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m3 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m3 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r3
@@ -72,7 +78,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m4 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m4 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r4
@@ -84,7 +93,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m5 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m5 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r5
@@ -96,7 +108,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m6 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m6 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r6
@@ -108,7 +123,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m7 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m7 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r7
@@ -120,7 +138,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m8 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m8 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r8
@@ -132,7 +153,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m9 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m9 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r9
@@ -144,7 +168,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m10 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m10 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r10
@@ -156,7 +183,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m11 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m11 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r11
@@ -168,7 +198,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m12 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m12 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r12
@@ -180,7 +213,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m13 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m13 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r13
@@ -192,7 +228,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m14 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m14 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r14
@@ -204,7 +243,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m15 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m15 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r15
@@ -216,7 +258,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m16 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m16 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r16
@@ -228,7 +273,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m17 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m17 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r17
@@ -240,7 +288,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m18 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m18 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r18
@@ -252,7 +303,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m19 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m19 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r19
@@ -264,7 +318,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m20 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m20 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r20
@@ -276,7 +333,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m21 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m21 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r21
@@ -288,7 +348,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m22 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m22 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r22
@@ -300,7 +363,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m23 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m23 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r23
@@ -312,7 +378,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m24 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m24 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r24
@@ -324,7 +393,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m25 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m25 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r25
@@ -336,7 +408,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m26 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m26 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r26
@@ -348,7 +423,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m27 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m27 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r27
@@ -360,7 +438,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} m28 7/25 {{clock}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}} m28 7/25 {{clock}}
 - button "Copy":
   - img
 - paragraph: r28
@@ -372,7 +453,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}}
 - button "Back to bottom":
   - img
 - textbox "Message or run a task... / commands, @ files or sessions"

+ 4 - 1
apps/web/tests/expected/steer-all/settled-expanded.expected.md

@@ -46,7 +46,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
apps/web/tests/expected/steer-all/settled.expected.md

@@ -34,7 +34,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 30 - 11
apps/web/tests/turn-tail-actions.e2e.ts

@@ -170,18 +170,37 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
     const { settled } = await sendPrompt(120_000)
     await settled
 
-    const disclosure = page.getByRole('button', { name: /Turn usage/ })
-    await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1)
-    expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
-    expect(await page.getByText('15.8K tok · Cache hit 49.7%', { exact: true }).count()).toBe(1)
+    const trigger = page.getByRole('button', { name: /Usage 15\.8K tok/ })
+    await expect.poll(() => trigger.count(), { timeout: 10_000 }).toBe(1)
+    expect(await trigger.getAttribute('aria-expanded')).toBe('false')
+    // The usage pill carries the icon and the turn total; the time pill beside
+    // it carries the run time, and both keep their details dialog-only.
+    expect(await trigger.textContent()).toBe('Usage 15.8K tok')
+    const timeTrigger = page.getByRole('button', { name: /^Ran for \S+$/ })
+    expect(await timeTrigger.count()).toBe(1)
+    expect(await page.locator('[data-turn-tail]').getByText(/tok\/s|TTFT/).count()).toBe(0)
+    expect(await page.getByRole('dialog').count()).toBe(0)
 
-    await disclosure.click()
-    expect(await disclosure.getAttribute('aria-expanded')).toBe('true')
-    expect(await page.getByText('deepseek-official/deepseek-v4-flash', { exact: true }).count()).toBe(1)
-    expect(await page.getByText('7,891 tok', { exact: true }).count()).toBe(1)
-    expect(await page.getByText('7,808 tok', { exact: true }).count()).toBe(1)
-    expect(await page.getByText('112 tok (42 tok reasoning)', { exact: true }).count()).toBe(1)
-    expect(await page.getByText('15,811 tok', { exact: true }).count()).toBe(1)
+    await trigger.click()
+    expect(await trigger.getAttribute('aria-expanded')).toBe('true')
+    const dialog = page.getByRole('dialog', { name: 'Turn usage' })
+    expect(await dialog.count()).toBe(1)
+    expect(await dialog.getByText('deepseek-official/deepseek-v4-flash', { exact: true }).count()).toBe(1)
+    expect(await dialog.getByText('49.7%', { exact: true }).count()).toBe(1)
+    expect(await dialog.getByText('7,891 tok', { exact: true }).count()).toBe(1)
+    expect(await dialog.getByText('7,808 tok', { exact: true }).count()).toBe(1)
+    expect(await dialog.getByText('112 tok (42 tok reasoning)', { exact: true }).count()).toBe(1)
+    expect(await dialog.getByText('15,811 tok', { exact: true }).count()).toBe(1)
+    await page.keyboard.press('Escape')
+    expect(await page.getByRole('dialog').count()).toBe(0)
+
+    await timeTrigger.click()
+    const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
+    expect(await timeDialog.count()).toBe(1)
+    expect(await timeDialog.getByText(/tok\/s/).count()).toBe(1)
+    expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(1)
+    await page.keyboard.press('Escape')
+    await trigger.click()
 
     const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
     await compareOrRefreshGolden(USAGE_EXPANDED_EXPECTED, expanded, MODE)

+ 3 - 1
packages/client/ui-chat/package.json

@@ -107,7 +107,9 @@
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@types/react": "~18.3.1",
-    "react": "^18.2.0"
+    "@types/react-dom": "~18.3.0",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
   },
   "dependencies": {
     "@deepseek-ai/schemastery": "workspace:^"

+ 22 - 21
packages/client/ui-chat/src/client/chat/MessageIconActions.module.css

@@ -1,47 +1,48 @@
 /* Shared message IconActions row (user + assistant). Parent modules own
-   layout offsets via the composed className. Icons stay visible when mounted;
-   the time label is hover-revealed inside a data-time-hover-root scope. */
+   layout offsets via the composed className. Row containers gate visibility
+   by recency through data-actions-reveal. */
 
 .actions {
   display: flex;
   align-items: center;
-  gap: 10px;
+  gap: 8px;
   height: calc(28px + var(--dsh-content-font-delta, 0px));
 }
 
-/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). */
+/* Clock before icons (user figma 388:20051) / after (assistant 43:32997).
+   Both read the secondary tier so the user row's clock matches the assistant
+   tail's meta line. */
 .timeStart {
   padding-right: 12px;
-  font-size: var(--dsh-content-font-size, 14px);
+  font-size: var(--dsh-content-font-size-secondary, 13px);
   line-height: calc(24px + var(--dsh-content-font-delta, 0px));
   color: var(--dsw-alias-label-tertiary);
   white-space: nowrap;
 }
 
+/* The assistant tail's clock sits at the row end after the stat pills; the
+   row's 8px flex gap spaces it, and it reads the secondary tier so it matches
+   the pill labels beside it. */
 .timeEnd {
-  padding-left: 12px;
-  font-size: var(--dsh-content-font-size, 14px);
+  font-size: var(--dsh-content-font-size-secondary, 13px);
   line-height: calc(24px + var(--dsh-content-font-delta, 0px));
   color: var(--dsw-alias-label-tertiary);
   white-space: nowrap;
 }
 
-/* Separator between the clock and the run-time label (time · Ran for 15s). */
-.runTimeDot {
-  margin: 0 10px;
-}
-
-/* Message containers opt in with data-time-hover-root: the time label fades
-   in on message hover (or keyboard focus within). Opacity keeps the layout
-   stable, and devices without hover keep the label always visible. */
+/* Row containers (user rows and turn tails) opt in with
+   data-actions-reveal='hover': every row but the latest of its kind reveals
+   the entire actions row (icons and text) on hover or focus-within, while the
+   latest ('always') keeps its row visible — no rule matches it. Opacity keeps
+   the layout stable, and devices without hover keep the row visible. */
 @media (hover: hover) {
-  [data-time-hover-root] :is(.timeStart, .timeEnd) {
+  [data-actions-reveal='hover'] .actions {
     opacity: 0;
     transition: opacity 80ms ease;
   }
 
-  [data-time-hover-root]:hover :is(.timeStart, .timeEnd),
-  [data-time-hover-root]:focus-within :is(.timeStart, .timeEnd) {
+  [data-actions-reveal='hover']:hover .actions,
+  [data-actions-reveal='hover']:focus-within .actions {
     opacity: 1;
   }
 }
@@ -60,11 +61,11 @@
   cursor: pointer;
 }
 
-/* The 16px action glyphs follow the same px delta as the text they serve;
+/* The 15px action glyphs follow the same px delta as the text they serve;
    the CSS edge overrides each svg's own width/height attributes. */
 .action svg {
-  width: calc(16px + var(--dsh-content-font-delta, 0px));
-  height: calc(16px + var(--dsh-content-font-delta, 0px));
+  width: calc(15px + var(--dsh-content-font-delta, 0px));
+  height: calc(15px + var(--dsh-content-font-delta, 0px));
 }
 
 .action:hover {

+ 9 - 36
packages/client/ui-chat/src/client/chat/MessageIconActions.tsx

@@ -6,7 +6,7 @@ import {
   IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
-import { formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts'
+import { formatMessageClock } from './message-chrome.ts'
 import { useCalendarDay } from './use-calendar-day.ts'
 import css from './MessageIconActions.module.css'
 
@@ -15,12 +15,6 @@ export interface MessageIconActionsProps {
   text: string
   /** Unix epoch ms for the clock label; omitted for transient messages. */
   time?: number | undefined
-  /** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */
-  runMs?: number | undefined
-  /** Turn first-step TTFT in ms, appended as `· TTFT 1.2s`; omitted when unrecorded. */
-  ttftMs?: number | undefined
-  /** Turn decode throughput, appended as `· 34 tok/s`; omitted when unrecorded. */
-  tokensPerSecond?: number | undefined
   /** Clock before icons (user) or after (assistant). */
   clock: 'start' | 'end'
   /** Fork the session at this message; omission hides the branch action. */
@@ -34,6 +28,11 @@ export interface MessageIconActionsProps {
    * built-in copy and branch controls.
    */
   extraActions?: ReactNode
+  /**
+   * Icon-row Turn-usage trigger (the TurnUsagePanel pill), seated after the
+   * branch control at the end of the icon cluster.
+   */
+  usageAction?: ReactNode
   /** The owning view's locale seat, passed down as a plain prop. */
   t: ChatViewSlotProps['t']
 }
@@ -44,8 +43,8 @@ export interface MessageIconActionsProps {
  * @returns The actions row element.
  */
 export function MessageIconActions({
-  text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className,
-  extraActions, t,
+  text, time, clock, onBranch, branchUnavailable = false, className,
+  extraActions, usageAction, t,
 }: MessageIconActionsProps) {
   const day = useCalendarDay()
   const reasonId = useId()
@@ -75,36 +74,9 @@ export function MessageIconActions({
       }, 1000)
     })
   }, [copied, text])
-  // The dot is decorative and stays hidden, but its margins separate the
-  // readings only on screen: without the flanking spaces a reader hears one
-  // run-on string ("Ran for 13sTTFT 0.2s12 tok/s") instead of three facts.
   const clockEl = time === undefined ? null : (
     <span className={clock === 'start' ? css.timeStart : css.timeEnd}>
       {formatMessageClock(time, t, day)}
-      {runMs !== undefined && (
-        <>
-          {' '}
-          <span className={css.runTimeDot} aria-hidden>·</span>
-          {' '}
-          {t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
-        </>
-      )}
-      {ttftMs !== undefined && (
-        <>
-          {' '}
-          <span className={css.runTimeDot} aria-hidden>·</span>
-          {' '}
-          {t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })}
-        </>
-      )}
-      {tokensPerSecond !== undefined && (
-        <>
-          {' '}
-          <span className={css.runTimeDot} aria-hidden>·</span>
-          {' '}
-          {t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
-        </>
-      )}
     </span>
   )
   return (
@@ -135,6 +107,7 @@ export function MessageIconActions({
       {onBranch !== undefined && branchUnavailable && (
         <span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
       )}
+      {usageAction}
       {clock === 'end' ? clockEl : null}
     </div>
   )

+ 15 - 3
packages/client/ui-chat/src/client/chat/MessageItem.tsx

@@ -148,7 +148,7 @@ function TurnMaxTokensItem({ t }: {
 
 /** Right-aligned bubble shared by user and steering rows. */
 function UserStyleBubble({
-  content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t,
+  content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, reveal = 'always', t,
 }: {
   content: readonly unknown[]
   renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
@@ -162,6 +162,8 @@ function UserStyleBubble({
   referenceLabels?: readonly string[]
   /** Local submission-echo previews replacing the content-derived image group. */
   previewImages?: readonly MessageImageSource[]
+  /** Whole actions-row visibility: earlier rows reveal on hover, the latest stays shown (turn tails' gate). */
+  reveal?: 'always' | 'hover'
   t: ChatViewSlotProps['t']
 }): ReactNode {
   const { text, images: contentImages, rest } = contentParts(content)
@@ -173,7 +175,7 @@ function UserStyleBubble({
       className={css.userRow}
       data-pending-steering={pending || undefined}
       data-submission-echo={echo || undefined}
-      data-time-hover-root
+      data-actions-reveal={reveal}
     >
       <div className={css.userStack}>
         {renderMessageImages({ images, align: 'end' })}
@@ -271,14 +273,24 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }:
 
 /** User and admitted-steering keyed Chat renderer. */
 export const UserMessageNodeView = memo(function UserMessageNodeView({
-  node, renderMessageImages, t,
+  node, renderMessageImages, useChat, t,
 }: ChatNodeViewProps<'user' | 'steering'>) {
   const data = node.data
+  // The transcript's last user-authored row keeps its actions row shown, the
+  // same recency gate turn tails use; earlier rows reveal on hover.
+  const isLatestUserRow = useChat((snapshot) => {
+    for (let index = snapshot.order.length - 1; index >= 0; index -= 1) {
+      const candidate = snapshot.nodes.get(snapshot.order[index] ?? '')
+      if (candidate?.kind === 'user' || candidate?.kind === 'steering') return candidate.key === node.key
+    }
+    return true
+  })
   return (
     <UserStyleBubble
       content={data.content}
       renderMessageImages={renderMessageImages}
       {...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }}
+      reveal={isLatestUserRow ? 'always' : 'hover'}
       t={t}
       actions={text => (
         <MessageIconActions

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

@@ -4,13 +4,6 @@
   gap: 16px;
 }
 
-.footer {
-  display: flex;
-  min-width: 0;
-  flex-direction: column;
-  gap: 4px;
-}
-
 .actions {
   margin-left: -6px;
 }

+ 30 - 18
packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx

@@ -2,7 +2,7 @@ import { memo } from 'react'
 import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
 import { MessageIconActions } from './MessageIconActions.tsx'
-import { TurnUsageDisclosure } from './TurnUsageDisclosure.tsx'
+import { TurnTimePanel, TurnUsagePanel } from './TurnUsagePanel.tsx'
 import { assistantText } from './turn-assistant.ts'
 import css from './TurnTailNodeView.module.css'
 
@@ -16,6 +16,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
   const data = node.data
   const hasLaterChatNode = useChat(snapshot =>
     snapshot.locations.getTurn(data.turn).at(-1) !== node.key)
+  const isLatestTurn = useChat(snapshot => snapshot.timeline.turnOrder.at(-1) === data.turn)
   const turn = node.location.kind === 'turn' || node.location.kind === 'step'
     ? node.location.turn
     : undefined
@@ -34,24 +35,35 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
     ? null
     : renderSlot('conversation.chat.assistant-actions', { messageId })
   return (
-    <div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
+    <div
+      className={css.root}
+      data-turn-tail={data.turn}
+      data-actions-reveal={isLatestTurn ? 'always' : 'hover'}
+    >
       {tail}
-      <div className={css.footer}>
-        {data.tokenUsage === undefined ? null : <TurnUsageDisclosure usage={data.tokenUsage} t={t} />}
-        <MessageIconActions
-          text={assistantText(closing.blocks)}
-          time={closing.time}
-          runMs={runMs}
-          ttftMs={data.ttftMs}
-          tokensPerSecond={data.tokensPerSecond}
-          clock="end"
-          onBranch={() => { forkAt(closing.finalNode.seq) }}
-          branchUnavailable={data.branchUnavailable || hasLaterChatNode}
-          className={css.actions}
-          extraActions={assistantActions}
-          t={t}
-        />
-      </div>
+      <MessageIconActions
+        text={assistantText(closing.blocks)}
+        time={closing.time}
+        clock="end"
+        onBranch={() => { forkAt(closing.finalNode.seq) }}
+        branchUnavailable={data.branchUnavailable || hasLaterChatNode}
+        className={css.actions}
+        extraActions={assistantActions}
+        usageAction={(
+          <>
+            {data.tokenUsage !== undefined && <TurnUsagePanel usage={data.tokenUsage} t={t} />}
+            {runMs !== undefined && (
+              <TurnTimePanel
+                runMs={runMs}
+                tokensPerSecond={data.tokensPerSecond}
+                ttftMs={data.ttftMs}
+                t={t}
+              />
+            )}
+          </>
+        )}
+        t={t}
+      />
     </div>
   )
 })

+ 0 - 88
packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css

@@ -1,88 +0,0 @@
-.root {
-  min-width: 0;
-}
-
-.root[data-open] {
-  padding-bottom: 4px;
-}
-
-.root [data-disclosure-row]:focus-visible {
-  border-radius: 6px;
-  outline: 2px solid var(--dsw-alias-label-tertiary);
-  outline-offset: -2px;
-}
-
-.chevron {
-  color: var(--dsw-alias-label-secondary);
-}
-
-.separator {
-  flex: none;
-  width: 2px;
-  height: 2px;
-  margin: 0 8px;
-  border-radius: 1px;
-  background: var(--dsw-alias-label-caption);
-}
-
-.summary {
-  min-width: 0;
-  overflow: hidden;
-  color: var(--dsw-alias-label-tertiary);
-  font-size: var(--dsh-content-font-size-secondary, 13px);
-  font-variant-numeric: tabular-nums;
-  line-height: calc(24px + var(--dsh-content-font-delta, 0px));
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.details {
-  display: grid;
-  grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
-  gap: 6px 16px;
-  box-sizing: border-box;
-  /* Indent under the title, which starts at leading (16 + delta) + gap 6. */
-  width: calc(100% - 22px - var(--dsh-content-font-delta, 0px));
-  margin: 4px 0 0 calc(22px + var(--dsh-content-font-delta, 0px));
-  padding: 10px 16px 12px 12px;
-  border-radius: 8px;
-  background: var(--dsw-alias-markdown-code-block);
-  color: var(--dsw-alias-label-tertiary);
-  font-size: 12px;
-  line-height: 18px;
-}
-
-.details dt,
-.details dd {
-  min-width: 0;
-  margin: 0;
-}
-
-.details dd {
-  color: var(--dsw-alias-label-secondary);
-  font-variant-numeric: tabular-nums;
-  text-align: right;
-}
-
-.details .route {
-  overflow-wrap: anywhere;
-}
-
-.reasoning {
-  color: var(--dsw-alias-label-tertiary);
-  white-space: nowrap;
-}
-
-.totalLabel,
-.details .totalValue {
-  padding-top: 6px;
-  border-top: 1px solid var(--dsw-alias-separator-primary);
-  color: var(--dsw-alias-label-primary);
-}
-
-@media (max-width: 480px) {
-  .details {
-    grid-template-columns: minmax(72px, auto) minmax(0, 1fr);
-    gap-inline: 10px;
-  }
-}

+ 0 - 86
packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx

@@ -1,86 +0,0 @@
-import { useState } from 'react'
-import { DisclosureRow, IconDataOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
-import type { ChatViewSlotProps } from '../contract/slots.ts'
-import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
-import css from './TurnUsageDisclosure.module.css'
-
-export interface TurnUsageDisclosureProps {
-  usage: TurnTokenUsage
-  t: ChatViewSlotProps['t']
-}
-
-function formatCompactCount(value: number, t: ChatViewSlotProps['t']): string {
-  return t('message.turnUsage.count', { count: formatTokens(value, t) })
-}
-
-function formatExactCount(value: number, t: ChatViewSlotProps['t']): string {
-  return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
-}
-
-/** Compact per-Turn usage summary with an opt-in bucket breakdown. */
-export function TurnUsageDisclosure({ usage, t }: TurnUsageDisclosureProps) {
-  const [open, setOpen] = useState(false)
-  const cacheHit = usage.cacheReadTokens === undefined
-    ? null
-    : formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1)
-  const total = formatCompactCount(usage.totalTokens, t)
-  const summary = cacheHit === null
-    ? total
-    : t('message.turnUsage.summaryWithCache', { total, percent: cacheHit })
-  const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? ''
-
-  return (
-    <DisclosureRow
-      icon={<IconDataOutline16 />}
-      title={t('message.turnUsage.title')}
-      open={open}
-      expandable
-      onToggle={() => { setOpen(value => !value) }}
-      expandOnRowClick
-      keepContentWhenOpen
-      collapsedContent={(
-        <>
-          <span className={css.separator} aria-hidden />
-          <span className={css.summary}>{summary}</span>
-        </>
-      )}
-      className={css.root}
-      chevronClassName={css.chevron}
-    >
-      <dl className={css.details} data-turn-usage-details>
-        {routes !== '' && (
-          <>
-            <dt>{t('message.turnUsage.model')}</dt>
-            <dd className={css.route}>{routes}</dd>
-          </>
-        )}
-        <dt>{t('message.turnUsage.input')}</dt>
-        <dd>{formatExactCount(usage.uncachedInputTokens, t)}</dd>
-        {usage.cacheReadTokens !== undefined && (
-          <>
-            <dt>{t('message.turnUsage.cacheRead')}</dt>
-            <dd>{formatExactCount(usage.cacheReadTokens, t)}</dd>
-          </>
-        )}
-        {usage.cacheWriteTokens !== undefined && (
-          <>
-            <dt>{t('message.turnUsage.cacheWrite')}</dt>
-            <dd>{formatExactCount(usage.cacheWriteTokens, t)}</dd>
-          </>
-        )}
-        <dt>{t('message.turnUsage.output')}</dt>
-        <dd>
-          {formatExactCount(usage.outputTokens, t)}
-          {usage.reasoningTokens !== undefined && (
-            <span className={css.reasoning}>
-              {t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })}
-            </span>
-          )}
-        </dd>
-        <dt className={css.totalLabel}>{t('message.turnUsage.total')}</dt>
-        <dd className={css.totalValue}>{formatExactCount(usage.totalTokens, t)}</dd>
-      </dl>
-    </DisclosureRow>
-  )
-}

+ 165 - 0
packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css

@@ -0,0 +1,165 @@
+/* Icon-row Turn-usage pill (data icon + turn total) plus its click-open
+   Turn-details dialog (menu surface: r12, inverted hairline, shadow-lv3 —
+   ContextMeter's panel skin). */
+
+.root {
+  display: inline-flex;
+  min-width: 0;
+}
+
+/* Adjacent stat pills (usage then time) rebate part of their combined internal
+   padding so the pair reads as one cluster inside the row's 8px flex gap,
+   while keeping 2px clear between their hover backgrounds. */
+.root + .root {
+  margin-left: -6px;
+}
+
+/* Same pill rules as the sibling `.action` icon buttons (28px hit height,
+   15px glyph, tertiary → secondary on hover with the interactive hover
+   background), widened to carry the turn-total label. */
+.trigger {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  min-width: 0;
+  height: calc(28px + var(--dsh-content-font-delta, 0px));
+  padding: 6px 8px;
+  border: none;
+  border-radius: 28px;
+  background: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  font-variant-numeric: tabular-nums;
+  line-height: calc(24px + var(--dsh-content-font-delta, 0px));
+  white-space: nowrap;
+  cursor: pointer;
+}
+
+/* A narrow column trims the label to an ellipsis instead of overflowing the
+   pill; the data glyph never shrinks. */
+.label {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.trigger svg {
+  width: calc(15px + var(--dsh-content-font-delta, 0px));
+  height: calc(15px + var(--dsh-content-font-delta, 0px));
+  flex: none;
+}
+
+.trigger:hover,
+.trigger[aria-expanded='true'] {
+  background: var(--dsw-alias-interactive-bg-hover);
+  color: var(--dsw-alias-label-secondary);
+}
+
+/* Narrow viewport: the pills collapse to bare icons with the sibling
+   `.action` geometry (28px circle, 6px padding, centered glyph); the
+   label-padding rebate no longer applies, so the pair keeps the row's plain
+   8px rhythm. The dialogs keep the words. */
+@media (max-width: 480px) {
+  .trigger {
+    justify-content: center;
+    width: calc(28px + var(--dsh-content-font-delta, 0px));
+    padding: 6px;
+  }
+
+  .trigger .label {
+    display: none;
+  }
+
+  .root + .root {
+    margin-left: 0;
+  }
+}
+
+/* Portal surface: fixed in the viewport, left/top supplied inline from the
+   anchored-position clamp so the panel keeps its 12px viewport margin instead
+   of hanging off the trigger and clipping at the window edge. Portaled panels
+   layer above modal overlays (z 1000). */
+.panel {
+  position: fixed;
+  z-index: 1100;
+  box-sizing: border-box;
+  /* Size to the widest row so provider/model stays on one line, within a cap.
+     Both bounds yield to a viewport narrower than themselves (12px margins),
+     so a fixed floor cannot push the panel past the placement clamp. */
+  width: max-content;
+  min-width: min(300px, calc(100vw - 24px));
+  max-width: min(440px, calc(100vw - 24px));
+  padding: 16px;
+  border: 1px solid var(--dsw-alias-border-inverted);
+  border-radius: 12px;
+  background: var(--dsw-specific-menu);
+  box-shadow: var(--dsw-shadow-lv3);
+  font-size: 12px;
+  line-height: 18px;
+  color: var(--dsw-alias-label-secondary);
+  cursor: default;
+}
+
+/* Dialog heading row: the section name left, the section's headline value
+   (the usage total) right, both at the primary weight. */
+.title {
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 8px;
+  color: var(--dsw-alias-label-primary);
+  font-weight: 500;
+}
+
+/* Rule under each section heading, above its rows. */
+.titleRule {
+  margin-bottom: 10px;
+  border-top: 1px solid var(--dsw-alias-border-l2);
+}
+
+.titleValue {
+  font-variant-numeric: tabular-nums;
+}
+
+/* Section-heading glyph seated left of the section name. */
+.titleLabel {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  min-width: 0;
+}
+
+.titleLabel svg {
+  width: 14px;
+  height: 14px;
+  flex: none;
+}
+
+.details {
+  display: grid;
+  grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
+  gap: 6px 16px;
+  margin: 0;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.details dt,
+.details dd {
+  min-width: 0;
+  margin: 0;
+}
+
+.details dd {
+  color: var(--dsw-alias-label-secondary);
+  font-variant-numeric: tabular-nums;
+  text-align: right;
+}
+
+.details .route {
+  overflow-wrap: anywhere;
+}
+
+.reasoning {
+  color: var(--dsw-alias-label-tertiary);
+  white-space: nowrap;
+}

+ 235 - 0
packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx

@@ -0,0 +1,235 @@
+// Icon-row Turn-stat actions: a database pill labelled with the turn total
+// click-opens the per-Turn usage dialog, and a clock pill labelled with the
+// turn wall time click-opens the Turn-time dialog. Both sit right of the
+// branch action in the tail's IconActions row, ahead of the plain clock text.
+
+import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react'
+import { createPortal } from 'react-dom'
+import {
+  IconClockOutline16, IconDatabaseOutline16, useAnchoredPosition, useDismissOnOutsidePointer,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
+import type { ChatViewSlotProps } from '../contract/slots.ts'
+import { formatLatencySeconds, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts'
+import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
+import css from './TurnUsagePanel.module.css'
+
+export interface TurnUsagePanelProps {
+  usage: TurnTokenUsage
+  /** The owning view's locale seat, passed down as a plain prop. */
+  t: ChatViewSlotProps['t']
+}
+
+export interface TurnTimePanelProps {
+  /** Turn wall time in ms, the pill's label. */
+  runMs: number
+  /** Turn decode throughput, a dialog row when known. */
+  tokensPerSecond?: number | undefined
+  /** Turn first-step TTFT in ms, a dialog row when known. */
+  ttftMs?: number | undefined
+  /** The owning view's locale seat, passed down as a plain prop. */
+  t: ChatViewSlotProps['t']
+}
+
+function formatCompactCount(value: number, t: ChatViewSlotProps['t']): string {
+  return t('message.turnUsage.count', { count: formatTokens(value, t) })
+}
+
+function formatExactCount(value: number, t: ChatViewSlotProps['t']): string {
+  return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
+}
+
+/** Viewport margin the placement clamp keeps (the Menu portal margin). */
+const PANEL_MARGIN = 12
+
+/** Distance between the trigger's top edge and the panel's bottom. */
+const PANEL_GAP = 8
+
+/**
+ * Unplaced portal panel: hidden but laid out so the clamp measures real
+ * dimensions (the `useAnchoredPosition` measure pass).
+ */
+const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
+
+interface StatDialogSeat {
+  open: boolean
+  setOpen: (open: boolean) => void
+  rootRef: MutableRefObject<HTMLSpanElement | null>
+  panelRef: MutableRefObject<HTMLDivElement | null>
+  pos: CSSProperties | null
+}
+
+/** One trigger-anchored dialog seat: open state, viewport-clamped placement, outside-close. */
+function useStatDialog(): StatDialogSeat {
+  const [open, setOpen] = useState(false)
+  const rootRef = useRef<HTMLSpanElement | null>(null)
+  const panelRef = useRef<HTMLDivElement | null>(null)
+
+  // Portal placement: the dialog is fixed above the trigger and clamped inside
+  // the viewport, so a trigger near the window edge cannot push it off-screen.
+  const pos = useAnchoredPosition({
+    open,
+    anchorRef: rootRef,
+    panelRef,
+    side: 'top',
+    gap: PANEL_GAP,
+    margin: PANEL_MARGIN,
+  })
+
+  // Outside pointerdown closes through the shared primitive; the portaled
+  // panel counts as inside. Escape close stays local, one listener while open.
+  useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef)
+  useEffect(() => {
+    if (!open) return
+    const onKeyDown = (e: KeyboardEvent): void => {
+      if (e.key === 'Escape') setOpen(false)
+    }
+    document.addEventListener('keydown', onKeyDown)
+    return () => { document.removeEventListener('keydown', onKeyDown) }
+  }, [open])
+
+  return { open, setOpen, rootRef, panelRef, pos }
+}
+
+/**
+ * Turn-usage IconActions pill with a click-open Turn-usage details dialog.
+ * @param props - Turn usage buckets and locale seat.
+ * @returns The trigger and, while open, its portaled dialog anchored above the trigger.
+ */
+export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
+  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
+
+  const cacheHit = usage.cacheReadTokens === undefined
+    ? null
+    : formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1)
+  const total = formatCompactCount(usage.totalTokens, t)
+  const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? ''
+
+  return (
+    <span ref={rootRef} className={css.root}>
+      <button
+        type="button"
+        className={css.trigger}
+        aria-haspopup="dialog"
+        aria-expanded={open}
+        onClick={() => { setOpen(!open) }}
+      >
+        <IconDatabaseOutline16 />
+        <span className={css.label}>{t('message.turnUsage.consumed', { total })}</span>
+      </button>
+      {open && createPortal(
+        <div
+          ref={panelRef}
+          className={css.panel}
+          role="dialog"
+          aria-label={t('message.turnUsage.title')}
+          style={pos ?? MEASURE_STYLE}
+        >
+          <div className={css.title}>
+            <span className={css.titleLabel}>
+              <IconDatabaseOutline16 />
+              {t('message.turnUsage.title')}
+            </span>
+            <span className={css.titleValue}>{formatExactCount(usage.totalTokens, t)}</span>
+          </div>
+          <div className={css.titleRule} aria-hidden />
+          <dl className={css.details} data-turn-usage-details>
+            {routes !== '' && (
+              <>
+                <dt>{t('message.turnUsage.model')}</dt>
+                <dd className={css.route}>{routes}</dd>
+              </>
+            )}
+            {cacheHit !== null && (
+              <>
+                <dt>{t('message.turnUsage.cacheHit')}</dt>
+                <dd>{`${cacheHit}%`}</dd>
+              </>
+            )}
+            <dt>{t('message.turnUsage.input')}</dt>
+            <dd>{formatExactCount(usage.uncachedInputTokens, t)}</dd>
+            {usage.cacheReadTokens !== undefined && (
+              <>
+                <dt>{t('message.turnUsage.cacheRead')}</dt>
+                <dd>{formatExactCount(usage.cacheReadTokens, t)}</dd>
+              </>
+            )}
+            {usage.cacheWriteTokens !== undefined && (
+              <>
+                <dt>{t('message.turnUsage.cacheWrite')}</dt>
+                <dd>{formatExactCount(usage.cacheWriteTokens, t)}</dd>
+              </>
+            )}
+            <dt>{t('message.turnUsage.output')}</dt>
+            <dd>
+              {formatExactCount(usage.outputTokens, t)}
+              {usage.reasoningTokens !== undefined && (
+                <span className={css.reasoning}>
+                  {t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })}
+                </span>
+              )}
+            </dd>
+          </dl>
+        </div>,
+        document.body,
+      )}
+    </span>
+  )
+}
+
+/**
+ * Turn-time IconActions pill with a click-open Turn-time details dialog.
+ * @param props - Turn timing facts and locale seat.
+ * @returns The clock-and-duration trigger and, while open, its portaled dialog anchored above the trigger.
+ */
+export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePanelProps) {
+  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
+  return (
+    <span ref={rootRef} className={css.root}>
+      <button
+        type="button"
+        className={css.trigger}
+        aria-haspopup="dialog"
+        aria-expanded={open}
+        onClick={() => { setOpen(!open) }}
+      >
+        <IconClockOutline16 />
+        <span className={css.label}>{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}</span>
+      </button>
+      {open && createPortal(
+        <div
+          ref={panelRef}
+          className={css.panel}
+          role="dialog"
+          aria-label={t('message.turnTime.title')}
+          style={pos ?? MEASURE_STYLE}
+        >
+          <div className={css.title}>
+            <span className={css.titleLabel}>
+              <IconClockOutline16 />
+              {t('message.turnTime.title')}
+            </span>
+          </div>
+          <div className={css.titleRule} aria-hidden />
+          <dl className={css.details} data-turn-time-details>
+            <dt>{t('message.turnTime.duration')}</dt>
+            <dd>{formatRunDuration(runMs, t)}</dd>
+            {tokensPerSecond !== undefined && (
+              <>
+                <dt>{t('message.turnTime.speed')}</dt>
+                <dd>{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}</dd>
+              </>
+            )}
+            {ttftMs !== undefined && (
+              <>
+                <dt>{t('message.turnTime.ttft')}</dt>
+                <dd>{t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}</dd>
+              </>
+            )}
+          </dl>
+        </div>,
+        document.body,
+      )}
+    </span>
+  )
+}

+ 12 - 6
packages/client/ui-chat/src/client/locale.ts

@@ -88,18 +88,21 @@ export const zh = {
   'message.maxTokens': '已达到输出 token 上限',
   'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
   'message.ranFor': '用时 {duration}',
-  'message.ttft': '首 token {seconds}秒',
   'message.tokensPerSecond': '{tps} tok/s',
   'message.turnUsage.title': '本轮用量',
-  'message.turnUsage.summaryWithCache': '{total} · 缓存命中率 {percent}%',
+  'message.turnUsage.consumed': '用量 {total}',
   'message.turnUsage.model': '提供方 / 模型',
+  'message.turnUsage.cacheHit': '缓存命中',
   'message.turnUsage.input': '未缓存输入',
   'message.turnUsage.cacheRead': '缓存读取',
   'message.turnUsage.cacheWrite': '缓存写入',
   'message.turnUsage.output': '输出',
   'message.turnUsage.reasoning': '(其中推理 {tokens})',
-  'message.turnUsage.total': '总计',
   'message.turnUsage.count': '{count} tok',
+  'message.turnTime.title': '本轮用时和速度',
+  'message.turnTime.duration': '本轮总用时',
+  'message.turnTime.speed': '输出速度(TPS)',
+  'message.turnTime.ttft': '首 token 用时(TTFT)',
   'duration.seconds': '{seconds}秒',
   'duration.minutes': '{minutes}分{seconds}秒',
   'command.running': '执行中…',
@@ -201,18 +204,21 @@ export const en = {
   'message.maxTokens': 'Output token limit reached',
   'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.',
   'message.ranFor': 'Ran for {duration}',
-  'message.ttft': 'TTFT {seconds}s',
   'message.tokensPerSecond': '{tps} tok/s',
   'message.turnUsage.title': 'Turn usage',
-  'message.turnUsage.summaryWithCache': '{total} · Cache hit {percent}%',
+  'message.turnUsage.consumed': 'Usage {total}',
   'message.turnUsage.model': 'Provider / model',
+  'message.turnUsage.cacheHit': 'Cache hit',
   'message.turnUsage.input': 'Uncached input',
   'message.turnUsage.cacheRead': 'Cached input',
   'message.turnUsage.cacheWrite': 'Cache write',
   'message.turnUsage.output': 'Output',
   'message.turnUsage.reasoning': ' ({tokens} reasoning)',
-  'message.turnUsage.total': 'Total',
   'message.turnUsage.count': '{count} tok',
+  'message.turnTime.title': 'Turn time and speed',
+  'message.turnTime.duration': 'Total run time',
+  'message.turnTime.speed': 'Tokens per second (TPS)',
+  'message.turnTime.ttft': 'Time to first token (TTFT)',
   'duration.seconds': '{seconds}s',
   'duration.minutes': '{minutes}m {seconds}s',
   'command.running': 'Running…',

+ 7 - 1
packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx

@@ -39,6 +39,12 @@ const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh)
 const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
 const RETRY_ID = 'retry-fixture' as Extract<ConversationNode, { kind: 'model-retry' }>['retryId']
 
+// Recency scans the whole transcript; a detached fixture is its own latest row.
+const useDetachedChat: ChatNodeViewProps['useChat'] = bindSnapshotSelector({
+  subscribe: () => () => {},
+  getSnapshot: () => ({ order: [], nodes: new Map() }),
+} as never)
+
 interface MessageItemProps {
   readonly node: ConversationNode
   readonly t: ChatNodeViewProps['t']
@@ -62,7 +68,7 @@ function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps)
         ? { ...node, referenceLabels }
         : node,
   }
-  const props = { node: viewNode, t: translate, renderMessageImages } as ChatNodeViewProps
+  const props = { node: viewNode, t: translate, renderMessageImages, useChat: useDetachedChat } as ChatNodeViewProps
   switch (node.kind) {
     case 'user':
     case 'steering':

+ 71 - 10
packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts

@@ -13,7 +13,7 @@ const read = (name: string): string =>
 
 function declarationsFrom(source: string, selector: string): string[] {
   const declarationText = source.replace(/\/\*[\s\S]*?\*\//g, ' ')
-  const rule = new RegExp(`(?:^|\\})\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(declarationText)
+  const rule = new RegExp(`(?:^|[{}])\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(declarationText)
   if (rule === null) throw new Error(`no \`${selector}\` rule`)
   return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
 }
@@ -45,14 +45,18 @@ describe('chat flow font-size axis', () => {
 
   it('the message clock and action glyphs scale with the text they serve', () => {
     const actions = read('MessageIconActions.module.css')
-    for (const selector of ['.timeStart', '.timeEnd']) {
-      expect(declarationsFrom(actions, selector)).toEqual(expect.arrayContaining([
-        'font-size: var(--dsh-content-font-size, 14px)',
-      ]))
-    }
+    // Both clocks read the secondary tier: the assistant tail's meta line
+    // (the whole-line usage trigger) and the user row's clock stay one step
+    // under the body size so the two rows match.
+    expect(declarationsFrom(actions, '.timeStart')).toEqual(expect.arrayContaining([
+      'font-size: var(--dsh-content-font-size-secondary, 13px)',
+    ]))
+    expect(declarationsFrom(actions, '.timeEnd')).toEqual(expect.arrayContaining([
+      'font-size: var(--dsh-content-font-size-secondary, 13px)',
+    ]))
     expect(declarationsFrom(actions, '.action svg')).toEqual(expect.arrayContaining([
-      'width: calc(16px + var(--dsh-content-font-delta, 0px))',
-      'height: calc(16px + var(--dsh-content-font-delta, 0px))',
+      'width: calc(15px + var(--dsh-content-font-delta, 0px))',
+      'height: calc(15px + var(--dsh-content-font-delta, 0px))',
     ]))
   })
 
@@ -80,8 +84,65 @@ describe('chat flow font-size axis', () => {
       .toEqual(expect.arrayContaining([`padding: 4px 0 4px ${indent}`]))
     expect(declarationsFrom(read('ContextInjectionRow.module.css'), '.body'))
       .toEqual(expect.arrayContaining([`margin: 4px 0 0 ${indent}`]))
-    expect(declarationsFrom(read('TurnUsageDisclosure.module.css'), '.details'))
-      .toEqual(expect.arrayContaining([`margin: 4px 0 0 ${indent}`]))
+  })
+
+  it('the usage-details trigger reads the secondary tier like its clock label', () => {
+    const css = read('TurnUsagePanel.module.css')
+    expect(declarationsFrom(css, '.trigger')).toEqual(expect.arrayContaining([
+      'font-size: var(--dsh-content-font-size-secondary, 13px)',
+      'line-height: calc(24px + var(--dsh-content-font-delta, 0px))',
+    ]))
+  })
+
+  it('the usage pill sizes its glyph and hit height like the sibling action buttons', () => {
+    // The pill sits in the icon row right of the branch action; its data glyph
+    // and 28px hit height follow the same delta rule as `.action` so the row
+    // stays one height at every font size.
+    const css = read('TurnUsagePanel.module.css')
+    expect(declarationsFrom(css, '.trigger')).toEqual(expect.arrayContaining([
+      'height: calc(28px + var(--dsh-content-font-delta, 0px))',
+      'white-space: nowrap',
+      'min-width: 0',
+    ]))
+    expect(declarationsFrom(css, '.trigger svg')).toEqual(expect.arrayContaining([
+      'width: calc(15px + var(--dsh-content-font-delta, 0px))',
+      'height: calc(15px + var(--dsh-content-font-delta, 0px))',
+    ]))
+    // A narrow column trims the pill label to an ellipsis instead of letting
+    // it overflow or widen the chat column.
+    expect(declarationsFrom(css, '.label')).toEqual(expect.arrayContaining([
+      'min-width: 0',
+      'overflow: hidden',
+      'text-overflow: ellipsis',
+    ]))
+  })
+
+  it('narrow viewports collapse the stat pills to the action-button circle', () => {
+    // Below 480px the label hides and the pill takes the sibling `.action`
+    // geometry (28px width, 6px padding, centered glyph); the -6px
+    // label-padding rebate between adjacent pills resets so the icon pair
+    // keeps the row's plain 8px rhythm instead of overlapping.
+    const css = read('TurnUsagePanel.module.css')
+    const narrow = /@media \(max-width: 480px\) \{([\s\S]*?)\n\}/.exec(css)?.[1] ?? ''
+    expect(narrow).toMatch(/\.trigger \{[^}]*justify-content: center/)
+    expect(narrow).toMatch(/\.trigger \{[^}]*width: calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/)
+    expect(narrow).toMatch(/\.trigger \{[^}]*padding: 6px/)
+    expect(narrow).toMatch(/\.trigger \.label \{[^}]*display: none/)
+    expect(narrow).toMatch(/\.root \+ \.root \{[^}]*margin-left: 0/)
+  })
+
+  it('non-latest turn tails hide the whole actions row until hover or focus', () => {
+    // TurnTailNodeView tags its root data-actions-reveal='hover' for every
+    // turn but the latest; the gate lives under @media (hover: hover) so
+    // no-hover devices keep the row visible. 'always' has no rule at all —
+    // absence, not an override, keeps the latest turn's row shown.
+    const css = read('MessageIconActions.module.css')
+    expect(declarationsFrom(css, "[data-actions-reveal='hover'] .actions"))
+      .toEqual(expect.arrayContaining(['opacity: 0']))
+    expect(css).toMatch(
+      /\[data-actions-reveal='hover'\]:hover \.actions,\s*\[data-actions-reveal='hover'\]:focus-within \.actions \{\s*opacity: 1/,
+    )
+    expect(css).not.toContain("[data-actions-reveal='always']")
   })
 
   it('the interrupted-turn tag stays fixed like the dense token variants', () => {

+ 5 - 0
packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts

@@ -6,6 +6,7 @@ import type {
 import type {
   ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation,
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
 import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts'
 import {
   sameTurnNavigationItem, turnNavigationItem,
@@ -179,6 +180,8 @@ export function chatSnapshotFixture(input: {
   readonly runningCalls?: readonly RunningToolCall[]
   readonly turnTimings?: LegacyConversationSlice['turnTimings']
   readonly turnEnds?: LegacyConversationSlice['turnEnds']
+  /** Per-turn usage buckets; production derives these from session events. */
+  readonly turnUsages?: ReadonlyMap<number, TurnTokenUsage> | undefined
 } = {}, previous?: ChatSnapshot): ChatSnapshot {
   const legacy: LegacyConversationSlice = {
     nodes: input.nodes ?? EMPTY,
@@ -360,6 +363,7 @@ export function chatSnapshotFixture(input: {
         && location.turn.turn === turnNumber
     })
     const metrics = deriveTurnMetrics(legacy.nodes).get(turnNumber)
+    const tokenUsage = input.turnUsages?.get(turnNumber)
     const tailData = {
       turn: turnNumber,
       seq: endSeq,
@@ -370,6 +374,7 @@ export function chatSnapshotFixture(input: {
         || (preceding.data as ReturnType<typeof assistantData>).finalNode.seq !== closing.finalNode.seq,
       ...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs },
       ...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond },
+      ...tokenUsage === undefined ? {} : { tokenUsage },
     }
     dataStore.set('turn-tail', tailData)
     nodes.push({

+ 73 - 13
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -92,7 +92,9 @@ function makeSessionSource(init: Partial<SessionSnapshot> = {}) {
   }
 }
 
-type ChatSlice = Partial<LegacyConversationSlice>
+type ChatSlice = Partial<LegacyConversationSlice> & {
+  readonly turnUsages?: NonNullable<Parameters<typeof chatSnapshotFixture>[0]>['turnUsages']
+}
 type HarnessUpdate = ChatSlice & Partial<SessionSnapshot> & { readonly chat?: ChatSnapshot }
 
 /** Scripted Chat target source, independent from Session lifecycle state. */
@@ -205,7 +207,7 @@ function makeHarness(
   chatSnapshot?: ChatSnapshot,
 ) {
   const {
-    chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds,
+    chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds, turnUsages,
     ...sessionInit
   } = init
   const chatSlice: ChatSlice = {
@@ -214,6 +216,7 @@ function makeHarness(
     ...(runningCalls === undefined ? {} : { runningCalls }),
     ...(turnTimings === undefined ? {} : { turnTimings }),
     ...(turnEnds === undefined ? {} : { turnEnds }),
+    ...(turnUsages === undefined ? {} : { turnUsages }),
   }
   const session = makeSessionSource({ ...sessionInit, ...sessionOverrides })
   const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot)
@@ -1536,7 +1539,7 @@ describe('ChatView', () => {
     expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒')
   })
 
-  it('the settled footer appends first-step ttft and turn decode throughput', () => {
+  it('the settled footer exposes ttft, decode throughput, and usage as the details trigger', () => {
     const first: AssistantMessageNode = {
       kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }],
       timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
@@ -1551,12 +1554,54 @@ describe('ChatView', () => {
       nodes: [user(1, 'hi'), first, second],
       turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
       turnEnds: new Map([[1, 20]]),
+      turnUsages: new Map([[1, {
+        uncachedInputTokens: 5_060,
+        cacheReadTokens: 4_940,
+        outputTokens: 100,
+        totalTokens: 10_100,
+      }]]),
     })
     const view = render(<h.ChatView {...h.props} />)
-    // First-step ttft (1.2s) plus 100 tokens over 5s of decode.
-    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)
+    // The usage pill carries the compact total; cache hit stays dialog-only.
+    const trigger = view.getByRole('button', { name: /用量 10\.1K tok/ })
+    expect(trigger.textContent).toBe('用量 10.1K tok')
+    expect(view.queryByRole('dialog')).toBeNull()
+    fireEvent.click(trigger)
+    const dialog = view.getByRole('dialog')
+    expect(dialog.getAttribute('aria-label')).toBe('本轮用量')
+    expect(dialog.firstChild?.textContent).toBe('本轮用量10,100 tok')
+    expect(dialog.textContent).toContain('缓存命中49.4%')
+    expect(dialog.textContent).toContain('未缓存输入5,060 tok')
+    fireEvent.keyDown(document, { key: 'Escape' })
+    // The time pill carries the run time; first-step ttft (1.2s) and 100
+    // tokens over 5s of decode move into its dialog.
+    const timeTrigger = view.getByRole('button', { name: /用时 19秒/ })
+    expect(timeTrigger.textContent).toBe('用时 19秒')
+    expect(view.queryByText(/速度 20 tok\/s|首 token/)).toBeNull()
+    fireEvent.click(timeTrigger)
+    const timeDialog = view.getByRole('dialog')
+    expect(timeDialog.getAttribute('aria-label')).toBe('本轮用时和速度')
+    expect(timeDialog.textContent).toContain('本轮总用时19秒')
+    expect(timeDialog.textContent).toContain('输出速度(TPS)20 tok/s')
+    expect(timeDialog.textContent).toContain('首 token 用时(TTFT)1.2秒')
+  })
+
+  it('withholds the usage-details trigger when turn usage is outside the window', () => {
+    const settled: AssistantMessageNode = {
+      kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }],
+      timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
+      usage: { outputTokens: 40 },
+    }
+    const h = makeHarness({
+      nodes: [user(1, 'hi'), settled],
+      turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
+      turnEnds: new Map([[1, 20]]),
+    })
+    const view = render(<h.ChatView {...h.props} />)
+    // Timing facts keep their pill, but with no usage in the window there is
+    // no usage pill to click.
+    expect(view.getByRole('button', { name: /用时/ })).toBeTruthy()
+    expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
   })
 
   it('withholds ttft and throughput while the turn is still running', () => {
@@ -1574,15 +1619,30 @@ describe('ChatView', () => {
     expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
   })
 
-  it('user and assistant message containers scope the hover-revealed time chrome', () => {
+  it('user rows and turn tails both gate the whole actions row by recency', () => {
     const h = makeHarness({
-      nodes: [user(1, 'hi'), assistant(2, 'answer')],
-      turnTimings: new Map([[1, { startTime: 1_000, endTime: 2_000 }]]),
-      turnEnds: new Map([[1, 2]]),
+      nodes: [
+        user(1, 'hi'),
+        assistant(2, 'answer'),
+        user(4, 'again'),
+        assistant(5, 'later answer', 2),
+      ],
+      turnTimings: new Map([
+        [1, { startTime: 1_000, endTime: 2_000 }],
+        [2, { startTime: 4_000, endTime: 5_000 }],
+      ]),
+      turnEnds: new Map([[1, 3], [2, 6]]),
     })
     const view = render(<h.ChatView {...h.props} />)
-    // The user row and the settled assistant's Turn Tail each own one clock scope.
-    expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
+    // The last user-authored row and the latest turn's tail stay shown;
+    // every earlier row of either kind reveals on hover.
+    const tails = view.container.querySelectorAll('[data-turn-tail]')
+    expect(new Map([...tails].map(tail => [
+      tail.getAttribute('data-turn-tail'), tail.getAttribute('data-actions-reveal'),
+    ]))).toEqual(new Map([['1', 'hover'], ['2', 'always']]))
+    const userRows = [...view.container.querySelectorAll('[data-actions-reveal]')]
+      .filter(row => row.getAttribute('data-turn-tail') === null)
+    expect(userRows.map(row => row.getAttribute('data-actions-reveal'))).toEqual(['hover', 'always'])
   })
 
   it('the run-time label is withheld when the turn start is outside the window', () => {

+ 0 - 76
packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx

@@ -1,76 +0,0 @@
-// @vitest-environment jsdom
-
-import { afterEach, describe, expect, it } from 'vitest'
-import { cleanup, fireEvent, render } from '@testing-library/react'
-import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
-import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
-import { TurnUsageDisclosure } from '../src/client/chat/TurnUsageDisclosure.tsx'
-import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
-import { en } from '../src/client/locale.ts'
-
-const t = makeTranslate(en, commonEn)
-
-afterEach(cleanup)
-
-describe('TurnUsageDisclosure', () => {
-  it('shows the exact compact summary and expands into provider facts', () => {
-    const usage: TurnTokenUsage = {
-      uncachedInputTokens: 5_060,
-      cacheReadTokens: 4_940,
-      cacheWriteTokens: 0,
-      outputTokens: 5_800,
-      reasoningTokens: 42,
-      totalTokens: 15_800,
-      routes: [{ provider: 'deepseek', model: 'deepseek-chat' }],
-    }
-    const view = render(<TurnUsageDisclosure usage={usage} t={t} />)
-
-    expect(view.getByText('15.8K tok · Cache hit 49.4%')).toBeTruthy()
-    expect(view.queryByRole('definition')).toBeNull()
-
-    fireEvent.click(view.getByRole('button'))
-    const details = view.container.querySelector('[data-turn-usage-details]') as HTMLElement
-    expect(details).toBeTruthy()
-    expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat')
-    expect(details.textContent).toContain('Uncached input5,060 tok')
-    expect(details.textContent).toContain('Cached input4,940 tok')
-    expect(details.textContent).toContain('Cache write0 tok')
-    expect(details.textContent).toContain('Output5,800 tok (42 tok reasoning)')
-    expect(details.textContent).toContain('Total15,800 tok')
-  })
-
-  it('omits unavailable optional facts instead of inventing values', () => {
-    const usage: TurnTokenUsage = {
-      uncachedInputTokens: 120,
-      outputTokens: 30,
-      totalTokens: 150,
-    }
-    const view = render(<TurnUsageDisclosure usage={usage} t={t} />)
-
-    expect(view.getByText('150 tok')).toBeTruthy()
-    expect(view.queryByText(/Cache hit/)).toBeNull()
-    fireEvent.click(view.getByRole('button'))
-    expect(view.queryByText('Provider / model')).toBeNull()
-    expect(view.queryByText('Cached input')).toBeNull()
-    expect(view.queryByText('Cache write')).toBeNull()
-    expect(view.queryByText(/reasoning/)).toBeNull()
-  })
-
-  it('keeps a partial cache hit below 100 and supports keyboard toggling', () => {
-    const usage: TurnTokenUsage = {
-      uncachedInputTokens: 1,
-      cacheReadTokens: 999,
-      outputTokens: 100,
-      totalTokens: 1_100,
-    }
-    const view = render(<TurnUsageDisclosure usage={usage} t={t} />)
-    expect(view.getByText('1.1K tok · Cache hit 99.9%')).toBeTruthy()
-
-    const disclosure = view.getByRole('button')
-    expect(disclosure.getAttribute('aria-expanded')).toBe('false')
-    fireEvent.keyDown(disclosure, { key: ' ' })
-    expect(disclosure.getAttribute('aria-expanded')).toBe('true')
-    fireEvent.keyDown(disclosure, { key: 'Enter' })
-    expect(disclosure.getAttribute('aria-expanded')).toBe('false')
-  })
-})

+ 133 - 0
packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx

@@ -0,0 +1,133 @@
+// @vitest-environment jsdom
+
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
+import { TurnTimePanel, TurnUsagePanel } from '../src/client/chat/TurnUsagePanel.tsx'
+import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
+import { en } from '../src/client/locale.ts'
+
+const t = makeTranslate(en, commonEn)
+
+afterEach(cleanup)
+
+describe('TurnUsagePanel', () => {
+  it('shows an icon-and-total pill and opens the usage dialog on click', () => {
+    const usage: TurnTokenUsage = {
+      uncachedInputTokens: 5_060,
+      cacheReadTokens: 4_940,
+      cacheWriteTokens: 0,
+      outputTokens: 5_800,
+      reasoningTokens: 42,
+      totalTokens: 15_800,
+      routes: [{ provider: 'deepseek', model: 'deepseek-chat' }],
+    }
+    const view = render(<TurnUsagePanel usage={usage} t={t} />)
+
+    const trigger = view.getByRole('button')
+    expect(trigger.textContent).toBe('Usage 15.8K tok')
+    expect(trigger.querySelector('svg')).not.toBeNull()
+    expect(trigger.getAttribute('aria-haspopup')).toBe('dialog')
+    expect(trigger.getAttribute('aria-expanded')).toBe('false')
+    expect(view.queryByRole('dialog')).toBeNull()
+
+    fireEvent.click(trigger)
+    expect(trigger.getAttribute('aria-expanded')).toBe('true')
+    const dialog = view.getByRole('dialog')
+    expect(dialog.getAttribute('aria-label')).toBe('Turn usage')
+    // Portaled out of the trigger's row, with a heading row carrying the total.
+    expect(dialog.parentElement).toBe(document.body)
+    expect(dialog.firstChild?.textContent).toBe('Turn usage15,800 tok')
+    const details = dialog.querySelector('[data-turn-usage-details]') as HTMLElement
+    expect(details).toBeTruthy()
+    expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat')
+    expect(details.textContent).toContain('Cache hit49.4%')
+    expect(details.textContent).toContain('Uncached input5,060 tok')
+    expect(details.textContent).toContain('Cached input4,940 tok')
+    expect(details.textContent).toContain('Cache write0 tok')
+    expect(details.textContent).toContain('Output5,800 tok (42 tok reasoning)')
+    expect(details.textContent).not.toContain('Total')
+  })
+
+  it('omits unavailable optional facts instead of inventing values', () => {
+    const usage: TurnTokenUsage = {
+      uncachedInputTokens: 120,
+      outputTokens: 30,
+      totalTokens: 150,
+    }
+    const view = render(<TurnUsagePanel usage={usage} t={t} />)
+
+    const trigger = view.getByRole('button')
+    expect(trigger.textContent).toBe('Usage 150 tok')
+    fireEvent.click(trigger)
+    expect(view.queryByText('Provider / model')).toBeNull()
+    expect(view.queryByText('Cache hit')).toBeNull()
+    expect(view.queryByText('Cached input')).toBeNull()
+    expect(view.queryByText('Cache write')).toBeNull()
+    expect(view.queryByText(/reasoning/)).toBeNull()
+  })
+
+  it('keeps a partial cache hit below 100 in the dialog and closes on Escape or outside pointerdown', () => {
+    const usage: TurnTokenUsage = {
+      uncachedInputTokens: 1,
+      cacheReadTokens: 999,
+      outputTokens: 100,
+      totalTokens: 1_100,
+    }
+    const view = render(<TurnUsagePanel usage={usage} t={t} />)
+    const trigger = view.getByRole('button')
+    // The pill carries the compact total; cache-hit rate and exact token
+    // counts stay in the dialog.
+    expect(trigger.textContent).toBe('Usage 1.1K tok')
+
+    fireEvent.click(trigger)
+    const dialog = view.getByRole('dialog')
+    expect(dialog.textContent).toContain('Cache hit99.9%')
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(view.queryByRole('dialog')).toBeNull()
+    expect(trigger.getAttribute('aria-expanded')).toBe('false')
+
+    fireEvent.click(trigger)
+    // A pointerdown inside the panel keeps it open; one outside closes it.
+    fireEvent.pointerDown(view.getByRole('dialog'))
+    expect(view.queryByRole('dialog')).toBeTruthy()
+    fireEvent.pointerDown(document.body)
+    expect(view.queryByRole('dialog')).toBeNull()
+  })
+})
+
+describe('TurnTimePanel', () => {
+  it('shows a clock-and-duration pill and opens the time dialog on click', () => {
+    const view = render(
+      <TurnTimePanel runMs={19_000} tokensPerSecond={20} ttftMs={1_200} t={t} />,
+    )
+    const trigger = view.getByRole('button')
+    expect(trigger.textContent).toBe('Ran for 19s')
+    expect(trigger.querySelector('svg')).not.toBeNull()
+    expect(trigger.getAttribute('aria-haspopup')).toBe('dialog')
+    expect(view.queryByRole('dialog')).toBeNull()
+
+    fireEvent.click(trigger)
+    expect(trigger.getAttribute('aria-expanded')).toBe('true')
+    const dialog = view.getByRole('dialog')
+    expect(dialog.getAttribute('aria-label')).toBe('Turn time and speed')
+    expect(dialog.parentElement).toBe(document.body)
+    const details = dialog.querySelector('[data-turn-time-details]') as HTMLElement
+    expect(details.textContent).toContain('Total run time19s')
+    expect(details.textContent).toContain('Tokens per second (TPS)20 tok/s')
+    expect(details.textContent).toContain('Time to first token (TTFT)1.2s')
+
+    fireEvent.keyDown(document, { key: 'Escape' })
+    expect(view.queryByRole('dialog')).toBeNull()
+  })
+
+  it('omits unrecorded speed and TTFT rows', () => {
+    const view = render(<TurnTimePanel runMs={3_000} t={t} />)
+    fireEvent.click(view.getByRole('button'))
+    const dialog = view.getByRole('dialog')
+    expect(dialog.textContent).toContain('Total run time3s')
+    expect(dialog.textContent).not.toContain('Tokens per second')
+    expect(dialog.textContent).not.toContain('Time to first token')
+  })
+})

+ 3 - 3
packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css

@@ -15,11 +15,11 @@
   cursor: pointer;
 }
 
-/* The 16px glyphs follow the same px delta as the text they serve; the CSS
+/* The 15px glyphs follow the same px delta as the text they serve; the CSS
    edge overrides each svg's own width/height attributes. */
 .action svg {
-  width: calc(16px + var(--dsh-content-font-delta, 0px));
-  height: calc(16px + var(--dsh-content-font-delta, 0px));
+  width: calc(15px + var(--dsh-content-font-delta, 0px));
+  height: calc(15px + var(--dsh-content-font-delta, 0px));
 }
 
 .action:hover {

+ 1 - 1
packages/client/ui-message-feedback/tests/styles.client.spec.ts

@@ -97,7 +97,7 @@ describe('MessageFeedbackActions theme styles', () => {
     // font size moves the row.
     expect(block('.action')).toMatch(/width:\s*calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/)
     expect(block('.action')).toMatch(/height:\s*calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/)
-    expect(block('.action svg')).toMatch(/width:\s*calc\(16px \+ var\(--dsh-content-font-delta, 0px\)\)/)
+    expect(block('.action svg')).toMatch(/width:\s*calc\(15px \+ var\(--dsh-content-font-delta, 0px\)\)/)
     expect(block('.noteOpen')).toMatch(/line-height:\s*calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/)
   })
 })

+ 17 - 0
packages/client/ui-primitives/src/icons/index.tsx

@@ -761,6 +761,23 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
   </svg>
 )
 
+/** IconDataOutline16 without its gear: a three-tier database cylinder. */
+export const IconDatabaseOutline16 = ({ size = 16, className }: IconProps) => (
+  <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <ellipse cx="8" cy="3.6" rx="5.75" ry="2.4" stroke="currentColor" strokeWidth="1.25" />
+    <path d="M2.25 3.6V12.3A5.75 2.4 0 0 0 13.75 12.3V3.6" stroke="currentColor" strokeWidth="1.25" />
+    <path d="M2.25 7.95A5.75 2.4 0 0 0 13.75 7.95" stroke="currentColor" strokeWidth="1.25" />
+  </svg>
+)
+
+/** Thin-stroke clock: outlined dial with square-cut hour and minute hands. */
+export const IconClockOutline16 = ({ size = 16, className }: IconProps) => (
+  <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <circle cx="8" cy="8" r="6.375" stroke="currentColor" strokeWidth="1.25" />
+    <path d="M8 4.4V8.3L10.7 9.85" stroke="currentColor" strokeWidth="1.25" />
+  </svg>
+)
+
 /** ic_send_outline_14 (figma extract): thin-stroke upward send arrow. */
 export const IconSendOutline14 = ({ size = 14, className }: IconProps) => (
   <svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">

+ 11 - 9
packages/client/ui-primitives/src/useAnchoredPosition.ts

@@ -3,10 +3,10 @@
  *
  * A portaled panel is positioned from its anchor's viewport rect, which stops
  * being true the moment anything scrolls or the window resizes. This owns that
- * one concern: measure the anchor, offset the panel below it, clamp the result
- * inside the viewport, and re-run on scroll (capture phase, so scrollers nested
- * inside the page are caught too), on resize, and on the panel's own size
- * changes while the element is open.
+ * one concern: measure the anchor, offset the panel below or above it, clamp
+ * the result inside the viewport, and re-run on scroll (capture phase, so
+ * scrollers nested inside the page are caught too), on resize, and on the
+ * panel's own size changes while the element is open.
  * @module @deepseek-ai/dsh-client-ui-primitives/useAnchoredPosition
  */
 
@@ -20,7 +20,9 @@ export interface AnchoredPositionOptions {
   anchorRef: RefObject<HTMLElement | null>
   /** The floating element, measured so the clamp uses real dimensions. */
   panelRef: RefObject<HTMLElement | null>
-  /** Distance kept between the anchor's bottom edge and the panel's top. */
+  /** Which anchor edge the panel hangs from: below it (`bottom`, the default) or above it (`top`). */
+  side?: 'top' | 'bottom'
+  /** Distance kept between the anchor edge named by `side` and the panel. */
   gap: number
   /** Distance kept between the panel and each viewport edge. */
   margin: number
@@ -28,11 +30,11 @@ export interface AnchoredPositionOptions {
 
 /**
  * Track an anchor and return the panel's fixed coordinates.
- * @param options - the open state, the two refs, and the gap/margin distances.
+ * @param options - the open state, the two refs, the placement side, and the gap/margin distances.
  * @returns `left`/`top` for the panel, or `null` before the first measurement.
  */
 export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProperties | null {
-  const { open, anchorRef, panelRef, gap, margin } = options
+  const { open, anchorRef, panelRef, side = 'bottom', gap, margin } = options
   const [position, setPosition] = useState<CSSProperties | null>(null)
   useLayoutEffect(() => {
     if (!open) {
@@ -49,7 +51,7 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper
       const width = panel?.offsetWidth ?? 0
       const height = panel?.offsetHeight ?? 0
       let left = rect.left
-      let top = rect.bottom + gap
+      let top = side === 'top' ? rect.top - gap - height : rect.bottom + gap
       if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin)
       if (height > 0) top = Math.min(Math.max(top, margin), window.innerHeight - height - margin)
       /* v8 ignore stop */
@@ -76,6 +78,6 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper
       window.removeEventListener('scroll', place, true)
       window.removeEventListener('resize', place)
     }
-  }, [open, anchorRef, panelRef, gap, margin])
+  }, [open, anchorRef, panelRef, side, gap, margin])
   return position
 }

+ 7 - 2
packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts

@@ -10,20 +10,25 @@ import type { RefObject } from 'react'
  * @param root - element containing both the trigger and the open surface.
  * @param open - whether the surface is showing; false detaches the listener.
  * @param setOpen - state setter invoked with false on an outside pointerdown.
+ * @param portal - surface portaled outside the root (a `document.body` dialog)
+ * that also counts as inside; omit when the root contains the whole popover.
  */
 export function useDismissOnOutsidePointer(
   root: RefObject<HTMLElement | null>,
   open: boolean,
   setOpen: (open: boolean) => void,
+  portal?: RefObject<HTMLElement | null>,
 ): void {
   useEffect(() => {
     if (!open) return
     const closeOutside = (event: PointerEvent): void => {
-      if (event.target instanceof Node && !root.current?.contains(event.target)) {
+      if (event.target instanceof Node
+        && root.current?.contains(event.target) !== true
+        && portal?.current?.contains(event.target) !== true) {
         setOpen(false)
       }
     }
     document.addEventListener('pointerdown', closeOutside)
     return () => { document.removeEventListener('pointerdown', closeOutside) }
-  }, [root, open, setOpen])
+  }, [root, open, setOpen, portal])
 }

+ 2 - 2
packages/client/ui-primitives/tests/icons.client.spec.tsx

@@ -17,8 +17,8 @@ const icons = Object.fromEntries(
 const iconNames = Object.keys(icons)
 
 describe('ic_ds_ icon set', () => {
-  it('exports the full icon set (46 deepsuite + 21 figma extracts + five product glyphs outside those sets)', () => {
-    expect(iconNames.length).toBe(72)
+  it('exports the full icon set (46 deepsuite + 21 figma extracts + seven product glyphs outside those sets)', () => {
+    expect(iconNames.length).toBe(74)
   })
 
   it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {

+ 41 - 0
packages/client/ui-primitives/tests/use-dismiss-on-outside-pointer.client.spec.tsx

@@ -0,0 +1,41 @@
+// @vitest-environment jsdom
+/** The outside-pointer dismissal primitive as observable popover behavior. */
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { afterEach, describe, expect, it } from 'vitest'
+import { useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
+
+afterEach(cleanup)
+
+function Popover({ portaled }: { portaled: boolean }) {
+  const [open, setOpen] = useState(true)
+  const rootRef = useRef<HTMLDivElement | null>(null)
+  const panelRef = useRef<HTMLDivElement | null>(null)
+  useDismissOnOutsidePointer(rootRef, open, setOpen, portaled ? panelRef : undefined)
+  return (
+    <div ref={rootRef} data-testid="root">
+      <button type="button">trigger</button>
+      {open && !portaled && <div data-testid="surface">surface</div>}
+      {open && portaled && createPortal(<div ref={panelRef} data-testid="surface">surface</div>, document.body)}
+    </div>
+  )
+}
+
+describe('useDismissOnOutsidePointer', () => {
+  it('closes on an outside pointerdown but not on one inside the root', () => {
+    const view = render(<Popover portaled={false} />)
+    fireEvent.pointerDown(view.getByTestId('root'))
+    expect(view.queryByTestId('surface')).not.toBeNull()
+    fireEvent.pointerDown(document.body)
+    expect(view.queryByTestId('surface')).toBeNull()
+  })
+
+  it('counts the portaled surface as inside while still closing outside it', () => {
+    const view = render(<Popover portaled />)
+    fireEvent.pointerDown(view.getByTestId('surface'))
+    expect(view.queryByTestId('surface')).not.toBeNull()
+    fireEvent.pointerDown(document.body)
+    expect(view.queryByTestId('surface')).toBeNull()
+  })
+})

+ 6 - 0
pnpm-lock.yaml

@@ -2080,9 +2080,15 @@ importers:
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
+      '@types/react-dom':
+        specifier: ~18.3.0
+        version: 18.3.7(@types/react@18.3.31)
       react:
         specifier: ^18.2.0
         version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
 
   packages/client/ui-commands:
     dependencies:

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

@@ -72,7 +72,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - button "Thought for a while":
   - text: Thought for a while
   - img
@@ -85,7 +88,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- 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 "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}} 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]:
@@ -104,7 +110,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/feedback-command/ack-expanded.expected.md

@@ -36,7 +36,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."':
   - img
   - img

+ 4 - 1
snapshots/web/feedback-command/ack.expected.md

@@ -28,7 +28,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."':
   - img
   - img

+ 4 - 1
snapshots/web/feedback-release/ack-expanded.expected.md

@@ -36,7 +36,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - '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

+ 4 - 1
snapshots/web/feedback-release/ack.expected.md

@@ -28,7 +28,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - '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

+ 4 - 1
snapshots/web/fresh-round-trip/ui-expanded.expected.md

@@ -44,7 +44,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/fresh-round-trip/ui.expected.md

@@ -28,7 +28,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 8 - 2
snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md

@@ -97,7 +97,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - button "System prompt":
   - img
   - img
@@ -217,7 +220,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - button "Back to bottom":
   - img
 - textbox "Message or run a task... / commands, @ files or sessions"

+ 8 - 2
snapshots/web/goal-multi-turn-actions/ui.expected.md

@@ -42,7 +42,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - button "System prompt":
   - img
   - img
@@ -107,7 +110,10 @@
 - button "Branch into a new conversation":
   - img
 - tooltip "Branch into a new conversation"
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md

@@ -36,7 +36,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/lifecycle-chrome/reloaded.expected.md

@@ -28,7 +28,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/live-interactions/cancel-expanded.expected.md

@@ -33,7 +33,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/live-interactions/cancel.expected.md

@@ -29,7 +29,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/live-interactions/retry-expanded.expected.md

@@ -38,7 +38,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/live-interactions/retry.expected.md

@@ -28,7 +28,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 9 - 2
snapshots/web/message-actions/ui.expected.md

@@ -31,7 +31,11 @@
   - img
 - button "Branch into a new conversation" [disabled]:
   - img
-- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: Available only on the last message of a completed turn
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}}
 - button "Read a.txt":
   - img
   - img
@@ -58,7 +62,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/minimal-preset/ui.expected.md

@@ -33,7 +33,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/plan-review/approved-expanded.expected.md

@@ -54,7 +54,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/plan-review/approved.expected.md

@@ -34,7 +34,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/ptc-round/ui.expected.md

@@ -49,7 +49,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/question-composer/answered-expanded.expected.md

@@ -46,7 +46,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/question-composer/answered.expected.md

@@ -28,7 +28,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/queue-actions/preserved-expanded.expected.md

@@ -33,7 +33,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - button "2 queued messages" [expanded]
 - list:
   - listitem:

+ 4 - 1
snapshots/web/queue-actions/preserved.expected.md

@@ -29,7 +29,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - button "2 queued messages" [expanded]
 - list:
   - listitem:

+ 4 - 1
snapshots/web/seeded-history/command-row.expected.md

@@ -47,7 +47,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}}
 - button "compact Compacted 5 history items (~{{tokens}} tokens)"
 - button "Context injection AGENTS.md":
   - img

+ 4 - 1
snapshots/web/seeded-history/feedback-row.expected.md

@@ -47,7 +47,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}}
 - button "compact Compacted 5 history items (~{{tokens}} tokens)"
 - button "Context injection AGENTS.md":
   - img

+ 4 - 1
snapshots/web/seeded-history/ui-expanded.expected.md

@@ -47,7 +47,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}}
 - button "compact Compacted 5 history items (~{{tokens}} tokens)"
 - button "Context injection AGENTS.md":
   - img

+ 4 - 1
snapshots/web/seeded-history/ui.expected.md

@@ -29,7 +29,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: 7/25 {{clock}}
 - button "compact Compacted 5 history items (~{{tokens}} tokens)"
 - button "Context injection AGENTS.md":
   - img

+ 4 - 1
snapshots/web/skill-tool-row/ui.expected.md

@@ -47,7 +47,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{date}} {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/steering/settled-expanded.expected.md

@@ -39,7 +39,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/steering/settled.expected.md

@@ -31,7 +31,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 8 - 2
snapshots/web/subagent-conversation/ui-expanded.expected.md

@@ -44,7 +44,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - button "System prompt":
   - img
   - img
@@ -68,7 +71,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 8 - 2
snapshots/web/subagent-conversation/ui.expected.md

@@ -36,7 +36,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - button "System prompt":
   - img
   - img
@@ -56,7 +59,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 7 - 5
snapshots/web/turn-tail-actions/completed.expected.md

@@ -20,10 +20,6 @@
   - text: 1 tool call · 1 message
   - img
 - paragraph: DONE
-- button "Turn usage 15.8K tok · Cache hit 49.7%":
-  - img
-  - img
-  - text: Turn usage 15.8K tok · Cache hit 49.7%
 - button "Copy":
   - img
 - button "Good response":
@@ -32,7 +28,13 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Usage 15.8K tok":
+  - img
+  - text: Usage 15.8K tok
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 7 - 5
snapshots/web/turn-tail-actions/focused.expected.md

@@ -33,10 +33,6 @@
   - img
   - text: Bash Print alpha to stdout
 - paragraph: DONE
-- button "Turn usage 15.8K tok · Cache hit 49.7%":
-  - img
-  - img
-  - text: Turn usage 15.8K tok · Cache hit 49.7%
 - button "Copy":
   - img
 - button "Good response":
@@ -45,7 +41,13 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Usage 15.8K tok":
+  - img
+  - text: Usage 15.8K tok
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/turn-tail-actions/settled.expected.md

@@ -30,7 +30,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 7 - 14
snapshots/web/turn-tail-actions/usage-expanded.expected.md

@@ -20,19 +20,6 @@
   - text: 1 tool call · 1 message
   - img
 - paragraph: DONE
-- button "Turn usage 15.8K tok · Cache hit 49.7%" [expanded]:
-  - img
-  - text: Turn usage 15.8K tok · Cache hit 49.7%
-- term: Provider / model
-- definition: deepseek-official/deepseek-v4-flash
-- term: Uncached input
-- definition: 7,891 tok
-- term: Cached input
-- definition: 7,808 tok
-- term: Output
-- definition: 112 tok (42 tok reasoning)
-- term: Total
-- definition: 15,811 tok
 - button "Copy":
   - img
 - button "Good response":
@@ -41,7 +28,13 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Usage 15.8K tok" [expanded]:
+  - img
+  - text: Usage 15.8K tok
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/web-search-round/ui.expected.md

@@ -36,7 +36,10 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
 - textbox "Message or run a task... / commands, @ files or sessions"
 - button "Commands":
   - img

+ 4 - 1
snapshots/web/workflow-run/ui.expected.md

@@ -36,4 +36,7 @@
   - img
 - button "Branch into a new conversation":
   - img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}