소스 검색

Merge remote-tracking branch 'origin/master' into codex/subprocess-native-containment

pku-xht 1 개월 전
부모
커밋
8096aba953
100개의 변경된 파일과 1667개의 추가작업 그리고 1071개의 파일을 삭제
  1. 0 37
      .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md
  2. 0 37
      .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md
  3. 3 3
      .agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.i18n.yaml
  4. 27 0
      .agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md
  5. 27 0
      .agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.zh.md
  6. 6 0
      .agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.i18n.yaml
  7. 27 0
      .agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md
  8. 27 0
      .agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md
  9. 6 0
      .agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.i18n.yaml
  10. 27 0
      .agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.md
  11. 27 0
      .agents/notes/implemented/feature/2026-08-28-web-turn-stat-pills.zh.md
  12. 6 0
      .agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.i18n.yaml
  13. 29 0
      .agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.md
  14. 29 0
      .agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.zh.md
  15. 0 344
      apps/web/tests/conversation-column-overflow.e2e.ts
  16. 3 3
      apps/web/tests/expected/composer-tab-geometry/geometry.expected.md
  17. 0 9
      apps/web/tests/expected/conversation-column-overflow/geometry.expected.md
  18. 4 1
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  19. 4 1
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  20. 4 1
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  21. 4 1
      apps/web/tests/expected/markdown-images/ui.expected.md
  22. 4 1
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  23. 4 1
      apps/web/tests/expected/math-rendering/ui.expected.md
  24. 4 1
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  25. 4 1
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  26. 112 28
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  27. 4 1
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  28. 4 1
      apps/web/tests/expected/steer-all/settled.expected.md
  29. 6 0
      apps/web/tests/reference-composer.e2e.ts
  30. 30 11
      apps/web/tests/turn-tail-actions.e2e.ts
  31. 2 2
      docs/module-graph.i18n.yaml
  32. 1 2
      docs/module-graph.md
  33. 1 2
      docs/module-graph.zh.md
  34. 2 2
      packages/client/ui-brand-official/README.i18n.yaml
  35. 5 5
      packages/client/ui-brand-official/README.md
  36. 5 5
      packages/client/ui-brand-official/README.zh.md
  37. 1 4
      packages/client/ui-brand-official/package.json
  38. 2 5
      packages/client/ui-brand-official/src/client/Brand.tsx
  39. 7 8
      packages/client/ui-brand-official/src/client/index.ts
  40. 11 4
      packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx
  41. 0 3
      packages/client/ui-brand-official/tsconfig.json
  42. 3 1
      packages/client/ui-chat/package.json
  43. 3 1
      packages/client/ui-chat/src/client/chat/ChatView.module.css
  44. 22 21
      packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
  45. 9 36
      packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
  46. 15 3
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  47. 0 7
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css
  48. 30 18
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
  49. 0 88
      packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css
  50. 0 86
      packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx
  51. 165 0
      packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
  52. 235 0
      packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
  53. 12 6
      packages/client/ui-chat/src/client/locale.ts
  54. 7 1
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  55. 71 10
      packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts
  56. 5 0
      packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts
  57. 73 13
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  58. 0 76
      packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx
  59. 133 0
      packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
  60. 25 31
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
  61. 18 17
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  62. 13 36
      packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
  63. 19 11
      packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css
  64. 17 4
      packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
  65. 2 2
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  66. 7 3
      packages/client/ui-input-trigger/src/client/MenuView.module.css
  67. 4 2
      packages/client/ui-input-trigger/src/client/MenuView.tsx
  68. 5 0
      packages/client/ui-input-trigger/src/client/controller.ts
  69. 11 3
      packages/client/ui-input-trigger/src/core/menu.ts
  70. 9 3
      packages/client/ui-input-trigger/tests/core-menu.client.spec.ts
  71. 9 0
      packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
  72. 42 0
      packages/client/ui-input-trigger/tests/service.client.spec.ts
  73. 3 3
      packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css
  74. 1 1
      packages/client/ui-message-feedback/tests/styles.client.spec.ts
  75. 23 9
      packages/client/ui-primitives/src/DiffBlock.tsx
  76. 4 0
      packages/client/ui-primitives/src/FishLogo.tsx
  77. 17 0
      packages/client/ui-primitives/src/icons/index.tsx
  78. 2 2
      packages/client/ui-primitives/src/index.ts
  79. 11 9
      packages/client/ui-primitives/src/useAnchoredPosition.ts
  80. 7 2
      packages/client/ui-primitives/src/useDismissOnOutsidePointer.ts
  81. 16 9
      packages/client/ui-primitives/src/user-text.module.css
  82. 2 2
      packages/client/ui-primitives/tests/icons.client.spec.tsx
  83. 41 0
      packages/client/ui-primitives/tests/use-dismiss-on-outside-pointer.client.spec.tsx
  84. 3 3
      packages/client/ui-primitives/tests/user-text-styles.client.spec.ts
  85. 1 1
      packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css
  86. 24 4
      packages/client/ui-tool/src/client/tool/components/ToolRow.module.css
  87. 13 2
      packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
  88. 2 0
      packages/client/ui-tool/tests/diff-card.client.spec.tsx
  89. 2 4
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  90. 6 3
      pnpm-lock.yaml
  91. 12 3
      snapshots/web/cordis-tool-round/ui.expected.md
  92. 4 1
      snapshots/web/feedback-command/ack-expanded.expected.md
  93. 4 1
      snapshots/web/feedback-command/ack.expected.md
  94. 4 1
      snapshots/web/feedback-release/ack-expanded.expected.md
  95. 4 1
      snapshots/web/feedback-release/ack.expected.md
  96. 4 1
      snapshots/web/fresh-round-trip/ui-expanded.expected.md
  97. 4 1
      snapshots/web/fresh-round-trip/ui.expected.md
  98. 8 2
      snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
  99. 8 2
      snapshots/web/goal-multi-turn-actions/ui.expected.md
  100. 4 1
      snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md

+ 0 - 37
.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md

@@ -1,37 +0,0 @@
-# Agent Note: The conversation column scrolls on one axis
-
-Status: implemented
-
-English | [中文](2026-08-04-conversation-column-one-axis-scroll.zh.md)
-
-## Problem
-
-Narrowing the center column — by the window or by the sidebar drag — put a horizontal scrollbar under the whole conversation column on the hero. The bleeding element is the hero's decorative backdrop ellipse: `.heroGlow` is sized `1051/776` of the hero box so its blur scales in userSpace with the input card, which means it reaches past the column whenever the column is narrower than the glow.
-
-That bleed is by construction and stays. What made it user-visible is the scroll container it sits in. `[data-conversation-scroll]` declared `overflow-y: auto` and left the other axis at its initial `visible`, and a box that scrolls in one axis computes `visible` to `auto` in the other. Every column narrower than the glow therefore offered a real horizontal scroll range — measured at 24–95px across the widths a laptop actually produces.
-
-## Decision
-
-`.scrollBody` declares `overflow-x: hidden`. The column states that it is a one-axis scroller instead of leaving the second axis to be derived.
-
-Clipping does not change. `overflow-y: auto` had already made the box a scroll container that clips both axes, so the declaration withdraws only the scrollbar and the user gesture; the glow keeps its bleed, its blur radius, and the same painted extent, and the column keeps its vertical scroll. Nothing in the composer chain moves.
-
-## Alternatives considered
-
-**Size the glow to fit the column.** Rejected. The glow's width is what scales its `stdDeviation="50"` blur with the input card (figma 313:14109); constraining it would make the blur tighten as the column narrows, which is a visual regression to fix a scrollbar.
-
-**Wrap the glow in a clipping box.** Rejected. It adds a box whose only job is to undo an overflow the column already clips, and it leaves the derived `overflow-x: auto` in place for the next element that bleeds — the transcript is full of candidates.
-
-**Rely on the frame's `.centerCol { overflow: hidden }`.** It cannot help. That clip is outside the scroll container, so it hides the glow's overhang at the column border while the container inside it still scrolls to reach it. The reported bar was that container's.
-
-**Assert `scrollWidth === clientWidth` in the test.** Rejected as the signal, because it does not distinguish the states: `hidden` clips the bleed rather than reflowing it away, so the scroll range reads the same on both sides of the fix. Only refusing a user gesture differs, which is what the scenario measures.
-
-## Testing
-
-[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) sweeps viewport widths bracketing the glow and, at each stop, wheels horizontally over the column and reads `scrollLeft`. The committed golden records the relation per stop; the widest stop is the control where the glow does not bleed at all.
-
-Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to its positive scroll boundary; the test measures that boundary directly because a stable scrollbar gutter can leave some overflow on the negative side of the scroll origin. Without the control, a `scrollLeft` of 0 could equally mean the wheel never arrived.
-
-## Consequences
-
-The conversation column no longer offers a horizontal scrollbar at any width, and decorative bleed in the composer chain is now clipped rather than exposed as scroll range. The cost is that genuinely wide content under this column is clipped instead of reachable by scrolling: any such surface owns its own scroller, as the markdown code block and the trajectory table already do.

+ 0 - 37
.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md

@@ -1,37 +0,0 @@
-# Agent Note: 会话列只在一个轴上滚动
-
-Status: implemented
-
-[English](2026-08-04-conversation-column-one-axis-scroll.md) | 中文
-
-## 问题
-
-当中间列被拉窄——无论是拖窗口还是拖侧边栏——hero 态的整条会话列下方就会出现一条横向滚动条。溢出的元素是 hero 的装饰性背景椭圆:`.heroGlow` 的宽度取 hero 盒子的 `1051/776`,好让它的模糊在 userSpace 中随输入卡片一同缩放;这也意味着只要列比它窄,它就会伸出列外。
-
-这处外溢是设计使然,保持不变。真正让它对用户可见的是它所处的滚动容器。`[data-conversation-scroll]` 只声明了 `overflow-y: auto`,另一个轴留在初始值 `visible`;而一个在某一轴上滚动的盒子,会把另一轴的 `visible` 计算为 `auto`。于是每一条比该椭圆窄的列都真的给出了一段横向滚动范围——在笔记本实际会产生的几档宽度上,实测为 24–95px。
-
-## 决策
-
-`.scrollBody` 声明 `overflow-x: hidden`。这条列明确声明自己是单轴滚动容器,而不是把第二个轴交给推导。
-
-裁剪行为不变。`overflow-y: auto` 早已使该盒子成为在两个轴上都裁剪的滚动容器,因此这条声明收回的只是滚动条和用户手势;椭圆保留它的外溢、模糊半径和同样的绘制范围,列也保留纵向滚动。输入区那条链路上没有任何东西移动。
-
-## 曾考虑的替代方案
-
-**把椭圆缩到列内。** 否决。椭圆的宽度正是让它 `stdDeviation="50"` 的模糊随输入卡片缩放的依据(figma 313:14109);约束宽度会使列越窄模糊越紧,等于为修一条滚动条而制造一处视觉回归。
-
-**给椭圆套一层裁剪盒。** 否决。这层盒子唯一的职责是抵消列本就会裁剪的溢出,而推导出的 `overflow-x: auto` 仍然留在原处,等着下一个外溢的元素——transcript(文本记录)里这样的候选者不少。
-
-**依赖外框的 `.centerCol { overflow: hidden }`。** 它帮不上忙。那处裁剪在滚动容器之外,只能在列边界处遮住椭圆探出的部分,而里面的容器照样可以滚过去够到它。用户报告的那条滚动条属于内层容器。
-
-**在测试里断言 `scrollWidth === clientWidth`。** 作为判据被否决,因为它区分不出两种状态:`hidden` 裁剪外溢,而不是把它重排掉,所以修复前后读到的滚动范围一样。唯一有差别的是拒绝用户手势,这正是该场景所测量的。
-
-## 测试
-
-[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) 扫过一组把椭圆宽度夹在中间的视口宽度,在每一档上于列上触发横向滚轮事件并读取 `scrollLeft`。提交的 golden 逐档记录该关系;最宽的一档是椭圆根本不外溢的对照。
-
-两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到正向滚动边界。测试直接测量该边界,因为稳定的滚动条槽可能让部分外溢处于滚动原点的负向。没有这项对照,`scrollLeft` 读到 0 同样可以解释为滚轮事件根本没送达。
-
-## 后果
-
-会话列在任何宽度下都不再给出横向滚动条,输入区链路上的装饰性外溢从暴露为滚动范围改为被裁剪。代价是这条列下真正过宽的内容会被裁掉而非可滚动够到:这类界面各自拥有自己的滚动容器,markdown 代码块和轨迹表格已经如此。

