Răsfoiți Sursa

Merge pull request #3807 from deepseek-harness/sidebar-ui-polish-main

feat(web): right sidebar UI polish
imccyu 4 zile în urmă
părinte
comite
88dfd72537
100 a modificat fișierele cu 2370 adăugiri și 616 ștergeri
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml
  2. 25 0
      .agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md
  3. 25 0
      .agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml
  5. 4 4
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
  6. 4 4
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md
  7. 2 2
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml
  8. 4 6
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md
  9. 4 6
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md
  10. 6 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml
  11. 27 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md
  12. 27 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md
  13. 1 1
      apps/web/tests/agent-preset-selection.e2e.ts
  14. 6 4
      apps/web/tests/document-preview.e2e.ts
  15. 2 3
      apps/web/tests/expected/agent-preset-selection/header.expected.md
  16. 2 3
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  17. 2 3
      apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
  18. 2 3
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  19. 2 3
      apps/web/tests/expected/goal-command-presentation/ui.expected.md
  20. 2 3
      apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
  21. 2 3
      apps/web/tests/expected/markdown-images/ui.expected.md
  22. 2 3
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  23. 2 3
      apps/web/tests/expected/math-rendering/ui.expected.md
  24. 2 3
      apps/web/tests/expected/reference-composer/order.expected.md
  25. 2 3
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  26. 2 3
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  27. 2 3
      apps/web/tests/expected/stats-paged-history/ui.expected.md
  28. 2 3
      apps/web/tests/expected/steer-all/mid-steer.expected.md
  29. 2 3
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  30. 2 3
      apps/web/tests/expected/steer-all/settled.expected.md
  31. 3 2
      apps/web/tests/navigation-panes.e2e.ts
  32. 1 1
      apps/web/tests/seeded-history.e2e.ts
  33. 121 29
      apps/web/tests/sidebar-right.e2e.ts
  34. 2 3
      apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md
  35. 3 4
      packages/client/ui-conversation/src/client/contract/slots.ts
  36. 32 25
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
  37. 2 2
      packages/client/ui-dockkit/README.i18n.yaml
  38. 6 7
      packages/client/ui-dockkit/README.md
  39. 8 10
      packages/client/ui-dockkit/README.zh.md
  40. 1 0
      packages/client/ui-dockkit/package.json
  41. 14 8
      packages/client/ui-dockkit/src/components/DockSurface.tsx
  42. 30 19
      packages/client/ui-dockkit/src/components/FloatLayer.tsx
  43. 12 10
      packages/client/ui-dockkit/src/components/TabMenu.tsx
  44. 26 11
      packages/client/ui-dockkit/src/components/TabPanel.tsx
  45. 37 0
      packages/client/ui-dockkit/src/components/TabTitle.tsx
  46. 401 99
      packages/client/ui-dockkit/src/components/dockkit.module.css
  47. 21 1
      packages/client/ui-dockkit/src/components/measure.ts
  48. 3 3
      packages/client/ui-dockkit/src/components/render.ts
  49. 5 1
      packages/client/ui-dockkit/src/contract/adapter.ts
  50. 23 10
      packages/client/ui-dockkit/src/engine/geometry.ts
  51. 282 19
      packages/client/ui-dockkit/tests/components.client.spec.tsx
  52. 1 0
      packages/client/ui-dockkit/tests/fixtures.client.ts
  53. 13 13
      packages/client/ui-dockkit/tests/geometry.client.spec.ts
  54. 6 6
      packages/client/ui-dockkit/tests/measure.client.spec.ts
  55. 3 0
      packages/client/ui-dockkit/tsconfig.json
  56. 4 4
      packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css
  57. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  58. 1 1
      packages/client/ui-primitives/README.md
  59. 1 1
      packages/client/ui-primitives/README.zh.md
  60. 167 0
      packages/client/ui-primitives/src/FileTypeIcon.tsx
  61. 12 22
      packages/client/ui-primitives/src/LinkIcon.tsx
  62. 59 0
      packages/client/ui-primitives/src/file-extensions.ts
  63. 3 1
      packages/client/ui-primitives/src/icons/index.tsx
  64. 2 0
      packages/client/ui-primitives/src/index.ts
  65. 61 0
      packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx
  66. 2 2
      packages/client/ui-sidebar-documentpreview/README.i18n.yaml
  67. 4 4
      packages/client/ui-sidebar-documentpreview/README.md
  68. 4 4
      packages/client/ui-sidebar-documentpreview/README.zh.md
  69. 133 17
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css
  70. 96 40
      packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx
  71. 24 0
      packages/client/ui-sidebar-documentpreview/src/client/TextTitle.tsx
  72. 27 11
      packages/client/ui-sidebar-documentpreview/src/client/icons.tsx
  73. 9 3
      packages/client/ui-sidebar-documentpreview/src/client/index.ts
  74. 16 12
      packages/client/ui-sidebar-documentpreview/src/client/locales.ts
  75. 6 4
      packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts
  76. 92 1
      packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx
  77. 28 0
      packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx
  78. 2 2
      packages/client/ui-sidebar-files/README.i18n.yaml
  79. 4 3
      packages/client/ui-sidebar-files/README.md
  80. 4 3
      packages/client/ui-sidebar-files/README.zh.md
  81. 101 21
      packages/client/ui-sidebar-files/src/client/FilesBody.module.css
  82. 57 13
      packages/client/ui-sidebar-files/src/client/FilesBody.tsx
  83. 37 0
      packages/client/ui-sidebar-files/src/client/FilesTitle.tsx
  84. 0 1
      packages/client/ui-sidebar-files/src/client/definition.ts
  85. 10 4
      packages/client/ui-sidebar-files/src/client/index.ts
  86. 3 5
      packages/client/ui-sidebar-files/src/client/locales.ts
  87. 4 2
      packages/client/ui-sidebar-files/tests/apply.client.spec.ts
  88. 0 1
      packages/client/ui-sidebar-files/tests/definition.client.spec.ts
  89. 57 5
      packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx
  90. 22 0
      packages/client/ui-sidebar-files/tests/files-title.client.spec.tsx
  91. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  92. 5 4
      packages/client/ui-sidebar-right/README.md
  93. 5 4
      packages/client/ui-sidebar-right/README.zh.md
  94. 1 2
      packages/client/ui-sidebar-right/src/client/contract/seed.ts
  95. 6 1
      packages/client/ui-sidebar-right/src/client/index.ts
  96. 7 0
      packages/client/ui-sidebar-right/src/client/labels.ts
  97. 27 17
      packages/client/ui-sidebar-right/src/client/locales.ts
  98. 3 3
      packages/client/ui-sidebar-right/src/client/service.ts
  99. 11 16
      packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css
  100. 18 18
      packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md
+2026-09-08-stable-room-reading-under-hidden-split-controls.md: c89c5328ee896e23ed24454c50223193c2d587ba
+2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md: edf9948e547b79b33165b9c7168238e6cf1d2685

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.md

