Selaa lähdekoodia

fix(client): address review — pill accessible names, static untimed pill, exclusive stat dialogs

StatsPills buttons carry explicit aria-labels separating segments with
' · '; a log with no timed figure renders the counts pill as a static
reading instead of a button opening an empty dialog; the pills row owns
one exclusive open slot so sibling dialogs never stack. ContextMeter
restores the '~' approximation prefix (usedTokens prefers heuristic
projectedTokens). ModelSelect's jscpd:ignore now names the real gap
(useAnchoredPosition places from the left edge only). The pills Agent
Note's stale facts (locale keys, context occupancy, golden ownership)
are rewritten in place, en+zh. plan-review.e2e.ts parks the pointer
after Approve so the ContextMeter tooltip cannot leak into the aria
captures; affected goldens refreshed.
Yif 1 viikko sitten
vanhempi
sitoutus
2c45dfe71e
58 muutettua tiedostoa jossa 252 lisäystä ja 166 poistoa
  1. 2 2
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml
  2. 7 8
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
  3. 7 8
      .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md
  4. 2 2
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  5. 2 2
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  6. 2 2
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  7. 2 2
      apps/web/tests/expected/steer-all/settled.expected.md
  8. 4 0
      apps/web/tests/plan-review.e2e.ts
  9. 8 3
      packages/client/ui-chat/src/client/chat/StatsPills.module.css
  10. 75 21
      packages/client/ui-chat/src/client/chat/StatsPills.tsx
  11. 7 3
      packages/client/ui-chat/src/client/chat/stat-dialog.ts
  12. 3 2
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  13. 38 16
      packages/client/ui-chat/tests/chat-stats.client.spec.tsx
  14. 2 1
      packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx
  15. 3 3
      packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx
  16. 4 4
      packages/client/ui-conversation/tests/context-meter.client.spec.tsx
  17. 3 3
      packages/client/ui-model-selection/src/client/ModelSelect.tsx
  18. 2 2
      snapshots/web/bash-abort-row/ui.expected.md
  19. 1 1
      snapshots/web/cordis-tool-round/ui.expected.md
  20. 2 2
      snapshots/web/feedback-command/ack-expanded.expected.md
  21. 2 2
      snapshots/web/feedback-command/ack.expected.md
  22. 2 2
      snapshots/web/feedback-release/ack-expanded.expected.md
  23. 2 2
      snapshots/web/feedback-release/ack.expected.md
  24. 2 2
      snapshots/web/file-upload-round/ui.expected.md
  25. 2 2
      snapshots/web/fresh-round-trip/ui-expanded.expected.md
  26. 2 2
      snapshots/web/fresh-round-trip/ui.expected.md
  27. 2 2
      snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
  28. 2 2
      snapshots/web/goal-multi-turn-actions/ui.expected.md
  29. 2 2
      snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md
  30. 2 2
      snapshots/web/lifecycle-chrome/reloaded.expected.md
  31. 2 3
      snapshots/web/live-interactions/error-auth.expected.md
  32. 2 3
      snapshots/web/live-interactions/retry-exhausted.expected.md
  33. 2 2
      snapshots/web/live-interactions/retry-expanded.expected.md
  34. 2 2
      snapshots/web/live-interactions/retry.expected.md
  35. 2 2
      snapshots/web/message-actions/ui.expected.md
  36. 2 2
      snapshots/web/minimal-preset/ui.expected.md
  37. 2 2
      snapshots/web/plan-review/approved-expanded.expected.md
  38. 2 3
      snapshots/web/plan-review/approved.expected.md
  39. 2 2
      snapshots/web/ptc-round/ui.expected.md
  40. 2 2
      snapshots/web/question-composer/answered-expanded.expected.md
  41. 2 2
      snapshots/web/question-composer/answered.expected.md
  42. 2 2
      snapshots/web/question-composer/cancelled.expected.md
  43. 2 2
      snapshots/web/queued-image/delivered.expected.md
  44. 2 2
      snapshots/web/seeded-history/command-row.expected.md
  45. 2 2
      snapshots/web/seeded-history/feedback-row.expected.md
  46. 2 2
      snapshots/web/seeded-history/ui-expanded.expected.md
  47. 2 2
      snapshots/web/seeded-history/ui.expected.md
  48. 2 2
      snapshots/web/skill-tool-row/ui.expected.md
  49. 2 2
      snapshots/web/steering/settled-expanded.expected.md
  50. 2 2
      snapshots/web/steering/settled.expected.md
  51. 2 2
      snapshots/web/subagent-conversation/ui-expanded.expected.md
  52. 2 2
      snapshots/web/subagent-conversation/ui.expected.md
  53. 2 2
      snapshots/web/turn-tail-actions/completed.expected.md
  54. 2 2
      snapshots/web/turn-tail-actions/focused.expected.md
  55. 2 2
      snapshots/web/turn-tail-actions/running.expected.md
  56. 2 2
      snapshots/web/turn-tail-actions/settled.expected.md
  57. 2 2
      snapshots/web/turn-tail-actions/usage-expanded.expected.md
  58. 2 2
      snapshots/web/web-search-round/ui.expected.md

+ 2 - 2
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
-2026-09-07-composer-session-stats-pills.md: 323a90067b11dd9419469e47e9b3bcdbf0078e5c
-2026-09-07-composer-session-stats-pills.zh.md: 00a63e2800f0cf2441ad2a233c3d5f206ca7545c
+2026-09-07-composer-session-stats-pills.md: 818c3c156f72816eb6540d505782be8e03dc6aa0
+2026-09-07-composer-session-stats-pills.zh.md: 8a74cddd3176026d799fb5b4223a51371ee8e253

+ 7 - 8
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md

@@ -6,27 +6,26 @@ English | [中文](2026-09-07-composer-session-stats-pills.zh.md)
 
 ## Problem
 