+ 3 - 3
.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml → .agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md
-2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d
-2026-08-04-conversation-column-one-axis-scroll.zh.md: b86f86f55757dff4fddab4c4e2ac64fa7c19fe59
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md
+2026-08-28-trigger-menu-stale-while-revalidate.md: 12541ba3ec6ee82ac6c12da85f99c0d8e044b9e8
+2026-08-28-trigger-menu-stale-while-revalidate.zh.md: 69b3d1b6a304562e1bb1835b1de2f09f8f38c5a4

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.md

@@ -0,0 +1,27 @@
+# Agent Note: The trigger menu keeps previous rows through refinement
+
+Status: implemented
+
+English | [中文](2026-08-28-trigger-menu-stale-while-revalidate.zh.md)
+
+## Problem
+
+Every keystroke inside an open `@`/`/` trigger menu launches a new candidates fetch. The menu reducer's `hit` case used to reseed the groups to pending-empty, so the list collapsed to a skeleton for the 100–460ms fetch round trip and repainted on every character — a visible flicker on each refinement keystroke (#3234).
+
+## Decision
+
+The reducer's `hit` case (`core/menu.ts`) now retains the previous query's rows and highlight, marking each group `pending` — stale-while-revalidate. Fresh opens (`seedGroups`) still start empty, so the first paint keeps its skeleton; `allReadyEmpty` still auto-closes after settle.
+
+Stale rows are display-only. `pick()` requires the candidate's group to be `ready`, and the `enter` arbitration checks the highlighted group's status before picking: during the pending window Enter is an explicit no-op (`'consumed'`) — it neither picks the stale row nor falls through to submit the draft. Tab already carried the same `ready` check for drilling.
+
+## Alternatives considered
+
+**Clear to a skeleton on every refinement.** Rejected; this was the flickering status quo. The production chat frontend's conversation search does clear (results and active index reset per debounced query), which keeps its Enter trivially safe — but its list is in a dedicated dialog, whereas this menu repaints directly under the caret on every keystroke, where the flicker is what users reported.
+
+**Pass Enter through to submit during the pending window.** Rejected. Before this change the window showed an empty skeleton, so Enter falling through to send was visually consistent; with retained rows the user is looking at a highlighted candidate, and sending the whole draft under it is a worse mis-fire than a few hundred milliseconds of dead key. The production search's pending-window Enter is likewise a no-op.
+
+**Queue the Enter and pick when the fetch settles.** Rejected. Acting on a keypress against rows the user has not seen yet reintroduces the stale-pick race with extra timing machinery.
+
+## Consequences
+
+Refinement keystrokes no longer flicker; the list content swaps in place when the fetch settles. The costs: Enter is dead for the pending window (pressing it again after settle picks normally), and rows are index-keyed, so a settle swaps DOM node content in place — pointer tests must wait for a stale-only row to disappear before clicking (`reference-composer.e2e.ts` polls `folderx/` away). A pre-existing highlight blink during refinement remains open and is deferred to a follow-up.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-08-28-trigger-menu-stale-while-revalidate.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: The trigger menu keeps previous rows through refinement
+
+Status: implemented
+
+[English](2026-08-28-trigger-menu-stale-while-revalidate.md) | 中文
+
+## Problem
+
+在已打开的 `@`/`/` 触发菜单里,每个按键都会发起一次新的候选请求。菜单 reducer 的 `hit` 分支过去会把各组重置为 pending-空,于是列表在 100–460ms 的请求往返期间塌缩成骨架屏,每输入一个字符就重绘一次——细化查询时肉眼可见的闪烁(#3234)。
+
+## Decision
+
+reducer 的 `hit` 分支(`core/menu.ts`)现在保留上一次查询的行和高亮,并把各组标记为 `pending`——即 stale-while-revalidate。首次打开(`seedGroups`)仍从空开始,首帧保持骨架屏;`allReadyEmpty` 仍在结算后自动关闭。
+
+旧行仅用于显示。`pick()` 要求候选所在组为 `ready`,`enter` 仲裁在 pick 前检查高亮组的状态:pending 窗口内 Enter 是显式 no-op(`'consumed'`)——既不选中旧行,也不落到草稿发送。Tab 的下钻早已带有相同的 `ready` 检查。
+
+## Alternatives considered
+
+**每次细化都清空为骨架屏。** 拒绝;这正是闪烁的现状。线上 chat 前端的会话搜索确实是清空(每次防抖查询重置结果和活动索引),其 Enter 因此天然安全——但那个列表在独立弹窗里,而本菜单直接在光标下随每个按键重绘,闪烁正是用户所报告的问题。
+
+**pending 窗口内让 Enter 透传到发送。** 拒绝。改动前该窗口显示空骨架屏,Enter 落到发送在视觉上是自洽的;保留旧行后用户正看着一个高亮候选,此时把整条草稿发出去比几百毫秒的按键失效是更糟的误触。线上搜索在 pending 窗口的 Enter 同样是 no-op。
+
+**把 Enter 排队,请求结算后再选中。** 拒绝。对用户尚未见到的行执行按键会重新引入选中旧数据的竞态,还额外增加时序机制。
+
+## Consequences
+
+细化按键不再闪烁;请求结算时列表内容原位替换。代价:pending 窗口内 Enter 失效(结算后再按即正常选中);行按 index 作为 key,结算时 DOM 节点内容原位替换——指针类测试点击前必须等待仅旧查询匹配的行消失(`reference-composer.e2e.ts` 轮询 `folderx/` 消失)。细化期间已存在的高亮闪动问题仍未解决,留待后续 PR。

+ 6 - 0
.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.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-12-hero-fish-hover-swim-morph.md
+2026-08-12-hero-fish-hover-swim-morph.md: c485f94e244040d7e72838065f977e698391c00d
+2026-08-12-hero-fish-hover-swim-morph.zh.md: 46fa50732bf98aa5e3afb503e84f8e23001499b2

+ 27 - 0
.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.md

@@ -0,0 +1,27 @@
+# Agent Note: Hero fish hover swim morph
+
+Status: implemented
+
+English | [中文](2026-08-12-hero-fish-hover-swim-morph.zh.md)
+
+## Problem
+
+Hovering the New Session hero fish (`EmptyHero.tsx` in `dsh-client-ui-conversation`) played a one-shot rigid CSS sway of the whole svg. The user wanted the whale to visibly swim — the tail wagging and the mouth curve lifting — which requires deforming the path geometry itself. CSS transforms cannot bend a subset of a path's curves, and the logo ships as one `FISH_LOGO_PATH` string in `dsh-client-ui-primitives`.
+
+## Decision
+
+Real curve deformation via SMIL `<animate attributeName="d">` cycling `rest → tail-up → rest → tail-down → rest` on the same 1.6s period as the CSS sway, which becomes continuous (`infinite`) for as long as the pointer stays. The two morph targets are generated programmatically (`/tmp`-run script, not checked in): parse `FISH_LOGO_PATH`'s absolute M/C/L/Z commands, rotate the tail region about a pivot with smoothstep falloff weights, bend the mouth/fin swoosh vertically with weight-squared falloff from its body anchor (a smile lift, not a rigid swing — rigid rotation read as detached), and emit structure-identical command strings SMIL can interpolate. The baked path constants live next to the component with the generation parameters documented. SMIL cannot ride CSS media queries, so a `hovering` state gated by `matchMedia('(prefers-reduced-motion: reduce)')` mounts the morph, while the CSS sway sits under `@media (hover: hover) and (prefers-reduced-motion: no-preference)`.
+
+The morphing fish reaches the hero as the fallback of the `conversation.hero.brand.mark` slot; no shipped package occupies it — `dsh-client-ui-brand-official` fills only the sidebar slots, since a feature plugin may not value-import `HeroFish` across packages ([client cross-package rule](../process/2026-08-23-client-cross-package-value-dependencies.md)) and the fallback already is the official mark. `FISH_LOGO_PATH` and `FISH_LOGO_VIEWBOX` are exported from `dsh-client-ui-primitives` for consumers that compose their own svg around the same geometry.
+
+## Alternatives considered
+
+**Vector-tool path editing for the morphs.** No interactive tool in the loop; programmatic weighted deformation was chosen because it guarantees the identical command structure SMIL `d` interpolation requires and makes amplitudes reviewable numbers.
+
+**Blowhole spout on hover.** Removed at the user's request; hover keeps only shape morph and sway.
+
+**Occupying the hero slot with the official mark.** The previous arrangement; rejected because the static occupant shadowed the animated fallback, and animating the occupant instead would need the forbidden cross-package value import.
+
+## Consequences
+
+The hover swim is decorative (`aria-hidden`) and reduced-motion-safe (static logo on hover). The sway CSS targets the stationary `.fishHitbox` wrapper, so a slot occupant would sway too; the body morph lives only in the fallback `HeroFish`. Coverage is the `skeleton.client.spec.tsx` suite asserting slot contract (name, owner props, fallback existence); the keyless snapshot harness records transcripts, not browser animation, so visual verification of the morph stays manual. Regenerating the morph targets requires re-running the (uncommitted) deformation script against `FISH_LOGO_PATH`; if the logo geometry ever changes, the baked constants must be regenerated with it.

+ 27 - 0
.agents/notes/implemented/feature/2026-08-12-hero-fish-hover-swim-morph.zh.md

@@ -0,0 +1,27 @@
+# Agent Note:主页鲸鱼 hover 游动变形
+
+Status: implemented
+
+[English](2026-08-12-hero-fish-hover-swim-morph.md) | 中文
+
+## 问题
+
+hover New Session 主页的鲸鱼(`dsh-client-ui-conversation` 的 `EmptyHero.tsx`)原本只播放一次整个 svg 的刚性 CSS 摇摆。用户希望鲸鱼有真实的游动感——尾巴摆动、嘴巴曲线上扬,这要求对路径几何本身做变形。CSS transform 无法弯曲路径中的部分曲线,且 logo 以单一 `FISH_LOGO_PATH` 字符串存放在 `dsh-client-ui-primitives`。
+
+## 决定
+
+通过 SMIL `<animate attributeName="d">` 做真实曲线变形,按与 CSS 摇摆相同的 1.6s 周期循环 `静止 → 尾上摆 → 静止 → 尾下压 → 静止`;CSS 摇摆改为持续循环(`infinite`),指针停留多久就游多久。两个变形目标由程序生成(在 `/tmp` 运行的脚本,未入库):解析 `FISH_LOGO_PATH` 的绝对 M/C/L/Z 命令,尾部区域绕支点做带 smoothstep 衰减权重的旋转,嘴巴/鳍的内侧曲线以距身体锚点的权重平方做竖直弯曲(微笑式上扬,而非刚性摆动——刚性旋转看起来与身体脱节),并输出结构完全一致、SMIL 可插值的命令串。烘焙出的路径常量与组件放在一起,并在注释中记录生成参数。SMIL 无法响应 CSS 媒体查询,因此用经 `matchMedia('(prefers-reduced-motion: reduce)')` 判定的 `hovering` 状态控制变形挂载,CSS 摇摆则在 `@media (hover: hover) and (prefers-reduced-motion: no-preference)` 之下。
+
+变形鲸鱼以 `conversation.hero.brand.mark` slot 的 fallback 身份进入主页;没有任何发布包占据该 slot——`dsh-client-ui-brand-official` 只填充侧栏槽位,因为 feature 插件不得跨包 value-import `HeroFish`([client 跨包规则](../process/2026-08-23-client-cross-package-value-dependencies.zh.md)),而 fallback 本身就是官方标志。`FISH_LOGO_PATH` 与 `FISH_LOGO_VIEWBOX` 从 `dsh-client-ui-primitives` 导出,供围绕同一几何自行组装 svg 的消费方使用。
+
+## 考虑过的替代方案
+
+**用矢量工具编辑路径做变形。** 流程中没有可交互的工具;选择程序化加权变形,因为它保证 SMIL `d` 插值所要求的完全一致的命令结构,且振幅是可评审的数字。
+
+**hover 气孔喷水。** 按用户要求移除;hover 只保留形状变形与摇摆。
+
+**让官方标志占据主页 slot。** 即先前的安排;否决,因为静态 occupant 会遮住动画 fallback,而给 occupant 加动画又需要被禁止的跨包 value import。
+
+## 影响
+
+hover 游动是纯装饰(`aria-hidden`)且对 reduced-motion 安全(hover 保持静态 logo)。摇摆 CSS 作用于外层静止的 `.fishHitbox`,因此换成 slot occupant 也会摇摆;身体变形只存在于 fallback 的 `HeroFish` 中。覆盖由 `skeleton.client.spec.tsx` 断言 slot 合约(名称、owner props、fallback 存在性);keyless 快照体系记录对话转录而非浏览器动画,变形的视觉验证仍需人工。重新生成变形目标需要对 `FISH_LOGO_PATH` 重跑(未入库的)变形脚本;若 logo 几何将来变化,烘焙常量必须随之重新生成。

+ 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 不出现在尾行。

+ 6 - 0
.agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.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/simplification/2026-08-28-remove-hero-input-glow.md
+2026-08-28-remove-hero-input-glow.md: 929fce91d38ed5436421e91666e91328389a1302
+2026-08-28-remove-hero-input-glow.zh.md: e8a32c8dc60feae344be587e4bcc4b8a4f8a2d87

+ 29 - 0
.agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.md

@@ -0,0 +1,29 @@
+# Agent Note: The hero input glow is removed
+
+Status: implemented
+
+English | [中文](2026-08-28-remove-hero-input-glow.zh.md)
+
+## Problem
+
+The New Session hero painted a decorative backdrop ellipse (`HeroGlow`, figma 313:14109) under the input card: a blurred blue gradient sized `1051/776` of the hero box so its `stdDeviation="50"` blur scaled with the card. On the shipped token sheets the ellipse read as stray blue tint rather than intentional chrome, and its by-construction bleed past the conversation column forced clipping scaffolding onto the column itself.
+
+That scaffolding existed because a box that scrolls in one axis computes the other axis's initial `visible` to `auto`: the glow's overhang gave `[data-conversation-scroll]` a real 24–95px horizontal scroll range on laptop widths, patched by declaring `overflow-x: hidden` on `.scrollBody` (2026-08-04). This note supersedes and consolidates that bug-fix note.
+
+## Decision
+
+`HeroGlow` is deleted with its positioning scaffolding: the component and its seat in `EmptyHero.tsx`, the glow z-index carve-outs in `ConversationRoot.module.css`, and the `.scrollBody { overflow-x: hidden }` clip, which had no owner other than the glow's bleed. The scroll body's horizontal axis returns to its derived value, and nothing under the column currently bleeds past it.
+
+The e2e scenario `conversation-column-overflow.e2e.ts` and its golden are deleted with the glow: the test's vacuity guard asserted the glow still bled past the column at narrow stops, so it cannot pass — by design — once nothing bleeds.
+
+## Alternatives considered
+
+**Keep the glow and retune its color.** Rejected. The tint was not a token mistake to correct; the product read is that the homepage input carries no backdrop chrome at all.
+
+**Keep `overflow-x: hidden` as a defensive clip.** Rejected. With the glow gone the declaration has no current owner, and the repo requires one; a silent clip would also hide the next accidental bleed instead of surfacing it in review.
+
+**Keep the overflow test against future bleed.** Rejected. Its vacuity guard requires a presently-bleeding element, so the scenario cannot express "nothing bleeds" without inverting into a different test; the composer geometry golden already pins the scroll body's `overflow` axes per tab.
+
+## Consequences
+
+The hero stack is plain chrome above the shared input card, and 67 lines of glow component, seat wiring, and clip scaffolding are gone. The cost is the standing guard: the conversation column is again a one-axis scroller only by construction, so a future decorative element that bleeds past the column will re-derive `overflow-x: auto` and surface a horizontal scrollbar. Whoever reintroduces bleed must restore an explicit one-axis clip on `.scrollBody` and a gesture-level regression test — asserting `scrollWidth === clientWidth` is not a substitute, because a clip hides the range without reflowing it away and only the refused wheel gesture distinguishes the states. The composer tab geometry golden records the current `overflow auto/auto` reading and will flag the derivation flipping back.

+ 29 - 0
.agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.zh.md

@@ -0,0 +1,29 @@
+# Agent Note: The hero input glow is removed
+
+Status: implemented
+
+[English](2026-08-28-remove-hero-input-glow.md) | 中文
+
+## Problem
+
+New Session 首页曾在输入卡片下方绘制一个装饰性背景椭圆(`HeroGlow`,figma 313:14109):一个模糊的蓝色渐变,尺寸为 hero 盒子的 `1051/776`,使其 `stdDeviation="50"` 模糊随卡片缩放。在实际交付的 token 表下,这个椭圆看起来是意外的蓝色沾染而非有意的装饰,并且它按构造就会溢出会话列,迫使列本身背上裁剪脚手架。
+
+脚手架的由来:单轴滚动的盒子会把另一轴初始的 `visible` 推导为 `auto`,glow 的溢出让 `[data-conversation-scroll]` 在笔记本宽度下出现 24–95px 的真实横向滚动范围,当时(2026-08-04)靠在 `.scrollBody` 上声明 `overflow-x: hidden` 修补。本 note 取代并合并了那个 bug-fix note。
+
+## Decision
+
+`HeroGlow` 连同其定位脚手架一并删除:组件本体及其在 `EmptyHero.tsx` 中的座位、`ConversationRoot.module.css` 中为 glow 开的 z-index 例外,以及 `.scrollBody { overflow-x: hidden }` 裁剪——后者除 glow 的溢出外没有任何持有者。滚动主体的横轴回到推导值,当前列下没有任何元素溢出。
+
+e2e 场景 `conversation-column-overflow.e2e.ts` 及其 golden 随 glow 一并删除:该测试的空洞防护(vacuity guard)断言 glow 在窄档位仍然溢出列,因此一旦没有任何东西溢出,它按设计就无法通过。
+
+## Alternatives considered
+
+**保留 glow 只调整颜色。** 拒绝。这不是一个待修正的 token 错误;产品判断是首页输入卡片根本不应携带背景装饰。
+
+**保留 `overflow-x: hidden` 作为防御性裁剪。** 拒绝。glow 删除后该声明没有当前持有者,而仓库要求每项内容都有;静默裁剪还会把下一次意外溢出藏起来,而不是在评审中暴露它。
+
+**保留 overflow 测试防范未来溢出。** 拒绝。它的空洞防护要求当下存在一个正在溢出的元素,场景无法在不改写成另一个测试的前提下表达"没有东西溢出";composer 几何 golden 已经按 tab 钉住了滚动主体的 `overflow` 两轴取值。
+
+## Consequences
+
+hero 栈成为共享输入卡片上方的朴素装饰,glow 组件、座位接线和裁剪脚手架共 67 行被删除。代价是失去常驻防线:会话列重新只靠构造保持单轴滚动,未来任何溢出列的装饰元素都会重新推导出 `overflow-x: auto` 并出现横向滚动条。重新引入溢出者必须在 `.scrollBody` 上恢复显式单轴裁剪并补上手势级回归测试——断言 `scrollWidth === clientWidth` 不能替代,因为裁剪只是隐藏范围而非将其回流消除,只有被拒绝的滚轮手势能区分两种状态。composer tab 几何 golden 记录了当前 `overflow auto/auto` 的读数,推导翻转回去时会报警。

+ 0 - 344
apps/web/tests/conversation-column-overflow.e2e.ts

@@ -1,344 +0,0 @@
-// Web e2e scenario: the conversation column scrolls on one axis only, as the
-// browser actually lays it out. The hazard: a horizontal scrollbar appears
-// under the whole center column once the window (or the sidebar drag) narrows
-// it — the hero's decorative backdrop ellipse bleeds past the column and
-// becomes user-scrollable.
-//
-// The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the
-// hero box (ConversationRoot.module.css) so the blur scales with the input
-// card. The scroll container is where the bar comes from:
-// `[data-conversation-scroll]` scrolls vertically, and a one-axis scroller
-// computes the other axis's initial `visible` to `auto`, so the bleed becomes
-// a bar; `overflow-x: hidden` on the scroller prevents it.
-//
-// Only a real engine reports that pair — the bleed and the resulting scroll
-// range — so the scenario sweeps viewport widths that bracket the glow's
-// width and asserts both at each stop. Asserting no horizontal scroll alone
-// would go vacuous the moment the glow stopped bleeding for an unrelated
-// reason, which is why each stop also records whether it bleeds; the wide stop
-// is the control where it does not.
-//
-// Zero model calls: the hero is the boot state, so nothing is seeded and no
-// replay row mounts. A stray stream would fail loud with NO_ADAPTER.
-import { fileURLToPath } from 'node:url'
-import { join } from 'node:path'
-import type { Browser, Page } from 'playwright'
-import { chromium } from 'playwright'
-import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
-import {
-  assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
-  type WebScaffold,
-} from './scaffold.ts'
-import { newEnglishPage, saveFailureShot } from './support.ts'
-
-const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/conversation-column-overflow', import.meta.url))
-/**
- * Committed golden of the one-axis relation at every stop. It records
- * relations and booleans, never absolute coordinates: the column width follows
- * the viewport and the sidebar, and a golden carrying pixels would document the
- * platform instead of the behavior.
- */
-const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
-const MODE = webSnapshotMode()
-/** Narrow sweep stop where the mutation control retains overflow across scrollbar implementations. */
-const CONTROL_VIEWPORT = 600
-/**
- * Viewport widths bracketing the glow: the narrow stops retain the reported
- * bleed while the widest stop proves the relation can also be false.
- */
-const WIDTHS = [1680, 1200, 1000, 800, CONTROL_VIEWPORT]
-/** Element id of the mutation control's injected sheet, so the test can take it back out. */
-const CONTROL_STYLE_ID = 'dsh-column-overflow-control'
-/** Horizontal wheel delta per gesture; must exceed the widest bleed the sweep can produce. */
-const WHEEL_DELTA = 300
-
-/** One viewport stop: whether the glow bleeds past the column, and whether that bleed scrolls. */
-interface ColumnMetrics {
-  /** Viewport width the stop was measured at. */
-  width: number
-  /** The column's content width. Not committed to the golden — it is what settles after a resize, and what the sweep waits on. */
-  columnWidth: number
-  /** Resolved `overflow-x` on the conversation scroll container. */
-  overflowX: string
-  /**
-   * True when the glow's box reaches past the column's content edge — the
-   * condition the `overflow-x: hidden` declaration has to survive.
-   */
-  glowBleeds: boolean
-  /**
-   * `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and
-   * `auto` both report the same value, because `hidden` clips the bleed rather
-   * than reflowing it away. Recorded because it is the vacuity guard in
-   * numbers — it must stay positive at the narrow stops, or the scenario has
-   * stopped reproducing the situation `overflow-x: hidden` exists for.
-   */
-  bleedRange: number
-  /** True when the column still scrolls vertically — the axis `overflow-x: hidden` must not take away. */
-  scrollsVertically: boolean
-}
-
-/**
- * Measure the conversation column at the page's current viewport.
- * @param page - the page under test.
- * @param width - the viewport width already applied, recorded with the reading.
- * @returns the stop's overflow relations.
- */
-function measureColumn(page: Page, width: number): Promise<ColumnMetrics> {
-  return page.evaluate((viewportWidth) => {
-    const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]')
-    if (scroller === null) throw new Error('conversation scroll container not in the DOM')
-    const glow = scroller.querySelector<SVGElement>('[class*="heroGlow"]')
-    if (glow === null) throw new Error('hero glow not in the DOM — the boot state is not the hero')
-    const box = scroller.getBoundingClientRect()
-    const glowBox = glow.getBoundingClientRect()
-    return {
-      width: viewportWidth,
-      columnWidth: scroller.clientWidth,
-      overflowX: getComputedStyle(scroller).overflowX,
-      // `clientWidth` is the content edge, which is what the scrollable
-      // overflow region is measured against; either side counts as a bleed,
-      // though only the right one can produce a bar in this writing mode.
-      glowBleeds: glowBox.right > box.left + scroller.clientWidth + 0.5 || glowBox.left < box.left - 0.5,
-      bleedRange: scroller.scrollWidth - scroller.clientWidth,
-      scrollsVertically: getComputedStyle(scroller).overflowY === 'auto',
-    }
-  }, width)
-}
-
-/**
- * Scroll the column sideways the way a user would and report where it landed.
- *
- * This is the one signal that separates the two states, and it is why the
- * scenario needs a real engine: `overflow-x: hidden` leaves the box
- * programmatically scrollable and leaves `scrollWidth` untouched, so every
- * property reading agrees across the two overflow modes. Only refusing an
- * actual input event differs — measured at the 1200px stop, the shipped
- * column stays at 0 while the same page with `overflow-x: auto` forced on
- * lands at its scroll boundary.
- * @param page - the page under test.
- * @returns `scrollLeft` after one horizontal wheel over the column.
- */
-async function wheelHorizontally(page: Page): Promise<number> {
-  const origin = await page.evaluate(() => {
-    const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]')
-    if (scroller === null) throw new Error('conversation scroll container not in the DOM')
-    // Start from the origin so the reading is this gesture's own effect.
-    scroller.scrollLeft = 0
-    const box = scroller.getBoundingClientRect()
-    // Near the top of the column, clear of the centered hero card: the wheel
-    // must reach the column, not a nested scroller the composer owns.
-    return { x: box.left + box.width / 2, y: box.top + 60 }
-  })
-  await page.mouse.move(origin.x, origin.y)
-  await page.mouse.wheel(WHEEL_DELTA, 0)
-  // A fixed settle, then two frames. Polling for a settled value cannot be
-  // used here — the value under test is 0, which a poll starting at 0 accepts
-  // before the gesture has had any chance to move it — so the wait is
-  // generous enough to cover a smooth-scroll animation on any engine the lane
-  // runs on. The timing is identical on both sides of the mutation control
-  // below, which is what makes a 0 reading evidence rather than a race won.
-  await page.waitForTimeout(400)
-  return page.evaluate(() => new Promise<number>((resolve) => {
-    requestAnimationFrame(() => {
-      requestAnimationFrame(() => {
-        resolve(document.querySelector<HTMLElement>('[data-conversation-scroll]')?.scrollLeft ?? -1)
-      })
-    })
-  }))
-}
-
-/**
- * Measure the positive horizontal scroll boundary without changing the
- * shipped overflow mode. This is distinct from `scrollWidth - clientWidth`
- * when a stable scrollbar gutter leaves part of the overflow on the negative
- * side of the scroll origin.
- * @param page - the page under test.
- * @returns the greatest positive `scrollLeft` reachable by the control gesture.
- */
-async function horizontalScrollLimit(page: Page): Promise<number> {
-  return page.evaluate((delta) => {
-    const scroller = document.querySelector<HTMLElement>('[data-conversation-scroll]')
-    if (scroller === null) throw new Error('conversation scroll container not in the DOM')
-    const previousScrollBehavior = scroller.style.scrollBehavior
-    scroller.style.scrollBehavior = 'auto'
-    scroller.scrollLeft = delta
-    const limit = scroller.scrollLeft
-    scroller.scrollLeft = 0
-    scroller.style.scrollBehavior = previousScrollBehavior
-    return limit
-  }, WHEEL_DELTA)
-}
-
-/** A stop's readings plus where a horizontal wheel over it landed. */
-type ColumnStop = ColumnMetrics & {
-  /** `scrollLeft` after one horizontal wheel: the user-facing claim, 0 at every stop. */
-  scrollLeftAfterWheel: number
-}
-
-/**
- * Render the golden body: one line per stop, relations only.
- *
- * Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`,
- * which the shipped overflow mode pins to 0 by construction. The bleed is
- * recorded as a boolean rather than its width, so the golden survives any
- * platform whose column lands a pixel off — a fixture that has to be
- * re-recorded per platform documents the platform, not the behavior.
- * @param stops - the measured stops, in sweep order.
- * @returns the golden body, without a trailing newline.
- */
-function renderGeometry(stops: ColumnStop[]): string {
-  return [
-    '# Conversation column horizontal overflow',
-    '',
-    '| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically |',
-    '| --- | --- | --- | --- | --- |',
-    ...stops.map(stop => `| ${String(stop.width)}px | ${stop.overflowX} | ${String(stop.glowBleeds)} `
-      + `| ${String(stop.scrollLeftAfterWheel)}px | ${String(stop.scrollsVertically)} |`),
-  ].join('\n')
-}
-
-describe('web e2e: the conversation column scrolls on one axis', () => {
-  let scaffold: WebScaffold
-  let browser: Browser
-  let page: Page
-  let tripwire: ReturnType<typeof watchConsole>
-
-  beforeAll(async () => {
-    scaffold = await launchWebScaffold({})
-    browser = await chromium.launch()
-    page = await newEnglishPage(browser, 900)
-    tripwire = watchConsole(page)
-    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
-    await page.waitForSelector('[data-conversation-scroll] [class*="heroGlow"]', { timeout: 30_000 })
-  }, 180_000)
-
-  afterAll(async () => {
-    await browser?.close()
-    await scaffold?.close()
-  })
-
-  /**
-   * Resize to a viewport and read the column once its width stops moving.
-   *
-   * The glow rides the hero box, which rides the column, and the frame eases
-   * its column tracks over `--ds-transition-duration-slow`: reading straight
-   * after a resize can report the previous viewport's relation, or a width
-   * caught mid-transition.
-   * @param width - viewport width to settle at.
-   * @returns the column's readings at that width.
-   */
-  const settleAt = async (width: number): Promise<ColumnMetrics> => {
-    await page.setViewportSize({ width, height: 900 })
-    let previous = -1
-    await expect.poll(async () => {
-      const current = (await measureColumn(page, width)).columnWidth
-      const settled = current === previous
-      previous = current
-      return settled
-    }, { timeout: 10_000 }).toBe(true)
-    return measureColumn(page, width)
-  }
-
-  /**
-   * Sweep the stops once per run and hand the SAME readings to every assertion
-   * below, so the golden and the assertions describe one measurement instead of
-   * two runs that could disagree. Memoized rather than re-run per test: the
-   * gestures below move the viewport, and a second sweep would be a second
-   * chance for a resize to settle differently.
-   * @returns the stops in {@link WIDTHS} order.
-   */
-  let swept: Promise<ColumnStop[]> | undefined
-  const sweep = (): Promise<ColumnStop[]> => {
-    swept ??= (async () => {
-      const stops: ColumnStop[] = []
-      for (const width of WIDTHS) {
-        stops.push({ ...await settleAt(width), scrollLeftAfterWheel: await wheelHorizontally(page) })
-      }
-      return stops
-    })()
-    return swept
-  }
-
-  it('never scrolls horizontally, at any width the glow bleeds past', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow'))
-    const stops = await sweep()
-    // The vacuity guard, in two halves: the glow has to reach past the column
-    // at the narrow stops, and that reach has to still register as scrollable
-    // overflow. Without both, the claim below holds for free.
-    expect(stops.filter(stop => stop.glowBleeds).map(stop => stop.width)).toEqual([
-      1200, 1000, 800, CONTROL_VIEWPORT,
-    ])
-    for (const stop of stops.filter(stop => stop.glowBleeds)) {
-      expect(stop.bleedRange, `viewport ${String(stop.width)}`).toBeGreaterThan(0)
-    }
-    for (const stop of stops) {
-      expect(stop.overflowX, `viewport ${String(stop.width)}`).toBe('hidden')
-      // The reported symptom, stated directly: a horizontal wheel over the
-      // column moves nothing, at every stop.
-      expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0)
-      // The axis the column is a scroller for must survive `overflow-x: hidden`.
-      expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true)
-    }
-    expect(tripwire.pageErrors).toEqual([])
-  }, 120_000)
-
-  it('scrolls horizontally again once the axis is opened back up (control)', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control'))
-    // The mutation control, run in the page rather than against a second
-    // build: it lifts exactly the `overflow-x: hidden` declaration, so the
-    // initial `visible` that a one-axis scroller computes to `auto` takes
-    // over, and shows the same gesture, at the same timing, carrying the
-    // column to its positive scroll boundary.
-    // Without it a `scrollLeft` of 0 could equally mean the wheel never arrived.
-    // Injected with an id rather than through `addStyleTag`, so the teardown
-    // below can take the sheet out again by selector: it must not outlive this
-    // test, or the golden ends up reading the control.
-    await page.evaluate((id: string) => {
-      const sheet = document.createElement('style')
-      sheet.id = id
-      sheet.textContent = '[data-conversation-scroll] { overflow-x: auto !important; }'
-      document.head.append(sheet)
-    }, CONTROL_STYLE_ID)
-    try {
-      // Resolve the mutated layout at the narrowest sweep stop. At wider stops,
-      // a classic scrollbar can change the available box enough to remove the
-      // overflow that the control is meant to expose.
-      const before = await settleAt(CONTROL_VIEWPORT)
-      expect(before.overflowX).toBe('auto')
-      expect(before.bleedRange).toBeGreaterThan(0)
-      const scrollLimit = await horizontalScrollLimit(page)
-      // The control has a reachable horizontal range, and the gesture exceeds
-      // it so the equality below proves that the wheel reached the far edge.
-      expect(scrollLimit).toBeGreaterThan(0)
-      expect(scrollLimit).toBeLessThan(WHEEL_DELTA)
-      // Rounded: `scrollLeft` is fractional under a fractional layout while
-      // the claim is that the column reached the positive boundary, not that
-      // two engines agree on a sub-pixel.
-      expect(Math.round(await wheelHorizontally(page))).toBe(Math.round(scrollLimit))
-    } finally {
-      await page.evaluate((id: string) => {
-        document.getElementById(id)?.remove()
-      }, CONTROL_STYLE_ID)
-    }
-    // The override is gone and the shipped state is back: the later goldens
-    // read the product, not the control.
-    expect((await settleAt(CONTROL_VIEWPORT)).overflowX).toBe('hidden')
-    expect(tripwire.pageErrors).toEqual([])
-  }, 120_000)
-
-  it('matches the committed column-overflow golden', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-golden'))
-    await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE)
-    expect(tripwire.pageErrors).toEqual([])
-  }, 120_000)
-
-  it('commits exactly the fixtures it reads', async () => {
-    // No model calls, so no replay log: the golden is the whole inventory.
-    await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
-  })
-
-  it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
-    expect(tripwire.warnings).toEqual([])
-    expect(tripwire.pageErrors).toEqual([])
-  })
-})

