Explorar o código

feat(sidebar): add tab navigation, injected information, and fullscreen shell

imccyu hai 1 mes
pai
achega
b67e0a838c
Modificáronse 31 ficheiros con 3779 adicións e 0 borrados
  1. 121 0
      .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md
  2. 121 0
      .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md
  3. 31 0
      .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md
  4. 31 0
      .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md
  5. 137 0
      docs/subsystems/sidebar-right.md
  6. 137 0
      docs/subsystems/sidebar-right.zh.md
  7. 133 0
      packages/client/ui-sidebar-right/README.md
  8. 133 0
      packages/client/ui-sidebar-right/README.zh.md
  9. 79 0
      packages/client/ui-sidebar-right/package.json
  10. 52 0
      packages/client/ui-sidebar-right/src/client/contract/params.ts
  11. 41 0
      packages/client/ui-sidebar-right/src/client/contract/seed.ts
  12. 170 0
      packages/client/ui-sidebar-right/src/client/contract/slots.ts
  13. 194 0
      packages/client/ui-sidebar-right/src/client/index.ts
  14. 30 0
      packages/client/ui-sidebar-right/src/client/labels.ts
  15. 50 0
      packages/client/ui-sidebar-right/src/client/locales.ts
  16. 476 0
      packages/client/ui-sidebar-right/src/client/service.ts
  17. 38 0
      packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css
  18. 48 0
      packages/client/ui-sidebar-right/src/client/shell/ExpandButton.tsx
  19. 108 0
      packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css
  20. 402 0
      packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx
  21. 353 0
      packages/client/ui-sidebar-right/src/client/stores.ts
  22. 184 0
      packages/client/ui-sidebar-right/src/client/tab-domain.ts
  23. 60 0
      packages/client/ui-sidebar-right/src/client/tab-info.ts
  24. 399 0
      packages/client/ui-sidebar-right/src/client/tab-registry.ts
  25. 79 0
      packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css
  26. 84 0
      packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx
  27. 27 0
      packages/client/ui-sidebar-right/src/client/tabs/guide/definition.ts
  28. 6 0
      packages/client/ui-sidebar-right/src/css-modules.d.ts
  29. 4 0
      packages/client/ui-sidebar-right/src/index.ts
  30. 48 0
      packages/client/ui-sidebar-right/tsconfig.json
  31. 3 0
      packages/client/ui-sidebar-right/tsdown.config.ts

+ 121 - 0
.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md

@@ -0,0 +1,121 @@
+# Agent Note: Right Sidebar tab types and navigation
+
+Status: implemented
+
+English | [中文](2026-09-05-sidebar-tab-types-and-navigation.zh.md)
+
+## Problem
+
+The [docking surface](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) gives the right Sidebar panes, tabs, and floating panels, but a pane full of tabs is only useful if other plugins can put content into them. That needs three contracts the surface itself does not define: how a plugin declares a kind of tab and the addresses it can show, how any caller — a produced-file chip in the conversation, a row in a file tree, a plugin's own button — asks the Sidebar to show something, and what a tab's body may rely on at runtime. Each contract is a public face that plugins shipped from outside this repository will write against, so each has to be settled before those plugins exist: a renamed field, a changed enum value, or a different address grammar afterwards breaks every one of them.
+
+Two constraints shaped the answers. Dynamic client plugins may not import runtime values from one another — a function, a constant, a class — only types, so nothing in these contracts may require a helper function or an exported constant from the Sidebar package. And the Web client already has one component model, the Slot system; a second one for tabs would be a parallel framework to learn and maintain.
+
+## Decision
+
+A tab type is a static registration into `ctx.sidebarRightTabs`; a tab's body and title are ordinary keyed Slot registrations; `ctx.sidebarRight` opens content in exactly two ways — a resource by address, or a page by kind — and otherwise only operates the layout; and bodies read occurrence information through the framework-injected `useTabInfo()`. The four faces are described below in the order a plugin author meets them.
+
+### The type registry: `ctx.sidebarRightTabs`
+
+`register(definition): () => void` records one tab type and returns the disposer the caller holds in its own `ctx.effect`, so a type lives exactly as long as the plugin that contributed it. The definition is static:
+
+```ts ignore-check
+interface SidebarRightTabDefinition {
+  readonly id: string                                   // this implementation's identity in the tab system
+  readonly kind: string                                 // what the tabs of this type are; what openTab names
+  readonly patterns?: readonly string[]                 // resource-address globs; omitted by a page type
+  readonly priority?: 'extension' | 'builtin' | 'fallback'   // defaults to extension
+  readonly canOpen?: (address: string) => boolean       // veto after a glob matched
+  readonly title: (address: string) => string           // chip text, captured at open time
+  readonly guide?: readonly SidebarRightGuideEntry[]    // entry boxes on the guide page
+}
+```
+
+`id` and `kind` are different things. `kind` is the type discriminator — what a tab *is*, what `openTab` names, what tab identity is built from. `id` is the identity of one *implementation* of a kind, unique across every registration; a package name is the natural value. The two are separate because a kind is not unique: an `extension` may register the kind a `builtin` already holds, and the two implementations then coexist in the registry with the extension in force. The registry rejects a second registration of an `id`, a second registration in the same band of a kind, and any registration meeting a `fallback` of the same kind; it accepts exactly the extension-over-builtin pair, and the builtin resumes when the extension unregisters.
+
+`patterns` are globs over resource addresses, matched with `picomatch` under VS Code's editor-resolver rule with one local change: a pattern containing `:` is matched against the whole address (`dsh-resource://file/**`), one without is matched against the URI's path at any depth (`*.md`), matching ignores case and does not hide dotfiles, and an address that is not a URI matches no path pattern. A page type — the guide, the file tree — recognizes no address and omits `patterns`; it is opened by kind.
+
+`priority` is one of three literal bands, spelled as strings so that a type from another package needs no runtime import: `extension` is the band of a type from outside the product and the highest, so a type that declares nothing outranks every viewer shipped here; `builtin` is the ordinary band for shipped types; `fallback` is the plain-content position that anything more specific should beat, which VS Code's text editor holds implicitly and our text preview holds explicitly. `candidates(address)` returns every type whose globs match and whose `canOpen` does not veto, ranked by band, then by the length of the longest pattern that matched, then by registration order. `claim(address, kind?)` takes the best candidate, or the named kind's type in force when the caller overrides (its globs are not consulted; naming the type is the decision), and throws for an address nothing will open — a wiring mistake, not a user error. `get(kind)` returns the type in force; `entries()` and `guide()` list the types and their guide boxes in force; `subscribe` observes changes.
+
+`title(address)` and `guide[].title()` are thunks read on every use, so a language change needs no re-registration. The registry itself is a plain object provided at `apply`'s top level **without** `Service.tracker`: a tracker would rebind `this.ctx` to the caller's context, and a cross-package `register()` would then add its effect to the caller's fiber while that fiber is the active scope, stalling the browser boot with no error.
+
+### Bodies and titles: keyed Slot seats under the definition's `id`
+
+The type registry says what a type is; the Slot system says what it looks like. A type registers its body into the keyed, session-scoped seat `sidebar.right.pane.tab` under its own `id`, and may register a title component into `sidebar.right.pane.tab.title` under the same key. The seat that draws a tab resolves the tab's `kind` to the type in force through the registry and dispatches to that type's `id`, so an extension taking over a builtin's kind is rendered without either package knowing about the other, and without any priority number crossing a package boundary. A kind with no type in force renders the owner's "nothing can view this" notice; a type with no title registration gets the `title(address)` text the registry captured when the tab opened.
+
+Two further seats extend the guide and the menu: `sidebar.right.tab.guide` is a chain whose first non-declining entry replaces the shipped guide body without replacing the tab, and `sidebar.right.tab.menu.item` is a list appended after the kit's own layout actions, for actions that mean something about a tab's content. A type's controls — a reload, a wrap toggle — live inside its own body; the strip belongs to the panel and carries only the panel's controls. A type's own state is an ordinary Slot store and inject face on the body registration; the framework adds nothing to the component model.
+
+### Tab occurrence information
+
+[Responsive Sidebar and tab information](2026-09-07-sidebar-responsive-tab-info.md) supersedes this note's choice of flat owner props for occurrence information. Bodies, titles and guide replacements receive the framework-injected `useTabInfo()` to read `{ sidebar, panel, tab }`. The record, navigation, visibility, signal and bound actions live inside `tab`; exact fields belong to the [Sidebar reference](../../../../docs/subsystems/sidebar-right.md).
+
+The Tab domain still owns one occurrence per committed record, with an `AbortController`, navigation snapshot and actions bound to its Session. It pins the address in the [resource model](2026-09-05-client-resource-model.md) for the record's lifetime; hiding and switching Sessions do not end it, while closing the record aborts and releases it. Existing framework store and navigation hooks provide live reads, without subscriptions in tab implementations.
+
+### Navigation: `ctx.sidebarRight`
+
+The face opens content in two ways and does nothing else with content:
+
+```ts ignore-check
+openResource(address: string, options?: { kind?: string; params?: SidebarRightResourceParams; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void
+openTab<K extends string>(kind: K, options?: { params?: SidebarRightTabParamsFor<K>; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void
+```
+
+`openResource` takes a resource address — a `dsh-resource://<type>/…` URI, the only scheme the resource model has — and asks the registry who shows it: without `kind`, every type is consulted and the ranking decides; with `kind`, that type's implementation in force opens it. An address with any other scheme fails on the same path as an address nothing claims. `openTab` opens a page type by kind and never sees an address: the Sidebar records the tab under `sidebar://<kind>`, composed in one place inside the package, so that a page tab has a `contentId` for identity and history like any other tab. The scheme is bookkeeping: no caller composes it, no business package contains the literal, and the file tree and the guide are opened as `openTab('files')` and `openTab('guide')`.
+
+Both opens run the same four steps: resolve the type (by ranking or by kind), locate an existing tab by `(kind, contentId)` unless `revealIfOpened` is `false`, place the tab — in `replaceTab`'s pane and strip slot, in `paneId`, or in the active pane — and record the expansion, the open-or-focus, and the `replaceTab` close as one history entry before handing `{ address, params }` to the tab domain. Placement is the caller's business, never a type-level trait: the file tree opens into its own pane because it says so, as VS Code's Explorer passes `SIDE_GROUP` or `ACTIVE_GROUP` itself. `replaceTab` means one thing — open in that tab's place and close it in the same step — and exists for the guide's entry boxes, which hand their tab over to the page they name.
+
+Parameters are typed by what is being opened, through two merge-extensible maps declared in the Sidebar package and augmented by the owners of the keys:
+
+```ts
+interface SidebarRightResourceParamsMap {}   // key: resource type — the text preview declares { line?: number }
+interface SidebarRightTabParamsMap {}        // key: kind — a page type declares its own shape, or nothing
+```
+
+`openResource` accepts the union of every declared resource shape and `openTab<K>` the shape declared for `K`; a body narrows `navigation.params` by the protocol or kind it knows it serves. Parameters belong to the resource type rather than to the viewer because a line number is a fact about a file location, not about the text preview, and any type that claims `file` addresses receives the same shape. Values must be JSON-serializable, and a record must be rebuildable from address and parameters alone, because undo, redo, reload, and HMR rebuild tabs after the opener is gone.
+
+Beside the two opens, the face carries `close(tabId)`, `active()`, `isExpanded()`, `toggleExpanded()`, and four operational methods — `focus(tabId)`, `split(paneId?)` (returning the new pane, or `undefined` when the pane budget or the width rule forbids the split, recording nothing), `float(tabId, rect?)`, and `dock(paneId)` — each recording one history entry and a no-op on a missing target or one already in the requested state. There is no layout snapshot, no subscription, and no lookup by address: the face grants control over the layout, not a view of it. The seat publishes its binding — its session, its store's actions, and its surface — while mounted; a command on the public face acts on the mounted session and throws with no mounted session surface. A tab's own actions reach their session's own store instead: the slot runtime mints one store per session, the plugin adopts each as it is minted, and the controller routes by session id, so an action fired after the user switched sessions still lands, and does nothing for a session whose store was never minted.
+
+### Addresses
+
+Addresses come in two families that never mix. Resource addresses are the resource model's `dsh-resource://<type>/…` URIs (a workspace file is `dsh-resource://file/session/<sessionId>/<path relative to that session's workspace root>`, an arbitrary file `dsh-resource://file/absolute/<absolute path>`, both built and parsed by `dsh-util-workspace-path`); they are what `openResource` takes, what `patterns` match, and what `useResource` reads. Navigation addresses name pages rather than data; today the only one is the internal `sidebar://<kind>` a page tab is recorded under. Only the resource family is a contract: the navigation family is composed and consumed inside the Sidebar, and a fuller navigation protocol is a later decision that this one leaves room for by keeping every navigation literal in one place.
+
+### Entry points
+
+The conversation's `openFile(path, { line? })` — tool-row path links, produced-file chips, closing-message mentions — encodes the path as a file resource address for the Session, and calls `openResource` with `params.line` when the caller knows one; the `read` tool row passes the line its `offset` argument started from. The strip's `+` calls `openTab('guide', { paneId, revealIfOpened: false })` for the pane it sits in; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`, which lands in the tree's own pane.
+
+## Alternatives considered
+
+**A chain slot for tab dispatch, or a keyed slot alone.** A chain's `select` is not enumerable, and the guide page and the navigation face must enumerate types; a keyed slot carries a body and nothing else, so a type's title and address recognition had nowhere to live. Two stages — a definition registry plus keyed component seats — is the repository's existing pattern (`ConversationViewRegistry`).
+
+**Runtime hooks or an instance object per tab.** Several forms were tried on paper — a Cordis fiber per tab, an abstract base class, an `initial`/`create` pair returning an instance with `dispose`, a set of `useTab*` hooks, a framework-managed `useTabResource(fetch)`, a `useTabStream`. Rejected in turn: a fiber per tab is far too heavy; dynamic packages cannot share a base class or an exported constant; an instance layer duplicates what a Slot store and inject face already are; per-tab hooks restate owner props; a framework-owned fetch has no good cache key; and a stream hook on the tab domain asks the wrong owner — chat data must come from the chat domain, file data from the workspace file service. What remains is owner props plus one client-wide `useResource`. `visible` was later added as a prop rather than a hook for the same reason: it is one more fact about the occurrence, and the props already carry the occurrence. The rejection of occurrence-reading hooks is superseded by the [tab information decision](2026-09-07-sidebar-responsive-tab-info.md); the independent instance, fiber and data-stream ownership rationale still applies.
+
+**A per-pane tools seat for the active tab's controls (`sidebar.right.pane.tab.tools`).** Shipped for one review round, then removed: it put type-private buttons on the panel's strip beside the split and collapse controls, where they read as panel chrome. A type's controls belong in its own body.
+
+**Type-level placement (`opensInto`) and a hidden sibling heuristic.** Rejected: where a tab lands is the opener's business, exactly as VS Code's Explorer decides `sideBySide` itself.
+
+**Extension lists and numeric priorities.** `claims.extensions` cannot express `.d.ts`, `Dockerfile`, a directory constraint, or a whole scheme — it is a degenerate glob; numeric priorities need an exported constant that dynamic packages cannot import. Literal bands over globs. VS Code's own bands were reduced from five to three: an `option` band (listed, never chosen automatically) has no consumer until an "open with…" affordance exists, and a `default` band was renamed `extension` because the name read as the lowest tier while it is the highest.
+
+**One `open(address)` for everything, with a helper that builds page addresses.** The first design opened pages by address too, so a business package needed a `sidebar://<kind>` literal or a `sidebarAddress(kind)` helper from the Sidebar package. Both are forbidden by the value-import rule and both leak a navigation scheme that is not yet designed. Splitting the face into `openResource` and `openTab` puts the only literal inside the package and lets each mode type its parameters.
+
+**Naming a specific implementation when opening (`?impl=`), `find(address)`, `mode()`/`setMode()`, a layout snapshot, a `features` list.** All considered and left out. Naming an implementation belongs to a navigation protocol that does not exist yet; `find` and a snapshot would make the face a view of the layout when it is meant to be control over it; presentation mode is a UI toggle, not a plugin concern; a capability list is premature while the face is settling.
+
+**Slot priorities to express an extension taking over a builtin, then registry-minted slot keys.** The first attempt had the overriding type register its body at a lower slot priority through an exported constant — a value import across dynamic plugins, and a second rule system (slot priority) standing in for the registry's. The second attempt had the registry mint a key per registration and return it from `register()`, which made registration a two-step dance whose ordering mattered. Letting the implementation declare its own `id` — required, unique, the same string it registers its seats under — needs no constant, no minting, and no ordering, and gives the registry the identity it needs to reject duplicates.
+
+## Consequences
+
+- A type is one static object plus one or two keyed seat registrations; its occurrence information is read through injected `useTabInfo()`. The framework grows no per-type API surface, and a type shipped from outside this repository imports only types from the Sidebar package.
+- Two opens with two parameter maps mean a caller cannot open a page by address or a resource by kind alone, and the compiler tells it so; the cost is that every new resource type or page kind that wants typed parameters augments a map.
+- `id` and `kind` being distinct lets an extension replace a shipped type in place, per kind, with the builtin resuming when the extension unregisters; the cost is one more required field on every definition.
+- The navigation face is control-only. A plugin that needs to know the layout cannot ask for it, which keeps the layout's shape out of every plugin's contract until a navigation protocol decides what to expose.
+- The `sidebar://<kind>` literal lives in one file. Changing the navigation grammar later touches the Sidebar package and nothing else.
+- These faces are the part of the Sidebar that is fixed: addresses, registration fields and bands, the two opens and their parameter maps, seat names and injected tab information. Everything a user sees as behaviour — where a float snaps, when a split control greys out, the copy, the tree's ordering — is a product rule outside every contract here and changes without notice to any plugin.
+
+## Testing
+
+`ui-sidebar-right` specs cover the registry (bands, extension-over-builtin with resumption, `id` and same-band collisions, glob and path matching, `canOpen`, ranking and tiebreaks), both opens (normal, edge, and failure paths including the wrong scheme and an unregistered kind), `replaceTab` as one history entry, the seat resolving a kind to the implementation in force and back, `useTabInfo()` including `tab.visible` under collapse and floating, and the operational methods with their no-op and throw cases. The Web e2e suite drives the guide, the file tree, and a file open through the real plugin graph in Chromium. Both suites are keyless.
+
+## Deferred
+
+- A navigation protocol beyond `sidebar://<kind>`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes.
+- Parameters for the shipped page types, which today declare none.
+- Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session.
+- A localized message when an open fails from the conversation; the failure is currently the thrown error's text.

+ 121 - 0
.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md

