فهرست منبع

feat(sidebar): last-tab close rules and collapsed expand button

The lone guide tab withholds its close and menu, any other lone tab closes
together with the column, and the expand button renders only while collapsed
so the corner seat costs the header nothing.
Yif 3 هفته پیش
والد
کامیت
f4c1e31ba8
36فایلهای تغییر یافته به همراه563 افزوده شده و 319 حذف شده
  1. 2 2
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml
  2. 4 4
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
  3. 4 4
      .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.i18n.yaml
  5. 4 6
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.md
  6. 4 6
      .agents/notes/implemented/feature/2026-09-08-sidebar-default-pages.zh.md
  7. 6 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.i18n.yaml
  8. 27 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.md
  9. 27 0
      .agents/notes/implemented/feature/2026-09-08-sidebar-last-tab-close-rules.zh.md
  10. 3 2
      apps/web/tests/navigation-panes.e2e.ts
  11. 112 28
      apps/web/tests/sidebar-right.e2e.ts
  12. 2 2
      packages/client/ui-sidebar-right/README.i18n.yaml
  13. 5 4
      packages/client/ui-sidebar-right/README.md
  14. 5 4
      packages/client/ui-sidebar-right/README.zh.md
  15. 1 2
      packages/client/ui-sidebar-right/src/client/contract/seed.ts
  16. 6 1
      packages/client/ui-sidebar-right/src/client/index.ts
  17. 7 0
      packages/client/ui-sidebar-right/src/client/labels.ts
  18. 21 15
      packages/client/ui-sidebar-right/src/client/locales.ts
  19. 3 3
      packages/client/ui-sidebar-right/src/client/service.ts
  20. 11 16
      packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css
  21. 18 18
      packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx
  22. 21 9
      packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css
  23. 42 39
      packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx
  24. 32 19
      packages/client/ui-sidebar-right/src/client/stores.ts
  25. 4 9
      packages/client/ui-sidebar-right/src/client/tab-registry.ts
  26. 33 44
      packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css
  27. 14 24
      packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx
  28. 35 0
      packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx
  29. 11 8
      packages/client/ui-sidebar-right/tests/apply.client.spec.ts
  30. 8 12
      packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx
  31. 7 10
      packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx
  32. 21 19
      packages/client/ui-sidebar-right/tests/seat.client.spec.tsx
  33. 3 3
      packages/client/ui-sidebar-right/tests/service.client.spec.ts
  34. 55 1
      packages/client/ui-sidebar-right/tests/stores.client.spec.ts
  35. 1 1
      packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx
  36. 2 2
      packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts

+ 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: 336394851445557537a9183c4a2172edadb5aedb
+2026-09-08-sidebar-last-tab-close-rules.zh.md: 9231246fff16a7bfc5cd7cf045e9907124a9ff97

+ 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, so the settle planner prepares 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 dismisses itself before the first paint, 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)在渲染出的面板上走完整条规则。

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

+ 112 - 28
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,20 +390,20 @@ 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.
@@ -592,6 +591,72 @@ 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 frame = page.locator('[class*="frame"]').first()
+      const column = page.locator('[data-rightbar-col]')
+      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)
+        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): {
@@ -850,33 +915,52 @@ 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-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.
+      await column.locator('[data-dockkit-tab]').filter({ hasText: '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'])
+      await column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
+        .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]').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-close]').first().click()
+      await expect.poll(async () => await tabTitles(column)).toEqual([SAMPLE_NAME])
+      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 +1043,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 - 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'),
+    },
   }
 }

+ 21 - 15
packages/client/ui-sidebar-right/src/client/locales.ts

@@ -8,22 +8,25 @@
 
 /** Simplified Chinese dictionary and key-set source of truth. */
 export const zh = {
-  'chrome.expand': '展开侧栏',
-  'chrome.collapse': '收起侧栏',
-  'chrome.toFullscreen': '全屏显示侧栏',
-  'chrome.exitFullscreen': '退出侧栏全屏',
+  'chrome.expand': '打开侧边栏',
+  'chrome.collapse': '收起侧边栏',
+  'chrome.toFullscreen': '全屏',
+  'chrome.exitFullscreen': '退出全屏',
   'dock.emptyPane': '空面板',
-  'dock.splitPane': '向右分栏',
+  'dock.splitPane': '分栏',
   'dock.splitPaneDisabled': '已达两格上限',
   'dock.splitPaneNarrow': '栏宽不足,拖宽侧栏后再分栏',
   'dock.closeTab': '关闭',
   'dock.addTab': '新标签页',
   '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 +34,23 @@ 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.collapse': 'Collapse 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.expand')}
+        data-sidebar-right-expand
+        onClick={() => { actions.setExpanded(sessionId, true) }}
+      >
+        <IconPanelLeftOutline16 className={css.icon} />
+      </button>
+    </Tooltip>
   )
 }

+ 21 - 9
packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css

@@ -29,9 +29,10 @@
   flex-direction: column;
   min-width: 0;
   /* The same ground as the conversation: this is a column of the page, not a
-     raised surface. */
+     raised surface. The edge is the weight of the kit's split dividers inside
+     (ui-dockkit `.divider`), so the panel's outline and its inner rules match. */
   background: var(--dsw-alias-bg-base);
-  border-left: 0.5px solid var(--dsw-alias-border-l1);
+  border-left: 0.5px solid var(--dsw-alias-border-l4);
   transform: translateX(100%);
   visibility: hidden;
   transition:
@@ -59,28 +60,38 @@
   }
 }
 