+ 3 - 3
apps/web/tests/expected/composer-tab-geometry/geometry.expected.md

@@ -2,7 +2,7 @@
 
 ## Wide viewport (1680px, card at its cap)
 
-- Chat: scrollbar-gutter stable, overflow hidden/auto
+- Chat: scrollbar-gutter stable, overflow auto/auto
 - Chat scroller scrolls: true
 - Chat reserved band: 8px
 - Trajectory: scrollbar-gutter auto, overflow hidden/auto
@@ -14,7 +14,7 @@
 
 ## Narrow viewport (800px, card shrinking with the column)
 
-- Chat: scrollbar-gutter stable, overflow hidden/auto
+- Chat: scrollbar-gutter stable, overflow auto/auto
 - Chat scroller scrolls: true
 - Chat reserved band: 8px
 - Trajectory: scrollbar-gutter auto, overflow hidden/auto
@@ -26,7 +26,7 @@
 
 ## Wide viewport, seat compensation removed in the page (control)
 
-- Chat: scrollbar-gutter stable, overflow hidden/auto
+- Chat: scrollbar-gutter stable, overflow auto/auto
 - Chat scroller scrolls: true
 - Chat reserved band: 8px
 - Trajectory: scrollbar-gutter auto, overflow hidden/auto

+ 0 - 9
apps/web/tests/expected/conversation-column-overflow/geometry.expected.md