-The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `conversation.composer.dock`) rendered every figure as one resident text line: turn/step counts, LLM and tool wall times, TTFT/TPS averages, token totals with cache-hit share, and context occupancy. The line crowded as figures accumulated, exact token values hid behind a hover tooltip with its own `ResizeObserver` overflow measurement, and the flat text gave no grouping — time figures and billing figures read as one undifferentiated row. An in-page A/B against a two-pill variant settled the direction: the pills won on scannability and on giving each figure family a home.
+The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `conversation.composer.dock`) rendered every figure as one resident text line: turn/step counts, LLM and tool wall times, TTFT/TPS averages, and compact token totals with cache-hit share. The line crowded as figures accumulated, exact token counts appeared nowhere (the `ResizeObserver`-measured hover tooltip only restated the same compact line when it truncated), and the flat text gave no grouping — time figures and billing figures read as one undifferentiated row. An in-page A/B against a two-pill variant settled the direction: the pills won on scannability and on giving each figure family a home.
 
 ## Decision
 
-`StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.elapsed` / `stats.tps` locale keys removed.
+`StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed.
 
-- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS). A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, cache write, output — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss) extracted for exactly this two-consumer split.
-- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy left the strip entirely — the composer's ContextMeter ring owns it.
+- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, cache write, output — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually.
+- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy stays on the composer's ContextMeter ring, where it already lived beside `StatsLine` — the strip never carried it.
 - **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing.
 - **`data-composer-stats` is a cross-package attribute contract.** The pills' root carries it; ui-conversation's `InputBar.module.css` `:has([data-composer-stats])` rule tightens the composer's bottom clearance to 4px when the row is mounted. The producer side pins the attribute in unit tests, following the `data-trigger-menu` precedent.
 
 ## Alternatives considered
 
-- **The single-line variant (StatsLine, the A/B loser).** All figures resident in one text row with a hover tooltip for exact totals. Lost on crowding and discoverability: the tooltip hid exact values behind an unadvertised hover, and one row gave time and billing figures no visual grouping.
+- **The single-line variant (StatsLine, the A/B loser).** All figures resident in one text row, with a hover tooltip restating the full line when it truncated. Lost on crowding and reach: exact token counts appeared nowhere (the line and its tooltip both carried compact totals only), and one row gave time and billing figures no visual grouping.
 - **Three resident groups with one shared dialog.** An intermediate iteration kept counts, time, and tokens as three inline groups. Two pills won because the time/usage split matches the two underlying projections one-to-one and each pill's icon telegraphs its dialog.
-- **Keeping context occupancy in the strip.** Rejected: the ContextMeter ring beside the composer already shows occupancy continuously; repeating it in the strip spent width on a duplicate.
 - **Extracting the dl bucket rows shared with `TurnUsagePanel`.** The session-total dialog and the per-turn panel render the same skin but different contracts (all buckets always present vs optional per-turn fields plus model routes); a shared component would be conditionals around nine lines. The mirror is marked `jscpd:ignore` with the reason inline.
 
 ## Consequences
 
 - `ChatSnapshotBuilder`'s legacy slice now serves StatsPills; the [node-assembly note](../architecture/2026-08-09-client-conversation-node-assembly.md) tracks that consumer rename.
-- Exact figures cost a click instead of a hover; the strip itself carries only the two headline readings.
-- Web e2e strip assertions match substring text inside the time pill; the paged-history scenario's aria golden pins the two-pill structure.
+- Exact token counts become reachable at all — one click — where `StatsLine` showed only compact totals; the strip itself carries only the two headline readings.
+- Web e2e strip assertions match substring text inside the time pill; the fresh-round-trip aria goldens pin the two-pill structure, and stats-paged-history pins the counts reading alone over a log with no billed tokens.
 - The `conversation.composer.dock` occupant in the generated slot catalog is `client-ui-chat StatsPills id 'stats'`.

+ 7 - 8
.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md

@@ -6,27 +6,26 @@ Status: implemented
 
 ## 问题
 
-输入框下方的会话统计条(`StatsLine`,ui-chat,挂载于 `conversation.composer.dock`)把所有数字渲染成一整行常驻文本:轮/步计数、模型与工具用时、TTFT/TPS 均值、token 总量与缓存命中率、上下文占用。数字越多行越拥挤,精确 token 值藏在带独立 `ResizeObserver` 溢出测量的悬停提示里,纯文本也没有分组——时间类数字和计费类数字读起来混作一行。一次页面内 A/B 对比双 pill 变体后方向定案:pill 方案在可扫读性和"每类数字有归属"上胜出。
+输入框下方的会话统计条(`StatsLine`,ui-chat,挂载于 `conversation.composer.dock`)把所有数字渲染成一整行常驻文本:轮/步计数、模型与工具用时、TTFT/TPS 均值、紧凑 token 总量与缓存命中率。数字越多行越拥挤,精确 token 计数无处可看(带 `ResizeObserver` 溢出测量的悬停提示只在截断时复述同一行紧凑文本),纯文本也没有分组——时间类数字和计费类数字读起来混作一行。一次页面内 A/B 对比双 pill 变体后方向定案:pill 方案在可扫读性和"每类数字有归属"上胜出。
 
 ## 决定
 
-`StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.elapsed` / `stats.tps` 文案键一并移除。
+`StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。
 
-- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度)。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、缓存写入、输出——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭)。
-- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用彻底离开统计条——由输入框旁的 ContextMeter 圆环独占
+- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、缓存写入、输出——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态;pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本
+- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍在输入框旁的 ContextMeter 圆环上,`StatsLine` 时代它就在那里——统计条从未承载过它
 - **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。
 - **`data-composer-stats` 是跨包属性契约。** pill 行根元素携带它;ui-conversation 的 `InputBar.module.css` 用 `:has([data-composer-stats])` 在该行挂载时把输入框底部留白收紧到 4px。生产方在单测里钉住该属性,沿用 `data-trigger-menu` 先例。
 
 ## 备选方案
 