@@ -0,0 +1,121 @@
+# Agent Note: 右侧 Sidebar 的 tab 类型与导航
+
+Status: implemented
+
+[English](2026-09-05-sidebar-tab-types-and-navigation.md) | 中文
+
+## Problem
+
+[停靠面](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)给了右侧 Sidebar 分栏、tab 与浮动面板,但一格 tab 只有在别的插件能往里放内容时才有用。这需要三份停靠面自身不定义的契约:插件如何声明一种 tab 及其能展示的地址;任何调用方——会话区里的产出文件 chip、文件树里的一行、插件自己的按钮——如何请 Sidebar 展示某样东西;以及 tab 的正文在运行时能依赖什么。每一份都是仓外插件将来要对着写的公开面,所以必须在那些插件出现之前定下来:之后改一个字段名、一个枚举值或地址语法,就会同时弄坏它们全部。
+
+两条约束决定了答案。动态客户端插件之间不允许引用运行时值——函数、常量、类——只能引类型,因此这些契约里不得要求从 Sidebar 包引入帮助函数或导出常量。而 Web 客户端已经有一套组件模型,即 Slot 系统;再为 tab 造一套,就是第二个要学要维护的并行框架。
+
+## Decision
+
+tab 类型是向 `ctx.sidebarRightTabs` 的一次静态注册;tab 的正文与标题是普通的 keyed Slot 注册;`ctx.sidebarRight` 只以两种方式打开内容——按地址开资源、按 kind 开页——其余只操作布局;正文通过框架注入的 `useTabInfo()` 读取实例信息。下面按插件作者遇到的顺序描述这四个面。
+
+### 类型注册表:`ctx.sidebarRightTabs`
+
+`register(definition): () => void` 记录一种 tab 类型并返回注销器,调用方把它放进自己的 `ctx.effect`,于是类型的寿命恰好等于贡献它的插件。定义是静态的:
+
+```ts ignore-check
+interface SidebarRightTabDefinition {
+  readonly id: string                                   // this implementation's identity in the tab system
+  readonly kind: string                                 // what the tabs of this type are; what openTab names
+  readonly patterns?: readonly string[]                 // resource-address globs; omitted by a page type
+  readonly priority?: 'extension' | 'builtin' | 'fallback'   // defaults to extension
+  readonly canOpen?: (address: string) => boolean       // veto after a glob matched
+  readonly title: (address: string) => string           // chip text, captured at open time
+  readonly guide?: readonly SidebarRightGuideEntry[]    // entry boxes on the guide page
+}
+```
+
+`id` 与 `kind` 是两回事。`kind` 是类型判别符——tab *是什么*、`openTab` 点名什么、tab 身份由什么构成。`id` 是某个 kind 的一个*实现*的身份,在全部注册里唯一,包名是自然的取值。两者分开是因为 kind 并不唯一:`extension` 可以注册一个 `builtin` 已持有的 kind,两个实现随即在注册表里共存,生效的是 extension。注册表拒绝重复的 `id`、同一 kind 在同一档的第二次注册、以及任何与同 kind 的 `fallback` 相遇的注册;它只接受 extension 压 builtin 这一对,extension 注销后 builtin 恢复。
+
+`patterns` 是资源地址上的 glob,用 `picomatch` 按 VS Code 编辑器解析器的规则匹配,只有一处本地改动:含 `:` 的 pattern 匹配整个地址(`dsh-resource://file/**`),不含的匹配 URI 的路径且任意深度(`*.md`),匹配不区分大小写、不隐藏 dotfile,不是 URI 的地址不匹配任何路径 pattern。页类型——引导页、文件树——不识别任何地址,省略 `patterns`,按 kind 打开。
+
+`priority` 是三个字面量档位之一,写成字符串,好让别的包的类型不需要任何运行时引入:`extension` 是来自产品之外的类型的档位也是最高档,所以什么都不声明的类型压过这里随包交付的每个查看器;`builtin` 是随包类型的常规档;`fallback` 是任何更具体的东西都应压过的纯内容位置,VS Code 的文本编辑器隐含地占据它,我们的文本预览明确地占据它。`candidates(address)` 返回 glob 命中且 `canOpen` 未否决的每个类型,按档位、再按命中的最长 pattern 长度、再按注册顺序排序。`claim(address, kind?)` 取最佳候选,或在调用方指定时取该 kind 生效的类型(不查它的 glob;点名即决定),对无人愿开的地址抛错——这是接线错误,不是用户错误。`get(kind)` 返回生效类型;`entries()` 与 `guide()` 列出生效类型及其引导入口;`subscribe` 观察变化。
+
+`title(address)` 与 `guide[].title()` 是每次使用时重读的 thunk,语言切换无需重新注册。注册表本身是 `apply` 顶层提供的普通对象,**不带** `Service.tracker`:tracker 会把 `this.ctx` 重绑到调用方上下文,跨包 `register()` 就会在调用方 fiber 仍是活动作用域时往它上加 effect,浏览器启动会无声卡死。
+
+### 正文与标题:按定义 `id` keyed 的 Slot 坑位
+
+类型注册表说类型是什么;Slot 系统说它长什么样。类型把正文注册进 keyed、session 作用域的坑位 `sidebar.right.pane.tab`,键是自己的 `id`,并可把标题组件注册进 `sidebar.right.pane.tab.title`,键相同。画 tab 的座位经注册表把 tab 的 `kind` 解析成生效类型,再派发到该类型的 `id`,于是 extension 接管 builtin 的 kind 时两个包互不知晓也能正确渲染,且没有任何优先级数字跨过包边界。没有生效类型的 kind 渲染属主的「没有东西能查看它」提示;没注册标题的类型得到注册表在打开时捕获的 `title(address)` 文本。
+
+另有两个坑位扩展引导与菜单:`sidebar.right.tab.guide` 是 chain,第一个不拒绝的条目在不替换 tab 的前提下替换随包引导正文;`sidebar.right.tab.menu.item` 是 list,追加在库自身布局动作之后,放与 tab 内容有关的动作。类型自己的控件——重载、换行开关——住在自己正文里;tab 条属于面板,只放面板的控件。类型自己的状态是正文注册上普通的 Slot store 与 inject 面;框架不给组件模型添任何东西。
+
+### 标签实例信息
+
+[响应式 Sidebar 与标签信息](2026-09-07-sidebar-responsive-tab-info.zh.md)取代本记录中以平铺 owner props 传递实例信息的选择。正文、标题与引导页替换项接收框架注入的 `useTabInfo()`,以 `{ sidebar, panel, tab }` 读取所属 Sidebar、窗格与标签。实例的记录、导航、可见性、signal 与绑定动作均在 `tab` 内;精确字段见 [Sidebar 参考](../../../../docs/subsystems/sidebar-right.zh.md)。
+
+标签域仍为每个已提交记录拥有一个实例,包括 `AbortController`、导航快照和绑定到所属 Session 的动作。记录存活期间,其地址被钉在[资源模型](2026-09-05-client-resource-model.zh.md)中;隐藏与切换 Session 不结束实例,关闭记录则中止并释放它。框架已有的存储与导航钩子提供实时读取,类型不自行订阅。
+
+### 导航:`ctx.sidebarRight`
+
+该面以两种方式打开内容,对内容不做别的事:
+
+```ts ignore-check
+openResource(address: string, options?: { kind?: string; params?: SidebarRightResourceParams; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void
+openTab<K extends string>(kind: K, options?: { params?: SidebarRightTabParamsFor<K>; paneId?; replaceTab?: TabId; revealIfOpened?: boolean }): void
+```
+
+`openResource` 接一个资源地址——`dsh-resource://<type>/…` URI,资源模型仅有的 scheme——并问注册表谁来展示:不带 `kind` 时问遍所有类型由排序决定;带 `kind` 时由该类型生效的实现打开。其它 scheme 的地址与无人认领的地址走同一条失败路径。`openTab` 按 kind 打开页类型,永远见不到地址:Sidebar 把该 tab 记账在 `sidebar://<kind>` 下,这个字面量只在包内一处拼装,为的是页 tab 与其它 tab 一样有 `contentId` 供身份与历史使用。这个 scheme 只是记账:没有调用方拼它,业务包里没有这个字面量,文件树与引导页分别以 `openTab('files')`、`openTab('guide')` 打开。
+
+两种打开走同样四步:解析类型(按排序或按 kind);除非 `revealIfOpened` 为 `false`,否则按 `(kind, contentId)` 定位已有 tab;落位——落在 `replaceTab` 的格与条位、`paneId`、或活跃格;把展开、打开或聚焦、以及 `replaceTab` 的关闭记为一条历史,再把 `{ address, params }` 交给 tab 域。落位是调用方的事,从不是类型级特性:文件树把文件开进自己的格是因为它自己说了,正如 VS Code 的 Explorer 自己传 `SIDE_GROUP` 或 `ACTIVE_GROUP`。`replaceTab` 只有一个含义——在那个 tab 的位置打开并在同一步关掉它——为的是引导页入口框把自己的 tab 交给所点的页。
+
+参数按被打开的东西定型,经 Sidebar 包声明、由键的拥有者增补的两张可声明合并表:
+
+```ts
+interface SidebarRightResourceParamsMap {}   // key: resource type — the text preview declares { line?: number }
+interface SidebarRightTabParamsMap {}        // key: kind — a page type declares its own shape, or nothing
+```
+
+`openResource` 接受所有已声明资源形状的联合,`openTab<K>` 接受为 `K` 声明的形状;正文按自己所服务的协议或 kind 收窄 `navigation.params`。参数属于资源类型而非查看器,因为行号是关于文件位置的事实,不是关于文本预览的,任何认领 `file` 地址的类型收到同一形状。值必须可 JSON 序列化,一条记录必须只凭地址与参数就能重建,因为撤销、重做、刷新与 HMR 都在开启方已不在时重建 tab。
+
+除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。座位挂载期间发布其绑定——自己的会话、其 store 的 action 与其面;公开面上的命令作用于已挂载会话,没有已挂载会话面时抛错。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。
+
+### 地址
+
+地址分两族,永不混用。资源地址是资源模型的 `dsh-resource://<type>/…` URI(工作区文件是 `dsh-resource://file/session/<sessionId>/<相对该会话工作区根的路径>`,任意文件是 `dsh-resource://file/absolute/<绝对路径>`,都由 `dsh-util-workspace-path` 构造与解析);它们是 `openResource` 的入参、`patterns` 的匹配对象、`useResource` 的读取对象。导航地址命名的是页而非数据;今天唯一的一种是页 tab 记账用的内部 `sidebar://<kind>`。只有资源族是契约:导航族在 Sidebar 内部拼装与消费,更完整的导航协议是之后的决定,本决定通过把所有导航字面量留在一处为它预留空间。
+
+### 入口
+
+会话区的 `openFile(path, { line? })`——工具行路径链接、产出文件 chip、收尾消息提及——把路径编码为该 Session 的文件资源地址并调用 `openResource`,调用方知道行号时带 `params.line`;`read` 工具行传入其 `offset` 参数起始的行。tab 条的「+」为所在格调用 `openTab('guide', { paneId, revealIfOpened: false })`;引导入口框调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的一行调用 `tab.actions.openResource(address)`,落在树自己的格里。
+
+## Alternatives considered
+
+**用 chain 坑位派发 tab,或只用 keyed 坑位。** chain 的 `select` 不可枚举,而引导页与导航面必须枚举类型;keyed 坑位只带正文,类型的标题与地址识别无处可住。两段——定义注册表加 keyed 组件坑位——是仓库既有模式(`ConversationViewRegistry`)。
+
+**运行时 hook 或每 tab 一个实例对象。** 纸面上试过多种形态——每 tab 一个 Cordis fiber、抽象基类、返回带 `dispose` 实例的 `initial`/`create` 对、一组 `useTab*` hook、框架托管的 `useTabResource(fetch)`、`useTabStream`。依次否决:每 tab 一个 fiber 太重;动态包无法共享基类或导出常量;实例层重复了 Slot store 与 inject 面已经是的东西;每 tab hook 复述 owner props;框架托管的 fetch 没有好的缓存键;tab 域上的流 hook 问错了主人——聊天数据必须来自聊天域,文件数据来自工作区文件服务。剩下的是 owner props 加一个全客户端的 `useResource`。`visible` 后来以 prop 而非 hook 加入也是同一理由:它是关于该次出现的又一个事实,而 props 已经承载了该次出现。 对实例读取钩子的否决由[标签信息决策](2026-09-07-sidebar-responsive-tab-info.zh.md)取代;对独立实例对象、fiber 与数据流所有权的理由仍适用。
+
+**每格一个工具区坑位放活跃 tab 的控件(`sidebar.right.pane.tab.tools`)。** 上线一轮评审后删除:它把类型私有按钮放到面板 tab 条上、与分栏和收起控件并列,读起来像面板 chrome。类型的控件属于自己的正文。
+
+**类型级落位(`opensInto`)与隐藏的相邻格启发式。** 否决:tab 落在哪是开启方的事,正如 VS Code 的 Explorer 自己决定 `sideBySide`。
+
+**扩展名列表与数字优先级。** `claims.extensions` 表达不了 `.d.ts`、`Dockerfile`、目录约束或整个 scheme——它是退化的 glob;数字优先级需要动态包无法引入的导出常量。字面量档位加 glob。VS Code 自己的档位从五个收成三个:`option` 档(只列出、永不自动选中)在「用其他方式打开」存在之前没有消费者,`default` 档改名 `extension`,因为那个名字读起来像最低档而它是最高档。
+
+**一个 `open(address)` 包打天下,外加拼页地址的帮助函数。** 第一版页也按地址打开,于是业务包需要 `sidebar://<kind>` 字面量或来自 Sidebar 包的 `sidebarAddress(kind)` 帮助函数。两者都被值引用规则禁止,也都泄露了尚未设计的导航 scheme。把面拆成 `openResource` 与 `openTab`,唯一的字面量留在包内,且每种模式各自定型参数。
+
+**打开时点名某个实现(`?impl=`)、`find(address)`、`mode()`/`setMode()`、布局快照、`features` 清单。** 都考虑过并留在外面。点名实现属于尚不存在的导航协议;`find` 与快照会把该面变成布局的视图,而它本该是对布局的控制;呈现模式是 UI 开关不是插件关心的事;能力清单在该面尚在收敛时为时过早。
+
+**用 Slot 优先级表达 extension 接管 builtin,随后是注册表铸造的坑位键。** 第一次尝试让覆盖方经一个导出常量以更低的 Slot 优先级注册正文——这是动态插件间的值引用,也是拿第二套规则(Slot 优先级)替注册表的规则站台。第二次尝试让注册表为每次注册铸一个键并从 `register()` 返回,这把注册变成了两步且顺序敏感的舞步。让实现自己声明 `id`——必填、唯一、与它注册坑位所用的同一个串——既不需要常量,也不需要铸键与顺序,还给了注册表拒绝重复所需的身份。
+
+## Consequences
+
+- 一个类型 = 一个静态对象 + 一到两个 keyed 坑位注册;其实例信息通过注入的 `useTabInfo()` 读取。框架不长任何按类型的 API 面,仓外类型从 Sidebar 包只引类型。
+- 两种打开配两张参数表,意味着调用方无法只按地址开页或只按 kind 开资源,编译器会说明;代价是每个想要类型化参数的新资源类型或页 kind 都要增补一张表。
+- `id` 与 `kind` 分离让 extension 能按 kind 原位替换随包类型,extension 注销后 builtin 恢复;代价是每个定义多一个必填字段。
+- 导航面只有控制权。需要知道布局的插件无法索取,这让布局的形状在导航协议决定暴露什么之前不进任何插件的契约。
+- `sidebar://<kind>` 字面量住在一个文件里。之后改导航语法只碰 Sidebar 包。
+- 这些面是 Sidebar 里被定死的部分:地址、注册字段与档位、两种打开及其参数表、slot 名与注入的标签信息。用户看到的一切行为——浮窗贴到哪、分栏控件何时置灰、文案、树的排序——都是这里任何契约之外的产品规则,改动无需通知任何插件。
+
+## Testing
+
+`ui-sidebar-right` 的 spec 覆盖注册表(档位、extension 压 builtin 及恢复、`id` 与同档冲突、glob 与路径匹配、`canOpen`、排序与平局)、两种打开(正常、边界与失败路径,含错误 scheme 与未注册 kind)、`replaceTab` 记一条历史、座位把 kind 解析到生效实现并回退、`useTabInfo()` 含折叠与浮窗下的 `tab.visible`、以及操作型方法的 no-op 与抛错情形。Web e2e 套件在 Chromium 里经真实插件图驱动引导页、文件树与一次文件打开。两套均无需密钥。
+
+## Deferred
+
+- `sidebar://<kind>` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。
+- 随包页类型的参数,今天未声明任何。
+- 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。
+- 从会话区打开失败时的本地化提示;目前是抛错文本本身。

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md