@@ -1,9 +0,0 @@
-# Conversation column horizontal overflow
-
-| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically |
-| --- | --- | --- | --- | --- |
-| 1680px | hidden | false | 0px | true |
-| 1200px | hidden | true | 0px | true |
-| 1000px | hidden | true | 0px | true |
-| 800px | hidden | true | 0px | true |
-| 600px | hidden | true | 0px | true |

+ 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

+ 6 - 0
apps/web/tests/reference-composer.e2e.ts

@@ -166,6 +166,12 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     expect(snapshot).not.toContain('text: Subagents')
 
     await input.fill('@reference')
+    // The open menu keeps the previous query's rows while the new one loads
+    // (stale-while-revalidate), and rows are keyed by index, so a click
+    // resolved against a stale row lands on whatever settles into that slot.
+    // `folderx/` matches only the bare '@' query: its disappearance marks the
+    // settled result set.
+    await expect.poll(() => menu.getByRole('option', { name: /folderx/ }).count(), { timeout: 15_000 }).toBe(0)
     await menu.getByRole('option', { name: /reference\.txt/ }).click()
     // The pick lands an atomic chip: a real DOM capsule carrying the domain
     // icon and the label (the canonical reference text lives on the node and

+ 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)

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: f9cafb86b22012c2dc1198ff810ededd988911f9
-module-graph.zh.md: ec4ac506cfef4d8ceaf3c5f83f07dad1fee66c50
+module-graph.md: 90c0f2061d27fa6410dd867edb94089440fab006
+module-graph.zh.md: fb3d7c7f6a22b9ff2f6b41ed9745c050aa483759