@@ -0,0 +1,25 @@
+# Agent Note: Keep the room reading independent of hidden split controls
+
+Status: implemented
+
+English | [中文](2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md)
+
+## Problem
+
+The dockkit room rule measures each pane's tab strip after every commit to decide whether an equal split leaves two working halves. With `hideSplitWhenBlocked`, a width-blocked pane unmounts its split control — and the unmount changes the very strip the rule measured: the strip sheds the control's 28px box plus its 4px gap, the fixed part shrinks, and the same pane reads as fitting again. Remounting the control reverses the reading. Across a roughly 32px band of pane widths the two states alternate inside nested layout effects until React stops the update loop (error #185); the slot runtime catches the crash and unmounts the Sidebar's entry while the column still records itself expanded, so neither the panel nor the header's collapsed-only expand button renders. A grip drag on a squeezed viewport sweeps the panel through that band, which presented as the whole sidebar vanishing with no way back in.
+
+## Decision
+
+When the embedder hides blocked split controls, the room rule leaves the split control's footprint out of the strip's fixed part unconditionally, so the reading is the same whether the control is currently mounted or not. [`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) takes the embedder's `hideSplitWhenBlocked` choice, measures the rendered control's box plus the strip's column gap (`splitControlFootprint`), and passes it as [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts), which `halvesFit` subtracts from the fixed part. Excluding the footprint is also correct on its own terms: a half too narrow to split would hide its own control, so the footprint is not part of what a half must carry. Embedders that render blocked controls disabled pass nothing and keep the control in the fixed part, as before.
+
+## Alternatives considered
+
+**Hide only budget-blocked controls, render width-blocked ones disabled.** This is what the code did before `hideSplitAtCapacity` widened into `hideSplitWhenBlocked`: the budget is state-driven and cannot feed back through the measurement. It avoids the loop but forfeits the Sidebar's requested presentation — no disabled split control on panes that cannot split.
+
+**Debounce or freeze re-measurement during oscillation.** Damping hides the instability instead of removing it: the reading would still depend on the control's visibility, settle on an arbitrary one of the two states, and flip on the next resize.
+
+**Measure the control's footprint from a constant.** A hardcoded 32px drifts from the stylesheet; measuring the rendered control and the strip's real `column-gap` keeps the subtraction equal to what the strip actually sheds, which is the exact condition for a stable reading.
+
+## Consequences
+
+The room reading is a fixed point under control visibility, so `hideSplitWhenBlocked` embedders get hidden controls without feedback. Panes near the boundary now read as splittable slightly earlier than a disabled-control embedder would report, because the half being asked about would not carry the control. A [dockkit regression test](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) emulates the strip shedding the control's footprint and fails with React's update-depth error on the unfixed code; a [Sidebar browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) drags the panel grip past both clamps on a squeezed viewport and asserts the panel, its grip, and a clean console survive, because the crash surfaces only as a console error the scaffold tripwire does not watch.

+ 25 - 0
.agents/notes/implemented/bug-fix/2026-09-08-stable-room-reading-under-hidden-split-controls.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: Keep the room reading independent of hidden split controls
+
+Status: implemented
+
+[English](2026-09-08-stable-room-reading-under-hidden-split-controls.md) | 中文
+
+## Problem
+
+dockkit 的空间规则在每次 commit 后测量各 pane 的标签条,判断等分后的两半是否仍可用。开启 `hideSplitWhenBlocked` 时,宽度不足的 pane 会卸载自己的分屏控件——而这次卸载恰恰改变了规则所测量的标签条:标签条少了控件的 28px 盒子加 4px 间距,固定部分随之变小,同一个 pane 又被读成"够宽"。控件重新挂载后读数再次反转。在约 32px 的 pane 宽度区间内,两种状态在嵌套 layout effect 中来回切换,直到 React 中止更新循环(错误 #185);slot 运行时捕获崩溃后卸载 Sidebar 的条目,而列状态仍记录为展开,于是面板和 header 上仅折叠时显示的展开按钮都不再渲染。在收窄的视口上拖动把手会让面板扫过该区间,表现为整个侧栏消失且无法再打开。
+
+## Decision
+
+当嵌入方选择隐藏被阻止的分屏控件时,空间规则无条件将分屏控件的占位排除在标签条固定部分之外,使读数与控件当前是否挂载无关。[`measurePaneFits`](../../../../packages/client/ui-dockkit/src/components/measure.ts) 接收嵌入方的 `hideSplitWhenBlocked` 选择,测量已渲染控件的盒子加标签条的列间距(`splitControlFootprint`),并作为 [`PaneMeasure.splitControlWidth`](../../../../packages/client/ui-dockkit/src/engine/geometry.ts) 传入,由 `halvesFit` 从固定部分中减去。排除该占位本身也是正确的:窄到无法分屏的一半会隐藏自己的控件,所以这份占位并不属于一半必须承载的内容。将被阻止控件渲染为禁用态的嵌入方不传该值,控件照旧计入固定部分。
+
+## Alternatives considered
+
+**只隐藏预算受限的控件,宽度受限的渲染为禁用态。** 这是 `hideSplitAtCapacity` 扩展为 `hideSplitWhenBlocked` 之前的做法:预算由状态驱动,不会经测量反馈回来。它避免了循环,但放弃了 Sidebar 想要的呈现——无法分屏的 pane 上不出现禁用的分屏控件。
+
+**在振荡期间对重新测量做防抖或冻结。** 阻尼只是掩盖不稳定而非消除它:读数仍依赖控件的可见性,会任意停在两种状态之一,并在下次 resize 时再次翻转。
+
+**用常量表示控件占位。** 硬编码的 32px 会与样式表漂移;测量实际渲染的控件和标签条真实的 `column-gap`,才能保证减去的量恰好等于标签条实际卸下的量,这正是读数稳定的确切条件。
+
+## Consequences
+
+空间读数在控件可见性变化下是不动点,`hideSplitWhenBlocked` 的嵌入方获得隐藏控件的呈现且无反馈循环。临界宽度附近的 pane 会比禁用态嵌入方的报告稍早读成可分屏,因为被询问的那一半不会承载控件。[dockkit 回归测试](../../../../packages/client/ui-dockkit/tests/components.client.spec.tsx) 模拟标签条卸下控件占位的反馈,在未修复的代码上以 React 更新深度错误失败;[Sidebar 浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts) 在收窄视口上把面板把手拖过两侧钳位,断言面板、把手与干净的 console 均存活——崩溃只以 console error 形式出现,而脚手架的 tripwire 不监听它。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.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-04-right-sidebar-docking-infrastructure.md
-2026-09-04-right-sidebar-docking-infrastructure.md: 3600a17fdd0659c4f235922fa1bc905f03f5841e
-2026-09-04-right-sidebar-docking-infrastructure.zh.md: 2a9ddfb7ec1fa34cefe434692829939767a6b710
+2026-09-04-right-sidebar-docking-infrastructure.md: 3f6bcff565598c4d8a24e374d308c388c1f6bb0a
+2026-09-04-right-sidebar-docking-infrastructure.zh.md: aea7aacc4cb50b8974ada4a7e7eec27ed41623b9

+ 4 - 4
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md

@@ -37,9 +37,9 @@ The right Sidebar uses one mounted content tree in normal and fullscreen modes;
 
 ### State
 
-[Default pages and close protection](2026-09-08-sidebar-default-pages.md) supersedes explicit last-tab closing and default-guide reseeding here; moving tabs still settles emptied panes.
+[Default pages](2026-09-08-sidebar-default-pages.md) supersede default-guide reseeding here; [last-tab close rules](2026-09-08-sidebar-last-tab-close-rules.md) own explicit closing, while moving tabs still settles emptied panes.
 
-`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the guide tab is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log.
+`ui-sidebar-right` keeps one `SurfaceState` per session id — the layout, its history, and the mint counter — in a store declared at the seat registration. Every action mints the ids its intent needs, asks a kit planner for the operations, runs the settle planner over the result, and records the whole intent as one history entry before assigning the session's surface back; no action edits a layout in place. The settle step is the product's rule: a docked pane whose last tab is closed, moved out, or floated is merged away, and when only the root pane remains and it is empty, the current default page is reseeded — there is always at least one tab and never an empty pane, so no pane-closing gesture exists. State is memory-only: a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was. Layout is presentation state and never enters the session log.
 
 ### Beyond the surface
 
@@ -69,7 +69,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind
 
 **Undo and redo buttons on the panel header.** Shipped first, then removed: the sequence is an architectural fact, and stepping it is not a product action yet. The API stays reachable as `@internal` methods for tests and the future navigation controller.
 
-**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent now settles the surface so an emptied pane is merged away and an emptied root pane reseeds the guide.
+**Empty panes as a persistent state.** The first design allowed a pane to stay after its last tab left, with a placeholder. Rejected because nothing offered a way to close such a pane; every intent settles the surface so an emptied pane is merged away and an emptied root pane reseeds the current default page.
 
 **Inline the kit through `packages/util` and the `INLINE_SAFE` list.** A build probe showed it works, but the util build chain has no CSS pipeline and the kit ships a stylesheet; the static-linked client package (the `ui-primitives` precedent) was chosen knowing that changing the kit means rebuilding the shell and reloading.
 
@@ -77,7 +77,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind
 
 - The docking surface itself no longer overflows its panel: `.surface` and `.pane` clamp to the column (`min-width: 0`, `overflow: hidden`), so a long unwrapped line scrolls inside the body and the strip's controls stay in view in every split.
 - Layout is undoable and per session, and it is memory-only; a reload starts every session collapsed. Undo is reachable only through `@internal` service methods; the product shows no history controls.
-- A pane cannot be left empty and the surface cannot be left tabless: closing, moving out, or floating a pane's last tab drops the pane, and emptying the last pane brings the guide back.
+- A pane cannot be left empty and the surface cannot be left tabless: closing, moving out, or floating a pane's last tab drops the pane, and emptying the last pane restores the current default page.
 - A pane holds at most one guide tab: a second one cannot be added, opened, duplicated, or moved in; the guide's uniqueness is per pane, so a split still seeds its new pane with a guide.
 - A pane may split only when each equal half can still hold what cannot shrink: the strip's fixed controls (its width minus the chip box and the fill, so the top-right pane's chrome counts on the half that hosts it) plus one chip at its minimum, measured in the component layer after every commit and on resize. Otherwise the split control stays, disabled with its own copy, the matching edge drop zones are withheld, and panes the user narrows keep their size; the product permits at most two horizontal panes, regardless of widening or divider movement.
 - The Sidebar panel never moves when the presentation switches, and its slide is the same in both presentations; the conversation is the only thing that animates on a switch. A hidden panel keeps its tabs mounted, so a preview survives a collapse.

+ 4 - 4
.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md

@@ -37,9 +37,9 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
 
 ### 状态
 
-[默认页与关闭保护](2026-09-08-sidebar-default-pages.zh.md)取代此处的显式关闭最后一个 tab 和默认补入引导页;移动 tab 仍会处理被清空的格。
+[默认页](2026-09-08-sidebar-default-pages.zh.md)取代此处的默认补入引导页;[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责显式关闭,移动 tab 仍会处理被清空的格。
 
-`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上引导 tab——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。
+`ui-sidebar-right` 为每个会话 id 保存一份 `SurfaceState`——布局、历史与铸造计数——住在坑位注册时声明的 store 里。每个 action 先铸造意图所需的 id,向库的 planner 索取操作,对结果跑一遍 settle planner,把整个意图记为一条历史账,再把该会话的 surface 整体赋回;没有 action 就地改布局。settle 是产品规则:最后一个 tab 被关闭、拖走或悬浮出去的停靠 pane 会被合并掉;只剩根 pane 且为空时重新种上当前默认页——永远至少有一个 tab、永远没有空 pane,所以不存在"关闭 pane"手势。状态仅在内存:刷新使所有会话回到折叠默认态,切换会话时各 surface 保持原样。布局是呈现状态,永不进入会话日志。
 
 ### 面之外
 
@@ -69,7 +69,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
 
 **面板头部的 undo 与 redo 按钮。** 先上后撤:序列是架构事实,步进它现在还不是产品动作。API 以 `@internal` 方法保留给测试与将来的导航控制器。
 
-**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;现在每个意图都会整理 surface,被清空的 pane 合并掉,被清空的根 pane 重新种上引导
+**空 pane 作为一种持久状态。** 第一版允许 pane 在最后一个 tab 离开后带占位留下。否决,因为没有任何方式关掉这样的 pane;每个意图都会整理 surface,被清空的 pane 合并掉,被清空的根 pane 重新种上当前默认页
 
 **经 `packages/util` 与 `INLINE_SAFE` 清单内联库。** 构建探针证明可行,但 util 构建链没有 CSS 管线而库带样式表;在知晓改库须重建壳并刷新页面的前提下,选择静态链接的 client 包(`ui-primitives` 先例)。
 
@@ -77,7 +77,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
 
 - 停靠面自身不再溢出面板:`.surface` 与 `.pane` 收在列内(`min-width: 0`、`overflow: hidden`),长的不换行行在正文内滚动,tab 条控件在任何分栏下都可见。
 - 布局可撤销且按会话隔离,同时仅在内存;刷新使所有会话回到折叠态。undo 只能经 `@internal` 服务方法触达;产品不显示历史控件。
-- pane 不能留空、surface 不能没有 tab:关闭、拖走或悬浮出 pane 的最后一个 tab 会删掉该 pane,清空最后一个 pane 会让引导回来
+- pane 不能留空、surface 不能没有 tab:关闭、拖走或悬浮出 pane 的最后一个 tab 会删掉该 pane,清空最后一个 pane 会恢复当前默认页
 - 一个 pane 最多持有一个引导 tab:第二个不能被添加、打开、复制或搬入;唯一性按 pane 算,所以分栏仍给新 pane 种引导。
 - pane 只有在等分后的两半都仍能容下不可收缩部分时才可分栏:tab 条的固定控件(条宽减去 chip 盒与填充,因此右上 pane 的面板控件只计在承载它的那一半)加一个最小宽度的 chip,由组件层在每次提交与尺寸变化后测量。否则分栏控件保留但禁用并带自己的文案,对应的边缘落区不再提供,用户拖窄的 pane 保持原尺寸;产品最多两个水平窗格,不因拉宽或拖分隔条而提高上限。
 - 切换呈现模式时 Sidebar 面板一动不动,两种模式的平移一模一样;切换时只有会话区在动。隐藏的面板保持 tab 挂载,预览在折叠后仍在。

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.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-08-sidebar-default-pages.md
-2026-09-08-sidebar-default-pages.md: c78d6a3af2c88c61ee5ba8fe4319d9b3f59ae7b4
-2026-09-08-sidebar-default-pages.zh.md: 8e0a081cb25862bffc20fdcac2b97c57b0080c36
+2026-09-08-sidebar-default-pages.md: 13374ab294cab74b54ada8550a6dff5af2cd9cd1
+2026-09-08-sidebar-default-pages.zh.md: 6730a87fbe9c59ef6d448fe0f2d4c3c2cb75e6bb

+ 4 - 6
.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md

@@ -1,4 +1,4 @@
-# Agent Note: Sidebar default pages and close protection
+# Agent Note: Sidebar default pages
 
 Status: implemented
 
@@ -6,13 +6,13 @@ English | [中文](2026-09-08-sidebar-default-pages.zh.md)
 
 ## Problem
 
-A guide with one registered entry adds a click without offering a choice. Hiding only the last tab's close button would let an added guide make the default file browser closable again.
+A guide with one registered entry adds a click without offering a choice.
 
 ## Decision
 
 The Sidebar selects each default page from the registered guide-entry list. Exactly one entry opens that entry's page; zero or multiple entries open the guide. Resource viewers without guide entries do not affect this count. Explicitly adding a guide always opens a guide, and each pane holds at most one.
 
-A single-entry default is protected from explicit close for its record lifetime. Every pane's final tab is also protected; other tabs can close. The Sidebar stores protected record IDs and shares one close predicate between its store actions and docking controls. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only.
+The [last-tab close rule](2026-09-08-sidebar-last-tab-close-rules.md) owns close protection: the sole docked guide remains open, while any other sole tab closes together with the column. The generic docking kit accepts a presentation callback and has no file-browser or guide policy. Moving tabs still settles empty panes, and layout state remains memory-only.
 
 This replaces default-guide selection in [the shipped types](2026-09-05-sidebar-text-preview-and-file-tree.md) and explicit last-tab closing in [docking infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, content-state, engine and layout ownership decisions remain active.
 
@@ -20,8 +20,6 @@ This replaces default-guide selection in [the shipped types](2026-09-05-sidebar-
 
 **Count all registered tab types or currently open tabs.** Neither counts choices available on the guide; resource viewers need not contribute an entry.
 
-**Protect only the final tab.** Adding a guide would expose a close control on the single-entry default, violating its retained-entry behavior.
-
 ## Consequences
 
-One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab, while ordinary close cannot empty a pane. Store and component tests cover registration counts and close protection; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing the guide.
+One-entry compositions open directly into their registered page without hardcoding Files. Guide selection can still replace its own tab. Store and component tests cover registration counts; the assembled browser scenarios cover default Files, explicit guide creation, and returning to Files after closing a lone tab.

+ 4 - 6
.agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md

@@ -1,4 +1,4 @@
-# Agent Note: Sidebar 默认页与关闭保护
+# Agent Note: Sidebar 默认页
 
 Status: implemented
 
@@ -6,13 +6,13 @@ Status: implemented
 
 ## 问题
 
-只有一个注册入口的引导页增加一次点击,却不提供选择。若只隐藏最后一个 tab 的关闭按钮,新增引导页后,默认文件浏览页又会变得可关闭。
+只有一个注册入口的引导页增加一次点击,却不提供选择。
 
 ## 决策
 
 Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入口时打开对应页面;没有入口或有多个入口时打开引导页。没有引导入口的资源查看器不影响计数。显式添加引导页始终打开引导,每个格最多持有一个。
 
-单入口默认页在记录生命周期内受到显式关闭保护。每个格的最后一个 tab 也受保护;其他 tab 可以关闭。Sidebar 保存受保护的记录 ID,store 动作与停靠控件共享一个关闭判定。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。
+[最后一个 tab 的关闭规则](2026-09-08-sidebar-last-tab-close-rules.zh.md)负责关闭保护:作为唯一停靠 tab 的引导页保持打开,其他任何唯一 tab 都会连同整列一起关闭。通用停靠套件接收呈现回调,不拥有文件浏览器或引导页策略。移动 tab 仍会处理空格,布局状态仅存于内存。
 
 本决策取代[随包类型](2026-09-05-sidebar-text-preview-and-file-tree.zh.md)中的默认引导选择,以及[停靠基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的显式关闭最后一个 tab。它们的注册、内容状态、引擎与布局所有权决策继续有效。
 
@@ -20,8 +20,6 @@ Sidebar 从已注册的引导入口列表选择每个默认页。恰好一个入
 
 **统计所有已注册 tab 类型或已打开的 tab。** 两者都不代表引导页提供的选择;资源查看器不一定贡献入口。
 
-**只保护最后一个 tab。** 新增引导页后,单入口默认页会出现关闭控件,违反保留该入口的行为要求。
-
 ## 后果
 
-单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab,普通关闭则不能清空一个格。store 与组件测试覆盖注册数量和关闭保护;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭引导后返回 Files。
+单入口组合直接打开已注册页面,不写死 Files。引导页选择仍可替换自身 tab。store 与组件测试覆盖注册数量;组装后的浏览器场景覆盖默认 Files、显式新增引导及关闭唯一 tab 后返回 Files。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md
+2026-09-08-sidebar-last-tab-close-rules.md: 4fb0e54af1075dd595d18db4d74779a7cfd2656b
+2026-09-08-sidebar-last-tab-close-rules.zh.md: 87cadf0f15eecb0cadc2fb2dacc5695771fd66f7

+ 27 - 0
.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md

@@ -0,0 +1,27 @@
+# Agent Note: Last-tab close rules on the Sidebar's docked surface
+
+Status: implemented
+
+English | [中文](2026-09-08-sidebar-last-tab-close-rules.zh.md)
+
+## Problem
+
+The settle planner guarantees the docked surface is never empty: closing the last tab reseeds the current default page. That guarantee made the last tab's close control a dead end in both directions. Closing the guide standing alone put the same guide straight back — a control that does nothing. Closing any other lone tab left the user with a column showing only the default page — after "close the last thing", an expanded panel with nothing in it is not what the gesture meant. The guide's chip also drew a hover capsule and a context menu whose only item was that no-op close.
+
+## Decision
+
+The docked surface's last tab carries one rule, decided in the Sidebar store's `closeTab` and mirrored to the kit through a new `canCloseTab(tabId)` control-policy prop (joining `canSplit` and `canAddTab`): the guide standing as the only docked tab is unclosable — no chip close control, no menu close item, and a programmatic close records nothing; any other lone tab closes together with the column in one history entry, resets fullscreen to push mode, and lets the settle planner prepare the current default page for the next expansion. `soleDockedTab(state, tabId)` in [stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) names the condition; floating panels take no part in it. Per the packages rule "enforce a decision in the operation that makes it", the store's `closeTab` is the enforcement and `canCloseTab` only mirrors it into the chrome. This rule supersedes the close-protection part of [the default-page decision](2026-09-08-sidebar-default-pages.md); its selection rule remains active.
+
+Two kit-side presentation rules complete it in [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) and [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx): a pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it; and a menu that would hold no item at all produces no visible popup, so a secondary press on such a chip shows nothing rather than an empty box.
+
+## Alternatives considered
+
+**Keep the guide closable and let settle reseed it.** The visible result is a close control that does nothing; the control lies about what a press will do.
+
+**Hide the close in the Sidebar's renderer instead of a kit prop.** The kit draws the chip's close and the menu's close item, so the embedder cannot withhold them without a seam; a CSS override would leave the menu item live and split one decision across two owners.
+
+**Collapse the column from the kit when the last tab closes.** The kit has no concept of the column or its expansion; the collapse is the embedder's intent, recorded by the store alongside the close in the same entry.
+
+## Consequences
+
+`canCloseTab` is a third control-policy prop every embedder may set; leaving it out keeps every tab closable. The quiet-chip and empty-menu rules are unconditional kit behavior keyed on the same policy, so any embedder withholding a lone tab's close gets the same presentation. Reopening the column after a lone-tab close shows the default page selected from the current guide entries. Kit specs cover the withheld control, the quiet chip, and the self-dismissing menu; Sidebar unit specs cover `closeTab`'s refusal and the close-with-column entry; a [browser case](../../../../apps/web/tests/sidebar-right.e2e.ts) walks the whole rule on the rendered panel.

+ 27 - 0
.agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md

@@ -0,0 +1,27 @@
+# Agent Note:Sidebar 停靠面最后一个 tab 的关闭规则
+
+Status: implemented
+
+[English](2026-09-08-sidebar-last-tab-close-rules.md) | 中文
+
+## 问题
+
+settle planner 保证停靠面永不为空:关掉最后一个 tab 会重新播种当前默认页。这条保证让最后一个 tab 的关闭控件在两个方向上都走进死胡同。独自留下的引导页被关闭后,同一个引导页立刻回来——一个什么也不做的控件。任何其它 tab 独自留下时被关闭,用户面前只剩一列只显示默认页的面板——在「关掉最后一个东西」之后,一块展开着却空无内容的面板不是这个手势的本意。引导页的 chip 还画着悬停胶囊,右键菜单里唯一的条目就是那个无效的关闭。
+
+## 决定
+
+停靠面的最后一个 tab 带一条规则,由 Sidebar store 的 `closeTab` 决定,并经新的控制策略 prop `canCloseTab(tabId)`(与 `canSplit`、`canAddTab` 并列)镜像给套件:作为唯一停靠 tab 的引导页不可关闭——chip 上没有关闭控件,菜单里没有关闭项,编程式关闭什么都不记录;任何其它 tab 独自留下时,关闭会连同整列一起收起、把全屏重置为挤压模式并记为一条历史,于是 settle planner 为下次展开准备当前默认页。[stores.ts](../../../../packages/client/ui-sidebar-right/src/client/stores.ts) 里的 `soleDockedTab(state, tabId)` 命名这个条件;浮动面板不参与。按照 packages 规则「在做出决定的操作里执行它」,store 的 `closeTab` 是执行点,`canCloseTab` 只是把它镜像到界面。本规则取代[默认页决策](2026-09-08-sidebar-default-pages.zh.md)中的关闭保护部分;其默认页选择规则仍然有效。
+
+两条套件侧的呈现规则在 [TabPanel.tsx](../../../../packages/client/ui-dockkit/src/components/TabPanel.tsx) 与 [TabMenu.tsx](../../../../packages/client/ui-dockkit/src/components/TabMenu.tsx) 里补全它:某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事;一个连一项都没有的菜单不会产生可见弹层,于是对这样的 chip 次键按下什么都不显示,而不是画一个空框。
+
+## 考虑过的替代方案
+
+**让引导页保持可关闭,由 settle 重新播种。** 可见的结果是一个什么也不做的关闭控件;这个控件在按下会发生什么这件事上撒谎。
+
+**在 Sidebar 的渲染器里藏掉关闭,而不加套件 prop。** chip 的关闭控件与菜单的关闭项都由套件绘制,没有接缝嵌入方就无法收起它们;CSS 覆盖会留下仍然生效的菜单项,把一个决定拆给两个所有者。
+
+**由套件在最后一个 tab 关闭时收起整列。** 套件没有「列」或「展开」的概念;收起是嵌入方的意图,由 store 在同一条历史里与关闭一并记录。
+
+## 后果
+
+`canCloseTab` 成为每个嵌入方都可设置的第三个控制策略 prop;不设置时每个 tab 都可关闭。安静 chip 与空菜单两条规则是套件的无条件行为,键在同一策略上,任何收起了独 tab 关闭的嵌入方都得到同样的呈现。独 tab 关闭后重新展开的列显示根据当前引导入口选出的默认页。套件 spec 覆盖收起的控件、安静 chip 与自行消失的菜单;Sidebar 单元 spec 覆盖 `closeTab` 的拒绝与「关闭连带整列」的历史条目;一个[浏览器用例](../../../../apps/web/tests/sidebar-right.e2e.ts)在渲染出的面板上走完整条规则。

+ 1 - 1
apps/web/tests/agent-preset-selection.e2e.ts

@@ -353,7 +353,7 @@ describe('web e2e: agent-preset selection', () => {
     expect(snapshot).toContain('Minimal mode')
     expect(snapshot).toContain('button "1 subagent"')
     expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode'))
-    expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "Session log"'))
+    expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "More actions"'))
     // Static chrome, not a control: the header can only report a composition
     // the host would refuse to change.
     expect(snapshot).not.toContain('button "Minimal mode"')

+ 6 - 4
apps/web/tests/document-preview.e2e.ts

@@ -130,9 +130,10 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     expect(await column.locator('[data-dockkit-tab]').count()).toBe(1)
     const defaultTitle = await filesTab.locator('[data-dockkit-tab-title]').innerText()
     const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count()
-    expect(initialFilesClose).toBe(0)
+    expect(initialFilesClose).toBe(1)
     await filesTab.click({ button: 'right' })
-    expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(0)
+    expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(1)
+    await page.keyboard.press('Escape')
     await addTab.waitFor({ state: 'visible' })
     const initialAdd = await addTab.count()
     expect(initialAdd).toBe(1)
@@ -143,9 +144,10 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     const guideClose = await guideTab.locator('[data-dockkit-tab-close]').count()
     const filesCloseWithGuide = await filesTab.locator('[data-dockkit-tab-close]').count()
     expect(guideClose).toBe(1)
-    expect(filesCloseWithGuide).toBe(0)
+    expect(filesCloseWithGuide).toBe(1)
     await expect.poll(() => addTab.count()).toBe(0)
     const addWithGuide = await addTab.count()
+    await guideTab.hover()
     await guideTab.locator('[data-dockkit-tab-close]').click()
     await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
     await expect.poll(() => column.locator('[data-sidebar-right-guide]').count()).toBe(0)
@@ -153,7 +155,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
     await addTab.waitFor({ state: 'visible' })
     const restoredFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count()
     const restoredAdd = await addTab.count()
-    expect(restoredFilesClose).toBe(0)
+    expect(restoredFilesClose).toBe(1)
     expect(restoredAdd).toBe(1)
     const preview = column.locator('[data-document-preview]')
     const openFile = async (name: string): Promise<void> => {

+ 2 - 3
apps/web/tests/expected/agent-preset-selection/header.expected.md

@@ -6,8 +6,7 @@
     - img
 - img
 - text: Minimal mode
-- button "Session log":
-  - text: Session log
+- button "More actions":
   - img
-- button "Open the sidebar":
+- button "Open right sidebar":
   - img

+ 2 - 3
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -1,10 +1,9 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Clickable links gallery" [disabled]
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

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

@@ -14,10 +14,9 @@
     - button "Review deepseek-harness/deepseek-harness#314" [disabled]
   - img
   - text: Standard mode
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

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

@@ -14,10 +14,9 @@
     - button "Review deepseek-harness/deepseek-harness#314" [disabled]
   - img
   - text: Standard mode
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

+ 2 - 3
apps/web/tests/expected/goal-command-presentation/ui.expected.md

@@ -3,10 +3,9 @@
     - button "workspace" [disabled]
   - img
   - text: Standard mode
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

+ 2 - 3
apps/web/tests/expected/markdown-cjk-strong/ui.expected.md

@@ -1,10 +1,9 @@
 - banner:
   - navigation "Session hierarchy":
     - button "CJK strong emphasis" [disabled]
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

+ 2 - 3
apps/web/tests/expected/markdown-images/ui.expected.md

@@ -1,10 +1,9 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Markdown image policy" [disabled]
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

+ 2 - 3
apps/web/tests/expected/markdown-inline-code-links/ui.expected.md

@@ -1,10 +1,9 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Inline code links" [disabled]
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

+ 2 - 3
apps/web/tests/expected/math-rendering/ui.expected.md

@@ -1,10 +1,9 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Math rendering" [disabled]
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

+ 2 - 3
apps/web/tests/expected/reference-composer/order.expected.md

@@ -1,10 +1,9 @@
 - banner:
   - navigation "Session hierarchy":
     - button "Reference order target" [disabled]
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

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

@@ -3,10 +3,9 @@
     - button "/user-invoke-demo and confirm the fixtur" [disabled]
   - img
   - text: Standard mode
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

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

@@ -3,10 +3,9 @@
     - button "/user-invoke-demo and confirm the fixtur" [disabled]
   - img
   - text: Standard mode
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

+ 2 - 3
apps/web/tests/expected/stats-paged-history/ui.expected.md

@@ -1,10 +1,9 @@
 - banner:
   - navigation "Session hierarchy":
     - button "{{workspace}}" [disabled]
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

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

@@ -3,10 +3,9 @@
     - button "Use the ask_user_question tool to" [disabled]
   - img
   - text: Standard mode
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

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

@@ -3,10 +3,9 @@
     - button "Use the ask_user_question tool to" [disabled]
   - img
   - text: Standard mode
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

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

@@ -3,10 +3,9 @@
     - button "Use the ask_user_question tool to" [disabled]
   - img
   - text: Standard mode
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

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

@@ -274,12 +274,12 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
   it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export'))
     await ensureSeedOpen(page)
-    const exportButton = page.getByRole('button', { name: 'Session log' })
+    const exportButton = page.getByRole('button', { name: 'More actions' })
     expect(await exportButton.isDisabled()).toBe(false)
     const header = exportButton.locator('xpath=ancestor::header[1]')
     // The right Sidebar's expand button holds the header's corner; the export
     // control sits immediately to its left.
-    const sidebarButton = page.getByRole('button', { name: 'Open the sidebar' })
+    const sidebarButton = page.getByRole('button', { name: 'Open right sidebar' })
     const [buttonBox, sidebarBox, headerBox] = await Promise.all([
       exportButton.boundingBox(), sidebarButton.boundingBox(), header.boundingBox(),
     ])
@@ -293,6 +293,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
       && new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 })
     const downloadPromise = page.waitForEvent('download', { timeout: 30_000 })
     await exportButton.click()
+    await page.getByRole('menuitem', { name: 'Download session log' }).click()
     const response = await responsePromise
     expect(response.status()).toBe(200)
     const download = await downloadPromise

+ 1 - 1
apps/web/tests/seeded-history.e2e.ts

@@ -433,7 +433,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
     // Put the column back so the later goldens see the default frame.
     await column.locator('[data-sidebar-right-toggle]').click()
     await expect.poll(() => frame.getAttribute('data-rightbar-collapsed'), { timeout: 5_000 }).toBe('true')
-    await page.getByRole('button', { name: 'Open the sidebar', exact: true }).waitFor({ state: 'visible' })
+    await page.getByRole('button', { name: 'Open right sidebar', exact: true }).waitFor({ state: 'visible' })
     await page.getByRole('navigation', { name: 'Turn navigation', exact: true }).waitFor({ state: 'visible' })
   })
 

+ 121 - 29
apps/web/tests/sidebar-right.e2e.ts

@@ -20,7 +20,7 @@
 import { mkdirSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import type { Browser, Locator, Page } from 'playwright'
+import type { Browser, ConsoleMessage, Locator, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
@@ -360,14 +360,13 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(panelWidth).toBeGreaterThan(0)
       expect(await width(conversation)).toBe(centerBefore - panelWidth)
       await expect.poll(async () => await expand.count()).toBe(0)
-      // The corner keeps its footprint, so the utilities' right edge stays where
-      // it was relative to the conversation's own right edge.
-      expect(await page.locator('[data-sidebar-right-expand-placeholder]').count()).toBe(1)
+      // The corner seat collapses with its button, so the utilities' right edge
+      // moves out toward the conversation's own.
       const utilitiesAfter = await utilities.boundingBox()
       const conversationAfter = await conversation.boundingBox()
       if (utilitiesAfter === null || conversationAfter === null) throw new Error('header is not rendered')
       const gapAfter = (conversationAfter.x + conversationAfter.width) - (utilitiesAfter.x + utilitiesAfter.width)
-      expect(Math.round(gapAfter)).toBe(Math.round(gapBefore))
+      expect(gapAfter).toBeLessThan(gapBefore)
 
       // The panel is in the column, not over it, and carries the seeded tab —
       // whose body arrives through the Files type's keyed registration, not from
@@ -391,23 +390,24 @@ describe('web e2e: shipped right Sidebar', () => {
         expect(await centreY(selector), selector).toBe(textLine)
       }
 
-      // Files is permanent. A manual guide is closable and suppresses another
-      // add control in its pane until it is closed.
+      // A manual guide is closable beside Files and suppresses another add
+      // control in its pane until it is closed.
       const addTab = column.locator('[data-dockkit-add-tab]')
       const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
       await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
       await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
-      expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0)
+      expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
       await expect.poll(async () => await addTab.count()).toBe(1)
       expect(await centreY('[data-dockkit-add-tab]')).toBe(textLine)
       await addTab.click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Files', 'Start'])
       await expect.poll(async () => await column.locator('[data-sidebar-right-guide]').count()).toBe(1)
       await expect.poll(async () => await addTab.count()).toBe(0)
-      expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(0)
+      expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
       const guideTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
       expect(await guideTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
       // Back to the seeded shape the cases below start from.
+      await guideTab.hover()
       await guideTab.locator('[data-dockkit-tab-close]').click()
       await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
       await expect.poll(async () => await addTab.count()).toBe(1)
@@ -592,6 +592,73 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(tripwire.warnings).toEqual([])
     })
 
+    it('survives grip drags past both clamps on a squeezed viewport', async () => {
+      // Regression: an overshoot drag swept the panel through widths where a
+      // width-blocked split control hid itself, which changed the very strip
+      // measurement that had blocked it — a layout-effect feedback loop that
+      // crashed the pane (React error #185) and unmounted the rightbar slot
+      // entry while the column still believed it was expanded, so neither the
+      // panel nor the header's expand button remained. The crash surfaces only
+      // as a console error, which the scaffold tripwire does not watch, so this
+      // case collects console errors itself.
+      const viewport = page.viewportSize()
+      if (viewport === null) throw new Error('expected a fixed viewport')
+      const column = await resetSidebar(page)
+      const frame = page.locator('[class*="frame"]').first()
+      const panel = column.locator('[data-sidebar-right-panel]')
+      const consoleErrors: string[] = []
+      const collect = (message: ConsoleMessage): void => {
+        if (message.type() === 'error') consoleErrors.push(message.text().slice(0, 600))
+      }
+      page.on('console', collect)
+      try {
+        await page.setViewportSize({ width: 1000, height: viewport.height })
+        await ensureExpanded(page, column)
+        await width(column)
+        const grip = frame.locator('[data-side="rightbar"]')
+        // The frame reads the new viewport through a throttled ResizeObserver,
+        // a couple of frames after the resize; until then the grip sits at the
+        // old frame's coordinates. Press only a grip aligned with the panel's
+        // left edge (the handle is 8px wide, centred on the seam).
+        await expect.poll(async () => {
+          const gripBox = await grip.boundingBox()
+          const panelBox = await panel.boundingBox()
+          if (gripBox === null || panelBox === null) return Number.NaN
+          return Math.abs(gripBox.x + 4 - panelBox.x)
+        }).toBeLessThanOrEqual(1)
+        // Narrow with overshoot: drag the grip far right past the clamp floor.
+        const from = await centre(grip)
+        await page.mouse.move(from.x, from.y)
+        await page.mouse.down()
+        await page.mouse.move(980, from.y, { steps: 30 })
+        await page.mouse.up()
+        // The panel holds its floor, still open, with its grip still rendered.
+        await expect.poll(async () => await width(panel)).toBeLessThanOrEqual(302)
+        expect(await width(panel)).toBeGreaterThanOrEqual(300)
+        expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
+        expect(await grip.count()).toBe(1)
+        // Widen with overshoot to the far left: clamped by the frame's range.
+        const back = await centre(grip)
+        await page.mouse.move(back.x, back.y)
+        await page.mouse.down()
+        await page.mouse.move(20, back.y, { steps: 30 })
+        await page.mouse.up()
+        const widened = await width(panel)
+        expect(widened).toBeGreaterThan(302)
+        expect(widened).toBeLessThan(1000)
+        expect(await column.locator('[data-sidebar-right-open]').count()).toBe(1)
+        expect(await grip.count()).toBe(1)
+        expect(consoleErrors).toEqual([])
+        expect(tripwire.pageErrors).toEqual([])
+        expect(tripwire.warnings).toEqual([])
+      } finally {
+        page.off('console', collect)
+        await page.setViewportSize(viewport)
+        await ensureExpanded(page, column)
+        await setPanelWidth(page, Math.round(viewport.width * 0.45))
+      }
+    }, 60_000)
+
     it('CONTROL: the host endpoint answers when called directly, bypassing the wire', async () => {
       const files = (scaffold.ctx as unknown as {
         get(name: string): {
@@ -686,7 +753,7 @@ describe('web e2e: shipped right Sidebar', () => {
       await expect.poll(async () => await panes.count()).toBe(2)
 
       const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
-      expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0)
+      expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(1)
       await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5))
       await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
 
@@ -850,33 +917,58 @@ describe('web e2e: shipped right Sidebar', () => {
       expect(tripwire.warnings).toEqual([])
     }, 90_000)
 
-    it('keeps the last tab unclosable and drops a pane emptied by a drag', async () => {
+    it('drops a pane whose last tab closes, and follows the last-tab rule on the surface', async () => {
       onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-settle'))
       const column = await resetSidebar(page)
       const panes = column.locator('[data-dockkit-pane]')
-      expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0)
+      expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1)
       await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
       await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME])
       await panes.first().locator('[data-dockkit-split-button]').click()
       await expect.poll(async () => await panes.count()).toBe(2)
-      await dragTo(page, panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }),
-        await pointIn(panes.nth(1), 0.5, 0.5))
-      await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual(['Files', SAMPLE_NAME])
-      const splitFiles = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
-      expect(await splitFiles.locator('[data-dockkit-tab-close]').count()).toBe(0)
-      await dragTo(page, splitFiles, await pointIn(panes.first(), 0.5, 0.5))
-      await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME])
 
-      // The remaining ordinary document is also unclosable while alone.
-      expect(await panes.nth(1).locator('[data-dockkit-tab-close]').count()).toBe(0)
-      await dragTo(page, panes.nth(1).locator('[data-dockkit-tab]'), await pointIn(panes.first(), 0.5, 0.5))
+      // Closing a pane's last tab drops the pane: there is no separate
+      // "close pane" gesture, and none is needed.
+      await panes.nth(1).locator('[data-dockkit-tab]').first().hover()
+      await panes.nth(1).locator('[data-dockkit-tab-close]').first().click()
       await expect.poll(async () => await panes.count()).toBe(1)
-      const documentTab = panes.first().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
-      await expect.poll(async () => await documentTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
-      await documentTab.locator('[data-dockkit-tab-close]').click()
-      await expect.poll(async () => await documentTab.count()).toBe(0)
-      expect(await panes.count()).toBe(1)
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME])
+
+      // Leave a guide as the sole docked tab.
+      const files = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
+      await files.hover()
+      await files.locator('[data-dockkit-tab-close]').click()
+      await column.locator('[data-dockkit-add-tab]').click()
+      await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME, 'Start'])
+      const sample = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
+      await sample.hover()
+      await sample.locator('[data-dockkit-tab-close]').click()
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
+
+      // The guide standing as the docked surface's only tab draws no close
+      // control, sits quiet (no capsule, no hover fill), and a secondary press
+      // opens no menu: an empty menu never shows.
       expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(0)
+      expect(await column.locator('[data-dockkit-tab-quiet]').count()).toBe(1)
+      await column.locator('[data-dockkit-tab]').first().click({ button: 'right' })
+      expect(await page.locator('[data-dockkit-tab-menu]').isVisible()).toBe(false)
+      expect(await page.getByRole('menu').count()).toBe(0)
+
+      // Any other tab standing alone closes together with the column. Open the
+      // sample file, close the guide (an ordinary close with two tabs), then
+      // close the file: the column collapses in the same gesture, and the
+      // settle rule reseeds the current default, so reopening shows Files.
+      await page.getByRole('button', { name: `Open ${SAMPLE_NAME}` }).click()
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Start', SAMPLE_NAME])
+      await column.locator('[data-dockkit-tab]').first().hover()
+      await column.locator('[data-dockkit-tab-close]').first().click()
+      await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME])
+      await column.locator('[data-dockkit-tab]').first().hover()
+      await column.locator('[data-dockkit-tab-close]').first().click()
+      await expect.poll(async () => await column.locator('[data-sidebar-right-open]').count()).toBe(0)
+      await expandOf(page).click()
+      await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
+      expect(await column.locator('[data-files-state="tree"]').count()).toBe(1)
 
       expect(tripwire.pageErrors).toEqual([])
       expect(tripwire.warnings).toEqual([])
@@ -959,8 +1051,8 @@ describe('web e2e: shipped right Sidebar', () => {
         // as a layout defect that is not there.
         expect(await width(column)).toBeGreaterThan(300)
         await expect.poll(async () => await tabTitles(column)).toEqual(['文件', '开始'])
-        await expect.poll(async () => await guide.locator('p').first().innerText())
-          .toBe('侧栏用来放你想一直看着的东西。')
+        await expect.poll(async () => await guide.locator('[data-sidebar-right-guide-entry="files"]').innerText())
+          .toBe('工作区文件')
         await shot(zhPage, '05-guide-copy-zh')
 
         expect(zhTripwire.pageErrors).toEqual([])

+ 2 - 3
apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md

@@ -3,10 +3,9 @@
     - button "Stream one TypeScript fence for" [disabled]
   - img
   - text: Standard mode
-  - button "Session log":
-    - text: Session log
+  - button "More actions":
     - img
-  - button "Open the sidebar":
+  - button "Open right sidebar":
     - img
   - tablist:
     - tab "Chat" [selected]

+ 3 - 4
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -143,10 +143,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
     }
     /**
      * The header's far-right corner, past the utilities' edge and into the
-     * header's own padding, for one control that must keep its place whether or
-     * not it currently shows anything. The corner reserves its width while an
-     * occupant is registered, so the utilities beside it never move; an
-     * occupant with nothing to show renders a same-size placeholder.
+     * header's own padding, for one control. The corner is laid out only while
+     * its occupant renders something; an occupant with nothing to show renders
+     * nothing, and the utilities take the header's edge.
      */
     'conversation.session.header.corner': {
       kind: 'single'

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

@@ -34,23 +34,17 @@
   --dsh-composer-dock-inset: 8px;
 }
 
+/* 76px with its rule: the height of the Sidebar's tab strip and header row
+   (ui-dockkit `.tabStrip` 38px + the pane header's 38px), so the two rules
+   meet at the column edge. The rows add up to it — 10px top inset, the 30px
+   title row, the tab strip's 10px margin, a 16px tab line, and its 9px bottom
+   inset — so no row below may grow past its figure. */
 .header {
-  position: relative;
   flex: none;
-  padding: 12px 28px 0 20px;
-  border-bottom: 1px solid transparent;
-}
-
-.header::after {
-  content: '';
-  position: absolute;
-  right: 0;
-  bottom: 1px;
-  left: 0;
-  z-index: 0;
-  height: 0.5px;
-  background: var(--dsw-alias-border-l3);
-  pointer-events: none;
+  box-sizing: border-box;
+  min-height: 76px;
+  padding: 10px 28px 0 20px;
+  border-bottom: 0.5px solid var(--dsw-alias-border-l3);
 }
 
 /* Blank hero/settling: keep the strict Session header mounted without taking
@@ -63,7 +57,7 @@
   display: flex;
   align-items: center;
   gap: 0;
-  min-height: 32px;
+  min-height: 30px;
 }
 
 .titleCluster {
@@ -147,14 +141,14 @@
 }
 
 /* The far-right corner seat reaches 16px into the header's 28px right padding,
-   so its control sits past the utilities' edge; it is laid out only while an
-   occupant is registered, and the occupant keeps its width while hidden, so the
-   utilities never move because of it. */
+   so its control sits past the utilities' edge; it is laid out only while its
+   occupant renders something. Its left margin equals the utilities' 8px gap so
+   the header's trailing controls space evenly. */
 .headerCorner {
   display: flex;
   flex: none;
   align-items: center;
-  margin-left: 12px;
+  margin-left: 8px;
   margin-right: -16px;
 }
 
@@ -162,20 +156,23 @@
   display: none;
 }
 
-/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */
+/* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned.
+   Positioned above the header's own paint, so the active bar covers the rule. */
 .tabs {
   position: relative;
   z-index: 1;
   display: flex;
   gap: 36px;
-  margin-top: 4px;
+  margin-top: 10px;
   padding-left: 8px;
 }
 
-/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500), gap 8 to the 2px bar. */
+/* figma .Tab 34:11442: 13/16 text (figma wt510, rendered 500). The 2px bar
+   reaches 1px past the tab's box to end flush with the header's bottom edge,
+   sitting over the rule. */
 .tab {
   position: relative;
-  padding: 0 0 11px;
+  padding: 0 0 9px;
   border: none;
   background: transparent;
   font-size: 13px;
@@ -189,7 +186,7 @@
   content: '';
   position: absolute;
   right: 0;
-  bottom: 1px;
+  bottom: -1px;
   left: 0;
   height: 2px;
   border-radius: 2px;
@@ -346,6 +343,10 @@
   flex: 1;
   flex-direction: column;
   min-height: 0;
+  /* The product-wide 2px scrollbar offset (ui-workspace WorkspaceBrowser
+     `.list`, ui-sidebar-files FilesBody `.body`): the bar sits 2px clear of
+     the column's edge instead of flush against it. */
+  margin-right: 2px;
   overflow-y: auto;
   /* 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`
@@ -354,6 +355,12 @@
   scrollbar-gutter: stable;
 }
 
+/* The bar also stops 2px short of the scroller's ends, matching its 2px edge
+   offset. WebKit-only: the Firefox path has no track to inset. */
+.scrollBody::-webkit-scrollbar-track {
+  margin: 2px;
+}
+
 .root[data-phase='active'] .viewArea {
   flex: 1 0 auto;
   min-height: auto;

+ 2 - 2
packages/client/ui-dockkit/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-dockkit/README.md
-README.md: f4f76072bd16cd75bff74715f8f9437348ee036a
-README.zh.md: 39e20b42bfabb49630b1c9e108b43010d6e7b199
+README.md: 396253d771196380d04da13cfc0843584b37fbc2
+README.zh.md: ec2d5077ef847b3fa50d7e78113b978ec4fa7507

Fișier diff suprimat deoarece este prea mare
+ 6 - 7
packages/client/ui-dockkit/README.md


+ 8 - 10
packages/client/ui-dockkit/README.zh.md

@@ -37,7 +37,7 @@ kind: "package-reference"
 - `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种。想要空格的嵌入方只需不调用它。
 - `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。
 
-**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。可选的 `canCloseTab(tabId)` 隐藏 chip、菜单和浮窗的关闭控件;嵌入方在意图处理器中执行关闭限制。chip 是一个胶囊,在允许关闭时携带关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
+**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
 
 <a id="embedding-it"></a>
 ## 如何嵌入
@@ -47,12 +47,12 @@ kind: "package-reference"
 | 契约 | 承载内容 |
 |---|---|
 | `DockLabels` | 每一个渲染出来的字符串,已本地化,含无障碍名称 |
-| `TabRenderer` | 一个 tab 的正文(`renderTab`),以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 |
+| `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 |
 | `DockIntents` | 每次手势落定的结果 |
 
-`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)。隐藏添加控件不会移动 tab 条里的其它任何东西。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
+`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。
 
-`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitAtCapacity` 在达到窗格预算时隐藏分栏控件,默认值为 false;宽度不足的控件仍以禁用状态显示
+`dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false
 
 tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。
 
@@ -64,18 +64,16 @@ tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOpt
 这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。
 
 - **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。
-- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow: hidden`);添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。
-- **chip 盒不是滚动容器。** 横向滚动容器会把按下并移动据为己有;tab 转而收缩、省略、然后被裁切
-- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 44px`,所以它的足印是 44px 加 10px + 5px 内边距,即 59px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(4px)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(36px)加 48px 正文:正文 12px 内边距内一行 13px、行高 1.6 的次级文字。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用,并且在该轴上不提供边缘落区(松手就不是移动)。用户随后把格拖窄——拖分隔条或拖嵌入方的列——的格保持原尺寸:规则只决定它的下一次分栏。
+- **chip 让位;tab 条末端的控件永不让位。** chip 盒是 tab 条里唯一会收缩的部分(`flex: 0 1 auto; min-width: 0; overflow-x: auto`):chip 先缩到 80px 下限,再在盒内随滚轮横向滚动、不画滚动条,且盒在每个藏有 chip 的一侧把 chip 在 24px 内渐隐(`data-dockkit-strip-scroll`,在每次提交、滚动与尺寸变化后由盒的滚动读数写入)。每当活动 tab 或 chip 的排列变化,盒会滚动到让活动 chip 避开渐隐带;已在视野内的 chip 不动。chip 的标题从不加省略号:`TabTitle` 拿文字宽度对照它的盒子,文字更宽时置 `data-dockkit-tab-clipped`,让文字在末端 16px 内渐隐。chip 的关闭控件在 chip 活动、悬停或持有焦点时显示,压在标题末端 14px 之上、标题在其下渐隐,因此 chip 宽度两种情况下都一样。活动 chip 两侧的槽不画细线,让填色胶囊立在裸 chip 之间。添加、分栏与 chrome 控件都是 `flex: none`,因此在任何不窄于它们自身的格里(带 chrome 约 130px,不带约 72px)都保持宽度与位置。停靠面的 `min-width: 0` 与格的 `overflow: hidden` 阻止正文里最长的不换行行把格撑出自己的盒子——正是那种情况把控件和正文滚动条推到了屏幕外。
+- **chip 盒会滚动,但绝不认领手势。** 横向滚动容器会把按下并移动据为己有并取消指针;盒、chip 与 tab 条都设 `touch-action: none`,手势又捕获了指针,所以在 chip 上按下并移动是拖动,只有滚轮滚动盒子
+- **分栏需要给两个可用的半格留出空间。** 格被等分成两半,因此每一半都必须容得下不可收缩的部分:tab 条的固定部分——按 tab 条宽减去 chip 盒与填充条测得,即内边距、间隙以及该格绘制的每个控件(含它自己的 chrome,所以右上格要求更多)——加上一枚最小尺寸的 chip——`.tab` 在 content-box 上声明 `min-width: 80px`,所以它的足印是 80px 加 10px + 10px 内边距,即 100px,从已渲染 chip 的计算样式读取(读不到时用样式表数值);两半之间的分隔条取其渲染厚度(0——它的细线画在接缝上、不占布局空间,因此正文自己画的分隔线能不断线地穿过接缝)。纵向分栏只由边缘落下产生,它要求每一半容得下 tab 条(34px)加 48px 正文:正文自留的 12px 内边距内一行 13px、行高 1.6 的次级文字——格的正文容器本身没有内边距,tab 的正文直接贴到 tab 条底边和格的边缘,由自己留白。`geometry.ts` 里的 `halvesFit` 是算术;`measure.ts` 在每次提交后与停靠面尺寸变化时读取矩形,因为布局状态只携带比例、从不携带像素,引擎的 planner 也保持如此。没有空间的格保留分栏控件,以 `splitPaneNarrow` 禁用(开启 `hideSplitWhenBlocked` 时改为隐藏),并且在该轴上不提供边缘落区(松手就不是移动)。开启 `hideSplitWhenBlocked` 时,分栏控件自己的占位——它的盒子加 tab 条的间隙——不计入固定部分:隐藏控件让 tab 条卸下的恰是这份占位,把它算进去的读数会随控件的可见性来回翻转、无限重渲染;不计入也正是被询问的那一半会承载的量,因为窄到无法分栏的一半会隐藏自己的控件。用户随后把格拖窄——拖分隔条或拖嵌入方的列——的格保持原尺寸:规则只决定它的下一次分栏。
 - **焦点落在 click 而不是按下。** 在 `pointerdown` 与第一次 `pointermove` 之间的状态变化会重建被按下的子树,而被替换的元素会取消指针。这也避免拖动先记录一条多余的焦点操作。chip、标签条各控件以及嵌入方 chrome 上的 click 都止于标签条:它们各自上报的意图已决定了活动格,或本就是嵌入方自己的事,所以格自身的点击聚焦不再多记一条。浮动面板的抓手与角柄同样通过手势上报——原地松开的按下是一次 click,抬起面板;真正的拖动只记录移动或缩放,由该操作自己抬起面板——而按在面板主体上则直接抬起它。点击本已活动的格、点击或按键选中该格本已选中的 chip,或按下本已活动且在最上层的面板,什么都不改变,也什么都不记录。
 - **嵌套在可拖动 chip 里的控件要拦住自己的按下。** 否则按下会开始拖动、捕获指针,嵌套控件的 click 就永远落不下。
-- **强调色用平台的强调 token,绝不用 `--dsw-alias-brand-primary`。** 本平台把 `brand-primary` 绑定到近黑(浅色)或近白(深色)的前景色,因此悬停的分隔条、落点光标与落区提示都用 `--dsw-alias-brand-primary-new-colorprimary-new-color`,与轨迹视图一致。浮窗的边框无论是否活动都是同一条 `--dsw-alias-border-l2` 细线:活动浮窗本就在最上层并投下阴影;围它一圈更深的边框读起来像缺陷。
+- **强调色用平台的强调 token,绝不用 `--dsw-alias-brand-primary`。** 本平台把 `brand-primary` 绑定到近黑(浅色)或近白(深色)的前景色,因此落点光标与落区提示都用 `--dsw-alias-brand-primary-new-colorprimary-new-color`,与轨迹视图一致;悬停的分隔条改用 caption 文字色,读起来是把手而不是高亮。浮窗不画边框——菜单同款阴影(`--dsw-elevation-prominent`)已勾出它的轮廓——活动浮窗也不加重边框:它本就在最上层、投同样的阴影;围它一圈更深的边框读起来像缺陷。
 
 <a id="build-shape"></a>
 ## 构建形态
 
-静态 ESM 为 Web 壳的 Vite 构建保留第三方导入;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。
-
 本包静态链接:tsdown 的 `staticLinked` 预设在 `lib/index.js` 产出一个浏览器 ESM bundle(所有裸说明符保持为 import,sourcemap 链回源码),并把样式表按其相对 `src` 的路径放到 `lib/` 下;Web 外壳按包名解析并自行打包该产物,因此 vite 仍是 class 哈希的唯一拥有者。有一个后果是承重的——套件只保留**一张**样式表 `dockkit.module.css`,因为消费方按文件名去重注入的样式表,撞名会静默丢掉一张。
 
 <a id="model-experience"></a>

+ 1 - 0
packages/client/ui-dockkit/package.json

@@ -25,6 +25,7 @@
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@testing-library/react": "^16.1.0",
     "@types/react": "~18.3.1",
     "@types/react-dom": "~18.3.0",

+ 14 - 8
packages/client/ui-dockkit/src/components/DockSurface.tsx

@@ -33,8 +33,8 @@ export interface DockSurfaceProps {
    * split control disabled with `labels.splitPaneNarrow` (see README).
    */
   readonly canSplit: boolean
-  /** Hide the split control when the pane budget is spent; defaults to false. Width-blocked controls remain disabled. */
-  readonly hideSplitAtCapacity?: boolean
+  /** Hide a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; defaults to false. */
+  readonly hideSplitWhenBlocked?: boolean
   /** Body drop geometry: all edge bands, or left/right halves with whole-pane moves once splitting is unavailable. */
   readonly dropZones?: 'edges' | 'horizontal'
   /** Smallest share a divider may leave a pane; defaults to the kit's fraction. */
@@ -45,7 +45,13 @@ export interface DockSurfaceProps {
    * end controls where they are and the chips as the only shrinking part.
    */
   readonly canAddTab?: (paneId: PaneId) => boolean
-  /** Whether a tab offers close controls; defaults to true. Called per tab on every render. */
+  /**
+   * Whether a tab draws its close control and its menu's close item. Called
+   * per rendered chip on every render; omit to keep every tab closable.
+   * `false` removes both routes without moving the chip: the close control
+   * paints over the title's end rather than beside it, so the chip is the same
+   * width either way. The menu still opens and carries the embedder's items.
+   */
   readonly canCloseTab?: (tabId: TabId) => boolean
   readonly intents: DockIntents
   readonly labels: DockLabels
@@ -97,7 +103,7 @@ const NO_PREVIEW: Preview = { draggingTabId: undefined, dropTarget: undefined, s
 /** Nothing measured yet: every pane fits until a reading says otherwise. */
 const NO_FITS: ReadonlyMap<PaneId, HalvesFit> = new Map()
 
-/** The default add-control policy: every pane offers one. */
+/** The default policy for the omitted callbacks: every pane offers the add control, every tab its close. */
 const ALWAYS = (): boolean => true
 
 /**
@@ -157,7 +163,7 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean {
 /** The split tree and the gestures over it. */
 export function DockSurface({
   state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom,
-  dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitAtCapacity = false,
+  dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false,
 }: DockSurfaceProps): ReactNode {
   const surface = useRef<HTMLDivElement | null>(null)
   const [preview, setPreview] = useState<Preview>(NO_PREVIEW)
@@ -178,10 +184,10 @@ export function DockSurface({
   // wider or narrower). A reading that changed nothing renders nothing.
   const remeasure = useCallback((): void => {
     withSurface((root) => {
-      const next = measurePaneFits(root)
+      const next = measurePaneFits(root, hideSplitWhenBlocked)
       setFits(current => sameFits(current, next) ? current : next)
     })
-  }, [withSurface])
+  }, [withSurface, hideSplitWhenBlocked])
   useLayoutEffect(() => { remeasure() })
   useEffect(() => { onRoom?.(fits) }, [fits, onRoom])
   useEffect(() => {
@@ -267,7 +273,7 @@ export function DockSurface({
       })
     },
     splitBlock,
-    hideSplitAtCapacity,
+    hideSplitWhenBlocked,
     canAddTab: canAddTab ?? ALWAYS,
     canCloseTab: canCloseTab ?? ALWAYS,
     dropTarget: preview.dropTarget,

+ 30 - 19
packages/client/ui-dockkit/src/components/FloatLayer.tsx

@@ -1,7 +1,8 @@
 /**
  * The floating layer: one overlay panel per floating pane, bottom-to-top in the
- * model's z order. A floating pane hosts exactly one tab and renders no tab
- * strip — the panel *is* the tab. Pressing a panel's body raises it. Its grip
+ * model's z order. A floating pane hosts exactly one tab; its header is the
+ * strip's row holding that tab's chip, never selectable or closable from the
+ * chip, and the send-back and close controls. Pressing a panel's body raises it. Its grip
  * and corner report through their gesture instead: a press released in place is
  * a click and raises the panel; a drag records the move or resize, and that
  * operation raises the panel itself, so one gesture is one intent. Raising a
@@ -14,12 +15,15 @@
  */
 import { useState } from 'react'
 import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react'
+import clsx from 'clsx'
+import { IconCloseOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts'
 import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts'
 import { FLOAT_MIN_SIZE } from '../engine/constraints.ts'
 import { movedRect, resizedRect } from '../engine/geometry.ts'
 import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts'
 import { useGesture } from './pointer.ts'
+import { TabTitle } from './TabTitle.tsx'
 import css from './dockkit.module.css'
 
 /** The layout whose `floats` this layer draws. */
@@ -114,32 +118,39 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle,
             onPointerDown={() => { raise(paneId) }}
           >
             <header
-              className={css.floatHeader}
+              className={clsx(css.tabStrip, css.floatHeader)}
               data-dockkit-float-grip={paneId}
               onPointerDown={(event) => { drag('move', paneId, event) }}
             >
-              <span className={css.floatTitle} data-dockkit-float-title>{renderTabTitle?.(tab) ?? tab.title}</span>
-              <button
-                type="button"
-                className={css.iconButton}
-                aria-label={labels.dockFloat}
-                data-dockkit-float-dock={paneId}
-                onPointerDown={(event) => { event.stopPropagation() }}
-                onClick={() => { intents.unfloatPane(paneId) }}
-              >
-                ⇤
-              </button>
-              {(canCloseTab?.(tab.id) ?? true) && (
+              <div className={clsx(css.tab, css.floatTitle)} data-dockkit-float-title>
+                <TabTitle>{renderTabTitle?.(tab) ?? tab.title}</TabTitle>
+              </div>
+              <div className={css.stripFill} />
+              <Tooltip label={labels.dockFloat} side="bottom" delayMs={500}>
                 <button
                   type="button"
                   className={css.iconButton}
-                  aria-label={labels.closeFloat}
-                  data-dockkit-float-close={paneId}
+                  aria-label={labels.dockFloat}
+                  data-dockkit-float-dock={paneId}
                   onPointerDown={(event) => { event.stopPropagation() }}
-                  onClick={() => { intents.closeTab(tab.id) }}
+                  onClick={() => { intents.unfloatPane(paneId) }}
                 >
-                  
+                  <IconPanelLeftOutline16 className={css.dockGlyph} />
                 </button>
+              </Tooltip>
+              {(canCloseTab?.(tab.id) ?? true) && (
+                <Tooltip label={labels.closeFloat} side="bottom" delayMs={500}>
+                  <button
+                    type="button"
+                    className={css.iconButton}
+                    aria-label={labels.closeFloat}
+                    data-dockkit-float-close={paneId}
+                    onPointerDown={(event) => { event.stopPropagation() }}
+                    onClick={() => { intents.closeTab(tab.id) }}
+                  >
+                    <IconCloseOutline16 />
+                  </button>
+                </Tooltip>
               )}
             </header>
             <div className={css.floatBody}>{renderTab(tab)}</div>

+ 12 - 10
packages/client/ui-dockkit/src/components/TabMenu.tsx

@@ -2,8 +2,10 @@
  * The per-tab context menu, opened by a secondary press on the chip. It carries
  * the close gesture and whatever the embedder appends; the copy and float
  * gestures have no menu item — copying is an embedder API, floating is a drag
- * released clear of the surface. Presentational — it renders what its props
- * supply and dismisses itself on outside presses.
+ * released clear of the surface. A menu that would hold no item at all renders
+ * no popup, so a secondary press on a chip with nothing to offer shows nothing.
+ * Presentational — it renders what its props supply and dismisses itself on
+ * outside presses.
  *
  * It renders in a portal, positioned against the control that opened it. The tab
  * strip clips its overflow on purpose (so it never becomes a scroll container
@@ -26,12 +28,11 @@ export interface TabMenuProps {
   readonly labels: DockLabels
   /** The control that opened the menu; the menu hangs below its left edge. */
   readonly anchor: HTMLElement
-  /** Whether to offer close; custom items remain available when false. */
-  readonly canCloseTab: boolean
-  readonly onClose: () => void
+  /** Close the tab; `undefined` removes the kit's item, leaving the extras only. */
+  readonly onClose: (() => void) | undefined
   /** Dismiss without acting. */
   readonly onDismiss: () => void
-  /** Embedder ARIA menu items, rendered after the kit's own; absent means none. */
+  /** Embedder items, rendered after the kit's own; absent means none. */
   readonly extras: ReactNode
 }
 
@@ -49,18 +50,19 @@ function placeMenu(anchor: HTMLElement, menu: HTMLElement): CSSProperties {
 }
 
 /** The actions menu body, anchored to the control that opened it. */
-export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extras }: TabMenuProps): ReactNode {
+export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuProps): ReactNode {
   const self = useRef<HTMLDivElement | null>(null)
   const [position, setPosition] = useState<CSSProperties | undefined>(undefined)
-  const hasItems = canCloseTab || Children.toArray(extras).some(item => item !== '')
+  const hasItems = onClose !== undefined || Children.toArray(extras).some(item => item !== '')
 
   useLayoutEffect(() => {
     if (self.current === null) return
     setPosition(placeMenu(anchor, self.current))
-  }, [anchor, canCloseTab, hasItems])
+  }, [anchor, hasItems])
 
   useEffect(() => {
     const menu = self.current
+    /* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
     if (menu === null) return undefined
     // A press anywhere but inside the menu dismisses it; one with no element
     // target (dispatched to the window itself) counts as outside.
@@ -91,7 +93,7 @@ export function TabMenu({ labels, anchor, canCloseTab, onClose, onDismiss, extra
       onPointerDown={(event) => { event.stopPropagation() }}
       onClick={(event) => { event.stopPropagation() }}
     >
-      {canCloseTab && (
+      {onClose !== undefined && (
         <button type="button" role="menuitem" className={css.menuItem} data-dockkit-menu-close onClick={onClose}>
           {labels.closeTab}
         </button>

Fișier diff suprimat deoarece este prea mare
+ 26 - 11
packages/client/ui-dockkit/src/components/TabPanel.tsx


+ 37 - 0
packages/client/ui-dockkit/src/components/TabTitle.tsx

@@ -0,0 +1,37 @@
+/**
+ * A chip's title: one line, clipped at the chip's inset, never ellipsized.
+ * While the text is wider than its box the span carries
+ * `data-dockkit-tab-clipped`, and the stylesheet fades the text out at the
+ * clipped edge in place of an ellipsis. Written to the DOM directly rather
+ * than through state: a reading changes nothing that renders, only how the
+ * stylesheet paints it. Re-read after every commit (the text may have
+ * changed) and whenever the span's box resizes (the chip shrank or grew).
+ */
+import { useLayoutEffect, useRef } from 'react'
+import type { ReactNode } from 'react'
+import css from './dockkit.module.css'
+
+/** Set or clear the span's `data-dockkit-tab-clipped` from its current geometry. */
+function markClipped(element: HTMLElement): void {
+  // Sub-pixel widths: the text counts as clipped past one whole pixel.
+  if (element.scrollWidth > element.clientWidth + 1) element.dataset.dockkitTabClipped = ''
+  else delete element.dataset.dockkitTabClipped
+}
+
+/** The title span of a strip chip or a floating panel's header chip. */
+export function TabTitle({ children }: { readonly children: ReactNode }): ReactNode {
+  const span = useRef<HTMLSpanElement | null>(null)
+  useLayoutEffect(() => {
+    /* v8 ignore next -- the span is rendered unconditionally. */
+    if (span.current !== null) markClipped(span.current)
+  })
+  useLayoutEffect(() => {
+    const element = span.current
+    /* v8 ignore next -- the span is rendered unconditionally. */
+    if (element === null || typeof ResizeObserver === 'undefined') return undefined
+    const observer = new ResizeObserver(() => { markClipped(element) })
+    observer.observe(element)
+    return () => { observer.disconnect() }
+  }, [])
+  return <span ref={span} className={css.tabTitle} data-dockkit-tab-title>{children}</span>
+}

+ 401 - 99
packages/client/ui-dockkit/src/components/dockkit.module.css

@@ -7,10 +7,11 @@
  * Colours come from the embedder's token layer; the kit names no literal. Type
  * follows the embedder's content axis (`--dsh-content-font-size` and its
  * secondary step) so a surface reads at the same size as the page around it.
- * Emphasis — a hovered divider, the drop caret, the drop-zone hint — takes the
- * platform's accent (`--dsw-alias-brand-primary-new-colorprimary-new-color`),
- * not `--dsw-alias-brand-primary`, which this platform binds to its
- * near-black (light) or near-white (dark) foreground.
+ * Emphasis — the drop caret, the drop-zone hint — takes the platform's accent
+ * (`--dsw-alias-brand-primary-new-colorprimary-new-color`), not
+ * `--dsw-alias-brand-primary`, which this platform binds to its near-black
+ * (light) or near-white (dark) foreground. A hovered divider takes the
+ * caption label ink instead, reading as a handle rather than a highlight.
  */
 
 .split {
@@ -35,25 +36,87 @@
   min-height: 0;
 }
 
+/* The divider owns no layout room (`SPLIT_MINIMUMS.divider` mirrors the 0):
+   the halves abut, so a rule a pane draws across its own edge — a header's
+   hairline — runs unbroken past the seam. The visible rule is a 0.5px hairline
+   centred on the seam, matching the embedder's other borders, and `::after`
+   widens the pointer target to 8px by reaching 4px over each neighbour.
+   `z-index` keeps that overhang above the later sibling, which would otherwise
+   take the hit. */
 .divider {
   position: relative;
+  z-index: 1;
   flex: none;
-  background: var(--dsw-alias-border-l1);
   touch-action: none;
 }
 
+.divider::before,
+.divider::after {
+  content: '';
+  position: absolute;
+}
+
+.divider::before {
+  background: var(--dsw-alias-border-l4);
+}
+
 .splitRow > .divider {
-  width: 4px;
+  width: 0;
   cursor: col-resize;
 }
 
+.splitRow > .divider::before {
+  top: 0;
+  bottom: 0;
+  left: -0.25px;
+  width: 0.5px;
+}
+
+.splitRow > .divider::after {
+  top: 0;
+  bottom: 0;
+  left: -4px;
+  right: -4px;
+}
+
 .splitColumn > .divider {
-  height: 4px;
+  height: 0;
   cursor: row-resize;
 }
 
-.divider:hover {
-  background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
+.splitColumn > .divider::before {
+  left: 0;
+  right: 0;
+  top: -0.25px;
+  height: 0.5px;
+}
+
+.splitColumn > .divider::after {
+  left: 0;
+  right: 0;
+  top: -4px;
+  bottom: -4px;
+}
+
+/* Hover: a 1px grip fades in over the hairline, its caption ink deepest at
+   its middle and fading toward both ends. The grip is painted on the `::after`
+   hit target, centred on the seam, and crossfaded through opacity, because a
+   gradient cannot transition from the hairline's solid colour. */
+.divider::after {
+  opacity: 0;
+  transition: opacity 120ms ease-out;
+}
+
+.divider:hover::after {
+  opacity: 1;
+}
+
+.splitRow > .divider::after {
+  background: linear-gradient(to bottom, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 1px 100% no-repeat;
+}
+
+.splitColumn > .divider::after {
+  background: linear-gradient(to right, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 100% 1px no-repeat;
 }
 
 /* Both floors: a flex item's minimum is its content's, and a body's longest
@@ -74,17 +137,18 @@
   min-width: 0;
   min-height: 0;
   overflow: hidden;
-  border: 1px solid transparent;
-}
-
-.pane[data-dockkit-pane-active] {
-  border-color: var(--dsw-alias-border-l2);
 }
 
-/* One centre line for everything in the strip: every child — chip, add
-   control, split control, the embedder's chrome — is 24px tall, and the strip
-   centres them, so chip text and control glyphs never sit at different heights.
-   A child with another height would break that; keep them at 24px.
+/* A 38px row whose 28px content band sits at its bottom: chips fill the band,
+   so they end flush with the strip's bottom edge, and the 28px add control, the
+   28px split control, and the embedder's 28px chrome centre on the band's one
+   centre line, so chip text and control glyphs never sit at different heights.
+   The 10px above is the strip's own top margin, inside its box so the hit
+   area stays one element; the 10px at the start is the first chip's inset
+   from the edge, and the 6px at the end puts the last control's glyph 12px
+   from the edge. The strip draws no border: a body
+   that wants a rule under it draws its own. A floating panel's header is
+   this same row.
 
    The strip never clips: the chip box below is its one shrinking part, and
    every control after it is `flex: none`, so a narrow pane costs chips, never
@@ -94,27 +158,47 @@
   flex: none;
   gap: 4px;
   align-items: center;
-  height: 36px;
-  padding: 0 6px;
-  border-bottom: 0.5px solid var(--dsw-alias-border-l1);
+  height: 28px;
+  padding: 10px 6px 0 10px;
   touch-action: none;
 }
 
-/* The chips. Shrinks to nothing before any control after it moves; what no
-   longer fits is clipped here. Deliberately not a scroller: a horizontal
-   scroll container claims a press-and-move as its own gesture and cancels the
-   pointer, which would abandon every tab drag in a narrow pane. Tabs shrink
-   and ellipsize first. */
+/* The chips. Shrinks to nothing before any control after it moves; chips keep
+   their 80px floor, so what no longer fits scrolls here on the wheel, with no
+   scrollbar drawn. `touch-action: none` keeps a touch press-and-move a tab drag
+   rather than a pan the scroller would claim and cancel the pointer for.
+   `data-dockkit-strip-scroll` names the hidden sides (`start`, `end`, or
+   both); each hidden side fades the chips out over 24px into whatever ground
+   the pane draws, so the row reads as continuing under the controls. */
 .stripTabs {
   display: flex;
   flex: 0 1 auto;
-  gap: 4px;
   align-items: center;
   min-width: 0;
-  overflow: hidden;
+  overflow-x: auto;
+  overflow-y: hidden;
+  /* The scroll to a newly active chip glides rather than jumps. */
+  scroll-behavior: smooth;
+  scrollbar-width: none;
   touch-action: none;
 }
 
+.stripTabs::-webkit-scrollbar {
+  display: none;
+}
+
+.stripTabs[data-dockkit-strip-scroll='end'] {
+  mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
+}
+
+.stripTabs[data-dockkit-strip-scroll='start'] {
+  mask-image: linear-gradient(to right, transparent, black 24px);
+}
+
+.stripTabs[data-dockkit-strip-scroll='start end'] {
+  mask-image: linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent);
+}
+
 /* Takes the free space and gives it all back first: a zero basis shrinks
    nothing, so shortage lands on the chip box alone. */
 .stripFill {
@@ -122,37 +206,64 @@
   min-width: 0;
 }
 
-/* Embedder controls at the strip's end, set off from the kit's own split
-   control by a hairline so the two groups read as two groups. */
+/* Embedder controls at the strip's end, spaced from the kit's own split
+   control as they are from each other: the strip's 4px gap plus this 4px
+   margin equals the 8px between the controls. */
 .stripChrome {
   display: flex;
   flex: none;
-  gap: 2px;
+  gap: 8px;
   align-items: center;
-  height: 24px;
-  margin-left: 2px;
-  padding-left: 4px;
-  border-left: 0.5px solid var(--dsw-alias-border-l1);
+  height: 28px;
+  margin-left: 4px;
 }
 
-.caret {
+/* The slot between two chips: a 10px box with a hairline down its centre.
+   Targeted by a drag, the same box draws the caret instead, so the chips
+   around it never move. */
+.slot {
+  display: flex;
   flex: none;
-  align-self: center;
+  align-items: center;
+  justify-content: center;
+  width: 10px;
+  height: 28px;
+}
+
+.slot::before {
+  content: '';
+  width: 0.5px;
+  height: 14px;
+  background: var(--dsw-alias-border-l4);
+}
+
+/* The active chip is a filled capsule and needs no rule against it: the two
+   slots beside it go blank, so the row reads as the capsule between bare
+   chips. A caret in either slot still draws. */
+.tabActive + .slot:not(.slotCaret)::before,
+.slot:not(.slotCaret):has(+ .tabActive)::before {
+  background: transparent;
+}
+
+.slotCaret::before {
   width: 2px;
   height: 20px;
   background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
 }
 
+/* The chip: a 28px capsule whose title runs to the right inset. The close
+   control is not in the flow — it sits over the title's last 14px and shows
+   while the chip is active, hovered, or holds focus — so a chip is the same
+   width with and without it and nothing shifts on hover. */
 .tab {
   position: relative;
   display: flex;
   flex: 0 1 auto;
-  gap: 4px;
   align-items: center;
-  min-width: 44px;
+  min-width: 80px;
   max-width: 170px;
-  height: 24px;
-  padding: 0 5px 0 10px;
+  height: 28px;
+  padding: 0 10px;
   color: var(--dsw-alias-label-secondary);
   font-size: var(--dsh-content-font-size-secondary, 13px);
   line-height: 1;
@@ -163,35 +274,82 @@
   user-select: none;
 }
 
+/* A flex row with a 5px gap, so an embedder's leading glyph (`renderTabTitle`)
+   centres on the text's line rather than sitting on its baseline and keeps
+   one distance from it. The clip is horizontal in intent; the line box must
+   hold the descenders it would otherwise cut, hence a line-height above the
+   chip's own 1. No ellipsis: a title wider than its box (`TabTitle` sets
+   `data-dockkit-tab-clipped`) fades out over its last 16px instead. A mask on
+   the title rather than a painted gradient: the chip's fill is bare,
+   translucent hover, or the active tag colour, and a mask matches every one
+   without naming it. */
 .tabTitle {
+  display: flex;
+  flex: 1 1 auto;
+  gap: 5px;
+  align-items: center;
+  min-width: 0;
   overflow: hidden;
-  text-overflow: ellipsis;
+  line-height: 1.4;
+}
+
+.tabTitle[data-dockkit-tab-clipped] {
+  mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
 }
 
+/* While the close shows, the title is gone under the close's circle and fades
+   out over the 16px before it: the circle spans the chip's last 24px and the
+   title ends 10px inside the chip, so the title's last 14px are under it.
+   Later than the clipped mask, so a clipped chip showing its close fades
+   under the close. A floating panel's header chip has no close, so it keeps
+   the clipped mask alone. */
+.tab:not(.floatTitle):hover .tabTitle,
+.tab:not(.floatTitle):focus-within .tabTitle,
+.tabActive .tabTitle {
+  mask-image: linear-gradient(to right, black calc(100% - 30px), transparent calc(100% - 14px));
+}
+
+/* A 20px icon button inset 4px from the chip's end, holding a 14px glyph in
+   the tertiary ink, so it sits back from the title beside it. */
 .tabClose {
+  position: absolute;
+  top: 4px;
+  right: 4px;
   display: flex;
-  flex: none;
   align-items: center;
   justify-content: center;
-  width: 16px;
-  height: 16px;
+  width: 20px;
+  height: 20px;
   padding: 0;
-  color: inherit;
+  color: var(--dsw-alias-label-tertiary);
   line-height: 1;
   background: transparent;
   border: none;
-  border-radius: 50%;
-  corner-shape: round;
+  border-radius: 20px;
   cursor: pointer;
+  opacity: 0;
+  /* Hidden means untouchable too: without this a touch press on the chip's
+     trailing 20px would close the tab instead of activating it. */
+  pointer-events: none;
 }
 
+/* Shown while the chip is hovered or holds focus, and always on the active
+   chip: the tab in view is the one a reader closes next. */
+.tab:hover .tabClose,
+.tab:focus-within .tabClose,
+.tabActive .tabClose {
+  opacity: 1;
+  pointer-events: auto;
+}
+
+/* The chip's height and corner, so it reads as one more capsule in the row. */
 .addTab {
   display: flex;
   flex: none;
   align-items: center;
   justify-content: center;
-  width: 24px;
-  height: 24px;
+  width: 28px;
+  height: 28px;
   padding: 0;
   color: var(--dsw-alias-label-secondary);
   line-height: 1;
@@ -208,46 +366,72 @@
 
 .tabClose:hover {
   color: var(--dsw-alias-label-primary);
-  background: var(--dsw-alias-interactive-bg-hover-solid);
+  background: var(--dsw-alias-interactive-bg-hover);
 }
 
 .tab:hover {
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
-/* The active chip is the filled capsule; the rest are bare text. */
+/* The active chip is the filled capsule, in the tag fill; the rest are bare text. */
 .tabActive {
   color: var(--dsw-alias-label-primary);
-  background: var(--dsw-alias-interactive-bg-active);
+  background: var(--dsw-alias-markdown-tag);
+}
+
+/* A pane's lone unclosable chip is a label, not a choice: no capsule, no
+   hover fill — the primary ink on the pane's own ground. Its title never
+   yields to a close control, so it keeps the clipped fade alone. */
+.tab.tabQuiet,
+.tab.tabQuiet:hover {
+  color: var(--dsw-alias-label-primary);
+  background: transparent;
+  cursor: default;
+}
+
+.tab.tabQuiet .tabTitle,
+.tab.tabQuiet:hover .tabTitle,
+.tab.tabQuiet:focus-within .tabTitle {
+  mask-image: none;
+}
+
+.tab.tabQuiet .tabTitle[data-dockkit-tab-clipped] {
+  mask-image: linear-gradient(to right, black calc(100% - 16px), transparent);
 }
 
 .tabDragging {
   opacity: 0.5;
 }
 
+/* The geometry of a message's action row button (ui-chat MessageIconActions):
+   a 28px circle around a 15px glyph. */
 .iconButton {
-  display: flex;
+  display: inline-flex;
   flex: none;
   align-items: center;
   justify-content: center;
-  width: 24px;
-  height: 24px;
-  padding: 0;
+  width: 28px;
+  height: 28px;
+  padding: 6px;
   color: var(--dsw-alias-label-secondary);
   line-height: 1;
   background: transparent;
   border: none;
-  border-radius: 4px;
+  border-radius: 28px;
   cursor: pointer;
 }
 
+.iconButton svg {
+  width: 15px;
+  height: 15px;
+}
+
 .iconButton:hover:not(:disabled) {
-  color: var(--dsw-alias-label-primary);
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
 .iconButton:disabled {
-  color: var(--dsw-alias-label-tertiary);
+  opacity: 0.4;
   cursor: default;
 }
 
@@ -288,12 +472,14 @@
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
+/* No padding of its own: a tab's body reaches the pane's edges and the strip's
+   bottom edge, and keeps its own insets, so a header row it draws sits flush
+   under the strip. */
 .paneBody {
   position: relative;
   flex: 1 1 auto;
   min-width: 0;
   min-height: 0;
-  padding: 12px;
   overflow: auto;  /* This sheet draws elevated surfaces (the strip and the menu), so a scroller
      inside it rebinds the thumb indirection in a complete pair — a base-surface
      thumb on an elevated ground reads as a smudge. */
@@ -303,14 +489,40 @@
 
 .empty {
   margin: 0;
+  padding: 12px;
   color: var(--dsw-alias-label-tertiary);
   font-size: var(--dsh-content-font-size-secondary, 13px);
 }
 
+/* The drop hint is a scrim and, over it, one box pair per landing region.
+   The scrim (`.dockScrim`) covers the whole body with the translucent, blurred
+   ground, so the insets between and around the cards dim the body's content
+   instead of letting it show through raw. The outer box (`.dockHint`) is the
+   region a release fills — the whole body, or 40% (edge bands) or 50%
+   (horizontal halves) of it — and draws nothing; it is a padded frame that
+   keeps the card inside it 8px clear of the body's edges and, between two
+   horizontal halves, 4px clear of the seam, so neighbouring cards sit 8px
+   apart like the body's own insets. The card (`.dockHintCard`) is what the eye
+   lands on: a dashed frame with the zone's glyph and caption stacked at its
+   centre. A card that is not under the pointer stays as a quiet outline so the
+   reader sees where the other release would land. */
+.dockScrim {
+  position: absolute;
+  inset: 0;
+  pointer-events: none;
+  /* The ground the panes sit on, not a raised layer: in dark mode `bg-layer-2`
+     is a lighter bluish step than the column's `bg-base`, which tinted the
+     whole scrim away from the content beneath it. */
+  background: color-mix(in srgb, var(--dsw-alias-bg-base) 72%, transparent);
+  backdrop-filter: blur(6px);
+  animation: dockScrimIn 140ms ease-out;
+}
+
 .dockHint {
   position: absolute;
-  background: var(--dsw-alias-bg-multi-select);
-  border: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color);
+  display: flex;
+  box-sizing: border-box;
+  padding: 8px;
   pointer-events: none;
 }
 
@@ -332,15 +544,82 @@
   width: 40%;
 }
 
-[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left'],
+[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left'] {
+  width: 50%;
+  padding-right: 4px;
+}
+
 [data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right'] {
   width: 50%;
+  padding-left: 4px;
 }
 
-[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='left']:not([data-dockkit-drop-active]),
-[data-dockkit-drop-zones='horizontal'] .dockHint[data-dockkit-dock-zone='right']:not([data-dockkit-drop-active]) {
-  background: transparent;
-  border-color: var(--dsw-alias-border-l2);
+.dockHintCard {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  gap: 8px;
+  align-items: center;
+  justify-content: center;
+  min-width: 0;
+  min-height: 0;
+  padding: 12px;
+  overflow: hidden;
+  color: var(--dsw-alias-label-tertiary);
+  border: 1.5px dashed var(--dsw-alias-border-l2);
+  border-radius: 12px;
+  transition: color 120ms ease-out, background-color 120ms ease-out, border-color 120ms ease-out;
+  animation: dockHintIn 140ms ease-out;
+}
+
+/* The card under the pointer takes the accent: a tinted ground over the scrim,
+   an accent dashed frame, and secondary ink for the glyph and caption. */
+.dockHint[data-dockkit-drop-active] .dockHintCard {
+  color: var(--dsw-alias-label-secondary);
+  background: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color) 8%, transparent);
+  border-color: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
+}
+
+.dockHintCard svg {
+  flex: none;
+  width: 20px;
+  height: 20px;
+}
+
+/* One line, clipped rather than wrapped: the card in a narrow half is still
+   wide enough for its glyph, and the caption fades under the frame. */
+.dockHintLabel {
+  max-width: 100%;
+  overflow: hidden;
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  font-weight: 500;
+  line-height: 1.4;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+@keyframes dockHintIn {
+  from {
+    opacity: 0;
+    transform: scale(0.98);
+  }
+
+  to {
+    opacity: 1;
+    transform: none;
+  }
+}
+
+/* The scrim fades in without the cards' scale: a full-body cover that shrinks
+   would expose an uncovered rim of raw content on entry. */
+@keyframes dockScrimIn {
+  from {
+    opacity: 0;
+  }
+
+  to {
+    opacity: 1;
+  }
 }
 
 .dockHint[data-dockkit-dock-zone='top'] {
@@ -361,43 +640,50 @@
   position: fixed;
   display: flex;
   flex-direction: column;
-  background: var(--dsw-alias-bg-layer-1);
-  border: 0.5px solid var(--dsw-alias-border-l2);
-  border-radius: 8px;
-  box-shadow: 0 8px 24px var(--dsw-alias-bg-mask-drop);
+  /* The body inside is unpadded and reaches the edges, so the frame clips it
+     to its own corners. */
+  overflow: hidden;
+  background: var(--dsw-alias-bg-layer-2);
+  border-radius: 20px;
+  /* The menu's shadow with the stroke rebound one step lighter than the
+     default (l2): the panel reads as the same kind of raised surface, and the
+     shadow's hairline outlines it, so the frame draws no border of its own. */
+  --dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
+  box-shadow: var(--dsw-elevation-prominent);
   pointer-events: auto;
 }
 
-/* The active panel keeps the same hairline: it is already on top of the z
-   order and casts the same shadow, and a heavier or darker frame read as a
-   defect. `data-dockkit-float-active` stays on the element for tests. */
+/* The active panel keeps the same frame: it is already on top of the z order
+   and casts the same shadow, and a heavier or darker frame read as a defect.
+   `data-dockkit-float-active` stays on the element for tests. */
 
+/* The panel's header is the pane strip's row (`.tabStrip` supplies the
+   metrics) and the whole of it is the move grip. */
 .floatHeader {
-  display: flex;
-  flex: none;
-  gap: 2px;
-  align-items: center;
-  height: 28px;
-  padding: 0 4px 0 10px;
-  border-bottom: 0.5px solid var(--dsw-alias-border-l1);
   cursor: move;
-  touch-action: none;
   user-select: none;
 }
 
+/* The one tab, drawn as its chip would be in a strip but never selected,
+   hovered, or closable from here: the frame's controls do that. */
 .floatTitle {
-  flex: 1 1 auto;
-  overflow: hidden;
-  color: var(--dsw-alias-label-primary);
-  font-size: var(--dsh-content-font-size-secondary, 13px);
-  white-space: nowrap;
-  text-overflow: ellipsis;
+  flex: 0 1 auto;
+  cursor: inherit;
+}
+
+.tab.floatTitle:hover {
+  background: transparent;
+}
+
+/* Mirrored so the filled pane sits at the right, where the docked surface is. */
+.dockGlyph {
+  transform: scaleX(-1);
 }
 
+/* Unpadded like `.paneBody`: the same body draws the same insets in a float. */
 .floatBody {
   flex: 1 1 auto;
   min-height: 0;
-  padding: 10px;
   overflow: auto;  /* This sheet draws elevated surfaces (the strip and the menu), so a scroller
      inside it rebinds the thumb indirection in a complete pair — a base-surface
      thumb on an elevated ground reads as a smudge. */
@@ -409,19 +695,35 @@
   position: absolute;
   right: 0;
   bottom: 0;
-  width: 14px;
-  height: 14px;
+  width: 20px;
+  height: 20px;
   cursor: nwse-resize;
   touch-action: none;
 }
 
+/* The grip is a quarter arc concentric with the frame's 20px corner (16px
+   radius at a 4px inset), so it reads as part of the frame. It shows only
+   while the pointer is over the panel — an idle panel keeps a clean corner —
+   and `:active` keeps it lit while a drag holds the pointer capture. */
 .floatResize::after {
-  position: absolute;
-  right: 3px;
-  bottom: 3px;
-  width: 6px;
-  height: 6px;
-  border-right: 2px solid var(--dsw-alias-label-tertiary);
-  border-bottom: 2px solid var(--dsw-alias-label-tertiary);
   content: '';
+  position: absolute;
+  right: 4px;
+  bottom: 4px;
+  width: 16px;
+  height: 16px;
+  border-right: 1.5px solid var(--dsw-alias-label-caption);
+  border-bottom: 1.5px solid var(--dsw-alias-label-caption);
+  border-bottom-right-radius: 16px;
+  opacity: 0;
+  transition: opacity 120ms ease-out;
+}
+
+.float:hover .floatResize::after,
+.floatResize:active::after {
+  opacity: 1;
+}
+
+.floatResize:hover::after {
+  border-color: var(--dsw-alias-label-tertiary);
 }

+ 21 - 1
packages/client/ui-dockkit/src/components/measure.ts

@@ -63,12 +63,31 @@ function dividerSize(root: HTMLElement): number {
   return thickness > 0 ? thickness : SPLIT_MINIMUMS.divider
 }
 
+/**
+ * The rendered split control's footprint in the strip's fixed part: its box
+ * plus the strip's own gap, both of which the strip sheds when the control
+ * hides. 0 while the control is hidden or unmeasured.
+ */
+function splitControlFootprint(pane: HTMLElement): number {
+  const control = pane.querySelector('[data-dockkit-split-button]')
+  if (control === null) return 0
+  const width = control.getBoundingClientRect().width
+  if (!(width > 0)) return 0
+  const strip = pane.querySelector('[data-dockkit-strip]')
+  /* v8 ignore next -- the control only renders inside a strip. */
+  return width + (strip === null ? 0 : px(getComputedStyle(strip).columnGap))
+}
+
 /**
  * Measure every docked pane under `root`.
  * @param root - the docked surface's element.
+ * @param splitHiddenWhenBlocked - whether the embedder hides blocked split
+ * controls (`hideSplitWhenBlocked`); the room rule then leaves the control's
+ * footprint out of each strip's fixed part, so the reading cannot flip with
+ * the control's visibility (see `PaneMeasure.splitControlWidth`).
  * @returns each pane's fit, keyed by pane id.
  */
-export function measurePaneFits(root: HTMLElement): ReadonlyMap<PaneId, HalvesFit> {
+export function measurePaneFits(root: HTMLElement, splitHiddenWhenBlocked = false): ReadonlyMap<PaneId, HalvesFit> {
   const minimums: SplitMinimums = { divider: dividerSize(root), chip: chipMinimum(root), body: SPLIT_MINIMUMS.body }
   const fits = new Map<PaneId, HalvesFit>()
   for (const [paneId, pane] of paneElements(root)) {
@@ -77,6 +96,7 @@ export function measurePaneFits(root: HTMLElement): ReadonlyMap<PaneId, HalvesFi
       strip: rectOf(pane.querySelector('[data-dockkit-strip]')),
       chipsWidth: rectOf(pane.querySelector('[data-dockkit-strip-tabs]')).width,
       fillWidth: rectOf(pane.querySelector('[data-dockkit-strip-fill]')).width,
+      splitControlWidth: splitHiddenWhenBlocked ? splitControlFootprint(pane) : 0,
     }, minimums))
   }
   return fits

+ 3 - 3
packages/client/ui-dockkit/src/components/render.ts

@@ -23,11 +23,11 @@ export interface PaneCallbacks {
   readonly onDividerPressed: (splitId: SplitId, index: number, event: ReactPointerEvent<HTMLElement>) => void
   /** Why a pane cannot split right now, or `undefined` while it can. */
   readonly splitBlock: (paneId: PaneId) => SplitBlock | undefined
-  /** Hide budget-blocked split controls without hiding width-blocked controls. */
-  readonly hideSplitAtCapacity?: boolean
+  /** Hide blocked split controls instead of rendering them disabled. */
+  readonly hideSplitWhenBlocked?: boolean
   /** Whether a pane's strip draws the add control. */
   readonly canAddTab: (paneId: PaneId) => boolean
-  /** Whether a tab's chip and menu offer close. */
+  /** Whether a tab draws its close control and its menu's close item. */
   readonly canCloseTab: (tabId: TabId) => boolean
   /** Live drop preview, or `undefined` while nothing is being dragged. */
   readonly dropTarget: DropTarget | undefined

+ 5 - 1
packages/client/ui-dockkit/src/contract/adapter.ts

@@ -31,6 +31,8 @@ export interface DockLabels {
   readonly dockFloat: string
   /** Close a floating panel. */
   readonly closeFloat: string
+  /** The drop hint's caption for each body zone a dragged tab can land on. */
+  readonly dropZone: Readonly<Record<DockZone, string>>
 }
 
 /**
@@ -45,7 +47,9 @@ export type TabRenderer = (tab: TabRecord) => ReactNode
  *
  * The kit's own item is the close gesture; anything that means something about
  * the tab's content comes from here. An item that acts MUST call `dismiss`,
- * because the menu closes on its own items only.
+ * because the menu closes on its own items only. Every rendered item MUST
+ * carry `role="menuitem"`: the kit probes for that role to dismiss a menu
+ * that would paint empty, so items without it count as an empty menu.
  * @param tab - the tab whose menu is open.
  * @param dismiss - close the menu without acting.
  * @returns extra actions with ARIA menuitem, menuitemcheckbox, or menuitemradio roles, or nothing.

+ 23 - 10
packages/client/ui-dockkit/src/engine/geometry.ts

@@ -71,6 +71,17 @@ export interface PaneMeasure {
   readonly chipsWidth: number
   /** Width of the fill: free space, not a control. */
   readonly fillWidth: number
+  /**
+   * Footprint of the rendered split control (its width plus the strip's gap)
+   * for embedders that hide blocked split controls: a half too narrow to
+   * split hides its own control, so the rule leaves the footprint out of the
+   * fixed part. Leaving it out is also what keeps the reading stable — the
+   * control hiding sheds the same footprint from the strip, and a reading
+   * that counted it would flip with the control's visibility and re-render
+   * forever. Absent or 0 keeps the control in the fixed part, for embedders
+   * that render a blocked control disabled.
+   */
+  readonly splitControlWidth?: number
 }
 
 /** Pixel minimums the room rule holds each half to. */
@@ -79,18 +90,19 @@ export interface SplitMinimums {
   readonly divider: number
   /** One chip at its minimum: the smallest strip that still names a tab. */
   readonly chip: number
-  /** The smallest body under a strip: one secondary text line inside the body's padding. */
+  /** The smallest body under a strip: one secondary text line inside 12px of the body's own insets. */
   readonly body: number
 }
 
 /**
  * The minimums where no computed style can be read, mirroring
- * `dockkit.module.css`: `.splitRow > .divider` is 4px wide; `.tab` is 44px of
- * content plus 10px + 5px of padding (content-box), 59px; the body's 12px
- * padding above and below one 13px secondary line at 1.6 line-height is 45px,
- * held to 48px.
+ * `dockkit.module.css`: `.splitRow > .divider` takes no layout width (its
+ * hairline is painted over the seam); `.tab` is 80px of content plus
+ * 10px + 10px of padding (content-box), 100px; 12px above and below one 13px
+ * secondary line at 1.6 line-height — the inset a body draws for itself, as
+ * `.empty` does — is 45px, held to 48px.
  */
-export const SPLIT_MINIMUMS: SplitMinimums = { divider: 4, chip: 59, body: 48 }
+export const SPLIT_MINIMUMS: SplitMinimums = { divider: 0, chip: 100, body: 48 }
 
 /** Whether a pane's two halves after an equal split would each still work. */
 export interface HalvesFit {
@@ -102,9 +114,10 @@ export interface HalvesFit {
 
 /**
  * The room rule. After an equal split each half must hold what cannot shrink:
- * horizontally the strip's fixed part — its width minus the chip box and the
- * fill, which is the padding, the gaps, and every control that pane draws —
- * plus one chip at its minimum; vertically the strip plus a minimum body. The
+ * horizontally the strip's fixed part — its width minus the chip box, the
+ * fill, and `splitControlWidth`, which is the padding, the gaps, and every
+ * control a half would still draw — plus one chip at its minimum; vertically
+ * the strip plus a minimum body. The
  * borders are what the pane's box exceeds the strip's by. An unmeasured pane
  * (no layout, as under jsdom) fits: the rule only blocks on a positive reading.
  * @param measure - the pane's rectangles.
@@ -115,7 +128,7 @@ export function halvesFit(measure: PaneMeasure, minimums: SplitMinimums = SPLIT_
   const { pane, strip } = measure
   if (!(pane.width > 0) || !(pane.height > 0) || !(strip.width > 0)) return { row: true, column: true }
   const borders = Math.max(0, pane.width - strip.width)
-  const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth)
+  const fixed = Math.max(0, strip.width - measure.chipsWidth - measure.fillWidth - (measure.splitControlWidth ?? 0))
   const halfWidth = (pane.width - minimums.divider) / 2 - borders
   const halfHeight = (pane.height - minimums.divider) / 2 - borders
   return {

+ 282 - 19
packages/client/ui-dockkit/tests/components.client.spec.tsx

@@ -260,14 +260,30 @@ describe('DockSurface', () => {
     expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget')
   })
 
-  it('hides capacity-blocked split controls when opted in and restores them when capacity returns', () => {
+  it('names the enabled split control through the shared tooltip, not a native title', () => {
+    vi.useFakeTimers()
+    try {
+      renderSurface(seededController(), spyIntents())
+      const button = screen.getByRole('button', { name: TEST_LABELS.splitPane })
+      expect(button.hasAttribute('title')).toBe(false)
+      fireEvent.mouseEnter(button)
+      act(() => { vi.advanceTimersByTime(500) })
+      expect(screen.getByRole('tooltip').textContent).toBe(TEST_LABELS.splitPane)
+      fireEvent.mouseLeave(button)
+      expect(screen.queryByRole('tooltip')).toBeNull()
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
+  it('hides blocked split controls when opted in and restores them when capacity returns', () => {
     const controller = seededController()
     controller.splitPane()
     const state = controller.getSnapshot().state
-    layOut(dockPaneIds(state), 420)
+    layOut(dockPaneIds(state), 520)
     const intents = spyIntents()
     const props: DockSurfaceProps = {
-      state, canSplit: false, hideSplitAtCapacity: true, intents, labels: TEST_LABELS, renderTab: tab => <p>{tab.title}</p>,
+      state, canSplit: false, hideSplitWhenBlocked: true, intents, labels: TEST_LABELS, renderTab: tab => <p>{tab.title}</p>,
     }
     const view = render(<DockSurface {...props} />)
     expect(screen.queryByRole('button', { name: TEST_LABELS.splitPane })).toBeNull()
@@ -285,15 +301,15 @@ describe('DockSurface', () => {
   // jsdom lays nothing out, so the room rule reads the rectangles this spec
   // hands it: two panes, one wide enough for two halves and one not. The
   // strip's fixed part is 104px in both (the chrome pane's controls), the chip
-  // minimum falls back to the stylesheet's 59px.
-  it.each([false, true])('keeps the width-blocked split control and its title with hideSplitAtCapacity=%s', (hideSplitAtCapacity) => {
+  // minimum falls back to the stylesheet's 100px.
+  it.each([false, true])('disables or hides the width-blocked split control with hideSplitWhenBlocked=%s', (hideSplitWhenBlocked) => {
     const controller = seededController()
     controller.setExpanded(true)
     controller.splitPane()
     const snapshot = controller.getSnapshot()
     const [wide, narrow] = dockPaneIds(snapshot.state)
     if (wide === undefined || narrow === undefined) throw new Error('expected two docked panes')
-    const widths: Record<string, number> = { [wide]: 420, [narrow]: 208 }
+    const widths: Record<string, number> = { [wide]: 520, [narrow]: 208 }
     vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
       const paneWidth = widths[this.closest<HTMLElement>('[data-dockkit-pane]')?.dataset.dockkitPane ?? ''] ?? 0
       if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
@@ -306,7 +322,7 @@ describe('DockSurface', () => {
       <DockSurface
         state={snapshot.state}
         canSplit
-        hideSplitAtCapacity={hideSplitAtCapacity}
+        hideSplitWhenBlocked={hideSplitWhenBlocked}
         intents={controller}
         labels={TEST_LABELS}
         renderTab={tab => <p>{tab.contentId}</p>}
@@ -315,10 +331,14 @@ describe('DockSurface', () => {
     const wideButton = document.querySelector(`[data-dockkit-split-button="${wide}"]`)
     const narrowButton = document.querySelector(`[data-dockkit-split-button="${narrow}"]`)
     expect(wideButton?.hasAttribute('disabled')).toBe(false)
-    expect(wideButton?.getAttribute('title')).toBe(TEST_LABELS.splitPane)
-    expect(narrowButton?.hasAttribute('disabled')).toBe(true)
-    expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow)
-    expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width')
+    expect(wideButton?.hasAttribute('title')).toBe(false)
+    if (hideSplitWhenBlocked) {
+      expect(narrowButton).toBeNull()
+    } else {
+      expect(narrowButton?.hasAttribute('disabled')).toBe(true)
+      expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow)
+      expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width')
+    }
   })
 
   it('reports the room readings through onRoom, and re-reads them when the surface resizes', () => {
@@ -342,7 +362,7 @@ describe('DockSurface', () => {
     controller.splitPane()
     const snapshot = controller.getSnapshot()
     const panes = dockPaneIds(snapshot.state)
-    let paneWidth = 420
+    let paneWidth = 520
     vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
       if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
       if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
@@ -379,6 +399,194 @@ describe('DockSurface', () => {
     expect(observer.disconnect).toHaveBeenCalledTimes(1)
   })
 
+  // The room reading must not flip with the hidden control's own footprint:
+  // hiding the width-blocked control widens the fill by the control plus the
+  // strip's gap, and a reading that counted the control would fit again, show
+  // it, and re-render forever (React's update-depth limit, which crashed the
+  // surface). The rule leaves the footprint out, so the fill the fake hands
+  // back here depends on whether the control is in the DOM — exactly the
+  // feedback the fix breaks.
+  it('reads the same room whether hideSplitWhenBlocked has hidden the split control or not', () => {
+    class FakeResizeObserver implements ResizeObserver {
+      static latest: FakeResizeObserver | undefined
+      readonly observe = vi.fn()
+      readonly unobserve = vi.fn()
+      readonly disconnect = vi.fn()
+      constructor(private readonly callback: ResizeObserverCallback) {
+        FakeResizeObserver.latest = this
+      }
+
+      /** What the platform does when the observed element's size changes. */
+      fire(): void {
+        this.callback([], this)
+      }
+    }
+    vi.stubGlobal('ResizeObserver', FakeResizeObserver)
+    const controller = seededController()
+    controller.setExpanded(true)
+    const snapshot = controller.getSnapshot()
+    // In the band where only the control's 28px footprint decides the fit:
+    // half 188px against 76px of other fixed controls plus the 100px chip.
+    let paneWidth = 380
+    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      const pane = this.closest<HTMLElement>('[data-dockkit-pane]')
+      if (this.hasAttribute('data-dockkit-pane')) return box(0, 0, paneWidth, 600)
+      if (this.hasAttribute('data-dockkit-strip')) return box(0, 0, paneWidth - 2, 36)
+      if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 60, 24)
+      if (this.hasAttribute('data-dockkit-split-button')) return box(0, 0, 28, 28)
+      if (this.hasAttribute('data-dockkit-strip-fill')) {
+        const fixed = pane?.querySelector('[data-dockkit-split-button]') === null ? 76 : 104
+        return box(0, 0, Math.max(0, paneWidth - 2 - 60 - fixed), 24)
+      }
+      return box(0, 0, 0, 0)
+    })
+    render(
+      <DockSurface
+        state={snapshot.state}
+        canSplit
+        hideSplitWhenBlocked
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+      />,
+    )
+    // Settled with the control shown: the discounted reading fits either way.
+    expect(document.querySelector('[data-dockkit-split-button]')).not.toBeNull()
+
+    // Narrowed under the discounted minimum: hidden, and the reading without
+    // the control settles hidden.
+    const observer = FakeResizeObserver.latest
+    if (observer === undefined) throw new Error('expected the surface to observe its own size')
+    paneWidth = 300
+    act(() => { observer.fire() })
+    expect(document.querySelector('[data-dockkit-split-button]')).toBeNull()
+  })
+
+  it('names the chip box\'s hidden sides in data-dockkit-strip-scroll as it scrolls', () => {
+    let scrollLeft = 0
+    const descriptors = ['scrollLeft', 'scrollWidth', 'clientWidth'].map(name =>
+      [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
+    const strip = (element: Element): boolean => element.hasAttribute('data-dockkit-strip-tabs')
+    Object.defineProperty(Element.prototype, 'scrollLeft', { configurable: true, get(this: Element) { return strip(this) ? scrollLeft : 0 } })
+    Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return strip(this) ? 400 : 0 } })
+    Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return strip(this) ? 200 : 0 } })
+    try {
+      const controller = seededController()
+      renderSurface(controller, spyIntents())
+      const box = document.querySelector<HTMLElement>('[data-dockkit-strip-tabs]')
+      if (box === null) throw new Error('expected the chip box')
+      expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('end')
+      scrollLeft = 100
+      fireEvent.scroll(box)
+      expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start end')
+      scrollLeft = 200
+      fireEvent.scroll(box)
+      expect(box.getAttribute('data-dockkit-strip-scroll')).toBe('start')
+    } finally {
+      for (const [name, descriptor] of descriptors) {
+        if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)
+        else Object.defineProperty(Element.prototype, name, descriptor)
+      }
+    }
+  })
+
+  it('scrolls the chip box to the active chip when it lies past either edge, clearing the fade band', () => {
+    let scrollLeft = 0
+    const descriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollLeft')
+    Object.defineProperty(Element.prototype, 'scrollLeft', {
+      configurable: true,
+      get() { return scrollLeft },
+      set(value: number) { scrollLeft = value },
+    })
+    // The box spans 0..200; the seeded chip sits at 40..130 and the opened one where `openedAt` says.
+    let openedTab: TabId | undefined
+    let openedAt = 250
+    vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+      if (this.hasAttribute('data-dockkit-strip-tabs')) return box(0, 0, 200, 28)
+      if (this.getAttribute('data-dockkit-tab') === openedTab) return box(openedAt, 0, 90, 28)
+      if (this.hasAttribute('data-dockkit-tab')) return box(40, 0, 90, 28)
+      return box(0, 0, 0, 0)
+    })
+    const surface = (controller: DockController) => (
+      <DockSurface
+        state={controller.getSnapshot().state}
+        canSplit
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+      />
+    )
+    try {
+      const controller = seededController()
+      const seededTab = getPane(controller.getSnapshot().state, controller.getSnapshot().state.activePaneId).activeTabId
+      if (seededTab === undefined) throw new Error('expected the seeded tab')
+      const { rerender } = render(surface(controller))
+      // The seeded chip is in view: nothing moves.
+      expect(scrollLeft).toBe(0)
+      openedTab = controller.openContent({ contentId: 'dsh-resource://file/session/s/b.txt', title: 'b.txt', kind: 'file' })
+      rerender(surface(controller))
+      // Past the right edge by 140, plus the 24px fade.
+      expect(scrollLeft).toBe(164)
+      // Selected again once it lies 30 past the left edge: back by 30 plus the fade.
+      openedAt = -30
+      controller.focusTab(seededTab)
+      rerender(surface(controller))
+      controller.focusTab(openedTab)
+      rerender(surface(controller))
+      expect(scrollLeft).toBe(164 - 30 - 24)
+    } finally {
+      if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, 'scrollLeft')
+      else Object.defineProperty(Element.prototype, 'scrollLeft', descriptor)
+    }
+  })
+
+  it('marks a chip title clipped while its text is wider than its box, re-reading on resize', () => {
+    class FakeResizeObserver implements ResizeObserver {
+      static readonly all: FakeResizeObserver[] = []
+      readonly observe = vi.fn()
+      readonly unobserve = vi.fn()
+      readonly disconnect = vi.fn()
+      constructor(private readonly callback: ResizeObserverCallback) {
+        FakeResizeObserver.all.push(this)
+      }
+
+      fire(): void {
+        this.callback([], this)
+      }
+    }
+    vi.stubGlobal('ResizeObserver', FakeResizeObserver)
+    let clientWidth = 200
+    const descriptors = ['scrollWidth', 'clientWidth'].map(name =>
+      [name, Object.getOwnPropertyDescriptor(Element.prototype, name)] as const)
+    const title = (element: Element): boolean => element.hasAttribute('data-dockkit-tab-title')
+    Object.defineProperty(Element.prototype, 'scrollWidth', { configurable: true, get(this: Element) { return title(this) ? 120 : 0 } })
+    Object.defineProperty(Element.prototype, 'clientWidth', { configurable: true, get(this: Element) { return title(this) ? clientWidth : 0 } })
+    try {
+      const controller = seededController()
+      const { unmount } = renderSurface(controller, spyIntents())
+      const span = document.querySelector<HTMLElement>('[data-dockkit-tab-title]')
+      if (span === null) throw new Error('expected a chip title')
+      const observer = FakeResizeObserver.all.find(candidate => candidate.observe.mock.calls.some(([target]) => target === span))
+      if (observer === undefined) throw new Error('expected the title to observe its own size')
+      expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false)
+      // The chip narrowed under the text.
+      clientWidth = 80
+      act(() => { observer.fire() })
+      expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(true)
+      clientWidth = 200
+      act(() => { observer.fire() })
+      expect(span.hasAttribute('data-dockkit-tab-clipped')).toBe(false)
+      unmount()
+      expect(observer.disconnect).toHaveBeenCalledTimes(1)
+    } finally {
+      for (const [name, descriptor] of descriptors) {
+        if (descriptor === undefined) Reflect.deleteProperty(Element.prototype, name)
+        else Object.defineProperty(Element.prototype, name, descriptor)
+      }
+      vi.unstubAllGlobals()
+    }
+  })
+
   it('asks for the seeded tab from the strip\'s add control, naming the pane and nothing else', () => {
     const controller = seededController()
     const intents = spyIntents()
@@ -414,6 +622,59 @@ describe('DockSurface', () => {
     expect(strip?.querySelector('[data-dockkit-strip-tabs]')).not.toBeNull()
   })
 
+  it('withholds a tab\'s close control and menu close item where the embedder\'s canCloseTab denies, tab by tab', () => {
+    const controller = seededController()
+    controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
+    const snapshot = controller.getSnapshot()
+    const seedTabId = getPane(snapshot.state, snapshot.state.activePaneId).tabs[0]
+    if (seedTabId === undefined) throw new Error('expected seeded tab')
+    render(
+      <DockSurface
+        state={snapshot.state}
+        canSplit
+        canCloseTab={tabId => tabId !== seedTabId}
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+        renderTabMenuItems={(_, dismiss) => <button type="button" role="menuitem" onClick={dismiss}>embedder item</button>}
+      />,
+    )
+    const [seedChip, fileChip] = screen.getAllByRole('tab')
+    if (seedChip === undefined || fileChip === undefined) throw new Error('expected two chips')
+    expect(seedChip.querySelector('[data-dockkit-tab-close]')).toBeNull()
+    expect(fileChip.querySelector('[data-dockkit-tab-close]')).not.toBeNull()
+
+    // The menu still opens on the withheld chip: the embedder's items remain reachable.
+    fireEvent.contextMenu(seedChip)
+    expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['embedder item'])
+    fireEvent.pointerDown(document.body)
+
+    fireEvent.contextMenu(fileChip)
+    expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([TEST_LABELS.closeTab, 'embedder item'])
+  })
+
+  it('draws a lone unclosable chip quiet, and shows no menu on it when the embedder renders no item', () => {
+    const controller = seededController()
+    render(
+      <DockSurface
+        state={controller.getSnapshot().state}
+        canSplit
+        canCloseTab={() => false}
+        intents={controller}
+        labels={TEST_LABELS}
+        renderTab={tab => <p>{tab.contentId}</p>}
+        renderTabMenuItems={() => undefined}
+      />,
+    )
+    const [chip] = screen.getAllByRole('tab')
+    if (chip === undefined) throw new Error('expected the seeded chip')
+    // The lone unclosable chip draws quiet: no capsule, no hover fill.
+    expect(chip.getAttribute('data-dockkit-tab-quiet')).toBe('true')
+    // The menu would hold nothing, so it dismisses itself before painting.
+    fireEvent.contextMenu(chip)
+    expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull()
+  })
+
   it('lets the embedder render a chip\'s title, and shows the record\'s text when it does not', () => {
     const controller = seededController()
     controller.openContent({ contentId: 'dsh-resource://file/session/s/a.txt', title: 'a.txt', kind: 'file' })
@@ -648,13 +909,13 @@ describe('tab drags', () => {
   })
 
   it('shows the dock hint on the pane under the pointer and reports the zone on release', () => {
-    const { intents, second, fileTabId, chip } = twoPanes()
-    drag(chip(fileTabId), FILE_CHIP, [420 + 210, 300], false)
+    const { intents, second, fileTabId, chip } = twoPanes(520)
+    drag(chip(fileTabId), FILE_CHIP, [520 + 260, 300], false)
     const hint = document.querySelector(`[data-dockkit-pane="${second}"] [data-dockkit-dock-zone]`)
     expect(hint?.getAttribute('data-dockkit-dock-zone')).toBe('center')
-    fireEvent.pointerMove(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 })
+    fireEvent.pointerMove(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 })
     expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('right')
-    fireEvent.pointerUp(window, { pointerId: 7, clientX: 420 + 410, clientY: 300 })
+    fireEvent.pointerUp(window, { pointerId: 7, clientX: 520 + 510, clientY: 300 })
     expect(intents.dropTab).toHaveBeenCalledWith(fileTabId, second, 'right')
     expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
   })
@@ -672,6 +933,8 @@ describe('tab drags', () => {
     const { intents, second, fileTabId, chip } = twoPanes(208)
     drag(chip(fileTabId), FILE_CHIP, [208 + 200, 300], false)
     expect(document.querySelector('[data-dockkit-dock-zone]')).toBeNull()
+    fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 500 })
+    expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('bottom')
     fireEvent.pointerMove(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 })
     expect(document.querySelector('[data-dockkit-dock-zone]')?.getAttribute('data-dockkit-dock-zone')).toBe('top')
     fireEvent.pointerUp(window, { pointerId: 7, clientX: 208 + 104, clientY: 100 })