@@ -0,0 +1,31 @@
+# Agent Note: Responsive Sidebar and injected tab information
+
+Status: implemented
+
+English | [中文](2026-09-07-sidebar-responsive-tab-info.zh.md)
+
+## Problem
+
+Tab extensions need consistent live information about their containing pane and Sidebar without a growing list of owner props. The workbench must also preserve content while adapting to limited viewport space, without reopening a Sidebar the user has closed.
+
+## Decision
+
+The slot framework injects one `useTabInfo()` returning nested `sidebar`, `panel`, and `tab` fields. It composes the framework-bound layout and navigation hooks; extensions neither subscribe themselves nor receive a service object. Body visibility requires an active tab in an expanded Sidebar; title visibility does not require an active tab. Hiding or switching Sessions leaves the tab lifetime intact. Closing the record aborts its signal. Tab actions stay bound to their owning Session. Store adoption is a private capability of the plugin assembly, not a public controller operation.
+
+The frame protects 400px for the conversation by shrinking the right column, then closing it before shrinking the conversation. Its first-open preference is 45% of the viewport, retained thereafter in pixels, with a 300px floor and 70% viewport ceiling. The left column keeps its preference at widths of at least 1024px. Closing is recorded state: widening never opens it, while a user action or explicit Session API may. Refresh restores defaults rather than persisting layout.
+
+Fullscreen uses the same mounted content tree and covers the viewport while retaining the underlying column reservation. Opening below 768px selects automatic fullscreen; exiting it there closes the Sidebar. Widening can end automatic fullscreen but leaves manually selected fullscreen intact. The product permits two horizontal panes, a 50/50 initial split, and a 20–80% divider; narrow panes refuse new splits. The generic docking engine retains its independent capabilities.
+
+This decision supersedes the flat owner-props choice in [tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md) and the no-concession, overlay presentation and product pane limit in [docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, record-lifetime, state ownership and engine-selection rationale remain active.
+
+## Alternatives considered
+
+**Flat information props or three separate hooks.** A single nested read groups the three ownership levels and allows additional fields without proliferating props or readers.
+
+**Automatic reopening after a viewport change.** It makes opening depend on layout history rather than an explicit action. A closed Sidebar stays closed, with its content preserved.
+
+**A separate fullscreen content tree.** Remounting would interrupt tab-local state. The same element changes presentation instead.
+
+## Consequences
+
+Tab extensions use a framework-injected reader and keep their own store actions separate from `tab.actions`. Layout, seat and docking tests cover width concessions, explicit reopening, body/title visibility, tab lifetimes, horizontal drop zones and divider limits; browser tests exercise the assembled application. Compact mobile controls and layout persistence remain outside this decision.

+ 31 - 0
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: 响应式 Sidebar 与注入的标签信息
+
+Status: implemented
+
+[English](2026-09-07-sidebar-responsive-tab-info.md) | 中文
+
+## 问题
+
+标签扩展需要一致的所属窗格与 Sidebar 实时信息,而不依赖不断增长的 owner props。工作区也需要适应有限的视口空间,同时保留内容,并且不重新打开用户已经关闭的 Sidebar。
+
+## 决策
+
+Slot 框架注入一个 `useTabInfo()`,返回嵌套的 `sidebar`、`panel` 与 `tab` 字段。它组合框架绑定的布局与导航 hook;扩展既不自行订阅,也不接收服务对象。正文可见要求 Sidebar 展开且标签活跃;标题可见不要求标签活跃。隐藏或切换 Session 保留标签生命周期。关闭记录会中止其 signal。标签动作始终绑定到所属 Session。Store 收编是插件组装的私有能力,不是公共控制器操作。
+
+框架先缩小右列,再关闭右列,最后才缩小会话区,以保护会话区的 400px 宽度。右列首次打开偏好为视口的 45%,此后按像素保留,下限为 300px,上限为视口的 70%。在宽度至少为 1024px 时,左列保持自身偏好。关闭是被记录的状态:变宽不会打开右栏,用户动作或显式 Session API 可以打开。刷新恢复默认值,不持久化布局。
+
+全屏使用同一棵已挂载内容树,覆盖视口并保留底层列的占位。在 768px 以下打开会选择自动全屏;在此宽度下退出全屏会关闭 Sidebar。变宽可以结束自动全屏,但保留手动选择的全屏。产品允许两个水平窗格,初始按 50/50 分割,分割线范围为 20–80%;窄窗格拒绝新分栏。通用停靠引擎保留其独立能力。
+
+本决策取代[标签类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md)的平铺 owner props 选择,以及[停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的无让步、覆盖模式和产品窗格上限。它们的注册、记录生命周期、状态所有权与引擎选型理由继续有效。
+
+## 考虑过的替代方案
+
+**平铺信息 props 或三个独立 hook。** 一个嵌套读取接口按三个所有权层级分组,允许增加字段而不增加 props 或读取接口。
+
+**视口变化后自动重开。** 这会让打开依赖布局历史,而不是显式动作。关闭的 Sidebar 保持关闭,同时保留内容。
+
+**独立的全屏内容树。** 重新挂载会打断标签局部状态。因此由同一元素改变呈现方式。
+
+## 后果
+
+标签扩展使用框架注入的读取接口,自身 store actions 与 `tab.actions` 保持分离。布局、seat 与停靠测试覆盖列宽让步、显式重开、正文与标题可见性、标签生命周期、水平放置区与分割比例;浏览器测试覆盖组装后的应用。紧凑移动端控件与布局持久化不属于本决策。

+ 137 - 0
docs/subsystems/sidebar-right.md

@@ -0,0 +1,137 @@
+# Right Sidebar
+
+English | [中文](sidebar-right.zh.md)
+
+The right Sidebar is the Web Client's per-Session docking surface: a column of panes and tabs beside the conversation in which addressed content — a workspace file, a directory tree, the product's own pages — opens, splits, floats, and closes. [`dsh-client-ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.md) owns the surface, the tab-type registry, and the navigation service; [`dsh-client-ui-dockkit`](../../packages/client/ui-dockkit/README.md) is its internal layout engine; [`dsh-client-resources`](../../packages/client/resources/README.md) turns addresses into live values for any component; [`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) provides both the Host workspace service and the Client `file` resource provider.
+
+This page is the reference for the subsystem's contracts: addresses, tab-type registration, the navigation service, the extension slots and their owner props, the resource model, the Workspace Files service, the shipped types, and what is deliberately not built. How the layout engine, the frame, and the surface fit together is in the [Agent Note](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md); slot mechanics are in the [Slots reference](slots.md).
+
+## Position and ownership
+
+One docking surface exists per Session, held in a session-scoped slot store and drawn by the `rightbar` seat; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and the last pane reseeds the guide tab.
+
+A tab type is two registrations that share one `kind`: a static definition in `ctx.sidebarRightTabs` saying which addresses the type opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types.
+
+| Package | Role |
+|---|---|
+| [`client/ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.md) | The panel and rail seats, the layout store, `ctx.sidebarRightTabs`, `ctx.sidebarRight`, the Tab domain, the guide type |
+| [`client/ui-dockkit`](../../packages/client/ui-dockkit/README.md) | Pure layout engine and React surface; an internal dependency of `ui-sidebar-right`, not a stable interface |
+| [`client/resources`](../../packages/client/resources/README.md) | `ctx.resources`, `useResource`, the protocol → value roster `ResourceProtocolMap` |
+| [`api/workspace-files`](../../packages/api/workspace-files/README.md) | Host `ctx.workspaceFiles`, the `workspaceFiles` Remote namespace, and the Client `file` resource provider |
+| [`util/workspace-path`](../../packages/util/workspace-path/README.md) | The file address grammar: `fileAddressFor`, `parseFileAddress` |
+| [`client/ui-sidebar-textpreview`](../../packages/client/ui-sidebar-textpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md) | The shipped `text` and `files` types |
+
+## Addresses
+
+Every tab is opened by an address string, and the address is the tab's content identity. Two families exist.
+
+A **resource address** is a `dsh-resource://<type>/…` URL. The host names the resource protocol — the key of `ResourceProtocolMap` — and everything after it is the protocol's own path; one scheme serves every protocol, so adding a protocol adds a host, never a scheme. The `file` protocol's path opens with its scope: `session/<sessionId>` followed by the path relative to that session's workspace root (`dsh-resource://file/session/abc/src/notes.txt`), or `absolute` followed by the absolute path with its leading `/` dropped (`dsh-resource://file/absolute/home/ys/notes.txt`, `dsh-resource://file/absolute/C:/x/y.txt` on Windows). Every id and path segment is component-encoded, with `:` kept literal for drive letters. `fileAddressFor(sessionId, cwd, path)` builds one — a relative path or an absolute path inside the workspace becomes `session`-relative, any other absolute path becomes `absolute` — and `parseFileAddress(address)` reads it back or returns `undefined` ([grammar](../../packages/util/workspace-path/README.md)).
+
+A **page address** is what the Sidebar records for a tab opened by kind rather than by resource: `sidebar://<kind>`, written by the Sidebar itself when `openTab(kind)` runs. Callers never build one — the guide and the file tree are opened as `openTab('guide')` and `openTab('files')` — and no other navigation address exists ([not built](#not-built)).
+
+Tab identity is the pair `(kind, address)`: the registry's claim uses the address verbatim as the record's `contentId`, so opening the same address through the same type finds the existing tab, and the same address through two types is two tabs.
+
+## Tab-type registration
+
+`ctx.sidebarRightTabs.register(definition)` registers one implementation of a type for the caller's lifetime and returns the disposer; the caller holds it inside its own `ctx.effect`, so an implementation lives exactly as long as the plugin that contributed it, and a second registration of the same `id` throws ([extension seats](../../packages/client/ui-sidebar-right/README.md#extension-seats)). The definition is static: no runtime hook, nothing per tab or per session.
+
+| Field | Meaning |
+|---|---|
+| `id` | The implementation's identity, unique across every registration; a package name is the natural value (`@deepseek-ai/dsh-client-ui-sidebar-files`). It is the key the body and title register under. |
+| `kind` | The type's discriminator: what its tabs are, and what `openTab` names. Not unique — an extension may take over a builtin's kind. The shipped kinds are `guide`, `text`, `files`. |
+| `patterns` | Optional resource-address globs the type recognizes; a page type opened by kind omits them. A pattern containing `:` matches the whole address (`dsh-resource://file/**`); one without matches the URL's path at any depth (`*.md`), and an address that is not a URL matches no such pattern. Matching is case-insensitive and does not hide dotfiles; the syntax is picomatch's POSIX dialect. |
+| `priority` | One of three literal bands: `extension` (the default and the highest: a type from outside the product outranks every shipped viewer), `builtin` (types shipped with the product), `fallback` (plain-content viewers anything more specific should beat). |
+| `canOpen(address)` | Optional synchronous veto of a glob match; it runs on every routing decision. |
+| `title(address)` | The chip's text, captured into the layout record when the tab opens and never rewritten. |
+| `guide` | Optional entry boxes for the guide page: `{ order, title(), description(), icon? }`. Picking a box opens the contributing type as a page; omit to stay off the page. |
+
+Routing is a ranked claim. `candidates(address)` ranks the types whose patterns match and whose `canOpen` does not veto: by band, then by the length of the longest matched pattern, then by registration order. `claim(address, kind?)` picks the first candidate, or the named `kind` outright — its globs are skipped, its `canOpen` still applies — and returns `{ kind, contentId: address, title }`. An address no type claims throws: it is a wiring mistake, not a user error.
+
+One `kind` may carry one `builtin` and one `extension` registration at the same time. The extension is the one in force for claims, `get(kind)`, `openTab(kind)`, and the guide page, and the seat finds a tab's body and title under the definition in force's `id`, so no slot priority is involved; when the extension unregisters, the builtin resumes. Every other collision on a kind, and every duplicate `id`, throws.
+
+```ts ignore-check
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+
+export const inject = ['sidebarRightTabs', 'slots']
+
+export function apply(ctx: Context): void {
+  ctx.effect(() => ctx.sidebarRightTabs.register({
+    id: '@acme/dsh-client-ui-image',
+    kind: 'image',
+    patterns: ['*.png', '*.jpg', '*.gif', '*.svg'],
+    canOpen: address => address.startsWith('dsh-resource://file/'),
+    title: address => address.slice(address.lastIndexOf('/') + 1),
+  }), 'image type')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
+    { name: 'sidebar.right.pane.tab', key: '@acme/dsh-client-ui-image' },
+    ImageBody,
+  )), 'image body')
+}
+```
+
+## Navigation: `ctx.sidebarRight`
+
+Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors.
+
+| Option | Meaning |
+|---|---|
+| `paneId` | Land a new tab in this pane; default is the active docked pane (the first docked pane while a floating pane is active). |
+| `replaceTab` | Take this tab's pane and strip slot, closing it in the same step; a floating tab lends no place, so the new tab lands as if unplaced. |
+| `revealIfOpened` | Default `true`: a tab already showing the same `(kind, address)` is focused and handed `params`. `false` opens another tab regardless. |
+| `kind` (`openResource` only) | Name the opening type instead of ranking claims; its implementation in force opens the address, and its `canOpen` still applies. |
+| `params` | Navigation parameters for the body, delivered as `navigation.params`. `openResource` types them by resource type through the merge-extensible `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); `openTab<K>` types them by kind through `SidebarRightTabParamsMap`, `undefined` for a kind that declares none; a body reads `SidebarRightNavigationParams`, the union of both. Values are JSON-shaped by convention and not validated at run time. |
+
+Placement is the caller's option, never a type's property. The conversation calls `openResource(fileAddressFor(sessionId, cwd, path))` and, from a `read` tool row, adds `{ params: { line } }` from the call's 1-based `offset`; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`; the strip's add control calls `openTab('guide', { paneId, revealIfOpened: false })`.
+
+`close(tabId)` closes a tab; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws.
+
+`focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import.
+
+## Slots and owner props
+
+The subsystem declares four slots; a tab type registers into the first, optionally the second, and any package into the others ([hierarchy](slots.md)).
+
+| Slot | Cardinality | Purpose |
+|---|---|---|
+| `sidebar.right.pane.tab` | keyed by the definition's `id`, Session scope | One tab's body. The seat dispatches a tab to the `id` of its kind's implementation in force, so the registrant receives every tab of its kind, docked or floating. A kind whose implementation registered no body renders the owner's "nothing can view this" notice. |
+| `sidebar.right.pane.tab.title` | keyed by the definition's `id`, Session scope | The chip's title, with the same owner share as the body. Optional: without an entry the chip shows the `title(address)` text captured at open time; a type with a live title reads its own store here. |
+| `sidebar.right.tab.guide` | chain, Session scope | Replaces the guide tab's contents without replacing the tab; the first non-declining entry takes the body, otherwise the shipped guide renders. |
+| `sidebar.right.tab.menu.item` | list, Session scope | Content-level actions appended after the kit's own layout actions. An item that acts must call the owner's `dismiss()`. |
+
+A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. Docked bodies are visible only while expanded and active; docked titles need only expansion; floats stay visible. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters.
+
+`navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)).
+
+## Resource model
+
+The model is documented in [Client Resources](client-resources.md); this section states what the Sidebar relies on. A resource is one address, and a resource address is a `dsh-resource://<type>/…` URL whose lower-cased host is the protocol key. The protocol's owning client package registers one provider with `ctx.resources.register(provider)` for its own lifetime; a second provider for the same protocol throws ([provide a protocol](../../packages/client/resources/README.md#provide-a-protocol)). A provider is `{ protocol, open(address, { signal }), reload?(address) }`: `open` yields `RemoteResult` frames — the current state first, one frame per later change — and stops when `signal` aborts; a failure is an `{ ok: false, error }` frame, never a throw, and a throw inside the stream is a programming error the model does not catch.
+
+`useResource<P>(address)` is a global standard prop on every slot component, whatever its scope. It returns `{ status, value, failure, reload }`: `none` when the address's protocol has no provider or the address is not a resource address (`sidebar://guide` names no resource), `loading` until the first frame, `live` with the latest `ok` value, `failed` with the latest frame's failure beside the last value. `reload()` asks the provider for a fresh frame and is a no-op without one ([read a resource](../../packages/client/resources/README.md#read-a-resource)).
+
+A resource stays open while it has a holder — a subscribed `useResource` or a `ctx.resources.pin(address, signal)`; the first holder opens the provider's stream, later holders share it and read the latest value at once, and the last release aborts the stream and discards the value. Streams carry metadata, not content: the `file` value is `{ version, bytes?, changed }`, and a consumer reads file text itself, by page, through the Workspace Files service ([lifecycle](../../packages/client/resources/README.md#lifecycle)).
+
+## Workspace Files
+
+The Host `ctx.workspaceFiles` service and the generated `workspaceFiles` Remote namespace answer for files inside the addressed Session's workspace root: `stat(path)` returns `{ absolutePath, version, bytes? }`; `read(path, { offset?, limit? })` returns one page of lines (`offset` 1-based, `limit` capped by the configured page size) as `{ …stat, offset, text, eof }`; `readBytes(path, { offset?, length? })` returns one raw byte window (`offset` 0-based, `length` capped by the configured byte limit) as base64 `{ …stat, offset, data, eof }` with no text decoding; `list(path)` returns a directory's direct children (`name`, `type: 'file' | 'directory' | 'other'`, `size?`) cut to the configured cap with `truncated` set; `changes()` yields `{ kind: 'ready' }` once subscribed, then `{ kind: 'change', change }` frames whose payload is `{ absolutePath, version }` or `{ absolutePath, absent: true }` ([README](../../packages/api/workspace-files/README.md#use-this-package)). Every call passes the same four gates — the path is inside the workspace root, symlinks are refused, page, window, and entry caps hold, `read`'s text is UTF-8 — and fails with a `workspace-file/*` error code otherwise ([failures](../../packages/api/workspace-files/README.md)).
+
+[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md) registers the `file` provider and declares `ResourceProtocolMap.file`. It sends a Session address's relative path unchanged to the Host and binds change filtering to the first successful `stat.absolutePath`. It waits for the Host's `ready` frame before stat, retaining changes delivered during that read. An absolute address uses the current Session; only its absence produces Client `workspace-file/unknown-workspace`. No Client `cwd` is required.
+
+## Shipped types
+
+- **`guide`** — `builtin`, opened as `openTab('guide')`. A centred title, one line, and one entry box per `guide` entry the registered types contributed, in `order`; picking a box opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, every new pane is seeded with one, and the strip's add control appears only while its pane has none ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)).
+- **`text`** — `fallback`, `dsh-resource://file/**`. Reads metadata through `useResource<'file'>` and the file's lines by page through `read`; honours `params.line` on every navigation; keeps pages, scroll, and wrap in its own store ([README](../../packages/client/ui-sidebar-textpreview/README.md)).
+- **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)).
+
+<a id="not-built"></a>
+## Not built
+
+- Persistence: layout state is memory-only; a reload starts every session collapsed, and no session's tabs are visible from another.
+- A read-only layout snapshot or subscription on `ctx.sidebarRight`: the service exposes operations only, and dockkit's `LayoutState`/`LayoutOp` are internal.
+- A capability-discovery array (`features`) on the service.
+- An `option` priority band: nothing lists a type without letting it claim.
+- Retitling a record: `title(address)` is captured once; a live chip comes from the title slot, not from the record.
+- Naming an implementation when opening: `openResource` names a kind at most, and the kind's implementation in force answers.
+- An address lookup on the service (`find`): a caller opens with `revealIfOpened` and lets the surface de-duplicate.
+- Navigation addresses beyond the Sidebar's own `sidebar://<kind>` bookkeeping; their grammar waits for the navigation controller as a whole.
+- A user-facing undo, a content navigation stack, tab icons, and closing restrictions ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)).

+ 137 - 0
docs/subsystems/sidebar-right.zh.md

@@ -0,0 +1,137 @@
+# 右侧 Sidebar
+
+[English](sidebar-right.md) | 中文
+
+右侧 Sidebar 是 Web Client 里每个会话一份的停靠面:会话区旁的一列 pane 与 tab,按地址寻址的内容——工作区文件、目录树、产品自带页面——在这里打开、分栏、浮出、关闭。[`dsh-client-ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.zh.md) 拥有这个面、tab 类型注册表与导航服务;[`dsh-client-ui-dockkit`](../../packages/client/ui-dockkit/README.zh.md) 是它内部的布局引擎;[`dsh-client-resources`](../../packages/client/resources/README.zh.md) 把地址变成任何组件都能读的活数据;[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 同时提供 Host 工作区文件服务与 Client `file` 资源提供者。
+
+本页是该子系统契约的参考:地址、tab 类型注册、导航服务、扩展 slot 与其 owner props、资源模型、Workspace Files 服务、内置类型,以及明确不做的事。布局引擎、frame 与停靠面如何拼在一起见 [Agent Note](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md);slot 机制见 [Slots 参考](slots.zh.md)。
+
+## 定位与归属
+
+每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar` 席位绘制;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,最后一个 pane 会重新种入引导 tab。
+
+一个 tab 类型是共用一个 `kind` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明该类型打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。
+
+| 包 | 职责 |
+|---|---|
+| [`client/ui-sidebar-right`](../../packages/client/ui-sidebar-right/README.zh.md) | 面板与栏席位、布局 store、`ctx.sidebarRightTabs`、`ctx.sidebarRight`、Tab 域、引导类型 |
+| [`client/ui-dockkit`](../../packages/client/ui-dockkit/README.zh.md) | 纯布局引擎与 React 面;`ui-sidebar-right` 的内部依赖,不是稳定接口 |
+| [`client/resources`](../../packages/client/resources/README.zh.md) | `ctx.resources`、`useResource`、协议 → 值类型的花名册 `ResourceProtocolMap` |
+| [`api/workspace-files`](../../packages/api/workspace-files/README.zh.md) | Host `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间与 Client `file` 资源提供者 |
+| [`util/workspace-path`](../../packages/util/workspace-path/README.zh.md) | 文件地址语法:`fileAddressFor`、`parseFileAddress` |
+| [`client/ui-sidebar-textpreview`](../../packages/client/ui-sidebar-textpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md) | 内置的 `text` 与 `files` 类型 |
+
+## 地址
+
+每个 tab 都由一个地址字串打开,地址就是 tab 的内容身份。地址分两族。
+
+**资源地址**是 `dsh-resource://<type>/…` 形式的 URL。host 命名资源协议——即 `ResourceProtocolMap` 的键——其后是该协议自己的路径;所有协议共用一个 scheme,新增协议只新增 host、不新增 scheme。`file` 协议的路径以其作用域开头:`session/<sessionId>` 后接相对该会话工作区根的路径(`dsh-resource://file/session/abc/src/notes.txt`),或 `absolute` 后接去掉前导 `/` 的绝对路径(`dsh-resource://file/absolute/home/ys/notes.txt`,Windows 上为 `dsh-resource://file/absolute/C:/x/y.txt`)。id 与每一段路径都做组件编码,盘符的 `:` 保留原样。`fileAddressFor(sessionId, cwd, path)` 构造地址——相对路径或工作区内的绝对路径成为 `session` 相对地址,其他绝对路径成为 `absolute` 地址——`parseFileAddress(address)` 读回各部分或返回 `undefined`([语法](../../packages/util/workspace-path/README.zh.md))。
+
+**页面地址**是 Sidebar 为按 kind(而非按资源)打开的 tab 记下的地址:`sidebar://<kind>`,由 Sidebar 自己在 `openTab(kind)` 运行时写入。调用方从不拼它——引导页与文件树以 `openTab('guide')`、`openTab('files')` 打开——此外不存在任何导航地址([不做](#not-built))。
+
+tab 身份是 `(kind, address)` 二元组:注册表的认领把地址原文用作记录的 `contentId`,因此同一地址经同一类型再次打开会找到已有 tab,同一地址经两个类型打开则是两个 tab。
+
+## Tab 类型注册
+
+`ctx.sidebarRightTabs.register(definition)` 在调用方的生命周期内注册一个类型的一份实现并返回注销器;调用方把它放在自己的 `ctx.effect` 里,因此实现与贡献它的插件同寿,同一 `id` 的第二次注册抛错([扩展席位](../../packages/client/ui-sidebar-right/README.zh.md#extension-seats))。定义是静态的:没有运行时 hook,没有按 tab 或按会话的东西。
+
+| 字段 | 含义 |
+|---|---|
+| `id` | 该实现的身份,在所有注册中唯一;包名是自然取值(`@deepseek-ai/dsh-client-ui-sidebar-files`)。正文与标题坑位按它注册。 |
+| `kind` | 类型的判别名:它的 tab 是什么,也是 `openTab` 点名的对象。不唯一——extension 可以接管 builtin 的 kind。内置 kind 为 `guide`、`text`、`files`。 |
+| `patterns` | 可选的资源地址 glob;按 kind 打开的页面类型省略。含 `:` 的模式匹配整个地址(`dsh-resource://file/**`);不含的匹配 URL 的路径部分且任意深度都中(`*.md`),不是 URL 的地址不会命中此类模式。匹配不分大小写、不隐藏 dotfile;语法为 picomatch 的 POSIX 方言。 |
+| `priority` | 三档字面量之一:`extension`(缺省且最高:产品之外的类型压过所有内置查看器)、`builtin`(随产品发布的类型)、`fallback`(任何更具体的类型都应压过的纯内容查看器)。 |
+| `canOpen(address)` | 可选的同步否决,对 glob 命中生效;每次路由决策都会调用。 |
+| `title(address)` | chip 文本,在 tab 打开时捕获进布局记录,之后不再改写。 |
+| `guide` | 可选的引导页入口框:`{ order, title(), description(), icon? }`。点一框即把贡献它的类型作为页面打开;省略即不上引导页。 |
+
+路由是一次排序认领。`candidates(address)` 对模式命中且未被 `canOpen` 否决的类型排序:先按档,再按最长命中模式的长度,最后按注册顺序。`claim(address, kind?)` 取第一个候选,或直接用点名的 `kind`——跳过它的 glob,但 `canOpen` 仍生效——返回 `{ kind, contentId: address, title }`。没有任何类型认领的地址会抛错:这是接线错误,不是用户错误。
+
+同一个 `kind` 可同时携带一个 `builtin` 与一个 `extension` 注册。extension 在认领、`get(kind)`、`openTab(kind)` 与引导页上生效,席位按生效定义的 `id` 找 tab 的正文与标题,不涉及任何 slot 优先级;extension 注销后 builtin 恢复。kind 上的其它任何撞名以及任何重复的 `id` 都抛错。
+
+```ts ignore-check
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+
+export const inject = ['sidebarRightTabs', 'slots']
+
+export function apply(ctx: Context): void {
+  ctx.effect(() => ctx.sidebarRightTabs.register({
+    id: '@acme/dsh-client-ui-image',
+    kind: 'image',
+    patterns: ['*.png', '*.jpg', '*.gif', '*.svg'],
+    canOpen: address => address.startsWith('dsh-resource://file/'),
+    title: address => address.slice(address.lastIndexOf('/') + 1),
+  }), 'image type')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register(
+    { name: 'sidebar.right.pane.tab', key: '@acme/dsh-client-ui-image' },
+    ImageBody,
+  )), 'image body')
+}
+```
+
+## 导航:`ctx.sidebarRight`
+
+两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。
+
+| 选项 | 含义 |
+|---|---|
+| `paneId` | 新 tab 落到这个 pane;缺省为活动的停靠 pane(活动的是浮窗时取第一个停靠 pane)。 |
+| `replaceTab` | 占用这个 tab 的 pane 与条上位置,并在同一步关闭它;浮窗里的 tab 让不出位置,新 tab 按未指定位置落位。 |
+| `revealIfOpened` | 缺省 `true`:已显示同一 `(kind, address)` 的 tab 被聚焦并收到 `params`。`false` 则无论如何再开一个。 |
+| `kind`(仅 `openResource`) | 点名打开类型而不排候选;该 kind 的生效实现打开地址,它的 `canOpen` 仍生效。 |
+| `params` | 给正文的导航参数,作为 `navigation.params` 送达。`openResource` 按资源类型经声明合并表 `SidebarRightResourceParamsMap` 定型(文本预览声明 `{ line?: number }`);`openTab<K>` 按 kind 经 `SidebarRightTabParamsMap` 定型,未声明的 kind 为 `undefined`;正文读到的是二者联合 `SidebarRightNavigationParams`。值按约定为 JSON 形状,运行时不校验。 |
+
+落位是调用方的选项,从不是类型的属性。会话区调 `openResource(fileAddressFor(sessionId, cwd, path))`,`read` 工具行另加 `{ params: { line } }`(来自调用的 1 起 `offset`);引导页入口框调 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的行调 `tab.actions.openResource(address)`;tab 条的新增控件调 `openTab('guide', { paneId, revealIfOpened: false })`。
+
+`close(tabId)` 关闭一个 tab;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。
+
+`focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。
+
+## Slot 与 owner props
+
+本子系统声明四个 slot;tab 类型注册进第一个,可选地注册第二个,任何包都可注册进其余两个([层级](slots.zh.md))。
+
+| Slot | Cardinality | 用途 |
+|---|---|---|
+| `sidebar.right.pane.tab` | 按定义的 `id` keyed,会话作用域 | 一个 tab 的正文。席位把 tab 分发到其 kind 生效实现的 `id`,因此注册者收到该 kind 的每个 tab,停靠或浮窗。实现没有注册正文的 kind 渲染 owner 的「无法查看此内容」提示。 |
+| `sidebar.right.pane.tab.title` | 按定义的 `id` keyed,会话作用域 | chip 的标题,owner share 与正文相同。可选:没有条目时 chip 显示打开时捕获的 `title(address)` 文本;有活标题的类型在此读自己的 store。 |
+| `sidebar.right.tab.guide` | chain,会话作用域 | 替换引导 tab 的内容而不替换 tab;第一个不拒绝的条目接管正文,否则渲染自带引导。 |
+| `sidebar.right.tab.menu.item` | list,会话作用域 | 追加在 kit 自身布局动作之后的内容级动作。执行了动作的条目必须调用 owner 的 `dismiss()`。 |
+
+正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。停靠正文仅在展开且活跃时可见;停靠标题只要求展开;浮窗保持可见。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。
+
+`navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。
+
+## 资源模型
+
+模型本身见[客户端资源](client-resources.zh.md);本节只写 Sidebar 依赖的部分。一份资源是一个地址,资源地址是 `dsh-resource://<type>/…` 形式的 URL,小写 host 即协议键。协议所属的客户端包用 `ctx.resources.register(provider)` 在自身生命周期内注册唯一的提供方;同一协议的第二个提供方抛错([提供协议](../../packages/client/resources/README.zh.md#provide-a-protocol))。提供方是 `{ protocol, open(address, { signal }), reload?(address) }`:`open` 产出 `RemoteResult` 帧——首帧是当前状态,之后每次变化一帧——并在 `signal` 中止时停下;失败是 `{ ok: false, error }` 帧而不是抛错,流里抛出的东西是编程错误,模型不捕获。
+
+`useResource<P>(address)` 是每个 slot 组件都有的全局标准 prop,不论作用域。它返回 `{ status, value, failure, reload }`:地址协议没有提供方或地址不是资源地址(`sidebar://guide` 不指向资源)时为 `none`,首帧之前为 `loading`,`live` 携带最新 `ok` 值,`failed` 在最后一个值旁携带最新帧的失败。`reload()` 请提供方给一个新帧,没有提供方时是空操作([读取资源](../../packages/client/resources/README.zh.md#read-a-resource))。
+
+资源有持有者就保持打开——订阅中的 `useResource` 或一次 `ctx.resources.pin(address, signal)`;第一个持有者打开提供方的流,之后的持有者共享它并立刻读到最新值,最后一个释放时中止流并丢弃值。流只推元数据不推内容:`file` 的值是 `{ version, bytes?, changed }`,消费方自己经 Workspace Files 服务按页读文件文本([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。
+
+## Workspace Files
+
+Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名空间负责所寻址会话工作区根之内的文件:`stat(path)` 返回 `{ absolutePath, version, bytes? }`;`read(path, { offset?, limit? })` 返回一页行(`offset` 1 起,`limit` 受配置页长限制),形如 `{ …stat, offset, text, eof }`;`readBytes(path, { offset?, length? })` 返回一个原始字节窗口(`offset` 0 起,`length` 受配置字节上限限制),形如 base64 的 `{ …stat, offset, data, eof }`、不做文本解码;`list(path)` 返回目录的直接子项(`name`、`type: 'file' | 'directory' | 'other'`、`size?`),按配置上限截断并置 `truncated`;`changes()` 在订阅就绪后产出 `{ kind: 'ready' }`,随后产出 `{ kind: 'change', change }` 帧,其载荷为 `{ absolutePath, version }` 或 `{ absolutePath, absent: true }`([README](../../packages/api/workspace-files/README.zh.md#use-this-package))。每次调用都过同样四关——路径在工作区根内、拒绝符号链接、页、窗口与条目上限、`read` 的 UTF-8 文本——否则以 `workspace-file/*` 错误码失败([失败](../../packages/api/workspace-files/README.zh.md))。
+
+[`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md) 注册 `file` 提供方并声明 `ResourceProtocolMap.file`。它把 Session 地址的相对路径原样发送给 Host,按首次成功的 `stat.absolutePath` 绑定变更过滤。它在 stat 前等待 Host 的 `ready` 帧,并保留读取期间到达的变更。绝对地址使用当前 Session;只有缺少当前 Session 时才产生 Client `workspace-file/unknown-workspace`。Client 不需要 `cwd`。
+
+## 内置类型
+
+- **`guide`**——`builtin`,以 `openTab('guide')` 打开。居中标题、一行说明,以及已注册类型贡献的每个 `guide` 入口一框、按 `order` 排列;点一框即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,每个新 pane 都种入一个,tab 条的新增控件只在本 pane 没有引导时出现([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。
+- **`text`**——`fallback`,`dsh-resource://file/**`。经 `useResource<'file'>` 读元数据、经 `read` 按页读文件行;每次导航都响应 `params.line`;页、滚动与换行放在自己的 store 里([README](../../packages/client/ui-sidebar-textpreview/README.zh.md))。
+- **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。
+
+<a id="not-built"></a>
+## 不做
+
+- 持久化:布局状态只在内存里;刷新后每个会话从折叠开始,任何会话的 tab 都不会出现在另一个会话里。
+- `ctx.sidebarRight` 上的只读布局快照或订阅:服务只暴露操作,dockkit 的 `LayoutState`/`LayoutOp` 是内部的。
+- 服务上的能力探测数组(`features`)。
+- `option` 优先级档:没有「只列出、不许认领」的类型。
+- 改写记录的标题:`title(address)` 只捕获一次;活的 chip 来自标题 slot,而不是记录。
+- 打开时点名某个实现:`openResource` 最多点名一个 kind,由该 kind 的生效实现应答。
+- 服务上的地址查找(`find`):调用方用 `revealIfOpened` 打开,由停靠面去重。
+- Sidebar 自身 `sidebar://<kind>` 记账之外的导航地址;其语法等导航控制器整体做时再定。
+- 面向用户的撤销、内容导航栈、tab 图标与关闭限制([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred))。

+ 133 - 0
packages/client/ui-sidebar-right/README.md

@@ -0,0 +1,133 @@
+---
+description: "The right Sidebar of the dsh web client: one docking surface per session, two presentations, the navigation controller ctx.sidebarRight, the tab-type registry ctx.sidebarRightTabs, and the Tab domain."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-right
+
+English | [中文](README.zh.md)
+
+## Summary
+
+The right Sidebar: where the docking kit meets this product. It holds one docking surface per session, draws it as one edge-anchored panel in the frame's right column in either of two presentations, puts the expand button in the conversation header, and owns the navigation controller (`ctx.sidebarRight`), the tab-type registry (`ctx.sidebarRightTabs`), and the Tab domain that tells each open tab how it was navigated to and how long it lives.
+
+## Table of Contents
+
+- [What lives here, and what does not](#what-lives-here-and-what-does-not)
+- [Presentations](#presentations)
+- [The expand button](#the-expand-button)
+- [State](#state)
+- [Extension seats](#extension-seats)
+- [`ctx.sidebarRight`](#ctxsidebarright)
+- [The Tab domain](#the-tab-domain)
+- [The guide](#the-guide)
+- [Copy](#copy)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="what-lives-here-and-what-does-not"></a>
+## What lives here, and what does not
+
+The layout itself — the split tree, its operations, the drag gestures, the floating panels — belongs to `@deepseek-ai/dsh-client-ui-dockkit` and stays host-agnostic. This package supplies everything that kit refuses to know: the product's copy, what a tab's `kind` means, which tab a fresh pane is seeded with, where the surface is mounted, and how other plugins reach it.
+
+<a id="presentations"></a>
+## Presentations
+
+Normal and fullscreen presentations share the same content tree, so switching does not remount tabs. The normal panel anchors to the right column; fullscreen covers the viewport while retaining the wide-screen columns underneath. Opening below 768px uses fullscreen automatically; leaving fullscreen on a narrow viewport closes the panel, and widening does not reopen a closed panel.
+
+| Mode | The track | The panel |
+|---|---|---|
+| `push` (default) | Panel width: the conversation makes room | In the track; its left edge and the conversation's right edge travel together, on the frame's own curve |
+| `fullscreen` | Retains the wide-screen normal track; automatic narrow-screen fullscreen takes no track | Covers the entire viewport |
+
+The seat reports presentation through `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()`; the frame does not inject this package. Switching fullscreen on a wide viewport leaves the center width unchanged, and the width handle appears only in expanded normal mode. Independent floating panels and `float`/`dock` operations remain available.
+
+The panel has no header row. Its two controls — the presentation switch and the collapse button — ride the kit's chrome seat at the far end of the top-right pane's tab strip, so the strip is the panel's whole top edge. Each strip reads, left to right: the tabs as capsules with their own close, the add control (drawn only while that pane holds no guide tab; it opens the guide there through `ctx.sidebarRight.openTab`), the pane's split control, and in the top-right pane the two panel controls. Only the chips give way in a narrow pane; the controls after them never shrink or clip.
+
+<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.
+
+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.
+
+<a id="state"></a>
+## State
+
+One `SurfaceState` per session id — the layout, its recorded sequence, and how many ids it has minted — held in a store declared at the registration. Every action follows the same shape: mint the ids the intent needs, ask a kit planner which operations carry it out, record them, then assign the session's whole surface back. No action edits a layout in place, which is what keeps the kit's pure functions the only thing that computes one.
+
+Carrying the mint counter in the surface is what makes a recorded sequence replayable: operations embed the ids they create, so replaying from the same initial state reproduces the same tree. Every action records one history entry, however many operations it needed. Expanding, collapsing, and switching presentation are recorded too.
+
+After every action the kit's settle planner keeps the surface populated: a docked pane whose last tab was closed, moved out, or floated is merged away, and when only the root pane is left and it is empty, the guide tab is reseeded. There is always at least one tab, and never an empty pane — so there is no separate "close pane" gesture.
+
+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>
+## Extension seats
+
+A tab type registers in two stages, and the shipped guide type goes through exactly the same public path a type from another package does (`ui-sidebar-textpreview` is the live proof). Both stages sit inside the type's own `ctx.effect`, so the registration lives exactly as long as the plugin that made it.
+
+1. **The type** — `ctx.sidebarRightTabs.register({ id, kind, patterns?, priority?, canOpen?, title, guide? })`, a static declaration with no runtime hook, returning a disposer. `id` is this implementation's identity in the tab system, unique across every registration (a package name is the natural value; the shipped guide is `@deepseek-ai/dsh-client-ui-sidebar-right/guide`): a kind is not unique once an extension may take a builtin's over, so the implementation names itself, and a second registration of an `id` throws. A resource type names `patterns`, globs over `dsh-resource://` addresses: one containing `:` matches the whole address (`dsh-resource://file/**`); one without matches the URI's path at any depth, ignoring case (`*.md`), and an address that is not a URI matches no such pattern. A page type — the guide, a file tree — names none and is opened by kind. `canOpen(address)` vetoes a match. `title(address)` is the tab chip's text, captured when the tab opens. `guide` lists entry boxes for the guide page; picking one opens the contributing type as a page. A `kind` carries at most one `builtin` and one `extension` registration (the extension is in force; the builtin resumes when it leaves); any other collision on a kind throws. The `id` is also the key the type's body and title register under, so an extension and the builtin it takes over hold distinct cells and the seat renders the one in force.
+2. **The body** — `ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` reads `{ sidebar, panel, tab }` through the framework-injected `useTabInfo()`. `sidebar` supplies expansion and fullscreen information; `panel.id` identifies its pane; `tab` contains the record fields, `visible`, `navigation`, `signal`, and `actions`. These are not parallel owner props; the type's own store still uses `useStore`/`actions`. Optional title registrations and guide replacements share this hook; an absent title registration uses the text captured at open time.
+
+Which type opens a resource follows the editor-resolver convention: the types whose `patterns` match are ranked by `priority` band — `extension` (a type from outside the product, the highest, and the default when none is named), `builtin`, `fallback` (plain viewers anything more specific should beat) — then by the length of the matched pattern, then by registration order; `canOpen` removes a candidate. The bands are string literals so a type in another package needs no runtime import from here. `candidates(address)` returns the ranking, `claim(address, kind?)` the decision; naming a `kind` skips its globs but keeps its `canOpen`.
+
+Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) replaces the guide tab's body without replacing the tab, and `sidebar.right.tab.menu.item` (list) appends content-level actions to a tab's menu after the kit's own layout actions. No seat exists for pane-level actions or for collapsed-state controls yet, because nothing needs one.
+
+<a id="ctxsidebarright"></a>
+## `ctx.sidebarRight`
+
+`openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource://<type>/…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a tab already showing the same (kind, contentId) is focused unless `revealIfOpened: false`; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error.
+
+`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws.
+
+<a id="the-tab-domain"></a>
+## The Tab domain
+
+The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store and reconciles records on its commits. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles, and floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller.
+
+<a id="the-guide"></a>
+## The guide
+
+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 seeds a guide through the kit's factory, one 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.
+
+<a id="copy"></a>
+## Copy
+
+Every string in the column comes from the `sidebarRight` locale namespace, including the kit's accessible names. A tab's title is fixed when the tab is minted; a type's display name follows the current language.
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as the package is a browser-side UI plugin layer that registers nothing model-facing.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **Memory-only.** Nothing is persisted; a reload starts every session collapsed.
+- **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.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>
+
+**Runtime invariant:** No companion is published. The two services (`sidebarRight`, `sidebarRightTabs`) are provided through `ctx.reflect.provide` inside one effect and torn down with it; the seat's binding and the Tab domain's occurrence lifetimes are asserted directly by this package's specs, and no independent observation exists to diverge from them.

+ 133 - 0
packages/client/ui-sidebar-right/README.zh.md

@@ -0,0 +1,133 @@
+---
+description: "dsh Web 客户端的右侧 Sidebar:每会话一个停靠面、两种呈现形态、导航控制器 ctx.sidebarRight、tab 类型注册表 ctx.sidebarRightTabs 与 Tab 域。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-right
+
+[English](README.md) | 中文
+
+## 概述
+
+右侧 Sidebar:停靠套件与本产品相遇的地方。它为每个会话持有一个停靠面,以两种呈现形态之一把它画成贴靠框架右列边缘的一块面板,把展开按钮放进会话 header,并拥有导航控制器(`ctx.sidebarRight`)、tab 类型注册表(`ctx.sidebarRightTabs`),以及告诉每个已开 tab 它是如何被导航到、能活多久的 Tab 域。
+
+## 目录
+
+- [什么住在这里,什么不住](#what-lives-here-and-what-does-not)
+- [呈现形态](#presentations)
+- [展开按钮](#the-expand-button)
+- [状态](#state)
+- [扩展席位](#extension-seats)
+- [`ctx.sidebarRight`](#ctxsidebarright)
+- [Tab 域](#the-tab-domain)
+- [引导页](#the-guide)
+- [文案](#copy)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="what-lives-here-and-what-does-not"></a>
+## 什么住在这里,什么不住
+
+布局本身——分裂树、它的操作、拖拽手势、浮窗——属于 `@deepseek-ai/dsh-client-ui-dockkit`,并保持与宿主无关。本包提供套件拒绝知道的一切:产品文案、tab 的 `kind` 是什么意思、新格用哪个 tab 播种、停靠面挂在哪里、其它插件如何触达它。
+
+<a id="presentations"></a>
+## 呈现形态
+
+普通与全屏共用同一棵面板内容树,切换不会重挂载Tab。普通面板贴靠右栏;全屏面板覆盖窗口并保留宽屏底层列宽。窗口低于768px时打开右栏自动全屏;窄屏退出全屏会收起右栏,变宽不重新打开已关闭的右栏。
+
+| 形态 | 轨道 | 面板 |
+|---|---|---|
+| `push`(默认) | 面板宽度:会话区让出空间 | 在轨道内;它的左缘与会话区的右缘沿框架自己的曲线一起移动 |
+| `fullscreen` | 保留宽屏普通轨道;窄屏自动全屏不占轨道 | 覆盖整个窗口 |
+
+席位通过 `ctx.layout.openRightbar(track, fullscreen)` / `closeRightbar()` 报告呈现,框架不注入本包。宽屏切换全屏不改变中栏宽度;宽度拖拽区只在普通展开态显示。独立浮窗及 `float`/`dock` 操作保持可用。
+
+面板没有标题行。它的两个控件——形态切换与折叠按钮——搭在套件 chrome 席位上,位于右上格 tab 条的最末端,因此 tab 条就是面板的整条上边。每条 tab 条从左到右读作:作为胶囊、各带关闭按钮的 tab,添加控件(只在该格没有引导 tab 时绘制;它通过 `ctx.sidebarRight.openTab` 在该格打开引导页),该格的分栏控件,以及右上格里的两个面板控件。窄格里只有 chip 让位;其后的控件从不收缩或被裁切。
+
+<a id="the-expand-button"></a>
+## 展开按钮
+
+面板隐藏时,会话 header 角落席位里的一个按钮(`conversation.session.header.corner`,在工具组右缘之外,与 Session 日志控件齐平)是回去的路。它的图形是左侧 sidebar 折叠图标的镜像。它与面板共用一个存储(slot 运行时允许两个同作用域席位共用一个 handle);面板显示时它渲染一个同尺寸的占位,因此角落保持宽度,header 行里没有东西会移动。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。
+
+面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。
+
+<a id="state"></a>
+## 状态
+
+每个会话 id 一个 `SurfaceState`——布局、它记录的序列、以及它已铸造的 id 数——保存在注册时声明的存储里。每个动作都遵循同一形态:铸造意图需要的 id,向套件 planner 询问由哪些操作承载,记录它们,然后把该会话的整个停靠面赋回去。没有任何动作就地编辑布局,这正是让套件的纯函数成为唯一计算布局之处的原因。
+
+把铸造计数器带在停靠面里,是记录的序列可回放的原因:操作内嵌它们创建的 id,因此从同一初始状态回放能复现同一棵树。每个动作记录一条历史,无论它需要多少操作。展开、折叠与切换形态也都被记录。
+
+每个动作之后,套件的 settle planner 保证停靠面有内容:最后一个 tab 被关闭、搬走或浮出的停靠格会被并掉;只剩根格且它为空时,重新播种引导 tab。永远至少有一个 tab,永远没有空格——因此没有单独的「关闭格」手势。
+
+状态只在内存中。刷新会让每个会话回到折叠的默认态;切换会话则让每个停靠面留在原处。
+
+<a id="extension-seats"></a>
+## 扩展席位
+
+tab 类型分两阶段注册,随包发布的引导类型走的正是别的包的类型走的同一条公开路径(`ui-sidebar-textpreview` 是活的证明)。两个阶段都在类型自己的 `ctx.effect` 里,因此注册与创建它的插件同生共死。
+
+1. **类型**——`ctx.sidebarRightTabs.register({ id, kind, patterns?, priority?, canOpen?, title, guide? })`,一份没有运行时钩子的静态声明,返回 disposer。`id` 是这个实现在 tab 系统里的身份,在全部注册中唯一(包名是天然取值;随包引导页是 `@deepseek-ai/dsh-client-ui-sidebar-right/guide`):一旦 extension 可以接管 builtin 的 kind,kind 就不再唯一,所以实现要自己命名,同一 `id` 的第二次注册会 throw。资源类型给出 `patterns`,即作用于 `dsh-resource://` 地址的 glob:含 `:` 的匹配整个地址(`dsh-resource://file/**`);不含的匹配 URI 路径的任意深度且忽略大小写(`*.md`),不是 URI 的地址不匹配任何这类模式。页类型——引导页、文件树——不给出模式,按 kind 打开。`canOpen(address)` 否决一次命中。`title(address)` 是 tab chip 的文字,在 tab 打开时捕获。`guide` 列出引导页的入口框;选中一个即把贡献它的类型作为页打开。一个 `kind` 最多承载一份 `builtin` 与一份 `extension` 注册(extension 生效;它离开后 builtin 恢复);kind 上的其它任何撞名都 throw。`id` 同时也是该类型正文与标题注册时用的 key,因此 extension 与它接管的 builtin 各占一个格位,席位渲染生效的那个。
+2. **正文**——`ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` 通过框架注入的 `useTabInfo()` 读取 `{ sidebar, panel, tab }`。`sidebar` 提供开合与全屏信息,`panel.id` 命名所在格,`tab` 包含原记录字段、`visible`、`navigation`、`signal` 和 `actions`。这些字段不再作为平铺owner props传入;类型自己的store仍使用 `useStore`/`actions`。可选标题注册及引导替换共享该hook;未注册标题时使用打开时保存的文本。
+
+由哪个类型打开资源遵循编辑器解析器的惯例:`patterns` 命中的类型先按 `priority` 档排序——`extension`(产品外的类型,最高档,也是未命名时的默认)、`builtin`、`fallback`(任何更具体的类型都应胜过的通用查看器)——再按命中模式的长度,再按注册顺序;`canOpen` 会剔除候选。各档是字符串字面量,因此别的包里的类型不需要从这里做运行时导入。`candidates(address)` 返回排序,`claim(address, kind?)` 返回决定;指定 `kind` 时跳过它的 glob 但保留它的 `canOpen`。
+
+另有两个席位扩展已有之物:`sidebar.right.tab.guide`(chain)替换引导 tab 的正文而不替换 tab,`sidebar.right.tab.menu.item`(list)在套件自己的布局动作之后向 tab 菜单追加内容级动作。目前没有面向格级动作或折叠态控件的席位,因为还没有东西需要它。
+
+<a id="ctxsidebarright"></a>
+## `ctx.sidebarRight`
+
+`openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource://<type>/…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的 tab 被聚焦,除非 `revealIfOpened: false`;否则新 tab 落到 `options.replaceTab` 所在的格与位置(并关掉那个 tab),再退而落到 `options.paneId`,再退而落到活跃停靠格;面板展开,因为用户看不到的内容不算打开。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。
+
+`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;形态切换是面板自己的控件,不属于这个接口。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。
+
+<a id="the-tab-domain"></a>
+## Tab 域
+
+Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私有装配回调收养各会话的store,并在每次提交时对齐记录。记录消失或插件卸载才中止signal,收起和切会话不销毁记录;undo恢复的是新occurrence。`useTabInfo()` 组合框架绑定的store与导航hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,浮窗不受整栏收起影响。`adopt` 不在公开控制器上。
+
+<a id="the-guide"></a>
+## 引导页
+
+引导 tab 是一个居中标题、其下一行说明,以及各已注册类型贡献的每个 `guide` 条目一个入口框,按 `order` 排列。选中一个框会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏或被清空的根格通过套件的工厂播种一个引导页,每个新格一个。普通的 `openTab('guide')` 保留每次打开都有的整树聚焦。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。
+
+<a id="copy"></a>
+## 文案
+
+该列里的每个字符串都来自 `sidebarRight` 语言命名空间,包括套件的无障碍名称。tab 的标题在 tab 铸造时固定;类型的显示名跟随当前语言。
+
+<a id="model-experience"></a>
+## 模型体验
+
+None, as the package is a browser-side UI plugin layer that registers nothing model-facing.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- **只在内存中。** 不持久化任何东西;刷新让每个会话从折叠态开始。
+- **没有会话就没有停靠面。** 状态按会话 id 键控,因此 hero 画面右侧什么都不显示。
+- **硬编码的层叠。** 面板与浮窗宿主使用固定的 z-index 值,因为客户端还没有 z-index token 层。
+- **未暴露撤销。** 记录的序列只能通过 `@internal` 服务方法步进;产品控件是有意缺席的。
+- **引导页文案是草稿**,等待产品评审;文字住在 `locales.ts`。
+- **标题在打开时固定。** 类型的 `title(address)` 被捕获进记录;会变的标题只来自可选的标题席位。
+- **没有内容导航栈。** 后退回放的是布局操作;编辑器式的「已访问内容」前进/后退尚未构建。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作上下文——点击展开</summary>
+
+无。
+
+</details>
+
+**运行时不变量:** 不发布 companion。两个服务(`sidebarRight`、`sidebarRightTabs`)在同一个 effect 内经 `ctx.reflect.provide` 提供并随之拆除;席位绑定与 Tab 域 occurrence 的生命周期由本包的 spec 直接断言,不存在会与之分歧的独立观察。

+ 79 - 0
packages/client/ui-sidebar-right/package.json

@@ -0,0 +1,79 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-sidebar-right",
+  "description": "Right Sidebar: the docking surface's session-bound state, its panel and header expand control, and the navigation service over it",
+  "version": "0.1.3-alpha.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/ui-sidebar-right"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "inject": [
+        "@deepseek-ai/dsh-api-session-controller",
+        "@deepseek-ai/dsh-client-resources",
+        "@deepseek-ai/dsh-client-ui-conversation",
+        "@deepseek-ai/dsh-client-ui-layout",
+        "@deepseek-ai/dsh-client-ui-session"
+      ],
+      "platform": "web"
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "dependencies": {
+    "clsx": "^2.0.0",
+    "picomatch": "^4.0.4",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-resources": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-layout": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@testing-library/react": "^16.1.0",
+    "@types/picomatch": "^4.0.2",
+    "@types/react": "~18.3.1",
+    "@types/react-dom": "~18.3.0"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 52 - 0
packages/client/ui-sidebar-right/src/client/contract/params.ts

@@ -0,0 +1,52 @@
+/**
+ * Navigation parameters, typed by what is being opened.
+ *
+ * Two declaration-merged maps. `SidebarRightResourceParamsMap` is keyed by
+ * resource type — the segment after `dsh-resource://` — and filled by the
+ * package that owns that type (the `file` provider adds `file: { line?: number }`);
+ * `SidebarRightTabParamsMap` is keyed by tab kind and filled by a page type that
+ * takes parameters (neither shipped page does). Values are JSON-shaped by
+ * convention; nothing validates them at run time, because caller and body meet
+ * at a typed same-process boundary. A body narrows `navigation.params` by the
+ * scheme and type of `navigation.address`.
+ *
+ * The unions below are spelled as indexed accesses over a record rather than as
+ * `A | B`: in a program where no package has augmented a map, both sides of such
+ * a union resolve to `undefined`, which the type-aware lint reads as a duplicated
+ * constituent. The indexed access names the same union without the pair.
+ */
+
+/** The values of a record, as one union. */
+type ValuesOf<T> = T[keyof T]
+
+/** Resource type → the parameters a resource of that type accepts. Merge-extensible. */
+export interface SidebarRightResourceParamsMap {}
+
+/** What `openResource` accepts as `params`: a declared resource type's parameters, or `undefined` for none. */
+export type SidebarRightResourceParams = ValuesOf<{
+  declared: SidebarRightResourceParamsMap[keyof SidebarRightResourceParamsMap]
+  none: undefined
+}>
+
+/** Tab kind → the parameters a page of that kind accepts. Merge-extensible. */
+export interface SidebarRightTabParamsMap {}
+
+/**
+ * What `openTab(kind)` accepts as `params` for one kind: its declared parameters,
+ * or `undefined` for none; a kind that declares none accepts only `undefined`.
+ */
+export type SidebarRightTabParamsFor<K extends string> =
+  | (K extends keyof SidebarRightTabParamsMap ? SidebarRightTabParamsMap[K] : never)
+  | undefined
+
+/** Every declared page kind's parameters, or `undefined` for none. */
+export type SidebarRightTabParams = ValuesOf<{
+  declared: SidebarRightTabParamsMap[keyof SidebarRightTabParamsMap]
+  none: undefined
+}>
+
+/** What a body may find in `navigation.params`: either map's values, or `undefined` when the opener gave none. */
+export type SidebarRightNavigationParams = ValuesOf<{
+  resource: SidebarRightResourceParams
+  tab: SidebarRightTabParams
+}>

+ 41 - 0
packages/client/ui-sidebar-right/src/client/contract/seed.ts

@@ -0,0 +1,41 @@
+/**
+ * The guide tab's identity, the page-address scheme, and the seed factory.
+ *
+ * These live in the contract because two sides need them and neither may read
+ * the other: the store seeds every new pane with a guide tab, and the guide
+ * domain registers the type under the same kind.
+ *
+ * The docking kit treats `kind` as opaque, so these strings mean something only
+ * here and in the registry. Both types go through the same two stages any other
+ * type would use — the guide is not special in the machinery, only in being
+ * always available.
+ */
+import type { TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
+
+/** The guide tab's kind. */
+export const GUIDE_KIND = 'guide'
+
+/**
+ * The address a page tab is recorded under: `sidebar://<kind>`. The scheme is
+ * this package's bookkeeping for `openTab`, spelled here and nowhere else; a
+ * caller names the kind and never sees or composes the address.
+ * @param kind - the page type's kind.
+ * @returns the page's address.
+ */
+export function pageAddress(kind: string): string {
+  return `sidebar://${kind}`
+}
+
+/**
+ * Build the guide tab a new pane is seeded with.
+ *
+ * The title is captured at mint time because it goes into the surface's
+ * operation sequence, which records what happened and must not change meaning
+ * later. A language change relabels the type, not tabs already open.
+ * @param id - tab id minted by the caller.
+ * @param title - the guide type's display name at mint time.
+ * @returns the guide tab record.
+ */
+export function makeGuideTab(id: TabId, title: string): TabRecord {
+  return { id, kind: GUIDE_KIND, contentId: pageAddress(GUIDE_KIND), title }
+}

+ 170 - 0
packages/client/ui-sidebar-right/src/client/contract/slots.ts

@@ -0,0 +1,170 @@
+/**
+ * The right Sidebar's extension seats and its copy namespace.
+ *
+ * Four seats, each with a different reason to exist:
+ * - `sidebar.right.pane.tab` is how a tab type contributes a body. It is keyed by
+ *   the type definition's `id`, so adding a type is a registration, never an
+ *   edit here. The key domain stays the open string space because a tab type may
+ *   ship from outside this repository.
+ * - `sidebar.right.pane.tab.title` is the same dispatch for what the chip shows
+ *   as the tab's title. Registering is optional: without an entry the chip shows
+ *   the title the registry captured when the tab opened.
+ * - `sidebar.right.tab.guide` lets a product replace the guide tab's contents
+ *   without replacing the tab. It is a chain because the replacement decides for
+ *   itself whether it applies, and the shipped guide is the owner's fallback.
+ * - `sidebar.right.tab.menu.item` extends a tab's actions menu. The kit owns the
+ *   actions that are gestures on the layout itself; this seat is for actions that
+ *   mean something about the tab's content.
+ *
+ * TYPE HOME RATIONALE: this package declares all four at runtime, and anything
+ * registering into one already depends on it for the declaration. The types
+ * therefore live with their declarer.
+ */
+import type {} from '@deepseek-ai/dsh-client-ui-slots'
+// The locale plugin's own merge carries the shared `common` vocabulary that the
+// lookup chain consults after this namespace misses.
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type { PaneId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots'
+import type { TabHookContext } from '../tab-info.ts'
+import type { SidebarRightKey } from '../locales.ts'
+import type { SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightTabParamsFor } from './params.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Right-Sidebar chrome, docking-kit vocabulary, and guide copy. */
+    sidebarRight: SidebarRightKey
+  }
+
+  interface SlotMap {
+    /**
+     * One tab's body, dispatched with the `id` of the type in force for
+     * `tab.kind`. A tab type registers here under its definition's `id` and
+     * receives every tab of that kind, in every pane, docked or floating. A kind
+     * with no type in force renders the owner's "nothing can view this" notice
+     * rather than an empty pane.
+     */
+    'sidebar.right.pane.tab': {
+      kind: 'keyed'
+      scope: 'session'
+      hookContext: TabHookContext
+      inject: SidebarRightTabInjected
+    }
+    /**
+     * A tab's title as its chip (and a floating panel's header) shows it,
+     * dispatched with the same key and information hook as the body. A type with a
+     * live title — a terminal named after its shell, a chat after its first
+     * line — registers here and reads its own store; one without registers
+     * nothing and the chip shows the registry's `title(address)` text captured
+     * at open time.
+     */
+    'sidebar.right.pane.tab.title': {
+      kind: 'keyed'
+      scope: 'session'
+      hookContext: TabHookContext
+      inject: SidebarRightTabInjected
+    }
+    /**
+     * The guide tab's body. Selectors run in chain order and the first
+     * non-declining entry replaces the shipped guide entirely; with no entry, or
+     * with every entry declining, the shipped guide renders.
+     */
+    'sidebar.right.tab.guide': {
+      kind: 'chain'
+      scope: 'session'
+      hookContext: UseSidebarRightTabInfo
+      inject: { hooks: { tabInfo: SlotHookFactory<'sidebar.right.tab.guide', UseSidebarRightTabInfo> } }
+    }
+    /**
+     * Extra items at the end of one tab's actions menu, in registration order.
+     * Entries decide their own visibility from the tab they are given. Without a
+     * registrant the menu shows only the kit's own layout actions.
+     */
+    'sidebar.right.tab.menu.item': { kind: 'list'; scope: 'session'; owner: SidebarRightTabMenuOwnerProps }
+  }
+}
+
+/** Where a tab was last navigated to: what the `open` that created or revealed it carried. */
+export interface SidebarRightTabNavigation {
+  /** The address opened; for a tab record this is its `contentId`. */
+  readonly address: string
+  /** The opener's `params` (see `contract/params.ts`); `undefined` when it gave none. */
+  readonly params: SidebarRightNavigationParams
+  /**
+   * Incremented on every navigation to this tab, whether or not `params`
+   * changed, so a body can act on "navigated again" alone. `0` for a record
+   * nobody opened by address: a seeded guide, or a tab restored by undo.
+   */
+  readonly revision: number
+}
+
+/** Where an open from a tab lands. Without any of these it lands in the pane holding the tab at call time. */
+export interface SidebarRightTabPlacement {
+  /** Land a new tab in this pane instead. */
+  readonly paneId?: PaneId
+  /** Defaults to `true`: a tab already showing the same content is focused instead of a second one opening. */
+  readonly revealIfOpened?: boolean
+  /** `true` opens in this tab's place — its pane and strip slot — and closes this tab in the same step. */
+  readonly replaceTab?: boolean
+}
+
+/** The actions one tab may take on itself; each acts on the session the tab is in. */
+export interface SidebarRightTabActions {
+  /**
+   * Open a resource from this tab; see `ISidebarRight.openResource`.
+   * @param address - a `dsh-resource://` address.
+   * @param options - placement and the resource's navigation parameters.
+   */
+  openResource(address: string, options?: SidebarRightTabPlacement & { readonly params?: SidebarRightResourceParams }): void
+  /**
+   * Open a page type from this tab; see `ISidebarRight.openTab`.
+   * @param kind - the page type's kind.
+   * @param options - placement and that kind's navigation parameters.
+   */
+  openTab<K extends string>(kind: K, options?: SidebarRightTabPlacement & { readonly params?: SidebarRightTabParamsFor<K> }): void
+  /** Close this tab. */
+  close(): void
+}
+
+/** Live information shared by a tab's body, title, and guide replacement. */
+export interface SidebarRightTabInfo {
+  readonly sidebar: {
+    readonly expanded: boolean
+    /** Presentation selected by manual mode or viewport width; preserved while collapsed. */
+    readonly fullscreen: boolean
+  }
+  readonly panel: { readonly id: PaneId }
+  readonly tab: TabRecord & {
+    /** Docked bodies need an expanded sidebar and an active tab; expanded titles include inactive tabs. Floats stay visible. */
+    readonly visible: boolean
+    readonly navigation: SidebarRightTabNavigation
+    /** Aborted only when the record disappears or this plugin unloads, not on hide or session switch. */
+    readonly signal: AbortSignal
+    readonly actions: SidebarRightTabActions
+  }
+}
+
+/**
+ * Read current tab information through the slot framework's subscriptions.
+ * @returns the sidebar presentation, containing pane, and live tab record.
+ */
+export type UseSidebarRightTabInfo = () => SidebarRightTabInfo
+
+/** The slot-owned hook shared by every tab body and title registration. */
+export interface SidebarRightTabInjected {
+  hooks: { tabInfo: SlotHookFactory<'sidebar.right.pane.tab', UseSidebarRightTabInfo> }
+}
+
+/** Owner share of one tab-menu item occurrence. */
+export interface SidebarRightTabMenuOwnerProps {
+  /** The tab whose menu is open. */
+  tab: TabRecord
+  /**
+   * Dismiss the menu.
+   *
+   * An item that acts MUST call this: the menu is the kit's, and it closes on
+   * its own actions only. An item that leaves it open leaves a menu floating
+   * over content the action may have just replaced.
+   */
+  dismiss: () => void
+}

+ 194 - 0
packages/client/ui-sidebar-right/src/client/index.ts

@@ -0,0 +1,194 @@
+/**
+ * Browser half: fill the frame's right column with the panel, put the expand
+ * button in the conversation header, and own the seats a tab type registers
+ * into.
+ *
+ * Two seats share one session-scoped store, which the slot runtime allows
+ * because both are session-scoped (a handle may not span scopes). The panel seat
+ * in the frame draws the surface normally or fullscreen, retaining the track
+ * on wide viewports; the header's corner seat draws the way back in
+ * while the panel is hidden. The store is the layout's only source of truth; the docking
+ * kit's pure planners compute every change and the store records them, one
+ * history entry per intent.
+ *
+ * The frame is a base package and never injects this one. What it needs —
+ * whether the panel is shown and whether it wants a track — arrives through its
+ * own `ctx.layout` action face, reported by the seat that knows both facts.
+ *
+ * Tab types register in two stages: the type itself into `ctx.sidebarRightTabs`,
+ * its body into the keyed `sidebar.right.pane.tab` seat under the same kind. The
+ * guide registers through those stages unmodified, exactly as a type shipped
+ * from another package does — `ui-sidebar-textpreview` is the live proof.
+ */
+import type { Context as ClientContext } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-resources/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type { ILayout } from '@deepseek-ai/dsh-client-ui-layout/client'
+import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
+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 { ExpandButton } from './shell/ExpandButton.tsx'
+import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx'
+import { createSidebarRightController, type SidebarRightController } from './service.ts'
+import { SidebarRightTabRegistry } from './tab-registry.ts'
+import { createSidebarRightStore } from './stores.ts'
+import { en, zh } from './locales.ts'
+import { GUIDE_ID, guideDefinition } from './tabs/guide/definition.ts'
+import { guideTabInfoFactory, tabInfoFactory } from './tab-info.ts'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+
+export type { RightbarSeatProps, SidebarRightInjected, SidebarRightPresentation } from './shell/SidebarRight.tsx'
+export type { GuideBodyProps, GuideInjected } from './tabs/guide/GuideBody.tsx'
+export type { ExpandButtonProps } from './shell/ExpandButton.tsx'
+export type { SidebarRightState, SurfaceState } from './stores.ts'
+export type {
+  ISidebarRight, SidebarRightBinding, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions,
+  SidebarRightPlacement, SurfaceActions,
+} from './service.ts'
+export type {
+  SidebarRightGuideBox, SidebarRightGuideEntry, SidebarRightTabClaim, SidebarRightTabDefinition,
+  SidebarRightTabPriority,
+} from './tab-registry.ts'
+export type {
+  SidebarRightTabInfo, SidebarRightTabInjected, UseSidebarRightTabInfo, SidebarRightTabActions,
+  SidebarRightTabMenuOwnerProps, SidebarRightTabNavigation, SidebarRightTabPlacement,
+} from './contract/slots.ts'
+export type {
+  SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightResourceParamsMap,
+  SidebarRightTabParams, SidebarRightTabParamsFor, SidebarRightTabParamsMap,
+} from './contract/params.ts'
+// The layout ids and rectangle the navigation face takes, so a caller needs no import from the kit.
+export type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
+export type { PinResource, SidebarRightNavigator, TabOccurrence } from './tab-domain.ts'
+export type { SidebarRightKey } from './locales.ts'
+export type { OpenContentIntent } from './stores.ts'
+
+/** This package's copy namespace. */
+const NS = 'sidebarRight'
+
+/** Required browser services: the slot registry, the frame's panel actions, copy, and the resource model. */
+export const inject = ['slots', 'layout', 'locale', 'resources']
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Right-Sidebar navigation and presentation face. */
+    sidebarRight: SidebarRightController
+    /** Right-Sidebar tab-type registry (stage one of a tab type's registration). */
+    sidebarRightTabs: SidebarRightTabRegistry
+  }
+}
+
+/**
+ * Client plugin body: provide the registry and the navigation face, register the
+ * panel seat and the rail seat over one store with their extension children, and
+ * register the guide type through the same public two-stage path any other type
+ * uses.
+ * @param ctx - client root context carrying the slot registry, the frame's face, and copy.
+ */
+export function apply(ctx: ClientContext): void {
+  // The registry and the face it backs are built here, at apply's top level,
+  // and never inside an effect. A registry other packages register into cannot
+  // have an effect-internal scope as its host: `register()` adds an effect to
+  // this fiber, and doing that from another plugin's apply while the effect is
+  // still the active scope stalls browser boot with no error at all. The
+  // template this follows (ui-conversation's definition registry) is built at
+  // its own apply top level for the same reason.
+  const t = ctx.locale.bind(NS)
+  const tabs = new SidebarRightTabRegistry(ctx)
+  const { controller, adopt } = createSidebarRightController(
+    tabs,
+    (address, signal) => { ctx.resources.pin(address, signal) },
+  )
+  const disposeRegistry = ctx.reflect.provide('sidebarRightTabs', tabs)
+  const disposeService = ctx.reflect.provide('sidebarRight', controller)
+  // Registered first, so it tears down last: the faces outlive every seat and
+  // type that reaches for them. provide()'s disposer settles asynchronously;
+  // teardown is synchronous fire-and-forget, matching ui-layout's root entry.
+  // Unloading aborts every tab occurrence, which releases every pin.
+  ctx.effect(() => () => {
+    controller.tabDomain.dispose()
+    void disposeService()
+    void disposeRegistry()
+  }, 'ui-sidebar-right: service faces')
+
+  ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-right: dictionaries')
+
+  ctx.effect(() => {
+    const handle = createSidebarRightStore(() => t('tab.guide.title'))
+    // The runtime mints one instance of this handle per session (the scope key
+    // is the session id) and caches it per key. Each is adopted as it is minted,
+    // so a tab's own action reaches its session's store while another session
+    // is on screen, and that store's commits sync the Tab domain themselves.
+    const adoptions: Array<() => void> = []
+    const store: typeof handle = {
+      ...handle,
+      create: (scopeKey) => {
+        const instance = handle.create(scopeKey)
+        if (scopeKey !== undefined) adoptions.push(adopt(scopeKey as SessionId, instance))
+        return instance
+      },
+    }
+    const layout: ILayout = ctx.layout
+    const injected: Omit<SidebarRightInjected, 'keyedHooks' | 'occurrence'> = {
+      syncPresentation({ shown, track, fullscreen }) {
+        if (shown) layout.openRightbar(track, fullscreen)
+        else layout.closeRightbar()
+      },
+      bindService: binding => controller.bind(binding),
+      openTab: (kind, options) => { controller.openTab(kind, options) },
+      hooks: { tabTypes: { subscribe: listener => tabs.subscribe(listener), getSnapshot: () => tabs.entries() } },
+    }
+
+    const disposeTypes = [tabs.register(guideDefinition(t))]
+    const disposeSeat = ctx.slots.inject('rightbar', () => ctx.slots.register({
+      name: 'rightbar',
+      locale: NS,
+      children: {
+        'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } },
+        'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: tabInfoFactory } } },
+        'sidebar.right.tab.menu.item': { kind: 'list', scope: 'session' },
+      },
+      store,
+      inject: (sessionId): SidebarRightInjected => ({
+        ...injected,
+        keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation },
+        occurrence: tab => controller.tabDomain.occurrence(sessionId, tab),
+      }),
+    }, RightbarSeat))
+    // The expand button shares the panel's store: it only needs to know whether
+    // the panel is expanded, and to ask for it to be. The header's corner seat
+    // is its own place, past the utilities, so showing and hiding it moves
+    // nothing else in the row.
+    const disposeExpand = ctx.slots.inject('conversation.session.header.corner', () => ctx.slots.register({
+      name: 'conversation.session.header.corner',
+      locale: NS,
+      store,
+    }, ExpandButton))
+    // Stage two for the guide: it declares the chain child it hosts and reads
+    // the registry's entry boxes, which an ordinary type has no reason to do.
+    const guideInjected: GuideInjected = {
+      hooks: { guideEntries: { subscribe: listener => tabs.subscribe(listener), getSnapshot: () => tabs.guide() } },
+    }
+    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 } },
+        },
+      },
+      inject: () => guideInjected,
+    }, GuideBody))
+    return () => {
+      disposeGuide()
+      disposeExpand()
+      disposeSeat()
+      for (const dispose of disposeTypes.reverse()) dispose()
+      for (const release of adoptions) release()
+    }
+  }, 'ui-sidebar-right: seats and shipped tab type')
+}

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

@@ -0,0 +1,30 @@
+/**
+ * The docking kit's vocabulary, in the product's language.
+ *
+ * The kit renders no string of its own, so every word a user reads inside it is
+ * handed over from here. This is a projection of the dictionary, not a second
+ * home for copy: the strings live in `locales.ts`.
+ */
+import type { DockLabels } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
+
+/**
+ * Project the dictionary into the kit's label contract.
+ *
+ * Called during render, so a language change reaches the kit with the next one —
+ * the kit caches no copy to invalidate.
+ * @param t - namespace-bound translate.
+ * @returns every string the kit renders.
+ */
+export function dockLabels(t: TranslateNS<'sidebarRight'>): DockLabels {
+  return {
+    emptyPane: t('dock.emptyPane'),
+    splitPane: t('dock.splitPane'),
+    splitPaneDisabled: t('dock.splitPaneDisabled'),
+    splitPaneNarrow: t('dock.splitPaneNarrow'),
+    closeTab: t('dock.closeTab'),
+    addTab: t('dock.addTab'),
+    dockFloat: t('dock.dockFloat'),
+    closeFloat: t('dock.closeFloat'),
+  }
+}

+ 50 - 0
packages/client/ui-sidebar-right/src/client/locales.ts

@@ -0,0 +1,50 @@
+/**
+ * `sidebarRight` namespace dictionaries.
+ *
+ * Everything a user reads in this column is here, including the strings handed
+ * to the docking kit — the kit renders no copy of its own, so its whole
+ * vocabulary is this package's to own and translate.
+ */
+
+/** Simplified Chinese dictionary and key-set source of truth. */
+export const zh = {
+  'chrome.expand': '展开侧栏',
+  'chrome.collapse': '收起侧栏',
+  'chrome.toFullscreen': '全屏显示侧栏',
+  'chrome.exitFullscreen': '退出侧栏全屏',
+  'dock.emptyPane': '空面板',
+  'dock.splitPane': '向右分栏',
+  'dock.splitPaneDisabled': '已达两格上限',
+  'dock.splitPaneNarrow': '栏宽不足,拖宽侧栏后再分栏',
+  'dock.closeTab': '关闭',
+  'dock.addTab': '新标签页',
+  'dock.dockFloat': '收回到侧栏',
+  'dock.closeFloat': '关闭',
+  'tab.guide.title': '开始',
+  'tab.unavailable': '这类内容还没有可用的查看方式。',
+  'guide.lead': '侧栏用来放你想一直看着的东西。',
+  'guide.body': '会话里的文件和产物会开在这一栏,也可以从下面的入口打开。',
+} satisfies Record<string, string>
+
+/** Right-Sidebar dictionary key union. */
+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',
+  'dock.emptyPane': 'Empty pane',
+  'dock.splitPane': 'Split to the right',
+  'dock.splitPaneDisabled': 'Two panes is the limit',
+  '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',
+  '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>

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

@@ -0,0 +1,476 @@
+/**
+ * `ctx.sidebarRight`: what other plugins may ask of this column.
+ *
+ * The surface is per session and its state lives in that session's store
+ * instance, which the slot runtime mints per session and a root service cannot
+ * reach on its own. Two paths lead in. The mounted seat publishes its binding —
+ * session id, bound actions, its surface — for exactly as long as it is mounted,
+ * and every command on the public face goes through that binding; a command
+ * arriving with no seat mounted has no session to act on and fails loudly rather
+ * than writing into a surface nobody is drawing. And the plugin adopts each
+ * session's store instance as the runtime mints it, so the controller reaches
+ * any session's store by id and syncs the Tab domain from that store's commits.
+ *
+ * A tab's own actions (`tabActions`) aim at the session the tab is in, not at
+ * the mounted one: they run through that session's adopted store, so a callback
+ * fired after the user switched sessions still lands where its tab is, and they
+ * do nothing for a session whose store was never minted.
+ *
+ * `openResource` and `openTab` are the navigation controller, and every way
+ * into the column is a call to one of them: the conversation's file links, a
+ * tool row's line reference, the strip's add control, a guide entry box, a file
+ * tree's rows. A resource is claimed through the registry by address; a page is
+ * named by kind and recorded at the address this package composes for it. Both
+ * hand the store one settled intent and record the navigation in the Tab
+ * domain. Placement is the caller's option, never a type's property.
+ *
+ * Wiring follows `LayoutController.attachPanels`: the registration hands the
+ * service its store actions, and the service is the face other plugins hold.
+ */
+import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { activeDockPaneId, canSplit, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightTabParamsFor } from './contract/params.ts'
+import { pageAddress } from './contract/seed.ts'
+import type { SidebarRightTabClaim, SidebarRightTabRegistry } from './tab-registry.ts'
+import type { SidebarRightState, SurfaceState } from './stores.ts'
+import type { createSidebarRightStore } from './stores.ts'
+import { TabDomain, type PinResource } from './tab-domain.ts'
+
+/** The seat's bound action set. */
+export type SurfaceActions = BoundActions<ReturnType<typeof createSidebarRightStore>>
+
+/** One session's store instance as the slot runtime minted it: its actions and its observable snapshot. */
+export interface SidebarRightSurfaceStore {
+  readonly actions: SurfaceActions
+  getSnapshot(): SidebarRightState
+  subscribe(listener: () => void): () => void
+}
+
+/** One adoption of a session's store; the token a release compares against. */
+interface Adoption {
+  readonly store: SidebarRightSurfaceStore
+  readonly unsubscribe: () => void
+}
+
+/**
+ * Create the public controller and the plugin-private store adoption callback.
+ * Adoption subscribes without reconciling; the first store commit creates occurrences.
+ * @param tabs - registered tab types.
+ * @param pin - resource retention for an occurrence's lifetime.
+ * @returns the controller and a callback releasing exactly its own adoption.
+ */
+export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource): {
+  controller: SidebarRightController
+  adopt: (sessionId: SessionId, store: SidebarRightSurfaceStore) => () => void
+} {
+  const adopted = new Map<SessionId, Adoption>()
+  const controller = new SidebarRightController(tabs, pin, adopted)
+  return {
+    controller,
+    adopt(sessionId, store) {
+      adopted.get(sessionId)?.unsubscribe()
+      const sync = (): void => {
+        const surface = store.getSnapshot().bySession[sessionId]
+        if (surface !== undefined) controller.tabDomain.sync(sessionId, surface.layout)
+      }
+      const adoption: Adoption = { store, unsubscribe: store.subscribe(sync) }
+      adopted.set(sessionId, adoption)
+      return () => {
+        adoption.unsubscribe()
+        if (adopted.get(sessionId) === adoption) adopted.delete(sessionId)
+      }
+    },
+  }
+}
+
+/** Everything a command needs, as the mounted seat sees it. */
+export interface SidebarRightBinding {
+  /** The session the mounted seat is drawing. */
+  readonly sessionId: SessionId
+  /** The seat's store's bound actions; every action names the session it acts on. */
+  readonly actions: SurfaceActions
+  /**
+   * The seat's store surfaces as last committed, keyed by session id; the
+   * mounted session's is `surfaces[sessionId]`, absent before the seat's first
+   * open. The runtime mints one store per session, so this holds that session.
+   */
+  readonly surfaces: Readonly<Record<string, SurfaceState>>
+  /**
+   * The room rule's verdict for a docked pane, as the kit last measured it:
+   * whether two working halves would fit. Unmeasured panes fit.
+   */
+  readonly canSplitPane: (paneId: PaneId) => boolean
+}
+
+/** Where an open lands; every field is optional and the defaults are the common case. */
+export interface SidebarRightPlacement {
+  /** Land a new tab in this pane instead of the active docked one. */
+  readonly paneId?: PaneId
+  /** Take this tab's place — its pane and its strip slot — and close it in the same step. */
+  readonly replaceTab?: TabId
+  /**
+   * Defaults to `true`: a tab already showing the same (kind, contentId) is
+   * focused and handed `params`. `false` opens another tab regardless.
+   */
+  readonly revealIfOpened?: boolean
+}
+
+/** How a caller wants a resource opened. */
+export interface SidebarRightOpenResourceOptions extends SidebarRightPlacement {
+  /** Name the opening type instead of letting the registry rank claims; its `canOpen` still applies. */
+  readonly kind?: string
+  /** The resource's navigation parameters, typed by resource type; delivered as `navigation.params`. */
+  readonly params?: SidebarRightResourceParams
+}
+
+/** How a caller wants a page type opened. */
+export interface SidebarRightOpenTabOptions<K extends string = string> extends SidebarRightPlacement {
+  /** That kind's navigation parameters, typed by kind; delivered as `navigation.params`. */
+  readonly params?: SidebarRightTabParamsFor<K>
+}
+
+/** The scheme every resource address carries; anything else is not a resource this face opens. */
+const RESOURCE_SCHEME = 'dsh-resource://'
+
+/** The outward right-Sidebar face (`ctx.sidebarRight`). */
+export interface ISidebarRight {
+  /**
+   * Open a resource: claim it, place it, reveal the column, record the navigation.
+   *
+   * Without `options.kind` the registry ranks the types whose globs and
+   * `canOpen` accept the address and the best band wins; with it, that kind's
+   * type in force opens the address (its `canOpen` still applies). An address
+   * outside `dsh-resource://`, or one no type will open, is a wiring mistake,
+   * not a user error, so it throws. The column expands in the same step,
+   * because content the user cannot see is not opened.
+   * @param address - a `dsh-resource://<type>/…` address.
+   * @param options - placement, the opening type, and navigation parameters.
+   */
+  openResource(address: string, options?: SidebarRightOpenResourceOptions): void
+  /**
+   * Open a page type by kind: the type in force for it, at the address this
+   * package records pages under. A kind nothing registered throws.
+   * @param kind - the page type's kind.
+   * @param options - placement and that kind's navigation parameters.
+   */
+  openTab<K extends string>(kind: K, options?: SidebarRightOpenTabOptions<K>): void
+  /**
+   * Close one tab of the mounted session.
+   * @param tabId - the tab to close.
+   */
+  close(tabId: TabId): void
+  /**
+   * The active tab of the active pane.
+   * @returns the record, or `undefined` when no seat is mounted.
+   */
+  active(): TabRecord | undefined
+  /**
+   * Whether the column is currently showing its panel.
+   * @returns `true` while expanded; `false` while collapsed to its rail.
+   */
+  isExpanded(): boolean
+  /** Collapse an expanded column, or expand a collapsed one. Recorded in the sequence. */
+  toggleExpanded(): void
+  /**
+   * Focus a tab and the pane holding it, raising a floating one. Recorded.
+   * @param tabId - the tab; one that does not exist is left alone.
+   */
+  focus(tabId: TabId): void
+  /**
+   * Split a docked pane to its right and seed the new pane, under the same
+   * pane budget and room rule as the strip's split control. Recorded when it
+   * splits.
+   * @param paneId - the pane to split; defaults to the active docked pane.
+   * @returns the new pane's id, or `undefined` when nothing was split: the pane
+   *   is missing or floating, the budget is spent, or two halves would not fit.
+   */
+  split(paneId?: PaneId): PaneId | undefined
+  /**
+   * Take a docked tab out into a floating panel. Recorded.
+   * @param tabId - the tab; one that is missing or already floating is left alone.
+   * @param rect - the panel's rectangle; defaults to the cascade from the last panel.
+   */
+  float(tabId: TabId, rect?: FloatRect): void
+  /**
+   * Return a floating panel's tab to the active docked pane. Recorded.
+   * @param paneId - the floating pane; one that is missing or docked is left alone.
+   */
+  dock(paneId: PaneId): void
+}
+
+/** Cross-plugin right-Sidebar face (ctx.sidebarRight). */
+export class SidebarRightController implements ISidebarRight {
+  private binding: SidebarRightBinding | undefined
+
+  /**
+   * The Tab domain this controller navigates into; synced from each adopted
+   * store's commits, read by the seat for each body's owner share.
+   */
+  readonly tabDomain: TabDomain
+
+  /**
+   * @param tabs - the tab-type registry consulted to claim an address.
+   * @param pin - `ctx.resources.pin`, which the Tab domain holds addresses with.
+   * @param adopted - plugin-owned session stores used by occurrence actions.
+   */
+  constructor(
+    private readonly tabs: SidebarRightTabRegistry,
+    pin: PinResource,
+    private readonly adopted = new Map<SessionId, Adoption>(),
+  ) {
+    this.tabDomain = new TabDomain(this, pin)
+  }
+
+  /**
+   * Adopt the mounted seat's binding, replacing any previous one.
+   *
+   * Called from the seat while it is mounted, and released when it leaves.
+   * @param binding - the mounted seat's session, actions, and the store's surfaces.
+   * @returns a release callback that clears exactly this binding.
+   */
+  bind(binding: SidebarRightBinding): () => void {
+    this.binding = binding
+    return () => {
+      // A newer seat may already have taken over; only the binding that is
+      // still ours may be cleared.
+      if (this.binding === binding) this.binding = undefined
+    }
+  }
+
+  /**
+   * Open a resource: claim it, place it, reveal the column, record the navigation.
+   * @param address - a `dsh-resource://<type>/…` address.
+   * @param options - placement, the opening type, and navigation parameters.
+   */
+  openResource(address: string, options: SidebarRightOpenResourceOptions = {}): void {
+    const { sessionId, actions } = this.require()
+    this.placeResource(sessionId, actions, address, options)
+  }
+
+  /**
+   * Open a page type by kind at the address this package records pages under.
+   * @param kind - the page type's kind.
+   * @param options - placement and that kind's navigation parameters.
+   */
+  openTab<K extends string>(kind: K, options: SidebarRightOpenTabOptions<K> = {}): void {
+    const { sessionId, actions } = this.require()
+    this.placeTab(sessionId, actions, kind, options)
+  }
+
+  /**
+   * Open a resource in one session, for a tab's own action; 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 acting tab is in.
+   * @param address - a `dsh-resource://<type>/…` address.
+   * @param options - placement, the opening type, and navigation parameters.
+   */
+  openResourceIn(sessionId: SessionId, address: string, options: SidebarRightOpenResourceOptions = {}): void {
+    const actions = this.actionsFor(sessionId)
+    if (actions !== undefined) this.placeResource(sessionId, actions, address, options)
+  }
+
+  /**
+   * Open a page type in one session, for a tab's own action; 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 acting tab is in.
+   * @param kind - the page type's kind.
+   * @param options - placement and that kind's navigation parameters.
+   */
+  openTabIn<K extends string>(sessionId: SessionId, kind: K, options: SidebarRightOpenTabOptions<K> = {}): void {
+    const actions = this.actionsFor(sessionId)
+    if (actions !== undefined) this.placeTab(sessionId, actions, kind, options)
+  }
+
+  /**
+   * Close a tab of one session, for the tab's own action; 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.
+   * @param tabId - the tab to close.
+   */
+  closeIn(sessionId: SessionId, tabId: TabId): void {
+    const actions = this.actionsFor(sessionId)
+    if (actions !== undefined) actions.closeTab(sessionId, tabId)
+  }
+
+  /** Claim a resource and place it in one session; an address outside the scheme or one no type claims throws. */
+  private placeResource(
+    sessionId: SessionId,
+    actions: SurfaceActions,
+    address: string,
+    options: SidebarRightOpenResourceOptions,
+  ): void {
+    if (!address.startsWith(RESOURCE_SCHEME)) {
+      throw new Error(`sidebarRight: no registered tab type claims "${address}"`)
+    }
+    this.place(sessionId, actions, this.tabs.claim(address, options.kind), address, options, options.params)
+  }
+
+  /** Place a page type in one session at the address pages are recorded under; an unregistered kind throws. */
+  private placeTab<K extends string>(
+    sessionId: SessionId,
+    actions: SurfaceActions,
+    kind: K,
+    options: SidebarRightOpenTabOptions<K>,
+  ): void {
+    const definition = this.tabs.get(kind)
+    if (definition === undefined) throw new Error(`sidebarRight: no tab type is registered as "${kind}"`)
+    const address = pageAddress(kind)
+    this.place(sessionId, actions, { kind, contentId: address, title: definition.title(address) }, address, options, options.params)
+  }
+
+  /** The steps both opens share: one store intent, and the navigation record for the tab it settles on. */
+  private place(
+    sessionId: SessionId,
+    actions: SurfaceActions,
+    claim: SidebarRightTabClaim,
+    address: string,
+    placement: SidebarRightPlacement,
+    params: SidebarRightNavigationParams,
+  ): void {
+    actions.openContent(sessionId, {
+      kind: claim.kind,
+      contentId: claim.contentId,
+      title: claim.title,
+      ...placement.paneId === undefined ? {} : { paneId: placement.paneId },
+      ...placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab },
+      ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened },
+    }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) })
+  }
+
+  /**
+   * Close one tab of the mounted session.
+   * @param tabId - the tab to close.
+   */
+  close(tabId: TabId): void {
+    const { sessionId, actions } = this.require()
+    actions.closeTab(sessionId, tabId)
+  }
+
+  /**
+   * The active tab of the active pane.
+   * @returns the record, or `undefined` with no mounted surface.
+   */
+  active(): TabRecord | undefined {
+    const layout = this.mounted()?.layout
+    if (layout === undefined) return undefined
+    const { activeTabId } = getPane(layout, layout.activePaneId)
+    return Object.values(layout.tabs).find(tab => tab.id === activeTabId)
+  }
+
+  /**
+   * Whether the column is currently showing its panel.
+   * @returns `true` while expanded; `false` while collapsed or with no mounted surface.
+   */
+  isExpanded(): boolean {
+    return this.mounted()?.layout.expanded ?? false
+  }
+
+  /** Collapse an expanded column, or expand a collapsed one. */
+  toggleExpanded(): void {
+    const { sessionId, actions } = this.require()
+    actions.toggleExpanded(sessionId)
+  }
+
+  /**
+   * Focus a tab and the pane holding it; a missing tab is left alone.
+   * @param tabId - the tab to focus.
+   */
+  focus(tabId: TabId): void {
+    const { sessionId, actions } = this.require()
+    if (this.mounted()?.layout.tabs[tabId] === undefined) return
+    actions.focusTab(sessionId, tabId)
+  }
+
+  /**
+   * Split a docked pane to its right when the budget and the room rule allow.
+   * @param paneId - the pane to split; defaults to the active docked pane.
+   * @returns the new pane's id, or `undefined` when nothing was split.
+   */
+  split(paneId?: PaneId): PaneId | undefined {
+    const { sessionId, actions, canSplitPane } = this.require()
+    const layout = this.mounted()?.layout
+    if (layout === undefined) return undefined
+    const target = paneId ?? activeDockPaneId(layout)
+    const node = layout.nodes[target]
+    if (node === undefined || node.kind !== 'pane' || node.host !== 'dock') return undefined
+    if (!canSplit(layout) || dockPaneIds(layout).length >= 2 || !canSplitPane(target)) return undefined
+    let created: PaneId | undefined
+    actions.splitPane(sessionId, target, (id) => { created = id })
+    return created
+  }
+
+  /**
+   * Take a docked tab out into a floating panel; a missing or floating tab is left alone.
+   * @param tabId - the tab to float.
+   * @param rect - the panel's rectangle; defaults to the cascade from the last panel.
+   */
+  float(tabId: TabId, rect?: FloatRect): void {
+    const { sessionId, actions } = this.require()
+    const layout = this.mounted()?.layout
+    if (layout === undefined || layout.tabs[tabId] === undefined) return
+    if (findTabPane(layout, tabId).host !== 'dock') return
+    actions.floatTab(sessionId, tabId, rect)
+  }
+
+  /**
+   * Return a floating panel's tab to the active docked pane; a missing or docked pane is left alone.
+   * @param paneId - the floating pane.
+   */
+  dock(paneId: PaneId): void {
+    const { sessionId, actions } = this.require()
+    const node = this.mounted()?.layout.nodes[paneId]
+    if (node === undefined || node.kind !== 'pane' || node.host !== 'float') return
+    actions.unfloatPane(sessionId, paneId)
+  }
+
+  /**
+   * Step the mounted session's surface back one intent.
+   *
+   * @internal Not part of the product: the sequence is an architectural fact
+   * with no user-facing control yet. Kept reachable for tests.
+   */
+  _undo(): void {
+    const { sessionId, actions } = this.require()
+    actions.undo(sessionId)
+  }
+
+  /**
+   * Step the mounted session's surface forward one intent.
+   *
+   * @internal See `_undo`.
+   */
+  _redo(): void {
+    const { sessionId, actions } = this.require()
+    actions.redo(sessionId)
+  }
+
+  /** The mounted session's surface; `undefined` without a seat or before its first open. */
+  private mounted(): SurfaceState | undefined {
+    const { binding } = this
+    return binding === undefined ? undefined : binding.surfaces[binding.sessionId]
+  }
+
+  /**
+   * The store actions a tab's own action on `sessionId` runs through: that
+   * session's adopted store. `undefined` — nothing to act on — for a session
+   * whose store was never minted or whose adoption was released.
+   */
+  private actionsFor(sessionId: SessionId): SurfaceActions | undefined {
+    return this.adopted.get(sessionId)?.store.actions
+  }
+
+  private require(): SidebarRightBinding {
+    // Reads answer for the no-session case (there is nothing expanded), but a
+    // write has no session to write to. Callers are UI gestures and tool
+    // results, both of which belong to a session that is on screen.
+    if (this.binding === undefined) {
+      throw new Error('sidebarRight: no session surface is mounted')
+    }
+    return this.binding
+  }
+}

+ 38 - 0
packages/client/ui-sidebar-right/src/client/shell/ExpandButton.module.css

@@ -0,0 +1,38 @@
+/*
+ * 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.
+ */
+.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;
+  color: var(--dsw-alias-label-secondary);
+  background: transparent;
+  border: none;
+  border-radius: 8px;
+  cursor: pointer;
+}
+
+.button:hover {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+/* The left sidebar's panel icon, mirrored: the divider moves to the right side. */
+.icon {
+  transform: scaleX(-1);
+}

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

@@ -0,0 +1,48 @@
+/**
+ * The way into a hidden panel: one button in the conversation header's corner
+ * seat, shown only while the panel is collapsed.
+ *
+ * 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.
+ *
+ * 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 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'
+import css from './ExpandButton.module.css'
+
+/** The button's props: the header corner seat, the shared store, and copy. */
+export type ExpandButtonProps =
+  & PropsRuntime<'conversation.session.header.corner'>
+  & PropsStore<ReturnType<typeof createSidebarRightStore>>
+  & PropsLocale<'sidebarRight'>
+
+/** The expand control while the panel is collapsed; its footprint 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 />
+  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>
+  )
+}

+ 108 - 0
packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css

@@ -0,0 +1,108 @@
+/*
+ * The Sidebar's own chrome only: the panel box, its two strip-end controls, and
+ * the placeholder tab bodies. Everything inside the split tree is styled by the
+ * docking kit; the expand button in the conversation header has its own sheet.
+ * Tokens only.
+ */
+
+/*
+ * One content tree uses the frame's width normally and fills the viewport in
+ * fullscreen. The frame retains a shown wide panel's track in both modes.
+ *
+ * Hidden, the panel is translated off the frame's right edge rather than
+ * unmounted, so showing and hiding are one slide in both presentations. The
+ * transform rides the same variables as the frame's track transition (ui-theme
+ * base.css); while squeezing, the panel's left edge and the conversation's right
+ * edge therefore travel together. Visibility flips after the slide so the box
+ * is out of reach once off-edge, and flips back before the slide starts.
+ *
+ * The normal panel is below frame overlays (20); fullscreen (40) covers the
+ * frame and remains below independently floating panels (60).
+ */
+.panel {
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 10;
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  /* The same ground as the conversation: this is a column of the page, not a
+     raised surface. */
+  background: var(--dsw-alias-bg-base);
+  border-left: 0.5px solid var(--dsw-alias-border-l1);
+  transform: translateX(100%);
+  visibility: hidden;
+  transition:
+    transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
+    visibility 0s linear var(--ds-transition-duration-slow);
+}
+
+.panel[data-sidebar-right-open] {
+  transform: none;
+  visibility: visible;
+  transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
+}
+
+.panel[data-sidebar-right-panel='fullscreen'] {
+  position: fixed;
+  inset: 0;
+  z-index: 40;
+  border: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .panel,
+  .panel[data-sidebar-right-open] {
+    transition: none;
+  }
+}
+
+.iconButton {
+  display: flex;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  padding: 0;
+  color: var(--dsw-alias-label-secondary);
+  font-size: 14px;
+  line-height: 1;
+  background: transparent;
+  border: none;
+  border-radius: 4px;
+  cursor: pointer;
+}
+
+.iconButton:hover {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.panelBody {
+  display: flex;
+  flex: 1 1 auto;
+  min-height: 0;
+}
+
+.unavailable {
+  margin: 0;
+  color: var(--dsw-alias-label-tertiary);
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+}
+
+/*
+ * Host for the portalled floating panels. Panels leave the column so they can
+ * cross it and the conversation, which puts them beside the app root rather than
+ * inside it — and a sibling of the root does not inherit its stacking, so the
+ * level is stated here. The value sits above the frame's own overlay layer;
+ * there is no z-index token layer to draw from yet.
+ */
+.floatHost {
+  position: fixed;
+  inset: 0;
+  z-index: 60;
+  pointer-events: none;
+}

+ 402 - 0
packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx

@@ -0,0 +1,402 @@
+/**
+ * The Sidebar's seat in the frame, and the panel it draws.
+ *
+ * The frame owns the right column's geometry; this package owns one content
+ * tree at the column width or fixed across the viewport. A shown wide panel
+ * retains its track in fullscreen, preserving the conversation width. Below
+ * 768px fullscreen is derived from viewport width, without changing manual mode.
+ *
+ * The panel stays mounted while collapsed, translated off the frame's right
+ * edge, so opening and closing are one gesture in both presentations: a slide
+ * from and to that edge. Because the frame's track transition reads the same
+ * duration and curve, the panel's left edge and the conversation's right edge
+ * travel together while squeezing.
+ *
+ * The panel has no header of its own: its two controls — presentation switch
+ * and collapse — ride the docking kit's chrome seat at the end of the top-right
+ * pane's tab strip, so the strip is the panel's whole top edge. The way back in
+ * while collapsed is not here either: it is one button in the conversation
+ * header (`ExpandButton.tsx`), because it exists only while this panel is
+ * hidden. Floating panels portal out because they must cross the column and the
+ * conversation, and the kit already positions them in viewport coordinates.
+ *
+ * Tab bodies do not live here. Each one is a registration under its type's kind,
+ * dispatched through the keyed `sidebar.right.pane.tab` seat (and a live chip
+ * title through `sidebar.right.pane.tab.title`), so a new tab type needs no edit
+ * to this file. What a body receives beyond the record — navigation, lifetime
+ * signal, actions — is read through the slot-owned useTabInfo hook. The Tab
+ * domain follows each session's store commits, including sessions off screen.
+ */
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
+import type { ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import type {
+  HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
+} from '@deepseek-ai/dsh-client-ui-slots'
+// The frame declares the `rightbar` seat this component fills.
+import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
+import type { DockIntents, DockMode, FloatRect, TabId, TabRecord, TabRenderer } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { canSplit, dockPaneIds, DockSurface, findPaneContentTab, FloatLayer } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { HalvesFit, LayoutState, PaneId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { GUIDE_KIND, pageAddress } from '../contract/seed.ts'
+import { dockLabels } from '../labels.ts'
+import type { SidebarRightOpenTabOptions } from '../service.ts'
+import type { SidebarRightTabDefinition } from '../tab-registry.ts'
+import type { createSidebarRightStore, SurfaceState } from '../stores.ts'
+import type { TabOccurrence } from '../tab-domain.ts'
+import type { SidebarRightTabNavigation } from '../contract/slots.ts'
+import type { TabHookContext } from '../tab-info.ts'
+import css from './SidebarRight.module.css'
+
+/** The store share the seat receives. */
+type Store = PropsStore<ReturnType<typeof createSidebarRightStore>>
+
+/** The child seats this component renders. */
+type Children = PropsRenderSlots<'sidebar.right.pane.tab' | 'sidebar.right.pane.tab.title' | 'sidebar.right.tab.menu.item'>
+
+/** What the panel reports to the frame: drawn or not, and whether it wants a track. */
+export interface SidebarRightPresentation {
+  /** Whether the panel is drawn at all. */
+  readonly shown: boolean
+  /** Whether the drawn panel wants the conversation to make room for it. */
+  readonly track: boolean
+  /** Whether the panel fills the viewport, independently of its retained track. */
+  readonly fullscreen: boolean
+}
+
+/** What this package needs from its host beyond the framework shares. */
+export interface SidebarRightInjected {
+  /**
+   * Report the panel's presentation to the frame.
+   *
+   * The frame sizes the track and places the resize handle; this only tells it
+   * the composition of the facts this package owns, and is called whenever that
+   * composition changes.
+   */
+  readonly syncPresentation: (presentation: SidebarRightPresentation) => void
+  /**
+   * Publish this seat's session, actions, and the store's surfaces to `ctx.sidebarRight`.
+   *
+   * The service is root-scoped and cannot read a per-entry store, so the only
+   * honest source is the mounted seat. Held for as long as the seat is mounted.
+   * @param binding - what a command needs to act on this session, and what a tab's own action needs to act on its.
+   * @returns a release callback.
+   */
+  readonly bindService: (binding: {
+    sessionId: SessionId
+    actions: Store['actions']
+    /** Every session's surface as last committed; the mounted one is `surfaces[sessionId]`. */
+    surfaces: Readonly<Record<string, SurfaceState>>
+    /** The room rule's verdict for a docked pane, as the kit last measured it. */
+    canSplitPane: (paneId: PaneId) => boolean
+  }) => () => void
+  /**
+   * The navigation face's `openTab`, for the strip's add control: a new tab is
+   * the guide opened by kind, through the same path as every other open.
+   */
+  readonly openTab: (kind: string, options?: SidebarRightOpenTabOptions) => void
+  readonly hooks: {
+    readonly tabTypes: HostObservable<readonly SidebarRightTabDefinition[]>
+  }
+  readonly keyedHooks: {
+    readonly tabNavigation: (key: string) => HostObservable<SidebarRightTabNavigation>
+  }
+  /** Read a committed record's lifetime; never creates an occurrence. */
+  readonly occurrence: (tab: Pick<TabRecord, 'id'>) => TabOccurrence
+}
+
+/** The column seat's props: session scope, so the session arrives as a standard prop. */
+export type RightbarSeatProps =
+  & PropsRuntime<'rightbar'>
+  & Children
+  & Store
+  & PropsLocale<'sidebarRight'>
+  & InjectFace<SidebarRightInjected>
+
+/** Everything the panel needs, already bound to one session. */
+interface PanelProps {
+  readonly sessionId: SessionId
+  readonly surface: SurfaceState
+  readonly actions: Store['actions']
+  readonly t: RightbarSeatProps['t']
+  readonly renderSlot: Children['renderSlot']
+  readonly openTab: SidebarRightInjected['openTab']
+  readonly useTabTypes: RightbarSeatProps['useTabTypes']
+  readonly useTabNavigation: RightbarSeatProps['useTabNavigation']
+  readonly useStore: Store['useStore']
+  readonly occurrence: SidebarRightInjected['occurrence']
+  readonly fullscreen: boolean
+  readonly autoFullscreen: boolean
+  /** Receives the kit's room-rule readings for the service's `split`. */
+  readonly reportRoom: (fits: ReadonlyMap<PaneId, HalvesFit>) => void
+}
+
+/** The guide tab one pane holds, if any: a pane holds at most one. */
+function guideIn(layout: LayoutState, paneId: PaneId): TabId | undefined {
+  return findPaneContentTab(layout, paneId, pageAddress(GUIDE_KIND), GUIDE_KIND)
+}
+
+/**
+ * Build the kit's intent face for one session out of the store's actions.
+ * @param sessionId - the session the seat draws; every action is bound to it.
+ * @param actions - the seat's bound store actions.
+ * @param openTab - the navigation face's `openTab`, which the strip's add control asks for a guide through.
+ * @returns the intents the kit reports gestures to.
+ */
+export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab']): DockIntents {
+  return {
+    focusTab: (tabId) => { actions.focusTab(sessionId, tabId) },
+    focusPane: (paneId) => { actions.focusPane(sessionId, paneId) },
+    splitPane: (paneId) => { actions.splitPane(sessionId, paneId) },
+    // The guide is unique per pane: the control is drawn only while its pane
+    // holds none (`canAddTab` below) and asks for one there without regard to
+    // guides in other panes; the store settles the open on a guide the pane
+    // already holds, so the ask is idempotent all the same.
+    addTab: (paneId) => { openTab(GUIDE_KIND, { paneId, revealIfOpened: false }) },
+    closeTab: (tabId) => { actions.closeTab(sessionId, tabId) },
+    duplicateTab: (tabId) => { actions.duplicateTab(sessionId, tabId) },
+    floatTab: (tabId, rect?: FloatRect) => { actions.floatTab(sessionId, tabId, rect) },
+    unfloatPane: (paneId) => { actions.unfloatPane(sessionId, paneId) },
+    placeTab: (tabId, toPaneId, index) => { actions.placeTab(sessionId, tabId, toPaneId, index) },
+    dropTab: (tabId, paneId, zone) => { actions.dropTab(sessionId, tabId, paneId, zone) },
+    moveFloat: (paneId, x, y) => { actions.moveFloat(sessionId, paneId, x, y) },
+    resizeFloat: (paneId, rect) => { actions.resizeFloat(sessionId, paneId, rect) },
+    resizeSplit: (splitId, sizes) => { actions.resizeSplit(sessionId, splitId, sizes) },
+  }
+}
+
+/** One tab's slot dispatch: which seat, and what to render when no type registered. */
+interface TabSlotProps extends Pick<PanelProps, 'renderSlot' | 'occurrence' | 'useTabTypes' | 'useTabNavigation' | 'useStore' | 'fullscreen'> {
+  readonly tab: TabRecord
+  readonly seat: 'sidebar.right.pane.tab' | 'sidebar.right.pane.tab.title'
+  readonly fallback: ReactNode
+}
+
+/**
+ * Dispatch one tab's body or title with stable framework hooks and record lifetime.
+ */
+function TabSlot({
+  renderSlot, occurrence, useTabTypes, useTabNavigation, useStore, fullscreen, tab, seat, fallback,
+}: TabSlotProps): ReactNode {
+  const { signal, tabActions } = occurrence(tab)
+  const definition = useTabTypes(types => types.find(definition => definition.kind === tab.kind))
+  const hookContext = useMemo((): TabHookContext => ({
+    tabId: tab.id,
+    title: seat === 'sidebar.right.pane.tab.title',
+    fullscreen,
+    signal,
+    actions: tabActions,
+    useStore,
+    useTabNavigation,
+  }), [tab.id, seat, fullscreen, signal, tabActions, useStore, useTabNavigation])
+  return renderSlot(seat, {}, { entryKey: definition?.id ?? tab.kind, fallback, hookContext })
+}
+
+/**
+ * Dispatch a tab's body to its registered type.
+ *
+ * A kind with no registrant is a real state, not a defect: a session log can
+ * carry a tab whose type shipped in a plugin that is no longer mounted. Saying so
+ * is better than an empty pane.
+ */
+function bodiesFor(panel: PanelProps): TabRenderer {
+  const { t, ...rest } = panel
+  // Keyed by record: the kit draws one body per pane in one place, and the
+  // keyed slot below keys on the type, so two tabs of one kind would otherwise
+  // share a component instance and its local state (a scroll position, a ref).
+  return tab => (
+    <TabSlot
+      key={tab.id}
+      {...rest}
+      tab={tab}
+      seat="sidebar.right.pane.tab"
+      fallback={<p className={css.unavailable} data-sidebar-right-unavailable>{t('tab.unavailable')}</p>}
+    />
+  )
+}
+
+/** Dispatch a tab's title to its registered type; without one the chip shows the title captured at open time. */
+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. */
+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>
+  )
+}
+
+/** Restore-from-fullscreen glyph. */
+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>
+  )
+}
+
+/** 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'
+  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>
+    </>
+  )
+}
+
+/**
+ * The panel: the docked surface with the two controls in its top-right strip,
+ * anchored to the frame's right edge and slid off it while collapsed.
+ */
+function SidebarPanel(panel: PanelProps & { width: number }): ReactNode {
+  const { sessionId, surface, actions, t, renderSlot, openTab, width, reportRoom, fullscreen, autoFullscreen } = panel
+  const { expanded } = surface.layout
+  return (
+    <div
+      className={css.panel}
+      style={{ width: fullscreen ? '100%' : width }}
+      data-sidebar-right-panel={fullscreen ? 'fullscreen' : 'push'}
+      data-sidebar-right-open={expanded || undefined}
+      // Off-edge is out of reach: the stylesheet's visibility flip takes the
+      // hidden panel out of the tab order, and this takes it out of the
+      // accessibility tree.
+      aria-hidden={!expanded || undefined}
+    >
+      <div className={css.panelBody}>
+        <DockSurface
+          state={surface.layout}
+          canSplit={canSplit(surface.layout) && dockPaneIds(surface.layout).length < 2}
+          dropZones="horizontal"
+          minPaneFraction={0.2}
+          canAddTab={paneId => guideIn(surface.layout, paneId) === undefined}
+          intents={intentsFor(sessionId, actions, openTab)}
+          labels={dockLabels(t)}
+          renderTab={bodiesFor(panel)}
+          renderTabTitle={titlesFor(panel)}
+          renderTabMenuItems={(tab, dismiss) =>
+            renderSlot('sidebar.right.tab.menu.item', { tab, dismiss })}
+          chrome={<PanelChrome sessionId={sessionId} fullscreen={fullscreen} autoFullscreen={autoFullscreen} actions={actions} t={t} />}
+          onRoom={reportRoom}
+        />
+      </div>
+    </div>
+  )
+}
+
+/** Portal the floating layer out of whichever seat rendered it. */
+function Floats(panel: PanelProps): ReactNode {
+  const { sessionId, surface, actions, t, openTab } = panel
+  if (surface.layout.floats.length === 0) return null
+  return createPortal(
+    <div className={css.floatHost} data-sidebar-right-float-host>
+      <FloatLayer
+        state={surface.layout}
+        intents={intentsFor(sessionId, actions, openTab)}
+        labels={dockLabels(t)}
+        renderTab={bodiesFor(panel)}
+        renderTabTitle={titlesFor(panel)}
+      />
+    </div>,
+    document.body,
+  )
+}
+
+/**
+ * The right column's occupant: the panel, anchored to the column's edge and
+ * shown or hidden by sliding, plus the floating layer. It is also where the
+ * frame learns the panel's presentation, and where `ctx.sidebarRight` learns
+ * which session it is acting on, because this is the seat that knows both.
+ */
+export function RightbarSeat({
+  sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab,
+  useTabTypes, useTabNavigation, occurrence,
+}: RightbarSeatProps): ReactNode {
+  // One store instance per session, so this map holds this session's surface.
+  // The binding published below serves the public face's commands on the
+  // mounted session; a tab's own actions route through the controller's
+  // adopted stores instead.
+  const surfaces = useStore(state => state.bySession)
+  const surface = surfaces[sessionId]
+  const shown = surface !== undefined && surface.layout.expanded
+  const autoFullscreen = viewportWidth < 768
+  const fullscreen = autoFullscreen || surface?.layout.mode === 'fullscreen'
+  // The kit's room-rule readings, kept in a ref: the service reads them at
+  // call time through the binding, and a reading never re-renders anything.
+  const room = useRef<ReadonlyMap<PaneId, HalvesFit>>(new Map())
+  const reportRoom = useCallback((fits: ReadonlyMap<PaneId, HalvesFit>): void => { room.current = fits }, [])
+  const track = shown && !autoFullscreen
+
+  useEffect(() => {
+    if (surface === undefined) actions.open(sessionId)
+  }, [actions, sessionId, surface])
+
+  useLayoutEffect(() => {
+    if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false)
+  }, [actions, sessionId, shown, fullscreen, canShow])
+
+  // Before paint, so the frame's track and this panel's slide start in the same
+  // frame: the frame re-renders synchronously from this report, and both
+  // transitions read the same duration and curve.
+  useLayoutEffect(() => { syncPresentation({ shown, track, fullscreen }) }, [shown, track, fullscreen, syncPresentation])
+  // Leaving is part of that report: a seat that unmounts with its session must
+  // hand the track back rather than leave one sized for a surface nobody draws.
+  useLayoutEffect(() => () => { syncPresentation({ shown: false, track: false, fullscreen: false }) }, [syncPresentation])
+
+  // Republished on every committed change: the service's readers answer from the
+  // last commit, and its commands act on the session actually on screen.
+  useEffect(
+    () => bindService({ sessionId, actions, surfaces, canSplitPane: paneId => room.current.get(paneId)?.row !== false }),
+    [bindService, sessionId, actions, surfaces],
+  )
+  // The Tab domain is not synced here: the controller adopted this session's
+  // store as the runtime minted it and reconciles on the store's own commits,
+  // on screen or not.
+
+  if (surface === undefined) return null
+  const panel: PanelProps = {
+    sessionId, actions, t, renderSlot, surface, openTab, useTabTypes, useTabNavigation, useStore, occurrence,
+    fullscreen, autoFullscreen, reportRoom,
+  }
+  return (
+    <>
+      <SidebarPanel {...panel} width={width} />
+      <Floats {...panel} />
+    </>
+  )
+}

+ 353 - 0
packages/client/ui-sidebar-right/src/client/stores.ts

@@ -0,0 +1,353 @@
+/**
+ * The store shell over the docking kit: one surface per session, held as plain
+ * data so the kit's pure functions are the only thing that ever computes a
+ * layout.
+ *
+ * Every action follows the same steps — mint the ids the intent needs, ask the
+ * kit's planner what operations carry it out, let the settle planner keep every
+ * pane populated, record it all as one history entry — and then assigns the
+ * session's whole surface back in one go. Nothing here reaches into a draft to
+ * edit a layout in place, which is what keeps the kit testable without a store
+ * and keeps snapshot identity honest.
+ *
+ * The settle step is this product's rule, not the kit's: a docked pane never
+ * stays empty, and the last pane reseeds the guide tab, so there is always at
+ * least one tab to look at.
+ *
+ * A focus that changes nothing — a tab already active in its already-active
+ * pane, a pane already active — plans nothing and records nothing, whoever
+ * asks: the kit's chip click and `ctx.sidebarRight.focus` alike.
+ *
+ * So is the guide's uniqueness: a pane holds at most one guide tab. Opening the
+ * guide into a pane that has one focuses it, and a guide dragged, dropped, or
+ * docked into such a pane merges into it — the arriving guide closes and the
+ * pane's own is focused. The kit plans none of this; it is decided here before
+ * its planners run.
+ */
+import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import type {
+  DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId,
+} from '@deepseek-ai/dsh-client-ui-dockkit'
+import {
+  activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findPaneContentTab, findTabPane,
+  planDropTab, planDuplicateTab, planFloatTab, planOpenContent, planPlaceTab, planResizeSplit, planSetExpanded,
+  planSetMode, planSettle, planSplitPane, planUnfloatPane, record, replay, stepBack, stepForward,
+} from '@deepseek-ai/dsh-client-ui-dockkit'
+import { GUIDE_KIND, makeGuideTab, pageAddress } from './contract/seed.ts'
+
+/** One session's docking surface: the layout, its sequence, and the id counter. */
+export interface SurfaceState {
+  readonly layout: LayoutState
+  readonly history: History
+  /** How many ids this surface has minted; carried so replay stays reproducible. */
+  readonly minted: number
+}
+
+/**
+ * Every session's surface, keyed by session id.
+ *
+ * Written mutable because an action receives this type as its draft; the
+ * immutability that matters is behavioural — actions only ever assign a whole
+ * new map, never reach into one.
+ */
+export interface SidebarRightState {
+  bySession: Record<string, SurfaceState>
+}
+
+/** A planner call, as the store needs it: state and a mint in, operations out. */
+type SurfacePlan = (state: LayoutState, mint: Mint) => readonly LayoutOp[]
+
+/**
+ * What the navigation controller asks the store to open, the address already
+ * claimed. Placement is by `replaceTab` first, then `paneId`, then the active pane.
+ */
+export interface OpenContentIntent {
+  readonly kind: string
+  readonly contentId: string
+  readonly title: string
+  /** Land a new tab in this pane. */
+  readonly paneId?: PaneId
+  /** Take this tab's pane and slot, and close it in the same entry. */
+  readonly replaceTab?: TabId
+  /** `false` opens another tab even when the identity is already shown; defaults to `true`. */
+  readonly revealIfOpened?: boolean
+}
+
+/** A mint that counts, so the surface can carry its position forward. */
+function counting(from: number): { mint: Mint; used: () => number } {
+  let counter = from
+  // The mint is where a string becomes a branded id: the prefix names the kind,
+  // the counter keeps every id of this surface unique.
+  const mint = ((prefix: string): string => {
+    counter += 1
+    return `${prefix}${counter}`
+  }) as Mint
+  return { mint, used: () => counter }
+}
+
+/**
+ * The surface a session starts with: collapsed, one pane, one guide tab.
+ * @param seedTitle - the guide type's display name at mint time.
+ * @returns the initial surface.
+ */
+export function createSurface(seedTitle: () => string): SurfaceState {
+  const counter = counting(0)
+  return {
+    layout: createInitialState({ next: counter.mint }, id => makeGuideTab(id, seedTitle())),
+    history: EMPTY_HISTORY,
+    minted: counter.used(),
+  }
+}
+
+/** The guide tab a pane holds, if any. */
+function paneGuide(state: LayoutState, paneId: PaneId): TabId | undefined {
+  return findPaneContentTab(state, paneId, pageAddress(GUIDE_KIND), GUIDE_KIND)
+}
+
+/** Whether a tab is the guide, which a pane holds at most once and which is therefore never copied. */
+function isGuide(state: LayoutState, tabId: TabId): boolean {
+  return state.tabs[tabId]?.kind === GUIDE_KIND
+}
+
+/** 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)
+  return pane.activeTabId === tabId && state.activePaneId === pane.id ? [] : [{ type: 'focusTab', tabId }]
+}
+
+/** Focus a pane: nothing to plan while it is the active one. */
+function planFocusPane(state: LayoutState, paneId: PaneId): readonly LayoutOp[] {
+  return state.activePaneId === paneId ? [] : [{ type: 'focusPane', paneId }]
+}
+
+/**
+ * Plan a tab's arrival in a docked pane: a guide arriving where one already is
+ * merges into it, anything else plans as the kit does.
+ * @param state - current layout.
+ * @param tabId - the arriving tab.
+ * @param toPaneId - the pane it arrives in.
+ * @param otherwise - the kit's plan for the move.
+ * @returns the operations.
+ */
+function arriving(state: LayoutState, tabId: TabId, toPaneId: PaneId, otherwise: () => readonly LayoutOp[]): readonly LayoutOp[] {
+  if (!isGuide(state, tabId)) return otherwise()
+  const existing = paneGuide(state, toPaneId)
+  if (existing === undefined || existing === tabId) return otherwise()
+  return [{ type: 'closeTab', tabId }, { type: 'focusTab', tabId: existing }]
+}
+
+/**
+ * Run one planner against a surface, settle what it left behind, and record the
+ * whole intent as one history entry.
+ * @param surface - the session's current surface.
+ * @param plan - the kit planner to consult.
+ * @param seedTitle - the guide type's display name, for a reseeded root pane.
+ * @returns the next surface, or the same one when the intent changes nothing.
+ */
+function advance(surface: SurfaceState, plan: SurfacePlan, seedTitle: () => string): SurfaceState {
+  const counter = counting(surface.minted)
+  const planned = plan(surface.layout, counter.mint)
+  if (planned.length === 0) return surface
+  // The settle planner reads the state the intent produces, so it is applied
+  // to a scratch copy first; the record then applies both parts once.
+  const after = replay(surface.layout, planned)
+  const settled = planSettle(after, counter.mint, id => makeGuideTab(id, seedTitle()))
+  const stepped = record(surface.history, surface.layout, [...planned, ...settled])
+  return { layout: stepped.state, history: stepped.history, minted: counter.used() }
+}
+
+/**
+ * Replace one session's surface, leaving every other session by reference.
+ *
+ * A session with no surface yet gets its initial one even when the intent
+ * changes nothing: materializing is itself the change.
+ */
+function seat(
+  state: SidebarRightState,
+  sessionId: string,
+  seedTitle: () => string,
+  next: (surface: SurfaceState) => SurfaceState,
+): Record<string, SurfaceState> {
+  const existing = state.bySession[sessionId]
+  const updated = next(existing ?? createSurface(seedTitle))
+  return updated === existing ? state.bySession : { ...state.bySession, [sessionId]: updated }
+}
+
+/** Declared write set; each entry is one settled intent. */
+type SidebarRightActions = {
+  open: (draft: SidebarRightState, sessionId: string) => void
+  setExpanded: (draft: SidebarRightState, sessionId: string, expanded: boolean) => void
+  toggleExpanded: (draft: SidebarRightState, sessionId: string) => void
+  setMode: (draft: SidebarRightState, sessionId: string, mode: DockMode) => void
+  splitPane: (draft: SidebarRightState, sessionId: string, paneId?: PaneId, settled?: (paneId: PaneId) => void) => void
+  openContent: (
+    draft: SidebarRightState,
+    sessionId: string,
+    intent: OpenContentIntent,
+    settled: (tabId: TabId) => void,
+  ) => void
+  duplicateTab: (draft: SidebarRightState, sessionId: string, tabId: TabId) => void
+  closeTab: (draft: SidebarRightState, sessionId: string, tabId: TabId) => void
+  focusTab: (draft: SidebarRightState, sessionId: string, tabId: TabId) => void
+  focusPane: (draft: SidebarRightState, sessionId: string, paneId: PaneId) => void
+  placeTab: (draft: SidebarRightState, sessionId: string, tabId: TabId, toPaneId: PaneId, index: number) => void
+  dropTab: (draft: SidebarRightState, sessionId: string, tabId: TabId, paneId: PaneId, zone: DockZone) => void
+  floatTab: (draft: SidebarRightState, sessionId: string, tabId: TabId, rect?: FloatRect) => void
+  unfloatPane: (draft: SidebarRightState, sessionId: string, paneId: PaneId) => void
+  moveFloat: (draft: SidebarRightState, sessionId: string, paneId: PaneId, x: number, y: number) => void
+  resizeFloat: (draft: SidebarRightState, sessionId: string, paneId: PaneId, rect: FloatRect) => void
+  resizeSplit: (draft: SidebarRightState, sessionId: string, splitId: SplitId, sizes: readonly number[]) => void
+  undo: (draft: SidebarRightState, sessionId: string) => void
+  redo: (draft: SidebarRightState, sessionId: string) => void
+}
+
+/** One direction through the recorded sequence. */
+type HistoryStepper =
+  (history: History, state: LayoutState) => { history: History; state: LayoutState } | undefined
+
+/** Step a surface through the history in one direction. */
+function stepped(surface: SurfaceState, step: HistoryStepper): SurfaceState {
+  const moved = step(surface.history, surface.layout)
+  return moved === undefined ? surface : { ...surface, layout: moved.state, history: moved.history }
+}
+
+/**
+ * Create the Sidebar store handle.
+ *
+ * The seed title arrives as a thunk rather than a string: a pane is seeded
+ * whenever one is created, which can be long after the store was built and in a
+ * language the user has since changed to.
+ * @param seedTitle - the guide type's display name, read at each mint.
+ * @returns the handle (spec, type, identity, and factory in one).
+ */
+export function createSidebarRightStore(
+  seedTitle: () => string,
+): EngineStoreHandle<SidebarRightState, SidebarRightActions> {
+  return defineStore({
+    init: (): SidebarRightState => ({ bySession: {} }),
+    actions: {
+      // Materialize a session's surface without changing it, so the first read
+      // after a session switch sees the collapsed default rather than nothing.
+      open: (d, sessionId: string) => { d.bySession = seat(d, sessionId, seedTitle, surface => surface) },
+      setExpanded: (d, sessionId: string, expanded: boolean) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planSetExpanded(state, expanded), seedTitle))
+      },
+      toggleExpanded: (d, sessionId: string) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planSetExpanded(state, !state.expanded), seedTitle))
+      },
+      // Switching presentation is recorded like any other change, so stepping
+      // back through the sequence puts the surface back the way it was drawn.
+      setMode: (d, sessionId: string, mode: DockMode) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planSetMode(state, mode), seedTitle))
+      },
+      // `settled` reports the pane the split created, synchronously, because
+      // actions return nothing; it is not called when nothing was split.
+      splitPane: (d, sessionId: string, paneId?: PaneId, settled?: (paneId: PaneId) => void) => {
+        d.bySession = seat(d, sessionId, seedTitle, (s) => {
+          const next = advance(s, (state, mint) => dockPaneIds(state).length >= 2
+            ? []
+            : planSplitPane(state, mint, paneId, id => makeGuideTab(id, seedTitle())), seedTitle)
+          if (settled !== undefined && next !== s) {
+            const before = new Set(dockPaneIds(s.layout))
+            for (const id of dockPaneIds(next.layout)) {
+              if (!before.has(id)) settled(id)
+            }
+          }
+          return next
+        })
+      },
+      // One entry carries the whole open: revealing the column (an open behind
+      // a collapsed panel is not an open), focusing or seating the tab, and
+      // closing the tab it replaces. `settled` reports the tab the planner
+      // landed on, synchronously, because actions return nothing.
+      openContent: (d, sessionId: string, intent, settled) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => advance(s, (state, mint) => {
+          const { kind, contentId, title, replaceTab: replace } = intent
+          const ops: LayoutOp[] = [...planSetExpanded(state, true)]
+          // A replaced tab lends its pane and slot; one that floats cannot (a
+          // floating pane holds one tab), so the new tab lands as if unplaced.
+          const replaced = replace === undefined ? undefined : findTabPane(state, replace)
+          const lent = replace !== undefined && replaced !== undefined && replaced.host === 'dock' ? replaced : undefined
+          const paneId = lent?.id ?? intent.paneId
+          const index = lent === undefined || replace === undefined ? undefined : lent.tabs.indexOf(replace)
+          // The guide is unique per pane: the pane it would land in may already
+          // hold one, which is then the tab this open settles on.
+          const held = kind === GUIDE_KIND ? paneGuide(state, paneId ?? activeDockPaneId(state)) : undefined
+          const planned = held !== undefined
+            ? { ops: [{ type: 'focusTab' as const, tabId: held }], tabId: held }
+            : planOpenContent(state, mint, {
+              kind,
+              contentId,
+              title,
+              ...paneId === undefined ? {} : { paneId },
+              ...index === undefined ? {} : { index },
+              ...intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened },
+            })
+          ops.push(...planned.ops)
+          if (replace !== undefined && replace !== planned.tabId) ops.push({ type: 'closeTab', tabId: replace })
+          settled(planned.tabId)
+          return ops
+        }, seedTitle))
+      },
+      // The guide is never copied: the copy would sit beside it in the same pane.
+      duplicateTab: (d, sessionId: string, tabId: TabId) => {
+        d.bySession = seat(d, sessionId, seedTitle, s =>
+          advance(s, (state, mint) => isGuide(state, tabId) ? [] : planDuplicateTab(state, mint, tabId).ops, seedTitle))
+      },
+      // 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.
+      closeTab: (d, sessionId: string, tabId: TabId) => {
+        d.bySession = seat(d, sessionId, seedTitle, s =>
+          advance(s, state => state.tabs[tabId] === undefined ? [] : [{ type: 'closeTab', tabId }], seedTitle))
+      },
+      focusTab: (d, sessionId: string, tabId: TabId) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planFocusTab(state, tabId), seedTitle))
+      },
+      focusPane: (d, sessionId: string, paneId: PaneId) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => advance(s, state => planFocusPane(state, paneId), seedTitle))
+      },
+      placeTab: (d, sessionId: string, tabId: TabId, toPaneId: PaneId, index: number) => {
+        d.bySession = seat(d, sessionId, seedTitle, s =>
+          advance(s, state => arriving(state, tabId, toPaneId, () => planPlaceTab(state, tabId, toPaneId, index)), seedTitle))
+      },
+      // Only a centre release lands in the target pane; an edge release makes a
+      // new pane, where nothing can already be.
+      dropTab: (d, sessionId: string, tabId: TabId, paneId: PaneId, zone: DockZone) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => advance(s, (state, mint) => {
+          if (zone === 'top' || zone === 'bottom') return []
+          if (zone !== 'center' && dockPaneIds(state).length >= 2) return []
+          const plan = (): readonly LayoutOp[] => planDropTab(state, mint, tabId, paneId, zone)
+          return zone === 'center' ? arriving(state, tabId, paneId, plan) : plan()
+        }, seedTitle))
+      },
+      floatTab: (d, sessionId: string, tabId: TabId, rect?: FloatRect) => {
+        d.bySession = seat(d, sessionId, seedTitle, s =>
+          advance(s, (state, mint) => planFloatTab(state, mint, tabId, rect).ops, seedTitle))
+      },
+      // A floating pane holds one tab; docking it back lands in the active
+      // docked pane, and only a guide is subject to the merge rule there.
+      unfloatPane: (d, sessionId: string, paneId: PaneId) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => advance(s, (state) => {
+          const guide = paneGuide(state, paneId)
+          const plan = (): readonly LayoutOp[] => planUnfloatPane(state, paneId)
+          return guide === undefined ? plan() : arriving(state, guide, activeDockPaneId(state), plan)
+        }, seedTitle))
+      },
+      moveFloat: (d, sessionId: string, paneId: PaneId, x: number, y: number) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => advance(s, () => [{ type: 'moveFloat', paneId, x, y }], seedTitle))
+      },
+      resizeFloat: (d, sessionId: string, paneId: PaneId, rect: FloatRect) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => advance(s, () => [{ type: 'resizeFloat', paneId, rect }], seedTitle))
+      },
+      resizeSplit: (d, sessionId: string, splitId: SplitId, sizes: readonly number[]) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => advance(s, () => planResizeSplit(splitId, sizes, 0.2), seedTitle))
+      },
+      undo: (d, sessionId: string) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => stepped(s, stepBack))
+      },
+      redo: (d, sessionId: string) => {
+        d.bySession = seat(d, sessionId, seedTitle, s => stepped(s, stepForward))
+      },
+    },
+  })
+}