+/* The geometry of a message's action row button (ui-chat MessageIconActions),
+   shared with the kit's split control beside it: 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);
-  font-size: 14px;
   line-height: 1;
   background: transparent;
   border: none;
-  border-radius: 4px;
+  border-radius: 28px;
   cursor: pointer;
 }
 
+.iconButton svg {
+  width: 15px;
+  height: 15px;
+}
+
 .iconButton:hover {
-  color: var(--dsw-alias-label-primary);
   background: var(--dsw-alias-interactive-bg-hover);
 }
 
+/* The expand button's glyph, so collapse and expand share one affordance. */
+.collapseGlyph {
+  transform: scaleX(-1);
+}
+
 .panelBody {
   display: flex;
   flex: 1 1 auto;
@@ -89,6 +100,7 @@
 
 .unavailable {
   margin: 0;
+  padding: 12px;
   color: var(--dsw-alias-label-tertiary);
   font-size: var(--dsh-content-font-size-secondary, 13px);
 }

+ 42 - 39
packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx

@@ -30,6 +30,7 @@
 import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
 import type { ReactNode, RefObject } from 'react'
 import { createPortal } from 'react-dom'
+import { IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
 import type {
   HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
 } from '@deepseek-ai/dsh-client-ui-slots'
@@ -221,29 +222,28 @@ function titlesFor(panel: PanelProps): TabRenderer {
   return tab => <TabSlot key={tab.id} {...panel} tab={tab} seat="sidebar.right.pane.tab.title" fallback={tab.title} />
 }
 
-/** Expand-to-viewport glyph. */
+/** Expand-to-viewport glyph: four frame corners (figma extract). */
 function FullscreenGlyph(): ReactNode {
   return (
-    <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
-      <path d="M5 1.5H1.5V5M9 1.5h3.5V5M1.5 9v3.5H5M12.5 9v3.5H9" stroke="currentColor" strokeLinecap="round" />
+    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
+      <g fill="currentColor" stroke="currentColor" strokeWidth="0.105646" strokeLinecap="square">
+        <path d="M6.04798 2.13627V0.815964H5.99549L5.36158 0.817345V0.815964L3.39978 0.815274C3.01892 0.815274 2.67749 0.814821 2.39919 0.844967C2.10813 0.87655 1.80506 0.949512 1.52981 1.14949C1.3822 1.25681 1.25251 1.38652 1.14518 1.53412C0.945217 1.80935 0.872245 2.11246 0.840659 2.4035C0.810509 2.68178 0.810965 3.02324 0.810966 3.40409L0.811656 5.36589V5.9998L0.810966 6.05297L0.864137 6.05228H2.13196L2.18513 6.05297L2.18444 5.9998V5.36589L2.18513 3.40409C2.18513 2.99322 2.18631 2.73978 2.20653 2.55266C2.22499 2.38234 2.25273 2.34575 2.25556 2.34204C2.27837 2.31066 2.30635 2.28267 2.33774 2.25987C2.34207 2.25657 2.37978 2.22911 2.54835 2.21084C2.73548 2.19063 2.98893 2.18944 3.39978 2.18944L5.36158 2.18875L5.9948 2.18944H6.04867L6.04798 2.13627Z" />
+        <path d="M9.94031 13.86L9.94031 15.1803L9.99279 15.1803L10.6267 15.179L10.6267 15.1803L12.5885 15.181C12.9694 15.181 13.3108 15.1815 13.5891 15.1513C13.8801 15.1198 14.1832 15.0468 14.4585 14.8468C14.6061 14.7395 14.7358 14.6098 14.8431 14.4622C15.0431 14.187 15.116 13.8838 15.1476 13.5928C15.1778 13.3145 15.1773 12.9731 15.1773 12.5922L15.1766 10.6304L15.1766 9.9965L15.1773 9.94333L15.1241 9.94402L13.8563 9.94402L13.8032 9.94333L13.8038 9.9965L13.8038 10.6304L13.8032 12.5922C13.8032 13.0031 13.802 13.2565 13.7817 13.4437C13.7633 13.614 13.7355 13.6506 13.7327 13.6543C13.7099 13.6856 13.6819 13.7136 13.6505 13.7364C13.6462 13.7397 13.6085 13.7672 13.4399 13.7855C13.2528 13.8057 12.9993 13.8069 12.5885 13.8069L10.6267 13.8076L9.99348 13.8069L9.93962 13.8069L9.94031 13.86Z" />
+        <path d="M13.8568 6.05243H15.1771V5.99995L15.1757 5.36604H15.1771L15.1778 3.40423C15.1778 3.02337 15.1783 2.68194 15.1481 2.40365C15.1165 2.11259 15.0436 1.80952 14.8436 1.53427C14.7363 1.38666 14.6066 1.25697 14.459 1.14964C14.1837 0.949672 13.8806 0.8767 13.5896 0.845114C13.3113 0.814965 12.9698 0.815421 12.589 0.815421L10.6272 0.816112H9.99329L9.94011 0.815421L9.9408 0.868592V2.13641L9.94011 2.18958L9.99329 2.18889H10.6272L12.589 2.18958C12.9999 2.18958 13.2533 2.19077 13.4404 2.21099C13.6107 2.22944 13.6473 2.25719 13.651 2.26002C13.6824 2.28282 13.7104 2.31081 13.7332 2.34219C13.7365 2.34653 13.764 2.38424 13.7822 2.5528C13.8025 2.73993 13.8037 2.99339 13.8037 3.40423L13.8043 5.36604L13.8037 5.99926V6.05312L13.8568 6.05243Z" />
+        <path d="M2.12951 9.94389L0.809205 9.94389L0.809205 9.99637L0.810586 10.6303L0.809205 10.6303L0.808514 12.5921C0.808514 12.9729 0.808061 13.3144 0.838207 13.5927C0.86979 13.8837 0.942753 14.1868 1.14273 14.4621C1.25005 14.6097 1.37976 14.7394 1.52736 14.8467C1.80259 15.0467 2.1057 15.1196 2.39674 15.1512C2.67502 15.1814 3.01648 15.1809 3.39733 15.1809L5.35913 15.1802L5.99304 15.1802L6.04621 15.1809L6.04552 15.1277L6.04552 13.8599L6.04621 13.8067L5.99304 13.8074L5.35913 13.8074L3.39733 13.8067C2.98646 13.8067 2.73302 13.8056 2.5459 13.7853C2.37559 13.7669 2.33899 13.7391 2.33528 13.7363C2.3039 13.7135 2.27591 13.6855 2.25311 13.6541C2.24981 13.6498 2.22235 13.6121 2.20408 13.4435C2.18387 13.2564 2.18268 13.0029 2.18268 12.5921L2.18199 10.6303L2.18268 9.99706L2.18268 9.9432L2.12951 9.94389Z" />
+      </g>
     </svg>
   )
 }
 
-/** Restore-from-fullscreen glyph. */
+/** Restore-from-fullscreen glyph: two corners drawn inward (figma extract). */
 function ExitFullscreenGlyph(): ReactNode {
   return (
-    <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
-      <path d="M1.5 5H5V1.5M9 1.5V5h3.5M1.5 9H5v3.5M9 12.5V9h3.5" stroke="currentColor" strokeLinecap="round" />
-    </svg>
-  )
-}
-
-/** The collapse glyph. */
-function CloseGlyph(): ReactNode {
-  return (
-    <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
-      <path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
+    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
+      <g fill="currentColor" stroke="currentColor" strokeWidth="0.105646" strokeLinecap="square">
+        <path d="M10.698 0.379607H9.43015L9.37698 0.378916L9.37767 0.432087V1.066L9.37698 4.0277C9.37698 4.40856 9.37653 4.74998 9.40667 5.02828C9.43826 5.31934 9.51053 5.62311 9.71051 5.89835C9.81779 6.04587 9.94762 6.1757 10.0951 6.28298C10.3704 6.48296 10.6741 6.55523 10.9652 6.58682C11.2435 6.61696 11.5849 6.61651 11.9658 6.61651L14.9275 6.61582H15.5614L15.6146 6.61651L15.6139 6.56334V5.29552L15.6146 5.24235L15.5614 5.24304H14.9275L11.9658 5.24235C11.5545 5.24235 11.3009 5.24191 11.1137 5.22163C10.9443 5.20329 10.9078 5.17501 10.9038 5.17191C10.8724 5.14911 10.8444 5.12112 10.8216 5.08974C10.8185 5.08566 10.7902 5.04908 10.7719 4.87982C10.7516 4.69263 10.7511 4.439 10.7511 4.0277L10.7505 1.066V0.432087L10.7511 0.378916L10.698 0.379607Z" />
+        <path d="M5.29031 15.6167L6.55813 15.6167L6.6113 15.6174L6.61061 15.5642L6.61061 14.9303L6.6113 11.9686C6.6113 11.5878 6.61176 11.2463 6.58161 10.968C6.55003 10.677 6.47775 10.3732 6.27777 10.098C6.17049 9.95045 6.04067 9.82062 5.89315 9.71334C5.6179 9.51336 5.31413 9.44109 5.02307 9.40951C4.74478 9.37936 4.40335 9.37981 4.02249 9.37981L1.06079 9.3805L0.426879 9.3805L0.373708 9.37981L0.374398 9.43298L0.374398 10.7008L0.373708 10.754L0.426879 10.7533L1.06079 10.7533L4.02249 10.754C4.43379 10.754 4.68742 10.7544 4.87461 10.7747C5.04393 10.793 5.08047 10.8213 5.08453 10.8244C5.11591 10.8472 5.1439 10.8752 5.1667 10.9066C5.16982 10.9107 5.19808 10.9472 5.21642 11.1165C5.2367 11.3037 5.23714 11.5573 5.23714 11.9686L5.23783 14.9303L5.23783 15.5642L5.23714 15.6174L5.29031 15.6167Z" />
+      </g>
     </svg>
   )
 }
@@ -251,31 +251,34 @@ function CloseGlyph(): ReactNode {
 /** The panel's two controls, placed by the kit at the top-right pane's strip end. */
 function PanelChrome({ sessionId, fullscreen, autoFullscreen, actions, t }: Pick<PanelProps, 'sessionId' | 'actions' | 't' | 'fullscreen' | 'autoFullscreen'>): ReactNode {
   const next: DockMode = fullscreen ? 'push' : 'fullscreen'
+  const modeLabel = fullscreen ? t('chrome.exitFullscreen') : t('chrome.toFullscreen')
   return (
     <>
-      <button
-        type="button"
-        className={css.iconButton}
-        aria-label={fullscreen ? t('chrome.exitFullscreen') : t('chrome.toFullscreen')}
-        title={fullscreen ? t('chrome.exitFullscreen') : t('chrome.toFullscreen')}
-        data-sidebar-right-mode={next}
-        onClick={() => {
-          if (fullscreen && autoFullscreen) actions.setExpanded(sessionId, false)
-          actions.setMode(sessionId, next)
-        }}
-      >
-        {fullscreen ? <ExitFullscreenGlyph /> : <FullscreenGlyph />}
-      </button>
-      <button
-        type="button"
-        className={css.iconButton}
-        aria-label={t('chrome.collapse')}
-        title={t('chrome.collapse')}
-        data-sidebar-right-toggle
-        onClick={() => { actions.toggleExpanded(sessionId) }}
-      >
-        <CloseGlyph />
-      </button>
+      <Tooltip label={modeLabel} side="bottom" delayMs={500}>
+        <button
+          type="button"
+          className={css.iconButton}
+          aria-label={modeLabel}
+          data-sidebar-right-mode={next}
+          onClick={() => {
+            if (fullscreen && autoFullscreen) actions.setExpanded(sessionId, false)
+            actions.setMode(sessionId, next)
+          }}
+        >
+          {fullscreen ? <ExitFullscreenGlyph /> : <FullscreenGlyph />}
+        </button>
+      </Tooltip>
+      <Tooltip label={t('chrome.collapse')} side="bottom" delayMs={500}>
+        <button
+          type="button"
+          className={css.iconButton}
+          aria-label={t('chrome.collapse')}
+          data-sidebar-right-toggle
+          onClick={() => { actions.toggleExpanded(sessionId) }}
+        >
+          <IconPanelLeftOutline16 className={css.collapseGlyph} />
+        </button>
+      </Tooltip>
     </>
   )
 }
@@ -303,7 +306,7 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<H
         <DockSurface
           state={surface.layout}
           canSplit={canSplit(surface.layout) && dockPaneIds(surface.layout).length < 2}
-          hideSplitAtCapacity
+          hideSplitWhenBlocked
           dropZones="horizontal"
           minPaneFraction={0.2}
           canAddTab={paneId => guideIn(surface.layout, paneId) === undefined}

+ 32 - 19
packages/client/ui-sidebar-right/src/client/stores.ts

@@ -41,8 +41,6 @@ export interface SurfaceState {
   readonly history: History
   /** How many ids this surface has minted; carried so replay stays reproducible. */
   readonly minted: number
-  /** Sole-entry defaults protected from close; retired ids remain protected when undo restores them. */
-  readonly permanentTabIds: readonly TabId[]
 }
 
 /**
@@ -61,21 +59,18 @@ type SurfacePlan = (state: LayoutState, mint: Mint, makeTab: (id: TabId) => TabR
 
 /**
  * Decide whether an explicit close may remove a tab.
- * @param surface - current surface including default-tab protection.
+ * @param surface - current surface.
  * @param tabId - tab requested for closing.
- * @returns false for missing tabs, protected docked defaults, and a docked pane's last tab.
+ * @returns false for a missing tab or the guide standing as the only docked tab.
  */
 export function canCloseTab(surface: SurfaceState, tabId: TabId): boolean {
-  if (surface.layout.tabs[tabId] === undefined) return false
-  const pane = findTabPane(surface.layout, tabId)
-  if (pane.host === 'float') return true
-  return !surface.permanentTabIds.includes(tabId) && pane.tabs.length > 1
+  const tab = surface.layout.tabs[tabId]
+  return tab !== undefined && !(tab.kind === GUIDE_KIND && soleDockedTab(surface.layout, tabId))
 }
 
-/** Build a default tab and retain its close protection outside the docking kit's data. */
-function seedRecord(id: TabId, seed: () => SidebarRightSeed, permanentTabIds: TabId[]): TabRecord {
+/** Build the currently selected default tab. */
+function seedRecord(id: TabId, seed: () => SidebarRightSeed): TabRecord {
   const initial = seed()
-  if (initial.permanent) permanentTabIds.push(id)
   return { id, kind: initial.kind, title: initial.title, contentId: pageAddress(initial.kind) }
 }
 
@@ -114,12 +109,10 @@ function counting(from: number): { mint: Mint; used: () => number } {
  */
 export function createSurface(seed: () => SidebarRightSeed): SurfaceState {
   const counter = counting(0)
-  const permanentTabIds: TabId[] = []
   return {
-    layout: createInitialState({ next: counter.mint }, id => seedRecord(id, seed, permanentTabIds)),
+    layout: createInitialState({ next: counter.mint }, id => seedRecord(id, seed)),
     history: EMPTY_HISTORY,
     minted: counter.used(),
-    permanentTabIds,
   }
 }
 
@@ -133,6 +126,19 @@ function isGuide(state: LayoutState, tabId: TabId): boolean {
   return state.tabs[tabId]?.kind === GUIDE_KIND
 }
 
+/**
+ * Whether a tab stands alone on the docked surface: its pane is the sole docked
+ * pane and holds nothing else. Floating panels do not count — they render
+ * whether or not the column is expanded.
+ * @param state - current layout.
+ * @param tabId - the tab asked about.
+ * @returns `true` for the docked surface's only tab.
+ */
+export function soleDockedTab(state: LayoutState, tabId: TabId): boolean {
+  const pane = findTabPane(state, tabId)
+  return pane.host === 'dock' && pane.tabs.length === 1 && dockPaneIds(state).length === 1
+}
+
 /** Focus a tab: nothing to plan while it is its pane's active tab and its pane is the active one. */
 function planFocusTab(state: LayoutState, tabId: TabId): readonly LayoutOp[] {
   const pane = findTabPane(state, tabId)
@@ -170,8 +176,7 @@ function arriving(state: LayoutState, tabId: TabId, toPaneId: PaneId, otherwise:
  */
 function advance(surface: SurfaceState, plan: SurfacePlan, seed: () => SidebarRightSeed): SurfaceState {
   const counter = counting(surface.minted)
-  const permanentTabIds = [...surface.permanentTabIds]
-  const makeTab = (id: TabId): TabRecord => seedRecord(id, seed, permanentTabIds)
+  const makeTab = (id: TabId): TabRecord => seedRecord(id, seed)
   const planned = plan(surface.layout, counter.mint, makeTab)
   if (planned.length === 0) return surface
   // The settle planner reads the state the intent produces, so it is applied
@@ -179,7 +184,7 @@ function advance(surface: SurfaceState, plan: SurfacePlan, seed: () => SidebarRi
   const after = replay(surface.layout, planned)
   const settled = planSettle(after, counter.mint, makeTab)
   const stepped = record(surface.history, surface.layout, [...planned, ...settled])
-  return { layout: stepped.state, history: stepped.history, minted: counter.used(), permanentTabIds }
+  return { layout: stepped.state, history: stepped.history, minted: counter.used() }
 }
 
 /**
@@ -322,9 +327,17 @@ export function createSidebarRightStore(
       },
       // A tab already gone — closed twice by a racing callback and the user — is
       // left alone rather than handed to the kit, which refuses an unknown tab.
+      // The docked surface's last tab follows the close rule, whoever asks: the
+      // guide stays (the kit hides its close routes through `canCloseTab`, and
+      // this plan refuses the programmatic path), and anything else closes
+      // together with the column — the settle planner reseeds the current
+      // default page for the next expansion.
       closeTab: (d, sessionId: string, tabId: TabId) => {
-        d.bySession = seat(d, sessionId, seed, s =>
-          advance(s, () => canCloseTab(s, tabId) ? [{ type: 'closeTab', tabId }] : [], seed))
+        d.bySession = seat(d, sessionId, seed, s => advance(s, (state) => {
+          if (!canCloseTab(s, tabId)) return []
+          if (!soleDockedTab(state, tabId)) return [{ type: 'closeTab', tabId }]
+          return [{ type: 'closeTab', tabId }, ...planSetExpanded(state, false)]
+        }, seed))
       },
       focusTab: (d, sessionId: string, tabId: TabId) => {
         d.bySession = seat(d, sessionId, seed, s => advance(s, state => planFocusTab(state, tabId), seed))

+ 4 - 9
packages/client/ui-sidebar-right/src/client/tab-registry.ts

@@ -57,21 +57,16 @@ const RANKS: Readonly<Record<SidebarRightTabPriority, number>> = {
 /** The band a definition that names none is in. */
 const DEFAULT_BAND: SidebarRightTabPriority = 'extension'
 
-/** One entry box the guide page offers, contributed by the type it opens (picking it opens that type as a page). */
+/** One entry capsule the guide page offers, contributed by the type it opens (picking it opens that type as a page). */
 export interface SidebarRightGuideEntry {
   /** Ascending position among every registered type's entries. */
   readonly order: number
   /**
-   * The box's heading.
-   * @returns the heading in the current language.
+   * The capsule's title, its only words.
+   * @returns the title in the current language.
    */
   readonly title: () => string
-  /**
-   * One line under the heading.
-   * @returns the line in the current language.
-   */
-  readonly description: () => string
-  /** Optional glyph, drawn at the box's leading edge. */
+  /** Optional glyph, drawn before the title. */
   readonly icon?: ComponentType<IconProps>
 }
 

+ 33 - 44
packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css

@@ -1,50 +1,45 @@
 /* The guide body's own sheet: its domain owns these rules, so the shell's
    stylesheet does not carry them. Tokens only. */
+
+/* The capsules in one centred column, lifted above the body's middle by the
+   spacer below — the empty-state offset. */
 .guide {
   display: flex;
   flex-direction: column;
   gap: 8px;
   align-items: center;
-  padding-top: 24px;
-  text-align: center;
-}
-
-.guideTitle {
-  margin: 0;
-  color: var(--dsw-alias-label-primary);
-  font-size: var(--dsh-content-font-size, 14px);
-  font-weight: 500;
-  line-height: 1.6;
+  justify-content: center;
+  box-sizing: border-box;
+  height: 100%;
+  padding: 0 24px;
 }
 
-.guideBody {
-  margin: 0;
-  color: var(--dsw-alias-label-secondary);
-  font-size: var(--dsh-content-font-size-secondary, 13px);
-  line-height: 1.6;
-}
-
-/* Entry boxes: as many per row as the pane's width allows. */
-.entries {
-  display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
-  gap: 8px;
-  width: 100%;
-  max-width: 480px;
-  margin-top: 16px;
+/* 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 capsules clip. */
+.guide::after {
+  content: '';
+  flex: 0 1 12%;
 }
 
+/* A 48px capsule, 360px wide by default; `max-width: 100%` shrinks it inside
+   the column's own 24px insets, so a narrow pane still keeps that gutter. A
+   long title ellipsizes inside rather than widening it. */
 .entry {
   display: flex;
   gap: 8px;
-  align-items: flex-start;
-  padding: 10px 12px;
-  color: inherit;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+  width: 360px;
+  max-width: 100%;
+  height: 48px;
+  padding: 0 20px;
+  color: var(--dsw-alias-label-primary);
   font: inherit;
-  text-align: left;
   background: transparent;
-  border: 0.5px solid var(--dsw-alias-border-l1);
-  border-radius: 8px;
+  border: 0.5px solid var(--dsw-alias-border-l4);
+  border-radius: 20px;
   cursor: pointer;
 }
 
@@ -55,25 +50,19 @@
 .entryIcon {
   display: flex;
   flex: none;
-  margin-top: 1px;
   color: var(--dsw-alias-label-secondary);
 }
 
-.entryText {
-  display: flex;
-  flex-direction: column;
-  gap: 2px;
-  min-width: 0;
-}
-
 .entryTitle {
-  color: var(--dsw-alias-label-primary);
+  min-width: 0;
+  overflow: hidden;
   font-size: var(--dsh-content-font-size, 14px);
   line-height: 1.4;
+  white-space: nowrap;
+  text-overflow: ellipsis;
 }
 
-.entryDescription {
-  color: var(--dsw-alias-label-secondary);
-  font-size: var(--dsh-content-font-size-secondary, 13px);
-  line-height: 1.4;
+/* The chip title's planet, beside the label the strip lays out. */
+.titleIcon {
+  flex: none;
 }

+ 14 - 24
packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx

@@ -8,13 +8,14 @@
  * entry of its own, so there is always exactly one body and the shipped one
  * cannot be outvoted by accident.
  *
- * The shipped guide is a centred title, one line under it, and the entry boxes
- * every registered type contributed. Picking a box opens that type as a page in
- * this tab's place, so the guide is a doorway rather than a page that stays open.
+ * The shipped guide is the entry capsules every registered type contributed,
+ * centred in the body, and nothing else. Picking one opens that type as a page
+ * in this tab's place, so the guide is a doorway rather than a page that stays
+ * open.
  */
 import type { ReactNode } from 'react'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
-import type { ChainRenderOpts, HookContextOf, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ChainRenderOpts, HookContextOf, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
 import type { SidebarRightGuideBox } from '../../tab-registry.ts'
 import css from './GuideBody.module.css'
 
@@ -24,14 +25,13 @@ export interface GuideInjected {
   readonly hooks: { readonly guideEntries: ObservableSnapshot<readonly SidebarRightGuideBox[]> }
 }
 
-/** The guide body's composed props: the tab it draws, its chain child, its copy, and the entries. */
+/** The guide body's composed props: the tab it draws, its chain child, and the entries. Its words are the entries' own. */
 export type GuideBodyProps =
   & PropsRuntime<'sidebar.right.pane.tab'>
   & PropsRenderSlots<'sidebar.right.tab.guide'>
-  & PropsLocale<'sidebarRight'>
   & InjectFace<GuideInjected>
 
-/** One entry box: the contributing type's glyph, heading, and line. */
+/** One entry capsule: the contributing type's glyph and title. */
 function EntryBox({ entry, onPick }: { entry: SidebarRightGuideBox; onPick: (entry: SidebarRightGuideBox) => void }): ReactNode {
   const Icon = entry.icon
   return (
@@ -42,42 +42,32 @@ function EntryBox({ entry, onPick }: { entry: SidebarRightGuideBox; onPick: (ent
       onClick={() => { onPick(entry) }}
     >
       {Icon !== undefined && <span className={css.entryIcon}><Icon size={16} /></span>}
-      <span className={css.entryText}>
-        <span className={css.entryTitle}>{entry.title()}</span>
-        <span className={css.entryDescription}>{entry.description()}</span>
-      </span>
+      <span className={css.entryTitle}>{entry.title()}</span>
     </button>
   )
 }
 
-/** The shipped guide: what the column is for, and the doors out of it. */
-function ShippedGuide({ entries, onPick, t }: {
+/** The shipped guide: the doors out of the column. */
+function ShippedGuide({ entries, onPick }: {
   entries: readonly SidebarRightGuideBox[]
   onPick: (entry: SidebarRightGuideBox) => void
-  t: GuideBodyProps['t']
 }): ReactNode {
   return (
     <div className={css.guide} data-sidebar-right-guide>
-      <p className={css.guideTitle}>{t('guide.lead')}</p>
-      <p className={css.guideBody}>{t('guide.body')}</p>
-      {entries.length > 0 && (
-        <div className={css.entries}>
-          {/* Keyed by position in the ordered list: one type may contribute several boxes, and `order` is not unique. */}
-          {entries.map((entry, index) => <EntryBox key={`${entry.kind}:${index}`} entry={entry} onPick={onPick} />)}
-        </div>
-      )}
+      {/* Keyed by position in the ordered list: one type may contribute several capsules, and `order` is not unique. */}
+      {entries.map((entry, index) => <EntryBox key={`${entry.kind}:${index}`} entry={entry} onPick={onPick} />)}
     </div>
   )
 }
 
 /** The guide tab's body, replaceable through its chain child. */
-export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain, t }: GuideBodyProps): ReactNode {
+export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain }: GuideBodyProps): ReactNode {
   const { tab } = useTabInfo()
   const entries = useGuideEntries(entries => entries)
   const options = {
     hookContext: useTabInfo,
     fallback: (
-      <ShippedGuide entries={entries} onPick={(entry) => { tab.actions.openTab(entry.kind, { replaceTab: true }) }} t={t} />
+      <ShippedGuide entries={entries} onPick={(entry) => { tab.actions.openTab(entry.kind, { replaceTab: true }) }} />
     ),
   } satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> }
   return renderSlotChain('sidebar.right.tab.guide', {}, options)

+ 35 - 0
packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx

@@ -0,0 +1,35 @@
+/**
+ * The guide type's chip title: the compass before the type's label. Registered
+ * under `sidebar.right.pane.tab.title`; without it the chip would show the
+ * bare label.
+ */
+import type { ReactNode } from 'react'
+import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import css from './GuideBody.module.css'
+
+/**
+ * The compass: a grey ring with the needle's rhombus pointing north-east.
+ */
+function CompassGlyph(): ReactNode {
+  return (
+    <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" className={css.titleIcon}>
+      <circle cx="8" cy="8" r="6" stroke="var(--dsw-alias-label-tertiary)" strokeWidth="1.4" />
+      <path d="M 10.9 5.1 L 9.1 9.1 L 5.1 10.9 L 6.9 6.9 Z" fill="var(--dsw-alias-label-tertiary)" />
+    </svg>
+  )
+}
+
+/**
+ * The title as the chip and a floating panel's header show it.
+ * @param props - the tab information hook.
+ * @returns the compass followed by the tab's title text.
+ */
+export function GuideTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.title'>): ReactNode {
+  const { tab } = useTabInfo()
+  return (
+    <>
+      <CompassGlyph />
+      {tab.title}
+    </>
+  )
+}

+ 11 - 8
packages/client/ui-sidebar-right/tests/apply.client.spec.ts

@@ -22,6 +22,7 @@ import { RightbarSeat } from '../src/client/shell/SidebarRight.tsx'
 import { RightbarRoot } from '../src/client/shell/RightbarRoot.tsx'
 import { ExpandButton } from '../src/client/shell/ExpandButton.tsx'
 import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx'
+import { GuideTitle } from '../src/client/tabs/guide/GuideTitle.tsx'
 import { GUIDE_ID } from '../src/client/tabs/guide/definition.ts'
 import { en, zh } from '../src/client/locales.ts'
 
@@ -92,11 +93,15 @@ describe('ui-sidebar-right apply', () => {
     expect(guide?.id).toBe(GUIDE_ID)
     expect(guide?.priority).toBe('builtin')
     expect(guide?.title('sidebar://guide')).toBe('tab.guide.title')
+    // Five registrations: the root and panel seats, the header's corner seat,
+    // and the guide body and chip title under the guide implementation's id.
+    // The guide binds no dictionary: its only words are the entries' own.
     expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
       ['rightbar', undefined, undefined, RightbarRoot],
       ['rightbar.session', undefined, 'sidebarRight', RightbarSeat],
       ['conversation.session.header.corner', undefined, 'sidebarRight', ExpandButton],
-      ['sidebar.right.pane.tab', GUIDE_ID, 'sidebarRight', GuideBody],
+      ['sidebar.right.pane.tab', GUIDE_ID, undefined, GuideBody],
+      ['sidebar.right.pane.tab.title', GUIDE_ID, undefined, GuideTitle],
     ])
     // The panel declares the extension seats; the guide declares its chain child.
     expect(Object.keys(seat('rightbar.session').children as object)).toEqual([
@@ -151,16 +156,14 @@ describe('ui-sidebar-right apply', () => {
     expect(seat('conversation.session.header.corner').store).toBe(handle)
     const instance = handle.create(SESSION)
     instance.actions.open(SESSION)
-    const guide = Object.values(instance.getSnapshot().bySession[SESSION]?.layout.tabs ?? {})[0]
+    // A second tab beside the guide makes the guide closable.
+    instance.actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s/a.txt', title: 'a' }, () => {})
+    const guide = Object.values(instance.getSnapshot().bySession[SESSION]?.layout.tabs ?? {}).find(tab => tab.kind === 'guide')
     if (guide === undefined) throw new Error('expected the seeded guide')
     // Held and pinned from the store's own commit: no seat synced anything.
     const occurrence = ctx.sidebarRight.tabDomain.occurrence(SESSION, guide)
     expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', occurrence.signal)
     occurrence.tabActions.close()
-    expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBe(guide)
-    expect(occurrence.signal.aborted).toBe(false)
-    instance.actions.openContent(SESSION, { kind: 'text', contentId: 'file:test', title: 'test' }, () => {})
-    occurrence.tabActions.close()
     expect(instance.getSnapshot().bySession[SESSION]?.layout.tabs[guide.id]).toBeUndefined()
     expect(occurrence.signal.aborted).toBe(true)
   })
@@ -175,7 +178,7 @@ describe('ui-sidebar-right apply', () => {
       id: 'spec/files',
       kind: 'files',
       title: () => 'Files',
-      guide: [{ order: 10, title: () => 'Files', description: () => 'The workspace tree' }],
+      guide: [{ order: 10, title: () => 'Files' }],
     })
     expect(seen).toHaveBeenCalledOnce()
     expect(guideEntries.getSnapshot().map(entry => entry.kind)).toEqual(['files'])
@@ -204,6 +207,6 @@ describe('ui-sidebar-right apply', () => {
     expect(dictionaries.size).toBe(0)
     await ctx.plugin({ inject: [...inject], apply }).await()
     expect(ctx.sidebarRightTabs.get('guide')?.id).toBe(GUIDE_ID)
-    expect(registered).toHaveLength(4)
+    expect(registered).toHaveLength(5)
   })
 })

+ 8 - 12
packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx

@@ -1,8 +1,8 @@
 // @vitest-environment jsdom
 /**
  * The way back into a hidden panel: the header's corner button exists exactly
- * while the panel is collapsed, asks for it to expand, and leaves a same-size
- * footprint while the panel is shown so the header row never moves.
+ * while the panel is collapsed, asks for it to expand, and renders nothing
+ * while the panel is shown.
  */
 import { describe, expect, it } from 'vitest'
 import { act, cleanup, fireEvent, render } from '@testing-library/react'
@@ -27,7 +27,7 @@ function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapsho
  * documented cast keeps the harness to what is actually exercised.
  */
 function mountButton() {
-  const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start', permanent: false })).create()
+  const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start' })).create()
   const props = {
     sessionId: SESSION,
     useStore: hookOf(instance),
@@ -37,32 +37,28 @@ function mountButton() {
   } as unknown as ExpandButtonProps
   const view = render(<ExpandButton {...props} />)
   const control = (): HTMLElement | null => view.container.querySelector('[data-sidebar-right-expand]')
-  const placeholder = (): HTMLElement | null => view.container.querySelector('[data-sidebar-right-expand-placeholder]')
-  return { instance, view, control, placeholder }
+  return { instance, view, control }
 }
 
 describe('ExpandButton', () => {
   it('offers the way in while the session has no surface yet, and asks the panel to expand', () => {
-    const { instance, control, placeholder } = mountButton()
+    const { instance, view, control } = mountButton()
     const button = control()
     if (button === null) throw new Error('expected the expand control')
     expect(button.getAttribute('aria-label')).toBe('chrome.expand')
-    expect(placeholder()).toBeNull()
     fireEvent.click(button)
     expect(instance.getSnapshot().bySession[SESSION]?.layout.expanded).toBe(true)
-    // Shown: the control gives way to its footprint, so the seat keeps its width.
-    expect(control()).toBeNull()
-    expect(placeholder()).not.toBeNull()
+    // Shown: the seat is empty, so the header lays out without it.
+    expect(view.container.childElementCount).toBe(0)
     cleanup()
   })
 
   it('comes back when the panel collapses again', () => {
-    const { instance, control, placeholder } = mountButton()
+    const { instance, control } = mountButton()
     act(() => { instance.actions.setExpanded(SESSION, true) })
     expect(control()).toBeNull()
     act(() => { instance.actions.setExpanded(SESSION, false) })
     expect(control()).not.toBeNull()
-    expect(placeholder()).toBeNull()
     cleanup()
   })
 })

+ 7 - 10
packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx

@@ -2,7 +2,7 @@
 /**
  * The guide tab's body: the chain seam, and the shipped guide behind it.
  *
- * The contract a type relies on is the entry box: one per guide entry every
+ * The contract a type relies on is the entry capsule: one per guide entry every
  * registered type contributed, in the registry's order, and picking one opens
  * that type as a page in the guide's own tab. The chain is asserted through
  * what the body hands it — the tab and the shipped guide as the fallback.
@@ -26,13 +26,12 @@ function Glyph({ size }: IconProps): ReactNode {
   return <span data-guide-glyph={size} />
 }
 
-/** One entry box as the registry lists it. */
+/** One entry capsule as the registry lists it. */
 function box(kind: string, order: number, icon?: SidebarRightGuideBox['icon']): SidebarRightGuideBox {
   return {
     kind,
     order,
     title: () => `${kind} title`,
-    description: () => `${kind} description`,
     ...icon === undefined ? {} : { icon },
   }
 }
@@ -49,8 +48,6 @@ function mountGuide(entries: readonly SidebarRightGuideBox[]) {
     useTabInfo: () => ({ tab: { ...TAB, actions: { openResource: vi.fn(), openTab, close: vi.fn() } } }),
     useGuideEntries: bindSnapshotSelector(guideEntries),
     renderSlotChain: renderSlot,
-    // Copy is the dictionary's contract; the key stands in for the translation.
-    t: (key: string) => key,
   } as unknown as GuideBodyProps
   const view = render(<GuideBody {...props} />)
   const boxes = (): string[] =>
@@ -64,13 +61,13 @@ describe('GuideBody', () => {
     expect(renderSlot).toHaveBeenCalledWith('sidebar.right.tab.guide', {}, {
       hookContext: useTabInfo, fallback: expect.anything() as ReactNode,
     })
+    // The guide says nothing of its own: its words are the capsules'.
     const guide = view.container.querySelector('[data-sidebar-right-guide]')
-    expect(guide?.textContent).toContain('guide.lead')
-    expect(guide?.textContent).toContain('guide.body')
-    // One box per entry, in the registry's order, each with its own words; only the first brought a glyph.
+    expect(guide?.textContent).toBe('files titleterminal title')
+    // One capsule per entry, in the registry's order, each with its own title; only the first brought a glyph.
     expect(boxes()).toEqual(['files', 'terminal'])
     const [files, terminal] = [...view.container.querySelectorAll('[data-sidebar-right-guide-entry]')]
-    expect(files?.textContent).toBe('files titlefiles description')
+    expect(files?.textContent).toBe('files title')
     expect(files?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('16')
     expect(terminal?.querySelector('[data-guide-glyph]')).toBeNull()
     cleanup()
@@ -85,7 +82,7 @@ describe('GuideBody', () => {
     cleanup()
   })
 
-  it('draws the words alone while no type contributed an entry, and follows the registry when one does', () => {
+  it('draws an empty guide while no type contributed an entry, and follows the registry when one does', () => {
     const { view, guideEntries, boxes } = mountGuide([])
     expect(view.container.querySelector('[data-sidebar-right-guide]')).not.toBeNull()
     expect(boxes()).toEqual([])

+ 21 - 19
packages/client/ui-sidebar-right/tests/seat.client.spec.tsx

@@ -130,38 +130,44 @@ describe('RightbarSeat presentation', () => {
     expect(h.layout()).toBe(retained)
   })
 
-  it.each([0, 1, 2])('selects the default from %i guide entries and protects a single tab', async (entryCount) => {
+  it.each([0, 1, 2])('selects the default from %i guide entries and protects only a sole guide', async (entryCount) => {
     const h = await mountSeat(1440, true, entryCount)
     act(() => { h.controller.toggleExpanded() })
     const initial = Object.values(h.layout().tabs)[0]!
     expect(initial.kind).toBe(entryCount === 1 ? 'text' : 'guide')
-    expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0)
-    const before = h.layout()
-    act(() => { h.controller.close(initial.id) })
-    expect(h.layout()).toBe(before)
-    fireEvent.contextMenu(element(h.view.container, '[data-dockkit-tab]'))
-    expect(document.querySelector('[data-dockkit-tab-menu] [role^="menuitem"]')).toBeNull()
     if (entryCount !== 1) {
+      expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0)
+      const before = h.layout()
+      act(() => { h.controller.close(initial.id) })
+      expect(h.layout()).toBe(before)
+      fireEvent.contextMenu(element(h.view.container, '[data-dockkit-tab]'))
+      expect(document.querySelector('[data-dockkit-tab-menu] [role^="menuitem"]')).toBeNull()
       expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull()
       return
     }
+    expect(h.view.container.querySelector(`[data-dockkit-tab-close="${initial.id}"]`)).not.toBeNull()
+    act(() => { h.controller.close(initial.id) })
+    expect(h.layout().expanded).toBe(false)
+    const reseeded = Object.values(h.layout().tabs)[0]!
+    expect(reseeded.kind).toBe('text')
+    expect(reseeded.id).not.toBe(initial.id)
+    act(() => { h.controller.toggleExpanded() })
     fireEvent.click(element(h.view.container, '[data-dockkit-add-tab]'))
     const guide = Object.values(h.layout().tabs).find(tab => tab.kind === 'guide')!
     expect(h.view.container.querySelector('[data-dockkit-add-tab]')).toBeNull()
-    expect(h.view.container.querySelector(`[data-dockkit-tab-close="${initial.id}"]`)).toBeNull()
+    expect(h.view.container.querySelector(`[data-dockkit-tab-close="${reseeded.id}"]`)).not.toBeNull()
     expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).not.toBeNull()
-    act(() => { h.controller.close(initial.id) })
-    expect(h.layout().tabs[initial.id]).toBe(initial)
+    act(() => { h.controller.close(reseeded.id) })
+    expect(h.layout().tabs[reseeded.id]).toBeUndefined()
+    expect(h.view.container.querySelector(`[data-dockkit-tab-close="${guide.id}"]`)).toBeNull()
     const preview = h.open('ordinary.txt')
     expect(h.view.container.querySelector(`[data-dockkit-tab-close="${preview.id}"]`)).not.toBeNull()
     act(() => { h.controller.close(preview.id) })
     expect(h.layout().tabs[preview.id]).toBeUndefined()
-    fireEvent.click(element(h.view.container, `[data-dockkit-tab-close="${guide.id}"]`))
-    expect(Object.keys(h.layout().tabs)).toEqual([initial.id])
+    expect(Object.keys(h.layout().tabs)).toEqual([guide.id])
     expect(h.view.container.querySelectorAll('[data-dockkit-tab-close]')).toHaveLength(0)
-    expect(h.view.container.querySelector('[data-dockkit-add-tab]')).not.toBeNull()
     act(() => { h.controller.split() })
-    expect(Object.values(h.layout().tabs).map(tab => tab.kind)).toEqual(['text', 'text'])
+    expect(Object.values(h.layout().tabs).map(tab => tab.kind)).toEqual(['guide', 'text'])
   })
 
   it('offers close for a floating tab while the docked pane keeps its sole tab', async () => {
@@ -449,7 +455,7 @@ describe('slot-owned useTabInfo', () => {
     await act(async () => {
       h.runtime.ctx.sidebarRightTabs.register({
         id: 'test/files', kind: 'files', title: () => 'Files',
-        guide: [{ order: 1, title: () => 'Files', description: () => 'Browse' }],
+        guide: [{ order: 1, title: () => 'Files' }],
       })
     })
     expect(h.view.container.querySelector('[data-sidebar-right-guide-entry="files"]')).not.toBeNull()
@@ -524,10 +530,6 @@ describe('slot-owned useTabInfo', () => {
     expect(getPane(h.layout(), right).tabs.filter(id => h.layout().tabs[id]?.kind === 'guide')).toHaveLength(1)
     const closing = [...getPane(h.layout(), right).tabs]
     act(() => { for (const tabId of closing) h.actions.closeTab(SESSION, tabId) })
-    expect(dockPaneIds(h.layout())).toHaveLength(2)
-    const remaining = getPane(h.layout(), right).tabs[0]!
-    expect(h.view.container.querySelector(`[data-dockkit-tab-close="${remaining}"]`)).toBeNull()
-    act(() => { h.actions.placeTab(SESSION, remaining, dockPaneIds(h.layout())[0]!, 0) })
     expect(dockPaneIds(h.layout())).toHaveLength(1)
     expect(splitButtons()).toHaveLength(1)
     expect(splitButtons()[0]?.disabled).toBe(false)

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

@@ -48,7 +48,7 @@ function harness() {
   })
   const pin = vi.fn<(address: string, signal: AbortSignal) => void>()
   const { controller, adopt } = createSidebarRightController(tabs, pin)
-  const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed', permanent: false })).create()
+  const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create()
   const layout = (): LayoutState => {
     const surface = instance.getSnapshot().bySession[SESSION]
     if (surface === undefined) throw new Error('expected a surface')
@@ -445,7 +445,7 @@ describe('SidebarRightController — a tab\'s own actions', () => {
     const guideOccurrence = controller.tabDomain.occurrence(SESSION, guide)
     // The user switches sessions: the other seat binds with the other session's
     // own instance, whose store knows nothing of this session.
-    const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed', permanent: false })).create(OTHER)
+    const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER)
     const releaseOther = adopt(OTHER, other)
     other.actions.open(OTHER)
     const otherSurface = other.getSnapshot().bySession[OTHER]
@@ -519,7 +519,7 @@ describe('SidebarRightController — a tab\'s own actions', () => {
     // Adopting another instance for the session ends the earlier adoption's
     // subscription with its routing: the old store's commits sync nothing, the
     // new store's commits reconcile the session against its own layout.
-    const replacement = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed', permanent: false })).create()
+    const replacement = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create()
     const third = adopt(SESSION, replacement)
     const pins = pin.mock.calls.length
     instance.actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/c.txt', title: 'c' }, () => {})

+ 55 - 1
packages/client/ui-sidebar-right/tests/stores.client.spec.ts

@@ -15,7 +15,7 @@ import { createSidebarRightStore } from '../src/client/stores.ts'
 const SESSION = 's-test'
 
 function harness() {
-  const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start', permanent: false })).create()
+  const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start' })).create()
   instance.actions.open(SESSION)
   const surface = () => {
     const held = instance.getSnapshot().bySession[SESSION]
@@ -136,6 +136,60 @@ describe('createSidebarRightStore — the sequence', () => {
   })
 })
 
+describe('createSidebarRightStore — the last docked tab', () => {
+  it('refuses to close the guide standing as the docked surface\'s only tab, recording nothing', () => {
+    const { actions, surface, layout, entries, guide } = harness()
+    actions.setExpanded(SESSION, true)
+    const before = surface()
+    const recorded = entries()
+    actions.closeTab(SESSION, guide())
+    expect(surface()).toBe(before)
+    expect(entries()).toBe(recorded)
+    expect(layout().tabs[guide()]).toBeDefined()
+  })
+
+  it('closes the last non-guide tab together with the column, reseeding the guide, as one reversible entry', () => {
+    const { actions, layout, entries } = harness()
+    actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {})
+    const text = Object.values(layout().tabs).find(tab => tab.kind === 'text')
+    const seeded = Object.values(layout().tabs).find(tab => tab.kind !== 'text')
+    if (text === undefined || seeded === undefined) throw new Error('expected the text tab beside the guide')
+    // Two tabs: closing the guide is an ordinary close, and the column stays open.
+    actions.closeTab(SESSION, seeded.id)
+    expect(layout().expanded).toBe(true)
+    expect(layout().tabs[seeded.id]).toBeUndefined()
+    const before = layout()
+    const recorded = entries()
+
+    actions.closeTab(SESSION, text.id)
+
+    expect(layout().expanded).toBe(false)
+    expect(layout().tabs[text.id]).toBeUndefined()
+    // The settle rule reseeded the emptied root pane, so reopening shows the guide.
+    const reseeded = Object.values(layout().tabs)
+    expect(reseeded).toHaveLength(1)
+    expect(entries()).toBe(recorded + 1)
+
+    actions.undo(SESSION)
+    expect(layout()).toEqual(before)
+  })
+
+  it('closes a floating tab without touching the column: floats do not count as the last docked tab', () => {
+    const { actions, layout, guide } = harness()
+    actions.setExpanded(SESSION, true)
+    actions.openContent(SESSION, { kind: 'text', contentId: 'dsh-resource://file/session/s-test/a.txt', title: 'a' }, () => {})
+    const text = Object.values(layout().tabs).find(tab => tab.kind === 'text')
+    if (text === undefined) throw new Error('expected the text tab')
+    actions.floatTab(SESSION, text.id, { x: 10, y: 20, width: 300, height: 200 })
+    // The guide is now the docked surface's only tab; the floating text tab closes freely.
+    actions.closeTab(SESSION, text.id)
+    expect(layout().floats).toHaveLength(0)
+    expect(layout().tabs[text.id]).toBeUndefined()
+    expect(layout().expanded).toBe(true)
+    expect(layout().tabs[guide()]).toBeDefined()
+  })
+})
+
 describe('createSidebarRightStore — floating panels and dividers', () => {
   it('closes the sole tab in a floating pane while retaining the docked pane', () => {
     const { actions, layout, entries, guide } = harness()

+ 1 - 1
packages/client/ui-sidebar-right/tests/tab-info.client.spec.tsx

@@ -21,7 +21,7 @@ afterEach(() => {
 })
 
 function harness() {
-  const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start', permanent: false })).create()
+  const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start' })).create()
   const domain = new TabDomain({ openResourceIn: vi.fn(), openTabIn: vi.fn(), closeIn: vi.fn() }, vi.fn())
   domains.push(domain)
   const navigationSources = new Map<string, TabOccurrence['navigation']>()

+ 2 - 2
packages/client/ui-sidebar-right/tests/tab-registry.client.spec.ts

@@ -210,7 +210,7 @@ describe('SidebarRightTabRegistry — ids and page types', () => {
       kind: 'files',
       priority: 'builtin',
       title: () => 'Files',
-      guide: [{ order: 10, title: () => 'Files', description: () => 'Browse' }],
+      guide: [{ order: 10, title: () => 'Files' }],
     })
     expect(ranked(registry, 'dsh-resource://file/session/s/a.txt')).toEqual([])
     expect(registry.get('files')?.title('x')).toBe('Files')
@@ -258,7 +258,7 @@ describe('SidebarRightTabRegistry — lifetime', () => {
 
   it('collects every type\'s guide entries in order, reference-stable between changes', () => {
     const registry = new SidebarRightTabRegistry(new Context())
-    const entry = (order: number) => ({ order, title: () => `#${order}`, description: () => '' })
+    const entry = (order: number) => ({ order, title: () => `#${order}` })
     registry.register(typeFor('files', [], { guide: [entry(10)] }))
     const first = registry.guide()
     expect(registry.guide()).toBe(first)