-- **单行变体(StatsLine,A/B 落选方)。** 全部数字常驻一行文本,精确总量靠悬停提示。败在拥挤与可发现性:悬停入口无提示可寻,单行也无法给时间与计费数字分组。
+- **单行变体(StatsLine,A/B 落选方)。** 全部数字常驻一行文本,悬停提示只在截断时复述整行。败在拥挤与可达性:精确 token 计数无处可看(行内和提示里都只有紧凑总量),单行也无法给时间与计费数字分组。
 - **三个常驻分组共享一个弹层。** 中间迭代曾把计数、时间、token 保持为三个内联分组。双 pill 胜出是因为时间/用量的切分与两个底层投影一一对应,且每个 pill 的图标直接预告其弹层内容。
-- **在统计条里保留上下文占用。** 否决:输入框旁的 ContextMeter 圆环已持续展示占用,统计条重复它是把宽度花在冗余上。
 - **抽出与 `TurnUsagePanel` 共享的 dl 分桶行。** 会话总量弹层与逐轮面板皮肤相同但契约不同(此处所有分桶恒在;逐轮字段可选且含模型路由);共享组件只是给九行代码包一层条件。该镜像以 `jscpd:ignore` 标注并内联说明理由。
 
 ## 后果
 
 - `ChatSnapshotBuilder` 的 legacy 切片现在服务于 StatsPills;[节点装配 note](../architecture/2026-08-09-client-conversation-node-assembly.zh.md) 已跟进该消费者更名。
-- 精确数字从悬停改为一次点击;统计条本身只承载两个头条读数。
-- Web e2e 对统计条的断言匹配时间 pill 内的子串文本;分页历史场景的 aria golden 钉住双 pill 结构
+- 精确 token 计数第一次变得可达——一次点击即可;`StatsLine` 只展示过紧凑总量。统计条本身只承载两个头条读数。
+- Web e2e 对统计条的断言匹配时间 pill 内的子串文本;fresh-round-trip 的 aria golden 钉住双 pill 结构,stats-paged-history 则在无计费 token 的日志上钉住单独的计数读数
 - 生成的插槽目录中 `conversation.composer.dock` 占用者为 `client-ui-chat StatsPills id 'stats'`。

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

@@ -51,9 +51,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "272 tokCache hit 0%":
+- button "272 tok · Cache hit 0%":
   - img
   - text: 272 tokCache hit 0%

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

@@ -43,9 +43,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "272 tokCache hit 0%":
+- button "272 tok · Cache hit 0%":
   - img
   - text: 272 tokCache hit 0%

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

@@ -61,9 +61,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "40 tokCache hit 0%":
+- button "40 tok · Cache hit 0%":
   - img
   - text: 40 tokCache hit 0%

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

@@ -49,9 +49,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "40 tokCache hit 0%":
+- button "40 tok · Cache hit 0%":
   - img
   - text: 40 tokCache hit 0%

+ 4 - 0
apps/web/tests/plan-review.e2e.ts