+ 184 - 0
packages/client/ui-sidebar-right/src/client/tab-domain.ts

@@ -0,0 +1,184 @@
+/**
+ * The Tab domain: what a tab record carries that the layout does not.
+ *
+ * One `TabOccurrence` per open record, keyed by (session, tab id): where the
+ * tab was navigated to, an abort signal spanning the record's life, and the
+ * actions the tab may take on itself. Holding a record also pins its address in
+ * the resource model, so switching tabs unmounts a body without dropping its
+ * content.
+ *
+ * `sync` reconciles occurrences against one session's layout after every
+ * commit: a record that appeared is pinned, one that vanished (closed, or its
+ * open undone) is aborted and dropped. A record restored by undo is a new
+ * occurrence and is fetched again if the resource model already let it go. The
+ * controller syncs each session from that session's adopted store on every
+ * commit, on screen or not, so a record closed from another session's seat is
+ * aborted on that commit. The slot framework binds the navigation sources for
+ * each record's `useTabInfo` reader.
+ */
+import type { LayoutState, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { findTabPane } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { SidebarRightNavigationParams } from './contract/params.ts'
+import type { SidebarRightTabActions, SidebarRightTabNavigation, SidebarRightTabPlacement } from './contract/slots.ts'
+import type { SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, SidebarRightPlacement } from './service.ts'
+
+/**
+ * The navigation face a tab's actions call back into, aimed at the session the
+ * tab is in; nothing happens for a session whose store is not adopted.
+ */
+export interface SidebarRightNavigator {
+  /** Open a resource in one session; see `ISidebarRight.openResource`. */
+  openResourceIn(sessionId: SessionId, address: string, options?: SidebarRightOpenResourceOptions): void
+  /** Open a page type in one session; see `ISidebarRight.openTab`. */
+  openTabIn(sessionId: SessionId, kind: string, options?: SidebarRightOpenTabOptions): void
+  /** Close a tab of one session. */
+  closeIn(sessionId: SessionId, tabId: TabId): void
+}
+
+/** `ctx.resources.pin`: hold an address's content open for as long as `signal` lives. */
+export type PinResource = (address: string, signal: AbortSignal) => void
+
+/** What one open tab record holds beyond its layout entry. */
+export interface TabOccurrence {
+  readonly sessionId: SessionId
+  readonly tabId: TabId
+  /** Aborted when the record disappears or this package unloads. */
+  readonly signal: AbortSignal
+  /** The latest navigation aimed at the record; `set` on every `navigate`. */
+  readonly navigation: SnapshotStore<SidebarRightTabNavigation>
+  /** Stable for the occurrence's life, so a body may hold it. */
+  readonly tabActions: SidebarRightTabActions
+}
+
+/** An occurrence plus what only the domain touches. */
+interface Held extends TabOccurrence {
+  readonly controller: AbortController
+  /** The docked pane holding the record at the last sync; `undefined` while it floats or before any sync. */
+  paneId: PaneId | undefined
+  /** Whether the resource model has been asked to hold the address. */
+  pinned: boolean
+}
+
+/** Every session's occurrences. */
+export class TabDomain {
+  private readonly bySession = new Map<SessionId, Map<TabId, Held>>()
+
+  /**
+   * @param navigator - where tab actions go, aimed at the tab's session; the navigation controller.
+   * @param pin - `ctx.resources.pin`, called once per occurrence at its first sync.
+   */
+  constructor(
+    private readonly navigator: SidebarRightNavigator,
+    private readonly pin: PinResource,
+  ) {}
+
+  /**
+   * Reconcile one session's occurrences with its committed layout.
+   *
+   * Called by the seat after every commit, and only then: aborting a vanished
+   * record runs the types' cleanup, which writes their stores.
+   * @param sessionId - the session whose layout committed.
+   * @param layout - that session's layout as committed.
+   */
+  sync(sessionId: SessionId, layout: LayoutState): void {
+    const held = this.session(sessionId)
+    for (const [tabId, occurrence] of held) {
+      if (layout.tabs[tabId] !== undefined) continue
+      held.delete(tabId)
+      occurrence.controller.abort()
+    }
+    for (const tab of Object.values(layout.tabs)) {
+      const occurrence = held.get(tab.id) ?? this.hold(sessionId, tab.id, { address: tab.contentId, params: undefined, revision: 0 })
+      const pane = findTabPane(layout, tab.id)
+      occurrence.paneId = pane.host === 'dock' ? pane.id : undefined
+      if (occurrence.pinned) continue
+      occurrence.pinned = true
+      this.pin(occurrence.navigation.getSnapshot().address, occurrence.signal)
+    }
+  }
+
+  /**
+   * Read an occurrence created by navigation or committed-store reconciliation.
+   * @param sessionId - the session the record is in.
+   * @param tab - the record being drawn.
+   * @returns its occurrence.
+   * @throws when the record has not been reconciled or has disappeared.
+   */
+  occurrence(sessionId: SessionId, tab: Pick<TabRecord, 'id'>): TabOccurrence {
+    const occurrence = this.bySession.get(sessionId)?.get(tab.id)
+    if (occurrence === undefined) throw new Error(`sidebarRight: tab "${tab.id}" has no committed occurrence in session "${sessionId}"`)
+    return occurrence
+  }
+
+  /**
+   * Record that an `open` settled on a tab.
+   *
+   * A record the layout has not yet shown the seat gets its occurrence here, so
+   * the body's first render already carries the opener's `params`.
+   * @param sessionId - the session opened into.
+   * @param tabId - the tab the open settled on.
+   * @param target - the address and the opener's params.
+   */
+  navigate(sessionId: SessionId, tabId: TabId, target: { address: string; params: SidebarRightNavigationParams }): void {
+    const existing = this.session(sessionId).get(tabId)
+    if (existing === undefined) {
+      this.hold(sessionId, tabId, { ...target, revision: 1 })
+      return
+    }
+    existing.navigation.set({ ...target, revision: existing.navigation.getSnapshot().revision + 1 })
+  }
+
+  /** Abort every occurrence of every session; the package is unloading. */
+  dispose(): void {
+    for (const held of this.bySession.values()) {
+      for (const occurrence of held.values()) occurrence.controller.abort()
+    }
+    this.bySession.clear()
+  }
+
+  private session(sessionId: SessionId): Map<TabId, Held> {
+    let held = this.bySession.get(sessionId)
+    if (held === undefined) {
+      held = new Map()
+      this.bySession.set(sessionId, held)
+    }
+    return held
+  }
+
+  private hold(sessionId: SessionId, tabId: TabId, navigation: SidebarRightTabNavigation): Held {
+    const controller = new AbortController()
+    const { navigator } = this
+    // Where an open from this tab lands, read at call time because the tab may
+    // have been dragged since: `replaceTab: true` names this tab; otherwise the
+    // pane holding it, unless the caller named another.
+    const place = (placement: SidebarRightTabPlacement): SidebarRightPlacement => ({
+      ...placement.replaceTab === true
+        ? { replaceTab: tabId }
+        : held.paneId === undefined ? {} : { paneId: held.paneId },
+      ...placement.paneId === undefined ? {} : { paneId: placement.paneId },
+      ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened },
+    })
+    const held: Held = {
+      sessionId,
+      tabId,
+      controller,
+      signal: controller.signal,
+      navigation: createSnapshotStore(navigation),
+      paneId: undefined,
+      pinned: false,
+      tabActions: {
+        openResource: (address, options = {}) => {
+          navigator.openResourceIn(sessionId, address, { ...place(options), params: options.params })
+        },
+        openTab: (kind, options = {}) => {
+          navigator.openTabIn(sessionId, kind, { ...place(options), params: options.params })
+        },
+        close: () => { navigator.closeIn(sessionId, tabId) },
+      },
+    }
+    this.session(sessionId).set(tabId, held)
+    return held
+  }
+}

+ 60 - 0
packages/client/ui-sidebar-right/src/client/tab-info.ts

@@ -0,0 +1,60 @@
+/** Slot-owned tab information derived from framework-bound store and navigation hooks. */
+import { useMemo } from 'react'
+import { findTabPane } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { KeyedSnapshotSelectorHook, PropsStore, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SidebarRightTabActions, SidebarRightTabNavigation, UseSidebarRightTabInfo } from './contract/slots.ts'
+import type { createSidebarRightStore } from './stores.ts'
+
+/** Stable dispatch identity and framework hooks; never passed as tab component props. */
+export interface TabHookContext {
+  readonly tabId: TabId
+  readonly title: boolean
+  readonly fullscreen: boolean
+  readonly signal: AbortSignal
+  readonly actions: SidebarRightTabActions
+  readonly useStore: PropsStore<ReturnType<typeof createSidebarRightStore>>['useStore']
+  readonly useTabNavigation: KeyedSnapshotSelectorHook<SidebarRightTabNavigation>
+}
+
+/**
+ * Bind a tab occurrence without subscribing or creating records during factory evaluation.
+ * @param standard - framework session identity.
+ * @param context - stable record lifetime and framework-bound readers.
+ * @returns the tab information hook.
+ */
+export const tabInfoFactory: SlotHookFactory<'sidebar.right.pane.tab', UseSidebarRightTabInfo> = (standard, context) => {
+  const { sessionId } = standard
+  const { tabId, title, fullscreen, signal, actions, useStore, useTabNavigation } = context
+  return function useTabInfo() {
+    const layout = useStore(state => state.bySession[sessionId]?.layout)
+    const navigation = useTabNavigation(tabId)
+    return useMemo(() => {
+      const tab = layout?.tabs[tabId]
+      if (layout === undefined || tab === undefined || navigation === undefined) {
+        throw new Error(`sidebarRight: tab "${tabId}" is not committed in session "${sessionId}"`)
+      }
+      const pane = findTabPane(layout, tabId)
+      return {
+        sidebar: { expanded: layout.expanded, fullscreen },
+        panel: { id: pane.id },
+        tab: {
+          ...tab,
+          visible: pane.host === 'float' || (layout.expanded && (title || pane.activeTabId === tabId)),
+          navigation,
+          signal,
+          actions,
+        },
+      }
+    }, [layout, navigation, tabId, title, fullscreen, signal, actions])
+  }
+}
+
+/**
+ * Forward the framework-bound tab hook to a guide replacement.
+ * @param _standard - the guide's framework standard props.
+ * @param useTabInfo - the enclosing tab's framework-bound reader.
+ * @returns the same reader for the replacement.
+ */
+export const guideTabInfoFactory: SlotHookFactory<'sidebar.right.tab.guide', UseSidebarRightTabInfo> =
+  (_standard, useTabInfo) => useTabInfo