+ 1 - 2
docs/module-graph.md

@@ -1462,7 +1462,6 @@ flowchart TD
   pkg_client_ui_approval --> pkg_llm
   pkg_client_ui_approval --> pkg_session
   pkg_client_ui_approval --> pkg_typert_protocol
-  pkg_client_ui_brand_official --> pkg_client_ui_conversation
   pkg_client_ui_brand_official --> pkg_client_ui_renderer
   pkg_client_ui_brand_official --> pkg_client_ui_sidebar
   pkg_client_ui_brand_official --> pkg_invariants
@@ -1955,7 +1954,7 @@ flowchart TD
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
-| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |

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

@@ -1464,7 +1464,6 @@ flowchart TD
   pkg_client_ui_approval --> pkg_llm
   pkg_client_ui_approval --> pkg_session
   pkg_client_ui_approval --> pkg_typert_protocol
-  pkg_client_ui_brand_official --> pkg_client_ui_conversation
   pkg_client_ui_brand_official --> pkg_client_ui_renderer
   pkg_client_ui_brand_official --> pkg_client_ui_sidebar
   pkg_client_ui_brand_official --> pkg_invariants
@@ -1957,7 +1956,7 @@ flowchart TD
 | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) |
 | [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | [`agent-presets`](../packages/preset/agent-presets), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`client-ui-approval`](../packages/client/ui-approval) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
-| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
+| [`client-ui-brand-official`](../packages/client/ui-brand-official) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/runtime-diagnostics/invariants) |
 | [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |

+ 2 - 2
packages/client/ui-brand-official/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-brand-official/README.md
-README.md: 03712e826fb415dd6fb3125c06865a2402342948
-README.zh.md: 94e8a30bb11abd468a59cc80b6745d5d929a1e23
+README.md: 6b613db7c302e77f4bbcd93468e712beb5cf2a61
+README.zh.md: a01f659dcd76b98b94607c764988b5295e761b76

+ 5 - 5
packages/client/ui-brand-official/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Official DeepSeek Harness brand occupants for the sidebar and conversation hero, active only in official builds; for users and maintainers choosing or replacing brand presentation."
+description: "Official DeepSeek Harness brand occupants for the sidebar, active only in official builds; for users and maintainers choosing or replacing brand presentation."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This package fills the browser brand slots — `sidebar.brand.mark`, `sidebar.brand.name`, and `conversation.hero.brand.mark` — with the official DeepSeek Harness mark and name. It registers these occupants only when the client bundle builds with the `official` profile; every other build loads the plugin but registers nothing, so the shell fallbacks stay visible. Choose it when the deployed identity is DeepSeek's own; a deployment with its own brand composes a different package into the same slots instead. It retains no runtime state and contributes nothing to model requests.
+This package fills the sidebar brand slots — `sidebar.brand.mark` and `sidebar.brand.name` — with the official DeepSeek Harness mark and name. It registers these occupants only when the client bundle builds with the `official` profile; every other build loads the plugin but registers nothing, so the shell fallbacks stay visible. The conversation hero slot (`conversation.hero.brand.mark`) stays unoccupied in every build: its declaring package renders the animated hero fish (hover swim morph) as the fallback, and the official brand is that fish. Choose this package when the deployed identity is DeepSeek's own; a deployment with its own brand composes a different package into the same slots instead. It retains no runtime state and contributes nothing to model requests.
 
 ## Table of Contents
 
@@ -29,11 +29,11 @@ Mount this plugin in the browser roster of a deployment whose identity is DeepSe
 
 ### Choosing the profile
 
-`DSH_CLIENT_BUILD_PROFILE` selects which brand renders. An `official` build shows the official mark and name in the sidebar and the mark in the conversation hero; any other value leaves the shell fallbacks — the fish mark and the local-build label — in place. The plugin still loads and validates in both cases; only the registration is profile-gated.
+`DSH_CLIENT_BUILD_PROFILE` selects which brand renders. An `official` build shows the official mark and name in the sidebar; any other value leaves the shell fallbacks — the fish mark and the local-build label — in place. The conversation hero shows the animated hero fish from `dsh-client-ui-conversation` regardless of profile, because that fallback is already the official mark. The plugin still loads and validates in both cases; only the registration is profile-gated.
 
 ### Replacing the brand
 
-A deployment with its own identity leaves this package out and composes another package that occupies the same three slots. Occupying a slot is the only composition route; there is no brand configuration surface here.
+A deployment with its own identity leaves this package out and composes another package that occupies the sidebar slots — and the hero slot, which this package leaves on its fallback. Occupying a slot is the only composition route; there is no brand configuration surface here.
 
 -----
 
@@ -43,7 +43,7 @@ A deployment with its own identity leaves this package out and composes another
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The three occupants install as one declaration-aware registration set: nested `ctx.slots.inject()` calls wait on the sidebar and conversation declarations, so the set works whether this row activates before or after the declarers, withdraws all three occupants when either declaration collapses, and leaves no partial brand mix during HMR. The browser half is [`src/client/index.ts`](src/client/index.ts); the node half is an empty Loader seat. The browser title is a build-environment concern (`DSH_CLIENT_TITLE`), outside the slot system.
+The two occupants install as one declaration-aware registration set: nested `ctx.slots.inject()` calls wait on the sidebar declaration, so the set works whether this row activates before or after the declarer, withdraws both occupants when the declaration collapses, and leaves no partial brand mix during HMR. The browser half is [`src/client/index.ts`](src/client/index.ts); the node half is an empty Loader seat. The browser title is a build-environment concern (`DSH_CLIENT_TITLE`), outside the slot system.
 
 </details>
 

+ 5 - 5
packages/client/ui-brand-official/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "面向侧栏与会话首屏的官方 DeepSeek Harness 品牌填充,仅在官方构建中生效;供选择或替换品牌呈现的用户与维护者阅读。"
+description: "面向侧栏的官方 DeepSeek Harness 品牌填充,仅在官方构建中生效;供选择或替换品牌呈现的用户与维护者阅读。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-本包向浏览器品牌槽位——`sidebar.brand.mark`、`sidebar.brand.name` 与 `conversation.hero.brand.mark`——填充官方 DeepSeek Harness 标志与名称。它只在客户端以 `official` profile 构建时注册这些填充;其余构建同样加载插件但不注册任何内容,因此外壳回退保持可见。当部署身份就是 DeepSeek 自身时选择它;自有品牌的部署改为在相同槽位中组合另一个包。它不保留任何运行时状态,也不向模型请求贡献任何内容。
+本包向侧栏品牌槽位——`sidebar.brand.mark` 与 `sidebar.brand.name`——填充官方 DeepSeek Harness 标志与名称。它只在客户端以 `official` profile 构建时注册这些填充;其余构建同样加载插件但不注册任何内容,因此外壳回退保持可见。会话首屏槽位(`conversation.hero.brand.mark`)在所有构建中都保持无填充:其声明包以动画首屏鱼(悬停游动形变)作为回退渲染,而官方品牌正是这条鱼。当部署身份就是 DeepSeek 自身时选择本包;自有品牌的部署改为在相同槽位中组合另一个包。它不保留任何运行时状态,也不向模型请求贡献任何内容。
 
 ## 目录
 
@@ -29,11 +29,11 @@ kind: "package-reference"
 
 ### 选择 profile
 
-`DSH_CLIENT_BUILD_PROFILE` 决定渲染哪个品牌。`official` 构建在侧栏显示官方标志与名称、在会话首屏显示标志;任何其他取值都让外壳回退——鱼形标志与本地构建标签——保持原样。两种情况下插件都会照常加载并通过校验;只有注册受 profile 门控。
+`DSH_CLIENT_BUILD_PROFILE` 决定渲染哪个品牌。`official` 构建在侧栏显示官方标志与名称;任何其他取值都让外壳回退——鱼形标志与本地构建标签——保持原样。会话首屏无论 profile 如何都显示来自 `dsh-client-ui-conversation` 的动画首屏鱼,因为这个回退本身就是官方标志。两种情况下插件都会照常加载并通过校验;只有注册受 profile 门控。
 
 ### 替换品牌
 
-自有身份的部署不组合本包,而是组合另一个占据相同三个槽位的包。占据槽位是唯一的组合路径;这里不存在任何品牌配置面。
+自有身份的部署不组合本包,而是组合另一个占据侧栏槽位——以及本包留给回退的首屏槽位——的包。占据槽位是唯一的组合路径;这里不存在任何品牌配置面。
 
 -----
 
@@ -43,7 +43,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-三个填充作为一组声明感知的注册安装:嵌套的 `ctx.slots.inject()` 调用等待侧栏与会话声明,因此无论本行在声明者之前还是之后激活,这组注册都能工作;任一声明消失时全部三个填充一并撤回,HMR 期间也不会留下残缺的品牌混合。浏览器半部是 [`src/client/index.ts`](src/client/index.ts);node 半部是一个空 Loader 座位。浏览器标题是构建环境的事(`DSH_CLIENT_TITLE`),不在槽位系统之内。
+两个填充作为一组声明感知的注册安装:嵌套的 `ctx.slots.inject()` 调用等待侧栏声明,因此无论本行在声明者之前还是之后激活,这组注册都能工作;声明消失时两个填充一并撤回,HMR 期间也不会留下残缺的品牌混合。浏览器半部是 [`src/client/index.ts`](src/client/index.ts);node 半部是一个空 Loader 座位。浏览器标题是构建环境的事(`DSH_CLIENT_TITLE`),不在槽位系统之内。
 
 </details>
 

+ 1 - 4
packages/client/ui-brand-official/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-brand-official",
-  "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar and conversation Hero slots",
+  "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots",
   "version": "0.1.2-alpha.1",
   "publishConfig": {
     "access": "public"
@@ -32,7 +32,6 @@
   "dsh": {
     "client": {
       "inject": [
-        "@deepseek-ai/dsh-client-ui-conversation",
         "@deepseek-ai/dsh-client-ui-renderer",
         "@deepseek-ai/dsh-client-ui-sidebar"
       ],
@@ -45,14 +44,12 @@
   },
   "license": "MIT",
   "peerDependencies": {
-    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
-    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",

+ 2 - 5
packages/client/ui-brand-official/src/client/Brand.tsx

@@ -1,16 +1,13 @@
 import { BrandWordmark, FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { HeroBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SidebarBrandMarkOwnerProps } from '@deepseek-ai/dsh-client-ui-sidebar/client'
 
-type OfficialBrandMarkProps = HeroBrandMarkOwnerProps & SidebarBrandMarkOwnerProps
-
 /**
  * Render the official mark with the presentation requested by its host surface.
  * @param props - Host-supplied mark presentation.
  * @returns the official whale mark.
  */
-export function OfficialBrandMark({ size, className }: OfficialBrandMarkProps) {
-  return <FishLogo size={size} className={className} />
+export function OfficialBrandMark({ size }: SidebarBrandMarkOwnerProps) {
+  return <FishLogo size={size} />
 }
 
 /**

+ 7 - 8
packages/client/ui-brand-official/src/client/index.ts

@@ -1,6 +1,5 @@
 /** Official DeepSeek Harness occupants for the generic browser-brand slots. */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
-import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
 import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx'
@@ -9,16 +8,16 @@ import { OfficialBrandMark, OfficialBrandName } from './Brand.tsx'
 export const inject = ['slots']
 
 /**
- * Fill every shipped brand slot as one declaration-aware registration set.
+ * Fill the sidebar brand slots as one declaration-aware registration set. The
+ * conversation hero stays on its declaring package's animated fish fallback,
+ * so the official build registers nothing there.
  * @param ctx - Client root context.
  */
 export function apply(ctx: ClientContext): void {
   if (process.env.DSH_CLIENT_BUILD_PROFILE !== 'official') return
   ctx.slots.inject('sidebar.brand.mark', () =>
-    ctx.slots.inject('sidebar.brand.name', () =>
-      ctx.slots.inject('conversation.hero.brand.mark', function* () {
-        yield ctx.slots.register({ name: 'sidebar.brand.mark' }, OfficialBrandMark)
-        yield ctx.slots.register({ name: 'sidebar.brand.name' }, OfficialBrandName)
-        yield ctx.slots.register({ name: 'conversation.hero.brand.mark' }, OfficialBrandMark)
-      })))
+    ctx.slots.inject('sidebar.brand.name', function* () {
+      yield ctx.slots.register({ name: 'sidebar.brand.mark' }, OfficialBrandMark)
+      yield ctx.slots.register({ name: 'sidebar.brand.name' }, OfficialBrandName)
+    }))
 }

+ 11 - 4
packages/client/ui-brand-official/tests/browser-plugin.client.spec.tsx

@@ -14,16 +14,17 @@ afterEach(() => {
 const HOLES = [
   'sidebar.brand.mark',
   'sidebar.brand.name',
-  'conversation.hero.brand.mark',
 ] as const
 
+const HERO_HOLE = 'conversation.hero.brand.mark'
+
 async function bench(declare = true) {
   const ctx = new Context()
   await ctx.plugin(SlotRegistry).await()
   const slots = ctx.get('slots') as SlotRegistry
   const declareHoles = () => slots.register({
     name: 'root',
-    children: Object.fromEntries(HOLES.map(name => [name, { kind: 'single', scope: 'root' }])),
+    children: Object.fromEntries([...HOLES, HERO_HOLE].map(name => [name, { kind: 'single', scope: 'root' }])),
   } as never, () => null)
   const disposeHoles = declare ? declareHoles() : undefined
   return { ctx, slots, declareHoles, disposeHoles }
@@ -65,14 +66,20 @@ describe('official browser-brand plugin', () => {
     for (const hole of HOLES) expect(after.slots.entries(hole)).toHaveLength(1)
   })
 
+  it('leaves the conversation hero on its declaring fallback even in official builds', async () => {
+    vi.stubEnv('DSH_CLIENT_BUILD_PROFILE', 'official')
+    const subject = await bench()
+    await subject.ctx.plugin({ inject: [...inject], apply }).await()
+    expect(subject.slots.entries(HERO_HOLE)).toHaveLength(0)
+  })
+
   it('renders the official name independently from both requested mark sizes', () => {
     const name = render(<OfficialBrandName />)
     expect(name.container.querySelector('svg')?.getAttribute('viewBox')).toBe('26 0 156 24')
     name.unmount()
 
-    const mark = render(<OfficialBrandMark size={34} className="hero-mark" />)
+    const mark = render(<OfficialBrandMark size={34} />)
     expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('34')
-    expect(mark.container.querySelector('svg')?.getAttribute('class')).toBe('hero-mark')
     mark.rerender(<OfficialBrandMark size={24} />)
     expect(mark.container.querySelector('svg')?.getAttribute('width')).toBe('24')
   })

+ 0 - 3
packages/client/ui-brand-official/tsconfig.json

@@ -14,9 +14,6 @@
     {
       "path": "../ui-renderer"
     },
-    {
-      "path": "../ui-conversation"
-    },
     {
       "path": "../ui-primitives"
     },

+ 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:^"

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

@@ -175,7 +175,9 @@
   position: sticky;
   bottom: 16px;
   /* Above the sticky composer (z-index 7) so the control stays clickable and
-     visible over the input card. */
+     visible over the input card. Exception: while an @/slash menu is open the
+     seat lifts to 9 (ui-conversation ConversationRoot) so the menu is never
+     covered by this control. */
   z-index: 8;
   height: 0;
   display: flex;

+ 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')
+  })
+})

+ 25 - 31
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css

@@ -1,5 +1,5 @@
 .root {
-  position: relative; /* width handles are absolute against the column */
+  position: relative; /* positioning context for slot-owned absolute chrome */
   display: flex;
   flex-direction: column;
   height: 100%;
@@ -36,9 +36,6 @@
 
 .header {
   position: relative;
-  /* Above the width handles (which start at top: 0) so the breadcrumb row and
-     header buttons stay clickable. */
-  z-index: 9;
   flex: none;
   padding: 12px 28px 0 20px;
   border-bottom: 1px solid transparent;
@@ -202,8 +199,10 @@
 }
 
 /* Width handles: 40px col-resize strips beside the transcript, absolute in
-   .root (NOT the scrollport: an absolute strip there would extend the
-   scrollable range). Inner edge sits 24px outside the content column — the
+   .body (NOT the scrollport: an absolute strip there would extend the
+   scrollable range; NOT .root: a strip from the column top would paint its
+   glow through the header's transparent background). Inner edge sits 24px
+   outside the content column — the
    same offset the glow line paints at — and the strip extends 40px outward
    from there; the outer edge is clamped to keep a 24px safe zone against the
    column edges (sidebar side and scrollbar side stay drag-free). When the
@@ -315,20 +314,23 @@
   flex: none;
 }
 
+/* Band below the header: the scrollport plus the width handles. Anchoring the
+   handles here keeps their full-height strips and glow under the header's
+   bottom edge at every header height. */
+.body {
+  position: relative;
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  min-height: 0;
+}
+
 .scrollBody {
   display: flex;
   flex: 1;
   flex-direction: column;
   min-height: 0;
   overflow-y: auto;
-  /* The column scrolls on ONE axis. Stating `hidden` rather than leaving the
-     initial `visible` is what removes the horizontal bar: a box that scrolls in
-     one axis computes `visible` to `auto` in the other, so any bleed becomes
-     user-scrollable. `.heroGlow` bleeds by construction (1051/776 of the hero
-     box), which put a horizontal scrollbar under every center column narrower
-     than the glow. Clipping is unchanged — `overflow-y: auto` already made this
-     a scroll container that clips both axes, so this only takes away the bar. */
-  overflow-x: hidden;
   /* Reserved unconditionally: the composer seat rides this box's content box in
      Chat and its padding box under a view's composer overlay, so an `auto`
      gutter moves the input card sideways by the bar's width whenever the two
@@ -360,6 +362,15 @@
   );
 }
 
+/* An open @/slash menu (data-trigger-menu, ui-input-trigger) renders inside
+   the input card, so the seat's stacking context caps its z-index; lift the
+   seat above the back-to-bottom control (z-index 8, ui-chat ChatView) while
+   the menu is open. The steady state stays 7 so the control keeps painting
+   over the card. */
+.root[data-phase='active'] .composerSeat:has([data-trigger-menu]) {
+  z-index: 9;
+}
+
 /* Views may opt into a composer overlay while ConversationRoot retains
    ownership of the seat geometry and its active-phase precedence. */
 .scrollBody:has([data-conversation-composer-overlay]) {
@@ -404,7 +415,6 @@
    NOT absolute+transform: a transform would make this box the containing
    block for position:fixed descendants (pickers/modals), shrinking them. */
 .composerHero {
-  position: relative; /* .heroGlow positioning context */
   align-self: center;
   /* figma 75:8208 drew 12 between all three rows; the workspace row now sits
      8 above the card (its margin-top restores 12 under the hero chrome). */
@@ -417,22 +427,6 @@
   z-index: 1;
 }
 
-/* Blue backdrop ellipse (figma 313:14109), centered on the input card: the
-   card's resting center sits ~92px above the stack bottom (32 foot pad +
-   half of the ~120px two-row card); width tracks the card (glow asset 1051
-   vs design card 776) so blur scales in userSpace with it. z-index -1 keeps
-   it behind the in-flow hero content inside this stacking context. */
-.heroGlow {
-  position: absolute;
-  left: 50%;
-  bottom: 92px;
-  z-index: -1;
-  width: calc(100% * 1051 / 776);
-  aspect-ratio: 1051 / 468;
-  transform: translate(-50%, 50%);
-  pointer-events: none;
-}
-
 .heroWorkspaceRow {
   display: flex;
   align-items: center;

+ 18 - 17
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -7,7 +7,7 @@ import clsx from 'clsx'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
 import { conversationPhase } from '../contract/snapshot.ts'
-import { HeroGlow, HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
+import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx'
 import css from './ConversationRoot.module.css'
 
 /** Full props composed from the slot contract. */
@@ -350,7 +350,6 @@ export function ConversationRoot({
 
   const composerBar = (
     <div className={clsx(css.composerStack, hero && css.composerHero)}>
-      {hero && <HeroGlow className={css.heroGlow} />}
       {hero && <HeroShell t={t} renderSlot={renderSlot} />}
       {hero && heroWorkspaceRow}
       {zone !== undefined && renderSlot('conversation.input.dock', zone)}
@@ -378,22 +377,24 @@ export function ConversationRoot({
   return (
     <div ref={rootResizeRef} className={css.root} data-phase={phase}>
       {sessionId === undefined ? null : renderSlot('conversation.session.header', {})}
-      <div className={css.scrollBody} data-conversation-scroll="">
-        {sessionId === undefined ? null : renderSlot('conversation.session', {})}
-        {composerSeat}
+      <div className={css.body}>
+        <div className={css.scrollBody} data-conversation-scroll="">
+          {sessionId === undefined ? null : renderSlot('conversation.session', {})}
+          {composerSeat}
+        </div>
+        {/* Width handles only while a transcript is on screen; the hero has no
+            content column to size. */}
+        {phase === 'active' && (['left', 'right'] as const).map(side => (
+          <WidthHandle
+            key={side}
+            side={side}
+            onStart={onHandleStart}
+            onDrag={onHandleDrag}
+            onCommit={onHandleCommit}
+            onEnd={onHandleEnd}
+          />
+        ))}
       </div>
-      {/* Width handles only while a transcript is on screen; the hero has no
-          content column to size. */}
-      {phase === 'active' && (['left', 'right'] as const).map(side => (
-        <WidthHandle
-          key={side}
-          side={side}
-          onStart={onHandleStart}
-          onDrag={onHandleDrag}
-          onCommit={onHandleCommit}
-          onEnd={onHandleEnd}
-        />
-      ))}
     </div>
   )
 }

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 13 - 36
packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx


+ 19 - 11
packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css

@@ -61,7 +61,8 @@
   white-space: nowrap;
 }
 
-/* Keep hover detection on a stationary box while the mark moves within it. */
+/* Keep hover detection on a stationary box while the mark moves within it;
+   hover rules target the hitbox so a slot-occupant mark swims too. */
 .fishHitbox {
   grid-row: 1;
   grid-column: 1;
@@ -70,35 +71,42 @@
   justify-content: center;
 }
 
-/* Keep the hero mark in the same primary ink as its headline. */
+/* The swim morph's curves poke slightly past the viewBox edges, so clipping
+   would slice the tail mid-wag. The hero mark stays in the same primary ink
+   as its headline. */
 .fish {
-  color: var(--dsw-alias-label-primary);
+  display: block;
+  overflow: visible;
   transform-origin: 50% 60%;
+  color: var(--dsw-alias-label-primary);
 }
 
+/* Hover swim: a slow continuous sway about the body center — the head
+   (left) lifts as the whale rises, then settles back with a small
+   counter-tilt, like treading water. Negative rotation = head up (the head
+   is the left half; origin 50% 60%). */
 @keyframes hero-fish-swim {
-  0%, 100% {
-    transform: translate(0, 0) rotate(0deg);
+  0%,
+  100% {
+    transform: none;
   }
 
   35% {
-    transform: translate(-1px, -1px) rotate(-5deg);
+    transform: rotate(-4deg) translate(-0.4px, -0.9px);
   }
 
   70% {
-    transform: translate(1px, 0) rotate(3deg);
+    transform: rotate(1.6deg) translate(0.3px, 0.2px);
   }
 }
 
 @media (hover: hover) and (prefers-reduced-motion: no-preference) {
   .fishHitbox:hover .fish {
-    animation: hero-fish-swim var(--ds-transition-duration-slow) var(--ds-ease-in-out);
+    animation: hero-fish-swim 1.6s ease-in-out infinite;
   }
 }
 
-/* Workspace row sits 12px above the input card (figma y80 → y112). The blue
-   glow lives with the owner (ConversationRoot .heroGlow) so it can center on
-   the input card. */
+/* Workspace row sits 12px above the input card (figma y80 → y112). */
 .body {
   position: relative;
   display: flex;

+ 17 - 4
packages/client/ui-conversation/src/client/skeleton/InputBar.module.css

@@ -117,6 +117,17 @@
 .scroll {
   max-height: var(--dsh-composer-text-max-height);
   overflow-y: auto;
+  /* Keeps the bar off the card's right border; .input's right pad gives the
+     4px back so the text column is unchanged. */
+  margin-right: 4px;
+}
+
+/* The card's 22px corner arc still overlaps the scrollport's top (10px top
+   pad reaches only y=10): start the thumb's travel below the arc so it never
+   pokes outside the capsule. WebKit-path only; Firefox's thin bar has no
+   track margin, an accepted remainder. */
+.scroll::-webkit-scrollbar-track {
+  margin-top: 8px;
 }
 
 /* Auto-grow anchor: the contenteditable is in normal flow and sets the
@@ -140,10 +151,11 @@
 }
 
 /* The contenteditable draft surface (grows with its content; .scroll caps
-   and scrolls it). figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */
+   and scrolls it). figma .InputText 34:10434: pl 16 / pr 12 / pt 4 — the
+   right pad is 8 here plus .scroll's 4px bar inset. */
 .input {
   box-sizing: border-box;
-  padding: 4px 12px 0 16px;
+  padding: 4px 8px 0 16px;
   font-family: var(--dsw-font-family);
   font-size: inherit;
   line-height: inherit;
@@ -170,10 +182,11 @@
   color: var(--dsw-alias-label-caption);
 }
 
-/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly. */
+/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly.
+   Right inset mirrors .input's 8px pad (the other 4px sits on .scroll). */
 .placeholder {
   position: absolute;
-  inset: 4px 12px auto 16px;
+  inset: 4px 8px auto 16px;
   color: var(--dsw-alias-label-caption);
   pointer-events: none;
   user-select: none;

+ 2 - 2
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -480,7 +480,7 @@ describe('ConversationRoot resident composer', () => {
     const b = mount(sessionSnapshotOf({ blank: true, openState: 'loading' }))
     const root = b.view.container.querySelector('[data-phase]')
     expect(root?.getAttribute('data-phase')).toBe('settling')
-    expect(b.view.queryByText('探索未至之境')).toBeNull()
+    expect(b.view.queryByTestId('hero-headline')).toBeNull()
   })
 
   it('settling phase: a session the list has no row for settles conservatively', () => {
@@ -523,7 +523,7 @@ describe('ConversationRoot resident composer', () => {
     expect(b.wiring.snapshot.draft).toBe('kept across flip')
     expect(b.store.store.getSnapshot().draft).toBe('kept across flip')
     expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
-    expect(b.view.queryByText('探索未至之境')).toBeNull()
+    expect(b.view.queryByTestId('hero-headline')).toBeNull()
     expect(b.view.getByTestId('view-chat')).toBeTruthy()
   })
 

+ 7 - 3
packages/client/ui-input-trigger/src/client/MenuView.module.css

@@ -185,16 +185,20 @@
 }
 
 /* Breadcrumb header of a drilled source: pinned outside .viewport so the
-   descent stays reversible while the candidate list scrolls under it. */
+   descent stays reversible while the candidate list scrolls under it.
+   4px sides + the crumb's own 6px = the first crumb's text sits at 10px,
+   flush with .sectionTitle and the item icons. */
 .crumbs {
   flex: none;
   display: flex;
   align-items: center;
   flex-wrap: wrap;
   gap: 2px;
-  padding: 4px 6px 6px;
+  padding: 4px 4px 6px;
   margin-bottom: 2px;
-  border-bottom: 1px solid var(--dsw-alias-border-inverted);
+  /* l1, not border-inverted (the menu frame's dark-only stroke): the divider
+     needs to survive light mode too; in dark both resolve to white/0.06. */
+  border-bottom: 1px solid var(--dsw-alias-border-l1);
 }
 /* Crumbs sit outside the listbox and take no keyboard highlight, so unlike
    .item they carry their own :hover tint with nothing to compete with. */

+ 4 - 2
packages/client/ui-input-trigger/src/client/MenuView.tsx

@@ -2,7 +2,9 @@
  * Trigger candidate menu: renders the InputTriggerService menu store into the
  * conversation.input.overlay anchor. Closed state renders null (the overlay
  * slot stays mounted); groups render in roster order under localized title
- * rows, pending groups as two skeleton rows; pointer picks route back through
+ * rows. A pending group keeps showing the items it already had (the reducer
+ * retains them across a query refinement) and falls back to two skeleton
+ * rows only while it has none; pointer picks route back through
  * the service (combobox pattern — focus never leaves the textarea, so rows
  * are mousedown-handled and the highlight is exposed via
  * aria-activedescendant on the listbox). A source publishing crumbs gets a
@@ -114,7 +116,7 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
               {group.showGroupTitle === false || group.items.some(item => item.section !== undefined)
                 ? null
                 : <div className={css.groupTitle} role="presentation" data-source={group.source}>{t(group.source as MenuKey)}</div>}
-              {group.status === 'pending'
+              {group.status === 'pending' && group.items.length === 0
                 ? (
                   <div role="status" aria-label={t('loading')} data-source={group.source}>
                     <div className={css.skeletonRow}><span className={css.skeletonBar} style={{ width: '32%' }} /></div>

+ 5 - 0
packages/client/ui-input-trigger/src/client/controller.ts

@@ -236,6 +236,11 @@ export class InputTriggerController {
       }
       case 'enter': {
         if (state.highlight === null) return 'pass'
+        // Refinement keeps the previous rows and highlight visible while the
+        // next fetch is pending; Enter then neither picks the stale row nor
+        // falls through to submit — an explicit no-op until the group is ready.
+        const group = state.groups.find(g => g.source === state.highlight?.source)
+        if (group === undefined || group.status !== 'ready') return 'consumed'
         this.pick(state.highlight.source, state.highlight.index)
         return 'pick-highlighted'
       }

+ 11 - 3
packages/client/ui-input-trigger/src/core/menu.ts

@@ -8,7 +8,10 @@
  * reducer cannot invent groups. Opening from a closed state, the shell seeds
  * the roster with {@link seedGroups} and then dispatches `hit`; a `hit`
  * while open (query refinement) resets the existing groups to pending under
- * a new generation. Auto-close and explicit close drop the groups.
+ * a new generation while keeping their items on screen until the new fetch
+ * settles (stale-while-revalidate — the render layer shows skeletons only
+ * for a pending group with no items). Auto-close and explicit close drop
+ * the groups.
  */
 import type { InputTriggerCandidate, InputTriggerSource } from '../types.ts'
 import type { ExactMatch, MenuReduce, MenuState } from './contract.ts'
@@ -96,8 +99,13 @@ export const menuReduce: MenuReduce = (state, ev) => {
         open: true,
         hit: ev.hit,
         generation: state.generation + 1,
-        groups: state.groups.map(g => ({ ...g, status: 'pending', items: [] })),
-        highlight: null,
+        // Items and highlight survive the refinement (stale-while-revalidate):
+        // the previous query's candidates stay rendered with the highlight
+        // parked where it was while the new fetch runs, and the settled
+        // generation replaces the items and revalidates the highlight
+        // wholesale. Pending status still fences picks off the stale rows.
+        groups: state.groups.map(g => ({ ...g, status: 'pending' })),
+        highlight: state.highlight,
       }
     }
     case 'source-settled': {

+ 9 - 3
packages/client/ui-input-trigger/tests/core-menu.client.spec.ts

@@ -36,13 +36,19 @@ describe('menuReduce hit', () => {
     expect(s.highlight).toBeNull()
   })
 
-  it('re-hit resets ready groups to pending under a bumped generation', () => {
+  it('re-hit resets ready groups to pending under a bumped generation, keeping items and highlight', () => {
     let s = open(['command'])
     s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] })
     s = menuReduce(s, { type: 'hit', hit: hit('g') })
     expect(s.generation).toBe(2)
-    expect(s.groups).toEqual([{ source: 'command', status: 'pending', items: [] }])
-    expect(s.highlight).toBeNull()
+    // Stale-while-revalidate: the previous query's items stay until the new
+    // generation settles and replaces them, and the highlight stays parked
+    // instead of blinking off between keystrokes.
+    expect(s.groups).toEqual([{ source: 'command', status: 'pending', items: [item('goal')] }])
+    expect(s.highlight).toEqual({ source: 'command', index: 0 })
+    s = menuReduce(s, { type: 'source-settled', generation: 2, source: 'command', items: [item('grep')] })
+    expect(s.groups).toEqual([{ source: 'command', status: 'ready', items: [item('grep')] }])
+    expect(s.highlight).toEqual({ source: 'command', index: 0 })
   })
 
   it('preserves a hidden group title through re-hit and settlement', () => {

+ 9 - 0
packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx

@@ -123,6 +123,15 @@ describe('MenuView', () => {
     expect(screen.getByRole('status', { name: '正在加载…' })).toBeTruthy()
   })
 
+  it('renders retained items instead of skeletons while a refinement is pending', () => {
+    mount(openState({
+      groups: [{ source: 'command', status: 'pending', items: [{ name: 'goal' }] }],
+      highlight: null,
+    }))
+    expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual(['goal'])
+    expect(screen.queryByRole('status')).toBeNull()
+  })
+
   it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => {
     const { view } = mount(openState({
       groups: [

+ 42 - 0
packages/client/ui-input-trigger/tests/service.client.spec.ts

@@ -293,6 +293,25 @@ describe('track', () => {
     expect(controller.menu.getSnapshot().groups[0]!.items).toEqual([{ name: 'goal' }])
   })
 
+  it('refinement keeps the settled items on screen until the new fetch lands', async () => {
+    const cmd = deferredSource('/', 'command')
+    const { controller } = controllerBench([cmd.source])
+    controller.track('/g', 2, { tier: 'plain' }, 1)
+    cmd.pending[0]!.resolve([{ name: 'goal' }])
+    await tick()
+
+    // Stale-while-revalidate: the pending group still carries the items.
+    controller.track('/go', 3, { tier: 'plain' }, 1)
+    expect(controller.menu.getSnapshot().groups[0]).toEqual(
+      { source: 'command', status: 'pending', items: [{ name: 'goal' }] },
+    )
+    cmd.pending[1]!.resolve([{ name: 'goat' }])
+    await tick()
+    expect(controller.menu.getSnapshot().groups[0]).toEqual(
+      { source: 'command', status: 'ready', items: [{ name: 'goat' }] },
+    )
+  })
+
   it('same hit re-track refreshes the span stamp without refetching', () => {
     const cmd = deferredSource('/', 'command')
     const { controller } = controllerBench([cmd.source])
@@ -884,6 +903,29 @@ describe('arbitrate', () => {
     controller.track('/g', 2, { tier: 'plain' }, 1)
     expect(controller.arbitrate('enter', false)).toBe('pass')
   })
+
+  it('enter during a pending refinement is consumed: no pick, no submit fallthrough', async () => {
+    const picks: string[] = []
+    const cmd = deferredSource('/', 'command', {
+      onPick: (pick) => { picks.push(pick.candidate.name); return undefined },
+    })
+    const { controller } = controllerBench([cmd.source])
+    controller.track('/g', 2, { tier: 'plain' }, 1)
+    cmd.pending[0]!.resolve([{ name: 'goal' }, { name: 'plan' }])
+    await tick()
+    expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
+    // Refinement: previous rows and highlight stay visible while the fetch pends.
+    controller.track('/go', 3, { tier: 'plain' }, 2)
+    expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 })
+    expect(controller.arbitrate('enter', false)).toBe('consumed')
+    expect(picks).toHaveLength(0)
+    expect(controller.menu.getSnapshot().open).toBe(true)
+    // Settled: the same gesture picks again.
+    cmd.pending[1]!.resolve([{ name: 'goal' }])
+    await tick()
+    expect(controller.arbitrate('enter', false)).toBe('pick-highlighted')
+    expect(picks).toEqual(['goal'])
+  })
 })
 
 describe('onSpace', () => {

+ 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\)\)/)
   })
 })

+ 23 - 9
packages/client/ui-primitives/src/DiffBlock.tsx

@@ -62,21 +62,37 @@ const ROW_CLASS: Record<DiffRow['kind'], string | undefined> = {
   gap: css.gap,
 }
 
+/**
+ * Total added/removed line counts across hunks — the same numbers the footer
+ * prints, exported so a summary row can show them without rebuilding the body.
+ * Every old-side line counts toward `removed` and every new-side line toward
+ * `added`, under {@link contentLines}'s terminator rule.
+ * @param diffs - the hunks to count.
+ * @returns the +/- totals.
+ */
+export function diffTotals(diffs: DiffHunk[]): { added: number; removed: number } {
+  let added = 0
+  let removed = 0
+  for (const diff of diffs) {
+    if (diff.oldText !== null) removed += contentLines(diff.oldText).length
+    added += contentLines(diff.newText).length
+  }
+  return { added, removed }
+}
+
 /**
  * Flatten the hunks into the body's rows plus the footer counts. A path header
  * opens each new file; a same-file second hunk (a scattered edit) opens with a
- * `⋯` gap instead of repeating the path. Every old-side line counts toward
- * `removed` and every new-side line toward `added`. The file count is of
- * DISTINCT paths, matching the TUI diff card's footer, so two hunks in one file
- * read as `1 file` on both front ends.
+ * `⋯` gap instead of repeating the path. The +/- totals are
+ * {@link diffTotals}'s. The file count is of DISTINCT paths, matching the TUI
+ * diff card's footer, so two hunks in one file read as `1 file` on both front
+ * ends.
  * @param diffs - the hunks to render.
  * @returns the body rows, the +/- totals, and the distinct-file count.
  */
 function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed: number; files: number } {
   const rows: DiffRow[] = []
   const paths = new Set<string>()
-  let added = 0
-  let removed = 0
   let prevPath: string | undefined
   for (const diff of diffs) {
     paths.add(diff.path)
@@ -86,15 +102,13 @@ function buildRows(diffs: DiffHunk[]): { rows: DiffRow[]; added: number; removed
     if (diff.oldText !== null) {
       for (const line of contentLines(diff.oldText)) {
         rows.push({ kind: 'del', text: line })
-        removed++
       }
     }
     for (const line of contentLines(diff.newText)) {
       rows.push({ kind: 'add', text: line })
-      added++
     }
   }
-  return { rows, added, removed, files: paths.size }
+  return { rows, ...diffTotals(diffs), files: paths.size }
 }
 
 /**

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 4 - 0
packages/client/ui-primitives/src/FishLogo.tsx


+ 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">

+ 2 - 2
packages/client/ui-primitives/src/index.ts

@@ -22,7 +22,7 @@ export { OnboardingSurface } from './OnboardingSurface.tsx'
 export { RiskConfirmation } from './RiskConfirmation.tsx'
 export type { RiskConfirmationProps } from './RiskConfirmation.tsx'
 export { ConnectionBanner } from './ConnectionBanner.tsx'
-export { FishLogo } from './FishLogo.tsx'
+export { FishLogo, FISH_LOGO_PATH, FISH_LOGO_VIEWBOX } from './FishLogo.tsx'
 export { BrandWordmark } from './BrandWordmark.tsx'
 export type { BrandWordmarkProps } from './BrandWordmark.tsx'
 export { ReferenceIcon } from './ReferenceIcon.tsx'
@@ -40,7 +40,7 @@ export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
 export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx'
 export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx'
 export type { ReadBlockProps, ReadBlockLine, ReadBlockLabels } from './ReadBlock.tsx'
-export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx'
+export { DiffBlock, DEFAULT_DIFF_MAX_LINES, diffTotals } from './DiffBlock.tsx'
 export type { DiffBlockProps, DiffHunk, DiffBlockLabels } from './DiffBlock.tsx'
 export { SearchBlock, DEFAULT_SEARCH_MAX_LINES } from './SearchBlock.tsx'
 export type {

+ 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])
 }

+ 16 - 9
packages/client/ui-primitives/src/user-text.module.css

@@ -9,20 +9,27 @@
 }
 
 .refChip {
-  display: inline-flex;
-  align-items: center;
-  gap: 4px;
+  /* Plain inline, NOT inline-flex: a flex container takes its baseline from
+     its first flex item — the icon svg, which has no text baseline — so the
+     chip's label rode ~3px above the surrounding text in both surfaces. An
+     inline chip shares the consumer's baseline by construction. */
+  display: inline;
   margin: 0 2px;
   color: var(--dsw-alias-state-business-primary);
   font-weight: 500;
   white-space: nowrap;
-  vertical-align: baseline;
 }
 
-/* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's text
-   size: the 16px svg edge moves by the same px delta as the surrounding font. */
+/* Inline reference glyphs (always ReferenceIcon svgs) ride the consumer's own
+   font: 1em keeps the glyph at the text's size in the bubble (14px + user
+   setting), the queue preview's fixed 13px line, and any future consumer — a
+   px+delta size followed only the bubble axis and left the glyph oversized in
+   the queue row. */
 .refIcon {
-  flex: none;
-  width: calc(16px + var(--dsh-content-font-delta, 0px));
-  height: calc(16px + var(--dsh-content-font-delta, 0px));
+  width: 1em;
+  height: 1em;
+  margin-right: 4px;
+  /* Optical centering against the text: drops the glyph below the baseline by
+     a font-relative amount, so it holds across the font axis. */
+  vertical-align: -0.125em;
 }

+ 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()
+  })
+})

+ 3 - 3
packages/client/ui-primitives/tests/user-text-styles.client.spec.ts

@@ -18,10 +18,10 @@ function declarations(selector: string): string[] {
 }
 
 describe('user-text.module.css font-size axis', () => {
-  it('scales reference glyphs by the shared px delta', () => {
+  it('scales reference glyphs with the consumer font', () => {
     expect(declarations('.refIcon')).toEqual(expect.arrayContaining([
-      'width: calc(16px + var(--dsh-content-font-delta, 0px))',
-      'height: calc(16px + var(--dsh-content-font-delta, 0px))',
+      'width: 1em',
+      'height: 1em',
     ]))
   })
 })

+ 1 - 1
packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css

@@ -11,7 +11,7 @@
   gap: 16px;
   font-size: 13px;
   line-height: 1.5;
-  color: var(--dsw-alias-label-secondary);
+  color: var(--dsw-alias-label-primary);
 }
 
 .toggleLabel {

+ 24 - 4
packages/client/ui-tool/src/client/tool/components/ToolRow.module.css

@@ -103,9 +103,28 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
-/* File-tool path: same geometry as .summary, with a persistent link affordance. */
+/* Diff-row +/- totals: the code font, matching the diff body the numbers
+   summarize; a wider gap keeps the digits from reading as part of the path.
+   Two px under the secondary tier (still riding the axis): mono digits read
+   optically larger than the sans path at the same size. Caption, one step
+   dimmer than the suffix's tertiary, so the digits stay behind the path. The
+   half-pixel nudge closes the baseline gap the size difference leaves under
+   the row's box-centering (a transform, so flex layout is untouched). */
+.diffStat {
+  margin-left: 10px;
+  font-family: var(--ds-font-family-code);
+  font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 2px);
+  color: var(--dsw-alias-label-caption);
+  transform: translateY(0.5px);
+}
+
+/* File-tool path: same type as .summary, with a persistent link affordance.
+   Dotted dimmed underline: visible enough to say "clickable", light enough
+   that a long path doesn't read as one heavy rule under the row. Shrink-to-fit
+   (flex 0): the click target ends where the path text ends — the row's empty
+   remainder stays the expand/collapse toggle. */
 .fileLink {
-  flex: 1 1 auto;
+  flex: 0 1 auto;
   min-width: 0;
   overflow: hidden;
   text-overflow: ellipsis;
@@ -119,8 +138,9 @@
   font-size: var(--dsh-content-font-size-secondary, 13px);
   line-height: calc(24px + var(--dsh-content-font-delta, 0px));
   color: var(--dsw-alias-label-secondary);
-  text-decoration: underline;
-  text-decoration-color: var(--dsw-alias-label-quaternary);
+  text-decoration: underline dotted;
+  text-decoration-color: var(--dsw-alias-label-tertiary);
+  text-decoration-thickness: 1px;
   text-underline-offset: 3px;
   cursor: pointer;
 }

+ 13 - 2
packages/client/ui-tool/src/client/tool/components/ToolRow.tsx

@@ -2,6 +2,7 @@ import { useMemo, useState, type KeyboardEvent, type MouseEvent, type ReactNode
 import clsx from 'clsx'
 import {
   CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock,
+  diffTotals,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
 import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts'
@@ -129,7 +130,15 @@ export function ToolRow({
   // A failure must replace, not supplement, the normal summary.
   const failureLine = state === 'error' ? errorSummary ?? null : null
   const summaryText = failureLine ?? terminalBody?.description ?? summary
-  const suffix = failureLine === null ? summarySuffix ?? null : null
+  // A diff row's collapsed line carries the card's +/- totals (the same
+  // numbers the expanded footer prints) so the change size reads without
+  // expanding; an explicit summarySuffix (none today on diff rows) wins.
+  const diffStat = useMemo(() => {
+    if (diffBody === null) return null
+    const { added, removed } = diffTotals(diffBody.card.diffs)
+    return `+${added} -${removed}`
+  }, [diffBody])
+  const suffix = failureLine === null ? summarySuffix ?? diffStat : null
   const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
   const toggleExpand = () => {
     setExpanded(v => !v)
@@ -184,7 +193,9 @@ export function ToolRow({
                 {summaryText}
               </span>
             )}
-            {suffix !== null && <span className={css.summarySuffix}>{suffix}</span>}
+            {suffix !== null && (
+              <span className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)}>{suffix}</span>
+            )}
           </>
         )}
       >

+ 2 - 0
packages/client/ui-tool/tests/diff-card.client.spec.tsx

@@ -252,6 +252,8 @@ describe('FileMutationRow diff card', () => {
       call: { name: 'write', argsRaw: writeArgs },
       meta: { diffs: [] },
     }), 'write')} />)
+    // The collapsed row already carries the card's +/- totals beside the path.
+    expect(view.getByText('+1 -0')).toBeTruthy()
     // The footer counts live inside the collapsed diff card.
     toggleRow(view)
     expect(view.getByText('└ +1 -0 · 1 个文件')).toBeTruthy()

+ 2 - 4
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -588,10 +588,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     hookContext: '',
     slotInject: '',
     declaredBy: 'an entry in \'conversation\' (client-ui-conversation), so it exists while that entry is mounted',
-    occupants: [
-      'client-ui-brand-official OfficialBrandMark',
-    ],
-    replaceRisk: 'shadows-shipped-ui',
+    occupants: [],
+    replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n      { name: \'conversation.hero.brand.mark\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
     source: 'packages/client/ui-conversation/src/client/contract/slots.ts:123',
   },

+ 6 - 3
pnpm-lock.yaml

@@ -1962,9 +1962,6 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
-      '@deepseek-ai/dsh-client-ui-conversation':
-        specifier: workspace:^
-        version: link:../ui-conversation
       '@deepseek-ai/dsh-client-ui-primitives':
         specifier: workspace:^
         version: link:../ui-primitives
@@ -2083,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

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.