feat(sidebar): retain session views across tab presentation changes

This commit is contained in:
imccyu committed 2026-09-21 00:58:23 +08:00
1 parent bf712e1404
commit 96b2aca346
15 files changed
+493 -125

No files matched your search

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md
2026-09-20-sidebar-retained-tab-layout.md: ed5404009d6d8844c54aef224eac2dd238dc7a4c
2026-09-20-sidebar-retained-tab-layout.zh.md: 0539dee38c9b2b9ecc1f5e9b8d1608dd9b5ea518
@@ -0,0 +1,83 @@
# Agent Note: Stable Sidebar tab mounting and Session retention
Status: implemented
English | [中文](2026-09-20-sidebar-retained-tab-layout.zh.md)
## Problem
Restoring Session-specific tab records and URLs does not preserve a live browsing context. The recursive docked renderer mounts only an active body, a separate floating portal creates another body, and changing a Session binding remounts Session-scoped slots. Electron 44's `WebViewElement.disconnectedCallback` detaches and resets its guest, so preserving a React key or caching an element reference cannot compensate for disconnected ancestors.
The first [Desktop Browser implementation](../feature/2026-09-20-desktop-browser-webview.md) keeps its guest under a fixed body-level parent and follows a placeholder with RAF measurements. User trials report working float, collapse and Session switches with that implementation; this change replaces its geometry mechanism, not a demonstrated failure of those interactions. Real parent layout must own content size, clipping and overlap.
## Decision
Sidebar owns live-body retention, independently of Browser navigation and Workspace storage. A retained page's DOM ancestors remain connected and unchanged during Session switches, tab selection, pane moves, collapse, floating and docking back. Browser Presentation attaches directly inside its content container and performs no placeholder measurement or overlay positioning.
### Layout ownership
- `DockLayout` renders the Sidebar's one or two horizontal panes with stable tab siblings. The existing recursive `DockSurface` and separate `FloatLayer` remain available for general split-tree rendering; the layout engine and serialized format are unchanged.
- Every tab has a stable Grid cell, frame, Header container and Body container. Explicit Grid columns determine docking placement; Flex assigns Header and Body height. Floating changes that same frame to `position: fixed` without changing its parent or replacing Body.
- A floating frame's zero-sized Grid cell establishes a stacking context at the float layer. CSS `order` expresses float depth within that layer; DOM order stays sorted by tab identity. Raising many floats cannot increment their layer past menus. Selection and strip order never reorder the content-node list.
- Common ancestors have no transform, clipping or enclosing stacking context. Docked cells own collapse transforms and visibility; each frame clips its content. Fullscreen changes the same root's width and docked layer. Existing window-drag region pulses remain, but no RAF copies content positions.
- Split and float gestures retain their existing measurements and operation state. These update user-selected split fractions and float rectangles, not a second Browser rectangle. Browser resize and content sizing use normal CSS layout.
### Session and tab lifetime
`SidebarRightTabDefinition.keepMounted` enables lazy retention for a type. Desktop Browser enables it; other types keep visibility-based body mounting. A retained body is first created on display, then survives hiding until its occurrence or provider lifetime ends. The policy is not persisted and does not select a storage partition.
`SidebarSessionView` acquires and releases one `SessionReference` using the independent `sidebarView` source, and owns its committed-mount count and retained-Body holds. `SidebarSessionViews` manages selection, retention policy and view indexes; it delegates lifetime operations to the view rather than mutating its counts or releasing its reference. `RightbarRoot` receives `SidebarSessionViewSnapshot` values and the existing injected callbacks, not resource-owner objects. It renders each through its own explicit `SessionProvider`, with a stable Session key; the renderer's general Session-remount semantics remain unchanged.
Tabs contribute Body-retention holds rather than independent Session references. Those holds follow the existing occurrence signal, including a new signal after close-and-undo; the view does not create a second tab lifetime. A retired view stays in the collection's reference index until its own disposal removes it, so plugin shutdown can also reach views whose React mounts have not finished.
Only the selected Session reports frame occupancy or binds public Sidebar navigation. Background tab actions still address their own adopted store. `tab.visible` includes Session and global-panel visibility; floating does not make a background Session visible. A Session change or global panel hides its whole content tree, while Sidebar collapse hides only docked cells and leaves foreground floats visible. Hidden content is inaccessible to pointer and keyboard input, and hidden body focus is blurred.
A background Session with no initialized retained body leaves the view list. Its reference releases after its committed React root unmounts, avoiding scope disposal underneath a mounted subtree. Plugin disposal releases all references, including retired views awaiting unmount. The Sidebar plugin depends on `sessions` and `uiSession`, so Cordis unloads it when either dependency ends; `ui-session` owns binding invalidation. Views do not register disposal callbacks on the shared Session Context. Catalog absence alone is not treated as deletion. Unvisited Browser tabs and unrelated Sessions are not eagerly opened.
### Provider responsibilities
`BrowserFrame` remains navigation and observable page state. Presentation creates and attaches the carrier DOM, without pane, clipping or stacking knowledge. Electron's provider receives physical attach/detach callbacks: hiding retained content emits neither, while an actual unmount cancels pending attachment and releases the guest. A subsequent physical mount recreates it from its known address. Disposal waits for pending creation and guest releases; an acquisition completing after cancellation is released rather than attached.
The [Desktop Browser decision](../feature/2026-09-20-desktop-browser-webview.md) retains guest security, shared IPC declarations and Workspace partition ownership. Separate tabs and Sessions never share one page instance merely because they share a storage partition. Web remains disabled by default and retains its iframe carrier when explicitly enabled.
### Retention versus recovery
| Operation | Content handling |
|---|---|
| Switch tabs | Hide the old retained cell; display the target's existing body. |
| Split, reorder or move to another pane | Change logical placement and Grid assignment, without moving Body. |
| Float, dock back or raise | Change the same frame's CSS mode or cell paint order. |
| Collapse Sidebar | Hide docked cells; foreground floats remain visible. |
| Session A → B → A | Hide and reshow A's original tree instead of loading its saved URL. |
| Close, provider unload or actual generation termination | Release the body and guest; undo creates a new occurrence. |
| Application restart, window reload or guest crash | Use provider recovery or retry; page memory and native history are not durable. |
[Layout and provider recovery](2026-09-14-sidebar-layout-provider-recovery.md) remains independent: persisted tab identity, placement and address support cold starts, not DOM serialization. A hidden page may continue scripts, media and networking.
## Alternatives considered
**Hide panes only.** Collapse works, but moving between panes or into another floating portal still changes content ancestors.
**Retain bodies only in the foreground Session.** Session-slot remounting still disconnects the page and fails to preserve the existing Session-switch experience.
**Give each tab its own Session reference.** A view's tabs share one `SessionProvider`, whose reference must exist before their bodies render. Per-view ownership covers that parent and its children without an additional reference lifecycle for each tab.
**Cache and reparent DOM.** Ordinary reparenting invokes Electron disconnection. Atomic-move dependencies, private attachment APIs and lifecycle monkey-patches are not used.
**Keep the fixed Browser overlay with event-driven measurement or CSS anchor positioning.** Content remains separate from the layout it imitates; the content itself must participate in Grid/Flex.
**Use native popovers.** Top-layer elements cannot be covered by ordinary Portal content through `z-index`. Migrating menus and dialogs into another stacking system expands the work beyond Sidebar; the implementation preserves the existing Portal and layer model.
**Raise floats by increasing their root z-index.** An unbounded float count can cross menu and modal layers. Equal-layer Grid cells with CSS paint order retain both stacking and DOM identity.
**Save the URL and rebuild when hidden.** This cannot preserve forms, script state, scroll position or native history. It is recovery after loss, not live retention.
## Consequences
Layout correctness belongs to Sidebar/DockKit rather than Browser. The cost is stable content containers and explicit Session-reference ownership: background retention holds Session scopes and subscriptions as well as page memory. There is no implicit LRU or idle timeout; future reclamation requires an explicit suspension/recovery policy. Other tab types do not automatically acquire these costs.
The change affects Sidebar chrome, float stacking, focus and platform styles, not just the Browser package. The owning [docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) and Desktop Browser notes remain active for their independent engine, navigation, storage and security decisions. Their separated-content presentation is superseded here; layout persistence and tab-type navigation remain applicable.
## Verification
Focused TypeScript compilation covers Sidebar, Browser and Desktop source. DockKit, Sidebar, Browser and Desktop bundles and the Web shell build complete. No tests or recordings were added or run, and the running Electron was not restarted. The stable-tree implementation still requires real Electron verification of guest identity, form and history retention, multi-float paint order, resizing, clipping, drop hints, focus and platform window controls. Compilation and bundling are not evidence that these runtime observations pass.
@@ -0,0 +1,83 @@
# Agent Note: Sidebar 的稳定 Tab 挂载与会话保活
Status: implemented
[English](2026-09-20-sidebar-retained-tab-layout.md) | 中文
## Problem
恢复各会话的 Tab 记录和 URL,并不能保留活的浏览上下文。递归停靠渲染只挂载活动 Body,独立浮动 Portal 会创建另一个 Body,切换会话绑定还会重挂载会话级 slot。Electron 44 的 `WebViewElement.disconnectedCallback` 会 detach 并重置 guest,因此保持 React key 或缓存元素引用不能抵消祖先断开。
首版 [Desktop Browser 实现](../feature/2026-09-20-desktop-browser-webview.zh.md)把 guest 留在 body 下的固定父节点中,通过 RAF 测量跟随占位元素。用户试用该实现时报告浮动、收起和切会话均可工作;本次替换的是其几何处理方式,并非这些交互已被证实存在故障。内容尺寸、裁剪与重叠必须由真实父节点布局负责。
## Decision
Sidebar 负责活 Body 的保留,不依赖 Browser 导航和 Workspace 存储。保活页面的 DOM 祖先在切会话、选 Tab、跨 Pane 移动、收起、浮动和回停靠时保持连接与身份不变。Browser Presentation 直接挂载到内容容器内,不做占位元素测量或浮层定位。
### 布局所有权
- `DockLayout` 用稳定的 Tab 兄弟节点渲染 Sidebar 的一个或两个水平 Pane。现有递归 `DockSurface` 和独立 `FloatLayer` 仍可用于通用分割树渲染;布局引擎和序列化格式不变。
- 每个 Tab 都有稳定的 Grid cell、frame、Header 容器和 Body 容器。显式 Grid 列决定停靠归属,Flex 分配 Header 与 Body 的高度。浮动只把同一个 frame 改为 `position: fixed`,不改父节点或替换 Body。
- 浮动 frame 的零尺寸 Grid cell 在浮窗层建立层叠上下文。CSS `order` 表达该层内的浮窗深度,DOM 顺序始终按 Tab 身份排序。大量浮窗置顶不会把层级递增到菜单之上;选中项和 Tab 条排序均不重排内容节点列表。
- 共同祖先不设置 transform、裁剪或包住全部内容的层叠上下文。停靠 cell 负责收起 transform 和显隐,各 frame 裁剪自身内容。Fullscreen 修改同一个根的宽度和停靠层级。窗口拖动区域的既有脉冲仍保留,但没有 RAF 复制内容位置。
- 分栏与浮窗手势沿用原有测量和操作状态,更新用户选择的分栏比例与浮窗矩形,而非维护第二份 Browser 矩形。窗口缩放和内容尺寸变化由普通 CSS 布局处理。
### 会话与 Tab 生命周期
`SidebarRightTabDefinition.keepMounted` 为类型启用懒保活。Desktop Browser 启用它,其他类型继续按可见性挂载 Body。保活 Body 在首次显示时创建,随后隐藏不卸载,直到 occurrence 或提供方生命周期结束。该策略不持久化,也不选择存储分区。
`SidebarSessionView` 使用独立的 `sidebarView` 来源申请、释放一个 `SessionReference`,并管理自己的已提交挂载计数和 Body 保活持有。`SidebarSessionViews` 管理选中、保留策略和视图索引,将生命周期操作委托给视图,而不修改其计数或释放其引用。`RightbarRoot` 接收 `SidebarSessionViewSnapshot` 和已有注入回调,不接收资源所有者对象;它为各视图分别渲染显式 `SessionProvider`,保持会话 key 稳定,渲染器通用的会话重挂载语义不变。
Tab 提供 Body 保活持有,而不独立申请会话引用。这些持有跟随已有 occurrence signal,包括关闭后撤销恢复得到的新 signal;视图不创建第二套 Tab 生命周期。退出视图列表的视图仍留在集合的引用索引中,直到自行释放时移除索引,因此插件关闭时也能清理尚未结束 React 挂载的视图。
只有选中的会话向 frame 报告占位、绑定公共 Sidebar 导航。后台 Tab actions 仍指向自己的已接管存储。`tab.visible` 包含会话和全局面板的可见性,浮动不会让后台会话变成可见。切会话或打开全局面板隐藏整个内容树,收起 Sidebar 则只隐藏停靠 cell,前台浮窗仍可见。隐藏内容不可通过指针或键盘访问,隐藏 Body 内的焦点会被移除。
后台会话没有已初始化的保活 Body 时,从视图列表移除;等已提交的 React 根卸载后再释放引用,避免仍挂载的子树下方先销毁会话 scope。插件释放会清理全部引用,包括等待卸载的已移除视图。Sidebar 插件依赖 `sessions` 和 `uiSession`,任一依赖结束时由 Cordis 卸载它,绑定失效由 `ui-session` 负责。视图不向共享会话 Context 注册销毁回调。仅在目录列表中缺项不视为删除。未访问的 Browser Tab 和无关会话不会提前打开。
### 提供方职责
`BrowserFrame` 仍只负责导航与可观察页面状态。Presentation 创建、挂载载体 DOM,不了解 Pane、裁剪或层叠。Electron 提供方接收物理 attach/detach 回调:隐藏保活内容不触发这些回调,真正卸载则取消未完成的挂载并释放 guest;之后再次物理挂载时从已知地址重建。dispose(资源释放)会等待未完成的创建和 guest 释放,取消后才完成的申请只释放、不挂载。
[Desktop Browser 决策](../feature/2026-09-20-desktop-browser-webview.zh.md)继续负责 guest 安全、共享 IPC 声明和 Workspace 分区所有权。不同 Tab 和会话不能因为共享存储分区就共享一个页面实例。Web 默认仍关闭,显式启用时继续使用 iframe 载体。
### 保活与恢复
| 操作 | 内容处理 |
|---|---|
| 切换 Tab | 隐藏旧的保活 cell,显示目标已有 Body。 |
| 分栏、排序或移到另一个 Pane | 改逻辑归属和 Grid 分配,不搬移 Body。 |
| 浮动、回停靠或置顶 | 改同一个 frame 的 CSS 模式或 cell 绘制顺序。 |
| 收起 Sidebar | 隐藏停靠 cell,前台浮窗保持可见。 |
| 会话 A → B → A | 隐藏并重新显示 A 的原内容树,而非加载已保存 URL。 |
| 关闭、提供方卸载或真实 generation 结束 | 释放 Body 和 guest;撤销关闭创建新 occurrence。 |
| 应用重启、窗口刷新或 guest 崩溃 | 走提供方恢复或重试;页面内存和原生 history 不持久化。 |
[布局与提供方恢复](2026-09-14-sidebar-layout-provider-recovery.zh.md)仍然独立:持久化的 Tab 身份、位置和地址支持冷启动,而不是序列化 DOM。隐藏页面可能继续执行脚本、播放媒体或联网。
## Alternatives considered
**只隐藏 Pane。** 可以覆盖收起,但跨 Pane 移动或进入独立浮动 Portal 仍会改变内容祖先。
**只保留前台会话的 Body。** 会话 slot 重挂载仍会断开页面,无法保留现有的切会话体验。
**每个 Tab 独立持有会话引用。** 同一视图的 Tab 共用一个 `SessionProvider`,它的引用必须先于这些 Body 渲染而存在。按视图持有即可覆盖父级及其子节点,无须再为每个 Tab 管理一份引用生命周期。
**缓存并搬移 DOM。** 普通 reparent 会触发 Electron 断开处理;实现不依赖原子搬移、私有挂载 API 或生命周期补丁。
**保留固定 Browser 浮层,改为事件驱动测量或 CSS 锚点定位。** 内容仍然与它模拟的布局分离,而内容本身必须参与 Grid/Flex。
**使用原生 popover。** 普通 Portal 内容无法通过 `z-index` 覆盖 top layer 元素。把菜单、对话框迁移到另一套层叠体系会扩大 Sidebar 之外的改动;实现保留既有 Portal 和层级模型。
**通过递增根 z-index 置顶浮窗。** 不设上限的浮窗数量可能越过菜单和模态层。同层 Grid cell 的 CSS 绘制顺序同时保留层叠与 DOM 身份。
**隐藏时保存 URL 再重建。** 无法保留表单、脚本状态、滚动位置和原生 history。这是丢失实例后的恢复,不是活页面保留。
## Consequences
布局正确性由 Sidebar/DockKit 负责,而不是 Browser。代价是稳定的内容容器与显式会话引用所有权:后台保活不仅持有页面内存,也持有会话 scope 和订阅。没有隐式 LRU 或空闲超时,未来回收需要明确的暂停与恢复策略;其他 Tab 类型不会自动承担这些成本。
变更涉及 Sidebar 控件、浮窗层级、焦点和平台样式,不仅是 Browser 包。[停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)和 Desktop Browser 记录的引擎、导航、存储与安全决策仍独立有效,因此保留为活动记录;其中的分离内容呈现由本文替代,布局持久化和 Tab 类型导航继续适用。
## Verification
定向 TypeScript 编译覆盖 Sidebar、Browser 和 Desktop 源码,DockKit、Sidebar、Browser、Desktop 打包及 Web 外壳构建完成。未添加或运行测试、未录制演示,也未重启正在运行的 Electron。稳定树实现仍需真实 Electron 验证 guest 身份、表单与 history 保留、多浮窗绘制顺序、缩放、裁剪、放置提示、焦点和平台窗口控件。编译与打包不代表这些运行时观察已通过。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md
2026-09-04-right-sidebar-docking-infrastructure.md: 3ebfe4b3a95ab5820682098be2a4c43f172d1a02
2026-09-04-right-sidebar-docking-infrastructure.zh.md: dc6516324a368dd86b3e01afb4051a4b20697363
2026-09-04-right-sidebar-docking-infrastructure.md: c5bfb75730f2037826723bc7fd0aa2bb01005d1e
2026-09-04-right-sidebar-docking-infrastructure.zh.md: 4959032b53f84654205a1973aee781acb356f999
@@ -16,6 +16,8 @@ The right column is a per-session docking surface — split panes, tabs, floatin
### Package topology
The [stable Sidebar mounting decision](../architecture/2026-09-20-sidebar-retained-tab-layout.md) owns one retained tab-content tree for docking, floating and Session switches. The engine, layout state and gesture ownership in this note remain applicable.
| Package | Kind | Owns |
|---|---|---|
| `packages/client/ui-dockkit` | static-linked library, zero DSH dependencies | the layout engine and the React components that render and drive it; consumers compile its sources, and it keeps exactly one stylesheet because a consumer de-duplicates injected sheets by file name |
@@ -33,7 +35,7 @@ Components render a snapshot and report settled intents, one per gesture: a drag
[Responsive Sidebar and tab information](../architecture/2026-09-07-sidebar-responsive-tab-info.md) supersedes this note's no-concession layout, overlay presentation and product pane limit. `ui-layout` still owns three-column geometry and pixel width preferences; the Sidebar occupant reports presentation through `ctx.layout.openRightbar(track, fullscreen)` and `closeRightbar()`, without the frame injecting the Sidebar package. Exact width rules belong to [ui-layout](../../../../packages/client/ui-layout/README.md).
The right Sidebar uses one mounted content tree in normal and fullscreen modes; hiding preserves tab state, and fullscreen covers the viewport while retaining underlying column reservation. Floats still use viewport coordinates through a portal and remain open when the Sidebar closes. The product limits docking to two horizontal panes and a 20–80% divider; the generic engine keeps its own defaults.
The right Sidebar uses one mounted content tree in normal and fullscreen modes; hiding preserves retained tab state, and fullscreen covers the viewport while retaining underlying column reservation. `DockLayout` renders stable Grid cells with fixed-position floating frames in the same tree; foreground floats remain open when the Sidebar closes. The product limits docking to two horizontal panes and a 20–80% divider; the generic engine keeps its own defaults.
### State
@@ -86,7 +88,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind
- The Detail panel and its duplicate card presentation are gone (a net removal of roughly 1,400 lines); cards are read in place, and `inspect` opens the trajectory view.
- The frame has no centre floor: a viewport narrower than the two edge columns squeezes the conversation toward zero instead of closing a column.
- The kit is compiled by its consumers, so a kit change requires a shell rebuild and a page reload; there is no HMR for it.
- The panel, the float host, and the portalled tab menu use hard-coded z-index values; the client still has no z-index token layer.
- Docked cells, floating cells and the portalled tab menu use fixed stacking levels; floating cells use CSS paint order within their level. The client still has no global z-index token layer.
## Testing
@@ -16,6 +16,8 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
### 包拓扑
[Sidebar 稳定挂载决策](../architecture/2026-09-20-sidebar-retained-tab-layout.zh.md)负责停靠、浮动与会话切换共用的一棵保活 Tab 内容树。本文的引擎、布局状态与手势所有权仍然适用。
| 包 | 形态 | 所有物 |
|---|---|---|
| `packages/client/ui-dockkit` | 静态链接库,零 DSH 依赖 | 布局引擎与渲染/驱动它的 React 组件;消费方编译其源码,且它只保留一张样式表,因为消费方按文件名去重注入的样式表 |
@@ -33,7 +35,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
[响应式 Sidebar 与标签信息](../architecture/2026-09-07-sidebar-responsive-tab-info.zh.md)取代本记录中的无让步布局、覆盖模式与产品窗格上限。`ui-layout` 仍拥有三列几何与像素宽度偏好,Sidebar 占位项通过 `ctx.layout.openRightbar(track, fullscreen)` 和 `closeRightbar()` 报告呈现方式,框架不注入 Sidebar 包。具体宽度规则见 [ui-layout](../../../../packages/client/ui-layout/README.zh.md)。
右栏在普通与全屏模式下使用同一棵已挂载内容树;隐藏保留标签状态,全屏覆盖视口并保留底层列占位。浮窗仍经 portal 使用视口坐标,不随右栏关闭。产品限制为两个水平窗格与 20–80% 分割比例,通用引擎保留自己的默认值。
右栏在普通与全屏模式下使用同一棵已挂载内容树;隐藏保留已保活的标签状态,全屏覆盖视口并保留底层列占位。`DockLayout` 在同一棵树中渲染稳定 Grid cell 与 fixed 定位的浮动 frame,前台浮窗不随右栏关闭。产品限制为两个水平窗格与 20–80% 分割比例,通用引擎保留自己的默认值。
### 状态
@@ -86,7 +88,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的
- Detail 面板及其重复的卡片展示消失(净删约 1,400 行);卡片就地阅读,`inspect` 打开 trajectory 视图。
- 框架没有中列下限:视口窄于两侧列之和时会话区被挤向零,而不是关掉某一列。
- 库由消费方编译,改库须重建壳并刷新页面;它没有 HMR。
- 面板、浮层宿主与 portal 出去的 tab 菜单使用硬编码 z-index;客户端仍没有 z-index token 层。
- 停靠 cell、浮动 cell 和 portal 菜单使用固定层级,浮动 cell 在所属层内通过 CSS 绘制顺序排序;客户端仍没有全局 z-index token 层。
## Testing
@@ -39,7 +39,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** Session content selected by the root-scoped right Sidebar controller. */
'rightbar.session': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps }
'rightbar.session': { kind: 'single'; scope: 'session'; owner: RightbarOwnerProps & { readonly active: boolean } }
/**
* 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
@@ -148,7 +148,7 @@ export interface SidebarRightTabInfo {
}
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. */
/** Only the foreground Session is visible. Docked bodies also require expansion and selection; its floats survive collapse. */
readonly visible: boolean
readonly navigation: SidebarRightTabNavigation
/** Aborted only when the record disappears or this plugin unloads, not on hide or session switch. */
@@ -33,7 +33,8 @@ import { GuideBody, type GuideInjected } from './tabs/guide/GuideBody.tsx'
import { GuideTitle } from './tabs/guide/GuideTitle.tsx'
import { ExpandButton } from './shell/ExpandButton.tsx'
import { RightbarSeat, type SidebarRightInjected } from './shell/SidebarRight.tsx'
import { RightbarRoot } from './shell/RightbarRoot.tsx'
import { RightbarRoot, type RightbarRootInjected } from './shell/RightbarRoot.tsx'
import { SidebarSessionViews } from './session-views.ts'
import { createSidebarRightController, type SidebarRightController } from './service.ts'
import { SidebarRightTabRegistry } from './tab-registry.ts'
import { createSidebarRightStore } from './stores.ts'
@@ -74,7 +75,7 @@ export type { SidebarRightOpenTab } from './tab-inventory.ts'
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']
export const inject = ['slots', 'layout', 'locale', 'resources', 'sessions', 'uiSession']
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -102,6 +103,14 @@ export function apply(ctx: ClientContext): void {
// its own apply top level for the same reason.
const t = ctx.locale.bind(NS)
const tabs = new SidebarRightTabRegistry(ctx)
const views = new SidebarSessionViews(ctx.sessions)
ctx.effect(() => {
const current = ctx.uiSession.adapter.current
const sync = (): void => { views.select(current.getSnapshot().key as SessionId | undefined) }
const unsubscribe = current.subscribe(sync)
sync()
return () => { unsubscribe(); views.dispose() }
}, 'ui-sidebar-right: retained Session views')
const { controller, adopt, forget } = createSidebarRightController(
tabs,
(address, signal) => { ctx.resources.pin(address, signal) },
@@ -141,7 +150,7 @@ export function apply(ctx: ClientContext): void {
},
}
const layout: ILayout = ctx.layout
const injected: Omit<SidebarRightInjected, 'keyedHooks' | 'occurrence' | 'closeTab'> = {
const injected: Omit<SidebarRightInjected, 'keyedHooks' | 'occurrence' | 'closeTab' | 'retainTab'> = {
syncPresentation({ shown, track, fullscreen }) {
if (shown) layout.openRightbar(track, fullscreen)
else layout.closeRightbar()
@@ -156,6 +165,10 @@ export function apply(ctx: ClientContext): void {
yield ctx.slots.register({
name: 'rightbar',
children: { 'rightbar.session': { kind: 'single', scope: 'session' } },
inject: (): RightbarRootInjected => ({
hooks: { views: views.source },
mountView: reference => views.mount(reference),
}),
}, RightbarRoot)
yield ctx.slots.register({
name: 'rightbar.session',
@@ -174,6 +187,7 @@ export function apply(ctx: ClientContext): void {
},
keyedHooks: { tabNavigation: key => controller.tabDomain.occurrence(sessionId, { id: key as TabId }).navigation },
occurrence: tab => controller.tabDomain.occurrence(sessionId, tab),
retainTab: tabId => views.retainTab(sessionId, tabId, controller.tabDomain.occurrence(sessionId, { id: tabId }).signal),
}),
}, RightbarSeat)
})
@@ -0,0 +1,86 @@
/** One Sidebar view's Session reference, retained bodies and committed mount lifetime. */
import type { ISessions, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
declare module '@deepseek-ai/dsh-api-session-controller/client' {
interface SessionReferenceSourceMap {
sidebarView: unknown
}
}
/** Owns its reference until retirement finishes or the Sidebar plugin shuts down. */
export class SidebarSessionView {
readonly reference: SessionReference
private readonly tabs = new Map<TabId, number>()
private mounts = 0
private retired = false
private disposed = false
/**
* @param sessionId - Session displayed by this view.
* @param sessions - allocator for this view's independent reference.
* @param onDispose - removes this view from the collection's index before reference release.
*/
constructor(
readonly sessionId: SessionId,
sessions: ISessions,
private readonly onDispose: (view: SidebarSessionView) => void,
) {
this.reference = sessions.retain(sessionId, { source: 'sidebarView' })
void this.reference.ready.catch(error => { console.error('Sidebar Session opening failed:', error) })
}
/** Whether an initialized retained body still needs this view. */
get hasRetainedTabs(): boolean {
return this.tabs.size > 0
}
/** @returns cleanup for one committed root; the final cleanup releases a retired view. */
mount(): () => void {
this.mounts += 1
return () => {
this.mounts -= 1
if (this.retired && this.mounts === 0) this.dispose()
}
}
/**
* Hold an initialized body until unmount or occurrence cancellation.
* @param tabId - initialized body identity.
* @param signal - occurrence lifetime; closing and undoing a tab creates a new lifetime.
* @param onRelease - lets the collection reconsider retaining this view.
* @returns idempotent release of this body's hold.
*/
retainTab(tabId: TabId, signal: AbortSignal, onRelease: () => void): () => void {
if (signal.aborted || this.disposed) return () => {}
this.tabs.set(tabId, (this.tabs.get(tabId) ?? 0) + 1)
let held = true
const release = (): void => {
if (!held) return
held = false
signal.removeEventListener('abort', release)
const count = this.tabs.get(tabId) as number
if (count === 1) this.tabs.delete(tabId)
else this.tabs.set(tabId, count - 1)
onRelease()
}
signal.addEventListener('abort', release, { once: true })
return release
}
/** End a withdrawn view after its existing committed mounts finish. */
retire(): void {
this.retired = true
if (this.mounts === 0) this.dispose()
}
/** Release once; plugin shutdown does not wait for remaining React mounts. */
dispose(): void {
if (this.disposed) return
this.disposed = true
this.retired = true
this.onDispose(this)
this.reference.release()
}
}
@@ -0,0 +1,90 @@
/** Selection and retention policy for independently owned Sidebar Session views. */
import type { ISessions, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { SidebarSessionView } from './session-view.ts'
/** The reference is exclusively a framework SessionProvider target, not a business-component service. */
export interface SidebarSessionViewSnapshot {
readonly sessionId: SessionId
readonly reference: SessionReference
readonly selected: boolean
}
/** Selects and retires views; the Sidebar plugin's injected dependencies own global teardown. */
export class SidebarSessionViews {
readonly source = createSnapshotStore<readonly SidebarSessionViewSnapshot[]>([])
private readonly views = new Map<SessionId, SidebarSessionView>()
private readonly viewsByReference = new Map<SessionReference, SidebarSessionView>()
private selected: SessionId | undefined
private closed = false
constructor(private readonly sessions: ISessions) {}
/** @param sessionId - main selection, or absence; existing retained background views stay bound. */
select(sessionId: SessionId | undefined): void {
if (this.closed || this.selected === sessionId) return
this.selected = sessionId
if (sessionId !== undefined && !this.views.has(sessionId)) {
const view = new SidebarSessionView(sessionId, this.sessions, disposed => {
this.viewsByReference.delete(disposed.reference)
})
this.views.set(sessionId, view)
this.viewsByReference.set(view.reference, view)
}
for (const view of this.views.values()) this.prune(view)
this.publish()
}
/**
* Bind a committed root; release retired references after that root unmounts.
* @param reference - framework target published by this owner.
* @returns releases this committed mount.
*/
mount(reference: SessionReference): () => void {
const view = this.viewsByReference.get(reference)
if (view === undefined) {
if (this.closed) return () => {}
throw new Error('Sidebar Session view reference is no longer owned')
}
return view.mount()
}
/**
* Hold an initialized tab occurrence until unmount or occurrence cancellation.
* @param sessionId - mounted Session identity.
* @param tabId - initialized body identity.
* @param signal - occurrence lifetime; closing and undoing a tab creates a new lifetime.
* @returns idempotent release of this body's hold.
*/
retainTab(sessionId: SessionId, tabId: TabId, signal: AbortSignal): () => void {
if (signal.aborted || this.closed) return () => {}
const view = this.views.get(sessionId)
if (view === undefined) throw new Error(`Sidebar Session "${sessionId}" has no mounted view`)
return view.retainTab(tabId, signal, () => { this.prune(view) })
}
/** Plugin shutdown releases every view, including any awaiting a React unmount. */
dispose(): void {
this.closed = true
this.views.clear()
this.source.set([])
for (const view of this.viewsByReference.values()) view.dispose()
}
private prune(view: SidebarSessionView): void {
if (view.sessionId === this.selected || view.hasRetainedTabs || this.views.get(view.sessionId) !== view) return
this.views.delete(view.sessionId)
// Render targets must be withdrawn before an unmounted view can release its reference.
this.publish()
view.retire()
}
private publish(): void {
if (this.closed) return
this.source.set([...this.views.values()].sort((a, b) => a.sessionId.localeCompare(b.sessionId)).map(view => ({
sessionId: view.sessionId, reference: view.reference, selected: view.sessionId === this.selected,
})))
}
}
@@ -1,20 +1,38 @@
/** Root-scoped controller for the right Sidebar's Session content. */
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { useLayoutEffect } from 'react'
import type { HostObservable, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionReference } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SidebarSessionViewSnapshot } from '../session-views.ts'
import type {} from '../contract/slots.ts'
import css from './SidebarRight.module.css'
/** Root-only retained Session targets and their committed mount lifetimes. */
export interface RightbarRootInjected {
readonly hooks: { readonly views: HostObservable<readonly SidebarSessionViewSnapshot[]> }
readonly mountView: (reference: SessionReference) => () => void
}
type RootProps = PropsRuntime<'rightbar'> & PropsRenderSlots<'rightbar.session'> & InjectFace<RightbarRootInjected>
function SessionView({ view, visible, SessionProvider, renderSlot, mountView, width, viewportWidth, canShow }:
Pick<RootProps, 'SessionProvider' | 'renderSlot' | 'mountView' | 'width' | 'viewportWidth' | 'canShow'>
& { readonly view: SidebarSessionViewSnapshot; readonly visible: boolean }) {
useLayoutEffect(() => mountView(view.reference), [mountView, view.reference])
const active = visible && view.selected
return <div className={css.session} hidden={!active} data-sidebar-right-session={view.sessionId}>
<SessionProvider session={view.reference}>
{renderSlot('rightbar.session', { width, viewportWidth, canShow, active })}
</SessionProvider>
</div>
}
/**
* Render the Session-bound Sidebar only while the Conversation is selected.
* @param props - frame geometry, panel selection, and the authorized Session renderer.
* @returns the current Session's right Sidebar, or no content for a global panel.
* Keep independent Session subtrees and hide those outside the selected Conversation.
* @param props - frame geometry, view targets and the authorized Session renderer.
* @returns the foreground and retained background Sidebars.
*/
export function RightbarRoot({
usePanelInfo, SessionProvider, renderSlot, width, viewportWidth, canShow,
}: PropsRuntime<'rightbar'> & PropsRenderSlots<'rightbar.session'>) {
export function RightbarRoot({ usePanelInfo, useViews, ...props }: RootProps) {
const visible = usePanelInfo(info => info.activePanelId === null)
if (!visible) return null
return (
<SessionProvider>
{renderSlot('rightbar.session', { width, viewportWidth, canShow })}
</SessionProvider>
)
const views = useViews(value => value)
return <>{views.map(view => <SessionView key={view.sessionId} {...props} view={view} visible={visible} />)}</>
}
@@ -9,7 +9,7 @@
* 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
* Hidden, docked Grid items translate 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
@@ -17,30 +17,45 @@
* 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).
* frame and remains below independently floating panels (60). The common
* content root owns no stacking context or transform.
*/
.session {
display: contents;
}
.session[hidden] {
display: none;
}
.panel {
--dsh-dockkit-dock-layer: 10;
--dsh-dockkit-float-layer: 60;
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. The edge is the weight of the kit's split dividers inside
(ui-dockkit `.divider`), so the panel's outline and its inner rules match. */
background: var(--dsw-alias-bg-base);
border-left: 0.5px solid var(--dsw-alias-border-l4);
transform: translateX(100%);
pointer-events: none;
}
/* Only docked Grid items slide. Their common ancestor remains untransformed,
unclipped and without a stacking context, so fixed floating children escape. */
.panel :global([data-dockkit-host='dock']),
.panel :global([data-dockkit-empty]),
.panel :global([data-dockkit-divider]) {
transform: translateX(var(--dsh-sidebar-width));
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] {
.panel[data-sidebar-right-open] :global([data-dockkit-host='dock']),
.panel[data-sidebar-right-open] :global([data-dockkit-empty]),
.panel[data-sidebar-right-open] :global([data-dockkit-divider]) {
transform: none;
visibility: visible;
transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
@@ -60,15 +75,12 @@
}
.panel[data-sidebar-right-panel='fullscreen'] {
position: fixed;
inset: 0;
z-index: 40;
border: none;
--dsh-dockkit-dock-layer: 40;
}
/* No whole-box subtraction here: Electron drag regions are window geometry,
not stacking, so the frame's leading band does keep acting through this
fixed overlay — but the kit already subtracts every box that must stay
fullscreen content — but the kit already subtracts every box that must stay
usable (tabs, strip-end chrome, pane bodies, dividers; ui-dockkit), and
ui-web base.css subtracts interactive elements and portalled overlays.
What remains of the band under the strips' blank runs is the window drag
@@ -79,10 +91,10 @@
/* Windows keeps caption controls above fullscreen file and terminal panels. */
:global([data-windows-titlebar]) .panel[data-sidebar-right-panel='fullscreen'] {
overflow: hidden;
left: var(--dsh-windows-sidebar-width);
max-width: calc(100% - var(--dsh-windows-sidebar-width));
top: var(--dsh-windows-titlebar-height);
max-width: calc(100vw - var(--dsh-windows-sidebar-width));
}
:global([data-windows-titlebar]) .panel[data-sidebar-right-panel='fullscreen'] :global([data-dockkit-column='0'][data-dockkit-pane]) {
border-radius: var(--dsh-windows-content-radius) 0 0 0;
corner-shape: round;
}
@@ -90,27 +102,27 @@
/* macOS fullscreen: the panel reaches the window's top-left corner, where the
hiddenInset traffic lights (x 16..~68) float over the first pane's tab
strip. Widen that strip's start inset (dockkit publishes the variable);
panes below or right of a divider sit clear of the lights, and every
non-first split cell resets the inherited value for its whole subtree. */
:global([data-platform='darwin']) .panel[data-sidebar-right-panel='fullscreen'] .panelBody {
the second Grid column sits clear of the lights and uses the ordinary inset. */
:global([data-platform='darwin']) .panel[data-sidebar-right-panel='fullscreen'] :global([data-dockkit-host='dock'][data-dockkit-column='0']) {
/* Lights end near x 68; 88 leaves the same 20px of air the conversation
header's leading controls keep after them (ui-sidebar HeaderLeadingControls). */
--dsh-dockkit-strip-inline-start: 88px;
}
:global([data-platform='darwin']) .panel[data-sidebar-right-panel='fullscreen'] .panelBody :global([data-dockkit-cell]):not(:first-child) {
:global([data-platform='darwin']) .panel[data-sidebar-right-panel='fullscreen'] :global([data-dockkit-host='dock'][data-dockkit-column='1']) {
--dsh-dockkit-strip-inline-start: 10px;
}
/* Window fullscreen hides the traffic lights (html[data-fullscreen], desktop
preload); the first pane's strip keeps only the ordinary inset. */
:global([data-platform='darwin'][data-fullscreen]) .panel[data-sidebar-right-panel='fullscreen'] .panelBody {
:global([data-platform='darwin'][data-fullscreen]) .panel[data-sidebar-right-panel='fullscreen'] :global([data-dockkit-host='dock'][data-dockkit-column='0']) {
--dsh-dockkit-strip-inline-start: 10px;
}
@media (prefers-reduced-motion: reduce) {
.panel,
.panel[data-sidebar-right-open] {
.panel :global([data-dockkit-host='dock']),
.panel :global([data-dockkit-empty]),
.panel :global([data-dockkit-divider]) {
transition: none;
}
}
@@ -159,17 +171,3 @@
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;
}
@@ -2,11 +2,11 @@
* 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
* tree at the column width or spanning 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
* Docked content 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. Normal presentation moves the frame's tracks with
* the panel. A fullscreen opening reserves its underlying track only after
@@ -17,8 +17,8 @@
* 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.
* hidden. Floating panels remain in the same content tree; untransformed
* ancestors let their fixed-position frames cross the column and conversation.
*
* 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
@@ -28,15 +28,14 @@
* domain follows each session's store commits, including sessions off screen.
*/
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { ReactNode, RefObject } from 'react'
import { createPortal } from 'react-dom'
import type { CSSProperties, ReactNode, RefObject } from 'react'
import { IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
} from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '../contract/slots.ts'
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 { canSplit, dockPaneIds, DockLayout, findPaneContentTab } 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'
@@ -108,6 +107,8 @@ export interface SidebarRightInjected {
}
/** Read a committed record's lifetime; never creates an occurrence. */
readonly occurrence: (tab: Pick<TabRecord, 'id'>) => TabOccurrence
/** Keep this Session generation while an initialized body is retained. */
readonly retainTab: (tabId: TabId) => () => void
}
/** The column seat's props: session scope, so the session arrives as a standard prop. */
@@ -133,6 +134,8 @@ interface PanelProps {
readonly occurrence: SidebarRightInjected['occurrence']
readonly fullscreen: boolean
readonly autoFullscreen: boolean
readonly active: boolean
readonly retainTab: SidebarRightInjected['retainTab']
/** Receives the kit's room-rule readings for the service's `split`. */
readonly reportRoom: (fits: ReadonlyMap<PaneId, HalvesFit>) => void
}
@@ -172,7 +175,7 @@ export function intentsFor(sessionId: SessionId, actions: Store['actions'], open
}
/** 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'> {
interface TabSlotProps extends Pick<PanelProps, 'renderSlot' | 'occurrence' | 'useTabTypes' | 'useTabNavigation' | 'useStore' | 'fullscreen' | 'active' | 'retainTab'> {
readonly tab: TabRecord
readonly seat: 'sidebar.right.pane.tab' | 'sidebar.right.pane.tab.title'
readonly fallback: ReactNode
@@ -182,19 +185,22 @@ interface TabSlotProps extends Pick<PanelProps, 'renderSlot' | 'occurrence' | 'u
* 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,
renderSlot, occurrence, useTabTypes, useTabNavigation, useStore, fullscreen, active, retainTab, tab, seat, fallback,
}: TabSlotProps): ReactNode {
const { signal, tabActions } = occurrence(tab)
const definition = useTabTypes(types => types.find(definition => definition.kind === tab.kind))
const retained = seat === 'sidebar.right.pane.tab' && definition?.keepMounted === true
useLayoutEffect(() => retained ? retainTab(tab.id) : undefined, [retained, retainTab, tab.id, signal])
const hookContext = useMemo((): TabHookContext => ({
tabId: tab.id,
title: seat === 'sidebar.right.pane.tab.title',
fullscreen,
active,
signal,
actions: tabActions,
useStore,
useTabNavigation,
}), [tab.id, seat, fullscreen, signal, tabActions, useStore, useTabNavigation])
}), [tab.id, seat, fullscreen, active, signal, tabActions, useStore, useTabNavigation])
return renderSlot(seat, {}, { entryKey: definition?.id ?? tab.kind, fallback, hookContext })
}
@@ -207,9 +213,7 @@ function TabSlot({
*/
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).
// Slot dispatch selects a type; the record key separates bodies of that type.
return tab => (
<TabSlot
key={tab.id}
@@ -287,20 +291,18 @@ function PanelChrome({ sessionId, fullscreen, autoFullscreen, actions, t }: Pick
function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<HTMLDivElement> }): ReactNode {
const { sessionId, surface, actions, t, renderSlot, openTab, width, reportRoom, fullscreen, autoFullscreen, panelRef } = panel
const { expanded } = surface.layout
const types = panel.useTabTypes(value => value)
return (
<div
ref={panelRef}
className={css.panel}
style={{ width: fullscreen ? '100%' : width }}
style={{ width: fullscreen ? '100vw' : width,
'--dsh-sidebar-width': fullscreen ? '100vw' : `${width}px` } as CSSProperties}
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
<DockLayout
state={surface.layout}
canSplit={canSplit(surface.layout) && dockPaneIds(surface.layout).length < 2}
hideSplitWhenBlocked
@@ -312,6 +314,8 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<H
labels={dockLabels(t)}
renderTab={bodiesFor(panel)}
renderTabTitle={titlesFor(panel)}
active={panel.active}
keepMounted={tab => types.find(type => type.kind === tab.kind)?.keepMounted === true}
renderTabMenuItems={(tab, dismiss) =>
renderSlot('sidebar.right.tab.menu.item', { tab, dismiss })}
chrome={<PanelChrome sessionId={sessionId} fullscreen={fullscreen} autoFullscreen={autoFullscreen} actions={actions} t={t} />}
@@ -322,34 +326,15 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<H
)
}
/** 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}
canCloseTab={tabId => canCloseTab(surface, tabId)}
intents={intentsFor(sessionId, actions, openTab, panel.closeTab)}
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
* The right column's occupant: stable tab containers, docked or floating.
* 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, closeTab,
useTabTypes, useTabNavigation, occurrence,
useTabTypes, useTabNavigation, occurrence, retainTab, active,
}: 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
@@ -357,7 +342,7 @@ export function RightbarSeat({
// adopted stores instead.
const surfaces = useStore(state => state.bySession)
const surface = surfaces[sessionId]
const shown = surface !== undefined && surface.layout.expanded
const shown = active && surface !== undefined && surface.layout.expanded
const autoFullscreen = viewportWidth < 768
const fullscreen = autoFullscreen || surface?.layout.mode === 'fullscreen'
const panelRef = useRef<HTMLDivElement | null>(null)
@@ -368,8 +353,8 @@ export function RightbarSeat({
const track = shown && !autoFullscreen
useEffect(() => {
if (surface === undefined) actions.open(sessionId)
}, [actions, sessionId, surface])
if (active && surface === undefined) actions.open(sessionId)
}, [actions, sessionId, surface, active])
useLayoutEffect(() => {
if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false)
@@ -386,7 +371,7 @@ export function RightbarSeat({
// of the gesture: once at the flip, once after the slide settles and the
// stylesheet's delayed visibility flip (0.3s) has landed.
useEffect(() => {
if (document.documentElement.dataset.platform !== 'darwin') return
if (!active || document.documentElement.dataset.platform !== 'darwin') return
const panel = panelRef.current
if (panel === null) return
let raf: number | null = null
@@ -404,17 +389,18 @@ export function RightbarSeat({
if (raf !== null) cancelAnimationFrame(raf)
panel.removeAttribute('data-sidebar-right-region-nudge')
}
}, [shown])
}, [shown, active])
// Fullscreen leaves the previous column report in force until its own slide
// completes. Normal presentation and zero-duration transitions report before paint.
useLayoutEffect(() => {
if (!active) return
let disposed = false
const reportWhenCovered = (): void => {
if (disposed) return
// A shown panel renders unconditionally and attaches its ref before this effect.
const entering = shown && fullscreen
? (panelRef.current as HTMLDivElement).getAnimations().filter(animation =>
? (panelRef.current as HTMLDivElement).getAnimations({ subtree: true }).filter(animation =>
'transitionProperty' in animation && animation.transitionProperty === 'transform'
&& animation.playState !== 'finished' && animation.playState !== 'idle')
: []
@@ -427,16 +413,18 @@ export function RightbarSeat({
}
reportWhenCovered()
return () => { disposed = true }
}, [sessionId, shown, track, fullscreen, syncPresentation])
}, [sessionId, shown, track, fullscreen, syncPresentation, active])
// 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])
useLayoutEffect(() => active
? () => { syncPresentation({ shown: false, track: false, fullscreen: false }) }
: undefined, [syncPresentation, active])
// 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],
() => active ? bindService({ sessionId, actions, surfaces, canSplitPane: paneId => room.current.get(paneId)?.row !== false }) : undefined,
[bindService, sessionId, actions, surfaces, active],
)
// 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,
@@ -445,12 +433,7 @@ export function RightbarSeat({
if (surface === undefined) return null
const panel: PanelProps = {
sessionId, actions, t, renderSlot, surface, openTab, closeTab, useTabTypes, useTabNavigation, useStore, occurrence,
fullscreen, autoFullscreen, reportRoom,
fullscreen, autoFullscreen, reportRoom, active, retainTab,
}
return (
<>
<SidebarPanel {...panel} width={width} panelRef={panelRef} />
<Floats {...panel} />
</>
)
return <SidebarPanel {...panel} width={width} panelRef={panelRef} />
}
@@ -11,6 +11,7 @@ export interface TabHookContext {
readonly tabId: TabId
readonly title: boolean
readonly fullscreen: boolean
readonly active: boolean
readonly signal: AbortSignal
readonly actions: SidebarRightTabActions
readonly useStore: PropsStore<ReturnType<typeof createSidebarRightStore>>['useStore']
@@ -25,7 +26,7 @@ export interface TabHookContext {
*/
export const tabInfoFactory: SlotHookFactory<'sidebar.right.pane.tab', UseSidebarRightTabInfo> = (standard, context) => {
const { sessionId } = standard
const { tabId, title, fullscreen, signal, actions, useStore, useTabNavigation } = context
const { tabId, title, fullscreen, active, signal, actions, useStore, useTabNavigation } = context
return function useTabInfo() {
const layout = useStore(state => state.bySession[sessionId]?.layout)
const navigation = useTabNavigation(tabId)
@@ -40,13 +41,13 @@ export const tabInfoFactory: SlotHookFactory<'sidebar.right.pane.tab', UseSideba
panel: { id: pane.id },
tab: {
...tab,
visible: pane.host === 'float' || (layout.expanded && (title || pane.activeTabId === tabId)),
visible: active && (pane.host === 'float' || (layout.expanded && (title || pane.activeTabId === tabId))),
navigation,
signal,
actions,
},
}
}, [layout, navigation, tabId, title, fullscreen, signal, actions])
}, [layout, navigation, tabId, title, fullscreen, active, signal, actions])
}
}
@@ -101,6 +101,8 @@ export interface SidebarRightTabDefinition {
readonly kind: string
/** Each open by kind creates independent content; omission keeps one page per kind in each pane. */
readonly multiple?: boolean
/** Lazily keep a visited body mounted through hiding, Session changes and docking; default false. */
readonly keepMounted?: boolean
/**
* Resource-address globs this type recognizes; omit for a page type, which is
* opened by kind and recognizes no address.