+ 399 - 0
packages/client/ui-sidebar-right/src/client/tab-registry.ts

@@ -0,0 +1,399 @@
+/**
+ * Stage one of tab-type registration: what a type IS.
+ *
+ * A registration is purely static — which addresses the type recognizes, how it
+ * ranks against other types that recognize the same one, what the tab chip says,
+ * and whether the type offers an entry box on the guide page. Nothing here is
+ * per-tab, per-session, or a runtime hook: stage two is the keyed
+ * `sidebar.right.pane.tab` registration that supplies the body under the same
+ * `kind`, and everything a body needs at runtime arrives in its props.
+ *
+ * Address recognition follows VS Code's editor resolver: a glob declaration
+ * narrows the candidates, an optional `canOpen` predicate vetoes, and the
+ * survivors are ranked by priority band, then by matched-pattern length, then by
+ * registration order. Addresses are `scheme://` URIs; the one local change to
+ * VS Code's glob rule is that a pattern containing `:` matches the whole address
+ * (`dsh-resource://file/**`, `sidebar://guide`) rather than the URI's path.
+ *
+ * A kind may carry one `builtin` and one `extension` registration at once: the
+ * extension is the one in force — claims, `get`, the guide page, and the body
+ * and title, which the seat finds under the definition's own `id` — and the
+ * builtin resumes when the extension unregisters. Everything else colliding on
+ * a kind throws, as does a second registration of an `id`.
+ *
+ * Thunked copy (`title`, `guide[].title`) is read again on every use, so a
+ * language change needs no re-registration.
+ */
+import type { ComponentType } from 'react'
+import type { Context } from '@deepseek-ai/cordis'
+import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
+import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
+// The POSIX build: the browser bundle must not reach for node's `path`, and
+// addresses are `/`-separated regardless of the host platform.
+import picomatch from 'picomatch/posix'
+
+/**
+ * How strongly a type wants an address it recognizes, as one of three literal
+ * bands (a string, not an imported constant, so a type shipped from another
+ * package needs no runtime import from here).
+ *
+ * - `extension` — a type from outside the product, and the highest: a type that
+ *   declares nothing outranks every viewer shipped here, exactly as in VS Code.
+ *   It is also the band that may take over a `builtin` kind.
+ * - `builtin` — the ordinary band for types shipped with the product.
+ * - `fallback` — plain-content viewers that anything more specific should beat.
+ *   VS Code's text editor holds this position implicitly; ours is a separate
+ *   package, so it says so.
+ */
+export type SidebarRightTabPriority = 'extension' | 'builtin' | 'fallback'
+
+/** Rank of each band, highest first. */
+const RANKS: Readonly<Record<SidebarRightTabPriority, number>> = {
+  extension: 3,
+  builtin: 2,
+  fallback: 1,
+}
+
+/** 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). */
+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.
+   */
+  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. */
+  readonly icon?: ComponentType<IconProps>
+}
+
+/** A guide entry as the registry lists it: with the kind of the type that contributed it, which is what picking it opens. */
+export interface SidebarRightGuideBox extends SidebarRightGuideEntry {
+  readonly kind: string
+}
+
+
+/** One registered tab type: its static face, and nothing else. */
+export interface SidebarRightTabDefinition {
+  /**
+   * This implementation's identity in the tab system, unique across every
+   * registration (a package name is the natural value). A kind is not unique —
+   * an extension may take a builtin's over — so the implementation carries its
+   * own name, and it is the key its body and title register under in the
+   * `sidebar.right.pane.tab` and `sidebar.right.pane.tab.title` seats.
+   */
+  readonly id: string
+  /** Type discriminator: what the tabs of this type are, and what `openTab` names. */
+  readonly kind: string
+  /**
+   * Resource-address globs this type recognizes; omit for a page type, which is
+   * opened by kind and recognizes no address.
+   *
+   * A pattern containing `:` is matched against the whole address
+   * (`dsh-resource://file/**`); one without is matched against the URI's path at
+   * any depth (`*.md` matches `dsh-resource://file/session/s1/home/me/notes.md`),
+   * and an address that is not a URI matches no such pattern. Matching ignores
+   * case and does not hide dotfiles.
+   */
+  readonly patterns?: readonly string[]
+  /** Defaults to `extension`: a type that says nothing is one from outside the product. */
+  readonly priority?: SidebarRightTabPriority
+  /**
+   * Veto an address this type's globs matched.
+   *
+   * Synchronous and cheap: it runs on every routing decision. Omit it to accept
+   * every match.
+   * @param address - the matched address.
+   * @returns whether this type will open it.
+   */
+  readonly canOpen?: (address: string) => boolean
+  /**
+   * The tab chip's initial text, captured into the layout record at open time.
+   * @param address - the address being opened.
+   * @returns the title in the current language.
+   */
+  readonly title: (address: string) => string
+  /** Entry boxes for the guide page. Omit to stay off it. */
+  readonly guide?: readonly SidebarRightGuideEntry[]
+}
+
+/** What a routing decision settles on: who draws the address, and as what. */
+export interface SidebarRightTabClaim {
+  /** The claiming type. */
+  readonly kind: string
+  /**
+   * Stable identity of the content, which is the address itself.
+   *
+   * Two opens of the same address are the same tab, which is what makes opening
+   * idempotent.
+   */
+  readonly contentId: string
+  /** Title for the tab chip. */
+  readonly title: string
+}
+
+/** A registered type with its patterns compiled. */
+interface Registered {
+  readonly definition: SidebarRightTabDefinition
+  readonly band: SidebarRightTabPriority
+  /** One matcher per declared pattern, in declaration order. */
+  readonly matchers: readonly { readonly pattern: string; readonly test: (address: string) => boolean }[]
+  /** Registration position across every kind, the last tiebreaker. */
+  readonly order: number
+}
+
+/**
+ * Everything registered under one kind: the registration in force and, while
+ * an `extension` holds a kind a `builtin` also registered, the builtin it
+ * shadows. A kind is in the registry's map exactly while something is in force
+ * for it, so a held slot always answers.
+ */
+interface KindSlot {
+  inForce: Registered
+  shadowed: Registered | undefined
+}
+
+/**
+ * Whether a band may join a held kind: an `extension` and a `builtin` pair up
+ * once, and a `fallback` shares its kind with nothing.
+ */
+function coexists(slot: KindSlot, band: SidebarRightTabPriority): boolean {
+  return band !== 'fallback' && slot.inForce.band !== 'fallback' && slot.inForce.band !== band && slot.shadowed === undefined
+}
+
+/** How a candidate ranked, kept only while `candidates` is sorting. */
+interface Ranked {
+  readonly definition: SidebarRightTabDefinition
+  readonly rank: number
+  /** Length of the longest pattern that matched, VS Code's specificity measure. */
+  readonly length: number
+  /** Registration position, the last tiebreaker. */
+  readonly order: number
+}
+
+/**
+ * The address's URI path: what a pattern with no scheme separator matches
+ * against. `dsh-resource://file/session/s1/home/me/b.md` gives `/session/s1/home/me/b.md`;
+ * `sidebar://guide` gives `''`; an address that is not a URI gives nothing.
+ */
+function pathOf(address: string): string | undefined {
+  try {
+    return new URL(address).pathname
+  } catch {
+    // The only thrower is the URL parser rejecting a non-URI address, which by
+    // the rule above matches no path pattern.
+    return undefined
+  }
+}
+
+/** Compile one declared pattern into the test the router runs. */
+function matcherFor(pattern: string): (address: string) => boolean {
+  // `basename: true` is what makes `*.md` match at any depth; it applies only to
+  // patterns without a separator, which is exactly the path case.
+  const whole = pattern.includes(':')
+  const match = picomatch(pattern, { nocase: true, dot: true, ...whole ? {} : { basename: true } })
+  return (address) => {
+    if (whole) return match(address)
+    const path = pathOf(address)
+    return path !== undefined && match(path)
+  }
+}
+
+/**
+ * The registered tab types.
+ *
+ * Registration order is part of the contract: it breaks ties between types that
+ * recognize an address equally well.
+ */
+export class SidebarRightTabRegistry {
+  private readonly kinds = new Map<string, KindSlot>()
+  private readonly ids = new Set<string>()
+  private readonly listeners = new Set<() => void>()
+  private registrations = 0
+  private cached: readonly SidebarRightTabDefinition[] = []
+  private guideEntries: readonly SidebarRightGuideBox[] = []
+
+  /** @param ctx - Context whose effects own the contributed types. */
+  constructor(private readonly ctx: Context) {}
+
+  /**
+   * Register one tab type for the caller's lifetime.
+   *
+   * The caller holds the returned disposer inside its own `ctx.effect`, so a
+   * type's registration lives exactly as long as the plugin that contributed it.
+   * An `extension` may register a kind a `builtin` already holds and takes it
+   * over until it unregisters; a second registration in the same band, or any
+   * registration meeting a `fallback` of the same kind, is a wiring mistake, and
+   * so is an `id` already in use.
+   * @param definition - the contributed type.
+   * @returns idempotent disposer.
+   * @throws when the id is taken, or the kind is already registered in a way this one cannot coexist with.
+   */
+  register(definition: SidebarRightTabDefinition): () => void {
+    const { id, kind } = definition
+    const band = definition.priority ?? DEFAULT_BAND
+    if (this.ids.has(id)) throw new Error(`sidebarRight: tab type id "${id}" is already registered`)
+    const held = this.kinds.get(kind)
+    if (held !== undefined && !coexists(held, band)) {
+      throw new Error(`sidebarRight: tab kind "${kind}" is already registered (${held.inForce.band})`)
+    }
+    this.registrations += 1
+    const entry: Registered = {
+      definition,
+      band,
+      matchers: (definition.patterns ?? []).map(pattern => ({ pattern, test: matcherFor(pattern) })),
+      order: this.registrations,
+    }
+    const dispose = this.ctx.effect(() => {
+      this.ids.add(id)
+      const slot = this.enter(kind, entry)
+      this.refresh()
+      return () => {
+        this.ids.delete(id)
+        this.leave(kind, slot, entry)
+        this.refresh()
+      }
+    }, `sidebarRight.tabs.register(${JSON.stringify(id)})`)
+    return () => { void dispose() }
+  }
+
+  /** Add a registration to its kind's slot, the higher band in force; `coexists` has already admitted it. */
+  private enter(kind: string, entry: Registered): KindSlot {
+    const held = this.kinds.get(kind)
+    if (held === undefined) {
+      const slot: KindSlot = { inForce: entry, shadowed: undefined }
+      this.kinds.set(kind, slot)
+      return slot
+    }
+    if (RANKS[entry.band] > RANKS[held.inForce.band]) {
+      held.shadowed = held.inForce
+      held.inForce = entry
+    } else {
+      held.shadowed = entry
+    }
+    return held
+  }
+
+  /** Remove a registration from its kind's slot: a shadowed builtin resumes, and an emptied kind is freed. */
+  private leave(kind: string, slot: KindSlot, entry: Registered): void {
+    if (slot.inForce !== entry) {
+      slot.shadowed = undefined
+    } else if (slot.shadowed === undefined) {
+      this.kinds.delete(kind)
+    } else {
+      slot.inForce = slot.shadowed
+      slot.shadowed = undefined
+    }
+  }
+
+  /** Every kind's registration in force, in registration order. */
+  private active(): Registered[] {
+    return [...this.kinds.values()].map(slot => slot.inForce).sort((left, right) => left.order - right.order)
+  }
+
+  /**
+   * Registered types in registration order.
+   * @returns reference-stable entries.
+   */
+  entries(): readonly SidebarRightTabDefinition[] {
+    return this.cached
+  }
+
+  /**
+   * Every type in force's guide entries, in `order`, each naming the kind it opens.
+   * @returns reference-stable entries.
+   */
+  guide(): readonly SidebarRightGuideBox[] {
+    return this.guideEntries
+  }
+
+  /**
+   * The type in force for a kind.
+   * @param kind - the type discriminator.
+   * @returns the type, or `undefined` when nothing registered it.
+   */
+  get(kind: string): SidebarRightTabDefinition | undefined {
+    return this.kinds.get(kind)?.inForce.definition
+  }
+
+  /**
+   * Every type that would open an address, best first.
+   *
+   * Ranked by priority band, then by the length of the pattern that matched,
+   * then by registration order. Types whose `canOpen` vetoes are absent.
+   * @param address - the address a caller wants opened.
+   * @returns the ranked types; empty when nothing recognizes the address.
+   */
+  candidates(address: string): readonly SidebarRightTabDefinition[] {
+    const ranked: Ranked[] = []
+    for (const { definition, band, matchers, order } of this.active()) {
+      let length = -1
+      for (const matcher of matchers) {
+        if (matcher.test(address) && matcher.pattern.length > length) length = matcher.pattern.length
+      }
+      if (length < 0) continue
+      if (definition.canOpen !== undefined && !definition.canOpen(address)) continue
+      ranked.push({ definition, rank: RANKS[band], length, order })
+    }
+    ranked.sort((left, right) =>
+      right.rank - left.rank || right.length - left.length || left.order - right.order)
+    return ranked.map(entry => entry.definition)
+  }
+
+  /**
+   * Decide which type opens an address, and as what.
+   *
+   * Without `kind`, the best candidate wins. With `kind`, that type opens the
+   * address if its `canOpen` agrees — its globs are not consulted, because
+   * naming the type IS the decision.
+   *
+   * An address no type will open is a wiring mistake, not a user error, so this
+   * throws rather than reporting absence.
+   * @param address - the address a caller wants opened.
+   * @param kind - a type named by the caller, overriding the ranking.
+   * @returns the claiming type and the record to open.
+   */
+  claim(address: string, kind?: string): SidebarRightTabClaim {
+    if (kind !== undefined) {
+      const definition = this.get(kind)
+      if (definition === undefined) {
+        throw new Error(`sidebarRight: no tab type is registered as "${kind}"`)
+      }
+      if (definition.canOpen !== undefined && !definition.canOpen(address)) {
+        throw new Error(`sidebarRight: tab type "${kind}" refuses "${address}"`)
+      }
+      return { kind, contentId: address, title: definition.title(address) }
+    }
+    const [chosen] = this.candidates(address)
+    if (chosen === undefined) {
+      throw new Error(`sidebarRight: no registered tab type claims "${address}"`)
+    }
+    return { kind: chosen.kind, contentId: address, title: chosen.title(address) }
+  }
+
+  /**
+   * Observe low-frequency registry changes.
+   * @param listener - synchronous invalidation callback.
+   * @returns unsubscribe callback.
+   */
+  subscribe(listener: () => void): () => void {
+    this.listeners.add(listener)
+    return () => { this.listeners.delete(listener) }
+  }
+
+  private refresh(): void {
+    this.cached = this.active().map(entry => entry.definition)
+    this.guideEntries = this.cached
+      .flatMap(definition => (definition.guide ?? []).map(entry => ({ ...entry, kind: definition.kind })))
+      .sort((left, right) => left.order - right.order)
+    notifySubscribers(this.listeners, '[ui-sidebar-right] tab registry')
+  }
+}