@@ -764,10 +1027,10 @@ describe('tab drags', () => {
 
 describe('horizontal workbench drops', () => {
   it.each([
-    { x: 450, y: 300, zone: 'left' },
-    { x: 810, y: 590, zone: 'right' },
+    { x: 550, y: 300, zone: 'left' },
+    { x: 1010, y: 590, zone: 'right' },
   ] as const)('offers both halves and targets $zone at ($x, $y)', ({ x, y, zone }) => {
-    const { intents, second, fileTabId, chip } = twoPanes(420, true, { dropZones: 'horizontal' })
+    const { intents, second, fileTabId, chip } = twoPanes(520, true, { dropZones: 'horizontal' })
     drag(chip(fileTabId), FILE_CHIP, [x, y], false)
     const hints = document.querySelectorAll('[data-dockkit-dock-zone]')
     expect([...hints].map(hint => hint.getAttribute('data-dockkit-dock-zone'))).toEqual(['left', 'right'])

+ 1 - 0
packages/client/ui-dockkit/tests/fixtures.client.ts

@@ -44,6 +44,7 @@ export const TEST_LABELS: DockLabels = {
   addTab: 'new tab',
   dockFloat: 'dock',
   closeFloat: 'close panel',
+  dropZone: { center: 'move here', left: 'split left', right: 'split right', top: 'split top', bottom: 'split bottom' },
 }
 
 /** Brand a literal a spec spells out: an id the kit would have minted. */

+ 13 - 13
packages/client/ui-dockkit/tests/geometry.client.spec.ts

@@ -118,26 +118,26 @@ describe('halvesFit — the room rule', () => {
   })
 
   it('needs each half to hold the strip\'s fixed controls plus one minimum chip', () => {
-    // 420px: halves of 206px inside the borders, against 104 + 59.
-    expect(halvesFit(measure(420, 600, 104)).row).toBe(true)
-    // 208px: halves of 100px, short of 163.
+    // 520px: halves of 258px inside the borders, against 104 + 100.
+    expect(halvesFit(measure(520, 600, 104)).row).toBe(true)
+    // 208px: halves of 102px, short of 204.
     expect(halvesFit(measure(208, 600, 104)).row).toBe(false)
-    // The boundary is inclusive: 2 * (163 + 2 borders) + 4 divider = 334.
-    expect(halvesFit(measure(334, 600, 104)).row).toBe(true)
-    expect(halvesFit(measure(333, 600, 104)).row).toBe(false)
-    // A strip with fewer controls needs less.
-    expect(halvesFit(measure(208, 600, 44)).row).toBe(false)
-    expect(halvesFit(measure(214, 600, 44)).row).toBe(true)
+    // The boundary is inclusive: 2 * (204 + 2 borders) = 412; the divider takes no room.
+    expect(halvesFit(measure(412, 600, 104)).row).toBe(true)
+    expect(halvesFit(measure(411, 600, 104)).row).toBe(false)
+    // A strip with fewer controls needs less: 2 * (144 + 2) = 292.
+    expect(halvesFit(measure(291, 600, 44)).row).toBe(false)
+    expect(halvesFit(measure(292, 600, 44)).row).toBe(true)
   })
 
   it('needs each half to hold the strip plus a minimum body for a column split', () => {
-    // Halves of (h - 4) / 2 - 2 against 36 + 48 = 84.
-    expect(halvesFit(measure(420, 176, 104)).column).toBe(true)
-    expect(halvesFit(measure(420, 175, 104)).column).toBe(false)
+    // Halves of h / 2 - 2 against 36 + 48 = 84.
+    expect(halvesFit(measure(420, 172, 104)).column).toBe(true)
+    expect(halvesFit(measure(420, 171, 104)).column).toBe(false)
   })
 
   it('takes the minimums it is given, and the stylesheet\'s by default', () => {
-    expect(SPLIT_MINIMUMS).toEqual({ divider: 4, chip: 59, body: 48 })
+    expect(SPLIT_MINIMUMS).toEqual({ divider: 0, chip: 100, body: 48 })
     expect(halvesFit(measure(208, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(false)
     expect(halvesFit(measure(220, 600, 104), { divider: 0, chip: 0, body: 0 }).row).toBe(true)
   })

+ 6 - 6
packages/client/ui-dockkit/tests/measure.client.spec.ts

@@ -57,21 +57,21 @@ describe('measurePaneFits', () => {
     const root = surface()
     const bare = document.createElement('section')
     bare.dataset.dockkitPane = 'bare'
-    root.append(pane('wide', 420), bare)
+    root.append(pane('wide', 520), bare)
     expect(paneElements(root).map(([id]) => id)).toEqual(['wide', 'bare'])
     const fits = measurePaneFits(root)
     expect(fits.get(asPane('wide'))).toEqual({ row: true, column: true })
     expect(fits.get(asPane('bare'))).toEqual({ row: true, column: true })
   })
 
-  // 308px: halves of 150px inside the borders, against 104px of controls plus one chip.
+  // 308px: halves of 152px inside the borders, against 104px of controls plus one chip.
   it('reads the chip minimum from a rendered chip\'s computed style, padding included for a content box', () => {
     const root = surface()
     const narrow = pane('p', 308)
     root.append(narrow)
-    // No chip rendered: the stylesheet's 59px, so 163 > 150.
+    // No chip rendered: the stylesheet's 100px, so 204 > 152.
     expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
-    // 44px of content plus 4px + 4px of padding is 52: 156 > 150.
+    // 44px of content plus 4px + 4px of padding is 52: 156 > 152.
     const rendered = chip(narrow, { minWidth: '44px', paddingLeft: '4px', paddingRight: '4px', boxSizing: 'content-box' })
     expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
     // The same declaration as a border box is the whole footprint: 148 fits.
@@ -84,10 +84,10 @@ describe('measurePaneFits', () => {
     expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(false)
   })
 
-  // 336px: halves of 164px against 163 with the stylesheet's 4px divider, 162 with an 8px one.
+  // 416px: halves of 204px against 204 with the stylesheet's zero divider, 202 with an 8px one.
   it('reads the divider\'s thickness from a rendered divider, and the stylesheet\'s before one exists', () => {
     const root = surface()
-    root.append(pane('p', 336))
+    root.append(pane('p', 416))
     expect(measurePaneFits(root).get(asPane('p'))?.row).toBe(true)
     const divider = document.createElement('div')
     divider.dataset.dockkitDivider = 's:0'

+ 3 - 0
packages/client/ui-dockkit/tsconfig.json

@@ -10,6 +10,9 @@
   "references": [
     {
       "path": "../../util/brand"
+    },
+    {
+      "path": "../ui-primitives"
     }
   ]
 }

+ 4 - 4
packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css

@@ -1,13 +1,13 @@
-/* The split button sits beside the session-log capsule at the same compact
-   scale (26px tall, pill radius, hairline l4 border, 11px primary-color label). */
+/* The split button matches the header's 28px control row (pill radius,
+   hairline l4 border, 11px primary-color label) beside the round icon buttons. */
 
 .split {
   display: inline-flex;
   align-items: stretch;
   box-sizing: border-box;
-  height: 26px;
+  height: 28px;
   border: 0.5px solid var(--dsw-alias-border-l4);
-  border-radius: 13px;
+  border-radius: 14px;
   overflow: hidden;
   font-family: var(--dsw-font-family);
 }

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 7b670394f6ca70ea369fc79766b26119f670f753
-README.zh.md: 8ddefde928a723992945f801fb7407a2d22f5f27
+README.md: 8fd59c5ecc98a609f1383e428701cf590da95e73
+README.zh.md: 2478330825681f65aa83415c698cf0edc54fdad6

Fișier diff suprimat deoarece este prea mare
+ 1 - 1
packages/client/ui-primitives/README.md


Fișier diff suprimat deoarece este prea mare
+ 1 - 1
packages/client/ui-primitives/README.zh.md


+ 167 - 0
packages/client/ui-primitives/src/FileTypeIcon.tsx

@@ -0,0 +1,167 @@
+/**
+ * The coloured file-type sheet: a 28×28 paper with a folded corner, filled in
+ * the type's colour, carrying a white mark for the type. Unlike the `ic_ds_*`
+ * glyphs it does not ride `currentColor` — the colour is the type's identity —
+ * so a consumer that wants it muted applies a CSS filter. Design sources:
+ * DSHFiles iconCode, iconHTML, iconImage, iconMD, iconPDF, iconExcel,
+ * iconPPT, iconWord, iconOthers.
+ *
+ * TODO: interim. The sheets and `classifyFileType` are drawn for the Sidebar
+ * only; the product-wide file-type standard that will replace them is described
+ * in `file-extensions.ts`.
+ */
+import type { ReactNode } from 'react'
+import {
+  CODE_EXTENSIONS, HTML_EXTENSIONS, IMAGE_EXTENSIONS, MARKDOWN_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS,
+  SLIDES_EXTENSIONS, WORD_EXTENSIONS, fileExtension,
+} from './file-extensions.ts'
+import type { IconProps } from './icons/props.ts'
+
+/** File types with their own sheet colour and mark; `other` is the grey sheet with text lines. */
+export type FileTypeKind = 'code' | 'html' | 'image' | 'markdown' | 'pdf' | 'sheet' | 'slides' | 'document' | 'other'
+
+/** Props for {@link FileTypeIcon}: the type plus the shared icon sizing seat. */
+export interface FileTypeIconProps extends IconProps {
+  kind: FileTypeKind
+}
+
+/** Extension sets in precedence order: a specific type wins over the code set it may also sit in. */
+const KIND_SETS: readonly (readonly [FileTypeKind, ReadonlySet<string>])[] = [
+  ['markdown', MARKDOWN_EXTENSIONS],
+  ['html', HTML_EXTENSIONS],
+  ['pdf', PDF_EXTENSIONS],
+  ['sheet', SHEET_EXTENSIONS],
+  ['slides', SLIDES_EXTENSIONS],
+  ['document', WORD_EXTENSIONS],
+  ['image', IMAGE_EXTENSIONS],
+  ['code', CODE_EXTENSIONS],
+]
+
+/**
+ * Derive a file path's type glyph from its extension. Unknown and missing
+ * extensions fall to `other`.
+ * @param path - File path or bare name, with either separator.
+ * @returns The file's type.
+ */
+export function classifyFileType(path: string): FileTypeKind {
+  const extension = fileExtension(path)
+  return KIND_SETS.find(([, set]) => set.has(extension))?.[0] ?? 'other'
+}
+
+const SHEET_PATH = 'M8.48949 28H19.511C21.6482 28 22.7167 28 23.5596 27.6509C24.6835 27.1853 25.5764 26.2924 26.042 25.1685C26.3911 24.3256 26.3911 23.257 26.3911 21.1199V8.79443C26.3911 8.32877 26.3911 8.09593 26.3473 7.87507C26.2889 7.58058 26.1733 7.30042 26.007 7.05048C25.8822 6.86303 25.718 6.69799 25.3895 6.36792L20.0613 1.01354C19.7307 0.681235 19.5653 0.515081 19.3771 0.38885C19.1263 0.220541 18.8446 0.103463 18.5483 0.0443412C18.3261 0 18.0917 0 17.6229 0H8.48949C6.35233 0 5.28376 0 4.44085 0.349145C3.31697 0.814671 2.42405 1.70759 1.95852 2.83147C1.60938 3.67438 1.60938 4.74296 1.60938 6.88011V21.1199C1.60938 23.257 1.60938 24.3256 1.95852 25.1685C2.42405 26.2924 3.31697 27.1853 4.44085 27.6509C5.28376 28 6.35233 28 8.48949 28Z'
+
+const CORNER_PATH = 'M26.3911 7.37445L19.0527 0V3.77445C19.0527 4.89271 19.0527 5.45184 19.2354 5.89289C19.479 6.48096 19.9462 6.94818 20.5343 7.19176C20.9753 7.37445 21.5345 7.37445 22.6527 7.37445H26.3911Z'
+
+/** One type's sheet colour and its white mark. */
+interface Face {
+  readonly fill: string
+  readonly mark: ReactNode
+}
+
+const FACES: Readonly<Record<FileTypeKind, Face>> = {
+  code: {
+    fill: '#4176E6',
+    mark: (
+      <>
+        <path d="M8.61 16.3601L11.76 18.3901V20.1401L7 17.0601V15.6601L11.76 12.5801V14.3301L8.61 16.3601Z" fill="white" />
+        <path d="M16.1918 14.3301V12.5801L20.9518 15.6601V17.0601L16.1918 20.1401V18.3901L19.3418 16.3601L16.1918 14.3301Z" fill="white" />
+      </>
+    ),
+  },
+  html: {
+    fill: '#4176E6',
+    mark: (
+      <path
+        fillRule="evenodd"
+        clipRule="evenodd"
+        d="M13.9983 9.68311C17.2109 9.68311 19.8156 12.2873 19.8157 15.4998C19.8157 18.7124 17.211 21.3172 13.9983 21.3172C10.7858 21.317 8.18164 18.7123 8.18164 15.4998C8.1818 12.2874 10.7859 9.68327 13.9983 9.68311ZM9.26104 16.0248C9.46915 17.9243 10.7925 19.4877 12.5628 20.0464C12.4189 19.7978 12.2941 19.5153 12.1868 19.2117C11.8839 18.3543 11.6919 17.2436 11.6413 16.0248H9.26104ZM16.3554 16.0248C16.3048 17.2435 16.1134 18.3543 15.8105 19.2117C15.7033 19.5153 15.5777 19.7972 15.4339 20.0457C17.2043 19.4871 18.5282 17.9243 18.7363 16.0248H16.3554ZM12.6927 16.0248C12.7428 17.146 12.9201 18.1335 13.1773 18.8617C13.3309 19.2963 13.5019 19.6102 13.6675 19.8051C13.8329 19.9998 13.9462 20.0257 13.9983 20.0259C14.0503 20.0259 14.164 20.0003 14.3299 19.8051C14.4955 19.6101 14.6665 19.2963 14.82 18.8617C15.0773 18.1335 15.2546 17.146 15.3047 16.0248H12.6927ZM13.9983 10.7331C13.9462 10.7332 13.8329 10.7599 13.6675 10.9546C13.5019 11.1495 13.3308 11.4634 13.1773 11.8979C12.9019 12.6778 12.7177 13.7546 12.6838 14.9748H15.3136C15.2797 13.7546 15.0955 12.6778 14.82 11.8979C14.6665 11.4634 14.4954 11.1495 14.3299 10.9546C14.164 10.7594 14.0503 10.7331 13.9983 10.7331ZM15.5877 11.0052C15.669 11.1758 15.7433 11.3577 15.8105 11.5479C16.1332 12.4615 16.3297 13.6621 16.3636 14.9748H18.7363C18.5341 13.1308 17.2806 11.6037 15.5877 11.0052ZM12.409 11.0052C10.7163 11.6038 9.46318 13.1309 9.26104 14.9748H11.6338C11.6677 13.6621 11.8641 12.4615 12.1868 11.5479C12.254 11.3578 12.3277 11.1757 12.409 11.0052Z"
+        fill="white"
+      />
+    ),
+  },
+  image: {
+    fill: '#8B76F6',
+    mark: (
+      <>
+        <path d="M10.4211 15.9204C10.5755 15.6558 10.9578 15.6558 11.1121 15.9204L13.6491 20.2696C13.8047 20.5362 13.6123 20.8711 13.3036 20.8711H8.22962C7.9209 20.8711 7.72855 20.5362 7.88411 20.2696L10.4211 15.9204Z" fill="white" />
+        <path d="M15.498 13.186C15.6504 12.9117 16.0449 12.9117 16.1973 13.186L20.1367 20.2769C20.2848 20.5435 20.092 20.8711 19.787 20.8711H11.9083C11.6033 20.8711 11.4105 20.5435 11.5587 20.2769L15.498 13.186Z" fill="white" />
+        <path d="M11.8599 11.3997C11.8599 12.286 11.1415 13.0045 10.2552 13.0045C9.36887 13.0045 8.65039 12.286 8.65039 11.3997C8.65039 10.5134 9.36887 9.79492 10.2552 9.79492C11.1415 9.79492 11.8599 10.5134 11.8599 11.3997Z" fill="white" />
+      </>
+    ),
+  },
+  markdown: {
+    fill: '#4176E6',
+    mark: (
+      <path
+        d="M8.75904 19.5V14.6H9.90004L11.93 17.932H11.328L13.302 14.6H14.443L14.457 19.5H13.183L13.169 16.539H13.386L11.909 19.017H11.293L9.77404 16.539H10.04V19.5H8.75904ZM15.4378 19.5V14.6H17.7548C18.2961 14.6 18.7721 14.7003 19.1828 14.901C19.5934 15.1017 19.9131 15.384 20.1418 15.748C20.3751 16.112 20.4918 16.546 20.4918 17.05C20.4918 17.5493 20.3751 17.9833 20.1418 18.352C19.9131 18.716 19.5934 18.9983 19.1828 19.199C18.7721 19.3997 18.2961 19.5 17.7548 19.5H15.4378ZM16.8238 18.394H17.6988C17.9788 18.394 18.2214 18.3427 18.4268 18.24C18.6368 18.1327 18.8001 17.9787 18.9168 17.778C19.0334 17.5727 19.0918 17.33 19.0918 17.05C19.0918 16.7653 19.0334 16.5227 18.9168 16.322C18.8001 16.1213 18.6368 15.9697 18.4268 15.867C18.2214 15.7597 17.9788 15.706 17.6988 15.706H16.8238V18.394Z"
+        fill="white"
+      />
+    ),
+  },
+  pdf: {
+    fill: '#EC1313',
+    mark: (
+      <path
+        d="M6.80641 19.5V14.6H9.04641C9.49441 14.6 9.87941 14.6723 10.2014 14.817C10.5281 14.9617 10.7801 15.1717 10.9574 15.447C11.1347 15.7177 11.2234 16.0397 11.2234 16.413C11.2234 16.7817 11.1347 17.1013 10.9574 17.372C10.7801 17.6427 10.5281 17.8527 10.2014 18.002C9.87941 18.1467 9.49441 18.219 9.04641 18.219H7.57641L8.19241 17.617V19.5H6.80641ZM8.19241 17.764L7.57641 17.127H8.96241C9.25174 17.127 9.46641 17.064 9.60641 16.938C9.75107 16.812 9.82341 16.637 9.82341 16.413C9.82341 16.1843 9.75107 16.007 9.60641 15.881C9.46641 15.755 9.25174 15.692 8.96241 15.692H7.57641L8.19241 15.055V17.764ZM11.8992 19.5V14.6H14.2162C14.7575 14.6 15.2335 14.7003 15.6442 14.901C16.0548 15.1017 16.3745 15.384 16.6032 15.748C16.8365 16.112 16.9532 16.546 16.9532 17.05C16.9532 17.5493 16.8365 17.9833 16.6032 18.352C16.3745 18.716 16.0548 18.9983 15.6442 19.199C15.2335 19.3997 14.7575 19.5 14.2162 19.5H11.8992ZM13.2852 18.394H14.1602C14.4402 18.394 14.6828 18.3427 14.8882 18.24C15.0982 18.1327 15.2615 17.9787 15.3782 17.778C15.4948 17.5727 15.5532 17.33 15.5532 17.05C15.5532 16.7653 15.4948 16.5227 15.3782 16.322C15.2615 16.1213 15.0982 15.9697 14.8882 15.867C14.6828 15.7597 14.4402 15.706 14.1602 15.706H13.2852V18.394ZM17.6824 19.5V14.6H21.5254V15.671H19.0684V19.5H17.6824ZM18.9704 17.82V16.749H21.2314V17.82H18.9704Z"
+        fill="white"
+      />
+    ),
+  },
+  sheet: {
+    fill: '#22C55E',
+    mark: (
+      <path
+        d="M10.2935 20.5L13.3535 16.25L13.3435 17.66L10.4035 13.5H12.6335L14.5135 16.21L13.5635 16.22L15.4135 13.5H17.5535L14.6135 17.58V16.18L17.7135 20.5H15.4335L13.5235 17.65H14.4335L12.5535 20.5H10.2935Z"
+        fill="white"
+      />
+    ),
+  },
+  slides: {
+    fill: '#F59E0B',
+    mark: (
+      <path
+        d="M11.0135 20.5V13.5H14.2135C14.8535 13.5 15.4035 13.6033 15.8635 13.81C16.3301 14.0167 16.6901 14.3167 16.9435 14.71C17.1968 15.0967 17.3235 15.5567 17.3235 16.09C17.3235 16.6167 17.1968 17.0733 16.9435 17.46C16.6901 17.8467 16.3301 18.1467 15.8635 18.36C15.4035 18.5667 14.8535 18.67 14.2135 18.67H12.1135L12.9935 17.81V20.5H11.0135ZM12.9935 18.02L12.1135 17.11H14.0935C14.5068 17.11 14.8135 17.02 15.0135 16.84C15.2201 16.66 15.3235 16.41 15.3235 16.09C15.3235 15.7633 15.2201 15.51 15.0135 15.33C14.8135 15.15 14.5068 15.06 14.0935 15.06H12.1135L12.9935 14.15V18.02Z"
+        fill="white"
+      />
+    ),
+  },
+  document: {
+    fill: '#5686FE',
+    mark: (
+      <path
+        d="M10.512 20.5L8.24203 13.5H10.282L12.192 19.56H11.162L13.172 13.5H14.992L16.892 19.56H15.902L17.872 13.5H19.762L17.492 20.5H15.372L13.752 15.35H14.322L12.632 20.5H10.512Z"
+        fill="white"
+      />
+    ),
+  },
+  other: {
+    fill: '#CFD3D6',
+    mark: (
+      <>
+        <rect x="7.98242" y="10.792" width="12.0368" height="1.46414" fill="#545557" />
+        <rect x="7.98242" y="14.9221" width="12.0368" height="1.46414" fill="#545557" />
+        <rect x="7.98242" y="19.0522" width="7.57257" height="1.46414" fill="#545557" />
+      </>
+    ),
+  },
+}
+
+/**
+ * Render one file type's coloured sheet.
+ * @param props - The type, optional size (default 28px, the drawn size), and
+ * optional CSS class.
+ * @returns The type's SVG, `aria-hidden`; the adjacent name carries the meaning.
+ */
+export function FileTypeIcon({ kind, size = 28, className }: FileTypeIconProps): ReactNode {
+  const face = FACES[kind]
+  return (
+    <svg width={size} height={size} className={className} viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden>
+      <path d={SHEET_PATH} fill={face.fill} />
+      {face.mark}
+      {/* The grey sheet folds a darker corner; every coloured sheet folds a translucent white one. */}
+      {kind === 'other'
+        ? <path d={CORNER_PATH} fill="#A2A4A6" />
+        : <path d={CORNER_PATH} fill="white" fillOpacity="0.7" />}
+    </svg>
+  )
+}

+ 12 - 22
packages/client/ui-primitives/src/LinkIcon.tsx

@@ -9,6 +9,10 @@
  * ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20.
  */
 import type { ReactNode } from 'react'
+import {
+  CODE_EXTENSIONS, IMAGE_EXTENSIONS, PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS,
+  fileExtension,
+} from './file-extensions.ts'
 import type { IconProps } from './icons/props.ts'
 
 /**
@@ -24,36 +28,22 @@ export interface LinkIconProps extends IconProps {
   kind: LinkIconKind
 }
 
-/** Code, web, and data extensions: all three categories share the code glyph. */
-const CODE_EXTENSIONS = new Set([
-  'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less',
-  'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml',
-  'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
-  'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx',
-  'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql',
-  'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart',
-])
-
-const IMAGE_EXTENSIONS = new Set([
-  'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif',
-])
-
-const DOCUMENT_EXTENSIONS = new Set(['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'])
+/** Office-style documents share one paper-doc glyph on a link. */
+const DOCUMENT_SETS = [PDF_EXTENSIONS, SHEET_EXTENSIONS, SLIDES_EXTENSIONS, WORD_EXTENSIONS]
 
 /**
- * Derive a file path's link-icon category from its extension. Unknown and
- * missing extensions fall to `other` (the plain-paper glyph).
+ * Derive a file path's link-icon category from its extension. Code, web, and
+ * data files share the code glyph; unknown and missing extensions fall to
+ * `other` (the plain-paper glyph).
  * @param path - File path as the producing tool spelled it (either separator).
  * @returns The file's glyph category; never `url` or `folder`.
  */
 export function classifyLinkPath(path: string): LinkIconKind {
-  const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
-  const dot = name.lastIndexOf('.')
-  if (dot < 0) return 'other'
-  const extension = name.slice(dot + 1).toLowerCase()
+  const extension = fileExtension(path)
+  if (extension === '') return 'other'
   if (CODE_EXTENSIONS.has(extension)) return 'code'
   if (IMAGE_EXTENSIONS.has(extension)) return 'image'
-  return DOCUMENT_EXTENSIONS.has(extension) ? 'document' : 'other'
+  return DOCUMENT_SETS.some(set => set.has(extension)) ? 'document' : 'other'
 }
 
 const GlobeGlyph = ({ size, className }: IconProps) => (

+ 59 - 0
packages/client/ui-primitives/src/file-extensions.ts

@@ -0,0 +1,59 @@
+/**
+ * File-extension vocabularies shared by the glyph pickers (`classifyLinkPath`,
+ * `classifyFileType`), so a path classifies the same way on a link and on a
+ * file-type icon.
+ *
+ * TODO: this is an interim rule. The product will settle one extension
+ * standard for every place a file is drawn — an upload in the input bar, a
+ * sent attachment, an artifact, the Sidebar's tree rows, the Sidebar's tab
+ * chips and pane headers — and these sets, the glyph pickers over them, and
+ * the sheets in `FileTypeIcon` are to be replaced by it. Until then the
+ * surfaces differ: the Sidebar classifies by these sets, the conversation's
+ * links fold the four office types into `document` (`LinkIcon`), and the
+ * attachment cards (`ui-attachment` `FileCard`) draw one generic glyph with no
+ * classification at all.
+ */
+
+/** Code, web, and data extensions. */
+export const CODE_EXTENSIONS: ReadonlySet<string> = new Set([
+  'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'cts', 'mts', 'css', 'scss', 'sass', 'less',
+  'html', 'htm', 'vue', 'svelte', 'astro', 'json', 'jsonc', 'json5', 'yaml', 'yml',
+  'toml', 'xml', 'ini', 'env', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd',
+  'py', 'pyi', 'rb', 'rs', 'go', 'java', 'kt', 'kts', 'c', 'cc', 'cpp', 'cxx',
+  'h', 'hh', 'hpp', 'cs', 'php', 'swift', 'sql', 'csv', 'tsv', 'proto', 'graphql',
+  'gql', 'lua', 'r', 'pl', 'scala', 'clj', 'cljs', 'ex', 'exs', 'erl', 'hs', 'dart',
+])
+
+/** Raster and vector image extensions. */
+export const IMAGE_EXTENSIONS: ReadonlySet<string> = new Set([
+  'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'avif', 'bmp', 'ico', 'tif', 'tiff', 'heic', 'heif',
+])
+
+/** Web page extensions; a subset of {@link CODE_EXTENSIONS} with its own file-type glyph. */
+export const HTML_EXTENSIONS: ReadonlySet<string> = new Set(['html', 'htm'])
+
+/** Markdown extensions. */
+export const MARKDOWN_EXTENSIONS: ReadonlySet<string> = new Set(['md', 'mdx', 'markdown'])
+
+/** PDF. */
+export const PDF_EXTENSIONS: ReadonlySet<string> = new Set(['pdf'])
+
+/** Spreadsheet extensions; `csv`/`tsv` also sit in {@link CODE_EXTENSIONS}. */
+export const SHEET_EXTENSIONS: ReadonlySet<string> = new Set(['xls', 'xlsx', 'xlsm', 'csv', 'tsv', 'numbers'])
+
+/** Slide-deck extensions. */
+export const SLIDES_EXTENSIONS: ReadonlySet<string> = new Set(['ppt', 'pptx', 'key'])
+
+/** Word-processor document extensions. */
+export const WORD_EXTENSIONS: ReadonlySet<string> = new Set(['doc', 'docx', 'rtf', 'odt', 'pages'])
+
+/**
+ * A path's lowercase extension without the dot, taken from its last segment.
+ * @param path - File path with either separator.
+ * @returns The extension, or `''` for a name without a dot.
+ */
+export function fileExtension(path: string): string {
+  const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
+  const dot = name.lastIndexOf('.')
+  return dot < 0 ? '' : name.slice(dot + 1).toLowerCase()
+}

+ 3 - 1
packages/client/ui-primitives/src/icons/index.tsx

@@ -241,10 +241,12 @@ export const IconCopyOutline16 = ({ size = 16, className }: IconProps) => (
   </svg>
 )
 
-/** ic_ds_refresh_outline_16 */
+/** ic_ds_refresh_outline_16, inset 10%: the exported glyph fills its box edge
+ * to edge, one visual size above the neighbouring 16px glyphs. */
 export const IconRefreshOutline16 = ({ size = 16, className }: IconProps) => (
   <svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
     <path
+      transform="translate(0.8 0.8) scale(0.9)"
       d="M7.92136 0.349152C10.3744 0.349234 12.5564 1.5052 13.9557 3.29894L15.1281 2.12759C15.3303 1.92546 15.6767 2.06943 15.6767 2.35538V5.53923C15.6766 5.71626 15.5329 5.85976 15.3559 5.86002H12.171C11.8854 5.8597 11.7426 5.51465 11.9443 5.31249L12.9641 4.29056C11.8237 2.74305 9.98908 1.74106 7.92136 1.74097C4.46436 1.74097 1.66233 4.543 1.66233 8C1.66233 11.457 4.46436 14.259 7.92136 14.259C11.3782 14.2589 14.1804 11.4569 14.1804 8H15.5722C15.5722 12.2251 12.1465 15.6507 7.92136 15.6508C3.69614 15.6508 0.270508 12.2252 0.270508 8C0.270508 3.77478 3.69614 0.349152 7.92136 0.349152Z"
       fill="currentColor"
     />

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

@@ -33,6 +33,8 @@ export { ReferenceIcon } from './ReferenceIcon.tsx'
 export type { ReferenceIconKind, ReferenceIconProps } from './ReferenceIcon.tsx'
 export { LinkIcon, classifyLinkPath } from './LinkIcon.tsx'
 export type { LinkIconKind, LinkIconProps } from './LinkIcon.tsx'
+export { FileTypeIcon, classifyFileType } from './FileTypeIcon.tsx'
+export type { FileTypeKind, FileTypeIconProps } from './FileTypeIcon.tsx'
 export { projectUserText } from './user-text.tsx'
 export { Tooltip } from './Tooltip.tsx'
 export type { TooltipSide } from './Tooltip.tsx'

+ 61 - 0
packages/client/ui-primitives/tests/file-type-icon.client.spec.tsx

@@ -0,0 +1,61 @@
+// @vitest-environment jsdom
+import { cleanup, render } from '@testing-library/react'
+import { afterEach, describe, expect, it } from 'vitest'
+import { FileTypeIcon, classifyFileType, type FileTypeKind } from '@deepseek-ai/dsh-client-ui-primitives'
+
+afterEach(cleanup)
+
+const KINDS: FileTypeKind[] = ['code', 'html', 'image', 'markdown', 'pdf', 'sheet', 'slides', 'document', 'other']
+
+describe('classifyFileType', () => {
+  it.each([
+    ['src/render.tsx', 'code'],
+    ['site/index.HTML', 'html'],
+    ['README.md', 'markdown'],
+    ['data/export.csv', 'sheet'],
+    ['book.xlsx', 'sheet'],
+    ['shots/hero.png', 'image'],
+    ['report.pdf', 'pdf'],
+    ['deck.pptx', 'slides'],
+    ['C:\\work\\summary.docx', 'document'],
+    ['notes.unknownext', 'other'],
+    ['Makefile', 'other'],
+    ['archive.tar/.hidden', 'other'],
+  ] as [string, FileTypeKind][])('%s → %s', (path, kind) => {
+    expect(classifyFileType(path)).toBe(kind)
+  })
+})
+
+describe('FileTypeIcon', () => {
+  it.each(KINDS)('%s draws the shared sheet in its own colour, aria-hidden', (kind) => {
+    const { container } = render(<FileTypeIcon kind={kind} />)
+    const svg = container.querySelector('svg')!
+    expect(svg.getAttribute('aria-hidden')).toBe('true')
+    expect(svg.getAttribute('width')).toBe('28')
+    expect(svg.querySelector('path')!.getAttribute('fill')).toMatch(/^#[0-9A-F]{6}$/)
+  })
+
+  it('every kind carries a distinct mark', () => {
+    const marks = KINDS.map((kind) => {
+      const { container } = render(<FileTypeIcon kind={kind} />)
+      const [, mark] = Array.from(container.querySelectorAll('path, rect'))
+      return `${mark!.getAttribute('d') ?? ''}${mark!.getAttribute('y') ?? ''}`
+    })
+    expect(new Set(marks).size).toBe(KINDS.length)
+  })
+
+  it('folds a darker corner on the grey sheet and a translucent one elsewhere', () => {
+    const grey = render(<FileTypeIcon kind="other" />).container.querySelectorAll('path')
+    expect(grey[grey.length - 1]!.getAttribute('fill')).toBe('#A2A4A6')
+    const blue = render(<FileTypeIcon kind="code" />).container.querySelectorAll('path')
+    expect(blue[blue.length - 1]!.getAttribute('fill-opacity')).toBe('0.7')
+  })
+
+  it('size and className land on the svg', () => {
+    const { container } = render(<FileTypeIcon kind="pdf" size={16} className="x" />)
+    const svg = container.querySelector('svg')!
+    expect(svg.getAttribute('width')).toBe('16')
+    expect(svg.getAttribute('height')).toBe('16')
+    expect(svg.classList.contains('x')).toBe(true)
+  })
+})

+ 2 - 2
packages/client/ui-sidebar-documentpreview/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md
-README.md: b4bf0cadce772f3ae596f8e62ab98e33db82cb46
-README.zh.md: f4eea40230ed9d3ed7669b9980491dfe0afab79e
+README.md: 5b258c154258cee170b757c8a8a9599b61998bb4
+README.zh.md: 1c18763770df7c73b5b41fff3334c045e5204d3b

+ 4 - 4
packages/client/ui-sidebar-documentpreview/README.md

@@ -26,8 +26,8 @@ Preview readable files in the right Sidebar and choose among registered renderer
 <a id="what-it-registers"></a>
 ## What it registers
 
-- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title.
-- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path, and a dropdown for matching renderers plus plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The shared body below owns document scrolling.
+- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
+- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The shared body below owns document scrolling.
 - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
 
 Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations.
@@ -43,7 +43,7 @@ A tab uses the Session address built by `fileAddressFor`, carrying a relative or
 The body reads its record, navigation and lifetime through `useTabInfo().tab`. `useResource<'file'>(tab.contentId)` supplies metadata; ordinary inject callbacks supply content reads:
 
 - The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; neither automatically replaces loaded content.
-- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. Failures retain loaded content and offer a retry.
+- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, explanation, and retry; a later failure retains loaded content and adds the retry below it.
 - **Complete bytes** — PDF and HTML use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array<ArrayBuffer>` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results.
 - **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice.
 
@@ -74,7 +74,7 @@ No direct effect; what the user reads here never enters a model request.
 - **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF and HTML require a complete result within the Host's `maxFileBytes` cap.
 - **Byte-view scroll state is not restored.** PDF and HTML can return to the top when their renderer remounts or reloads; HTML iframe scrolling belongs to its opaque browsing context.
 - **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe.
-- **Package-local wrap glyph.** `IconWrapOutline16` lives in `src/client/icons.tsx` until the shared icon set carries one; the props contract already matches.
+- **Package-local wrap glyphs.** `IconWrapFill16` and `IconNowrapFill16` live in `src/client/icons.tsx` until the shared icon set carries them; their props already match the shared icon contract.
 - **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back.
 
 <a id="dev-note"></a>

+ 4 - 4
packages/client/ui-sidebar-documentpreview/README.zh.md

@@ -26,8 +26,8 @@ kind: "package-reference"
 <a id="what-it-registers"></a>
 ## 注册了什么
 
-- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题。
-- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径,并提供匹配渲染器及纯文本的下拉选择。仅当所选渲染器声明 `wrap: true` 时显示换行开关;该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。下方的共享正文区域负责文档滚动。
+- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统里的唯一键,也是其体注册所用的 key),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`
+- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。下方的共享正文区域负责文档滚动。
 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
 
 文档实现在 `ctx.documentPreviews.register({ id, extensions, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。
@@ -43,7 +43,7 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
 正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取:
 
 - 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;两者都不会自动替换已加载内容。
-- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码行偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。失败时保留已加载内容并提供重试。
+- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码行偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明与重试按钮填满正文;较晚的失败保留已有内容并在其下提供重试。
 - **完整字节** —— PDF 和 HTML 通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array<ArrayBuffer>`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。
 - **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。
 
@@ -74,7 +74,7 @@ HTML 在 Blob iframe 中运行,沙箱属性严格为 `sandbox="allow-scripts"`
 - **文本顺序分页,完整文件受限。** 定位深处源码行需要先加载此前各页;PDF 和 HTML 必须取得 Host `maxFileBytes` 上限内的完整结果。
 - **字节视图不恢复滚动位置。** PDF 与 HTML 的渲染器重新挂载或重新载入时可能回到顶部;HTML iframe 的滚动属于其不透明浏览上下文。
 - **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。
-- **换行图标为包内自绘。** `IconWrapOutline16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;props 契约已经一致。
+- **换行图标为包内自绘。** `IconWrapFill16` 与 `IconNowrapFill16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;它们的 props 已与共享图标契约一致。
 - **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。
 
 <a id="dev-note"></a>

+ 133 - 17
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css

@@ -9,24 +9,57 @@
   min-height: 0;
 }
 
-/* One row: the path, then the type's controls at its end. */
+/* One 38px row flush under the pane's strip: the path, then the type's
+   controls at its end. With the strip's 38px above, its rule lies where the
+   conversation header's does (ui-conversation ConversationRoot `.header`,
+   76px with the same rule), so the two lines meet at the column edge.
+
+   The same row, path, and controls are drawn by the files tab
+   (ui-sidebar-files FilesBody `.header`); TODO: once the artifact and slot
+   surfaces settle, one copy in ui-primitives could serve every pane header. */
 .header {
   display: flex;
   flex: 0 0 auto;
-  gap: 2px;
+  gap: 4px;
   align-items: center;
-  padding: 3px 6px 3px 10px;
-  border-bottom: 0.5px solid var(--dsw-alias-border-l1);
+  box-sizing: border-box;
+  height: 38px;
+  padding: 0 6px 0 16px;
+  border-bottom: 0.5px solid var(--dsw-alias-border-l3);
 }
 
+/* The path is never ellipsized. Its text sits at the left while it fits: the
+   auto margin takes the free space. Once it is wider than the box, the auto
+   margin is zero and `flex-end` holds the name at the right edge, so the
+   directories run off the left and the box clips them there, faded by the
+   mask while `data-textpreview-path-clipped` is set. The margin plus the row's
+   gap put the text 16px clear of the first control. */
 .path {
+  display: flex;
   flex: 1 1 auto;
+  justify-content: flex-end;
   min-width: 0;
+  margin-right: 12px;
   overflow: hidden;
-  color: var(--dsw-alias-label-tertiary);
   font-size: 12px;
   white-space: nowrap;
-  text-overflow: ellipsis;
+}
+
+.path[data-textpreview-path-clipped] {
+  mask-image: linear-gradient(to right, transparent, black 28px);
+}
+
+.pathText {
+  flex: none;
+  margin-right: auto;
+}
+
+.pathDirectory {
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.pathName {
+  color: var(--dsw-alias-label-primary);
 }
 
 /* Announced, not applied: the reader keeps the text they are looking at. */
@@ -48,8 +81,13 @@
   position: relative;
   flex: 1 1 auto;
   min-height: 0;
-  padding: 10px 0;
+  /* The files tree's inset and 2px scrollbar offset (ui-sidebar-files
+     FilesBody `.body`), so the two bodies start their text at one edge and
+     both bars sit 2px clear of the pane's edge. */
+  margin-right: 2px;
+  padding: 8px 0 8px 8px;
   overflow: auto;
+  scrollbar-gutter: stable;
   /* The notice and retry surfaces in this sheet are elevated, so the file body's
      scroller rebinds the thumb indirection in a complete pair. */
   --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
@@ -61,6 +99,13 @@
   white-space: pre;
 }
 
+/* The bar also stops 2px short of the body's ends, matching its 2px edge
+   offset (ui-sidebar-files FilesBody keeps the same rule). WebKit-only: the
+   Firefox path has no track to inset. */
+.body::-webkit-scrollbar-track {
+  margin: 2px;
+}
+
 .wrap {
   white-space: pre-wrap;
   word-break: break-word;
@@ -78,6 +123,7 @@
   white-space: inherit;
 }
 
+/* The files tree row's 10px side inset, so a line's text sits where a row's does. */
 .line {
   padding: 0 10px;
 }
@@ -86,6 +132,74 @@
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
+/* The failure with nothing read yet: the whole body, centred a little above
+   the middle (the spacer below). Fills the body's content box exactly (a
+   percentage height resolves against it), so the body does not scroll. The
+   body's monospace `pre` setting is undone here. */
+.empty {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+  height: 100%;
+  padding: 0 24px;
+  color: var(--dsw-alias-label-secondary);
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  font-family: var(--dsw-font-family);
+  line-height: 1.6;
+  text-align: center;
+  white-space: normal;
+}
+
+/* The lift, as a share of the pane's height (a column flex basis resolves
+   against it): the centred column rises 6% of the pane, and a short pane
+   gives it up before the content clips. */
+.empty::after {
+  content: '';
+  flex: 0 1 12%;
+}
+
+/* The type's sheet keeps its shape but gives up its colour. */
+.emptyIcon {
+  flex: none;
+  opacity: 0.6;
+  filter: grayscale(1);
+}
+
+.emptyLine {
+  margin: 0;
+}
+
+/* A capsule holding the refresh glyph and the retry word. `flex: none` keeps
+   its height when the empty column runs out of room in a short panel. */
+.retry {
+  display: inline-flex;
+  flex: none;
+  gap: 6px;
+  align-items: center;
+  height: 32px;
+  padding: 0 14px 0 12px;
+  color: var(--dsw-alias-label-primary);
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  font-family: inherit;
+  background: transparent;
+  border: 0.5px solid var(--dsw-alias-border-l2);
+  border-radius: 16px;
+  cursor: pointer;
+}
+
+.retry:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* The type's 16px sheet before the chip's text; the chip's title row centres
+   it on the text's line and spaces it. */
+.titleIcon {
+  flex: none;
+}
+
 .statusLine {
   display: flex;
   gap: 10px;
@@ -148,31 +262,33 @@
 }
 
 /* Controls in the header row, sized like the docking kit's own pane controls. */
+/* The same icon button as the Sidebar's strip controls above it: a 28px
+   circle around a 15px glyph. */
 .tool {
-  display: flex;
+  display: inline-flex;
+  flex: none;
   align-items: center;
   justify-content: center;
-  width: 24px;
-  height: 24px;
-  padding: 0;
+  width: 28px;
+  height: 28px;
+  padding: 6px;
   color: var(--dsw-alias-label-secondary);
   line-height: 1;
   background: transparent;
   border: none;
-  border-radius: 4px;
+  border-radius: 28px;
   cursor: pointer;
 }
 
-.tool:hover {
-  color: var(--dsw-alias-label-primary);
-  background: var(--dsw-alias-interactive-bg-hover);
+.tool svg {
+  width: 15px;
+  height: 15px;
 }
 
-.toolOn {
+.tool:hover {
   color: var(--dsw-alias-label-primary);
   background: var(--dsw-alias-interactive-bg-hover);
 }
-
 .viewerTool {
   width: auto;
   max-width: 160px;

+ 96 - 40
packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx

@@ -10,15 +10,16 @@
  * with the same reload. The type's controls, viewer choice, wrap and reload, sit at the end of
  * the path row; the Sidebar's strip carries none of them.
  */
-import { useEffect, useMemo, useRef, useState } from 'react'
-import type { ReactNode } from 'react'
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import type { ReactNode, RefObject } from 'react'
 import clsx from 'clsx'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
-import { IconRefreshOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
+import { FileTypeIcon, IconRefreshOutline16, Menu, Tooltip, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives'
+import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
 import type { TextInjected } from './face.ts'
 import { failureLine } from './failure-line.ts'
-import { IconWrapOutline16 } from './icons.tsx'
+import { IconNowrapFill16, IconWrapFill16 } from './icons.tsx'
 import { LoadingIndicator } from './LoadingIndicator.tsx'
 import { hostFileOf } from './rpc.ts'
 import type { TextStore } from './store.ts'
@@ -32,6 +33,29 @@ import css from './TextPreview.module.css'
 export { linesOf, loadedPages, lastLineLoaded, scrollToLine } from './text/lines.ts'
 export type { LoadedPage } from './text/lines.ts'
 
+/** Keep the path fade in sync with whether its full text fits the header row. */
+function usePathClipped(
+  box: RefObject<HTMLDivElement | null>,
+  text: RefObject<HTMLSpanElement | null>,
+  path: string,
+  shown: boolean,
+): void {
+  useLayoutEffect(() => {
+    const outer = box.current
+    const inner = text.current
+    if (outer === null || inner === null) return undefined
+    const apply = (): void => {
+      if (inner.offsetWidth > outer.clientWidth) outer.dataset.textpreviewPathClipped = ''
+      else delete outer.dataset.textpreviewPathClipped
+    }
+    apply()
+    const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply)
+    observer?.observe(outer)
+    observer?.observe(inner)
+    return () => { observer?.disconnect() }
+  }, [box, text, path, shown])
+}
+
 /** Private registration inputs; the framework binds the registry source to useDocumentPreviews. */
 export interface TextPreviewInjected extends TextInjected {
   readonly hooks: { readonly documentPreviews: ObservableSnapshot<readonly DocumentPreviewDefinition[]> }
@@ -70,7 +94,11 @@ export function TextPreview({
   const mode = selected?.loading
   const current = (state?.mode ?? 'text-pages') === mode ? state : undefined
   const bodyRef = useRef<HTMLDivElement | null>(null)
+  const pathRef = useRef<HTMLDivElement | null>(null)
+  const pathTextRef = useRef<HTMLSpanElement | null>(null)
   const [menuOpen, setMenuOpen] = useState(false)
+  const displayPath = meta.value?.absolutePath ?? current?.complete?.absolutePath ?? file.path
+  usePathClipped(pathRef, pathTextRef, displayPath, state !== undefined)
   // Every tab of this type is a `file` resource address, so its params are the
   // `file` type's; the union is narrowed on the one field read, not validated.
   const line = navigation.params !== undefined && 'line' in navigation.params ? navigation.params.line : undefined
@@ -142,14 +170,13 @@ export function TextPreview({
     )
   }
   const next = loadedThrough + 1
-  const displayPath = meta.value?.absolutePath ?? current?.complete?.absolutePath ?? file.path
+  const { directory, name } = pathPartsOf(displayPath)
   const observedVersion = meta.value?.version
   const changed = current?.version !== undefined && observedVersion !== undefined
     && observedVersion !== current.version && observedVersion !== current.observedVersion
   const loadNext = (): void => {
     if (!canRead || current?.loading || current?.eof) return
-    if (mode === 'text-pages') loadPage(tab.id, file, next, signal, meta.value?.version)
-    else loadAll(tab.id, file, signal, meta.value?.version)
+    loadPage(tab.id, file, next, signal, meta.value?.version)
   }
   const reload = (): void => {
     if (!canRead) return
@@ -158,10 +185,12 @@ export function TextPreview({
   }
   return (
     <div className={css.preview} data-textpreview-state="text" data-textpreview-url={tab.contentId} data-document-preview={selected.id}>
-      {meta.failure !== undefined
+      {meta.failure !== undefined && hasContent
         ? (
           // The file's metadata failed — gone, or its workspace unknown — which
           // outranks a pending change; the pages already read stay under it.
+          // With nothing read the body's own failure already says it, so the
+          // bar would only repeat the same line.
           <p className={css.changed} data-textpreview-meta-failed={meta.failure.code}>
             <span>{failureLine(t, meta.failure)}</span>
             <button
@@ -188,7 +217,12 @@ export function TextPreview({
           </p>
         )}
       <div className={css.header}>
-        <div className={css.path} title={displayPath} data-textpreview-path>{displayPath}</div>
+        <div ref={pathRef} className={css.path} title={displayPath} data-textpreview-path>
+          <span ref={pathTextRef} className={css.pathText}>
+            {directory !== '' && <span className={css.pathDirectory}>{directory}</span>}
+            <span className={css.pathName}>{name}</span>
+          </span>
+        </div>
         <Menu
           open={menuOpen}
           anchor={(
@@ -205,28 +239,32 @@ export function TextPreview({
           dense
         />
         {selected.wrap === true && (
+          // The tooltip names the action while the stable aria name and
+          // `aria-pressed` expose the control and its current state.
+          <Tooltip label={t(state.wrap ? 'wrap.disable' : 'wrap.enable')} side="bottom" delayMs={500}>
+            <button
+              type="button"
+              className={css.tool}
+              aria-pressed={state.wrap}
+              aria-label={t('wrap.aria')}
+              data-textpreview-tool="wrap"
+              onClick={() => { actions.toggledWrap(tab.id) }}
+            >
+              {state.wrap ? <IconNowrapFill16 /> : <IconWrapFill16 />}
+            </button>
+          </Tooltip>
+        )}
+        <Tooltip label={t('reload')} side="bottom" delayMs={500}>
           <button
             type="button"
-            className={clsx(css.tool, state.wrap && css.toolOn)}
-            aria-pressed={state.wrap}
-            aria-label={t('wrap')}
-            title={t('wrap')}
-            data-textpreview-tool="wrap"
-            onClick={() => { actions.toggledWrap(tab.id) }}
+            className={css.tool}
+            aria-label={t('reload')}
+            data-textpreview-tool="reload"
+            onClick={reload}
           >
-            <IconWrapOutline16 />
+            <IconRefreshOutline16 />
           </button>
-        )}
-        <button
-          type="button"
-          className={css.tool}
-          aria-label={t('reload')}
-          title={t('reload')}
-          data-textpreview-tool="reload"
-          onClick={reload}
-        >
-          <IconRefreshOutline16 />
-        </button>
+        </Tooltip>
       </div>
       <div
         ref={bodyRef}
@@ -249,19 +287,37 @@ export function TextPreview({
           entryKey: selected.id, hookContext: useTabInfo,
           fallback: <p className={css.statusLine}>{t('rendererUnavailable', { name: selected.title() })}</p>,
         })}
-        {current?.failure !== undefined && (
-          <p className={css.statusLine} data-textpreview-failed={current.failure.code}>
-            <span>{failureLine(t, current.failure)}</span>
-            <button
-              type="button"
-              className={css.action}
-              data-textpreview-retry
-              onClick={loadNext}
-            >
-              {t('retry')}
-            </button>
-          </p>
-        )}
+        {current?.failure !== undefined && (hasContent
+          ? (
+            <p className={css.statusLine} data-textpreview-failed={current.failure.code}>
+              <span>{failureLine(t, current.failure)}</span>
+              <button
+                type="button"
+                className={css.action}
+                data-textpreview-retry
+                onClick={loadNext}
+              >
+                {t('retry')}
+              </button>
+            </p>
+          )
+          : (
+            // With no content, retry the selected renderer's read; metadata
+            // observation remains owned by the resource provider.
+            <div className={css.empty} data-textpreview-failed={current.failure.code}>
+              <FileTypeIcon kind={classifyFileType(name)} size={36} className={css.emptyIcon} />
+              <p className={css.emptyLine}>{failureLine(t, current.failure)}</p>
+              <button
+                type="button"
+                className={css.retry}
+                data-textpreview-retry
+                onClick={reload}
+              >
+                <IconRefreshOutline16 size={14} />
+                {t('retry')}
+              </button>
+            </div>
+          ))}
         {mode === 'text-pages' && current !== undefined && loaded.length > 0 && !current.eof && current.failure === undefined && (
           <button
             type="button"

+ 24 - 0
packages/client/ui-sidebar-documentpreview/src/client/TextTitle.tsx

@@ -0,0 +1,24 @@
+/**
+ * The text type's chip title: the file type's coloured sheet before the name
+ * the registry captured at open time. Registered under
+ * `sidebar.right.pane.tab.title`; without it the chip would show the bare name.
+ */
+import type { ReactNode } from 'react'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import { FileTypeIcon, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives'
+import css from './TextPreview.module.css'
+
+/**
+ * The title as the chip and a floating panel's header show it.
+ * @param props - the tab information hook.
+ * @returns the type's 16px sheet followed by the tab's title text.
+ */
+export function TextTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.title'>): ReactNode {
+  const { tab } = useTabInfo()
+  return (
+    <>
+      <FileTypeIcon kind={classifyFileType(tab.title)} size={16} className={css.titleIcon} />
+      {tab.title}
+    </>
+  )
+}

+ 27 - 11
packages/client/ui-sidebar-documentpreview/src/client/icons.tsx

@@ -2,26 +2,42 @@
  * Glyphs this package draws that the shared icon set does not carry yet.
  * Same props contract as `@deepseek-ai/dsh-client-ui-primitives` icons, so a
  * shared replacement is a one-line import change.
+ *
+ * The wrap control swaps between the two glyphs below to preview the mode a
+ * click switches to, so neither needs a pressed style.
  */
 import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
 
-/** Three text lines, the middle one turning back under itself. */
-export const IconWrapOutline16 = ({ size = 16, className }: IconProps) => (
+/** Two margin bars, a straight arrow running to the right one: lines run past the edge. */
+export const IconNowrapFill16 = ({ size = 16, className }: IconProps) => (
   <svg
     width={size}
     height={size}
     className={className}
-    viewBox="0 0 16 16"
+    viewBox="0 0 24 24"
     fill="none"
-    stroke="currentColor"
-    strokeWidth="1.3"
-    strokeLinecap="round"
-    strokeLinejoin="round"
     xmlns="http://www.w3.org/2000/svg"
   >
-    <path d="M2.5 4h11" />
-    <path d="M2.5 8h8.5a2.5 2.5 0 0 1 0 5H9.5" />
-    <path d="M11 11.5 9.5 13l1.5 1.5" />
-    <path d="M2.5 12h3.5" />
+    <path
+      d="M1.5 2.5H3.5V21.5H1.5V2.5ZM20.5 2.5H22.5V21.5H20.5V2.5ZM14 9L19 12L14 15V13H5V11H14V9Z"
+      fill="currentColor"
+    />
+  </svg>
+)
+
+/** Two margin bars, an arrow sweeping around and back left: lines turn under themselves. */
+export const IconWrapFill16 = ({ size = 16, className }: IconProps) => (
+  <svg
+    width={size}
+    height={size}
+    className={className}
+    viewBox="0 0 24 24"
+    fill="none"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <path
+      d="M1.5 2.5H3.5V21.5H1.5V2.5ZM20.5 2.5H22.5V21.5H20.5V2.5ZM6.75 5H11.5A6 6 0 0 1 12 16.98V19L7 16L12 13V14.97A4 4 0 0 0 11.5 7H6.75V5Z"
+      fill="currentColor"
+    />
   </svg>
 )

+ 9 - 3
packages/client/ui-sidebar-documentpreview/src/client/index.ts

@@ -2,8 +2,9 @@
  * Browser half: register `text` as a right-Sidebar tab type.
  *
  * The type reaches the Sidebar through its public path only: the definition into
- * `ctx.sidebarRightTabs` and the body into the keyed `sidebar.right.pane.tab`
- * seat under the definition's `id`. Nothing here reaches into the Sidebar's store, its
+ * `ctx.sidebarRightTabs`, the body into the keyed `sidebar.right.pane.tab`
+ * seat, and the chip title into `sidebar.right.pane.tab.title`, both under the
+ * definition's `id`. Nothing here reaches into the Sidebar's store, its
  * panes, or its sequence. The file's metadata comes from the standard
  * `useResource`, served by the `file` provider; the content is this type's own
  * business, read through its face. Every import from another
@@ -21,6 +22,7 @@ import type {} from '@deepseek-ai/dsh-api-workspace-files/remote'
 import type { WorkspaceFileParams } from '@deepseek-ai/dsh-api-workspace-files/client'
 import { TextPreview } from './TextPreview.tsx'
 import type { TextPreviewInjected } from './TextPreview.tsx'
+import { TextTitle } from './TextTitle.tsx'
 import { TEXTPREVIEW_ID, textDefinition } from './definition.ts'
 import { textFace } from './face.ts'
 import { createReadPage } from './rpc.ts'
@@ -76,7 +78,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles']
 
 /**
- * Client plugin body: register the type, its dictionaries, and its body.
+ * Client plugin body: register the type, its dictionaries, its body, and its chip title.
  * @param ctx - client root context carrying the registry, the slots, copy, and the Remote face.
  */
 export function apply(ctx: ClientContext): void {
@@ -102,6 +104,10 @@ export function apply(ctx: ClientContext): void {
     },
     TextPreview,
   )), 'ui-sidebar-documentpreview: text body')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
+    { name: 'sidebar.right.pane.tab.title', key: TEXTPREVIEW_ID },
+    TextTitle,
+  )), 'ui-sidebar-documentpreview: text title')
   registerText(ctx)
   registerMarkdown(ctx)
   registerHtml(ctx)

+ 16 - 12
packages/client/ui-sidebar-documentpreview/src/client/locales.ts

@@ -10,18 +10,20 @@
 export const zh = {
   loading: '正在读取…',
   loadMore: '加载更多',
-  changed: '文件已被修改,显示的还是旧内容。',
+  changed: '文件已更新,当前显示为旧内容。',
   reloadNow: '重新载入',
   reload: '重新读取文件',
-  wrap: '自动换行',
+  'wrap.enable': '自动换行',
+  'wrap.disable': '取消换行',
+  'wrap.aria': '自动换行',
   openWith: '打开方式',
   'viewer.text': '纯文本',
   resourceUnavailable: '文件资源服务不可用。',
   rendererUnavailable: '预览器 {name} 不可用。',
-  'error.notFound': '这个文件不在了。可能已被移动或删除。',
-  'error.tooLarge': '请求读取的内容太大,超过了 {limit} 的上限。',
-  'error.notText': '这不是文本文件,没法在这里查看。',
-  'error.notRegularFile': '这不是一个普通文件,没有可显示的文本。',
+  'error.notFound': '文件不存在,可能已被移动或删除。',
+  'error.tooLarge': '单页内容超过 {limit} 上限,无法读取。',
+  'error.notText': '非文本文件,暂时无法预览。',
+  'error.notRegularFile': '该路径不是普通文件,没有可显示的内容。',
   'error.unavailable': '读取失败:{message}',
   retry: '重试',
 } satisfies Record<string, string>
@@ -33,18 +35,20 @@ export type SidebarDocumentPreviewKey = keyof typeof zh
 export const en = {
   loading: 'Reading…',
   loadMore: 'Load more',
-  changed: 'The file has changed; this is the older text.',
+  changed: 'The file has changed, showing the previous content.',
   reloadNow: 'Reload',
   reload: 'Read the file again',
-  wrap: 'Wrap lines',
+  'wrap.enable': 'Turn on line wrap',
+  'wrap.disable': 'Turn off line wrap',
+  'wrap.aria': 'Line wrap',
   openWith: 'Open with',
   'viewer.text': 'Plain text',
   resourceUnavailable: 'The file resource service is unavailable.',
   rendererUnavailable: 'The {name} preview is unavailable.',
-  'error.notFound': 'That file is gone. It may have been moved or deleted.',
-  'error.tooLarge': 'The requested content exceeds the {limit} read limit.',
-  'error.notText': 'That is not a text file, so it cannot be shown here.',
-  'error.notRegularFile': 'That is not a regular file, so it has no text to show.',
+  'error.notFound': 'File not found. It may have been moved or deleted.',
+  'error.tooLarge': 'This page exceeds the {limit} limit and cannot be read.',
+  'error.notText': 'Not a text file, preview is unavailable for now.',
+  'error.notRegularFile': 'Not a regular file, nothing to display.',
   'error.unavailable': 'Read failed: {message}',
   retry: 'Retry',
 } satisfies Record<SidebarDocumentPreviewKey, string>

+ 6 - 4
packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts

@@ -14,6 +14,7 @@ import { TEXTPREVIEW_ID, TEXTPREVIEW_KIND } from '../src/client/definition.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { apply as hostApply } from '../src/index.ts'
 import { TextPreview } from '../src/client/TextPreview.tsx'
+import { TextTitle } from '../src/client/TextTitle.tsx'
 import { TextBody } from '../src/client/text/TextBody.tsx'
 import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
 import { MarkdownBody } from '../src/client/markdown/MarkdownBody.tsx'
@@ -31,9 +32,9 @@ import { FILE, SESSION, TAB_ID, page } from './fixtures.client.ts'
 interface Recorded {
   name: string
   key: string
-  locale: string
-  store: unknown
-  inject: unknown
+  locale?: string
+  store?: unknown
+  inject?: unknown
   component: unknown
 }
 
@@ -79,7 +80,7 @@ describe('ui-sidebar-documentpreview apply', () => {
     expect(hostApply).not.toThrow()
   })
 
-  it('registers the type, its dictionaries, and the body seat under the type\'s id with a store and a face', async () => {
+  it('registers the type, its dictionaries, and the body and title seats under the type\'s id, the body with a store and a face', async () => {
     const { tabs, registered, dictionaries } = await boot()
     expect(tabs.get(TEXTPREVIEW_KIND)?.priority).toBe('fallback')
     expect(tabs.get(TEXTPREVIEW_KIND)?.id).toBe(TEXTPREVIEW_ID)
@@ -88,6 +89,7 @@ describe('ui-sidebar-documentpreview apply', () => {
     // take the kind over, and the seat must still find this body.
     expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
       ['sidebar.right.pane.tab', TEXTPREVIEW_ID, 'sidebarDocumentPreview', TextPreview],
+      ['sidebar.right.pane.tab.title', TEXTPREVIEW_ID, undefined, TextTitle],
       ['sidebar.right.tab.document', PLAIN_BODY_ID, undefined, TextBody],
       ['sidebar.right.tab.document', MARKDOWN_BODY_ID, 'documentMarkdown', MarkdownBody],
       ['sidebar.right.tab.document', HTML_BODY_ID, 'documentHtml', HtmlBody],

+ 92 - 1
packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx

@@ -151,6 +151,56 @@ describe('TextPreview — pages', () => {
     expect(view.container.querySelector('[data-textpreview-path]')?.getAttribute('title')).toBe(ABSOLUTE_PATH)
   })
 
+  it('marks the path clipped while its text is wider than its box, re-reading on resize', async () => {
+    class FakeResizeObserver implements ResizeObserver {
+      static latest: FakeResizeObserver | undefined
+      readonly observe = vi.fn()
+      readonly unobserve = vi.fn()
+      readonly disconnect = vi.fn()
+      constructor(private readonly callback: ResizeObserverCallback) {
+        FakeResizeObserver.latest = this
+      }
+
+      fire(): void {
+        this.callback([], this)
+      }
+    }
+    vi.stubGlobal('ResizeObserver', FakeResizeObserver)
+    let boxWidth = 300
+    const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')
+    const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
+    Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
+    Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => boxWidth })
+    try {
+      const h = harness({ 1: page(1, ['one'], true) })
+      const view = render(<TextPreview {...h.props()} />)
+      await settle()
+      const path = view.container.querySelector<HTMLElement>('[data-textpreview-path]')
+      const text = path?.firstElementChild
+      expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(false)
+      const observer = FakeResizeObserver.latest
+      if (observer === undefined) throw new Error('expected the path to observe its size')
+      expect(observer.observe).toHaveBeenCalledWith(path)
+      expect(observer.observe).toHaveBeenCalledWith(text)
+
+      boxWidth = 120
+      act(() => { observer.fire() })
+      expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(true)
+
+      boxWidth = 300
+      act(() => { observer.fire() })
+      expect(path?.hasAttribute('data-textpreview-path-clipped')).toBe(false)
+      view.unmount()
+      expect(observer.disconnect).toHaveBeenCalledTimes(1)
+    } finally {
+      vi.unstubAllGlobals()
+      for (const [name, descriptor] of [['offsetWidth', offsetWidth], ['clientWidth', clientWidth]] as const) {
+        if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name)
+        else Object.defineProperty(HTMLElement.prototype, name, descriptor)
+      }
+    }
+  })
+
   it('reads the first page on first mount and draws its lines, offering the next', async () => {
     const h = harness({ 1: page(1, ['one', 'two', 'three'], false) })
     const view = render(<TextPreview {...h.props()} />)
@@ -211,8 +261,11 @@ describe('TextPreview — pages', () => {
     const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) })
     const view = render(<TextPreview {...h.props()} />)
     await settle()
-    expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text')
+    const failed = view.container.querySelector('[data-textpreview-failed]')
+    expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text')
     expect(view.container.textContent).toContain('error.notText')
+    // Nothing read yet: the failure stands as the body, under the file's type sheet.
+    expect(failed?.querySelector('svg')).not.toBeNull()
     expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
     h.script(1, page(1, ['one'], true))
     click(view.container, '[data-textpreview-retry]')
@@ -222,6 +275,23 @@ describe('TextPreview — pages', () => {
     expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
   })
 
+  it('says why a later page failed on a line under the pages already read', async () => {
+    const h = harness({ 1: page(1, ['a'], false), 2: failure('workspace-file/too-large', { path: PATH, limit: 1024 }) })
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    click(view.container, '[data-textpreview-more]')
+    await settle()
+    const failed = view.container.querySelector('[data-textpreview-failed]')
+    expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/too-large')
+    expect(failed?.querySelector('svg')).toBeNull()
+    expect(lines(view.container)).toEqual(['a\n'])
+    h.script(2, page(2, ['b'], true))
+    click(view.container, '[data-textpreview-retry]')
+    await settle()
+    expect(lines(view.container)).toEqual(['a\n', 'b\n'])
+    expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
+  })
+
   it('announces a change and, on request, re-reads the pages keeping the reader\'s place', async () => {
     const h = harness({ 1: page(1, ['a', 'b'], true) })
     const view = render(<TextPreview {...h.props()} />)
@@ -326,6 +396,27 @@ describe('TextPreview — the file\'s metadata', () => {
     expect(view.container.querySelector('[data-textpreview-changed]')).toBeNull()
   })
 
+  it('says a metadata-and-read failure once and retries the content read', async () => {
+    const h = harness({ 1: failure('workspace-file/outside-workspace', { path: PATH }) })
+    h.setFailure(new RemoteError('workspace-file/outside-workspace', 'outside', { path: PATH }))
+    const view = render(<TextPreview {...h.props()} />)
+    await settle()
+    // With nothing read the body's failure is the whole story: a metadata bar
+    // above it would repeat the same line.
+    expect(view.container.querySelector('[data-textpreview-meta-failed]')).toBeNull()
+    expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed'))
+      .toBe('workspace-file/outside-workspace')
+    h.script(1, page(1, ['a'], true))
+    click(view.container, '[data-textpreview-retry]')
+    await settle()
+    expect(h.read).toHaveBeenCalledTimes(2)
+    expect(lines(view.container)).toEqual(['a\n'])
+    h.setFailure(undefined)
+    view.rerender(<TextPreview {...h.props()} />)
+    expect(view.container.querySelector('[data-textpreview-meta-failed]')).toBeNull()
+    expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
+  })
+
   it('draws a page holding one empty line as one line, and nothing for a page past the end', async () => {
     const h = harness({ 1: page(1, [''], false), 2: page(2, [], true) })
     const view = render(<TextPreview {...h.props()} />)

+ 28 - 0
packages/client/ui-sidebar-documentpreview/tests/text-title.client.spec.tsx

@@ -0,0 +1,28 @@
+// @vitest-environment jsdom
+/** The chip title: the file type's sheet, then the name the registry captured. */
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import { TextTitle } from '../src/client/TextTitle.tsx'
+
+afterEach(cleanup)
+
+function props(title: string): PropsRuntime<'sidebar.right.pane.tab.title'> {
+  return { useTabInfo: () => ({ tab: { title } }) } as unknown as PropsRuntime<'sidebar.right.pane.tab.title'>
+}
+
+describe('TextTitle', () => {
+  it('draws the type sheet before the title text, sized to the chip line', () => {
+    const { container } = render(<TextTitle {...props('README.md')} />)
+    const svg = container.querySelector('svg')
+    expect(svg?.getAttribute('width')).toBe('16')
+    expect(svg?.getAttribute('aria-hidden')).toBe('true')
+    expect(container.textContent).toBe('README.md')
+  })
+
+  it('picks the sheet from the title\'s extension', () => {
+    const markdown = render(<TextTitle {...props('notes.md')} />).container.querySelector('path')?.getAttribute('fill')
+    const pdf = render(<TextTitle {...props('paper.pdf')} />).container.querySelector('path')?.getAttribute('fill')
+    expect(markdown).not.toBe(pdf)
+  })
+})

+ 2 - 2
packages/client/ui-sidebar-files/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-files/README.md
-README.md: 6e9e305b15058797156f832a56a29c095b0641f3
-README.zh.md: 95a15444182a988fdf512735be4e0fbfcaad6bdd
+README.md: 89dc8ca0d3b34c7f2e0a720e959d9a163beb533f
+README.zh.md: 166e04bde8a61392248f14b43f71f2b88a4332bd

+ 4 - 3
packages/client/ui-sidebar-files/README.md

@@ -25,14 +25,15 @@ The right Sidebar's navigator tab type: the session's workspace root as a tree,
 ## What it registers
 
 - **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, titled from the `sidebarFiles` namespace) that opens the type.
-- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: the tree, with its one control, reload, at the right of its header row.
+- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the one control, reload, at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header.
+- **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a 16px folder sheet before the type's label. The tree's own rows never draw this sheet.
 
-Six source files under `src/client/`: `definition.ts` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `locales.ts` (what it says), and `index.ts` (the wiring).
+Seven source files under `src/client/`: `definition.ts` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring).
 
 <a id="the-tree"></a>
 ## The tree
 
-The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and labelled by `workspaceTitleOf` from `@deepseek-ai/dsh-util-workspace-path`. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry.
+The root is the session's working directory, read from `useSessions().byId[sessionId].cwd`, and split for the header row by `pathPartsOf` from `@deepseek-ai/dsh-util-workspace-path`. Every level is keyed by absolute path; a child's path is its parent's joined with the entry name by `/`. A level is listed when it is first expanded, through `remote.workspaceFiles.list(sessionId, absolutePath)` on the `@deepseek-ai/dsh-api-workspace-files` namespace; the adapter keeps the listing's entries and truncation flag and drops its workspace-relative path. Rows are ordered directories first, then by natural, case-insensitive name; dotfiles are shown like any other entry.
 
 | Entry type | Row |
 |---|---|

+ 4 - 3
packages/client/ui-sidebar-files/README.zh.md

@@ -25,14 +25,15 @@ kind: "package-reference"
 ## 注册了什么
 
 - **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题取自 `sidebarFiles` 命名空间)。
-- **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:树本身,以及它唯一的控件、位于标题行右端的重新读取。
+- **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端是它唯一的控件、重新读取。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。
+- **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` 坑位:类型标签前的一枚 16px 文件夹图标。树本身的行不画这枚图标。
 
-`src/client/` 下个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`locales.ts`(它说什么)、`index.ts`(接线)。
+`src/client/` 下个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。
 
 <a id="the-tree"></a>
 ## 树
 
-根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标签由 `@deepseek-ai/dsh-util-workspace-path` 的 `workspaceTitleOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配层保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
+根是会话的工作目录,读自 `useSessions().byId[sessionId].cwd`,标题行里的拆分由 `@deepseek-ai/dsh-util-workspace-path` 的 `pathPartsOf` 给出。每一层以绝对路径为键;子路径是父路径以 `/` 拼上条目名。一层在首次展开时经 `@deepseek-ai/dsh-api-workspace-files` 命名空间的 `remote.workspaceFiles.list(sessionId, absolutePath)` 列出;适配层保留列表的条目与截断标志,丢弃其工作区相对路径。行序为目录优先,其后按自然序、不分大小写的名称排列;dotfiles 与其他条目一样显示。
 
 | 条目类型 | 行 |
 |---|---|

+ 101 - 21
packages/client/ui-sidebar-files/src/client/FilesBody.module.css

@@ -1,23 +1,89 @@
+/* The pane body is a block scroller with a definite height, so the tree takes
+   that height outright: the header row stays put and the levels below are the
+   one scroller. */
 .root {
   display: flex;
   flex: 1 1 auto;
   flex-direction: column;
+  height: 100%;
   min-height: 0;
-  overflow: auto;
-  padding: 4px 0 8px;
   color: var(--dsw-alias-label-primary);
   font-size: var(--dsh-content-font-size-secondary, 13px);
   line-height: 1.5;
 }
 
+/* The header row, its path, and its control are the text preview's
+   (ui-sidebar-documentpreview TextPreview `.header`, `.path`, `.tool`), copied so
+   the two pane types read as one; keep the two sheets in step. TODO: once the
+   artifact and slot surfaces settle, one copy in ui-primitives could serve
+   every pane header.
+
+   One 38px row flush under the pane's strip: the root's path, then reload at
+   its end. With the strip's 38px above, its rule lies where the conversation
+   header's does (ui-conversation ConversationRoot `.header`, 76px with the
+   same rule). */
 .header {
   display: flex;
   flex: 0 0 auto;
-  gap: 6px;
+  gap: 4px;
   align-items: center;
-  padding: 4px 10px;
-  color: var(--dsw-alias-label-secondary);
-  font-weight: 500;
+  box-sizing: border-box;
+  height: 38px;
+  padding: 0 6px 0 16px;
+  border-bottom: 0.5px solid var(--dsw-alias-border-l3);
+}
+
+/* The path is never ellipsized. Its text sits at the left while it fits: the
+   auto margin takes the free space. Once it is wider than the box, the auto
+   margin is zero and `flex-end` holds the name at the right edge, so the
+   directories run off the left and the box clips them there, faded by the
+   mask while `data-files-path-clipped` is set. The margin plus the row's gap
+   put the text 16px clear of the first control. */
+.path {
+  display: flex;
+  flex: 1 1 auto;
+  justify-content: flex-end;
+  min-width: 0;
+  margin-right: 12px;
+  overflow: hidden;
+  font-size: 12px;
+  white-space: nowrap;
+}
+
+.path[data-files-path-clipped] {
+  mask-image: linear-gradient(to right, transparent, black 28px);
+}
+
+.pathText {
+  flex: none;
+  margin-right: auto;
+}
+
+.pathDirectory {
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.pathName {
+  color: var(--dsw-alias-label-primary);
+}
+
+.body {
+  flex: 1 1 auto;
+  min-height: 0;
+  /* The workspace list's 2px scrollbar offset (ui-workspace WorkspaceBrowser
+     `.list`): the bar sits 2px clear of the pane's edge, and the stable gutter
+     stands in for the right padding so the rows keep their inset whether or
+     not the tree overflows. */
+  margin-right: 2px;
+  padding: 8px 0 8px 8px;
+  overflow: auto;
+  scrollbar-gutter: stable;
+}
+
+/* The bar also stops 2px short of the body's ends, matching its 2px edge
+   offset. WebKit-only: the Firefox path has no track to inset. */
+.body::-webkit-scrollbar-track {
+  margin: 2px;
 }
 
 .level {
@@ -28,7 +94,7 @@
 
 /* Every nested level indents by one step; the root level sits under the header. */
 .level .level {
-  padding-left: 14px;
+  padding-left: 18px;
 }
 
 .item {
@@ -42,13 +108,15 @@
   align-items: center;
   width: 100%;
   min-width: 0;
-  padding: 3px 10px;
+  /* Rows abut, so the hover fill is the whole row; the tree's air is this
+     inset, not a gap between rows. */
+  padding: 5px 10px;
   color: inherit;
   font: inherit;
   text-align: left;
   background: transparent;
   border: 0;
-  border-radius: 6px;
+  border-radius: 10px;
   cursor: pointer;
 }
 
@@ -56,16 +124,15 @@
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
+/* The folder glyph rides the tertiary ink, as the workspace tree's does (ui-workspace Rows `.slot`). */
 .icon {
   flex: 0 0 auto;
-  color: var(--dsw-alias-label-secondary);
+  color: var(--dsw-alias-label-tertiary);
 }
 
-/* The document glyph is drawn 24×28; it rides the row at icon height. */
+/* The type's coloured sheet at the folder glyph's 16px; its colour is its own. */
 .fileIcon {
   flex: 0 0 auto;
-  width: 14px;
-  height: 16px;
 }
 
 .name {
@@ -106,23 +173,36 @@
   line-height: 1.6;
 }
 
-/* The header's reload control, pushed to the row's right edge. */
+/* The same icon button as the Sidebar's strip controls above it: a 28px
+   circle around a 15px glyph. */
 .tool {
   display: inline-flex;
-  flex: 0 0 auto;
+  flex: none;
   align-items: center;
   justify-content: center;
-  width: 24px;
-  height: 24px;
-  margin-left: auto;
-  padding: 0;
+  width: 28px;
+  height: 28px;
+  padding: 6px;
   color: var(--dsw-alias-label-secondary);
+  line-height: 1;
   background: transparent;
-  border: 0;
-  border-radius: 6px;
+  border: none;
+  border-radius: 28px;
   cursor: pointer;
 }
 
+.tool svg {
+  width: 15px;
+  height: 15px;
+}
+
 .tool:hover {
+  color: var(--dsw-alias-label-primary);
   background: var(--dsw-alias-interactive-bg-hover);
 }
+
+/* The folder sheet before the chip's text; the chip's title row centres it
+   on the text's line and spaces it. */
+.titleIcon {
+  flex: none;
+}

+ 57 - 13
packages/client/ui-sidebar-files/src/client/FilesBody.tsx

@@ -5,19 +5,20 @@
  * for goes through its injected face. The component itself only decides what to
  * draw for each absolute path and what a click means: a directory toggles, a
  * file opens through the owner's `tabActions` for a `file:` viewer to claim, and
- * anything else is shown but refuses to open. The header row carries the one
- * control: reload, which drops every listed level and asks again for the
- * expanded ones.
+ * anything else is shown but refuses to open. The header row is the text
+ * preview's: the root's path, directories greyed and the last segment in full
+ * ink, then the one control at its end, reload, which drops every listed level
+ * and asks again for the expanded ones.
  */
-import { useEffect } from 'react'
-import type { ReactNode } from 'react'
+import { useEffect, useLayoutEffect, useRef } from 'react'
+import type { ReactNode, RefObject } from 'react'
 import clsx from 'clsx'
 import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
 import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
 import {
-  DocumentFileIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16,
+  FileTypeIcon, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, classifyFileType,
 } from '@deepseek-ai/dsh-client-ui-primitives'
-import { fileAddressFor, workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path'
+import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
 import type { WorkspaceDirectoryEntry } from '@deepseek-ai/dsh-api-workspace-files/types'
 import { childPath } from './face.ts'
 import type { FilesInjected } from './face.ts'
@@ -66,6 +67,39 @@ export function failureLine(t: TranslateNS<'sidebarFiles'>, failure: RemoteFailu
   }
 }
 
+/* jscpd:ignore-start -- the header row is the document preview's (ui-sidebar-documentpreview
+   TextPreview `usePathClipped`), copied because a plugin bundle shares runtime code
+   only through the platform modules. TODO: once the artifact and slot surfaces
+   settle, one copy in ui-primitives could serve every pane header. */
+/**
+ * Keep the path row's `data-files-path-clipped` current: set while the path's
+ * text is wider than its box, so the stylesheet fades the clipped start. Read
+ * after each commit that can change the path or mount the header, and whenever
+ * either box resizes; written to the DOM directly because it changes only how
+ * the stylesheet fades what is already rendered.
+ */
+function usePathClipped(
+  box: RefObject<HTMLDivElement | null>,
+  text: RefObject<HTMLSpanElement | null>,
+  path: string | undefined,
+): void {
+  useLayoutEffect(() => {
+    const outer = box.current
+    const inner = text.current
+    if (outer === null || inner === null) return undefined
+    const apply = (): void => {
+      if (inner.offsetWidth > outer.clientWidth) outer.dataset.filesPathClipped = ''
+      else delete outer.dataset.filesPathClipped
+    }
+    apply()
+    const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(apply)
+    observer?.observe(outer)
+    observer?.observe(inner)
+    return () => { observer?.disconnect() }
+  }, [box, text, path])
+}
+/* jscpd:ignore-end */
+
 /** What every level shares: the tab's tree and the two gestures. */
 interface TreeContext {
   readonly state: FilesTabState
@@ -93,7 +127,7 @@ function Entry({ parent, entry, tree }: { parent: string; entry: WorkspaceDirect
     return (
       <li className={css.item} data-files-entry="file" data-files-path={path}>
         <button type="button" className={css.row} onClick={() => { tree.onOpen(path) }}>
-          <DocumentFileIcon className={css.fileIcon} />
+          <FileTypeIcon kind={classifyFileType(entry.name)} size={16} className={css.fileIcon} />
           <span className={css.name}>{entry.name}</span>
         </button>
       </li>
@@ -140,6 +174,9 @@ export function FilesBody({
   const { signal, actions: tabActions } = tab
   const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd)
   const state = useStore(store => store.byTab[tab.id])
+  const pathRef = useRef<HTMLDivElement>(null)
+  const pathTextRef = useRef<HTMLSpanElement>(null)
+  usePathClipped(pathRef, pathTextRef, state?.root)
   useEffect(() => {
     // A bucket gone because the record aborted must not be re-seeded by a
     // component that has not unmounted yet.
@@ -168,13 +205,17 @@ export function FilesBody({
     actions.reset(tab.id)
     for (const path of state.expanded) load(tab.id, path, signal)
   }
-  // A separator-only root has no final segment; the root itself is the label then.
-  const title = workspaceTitleOf(state.root) || state.root
+  const { directory, name } = pathPartsOf(state.root)
   return (
     <div className={css.root} data-files-state="tree" data-files-root={state.root}>
+      {/* jscpd:ignore-start -- the text preview's header row; see `usePathClipped`. */}
       <div className={css.header}>
-        <IconFolderOpen16 className={css.icon} />
-        <span className={css.name}>{title}</span>
+        <div ref={pathRef} className={css.path} title={state.root} data-files-path>
+          <span ref={pathTextRef} className={css.pathText}>
+            {directory !== '' && <span className={css.pathDirectory}>{directory}</span>}
+            <span className={css.pathName}>{name}</span>
+          </span>
+        </div>
         <button
           type="button"
           className={css.tool}
@@ -186,7 +227,10 @@ export function FilesBody({
           <IconRefreshOutline16 />
         </button>
       </div>
-      <ul className={css.level}><Level path={state.root} tree={tree} /></ul>
+      {/* jscpd:ignore-end */}
+      <div className={css.body}>
+        <ul className={css.level}><Level path={state.root} tree={tree} /></ul>
+      </div>
     </div>
   )
 }

+ 37 - 0
packages/client/ui-sidebar-files/src/client/FilesTitle.tsx

@@ -0,0 +1,37 @@
+/**
+ * The files type's chip title: the folder sheet before the type's label.
+ * Registered under `sidebar.right.pane.tab.title`; without it the chip would
+ * show the bare label. The tree in the body draws its own row glyphs and never
+ * this one.
+ */
+import type { ReactNode } from 'react'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import css from './FilesBody.module.css'
+
+/** The folder: a 28-unit sheet in the folder colour, a white tab line across it. */
+function FolderGlyph(): ReactNode {
+  return (
+    <svg width="16" height="16" viewBox="0 0 28 28" fill="none" aria-hidden="true" className={css.titleIcon}>
+      <path
+        d="M1.40234 9.66489C1.40234 8.89627 1.40234 8.51197 1.46125 8.15149C1.61126 7.2335 2.04206 6.38455 2.69437 5.72144C2.95052 5.46106 3.26068 5.23414 3.88101 4.78031C4.20493 4.54333 4.36689 4.42484 4.53696 4.32686C4.96871 4.07811 5.4474 3.9217 5.94272 3.86753C6.13784 3.84619 6.33851 3.84619 6.73986 3.84619H11.3625C12.2767 3.84619 12.7338 3.84619 13.1688 3.95634C13.3131 3.99288 13.455 4.03833 13.5937 4.09244C14.0117 4.25555 14.3837 4.52115 15.1278 5.05235L16.5476 6.06601C16.8731 6.29841 17.0359 6.41461 17.2187 6.48597C17.2794 6.50964 17.3415 6.52953 17.4046 6.54551C17.5949 6.5937 17.7949 6.5937 18.1949 6.5937H20.5273C23.0584 6.5937 24.3239 6.5937 25.2111 7.23827C25.4976 7.44644 25.7496 7.69841 25.9578 7.98493C26.6023 8.8721 26.6023 10.1376 26.6023 12.6687V19.2466C26.6023 21.7777 26.6023 23.0432 25.9578 23.9304C25.7496 24.2169 25.4976 24.4688 25.2111 24.677C24.3239 25.3216 23.0584 25.3216 20.5273 25.3216H7.47734C4.94627 25.3216 3.68074 25.3216 2.79357 24.677C2.50705 24.4688 2.25508 24.2169 2.04691 23.9304C1.40234 23.0432 1.40234 21.7777 1.40234 19.2466V9.66489Z"
+        fill="#F7AD31"
+      />
+      <path d="M5.35156 12.8799H22.6483" stroke="white" strokeWidth="2.6775" />
+    </svg>
+  )
+}
+
+/**
+ * The title as the chip and a floating panel's header show it.
+ * @param props - the tab information hook.
+ * @returns the folder sheet followed by the tab's title text.
+ */
+export function FilesTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.title'>): ReactNode {
+  const { tab } = useTabInfo()
+  return (
+    <>
+      <FolderGlyph />
+      {tab.title}
+    </>
+  )
+}

+ 0 - 1
packages/client/ui-sidebar-files/src/client/definition.ts

@@ -30,7 +30,6 @@ export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTab
     guide: [{
       order: 10,
       title: () => t('guide.title'),
-      description: () => t('guide.description'),
       icon: IconFolderClose16,
     }],
   }

+ 10 - 4
packages/client/ui-sidebar-files/src/client/index.ts

@@ -2,12 +2,13 @@
  * Browser half: register `files` as a right-Sidebar tab type.
  *
  * The public two-stage path, unmodified: the type into `ctx.sidebarRightTabs`,
- * the body into the keyed `sidebar.right.pane.tab` seat under the type's `id`.
+ * the body into the keyed `sidebar.right.pane.tab` seat and the chip title into
+ * the keyed `sidebar.right.pane.tab.title` seat, both under the type's `id`.
  *
  * The file split is this package's layering: what the type IS
  * (`definition.ts`), what it keeps (`store.ts`), how it lists (`face.ts`), what
- * it draws (`FilesBody.tsx`), what it says (`locales.ts`), and this module,
- * which only wires them together.
+ * it draws (`FilesBody.tsx`, `FilesTitle.tsx`), what it says (`locales.ts`),
+ * and this module, which only wires them together.
  */
 import type { Context as ClientContext } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -17,6 +18,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
 import { FILES_ID, filesDefinition } from './definition.ts'
 import { createList, filesFace } from './face.ts'
 import { FilesBody } from './FilesBody.tsx'
+import { FilesTitle } from './FilesTitle.tsx'
 import { en, zh } from './locales.ts'
 import { createFilesStore } from './store.ts'
 
@@ -35,7 +37,7 @@ const NS = 'sidebarFiles'
 export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles']
 
 /**
- * Client plugin body: register the type, its dictionaries, then its body.
+ * Client plugin body: register the type, its dictionaries, its body, and its chip title.
  * @param ctx - client root context carrying the registry, the slots, and the Remote face.
  */
 export function apply(ctx: ClientContext): void {
@@ -49,4 +51,8 @@ export function apply(ctx: ClientContext): void {
     { name: 'sidebar.right.pane.tab', key: FILES_ID, locale: NS, store, inject },
     FilesBody,
   )), 'ui-sidebar-files: files tab body')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
+    { name: 'sidebar.right.pane.tab.title', key: FILES_ID },
+    FilesTitle,
+  )), 'ui-sidebar-files: files tab title')
 }

+ 3 - 5
packages/client/ui-sidebar-files/src/client/locales.ts

@@ -21,8 +21,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
 /** Simplified Chinese dictionary and key-set source of truth. */
 export const zh = {
   'type.label': '文件',
-  'guide.title': '文件',
-  'guide.description': '浏览这个会话工作区里的文件,点开就能查看。',
+  'guide.title': '工作区文件',
   loading: '正在读取…',
   empty: '空目录',
   truncated: '条目太多,只显示了一部分。',
@@ -41,11 +40,10 @@ export type SidebarFilesKey = keyof typeof zh
 /** English dictionary, checked against the Chinese key set. */
 export const en = {
   'type.label': 'Files',
-  'guide.title': 'Files',
-  'guide.description': 'Browse the files in this session\'s workspace and open any of them.',
+  'guide.title': 'Workspace files',
   loading: 'Reading…',
   empty: 'Empty directory',
-  truncated: 'Too many entries; showing only some of them.',
+  truncated: 'Too many entries, showing only some of them.',
   noWorkspace: 'This session has no workspace directory.',
   reload: 'Reload',
   'entry.other': 'Not a file or a directory, so it cannot be opened.',

+ 4 - 2
packages/client/ui-sidebar-files/tests/apply.client.spec.ts

@@ -14,6 +14,7 @@ import { FILES_ID, FILES_KIND } from '../src/client/definition.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { apply as hostApply } from '../src/index.ts'
 import { FilesBody } from '../src/client/FilesBody.tsx'
+import { FilesTitle } from '../src/client/FilesTitle.tsx'
 import { en, zh } from '../src/client/locales.ts'
 
 interface Recorded {
@@ -62,18 +63,19 @@ describe('ui-sidebar-files apply', () => {
     expect(hostApply).not.toThrow()
   })
 
-  it('registers the type, its dictionaries, and the body seat under the type\'s id with a store and a face', async () => {
+  it('registers the type, its dictionaries, and the body and title seats under the type\'s id', async () => {
     const { tabs, registered, dictionaries } = await boot()
     const definition = tabs.get(FILES_KIND)
     expect(definition?.id).toBe(FILES_ID)
     expect(definition?.priority).toBe('builtin')
     expect(definition?.title('sidebar://files')).toBe('type.label')
-    expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description()])).toEqual([[10, 'guide.title', 'guide.description']])
+    expect(definition?.guide?.map(entry => [entry.order, entry.title()])).toEqual([[10, 'guide.title']])
     expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en })
     // The seat key is the implementation's id, not the kind: an extension may
     // take the kind over, and the seat must still find this body.
     expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
       ['sidebar.right.pane.tab', FILES_ID, 'sidebarFiles', FilesBody],
+      ['sidebar.right.pane.tab.title', FILES_ID, undefined, FilesTitle],
     ])
     expect(registered[0]?.store).toBeDefined()
     expect(typeof registered[0]?.inject).toBe('function')

+ 0 - 1
packages/client/ui-sidebar-files/tests/definition.client.spec.ts

@@ -31,7 +31,6 @@ describe('filesDefinition', () => {
     expect(entry?.order).toBe(10)
     expect(entry?.kind).toBe(FILES_KIND)
     expect(entry?.title()).toBe(zh['guide.title'])
-    expect(entry?.description()).toBe(zh['guide.description'])
     expect(entry?.icon).toBeDefined()
   })
 

+ 57 - 5
packages/client/ui-sidebar-files/tests/files-body.client.spec.tsx

@@ -9,7 +9,7 @@
  * was cut or could not be read, and reload asks again for the expanded levels
  * only. The two pure helpers the rows are built from are checked on their own.
  */
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup, fireEvent } from '@testing-library/react'
 import { makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
@@ -43,23 +43,75 @@ describe('FilesBody', () => {
     expect(script.list).not.toHaveBeenCalled()
   })
 
-  it('lists the root on mount, names it by its basename, and draws directories first with dotfiles kept', async () => {
+  it('lists the root on mount, heads it with its path split at the last segment, and draws directories first with dotfiles kept', async () => {
     const { view, script } = mountBody()
     expect(script.list).toHaveBeenCalledWith(SESSION, ROOT, expect.any(AbortSignal))
     expect(view.container.querySelector('[data-files-row="loading"]')).not.toBeNull()
     await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
     expect(view.container.querySelector('[data-files-state="tree"]')?.getAttribute('data-files-root')).toBe(ROOT)
-    expect(view.container.querySelector('[data-files-state="tree"] > div')?.textContent).toBe('app')
+    const path = view.container.querySelector('[data-files-path]')
+    expect(path?.getAttribute('title')).toBe(ROOT)
+    expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/work/', 'app'])
     expect(names(view.container)).toEqual([`${ROOT}/src`, `${ROOT}/.env`, `${ROOT}/pipe`, `${ROOT}/README.md`])
   })
 
-  it('labels a separator-only root by the root itself, since it has no final segment', async () => {
+  it('heads a separator-only root by the root itself, since it has no final segment', async () => {
     const { view, script } = mountBody('/')
     await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
-    expect(view.container.querySelector('[data-files-state="tree"] > div')?.textContent).toBe('/')
+    const path = view.container.querySelector('[data-files-path]')
+    expect([...path?.querySelectorAll('span > span') ?? []].map(span => span.textContent)).toEqual(['/'])
     expect(names(view.container)).toEqual(['/src', '/.env', '/pipe', '/README.md'])
   })
 
+  it('marks the root path clipped while its text is wider than its box, re-reading on resize', async () => {
+    class FakeResizeObserver implements ResizeObserver {
+      static latest: FakeResizeObserver | undefined
+      readonly observe = vi.fn()
+      readonly unobserve = vi.fn()
+      readonly disconnect = vi.fn()
+      constructor(private readonly callback: ResizeObserverCallback) {
+        FakeResizeObserver.latest = this
+      }
+
+      fire(): void {
+        this.callback([], this)
+      }
+    }
+    vi.stubGlobal('ResizeObserver', FakeResizeObserver)
+    let boxWidth = 300
+    const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')
+    const clientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
+    Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
+    Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => boxWidth })
+    try {
+      const { view, script } = mountBody()
+      await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))
+      const path = view.container.querySelector<HTMLElement>('[data-files-path]')
+      const text = path?.firstElementChild
+      expect(path?.hasAttribute('data-files-path-clipped')).toBe(false)
+      const observer = FakeResizeObserver.latest
+      if (observer === undefined) throw new Error('expected the path to observe its size')
+      expect(observer.observe).toHaveBeenCalledWith(path)
+      expect(observer.observe).toHaveBeenCalledWith(text)
+
+      boxWidth = 120
+      act(() => { observer.fire() })
+      expect(path?.hasAttribute('data-files-path-clipped')).toBe(true)
+
+      boxWidth = 300
+      act(() => { observer.fire() })
+      expect(path?.hasAttribute('data-files-path-clipped')).toBe(false)
+      view.unmount()
+      expect(observer.disconnect).toHaveBeenCalledTimes(1)
+    } finally {
+      vi.unstubAllGlobals()
+      for (const [name, descriptor] of [['offsetWidth', offsetWidth], ['clientWidth', clientWidth]] as const) {
+        if (descriptor === undefined) Reflect.deleteProperty(HTMLElement.prototype, name)
+        else Object.defineProperty(HTMLElement.prototype, name, descriptor)
+      }
+    }
+  })
+
   it('a directory click lists that level once and marks it expanded; a second click collapses without asking again', async () => {
     const { view, script } = mountBody()
     await act(() => script.settle({ ok: true, value: ROOT_LEVEL }))

+ 22 - 0
packages/client/ui-sidebar-files/tests/files-title.client.spec.tsx

@@ -0,0 +1,22 @@
+// @vitest-environment jsdom
+/** The chip title: the folder sheet, then the type's label. */
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import { FilesTitle } from '../src/client/FilesTitle.tsx'
+
+afterEach(cleanup)
+
+function props(title: string): PropsRuntime<'sidebar.right.pane.tab.title'> {
+  return { useTabInfo: () => ({ tab: { title } }) } as unknown as PropsRuntime<'sidebar.right.pane.tab.title'>
+}
+
+describe('FilesTitle', () => {
+  it('draws the folder sheet before the title text, sized to the chip line', () => {
+    const { container } = render(<FilesTitle {...props('文件')} />)
+    const svg = container.querySelector('svg')
+    expect(svg?.getAttribute('width')).toBe('16')
+    expect(svg?.getAttribute('aria-hidden')).toBe('true')
+    expect(container.textContent).toBe('文件')
+  })
+})

+ 2 - 2
packages/client/ui-sidebar-right/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md
-README.md: 993f3772f2b4b24ea7fed6a58d6614f1bc445cc0
-README.zh.md: 2907f202be7872a56abfe9052109b8d781e31c6a
+README.md: 128f0e41a1b1a90f39f4db36f1a8ca8dfa3089f7
+README.zh.md: 66f2e8d6a07635d7e94ee50395c274951911e517

+ 5 - 4
packages/client/ui-sidebar-right/README.md

@@ -50,7 +50,7 @@ The panel has no header row. Its two controls — the presentation switch and th
 <a id="the-expand-button"></a>
 ## The expand button
 
-While the panel is hidden, one button in the conversation header's corner seat (`conversation.session.header.corner`, past the utilities' right edge and level with the Session log control) is the way back in. Its glyph is the left sidebar's collapse icon mirrored. It shares the panel's store (the slot runtime allows one handle across two same-scope seats); while the panel is shown it renders a same-size placeholder, so the corner keeps its width and nothing in the header row moves. A collapsed Sidebar therefore costs the conversation nothing: no rail, no width, and the transcript's scrollbar stays at the column's edge. Without a session there is no button and no panel.
+While the panel is hidden, one button in the conversation header's corner seat (`conversation.session.header.corner`, past the utilities' right edge and level with the Session log control) is the way back in. Its glyph is the left sidebar's collapse icon mirrored. It shares the panel's store (the slot runtime allows one handle across two same-scope seats); while the panel is shown it renders nothing, and the corner seat collapses with it. A collapsed Sidebar therefore costs the conversation nothing: no rail, no width, and the transcript's scrollbar stays at the column's edge. Without a session there is no button and no panel.
 
 The panel takes the conversation's ground colour and content font sizes rather than a raised layer of its own: it is a column of the page, not a card over it.
 
@@ -65,6 +65,8 @@ Carrying the mint counter in the surface is what makes a recorded sequence repla
 
 After every action the kit's settle planner keeps the surface populated: a docked pane whose last tab was moved out or floated is merged away, and an emptied root pane receives the default page. Explicit closing follows [the default-page and close rules](#the-guide). There is always at least one tab, and never an empty pane — so there is no separate "close pane" gesture.
 
+The docked surface's last tab carries one more rule, decided in the store's `closeTab` and mirrored to the kit through `canCloseTab`: the guide standing as the only docked tab draws no close control and no menu close item — its chip sits quiet, and with no extension item contributed a secondary press opens no menu — and a programmatic close of it records nothing; any other tab standing alone closes together with the column in one entry — the settle planner reseeds the current default page for the next expansion. Floating panels take no part in the rule: they render whether or not the column is expanded, and their tabs close freely.
+
 State is memory-only. A reload returns every session to the collapsed default; switching sessions keeps each surface where it was.
 
 <a id="extension-seats"></a>
@@ -94,9 +96,9 @@ The Tab domain retains navigation, an abort signal, and bound actions per (Sessi
 <a id="the-guide"></a>
 ## The guide
 
-Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Each single-entry default remains non-closable when other tabs are added. Any pane's last tab is also non-closable; other tabs can close. The chip, context menu, and `close` API apply the same rule. Explicitly adding a guide still opens the guide, even with one entry.
+Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule.
 
-The guide tab is a centred title, one line under it, and one entry box per `guide` entry the registered types contributed, in `order`. Picking a box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
+The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split or an emptied root pane uses the same default-page rule, one tab per new pane. A plain `openTab('guide')` keeps the tree-wide reveal every open has. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
 
 <a id="copy"></a>
 ## Copy
@@ -120,7 +122,6 @@ None; this package neither assembles nor sends a provider request.
 - **No surface without a session.** State is keyed by session id, so the hero screen shows nothing on the right.
 - **Hard-coded stacking.** The panel and the float host use fixed z-index values because the client has no z-index token layer yet.
 - **Undo is not exposed.** The recorded sequence is stepped only through the `@internal` service methods; product controls are deliberately absent.
-- **Guide copy is a draft** awaiting product review; the words live in `locales.ts`.
 - **Titles are fixed at open time.** A type's `title(address)` is captured into the record; a live title comes only from the optional title seat.
 - **No content navigation stack.** Stepping back replays layout operations; an editor-style back/forward over visited content is not built.
 

+ 5 - 4
packages/client/ui-sidebar-right/README.zh.md

@@ -50,7 +50,7 @@ kind: "package-reference"
 <a id="the-expand-button"></a>
 ## 展开按钮
 
-面板隐藏时,会话 header 角落席位里的一个按钮(`conversation.session.header.corner`,在工具组右缘之外,与 Session 日志控件齐平)是回去的路。它的图形是左侧 sidebar 折叠图标的镜像。它与面板共用一个存储(slot 运行时允许两个同作用域席位共用一个 handle);面板显示时它渲染一个同尺寸的占位,因此角落保持宽度,header 行里没有东西会移动。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。
+面板隐藏时,会话 header 角落席位里的一个按钮(`conversation.session.header.corner`,在工具组右缘之外,与 Session 日志控件齐平)是回去的路。它的图形是左侧 sidebar 折叠图标的镜像。它与面板共用一个存储(slot 运行时允许两个同作用域席位共用一个 handle);面板显示时它什么也不渲染,角落席位随之收起。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。
 
 面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。
 
@@ -65,6 +65,8 @@ kind: "package-reference"
 
 每个动作之后,套件的 settle planner 保证停靠面有内容:最后一个 tab 被搬走或浮出的停靠格会被并掉;根格被清空时填入默认页。显式关闭遵循[默认页与关闭规则](#the-guide)。永远至少有一个 tab,永远没有空格——因此没有单独的「关闭格」手势。
 
+停靠面的最后一个 tab 还多带一条规则,由 store 的 `closeTab` 决定并经 `canCloseTab` 镜像给套件:作为唯一停靠 tab 的引导页不画关闭控件也不画菜单里的关闭项——它的 chip 呈安静样式,在没有扩展条目时次键按下也不弹出菜单——对它的编程式关闭什么都不记录;任何其它 tab 独自留下时,点击关闭会连同整列一起收起,记为一条历史——settle planner 为下次展开重新播种当前默认页。浮动面板不参与这条规则:它们无论列是否展开都会渲染,其 tab 照常关闭。
+
 状态只在内存中。刷新会让每个会话回到折叠的默认态;切换会话则让每个停靠面留在原处。
 
 <a id="extension-seats"></a>
@@ -94,9 +96,9 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私
 <a id="the-guide"></a>
 ## 引导页
 
-默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。单入口生成的默认页在增加其他 tab 后仍不可关闭。任何格的最后一个 tab 也不可关闭;其他 tab 可以关闭。chip、上下文菜单与 `close` API 使用同一规则。显式添加引导页始终打开引导,即使只有一个入口。
+默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。
 
-引导 tab 是一个居中标题、其下一行说明,以及各已注册类型贡献的每个 `guide` 条目一个入口框,按 `order` 排列。选中一个框会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。 达到两格上限时隐藏分栏控件;关闭回单格后恢复。
+引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格使用相同的默认页规则,每个新格一个 tab。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
 
 <a id="copy"></a>
 ## 文案
@@ -120,7 +122,6 @@ None; this package neither assembles nor sends a provider request.
 - **没有会话就没有停靠面。** 状态按会话 id 键控,因此 hero 画面右侧什么都不显示。
 - **硬编码的层叠。** 面板与浮窗宿主使用固定的 z-index 值,因为客户端还没有 z-index token 层。
 - **未暴露撤销。** 记录的序列只能通过 `@internal` 服务方法步进;产品控件是有意缺席的。
-- **引导页文案是草稿**,等待产品评审;文字住在 `locales.ts`。
 - **标题在打开时固定。** 类型的 `title(address)` 被捕获进记录;会变的标题只来自可选的标题席位。
 - **没有内容导航栈。** 后退回放的是布局操作;编辑器式的「已访问内容」前进/后退尚未构建。
 

+ 1 - 2
packages/client/ui-sidebar-right/src/client/contract/seed.ts

@@ -16,7 +16,6 @@ import type { SidebarRightTabRegistry } from '../tab-registry.ts'
 export interface SidebarRightSeed {
   readonly kind: string
   readonly title: string
-  readonly permanent: boolean
 }
 
 /**
@@ -30,7 +29,7 @@ export function defaultSeed(tabs: SidebarRightTabRegistry): SidebarRightSeed {
   const kind = single ? only.kind : GUIDE_KIND
   const definition = tabs.get(kind)
   if (definition === undefined) throw new Error(`sidebarRight: default tab kind "${kind}" is not registered`)
-  return { kind, title: definition.title(pageAddress(kind)), permanent: single }
+  return { kind, title: definition.title(pageAddress(kind)) }
 }
 
 /** The guide tab's kind. */

+ 6 - 1
packages/client/ui-sidebar-right/src/client/index.ts

@@ -30,6 +30,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {} from './contract/slots.ts'
 import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx'
+import { GuideTitle } from './tabs/guide/GuideTitle.tsx'
 import { ExpandButton } from './shell/ExpandButton.tsx'
 import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx'
 import { RightbarRoot } from './shell/RightbarRoot.tsx'
@@ -183,7 +184,6 @@ export function apply(ctx: ClientContext): void {
     const disposeGuide = ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
       name: 'sidebar.right.pane.tab',
       key: GUIDE_ID,
-      locale: NS,
       children: {
         'sidebar.right.tab.guide': {
           kind: 'chain', scope: 'session', inject: { hooks: { tabInfo: guideTabInfoFactory } },
@@ -191,7 +191,12 @@ export function apply(ctx: ClientContext): void {
       },
       inject: () => guideInjected,
     }, GuideBody))
+    const disposeGuideTitle = ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register(
+      { name: 'sidebar.right.pane.tab.title', key: GUIDE_ID },
+      GuideTitle,
+    ))
     return () => {
+      disposeGuideTitle()
       disposeGuide()
       disposeExpand()
       disposeSeat()

+ 7 - 0
packages/client/ui-sidebar-right/src/client/labels.ts

@@ -26,5 +26,12 @@ export function dockLabels(t: TranslateNS<'sidebarRight'>): DockLabels {
     addTab: t('dock.addTab'),
     dockFloat: t('dock.dockFloat'),
     closeFloat: t('dock.closeFloat'),
+    dropZone: {
+      center: t('dock.drop.center'),
+      left: t('dock.drop.left'),
+      right: t('dock.drop.right'),
+      top: t('dock.drop.top'),
+      bottom: t('dock.drop.bottom'),
+    },
   }
 }

+ 27 - 17
packages/client/ui-sidebar-right/src/client/locales.ts

@@ -8,22 +8,27 @@
 
 /** Simplified Chinese dictionary and key-set source of truth. */
 export const zh = {
-  'chrome.expand': '展开侧栏',
-  'chrome.collapse': '收起侧栏',
-  'chrome.toFullscreen': '全屏显示侧栏',
-  'chrome.exitFullscreen': '退出侧栏全屏',
+  'chrome.expand': '打开侧边栏',
+  'chrome.expandAria': '打开右侧边栏',
+  'chrome.collapse': '收起侧边栏',
+  'chrome.collapseAria': '收起右侧边栏',
+  'chrome.toFullscreen': '全屏',
+  'chrome.exitFullscreen': '退出全屏',
   'dock.emptyPane': '空面板',
-  'dock.splitPane': '向右分栏',
+  'dock.splitPane': '分栏',
   'dock.splitPaneDisabled': '已达两格上限',
-  'dock.splitPaneNarrow': '栏宽不足,拖宽侧栏后再分栏',
+  'dock.splitPaneNarrow': '栏宽不足,拖宽侧栏后再分栏',
   'dock.closeTab': '关闭',
   'dock.addTab': '新标签页',
-  'dock.dockFloat': '收回到侧栏',
+  'dock.dockFloat': '收回到侧栏',
   'dock.closeFloat': '关闭',
+  'dock.drop.center': '移到这里',
+  'dock.drop.left': '左分栏',
+  'dock.drop.right': '右分栏',
+  'dock.drop.top': '上分栏',
+  'dock.drop.bottom': '下分栏',
   'tab.guide.title': '开始',
   'tab.unavailable': '这类内容还没有可用的查看方式。',
-  'guide.lead': '侧栏用来放你想一直看着的东西。',
-  'guide.body': '会话里的文件和产物会开在这一栏,也可以从下面的入口打开。',
 } satisfies Record<string, string>
 
 /** Right-Sidebar dictionary key union. */
@@ -31,20 +36,25 @@ export type SidebarRightKey = keyof typeof zh
 
 /** English dictionary, checked against the Chinese key set. */
 export const en = {
-  'chrome.expand': 'Open the sidebar',
-  'chrome.collapse': 'Close the sidebar',
-  'chrome.toFullscreen': 'Show the sidebar fullscreen',
-  'chrome.exitFullscreen': 'Exit sidebar fullscreen',
+  'chrome.expand': 'Open sidebar',
+  'chrome.expandAria': 'Open right sidebar',
+  'chrome.collapse': 'Collapse sidebar',
+  'chrome.collapseAria': 'Collapse right sidebar',
+  'chrome.toFullscreen': 'Fullscreen',
+  'chrome.exitFullscreen': 'Exit fullscreen',
   'dock.emptyPane': 'Empty pane',
-  'dock.splitPane': 'Split to the right',
+  'dock.splitPane': 'Split',
   'dock.splitPaneDisabled': 'Two panes is the limit',
-  'dock.splitPaneNarrow': 'Not enough width to split; widen the sidebar',
+  'dock.splitPaneNarrow': 'Not enough width to split, widen the sidebar',
   'dock.closeTab': 'Close',
   'dock.addTab': 'New tab',
   'dock.dockFloat': 'Send back to the sidebar',
   'dock.closeFloat': 'Close',
+  'dock.drop.center': 'Move here',
+  'dock.drop.left': 'Add left split',
+  'dock.drop.right': 'Add right split',
+  'dock.drop.top': 'Add top split',
+  'dock.drop.bottom': 'Add bottom split',
   'tab.guide.title': 'Start',
   'tab.unavailable': 'Nothing here can view this kind of content yet.',
-  'guide.lead': 'The sidebar holds what you want to keep looking at.',
-  'guide.body': 'Files and artifacts from the conversation open in this column; the entries below open more.',
 } satisfies Record<SidebarRightKey, string>

+ 3 - 3
packages/client/ui-sidebar-right/src/client/service.ts

@@ -157,7 +157,7 @@ export interface ISidebarRight {
    */
   openTab<K extends string>(kind: K, options?: SidebarRightOpenTabOptions<K>): void
   /**
-   * Close one tab of the mounted session; permanent defaults and a pane's last tab remain open.
+   * Close one tab of the mounted session; the sole docked guide remains open.
    * @param tabId - the tab to close.
    */
   close(tabId: TabId): void
@@ -286,7 +286,7 @@ export class SidebarRightController implements ISidebarRight {
   }
 
   /**
-   * Close a tab of one session, preserving permanent defaults and a pane's last tab; nothing happens
+   * Close a tab of one session, preserving the sole docked guide; nothing happens
    * for a session whose store was never adopted or whose adoption was released.
    * Not part of `ISidebarRight`: the Tab domain's path.
    * @param sessionId - the session the tab is in.
@@ -343,7 +343,7 @@ export class SidebarRightController implements ISidebarRight {
   }
 
   /**
-   * Close one tab of the mounted session; permanent defaults and a pane's last tab remain open.
+   * Close one tab of the mounted session; the sole docked guide remains open.
    * @param tabId - the tab to close.
    */
   close(tabId: TabId): void {

+ 11 - 16
packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css

@@ -1,34 +1,29 @@
 /*
  * Sits in the conversation header's corner seat, level with the Session log
- * control, so it takes that row's 32px height and the same hover fill; square
- * with the header's corner radius rather than a pill, because it is an icon
- * button, not a labelled one. The placeholder is the same box with nothing in
- * it, so the seat's width holds while the panel is shown.
+ * control. The same icon button as the panel's own strip controls: a 28px
+ * circle around a 15px glyph.
  */
-.placeholder {
-  display: inline-block;
-  flex: none;
-  width: 32px;
-  height: 32px;
-}
-
 .button {
   display: inline-flex;
   flex: none;
   align-items: center;
   justify-content: center;
-  width: 32px;
-  height: 32px;
-  padding: 0;
+  width: 28px;
+  height: 28px;
+  padding: 6px;
   color: var(--dsw-alias-label-secondary);
   background: transparent;
   border: none;
-  border-radius: 8px;
+  border-radius: 28px;
   cursor: pointer;
 }
 
+.button svg {
+  width: 15px;
+  height: 15px;
+}
+
 .button:hover {
-  color: var(--dsw-alias-label-primary);
   background: var(--dsw-alias-interactive-bg-hover);
 }
 

+ 18 - 18
packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx

@@ -5,17 +5,16 @@
  * It lives in the conversation's own header rather than in the frame's right
  * column so that a collapsed Sidebar costs the conversation nothing — no rail,
  * no width, and the transcript's scrollbar stays at the column's edge. The
- * corner seat is its own, past the utilities' edge, so the button neither joins
- * the utilities row nor moves it: while the panel is shown this renders a
- * same-size placeholder, and the seat's width stays reserved. It shares the
- * panel's per-session store, which the slot runtime allows because both seats
- * are session-scoped.
+ * corner seat is its own, past the utilities' edge, so the button never joins
+ * the utilities row; while the panel is shown this renders nothing, and the
+ * seat collapses with it. It shares the panel's per-session store, which the
+ * slot runtime allows because both seats are session-scoped.
  *
  * The glyph is the left sidebar's collapse icon mirrored: the same affordance,
  * on the other edge.
  */
 import type { ReactNode } from 'react'
-import { IconPanelLeftOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { createSidebarRightStore } from '../stores.ts'
@@ -27,22 +26,23 @@ export type ExpandButtonProps =
   & PropsStore<ReturnType<typeof createSidebarRightStore>>
   & PropsLocale<'sidebarRight'>
 
-/** The expand control while the panel is collapsed; its footprint while it is shown. */
+/** The expand control while the panel is collapsed; nothing while it is shown. */
 export function ExpandButton({ sessionId, useStore, actions, t }: ExpandButtonProps): ReactNode {
   // A session with no surface yet is collapsed: the panel seat materializes the
   // surface on its own mount, and until then there is nothing expanded.
   const expanded = useStore(state => state.bySession[sessionId]?.layout.expanded ?? false)
-  if (expanded) return <span className={css.placeholder} aria-hidden data-sidebar-right-expand-placeholder />
+  if (expanded) return null
   return (
-    <button
-      type="button"
-      className={css.button}
-      aria-label={t('chrome.expand')}
-      title={t('chrome.expand')}
-      data-sidebar-right-expand
-      onClick={() => { actions.setExpanded(sessionId, true) }}
-    >
-      <IconPanelLeftOutline16 className={css.icon} />
-    </button>
+    <Tooltip label={t('chrome.expand')} side="bottom" delayMs={500}>
+      <button
+        type="button"
+        className={css.button}
+        aria-label={t('chrome.expandAria')}
+        data-sidebar-right-expand
+        onClick={() => { actions.setExpanded(sessionId, true) }}
+      >
+        <IconPanelLeftOutline16 className={css.icon} />
+      </button>
+    </Tooltip>
   )
 }

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff