Преглед изворни кода

Merge pull request #3265 from deepseek-harness/fix/web-ui-polish

fix(client): Web 会话与输入 UI 细节修复
Yifffan пре 1 месец
родитељ
комит
15f66b9c3c
32 измењених фајлова са 364 додато и 565 уклоњено
  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/simplification/2026-08-28-remove-hero-input-glow.i18n.yaml
  7. 29 0
      .agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.md
  8. 29 0
      .agents/notes/implemented/simplification/2026-08-28-remove-hero-input-glow.zh.md
  9. 0 344
      apps/web/tests/conversation-column-overflow.e2e.ts
  10. 3 3
      apps/web/tests/expected/composer-tab-geometry/geometry.expected.md
  11. 0 9
      apps/web/tests/expected/conversation-column-overflow/geometry.expected.md
  12. 6 0
      apps/web/tests/reference-composer.e2e.ts
  13. 3 1
      packages/client/ui-chat/src/client/chat/ChatView.module.css
  14. 25 31
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
  15. 18 17
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  16. 1 37
      packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx
  17. 1 3
      packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css
  18. 17 4
      packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
  19. 7 3
      packages/client/ui-input-trigger/src/client/MenuView.module.css
  20. 4 2
      packages/client/ui-input-trigger/src/client/MenuView.tsx
  21. 5 0
      packages/client/ui-input-trigger/src/client/controller.ts
  22. 11 3
      packages/client/ui-input-trigger/src/core/menu.ts
  23. 9 3
      packages/client/ui-input-trigger/tests/core-menu.client.spec.ts
  24. 9 0
      packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx
  25. 42 0
      packages/client/ui-input-trigger/tests/service.client.spec.ts
  26. 23 9
      packages/client/ui-primitives/src/DiffBlock.tsx
  27. 1 1
      packages/client/ui-primitives/src/index.ts
  28. 16 9
      packages/client/ui-primitives/src/user-text.module.css
  29. 3 3
      packages/client/ui-primitives/tests/user-text-styles.client.spec.ts
  30. 24 4
      packages/client/ui-tool/src/client/tool/components/ToolRow.module.css
  31. 13 2
      packages/client/ui-tool/src/client/tool/components/ToolRow.tsx
  32. 2 0
      packages/client/ui-tool/tests/diff-card.client.spec.tsx

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

+ 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

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

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

+ 1 - 37
packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx

@@ -1,7 +1,6 @@
 // The composer remains in ConversationRoot so switching out of the blank-draft
 // phase does not remount its textarea.
 
-import { useId } from 'react'
 import type { ReactNode, RefObject } from 'react'
 import {
   FishLogo, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16,
@@ -61,40 +60,6 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick, t }
   )
 }
 
-/**
- * The soft blue backdrop ellipse (figma 313:14109). Rendered by the hero
- * owner (ConversationRoot), not HeroShell, so it can center on the input
- * card; the owner's className supplies all positioning.
- * @param props.className - positioning class from the owner.
- * @returns the blurred-ellipse svg element.
- */
-export function HeroGlow({ className }: { className?: string | undefined }) {
-  // Stable filter id so multiple hero mounts do not collide in the DOM.
-  const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}`
-  return (
-    <svg className={className} viewBox="0 0 1051 468" fill="none" aria-hidden="true">
-      <defs>
-        <filter
-          id={glowFilterId}
-          x="0"
-          y="0"
-          width="1051"
-          height="468"
-          filterUnits="userSpaceOnUse"
-          colorInterpolationFilters="sRGB"
-        >
-          <feFlood floodOpacity="0" result="BackgroundImageFix" />
-          <feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
-          <feGaussianBlur stdDeviation="50" result="effect1_foregroundBlur" />
-        </filter>
-      </defs>
-      <g filter={`url(#${glowFilterId})`}>
-        <ellipse cx="525.5" cy="234" rx="425.5" ry="134" fill="#6187D8" fillOpacity="0.08" />
-      </g>
-    </svg>
-  )
-}
-
 /** Hero chrome props. The workspace row rides the InputBar accessory hole, not here. */
 export interface HeroShellProps {
   /** The owner's locale seat, passed down as a plain prop. */
@@ -106,8 +71,7 @@ export interface HeroShellProps {
 }
 
 /**
- * Render the hero chrome (headline only; no glow, no composer, no workspace
- * row — the glow is the owner's {@link HeroGlow}).
+ * Render the hero chrome (headline only; no composer, no workspace row).
  * @param props - see {@link HeroShellProps}.
  * @returns the centered hero element tree.
  */

+ 1 - 3
packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css

@@ -96,9 +96,7 @@
   }
 }
 
-/* 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;

+ 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', () => {

+ 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 }
 }
 
 /**

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

@@ -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 {

+ 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;
 }

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

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