+ 79 - 0
packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.module.css

@@ -0,0 +1,79 @@
+/* The guide body's own sheet: its domain owns these rules, so the shell's
+   stylesheet does not carry them. Tokens only. */
+.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;
+}
+
+.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;
+}
+
+.entry {
+  display: flex;
+  gap: 8px;
+  align-items: flex-start;
+  padding: 10px 12px;
+  color: inherit;
+  font: inherit;
+  text-align: left;
+  background: transparent;
+  border: 0.5px solid var(--dsw-alias-border-l1);
+  border-radius: 8px;
+  cursor: pointer;
+}
+
+.entry:hover {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.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);
+  font-size: var(--dsh-content-font-size, 14px);
+  line-height: 1.4;
+}
+
+.entryDescription {
+  color: var(--dsw-alias-label-secondary);
+  font-size: var(--dsh-content-font-size-secondary, 13px);
+  line-height: 1.4;
+}

+ 84 - 0
packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx

@@ -0,0 +1,84 @@
+/**
+ * The guide tab's body: a chain host, and the guide it falls back to.
+ *
+ * The chain is the replacement seam. A product with its own idea of what an
+ * empty sidebar should say registers into `sidebar.right.tab.guide`, and its entry
+ * takes the whole body; with no entry, or with every entry declining, the guide
+ * below renders. The shipped guide is the owner's fallback rather than a chain
+ * 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.
+ */
+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 { SidebarRightGuideBox } from '../../tab-registry.ts'
+import css from './GuideBody.module.css'
+
+/** What the guide body needs from its host beyond the framework shares. */
+export interface GuideInjected {
+  /** The registry's guide entries in `order`; observable, so a type registering later appears. */
+  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. */
+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. */
+function EntryBox({ entry, onPick }: { entry: SidebarRightGuideBox; onPick: (entry: SidebarRightGuideBox) => void }): ReactNode {
+  const Icon = entry.icon
+  return (
+    <button
+      type="button"
+      className={css.entry}
+      data-sidebar-right-guide-entry={entry.kind}
+      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>
+    </button>
+  )
+}
+
+/** The shipped guide: what the column is for, and the doors out of it. */
+function ShippedGuide({ entries, onPick, t }: {
+  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>
+      )}
+    </div>
+  )
+}
+
+/** The guide tab's body, replaceable through its chain child. */
+export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain, t }: 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} />
+    ),
+  } satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> }
+  return renderSlotChain('sidebar.right.tab.guide', {}, options)
+}