@@ -97,6 +97,10 @@ describe('web e2e: plan review takeover round trip', () => {
     }
 
     await card.getByRole('button', { name: 'Approve' }).click()
+    // Park the pointer: the card unmounts and the ContextMeter ring lands
+    // under the click position, whose 200ms hover delay would arm a tooltip
+    // into the aria captures below.
+    await page.mouse.move(0, 0)
 
     const sessionId = await settled
     if (MODE === 'record') {

+ 8 - 3
packages/client/ui-chat/src/client/chat/StatsPills.module.css

@@ -36,7 +36,6 @@
   font-variant-numeric: tabular-nums;
   line-height: inherit;
   white-space: nowrap;
-  cursor: pointer;
 }
 
 .pill svg {
@@ -45,8 +44,14 @@
   flex: none;
 }
 
-.pill:hover,
-.pill[aria-expanded='true'] {
+/* Only the button form (a pill with dialog rows to open) invites interaction;
+   the static span form keeps the resting tertiary reading. */
+button.pill {
+  cursor: pointer;
+}
+
+button.pill:hover,
+button.pill[aria-expanded='true'] {
   background: var(--dsw-alias-interactive-bg-hover);
   color: var(--dsw-alias-label-secondary);
 }

+ 75 - 21
packages/client/ui-chat/src/client/chat/StatsPills.tsx

@@ -5,7 +5,7 @@
 // Mounted on 'conversation.composer.dock' so it sticks with the composer in the
 // active conversation scrollport (see ConversationRoot data-conversation-scroll).
 
-import { memo, useMemo } from 'react'
+import { memo, useMemo, useState } from 'react'
 import { createPortal } from 'react-dom'
 import { IconDatabaseOutline16, IconGaugeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -132,8 +132,44 @@ function exactCount(value: number, t: ChatViewSlotProps['t']): string {
   return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
 }
 
-function TimePill({ stats, t }: { stats: WindowStats; t: ChatViewSlotProps['t'] }) {
-  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
+/** External open state one pill's dialog reads and writes (the row's exclusive slot). */
+type PillDialog = Pick<ReturnType<typeof useStatDialog>, 'open' | 'setOpen'>
+
+function TimePill({ stats, t, dialog }: {
+  stats: WindowStats
+  t: ChatViewSlotProps['t']
+  dialog: PillDialog
+}) {
+  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
+  const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
+  const tps = stats.decodeMs > 0
+    ? t('message.tokensPerSecond', {
+      tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
+    })
+    : null
+  const label = (
+    <span className={css.label}>
+      {counts}
+      {tps !== null && (
+        <>
+          <span className={css.sep} aria-hidden>·</span>
+          {tps}
+        </>
+      )}
+    </span>
+  )
+  // A window without one timed figure has no dialog rows to show, so the pill
+  // stays a plain reading instead of a button opening an empty dialog.
+  if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && stats.decodeMs <= 0) {
+    return (
+      <span className={css.anchor}>
+        <span className={css.pill}>
+          <IconGaugeOutline16 />
+          {label}
+        </span>
+      </span>
+    )
+  }
   return (
     <span ref={rootRef} className={css.anchor}>
       <button
@@ -141,20 +177,11 @@ function TimePill({ stats, t }: { stats: WindowStats; t: ChatViewSlotProps['t']
         className={css.pill}
         aria-haspopup="dialog"
         aria-expanded={open}
+        aria-label={tps === null ? counts : `${counts} · ${tps}`}
         onClick={() => { setOpen(!open) }}
       >
         <IconGaugeOutline16 />
-        <span className={css.label}>
-          {t('stats.counts', { turns: stats.turns, steps: stats.steps })}
-          {stats.decodeMs > 0 && (
-            <>
-              <span className={css.sep} aria-hidden>·</span>
-              {t('message.tokensPerSecond', {
-                tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
-              })}
-            </>
-          )}
-        </span>
+        {label}
       </button>
       {open && createPortal(
         <div
@@ -206,11 +233,17 @@ function TimePill({ stats, t }: { stats: WindowStats; t: ChatViewSlotProps['t']
   )
 }
 
-function UsagePill({ usage, t }: { usage: TokenUsageProjection; t: ChatViewSlotProps['t'] }) {
-  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
+function UsagePill({ usage, t, dialog }: {
+  usage: TokenUsageProjection
+  t: ChatViewSlotProps['t']
+  dialog: PillDialog
+}) {
+  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
   // Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output.
   const total = billedInputTokens(usage) + usage.outputTokens
+  const totalText = t('message.turnUsage.count', { count: formatTokens(total, t) })
   const cacheHit = cacheHitPercent(usage)
+  const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null
   return (
     <span ref={rootRef} className={css.anchor}>
       <button
@@ -218,15 +251,16 @@ function UsagePill({ usage, t }: { usage: TokenUsageProjection; t: ChatViewSlotP
         className={css.pill}
         aria-haspopup="dialog"
         aria-expanded={open}
+        aria-label={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
         onClick={() => { setOpen(!open) }}
       >
         <IconDatabaseOutline16 />
         <span className={css.label}>
-          {t('message.turnUsage.count', { count: formatTokens(total, t) })}
-          {cacheHit !== null && (
+          {totalText}
+          {cacheHitText !== null && (
             <>
               <span className={css.sep} aria-hidden>·</span>
-              {t('stats.cacheHit', { percent: cacheHit })}
+              {cacheHitText}
             </>
           )}
         </span>
@@ -277,6 +311,8 @@ function UsagePill({ usage, t }: { usage: TokenUsageProjection; t: ChatViewSlotP
 export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }: StatsPillsProps) {
   const settledNodes = useChat(s => s.legacy.nodes)
   const usage = useProjection('tokenUsage')
+  // One exclusive slot for both dialogs: opening either pill closes the other.
+  const [openPill, setOpenPill] = useState<'time' | 'usage' | null>(null)
   // Every figure rides the durable sessionStats projection, so paging and
   // compaction cannot change any of them; an assembly without the unit falls
   // back to the window-scoped fold wholesale (same field names), paid only
@@ -292,8 +328,26 @@ export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }
   // tightens the composer's bottom clearance only while this row renders.
   return (
     <div className={css.root} data-composer-stats>
-      {stats.steps > 0 && <TimePill stats={stats} t={t} />}
-      {hasTokens && <UsagePill usage={usage} t={t} />}
+      {stats.steps > 0 && (
+        <TimePill
+          stats={stats}
+          t={t}
+          dialog={{
+            open: openPill === 'time',
+            setOpen: (open) => { setOpenPill(open ? 'time' : null) },
+          }}
+        />
+      )}
+      {hasTokens && (
+        <UsagePill
+          usage={usage}
+          t={t}
+          dialog={{
+            open: openPill === 'usage',
+            setOpen: (open) => { setOpenPill(open ? 'usage' : null) },
+          }}
+        />
+      )}
     </div>
   )
 })

+ 7 - 3
packages/client/ui-chat/src/client/chat/stat-dialog.ts

@@ -28,10 +28,14 @@ export interface StatDialogSeat {
 
 /**
  * One trigger-anchored dialog seat: open state, viewport-clamped placement, outside-close.
+ * @param controlled - external open state; when given the seat reads and writes
+ * it instead of owning its own, letting sibling dialogs share one exclusive slot.
  * @returns the seat; spread `pos ?? MEASURE_STYLE` onto the portaled panel.
  */
-export function useStatDialog(): StatDialogSeat {
-  const [open, setOpen] = useState(false)
+export function useStatDialog(controlled?: Pick<StatDialogSeat, 'open' | 'setOpen'>): StatDialogSeat {
+  const [ownOpen, setOwnOpen] = useState(false)
+  const open = controlled?.open ?? ownOpen
+  const setOpen = controlled?.setOpen ?? setOwnOpen
   const rootRef = useRef<HTMLSpanElement | null>(null)
   const panelRef = useRef<HTMLDivElement | null>(null)
 
@@ -56,7 +60,7 @@ export function useStatDialog(): StatDialogSeat {
     }
     document.addEventListener('keydown', onKeyDown)
     return () => { document.removeEventListener('keydown', onKeyDown) }
-  }, [open])
+  }, [open, setOpen])
 
   return { open, setOpen, rootRef, panelRef, pos }
 }

+ 3 - 2
packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx

@@ -1051,8 +1051,9 @@ describe('small branch tails', () => {
           : undefined}
       />,
     )
-    const [timePill, usagePill] = [...view.getAllByRole('button')] as [HTMLElement, HTMLElement]
-    expect(timePill.textContent).toBe('1 轮 1 步')
+    // The untimed counts pill renders static, so the usage pill is the only button.
+    const [usagePill] = [...view.getAllByRole('button')] as [HTMLElement]
+    expect(view.getByText('1 轮 1 步').closest('button')).toBeNull()
     expect(usagePill.textContent).toBe('10 tok')
     // Pure output accounting still reaches the usage pill's click-open dialog rows.
     fireEvent.click(usagePill)

+ 38 - 16
packages/client/ui-chat/tests/chat-stats.client.spec.tsx

@@ -158,18 +158,21 @@ describe('StatsPills', () => {
     timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
   })
 
-  it('renders the counts and usage pills and hides a brand-new empty session', () => {
+  it('renders the counts reading and usage pill and hides a brand-new empty session', () => {
     const { source } = makeSource({ nodes: [assistant(1, 1)] })
     const view = render(<StatsPills {...props(source)} />)
     // InputBar's `.root:has([data-composer-stats])` bottom-clearance rule keys
     // off this attribute: present exactly while the row renders.
     expect(view.container.querySelector('[data-composer-stats]')).toBeTruthy()
-    // No timing on the fixture: the speed segment drops out. Cache hit comes
-    // from the projection, so paging the window cannot change it; the usage
-    // pill leads with the whole-log token total.
-    expect(view.getAllByRole('button').map(pill => pill.textContent)).toEqual(
-      ['1 turns 1 steps', '105 tok·Cache hit 90%'],
-    )
+    // No timing on the fixture: the speed segment drops out and the dialog
+    // would have no rows, so the counts reading stays a static pill (no button).
+    expect(view.getByText('1 turns 1 steps').closest('button')).toBeNull()
+    // Cache hit comes from the projection, so paging the window cannot change
+    // it; the usage pill leads with the whole-log token total. Its accessible
+    // name separates the segments the visual sep glyph joins.
+    const usagePill = view.getAllByRole('button')
+    expect(usagePill.map(pill => pill.textContent)).toEqual(['105 tok·Cache hit 90%'])
+    expect(usagePill[0]!.getAttribute('aria-label')).toBe('105 tok · Cache hit 90%')
     const empty = makeSource()
     const emptyView = render(<StatsPills {...props(empty.source, {
       tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
@@ -199,13 +202,15 @@ describe('StatsPills', () => {
   ])('formats an actual $actual cache-hit ratio as $expected', ({ tokenUsageValue, expected }) => {
     const { source } = makeSource({ nodes: [assistant(1, 1)] })
     const view = render(<StatsPills {...props(source, { tokenUsage: tokenUsageValue })} />)
-    expect(view.getAllByRole('button')[1]!.textContent).toContain(expected)
+    expect(view.getAllByRole('button')[0]!.textContent).toContain(expected)
   })
 
   it('exposes output speed on the counts pill when decode timing exists', () => {
     const { source } = makeSource({ nodes: [timedStep()] })
     const view = render(<StatsPills {...props(source)} />)
-    expect(view.getAllByRole('button')[0]!.textContent).toBe('1 turns 1 steps·20 tok/s')
+    const timePill = view.getAllByRole('button')[0]!
+    expect(timePill.textContent).toBe('1 turns 1 steps·20 tok/s')
+    expect(timePill.getAttribute('aria-label')).toBe('1 turns 1 steps · 20 tok/s')
   })
 
   it('click-opens the time-and-speed dialog carrying the time split and speeds', () => {
@@ -259,7 +264,7 @@ describe('StatsPills', () => {
   })
 
   it('closes the dialog on Escape or outside pointerdown', () => {
-    const { source } = makeSource({ nodes: [assistant(1, 1)] })
+    const { source } = makeSource({ nodes: [timedStep()] })
     const view = render(<StatsPills {...props(source)} />)
     const timePill = view.getAllByRole('button')[0]!
 
@@ -277,6 +282,21 @@ describe('StatsPills', () => {
     expect(view.queryByRole('dialog')).toBeNull()
   })
 
+  it('keeps at most one dialog open: a sibling pill click swaps, never stacks', () => {
+    const { source } = makeSource({ nodes: [timedStep()] })
+    const view = render(<StatsPills {...props(source)} />)
+    const [timePill, usagePill] = [...view.getAllByRole('button')] as [HTMLElement, HTMLElement]
+
+    fireEvent.click(timePill)
+    expect(view.getByRole('dialog').getAttribute('aria-label')).toBe('Session statistics')
+    fireEvent.click(usagePill)
+    const dialogs = view.getAllByRole('dialog')
+    expect(dialogs).toHaveLength(1)
+    expect(dialogs[0]!.getAttribute('aria-label')).toBe('Token usage')
+    expect(timePill.getAttribute('aria-expanded')).toBe('false')
+    expect(usagePill.getAttribute('aria-expanded')).toBe('true')
+  })
+
   it('takes every pill and dialog label from the active locale', () => {
     const { source } = makeSource({ nodes: [timedStep()] })
     const view = render(<StatsPills {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} t={t} />)
@@ -313,7 +333,8 @@ describe('StatsPills', () => {
     const { source } = makeSource({ nodes: [assistant(1, 1)] })
     const view = render(<StatsPills {...props(source, {})} />)
     expect(view.container.textContent).toBe('1 turns 1 steps')
-    expect(view.getAllByRole('button')).toHaveLength(1)
+    // The untimed window has no dialog rows either, so no button renders at all.
+    expect(view.queryAllByRole('button')).toHaveLength(0)
   })
 
   it('renders whole-session counts from the sessionStats projection over the paged window', () => {
@@ -324,7 +345,7 @@ describe('StatsPills', () => {
       tokenUsage: USAGE,
       sessionStats: sessionStats({ turns: 10, steps: 89 }),
     })} />)
-    expect(view.getAllByRole('button')[0]!.textContent).toBe('10 turns 89 steps')
+    expect(view.getByText('10 turns 89 steps')).toBeTruthy()
   })
 
   it('treats a defined zero-count projection as empty, not as fallback', () => {
@@ -347,7 +368,7 @@ describe('StatsPills', () => {
       sessionStats: sessionStats({ turns: 1, steps: 1 }),
     })} />)
     expect(view.container.textContent).toBe('1 turns 1 steps')
-    expect(view.getAllByRole('button')).toHaveLength(1)
+    expect(view.queryAllByRole('button')).toHaveLength(0)
   })
 
   it('keeps the counts pill over an empty visible window when the projection carries totals', () => {
@@ -358,7 +379,7 @@ describe('StatsPills', () => {
       tokenUsage: USAGE,
       sessionStats: sessionStats({ turns: 7, steps: 44 }),
     })} />)
-    expect(view.getAllByRole('button')[0]!.textContent).toBe('7 turns 44 steps')
+    expect(view.getByText('7 turns 44 steps')).toBeTruthy()
   })
 
   it('renders whole-log speed and dialog figures from the projection, not the loaded window', () => {
@@ -388,8 +409,9 @@ describe('StatsPills', () => {
     const view = render(<StatsPills {...props(source, {
       tokenUsage: { uncachedInputTokens: 0, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 0 },
     })} />)
-    const usagePill = view.getAllByRole('button')[1]!
+    const usagePill = view.getAllByRole('button')[0]!
     expect(usagePill.textContent).toBe('7 tok')
+    expect(usagePill.getAttribute('aria-label')).toBe('7 tok')
     // Output-only activity still fills the dialog's token rows.
     fireEvent.click(usagePill)
     expect(view.getByRole('dialog').textContent).toContain('Output7 tok')
@@ -405,7 +427,7 @@ describe('StatsPills', () => {
         cacheWriteTokens: 100,
       },
     })} />)
-    expect(view.getAllByRole('button')[1]!.textContent).toBe('207 tok·Cache hit 45%')
+    expect(view.getAllByRole('button')[0]!.textContent).toBe('207 tok·Cache hit 45%')
   })
 
   it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {

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

@@ -104,7 +104,8 @@ describe('render branch tails', () => {
       />,
     )
     expect(view.container.textContent).toBe('2 轮 3 步')
-    expect(view.getAllByRole('button')).toHaveLength(1)
+    // Window-fold counts carry no timed figure, so the pill is a static reading.
+    expect(view.queryAllByRole('button')).toHaveLength(0)
   })
 
   it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {

+ 3 - 3
packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx

@@ -135,10 +135,10 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
             <span className={css.headline}>{headBefore}</span>
             <span className={css.percent}>{reading}</span>
             <span className={css.headline}>{headAfter}</span>
-            {/* No `~`: the headline figure is anchored to provider-reported
-                usage, unlike the heuristic composition rows below. */}
+            {/* `~`: usedTokens prefers projectedTokens, whose surface delta is
+                heuristically repriced on top of the provider-anchored sample. */}
             <span className={css.figures}>
-              {`${formatTokens(context.usedTokens, t)} / ${formatTokens(context.contextWindow, t)}`}
+              {`~${formatTokens(context.usedTokens, t)} / ${formatTokens(context.contextWindow, t)}`}
             </span>
           </div>
           <div className={css.bar}>

+ 4 - 4
packages/client/ui-conversation/tests/context-meter.client.spec.tsx

@@ -54,7 +54,7 @@ describe('ContextMeter', () => {
     expect(view.container.querySelector('[role="dialog"]')).toBeNull()
     fireEvent.click(trigger)
     const panel = view.container.querySelector('[role="dialog"]')!
-    expect(panel.textContent).toContain('32K / 128K')
+    expect(panel.textContent).toContain('~32K / 128K')
     expect(panel.textContent).toContain('25%')
     expect(panel.textContent).toContain('上下文已用')
     expect(panel.textContent).toContain('系统提示词~120')
@@ -94,7 +94,7 @@ describe('ContextMeter', () => {
     // `.segment` carries a min-width, so a zero-width part would still paint a
     // filled sliver over an empty context.
     expect(panel.getElementsByClassName(segmentClass)).toHaveLength(0)
-    expect(panel.textContent).toContain('0 / 128K')
+    expect(panel.textContent).toContain('~0 / 128K')
   })
 
   it('reads the ring from the projected figure so a compaction shows at once', () => {
@@ -106,14 +106,14 @@ describe('ContextMeter', () => {
     })
     const trigger = view.getByRole('button', { name: '上下文已用 2%' })
     fireEvent.click(trigger)
-    expect(view.container.querySelector('[role="dialog"]')!.textContent).toContain('3K / 128K')
+    expect(view.container.querySelector('[role="dialog"]')!.textContent).toContain('~3K / 128K')
   })
 
   it('omits the composition rows while the contextBreakdown projection is absent', () => {
     const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } })
     fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
     const panel = view.container.querySelector('[role="dialog"]')!
-    expect(panel.textContent).toContain('32K / 128K')
+    expect(panel.textContent).toContain('~32K / 128K')
     expect(panel.textContent).not.toContain('系统提示词')
     expect(panel.textContent).not.toContain('对话消息')
     // Without composition shares, the bar falls back to one plain segment.

+ 3 - 3
packages/client/ui-model-selection/src/client/ModelSelect.tsx

@@ -127,9 +127,9 @@ export function ModelSelect(
   // anchor rect, measured before paint, clamped inside the viewport): above
   // the trigger, right edges aligned. Depends on pane and directory state
   // because pane switches and async catalog loads resize the card.
-  /* jscpd:ignore-start -- deliberate mirror of ui-primitives Menu's clamped fixed
-     placement; this select keeps its own pane/reload lifecycle instead of adopting
-     Menu's item model, so only the measure-and-clamp plumbing repeats. */
+  /* jscpd:ignore-start -- deliberate mirror of ui-primitives useAnchoredPosition:
+     that hook only places from the anchor's LEFT edge, while this card aligns
+     right edges (x = rect.right - width), so the measure-and-clamp plumbing repeats. */
   useLayoutEffect(() => {
     if (!open) { setMenuPos(null); return }
     const place = (): void => {

+ 2 - 2
snapshots/web/bash-abort-row/ui.expected.md

@@ -36,9 +36,9 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "20 tokCache hit 0%":
+- button "20 tok · Cache hit 0%":
   - img
   - text: 20 tokCache hit 0%

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

@@ -128,6 +128,6 @@
 - button "3 turns 7 steps":
   - img
   - text: 3 turns 7 steps
-- button "66.8K tokCache hit 77%":
+- button "66.8K tok · Cache hit 77%":
   - img
   - text: 66.8K tokCache hit 77%

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

@@ -55,9 +55,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "7.8K tokCache hit 99%":
+- button "7.8K tok · Cache hit 99%":
   - img
   - text: 7.8K tokCache hit 99%

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

@@ -47,9 +47,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "7.8K tokCache hit 99%":
+- button "7.8K tok · Cache hit 99%":
   - img
   - text: 7.8K tokCache hit 99%

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

@@ -55,9 +55,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "7.8K tokCache hit 99%":
+- button "7.8K tok · Cache hit 99%":
   - img
   - text: 7.8K tokCache hit 99%

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

@@ -47,9 +47,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "7.8K tokCache hit 99%":
+- button "7.8K tok · Cache hit 99%":
   - img
   - text: 7.8K tokCache hit 99%

+ 2 - 2
snapshots/web/file-upload-round/ui.expected.md

@@ -49,9 +49,9 @@
   - img
 - button "1% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "17.3K tokCache hit 51%":
+- button "17.3K tok · Cache hit 51%":
   - img
   - text: 17.3K tokCache hit 51%

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

@@ -59,9 +59,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "15.8K tokCache hit 99%":
+- button "15.8K tok · Cache hit 99%":
   - img
   - text: 15.8K tokCache hit 99%

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

@@ -43,9 +43,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "15.8K tokCache hit 99%":
+- button "15.8K tok · Cache hit 99%":
   - img
   - text: 15.8K tokCache hit 99%

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

@@ -237,9 +237,9 @@
   - img
 - button "9% of context used"
 - button "Send message" [disabled]
-- button "2 turns 12 steps{{throughput}} tok/s":
+- button "2 turns 12 steps · {{throughput}} tok/s":
   - img
   - text: 2 turns 12 steps{{throughput}} tok/s
-- button "115K tokCache hit 91%":
+- button "115K tok · Cache hit 91%":
   - img
   - text: 115K tokCache hit 91%

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

@@ -125,9 +125,9 @@
   - img
 - button "9% of context used"
 - button "Send message" [disabled]
-- button "2 turns 12 steps{{throughput}} tok/s":
+- button "2 turns 12 steps · {{throughput}} tok/s":
   - img
   - text: 2 turns 12 steps{{throughput}} tok/s
-- button "115K tokCache hit 91%":
+- button "115K tok · Cache hit 91%":
   - img
   - text: 115K tokCache hit 91%

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

@@ -51,9 +51,9 @@
   - img
 - button "8% of context used"
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "10K tokCache hit 99.5%":
+- button "10K tok · Cache hit 99.5%":
   - img
   - text: 10K tokCache hit 99.5%

+ 2 - 2
snapshots/web/lifecycle-chrome/reloaded.expected.md

@@ -43,9 +43,9 @@
   - img
 - button "8% of context used"
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "10K tokCache hit 99.5%":
+- button "10K tok · Cache hit 99.5%":
   - img
   - text: 10K tokCache hit 99.5%

+ 2 - 3
snapshots/web/live-interactions/error-auth.expected.md

@@ -33,6 +33,5 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- button "1 turns 1 steps":
-  - img
-  - text: 1 turns 1 steps
+- img
+- text: 1 turns 1 steps

+ 2 - 3
snapshots/web/live-interactions/retry-exhausted.expected.md

@@ -35,6 +35,5 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- button "1 turns 1 steps":
-  - img
-  - text: 1 turns 1 steps
+- img
+- text: 1 turns 1 steps

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

@@ -53,9 +53,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "7.9K tokCache hit 99%":
+- button "7.9K tok · Cache hit 99%":
   - img
   - text: 7.9K tokCache hit 99%

+ 2 - 2
snapshots/web/live-interactions/retry.expected.md

@@ -43,9 +43,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "7.9K tokCache hit 99%":
+- button "7.9K tok · Cache hit 99%":
   - img
   - text: 7.9K tokCache hit 99%

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

@@ -72,9 +72,9 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- button "2 turns 3 steps{{throughput}} tok/s":
+- button "2 turns 3 steps · {{throughput}} tok/s":
   - img
   - text: 2 turns 3 steps{{throughput}} tok/s
-- button "7.9K tokCache hit 98%":
+- button "7.9K tok · Cache hit 98%":
   - img
   - text: 7.9K tokCache hit 98%

+ 2 - 2
snapshots/web/minimal-preset/ui.expected.md

@@ -48,9 +48,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "28 tokCache hit 0%":
+- button "28 tok · Cache hit 0%":
   - img
   - text: 28 tokCache hit 0%

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

@@ -69,9 +69,9 @@
   - img
 - button "4% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "10.6K tokCache hit 51%":
+- button "10.6K tok · Cache hit 51%":
   - img
   - text: 10.6K tokCache hit 51%

+ 2 - 3
snapshots/web/plan-review/approved.expected.md

@@ -48,11 +48,10 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "4% of context used"
-- tooltip "4% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "10.6K tokCache hit 51%":
+- button "10.6K tok · Cache hit 51%":
   - img
   - text: 10.6K tokCache hit 51%

+ 2 - 2
snapshots/web/ptc-round/ui.expected.md

@@ -70,9 +70,9 @@
   - img
 - button "7% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "17.5K tokCache hit 52%":
+- button "17.5K tok · Cache hit 52%":
   - img
   - text: 17.5K tokCache hit 52%

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

@@ -61,9 +61,9 @@
   - img
 - button "3% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "8.8K tokCache hit 95%":
+- button "8.8K tok · Cache hit 95%":
   - img
   - text: 8.8K tokCache hit 95%

+ 2 - 2
snapshots/web/question-composer/answered.expected.md

@@ -43,9 +43,9 @@
   - img
 - button "3% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "8.8K tokCache hit 95%":
+- button "8.8K tok · Cache hit 95%":
   - img
   - text: 8.8K tokCache hit 95%

+ 2 - 2
snapshots/web/question-composer/cancelled.expected.md

@@ -40,9 +40,9 @@
   - img
 - button "3% of context used"
 - button "Send message" [disabled]
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "4.4K tokCache hit 97%":
+- button "4.4K tok · Cache hit 97%":
   - img
   - text: 4.4K tokCache hit 97%

+ 2 - 2
snapshots/web/queued-image/delivered.expected.md

@@ -87,9 +87,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "3 turns 3 steps{{throughput}} tok/s":
+- button "3 turns 3 steps · {{throughput}} tok/s":
   - img
   - text: 3 turns 3 steps{{throughput}} tok/s
-- button "15.7K tokCache hit 99%":
+- button "15.7K tok · Cache hit 99%":
   - img
   - text: 15.7K tokCache hit 99%

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

@@ -68,9 +68,9 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "16K tokCache hit 98%":
+- button "16K tok · Cache hit 98%":
   - img
   - text: 16K tokCache hit 98%

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

@@ -72,9 +72,9 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "16K tokCache hit 98%":
+- button "16K tok · Cache hit 98%":
   - img
   - text: 16K tokCache hit 98%

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

@@ -66,9 +66,9 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "16K tokCache hit 98%":
+- button "16K tok · Cache hit 98%":
   - img
   - text: 16K tokCache hit 98%

+ 2 - 2
snapshots/web/seeded-history/ui.expected.md

@@ -48,9 +48,9 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "16K tokCache hit 98%":
+- button "16K tok · Cache hit 98%":
   - img
   - text: 16K tokCache hit 98%

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

@@ -61,9 +61,9 @@
   - text: DeepSeek-V4-Flash
   - img
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "310 tokCache hit 0%":
+- button "310 tok · Cache hit 0%":
   - img
   - text: 310 tokCache hit 0%

+ 2 - 2
snapshots/web/steering/settled-expanded.expected.md

@@ -54,9 +54,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "30 tokCache hit 0%":
+- button "30 tok · Cache hit 0%":
   - img
   - text: 30 tokCache hit 0%

+ 2 - 2
snapshots/web/steering/settled.expected.md

@@ -46,9 +46,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "30 tokCache hit 0%":
+- button "30 tok · Cache hit 0%":
   - img
   - text: 30 tokCache hit 0%

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

@@ -78,9 +78,9 @@
 - 'button "Access mode, current: Custom"': Custom
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "2 turns 2 steps{{throughput}} tok/s":
+- button "2 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 2 turns 2 steps{{throughput}} tok/s
-- button "15.7K tokCache hit 99%":
+- button "15.7K tok · Cache hit 99%":
   - img
   - text: 15.7K tokCache hit 99%

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

@@ -66,9 +66,9 @@
 - 'button "Access mode, current: Custom"': Custom
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "2 turns 2 steps{{throughput}} tok/s":
+- button "2 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 2 turns 2 steps{{throughput}} tok/s
-- button "15.7K tokCache hit 99%":
+- button "15.7K tok · Cache hit 99%":
   - img
   - text: 15.7K tokCache hit 99%

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

@@ -46,9 +46,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "15.8K tokCache hit 50%":
+- button "15.8K tok · Cache hit 50%":
   - img
   - text: 15.8K tokCache hit 50%

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

@@ -59,9 +59,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "15.8K tokCache hit 50%":
+- button "15.8K tok · Cache hit 50%":
   - img
   - text: 15.8K tokCache hit 50%

+ 2 - 2
snapshots/web/turn-tail-actions/running.expected.md

@@ -43,9 +43,9 @@
   - img
 - button "6% of context used"
 - button "Stop generating"
-- button "1 turns 1 steps{{throughput}} tok/s":
+- button "1 turns 1 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 1 steps{{throughput}} tok/s
-- button "7.9K tokCache hit 0%":
+- button "7.9K tok · Cache hit 0%":
   - img
   - text: 7.9K tokCache hit 0%

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

@@ -45,9 +45,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "7.9K tokCache hit 0%":
+- button "7.9K tok · Cache hit 0%":
   - img
   - text: 7.9K tokCache hit 0%

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

@@ -46,9 +46,9 @@
   - img
 - button "6% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "15.8K tokCache hit 50%":
+- button "15.8K tok · Cache hit 50%":
   - img
   - text: 15.8K tokCache hit 50%

+ 2 - 2
snapshots/web/web-search-round/ui.expected.md

@@ -51,9 +51,9 @@
   - img
 - button "0% of context used"
 - button "Send message" [disabled]
-- button "1 turns 2 steps{{throughput}} tok/s":
+- button "1 turns 2 steps · {{throughput}} tok/s":
   - img
   - text: 1 turns 2 steps{{throughput}} tok/s
-- button "29 tokCache hit 0%":
+- button "29 tok · Cache hit 0%":
   - img
   - text: 29 tokCache hit 0%