+ 27 - 0
packages/client/ui-sidebar-right/src/client/tabs/guide/definition.ts

@@ -0,0 +1,27 @@
+/**
+ * Stage one of the guide type's registration: what it IS.
+ */
+import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
+import type { SidebarRightTabDefinition } from '../../tab-registry.ts'
+import { GUIDE_KIND } from '../../contract/seed.ts'
+
+/** The shipped guide implementation's identity: the key its body registers under. */
+export const GUIDE_ID = '@deepseek-ai/dsh-client-ui-sidebar-right/guide'
+
+/**
+ * The guide type's registry definition.
+ *
+ * A page type: it recognizes no resource address, because a guide views
+ * nothing, and is opened by kind; `builtin` is the ordinary band for a type
+ * shipped here.
+ * @param t - namespace-bound translate, read fresh on every title call.
+ * @returns the definition to register.
+ */
+export function guideDefinition(t: TranslateNS<'sidebarRight'>): SidebarRightTabDefinition {
+  return {
+    id: GUIDE_ID,
+    kind: GUIDE_KIND,
+    priority: 'builtin',
+    title: () => t('tab.guide.title'),
+  }
+}

+ 6 - 0
packages/client/ui-sidebar-right/src/css-modules.d.ts

@@ -0,0 +1,6 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}
+
+declare module '*.css'

+ 4 - 0
packages/client/ui-sidebar-right/src/index.ts

@@ -0,0 +1,4 @@
+/** Pure host half; the whole Sidebar lives in the browser export. */
+
+/** Host plugin body: the Sidebar contributes nothing to the host tree. */
+export function apply(): void {}

+ 48 - 0
packages/client/ui-sidebar-right/tsconfig.json

@@ -0,0 +1,48 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../api/session-controller/tsconfig.client.json"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../resources"
+    },
+    {
+      "path": "../store"
+    },
+    {
+      "path": "../ui-conversation"
+    },
+    {
+      "path": "../ui-dockkit"
+    },
+    {
+      "path": "../ui-layout"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-session"
+    },
+    {
+      "path": "../ui-slots"
+    }
+  ]
+}

+ 3 - 0
packages/client/ui-sidebar-right/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-right', ['lib/types/index.js'])