Merge branch 'master' into feat/preset-scoped-schedule-tools

This commit is contained in:
Chinesezjc authored and GitHub committed 2026-09-29 12:20:57 +08:00
commit 538e831512
33 files changed
+610 -358

No files matched your search

@@ -21,8 +21,8 @@
en: 774c3bf80a48ffbe
zh: c513245789aa8efd
/agent-note-right-sidebar-tab-types-and-navigation/decision/navigation-ctx-sidebarright:
en: b32bad4c3b8c2cc8
zh: 57ba7b8d3451e478
en: c52363f57001b7ac
zh: bbcbdbc038cbf316
/agent-note-right-sidebar-tab-types-and-navigation/decision/addresses:
en: eeb1e7e7478a2f2c
zh: bddbc5bc78245f63
@@ -30,8 +30,8 @@
en: 6386bde2654f9a0a
zh: 700439be6a01e162
/agent-note-right-sidebar-tab-types-and-navigation/alternatives-considered:
en: 91fbca006e8de5bc
zh: f48d3ce1dd7b63bf
en: c47eb4e2f3c307f9
zh: fd4515b8be88506e
/agent-note-right-sidebar-tab-types-and-navigation/consequences:
en: 3cdb1159ee55d851
zh: dcd068005e678a89
@@ -39,5 +39,5 @@
en: 49449a6c8465c098
zh: 1f476cc1226aa9cd
/agent-note-right-sidebar-tab-types-and-navigation/deferred:
en: fa27b7684e38329b
zh: d270e0cdcab73732
en: 5b2416692028bf89
zh: 65930102aa253bf2
@@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
`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.
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 plugin names the Session on screen — the selected Session while the Conversation fills the main column — from the Session selection and the main panel, before React renders either change, and publishes it as `mounted`; a command on the public face acts on that Session through its adopted store and throws when no Session is on screen or its store has not been minted. The seat reports only what it renders with: the room its docking kit measured and whether its frame width presents the panel fullscreen automatically. 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
@@ -100,6 +100,8 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced
**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.
**A binding the seat publishes from its own effects.** The first form had the mounted seat publish its session, actions, and surface after each commit and release them when it left. React runs a commit's passive effects in tree order, and the frame renders the Conversation column before the right column, so in a commit that switched Sessions or returned from a global panel a Conversation component opening content from its effect ran while no seat was bound — after the departing seat's release and before the arriving seat's bind — and the open threw. The Session selection and the main panel change before React renders, so deriving the on-screen Session from them removes that ordering dependency. The seat still measures pane room, because only its rendered kit can.
## 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.
@@ -116,5 +118,5 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced
## Deferred
- A navigation protocol beyond `sidebar://<kind>`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes.
- 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.
- Opening into a session other than the one on screen from the public face, which acts on the on-screen 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.
@@ -72,7 +72,7 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
`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 从未铸出的会话什么也不做。
除两种打开外,该面还有 `close(tabId)`、`active()`、`isExpanded()`、`toggleExpanded()`,以及四个操作型方法——`focus(tabId)`、`split(paneId?)`(返回新格,预算或宽度规则不允许分栏时返回 `undefined` 且不记账)、`float(tabId, rect?)` 与 `dock(paneId)`——每个记一条历史,目标不存在或已在目标态时为 no-op。没有布局快照、没有订阅、没有按地址查找:该面给的是对布局的控制权,不是布局的视图。插件依据会话选择与主面板确定屏上会话——Conversation 占据主栏时的选中会话——并在 React 渲染这两者的变化之前将其发布为 `mounted`;公开面上的命令经该会话被收养的 store 作用于它,没有会话在屏上或其 store 尚未铸出时抛错。座位只上报它渲染时依据的事实:停靠套件测得的空间,以及框架宽度是否让面板自动全屏。tab 自己的动作则到达其会话自己的 store:slot 运行时每个会话铸一个 store,插件在铸出时逐个收养,控制器按会话 id 路由,因此用户切换会话之后触发的动作照样落地,而 store 从未铸出的会话什么也不做。
### 地址
@@ -100,6 +100,8 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
**用 Slot 优先级表达 extension 接管 builtin,随后是注册表铸造的坑位键。** 第一次尝试让覆盖方经一个导出常量以更低的 Slot 优先级注册正文——这是动态插件间的值引用,也是拿第二套规则(Slot 优先级)替注册表的规则站台。第二次尝试让注册表为每次注册铸一个键并从 `register()` 返回,这把注册变成了两步且顺序敏感的舞步。让实现自己声明 `id`——必填、唯一、与它注册坑位所用的同一个串——既不需要常量,也不需要铸键与顺序,还给了注册表拒绝重复所需的身份。
**由座位在自己的 effect 里发布绑定。** 最初的形式由已挂载的座位在每次提交后发布自己的会话、action 与面,离开时释放。React 按树序运行一次提交的被动 effect,而框架先渲染 Conversation 列、后渲染右栏,于是在切换会话或从全局面板返回的那次提交里,从自己 effect 打开内容的 Conversation 组件运行时没有任何座位已绑定——在离开的座位释放之后、到来的座位绑定之前——打开随之抛错。会话选择与主面板都在 React 渲染之前变化,从它们推导屏上会话便消除了这一顺序依赖。分栏空间仍由座位测量,因为只有它渲染出的套件能测。
## Consequences
- 一个类型 = 一个静态对象 + 一到两个 keyed 坑位注册;其实例信息通过注入的 `useTabInfo()` 读取。框架不长任何按类型的 API 面,仓外类型从 Sidebar 包只引类型。
@@ -116,5 +118,5 @@ interface SidebarRightTabParamsMap {} // key: kind — a page type declar
## Deferred
- `sidebar://<kind>` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。
- 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。
- 从公开面往屏上会话之外的会话里打开;公开面只作用于屏上会话,而 tab 自己的动作已作用于其所在会话。
- 从会话区打开失败时的本地化提示;目前是抛错文本本身。
@@ -9,8 +9,8 @@
en: 36bf235f0821ef6a
zh: a86bf07af03d54a3
/agent-note-desktop-primary-runtime/decision:
en: b3b104e314762322
zh: 77478c007893201f
en: 3d2241842f5a2a83
zh: 8617717f0337d76c
/agent-note-desktop-primary-runtime/alternatives-considered:
en: 566fd91c30cb0a0a
zh: 70758ae2617f6ebb
@@ -16,7 +16,7 @@ The application version, top-level interpreter/package-manager versions, Python
The shared Node build entry downloads and hash-verifies the complete locked wheel set and unpacks library files into site-packages. This avoids build-host Python and pip version selection without implementing dependency resolution or general wheel installation. Auxiliary scripts, including XlsxWriter's VBA extraction script, remain under the wheel's `.data/scripts` directory; command-line entry-point wrappers are outside this library payload. Other `.data` installation schemes are rejected. Native smoke executes the final payload after temporary files are removed, checking the exact locked distribution set plus bundled pip, Python and pinned wheel versions, dependency completeness and editable Office document round trips, so interpreter links must survive relocation. Smoke checks disable bytecode writes to keep validation artifacts out of the shipped payload.
macOS grants `com.apple.security.cs.allow-jit` only to the standalone Node executable. Hardened-runtime signing without that entitlement prevents V8 from allocating its code region. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
macOS grants `com.apple.security.cs.allow-jit` to the standalone Node executable and LibreOffice helpers. The x64 Node executable additionally requires `com.apple.security.cs.allow-unsigned-executable-memory`: with JIT-only signing, the bundled x64 Node fails during startup under Rosetta in V8 `OS::SetPermissions`. ARM64 Node and LibreOffice helpers retain JIT-only permissions; the additional permission is confined to the interpreter that requires it. Interpreter and library smoke checks run after signing as well as after staging cleanup; a valid signature alone does not establish executable behavior.
Windows signed packaging separates materialization from execution with supervised signing stages for the primary runtime and application production dependencies. PE inspection identifies content independently of extensions, excludes non-PE foreign-platform Node addons and refuses directory links. Public-key verification uses batches of 32 files with at most four processes and drains each batch before returning failure; hardware operations remain serial. Valid vendor signatures remain intact; only unsigned files receive the configured EV signature. Invalid existing signatures fail before hardware access, and each new signature is checked for validity, timestamp and certificate identity before the next file. Electron-builder's copy-time signing hook preserves runtime executables only after exact-byte and signature verification; the same serial queue rejects later tasks if preservation fails. The existing per-user interlock, serialized signer and redacted journal own hardware calls; hardware failures prohibit retries and later stages. Public timestamp attempts follow the [signature completion policy](../process/2026-09-17-windows-signature-completion.md). Runtime execution receives no signing credentials and follows complete verification. Development and unsigned preparation retain native smoke without automatic hardware access. Runtime inventories record signed bytes. Electron-builder unpacks detected dependency PE files and preserves signed copies. Final signature verification and ASAR payload and Host smoke use a private native cache before release completion.
@@ -16,7 +16,7 @@ Desktop 将 Python、Node.js、pnpm、数据处理库和 Office 创作库作为
共享 Node 构建入口下载并校验完整锁定 wheel 集的哈希,将库文件解压到 site-packages。这避免选择构建主机的 Python 和 pip 版本,也无需实现依赖解析或通用 wheel 安装。XlsxWriter 的 VBA 提取脚本等辅助脚本保留在 wheel 的 `.data/scripts` 目录中;命令行入口包装器不属于该库产物。其他 `.data` 安装方案会被拒绝。本机 smoke 在临时文件删除后执行最终产物,检查精确锁定的分发包集合与内置 pip、Python 与固定 wheel 版本、依赖完整性及可编辑 Office 文档的写入和读取,因此解释器链接必须在迁移后仍有效。Smoke 检查禁用字节码写入,避免把验证产物纳入分发内容。
macOS 仅向独立 Node 可执行文件授予 `com.apple.security.cs.allow-jit`。缺少此权限的强化运行时签名会阻止 V8 分配代码区域。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
macOS 向独立 Node 可执行文件和 LibreOffice 辅助程序授予 `com.apple.security.cs.allow-jit`。x64 Node 可执行文件还需要 `com.apple.security.cs.allow-unsigned-executable-memory`:仅授予 JIT 权限时,内置 x64 Node 在 Rosetta 下启动会在 V8 `OS::SetPermissions` 中失败。ARM64 Node 和 LibreOffice 辅助程序保持仅有 JIT 权限;额外权限仅授予需要它的解释器。解释器和库的 smoke 检查在签名后以及暂存清理后执行;签名有效本身不能证明程序可运行。
Windows 签名打包通过受监督的签名阶段覆盖第一方运行时和应用生产依赖,将文件准备与执行分开。PE 检查独立于扩展名识别文件内容,排除其他平台的非 PE Node 原生插件,并拒绝目录链接。公钥验签每个进程处理最多 32 个文件,同时最多运行四个进程,返回失败前等待当前批次全部结束;硬件操作仍串行执行。有效的上游签名保持不变;仅未签名文件使用配置的 EV 证书签名。已有签名无效时,在访问硬件前失败;每个新签名通过有效性、时间戳和证书身份检查后,才处理下一个文件。electron-builder 复制阶段的签名钩子仅在逐字节比对和验签通过后保留运行时可执行文件;保留验证失败时,同一串行队列拒绝后续任务。硬件调用复用现有的用户级互锁、串行签名器和脱敏日志;硬件失败不允许重试或继续后续阶段。公开时间戳尝试遵循[签名完成规则](../process/2026-09-17-windows-signature-completion.zh.md)。运行时执行不接收签名凭据,并在完整验签后进行。开发和未签名准备保留本机 smoke,不自动访问硬件。运行时清单记录签名后的字节。electron-builder 将检测到的依赖 PE 文件放在归档外,并保留已签名的副本。发布完成前,最终签名检查以及 ASAR 载荷和 Host 冒烟检查使用私有原生缓存。
+2 -2
View File
@@ -12,8 +12,8 @@
en: 069fdfd72d2ea26a
zh: 76ae549d43c99bb4
/deepseek-harness-desktop/key-technical-decisions/bundled-workspace-dependencies:
en: 31cbb7452be8e1ad
zh: 502ec556e2ec301e
en: 320733f93ede61a4
zh: 33206c3c4753c802
/deepseek-harness-desktop/installation-ownership:
en: 863ebc136313b73b
zh: 6aa9a9925ceaa80a
+1 -1
View File
@@ -60,7 +60,7 @@ The payload follows the Desktop release. `runtime.json` records the Desktop vers
The private Desktop `runtime/bin` directory is added only to package-installation processes, not the Host PATH inherited by PTC and agent shells. This tool does not change PATH, environment variables or user package-manager configuration. pnpm retains its own defaults and user settings for global packages, executable entries and its store, including native errors when the environment does not support global installation. There is no separate dependency updater. [The primary-runtime decision](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.md) records these choices.
Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
Node prepares the bundled interpreters and Python libraries without a system Python or pip. [The download lock](../../scripts/primary-runtime/lock.json) pins interpreter archives, Python distribution versions and target-specific wheel URLs and hashes; the shared builder resolves pnpm from the root development dependency pin. The root package-manager version and the Desktop pin are checked for agreement. Wheel filenames and distribution versions must agree for every target. Preserve key order within the selected target, wheel records and distribution map, plus wheel-entry order; these affect payload identity, while top-level lock key order does not. Library wheels unpack into site-packages, retaining auxiliary files under each wheel's `.data/scripts` directory without generating command wrappers. Other installation schemes are rejected. Native-target checks verify the locked wheel set and versions, permit the interpreter's bundled pip, and check the Python version, Office document read/write operations and dependency completeness without writing bytecode after staging cleanup and again after macOS signing. The standalone Node executable receives the JIT entitlement required by V8; the x64 target also receives `com.apple.security.cs.allow-unsigned-executable-memory` for executable code pages, including under Rosetta. ARM64 Node and Office helpers retain JIT-only permissions. Cross-target execution and signed installation require the target release host. Both `dev:desktop` and `start:desktop` prepare `.desktop-build/targets/<target>/runtime/primary-runtime` before launching Electron; first use may download locked dependencies. An unfinished preparation cannot report a successful launcher exit.
| Decision | Why | Direct consequence |
|---|---|---|
+1 -1
View File
@@ -62,7 +62,7 @@ Desktop 默认注册 `office-docx`、`office-pptx` 和 `office-xlsx`。这些技
Desktop 私有的 `runtime/bin` 目录仅添加到包安装进程,不进入 PTC 和 agent shell 从 Host 继承的 PATH。该工具不修改 PATH、环境变量或用户包管理器配置。pnpm 的全局包、命令入口和 store 保留自身默认值及用户设置,包括环境不支持全局安装时的原生错误。不提供独立依赖更新器。[第一方 Runtime 决策](../../.agents/notes/implemented/feature/2026-09-14-desktop-primary-runtime.zh.md)记录这些选择。
Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载锁](../../scripts/primary-runtime/lock.json)固定解释器压缩包、Python 分发包版本及目标平台 wheel 的 URL 和哈希;共享构建器从根开发依赖中解析 pnpm 固定版本。测试检查根 package-manager 版本和 Desktop 固定版本保持一致。每个目标的 wheel 文件名必须与分发包版本一致。所选目标、wheel 记录及分发包映射内部的键顺序,以及 wheel 条目顺序都会影响产物身份,编辑时须保留;锁文件顶层键的顺序不影响该身份。库 wheel 解压到 site-packages,各 wheel 的 `.data/scripts` 目录保留辅助文件,不生成命令行包装器。其他安装方案会被拒绝。本机目标检查在清理暂存目录后以及 macOS 签名后验证锁定 wheel 的集合与版本,允许解释器自带的 pip,并检查 Python 版本、Office 文档读写和依赖完整性,不写入字节码。独立 Node 可执行文件获得 V8 所需的 JIT 权限;x64 目标还获得 `com.apple.security.cs.allow-unsigned-executable-memory`,允许可执行代码页,包括在 Rosetta 下运行时。ARM64 Node 和 Office 辅助程序保留仅 JIT 权限。跨目标执行和签名安装需要对应的发布主机。`dev:desktop` 和 `start:desktop` 都会在启动 Electron 前准备 `.desktop-build/targets/<target>/runtime/primary-runtime`;首次准备可能需要下载锁定的依赖。准备未完成时,启动命令不能报告成功退出。
| 决策 | 原因 | 直接结果 |
|---|---|---|
+8 -4
View File
@@ -24,11 +24,12 @@ function magic(path: string): string {
* @param root - Self-contained production runtime without symlinks.
* @param appId - Release application identifier.
* @param expected - Required signing identity.
* @param cacheDirectory Optional content-addressed cache; requires the keychain-owned signing probe.
* @param arch - Target runtime architecture, independent of the signing host.
* @param cacheDirectory - Optional content-addressed cache; requires the keychain-owned signing probe.
* @returns Number of signed native files.
*/
export async function signMacOSRuntime(
root: string, appId: string, expected: MacOSSigningEnvironment, cacheDirectory?: string,
root: string, appId: string, expected: MacOSSigningEnvironment, arch: 'arm64' | 'x64', cacheDirectory?: string,
): Promise<number> {
const files = inventoryDesktopRuntime(root).map(file => file.path).filter(path => MACH_O_MAGICS.has(magic(join(root, path))))
const policy = cacheDirectory === undefined ? undefined : macOSCachePolicy(process.env.DSH_DESKTOP_MACOS_SIGNING_PROBE ?? '')
@@ -40,9 +41,12 @@ export async function signMacOSRuntime(
const path = files[next++]
if (path === undefined) return
const identifier = `${appId}.runtime.${createHash('sha256').update(path).digest('hex')}`
const needsJit = path === 'dependencies/node/bin/node'
const isNode = path === 'dependencies/node/bin/node'
const needsJit = isNode
|| /^node_modules\/@deepseek-ai\/libreoffice-kit-darwin-(?:arm64|x64)\/bin\/libreoffice-kit$/u.test(path)
const entitlements = needsJit ? join(import.meta.dirname, 'jit-entitlements.plist') : undefined
const entitlementsFile = isNode && arch === 'x64'
? 'node-x64-entitlements.plist' : 'jit-entitlements.plist'
const entitlements = needsJit ? join(import.meta.dirname, entitlementsFile) : undefined
const file = join(root, path)
const thin = ['cefaedfe', 'cffaedfe', 'feedface', 'feedfacf'].includes(magic(file))
if (cacheDirectory !== undefined && policy !== undefined && thin) {
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>com.apple.security.cs.allow-jit</key><true/><key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/></dict></plist>
+4 -4
View File
@@ -29,7 +29,7 @@ import {
import {
signMacOSRuntime,
} from './macos-runtime.ts'
import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
import { desktopTargetPlatform, resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
import { desktopRuntimeFileExclusion } from './runtime-file-policy.ts'
import { selectOfficeEngine } from '../../../scripts/libreoffice-packages.mjs'
@@ -130,7 +130,7 @@ async function main(): Promise<void> {
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:install', () => runPnpm(['install', '--prod', '--frozen-lockfile', '--trust-lockfile']))
const packageSet = readDesktopCorePackageSet(BUILD_ROOT, release.version)
const targetName = resolveDesktopBuildTarget()
const target = { platform: process.platform, arch: targetName.endsWith('arm64') ? 'arm64' : 'x64' }
const target = { platform: process.platform, arch: desktopTargetPlatform(targetName).arch }
const modules = join(BUILD_ROOT, 'node_modules')
const officeManifest = JSON.parse(readFileSync(join(modules, '@deepseek-ai/libreoffice-kit/package.json'), 'utf8'))
const officeEngine = selectOfficeEngine(officeManifest, target)
@@ -154,8 +154,8 @@ async function main(): Promise<void> {
throw new Error(`desktop runtime: missing required LibreOffice engine ${officeEngine}`)
}
if (process.platform === 'darwin') {
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache')))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), join(BUILD_PATHS.root, 'signature-cache')))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:dsh-native', () => signMacOSRuntime(DSH_OUTPUT_ROOT, resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache')))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'sign:primary-native', () => signMacOSRuntime(join(RUNTIME_ROOT, 'primary-runtime'), resolveDesktopAppId(process.env), resolveMacOSSigningEnvironment(process.env), target.arch, join(BUILD_PATHS.root, 'signature-cache')))
}
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:manifests', () => prepareRuntimeManifests(DSH_OUTPUT_ROOT))
await packagingStep(process.env.DSH_DESKTOP_PACKAGING_RUN_DIR, 'runtime:primary-smoke', async () => smokePrimaryRuntime(join(RUNTIME_ROOT, 'primary-runtime')))
@@ -51,7 +51,9 @@ it('includes certificate, identifier and entitlement contents in the cache polic
const create = macOSCachePolicy('probe')
const original = create('com.example.file', identity).policy
expect(create('com.other.file', identity).policy).not.toBe(original)
expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy).not.toBe(original)
const jit = create('com.example.file', identity, join(import.meta.dirname, '../scripts/jit-entitlements.plist')).policy
expect(jit).not.toBe(original)
expect(create('com.example.file', identity, join(import.meta.dirname, '../scripts/node-x64-entitlements.plist')).policy).not.toBe(jit)
certificate = 'renewed-certificate'
expect(macOSCachePolicy('probe')('com.example.file', identity).policy).not.toBe(original)
})
+12 -7
View File
@@ -1,4 +1,4 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
@@ -21,7 +21,7 @@ it('signs Mach-O files in their final locations and verifies each signature', as
const path = root()
writeFileSync(join(path, 'addon.node'), Buffer.from('cffaedfe00000000', 'hex'))
writeFileSync(join(path, 'source.js'), 'export {}')
await expect(signMacOSRuntime(path, 'com.example.app', identity)).resolves.toBe(1)
await expect(signMacOSRuntime(path, 'com.example.app', identity, 'arm64')).resolves.toBe(1)
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), expect.stringMatching(/^com\.example\.app\.runtime\.[a-f0-9]{64}$/u), identity, undefined)
expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'addon.node'), identity)
})
@@ -38,7 +38,7 @@ it('awaits other signers before rejecting and permitting output cleanup', async
await barrier
})
let completed = false
const result = signMacOSRuntime(path, 'com.example.app', identity).catch((error: unknown) => { completed = true; return error })
const result = signMacOSRuntime(path, 'com.example.app', identity, 'arm64').catch((error: unknown) => { completed = true; return error })
try {
await ready
expect(completed).toBe(false)
@@ -47,16 +47,21 @@ it('awaits other signers before rejecting and permitting output cleanup', async
expect(verifyMacOSRuntimeCode).toHaveBeenCalledWith(join(path, 'b.node'), identity)
})
it('grants JIT only to the standalone Node interpreter and native Office helpers', async () => {
it.each(['arm64', 'x64'] as const)('selects %s Node entitlements and keeps helpers JIT-only', async (arch) => {
const path = root()
mkdirSync(join(path, 'dependencies/node/bin'), { recursive: true })
const node = join(path, 'dependencies/node/bin/node')
const addon = join(path, 'addon.node')
const helpers = ['arm64', 'x64'].map(arch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${arch}`, 'bin/libreoffice-kit'))
const helpers = ['arm64', 'x64'].map(helperArch => join(path, 'node_modules/@deepseek-ai', `libreoffice-kit-darwin-${helperArch}`, 'bin/libreoffice-kit'))
for (const helper of helpers) mkdirSync(join(helper, '..'), { recursive: true })
for (const file of [node, addon, ...helpers]) writeFileSync(file, Buffer.from('cffaedfe00000000', 'hex'))
await signMacOSRuntime(path, 'com.example.app', identity)
for (const file of [node, ...helpers]) {
await signMacOSRuntime(path, 'com.example.app', identity, arch)
const nodePlist = join(import.meta.dirname, '../scripts', arch === 'x64' ? 'node-x64-entitlements.plist' : 'jit-entitlements.plist')
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(node, expect.any(String), identity, nodePlist)
const xml = readFileSync(nodePlist, 'utf8')
expect(xml).toMatch(/<key>com\.apple\.security\.cs\.allow-jit<\/key>\s*<true\s*\/>/u)
expect(/<key>com\.apple\.security\.cs\.allow-unsigned-executable-memory<\/key>\s*<true\s*\/>/u.test(xml)).toBe(arch === 'x64')
for (const file of helpers) {
expect(signMacOSRuntimeCode).toHaveBeenCalledWith(file, expect.any(String), identity,
join(import.meta.dirname, '../scripts/jit-entitlements.plist'))
}
+126 -2
View File
@@ -16,9 +16,9 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
@@ -264,6 +264,130 @@ describe('web e2e: plan review takeover round trip', () => {
})
})
describe.skipIf(MODE === 'record')('web e2e: pending plan review across Sidebar seat changes', () => {
// Two recorded Sessions seeded side by side; opening one in the page gives it
// a live Agent, so a review can be delivered to it while it is off screen.
const OTHER_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url))
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let planned: SessionId
let other: SessionId
const crashes: string[] = []
const row = (session: SessionId) => page.locator(`[data-row-key="session:${session}"]`)
async function showSessions(): Promise<void> {
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
// Expand the Workspace group by its state, not by whether its rows have rendered yet.
const group = page.locator('[data-row-key^="workspace:"]').first()
await group.waitFor({ timeout: 15_000 })
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
await row(planned).waitFor({ timeout: 15_000 })
}
async function open(session: SessionId): Promise<void> {
await row(session).click()
await page.locator(`[data-conversation-session="${session}"]`).waitFor({ timeout: 15_000 })
await page.locator(`[data-sidebar-right-session="${session}"]:not([hidden])`).first().waitFor({ state: 'attached', timeout: 15_000 })
// `expect.poll` is test-scoped and this also runs from beforeAll, so poll by hand.
const deadline = Date.now() + 15_000
while (scaffold.ctx.agents.get(session) === undefined) {
if (Date.now() > deadline) throw new Error(`opening Session ${session} published no live Agent`)
await new Promise(resolve => setTimeout(resolve, 100))
}
}
async function showPlugins(): Promise<void> {
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
await expect.poll(() => page.locator('[data-composer-input]').count(), { timeout: 10_000 }).toBe(0)
}
/** Deliver a plan review to one Session, run the scenario, and withdraw the review. */
async function whileReviewing(session: SessionId, detail: string, scenario: () => Promise<void>): Promise<void> {
const agent = scaffold.ctx.agents.get(session)
if (agent === undefined) throw new Error(`Session ${session} has no live Agent`)
const controller = new AbortController()
const asked = scaffold.ctx.userQuestions.ask({
agent, signal: controller.signal,
questions: [{ id: 'seat-change', question: 'Approve this plan?', detail,
options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' },
}],
})
const outcome = asked.then(value => value, (error: unknown) => ({ error }))
try {
await row(session).locator('[data-state="warning"]').waitFor({ timeout: 10_000 })
await scenario()
} finally {
controller.abort()
await outcome
}
}
/** The review opened itself in the Sidebar and kept its manual opener. */
async function expectOpened(text: string): Promise<void> {
const card = page.locator('[data-plan-review-key]')
await card.waitFor({ timeout: 10_000 })
const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]')
await preview.waitFor({ state: 'visible', timeout: 10_000 })
expect(await preview.getByText(text).isVisible()).toBe(true)
expect(await card.getByRole('button', { name: 'Open plan in sidebar' }).count()).toBe(1)
expect(await page.locator('[data-slot-error]').count()).toBe(0)
expect(crashes).toEqual([])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}
beforeAll(async () => {
scaffold = await launchWebScaffold({})
planned = await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), 'plan-review-seat-planned')
other = await seedSession(scaffold, await readFile(OTHER_FIXTURE, 'utf8'), 'plan-review-seat-other')
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
page.on('console', (message: ConsoleMessage) => {
if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) crashes.push(message.text())
})
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await showSessions()
await open(planned)
await open(other)
})
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('opens a review that arrives while another Session is on screen', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-session-return'))
// Returning mounts the review in the commit that brings its Session back on
// screen and swaps the Sidebar seats; the review's automatic open runs
// before either seat's effects.
await whileReviewing(planned, '# Session review\n\nSubmitted while another Session was on screen.', async () => {
await row(planned).click()
await expectOpened('Submitted while another Session was on screen.')
})
})
it('opens a review for a Session whose Sidebar layout was never saved', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-first-layout'))
// The layout is stored per browser. Without it the arriving seat creates
// one, and that store commit lands in the commit where the review opens.
await open(other)
const warningStart = tripwire.warnings.length
await page.evaluate((session) => { localStorage.removeItem(`dsh.sidebar-right.v1.${session}`) }, planned)
await page.reload({ waitUntil: 'load' })
await showSessions()
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await showPlugins()
await whileReviewing(planned, '# Layout review\n\nSubmitted to a Session without a saved Sidebar layout.', async () => {
await row(planned).click()
await expectOpened('Submitted to a Session without a saved Sidebar layout.')
})
})
})
describe('web e2e: dismissed plan history', () => {
it.skipIf(MODE === 'record')('reopens the permanent plan card after dismissing its review', async () => {
// Replay supplies a finite continuation; the real question rejection and retained document are the assertions.
+2 -2
View File
@@ -27,8 +27,8 @@
en: a13078ade4ef3b9d
zh: 2c8b3e8b62ad5652
/deepseek-ai-dsh-client-ui-plan/understand-the-implementation:
en: 1360fc232aee29e7
zh: 0beca76c9214a079
en: 9b663a9a30286847
zh: 9dca5298fce6a6dd
/deepseek-ai-dsh-client-ui-plan/further-exploration:
en: 59f62ef6269abf82
zh: c8b4ee607be1e432
+1 -1
View File
@@ -51,7 +51,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface
The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint.
Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The automatic open reads `ctx.sidebarRight.mounted` through a bound hook and runs once a seat is on screen: a review arriving while a global panel is active mounts ahead of the returning seat in the same commit, before that seat binds. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor.
Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The automatic open reads `ctx.sidebarRight.mounted` through a bound hook and runs once a Session is on screen: the Sidebar names the returning Session before the commit that mounts the review renders, so a review that arrived while a global panel or another Session was on screen opens from that commit's effect. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor.
The framework-bound `usePlans(turn)` exposes only submitted-plan data for that Turn. Chat indexes membership on Node updates and orders this collection when read; card rendering neither scans the transcript nor subscribes to other Turns or Node kinds.
+1 -1
View File
@@ -51,7 +51,7 @@ kind: "package-reference"
徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。
计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在座位出现在屏幕上后才执行:全局面板打开期间到达的审批,会在返回时与座位在同一次提交里先挂载、早于座位完成绑定。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。
计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在有会话出现在屏幕上后才执行:Sidebar 在挂载审批的那次提交渲染之前就已给出返回的会话,因此在全局面板或其他会话占据屏幕期间到达的审批,会在那次提交的 effect 里打开。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。
框架绑定的 `usePlans(turn)` 只提供该回合的已提交计划数据。Chat 随节点更新维护成员索引,并在读取时排列该集合;卡片渲染不扫描整段对话,也不订阅其他回合或节点类型。
@@ -30,7 +30,7 @@ export interface PlanReviewOpenInjected {
/** Open the logged plan, or the request's temporary document when no invocation exists. */
openReview: (review: PropsRuntime<'conversation.plan-review.actions'>['review'], requestKey: string) => void
readonly hooks: {
/** The session whose right Sidebar seat is mounted; `undefined` while none is on screen. */
/** The Session on screen, whose right Sidebar the opens land in; `undefined` while none is. */
readonly sidebarMounted: HostObservable<SessionId | undefined>
}
}
@@ -62,10 +62,10 @@ export function PlanCards({ turn, usePlans, openPlan, t }: PropsRuntime<'convers
/**
* Open each pending plan automatically and retain a manual opener without answering it.
*
* The automatic open waits for a mounted Sidebar seat: a review that arrives
* while the Conversation is off screen mounts in the same commit as the seat,
* ahead of it, and the seat binds from its own effect. Reading the bound
* session through the hook opens once that binding exists.
* The automatic open waits for a Session on screen. `ctx.sidebarRight` names
* the Session before the commit that brings its Conversation back renders, so
* a review that arrived while the Conversation was off screen opens from the
* effect of the commit that mounts it.
* @param props - Review identity, Session store, localized copy, and navigation.
* @returns an opener for either logged or temporary plan text.
*/
@@ -109,14 +109,14 @@ describe('ui-plan browser apply', () => {
const tabs = new SidebarRightTabRegistry(b.ctx)
b.registerType.mockImplementation(definition => tabs.register(definition))
const store = createSidebarRightStore(() => ({ kind: 'guide', title: 'Guide' })).create()
const { controller, adopt } = createSidebarRightController(tabs, vi.fn())
const { controller, adopt, show } = createSidebarRightController(tabs, vi.fn(), {
autoFullscreen: () => false,
openWithFocus: (_sessionId, open) => { open() },
closeWithFocus: (_sessionId, _paneId, close) => { close() },
})
const release = adopt(parent, store)
store.actions.open(parent)
const unbind = controller.bind({
sessionId: parent, actions: store.actions, surfaces: store.getSnapshot().bySession, canSplitPane: () => true,
closeWithFocus: (_paneId, close) => { close() },
openWithFocus: (open) => { open() },
})
show(parent)
b.openResource.mockImplementation(controller.openResource.bind(controller))
b.openResourceIn.mockImplementation(controller.openResourceIn.bind(controller))
b.subagentAddress.mockReturnValue({ parentSessionId: parent, childSessionId: child, mode: 'continuable' })
@@ -137,7 +137,7 @@ describe('ui-plan browser apply', () => {
expect(store.getSnapshot().bySession[child]).toBeUndefined()
} finally {
await fiber.dispose()
unbind()
show(undefined)
release()
controller.tabDomain.dispose()
}
@@ -220,7 +220,7 @@ describe('ui-plan browser apply', () => {
const review = b.slots.entries('conversation.plan-review.actions')[0]!
expect(review.component).toBe(PlanReviewOpen)
const reviewInjected = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(SID)
// The automatic open reads the service's mounted-seat source, not a copy.
// The automatic open reads the service's on-screen Session source, not a copy.
expect(reviewInjected.hooks.sidebarMounted).toBe(b.mounted)
const pending = { id: 'review', question: 'Approve?', plan: plan.markdown, callId: plan.callId, approve: { label: 'Approve' } }
reviewInjected.openReview(pending, 'question:1')
@@ -29,13 +29,13 @@ function planHook(plans: readonly typeof plan[]): Parameters<typeof PlanCards>[0
const source = createSnapshotStore(plans)
return keyedObservableHook(() => source) as Parameters<typeof PlanCards>[0]['usePlans']
}
/** The opener's props share; each test supplies the seat hook beside it. */
/** The opener's props share; each test supplies the on-screen Session hook beside it. */
function reviewProps(review: { plan: string; callId?: typeof plan.callId }, openReview: Mock, store: ReturnType<ReturnType<typeof createPlanReviewStore>['create']>) {
return { review, requestKey: 'question:1', t, openReview, actions: store.actions,
useStore: (select: (state: ReturnType<typeof store.getSnapshot>) => unknown) => select(store.getSnapshot()),
} as unknown as Parameters<typeof PlanReviewOpen>[0]
}
/** A bound `useSidebarMounted` reading one observable seat value. */
/** A bound `useSidebarMounted` reading one on-screen Session value. */
function seatHook(read: () => SessionId | undefined) {
return <S,>(select: (session: SessionId | undefined) => S): S => select(read())
}
@@ -257,9 +257,8 @@ describe('plan entry points and document', () => {
} as Parameters<typeof PlanReviewOpen>[0]} />)
expect(openReview).toHaveBeenCalledTimes(4)
})
it('waits for a mounted sidebar seat before opening automatically, then opens once', () => {
// The review and the seat mount in one commit, the review first: its effect
// runs while no seat is bound, and the seat's own effect binds afterwards.
it('waits for a Session on screen before opening automatically, then opens once', () => {
// A retained Sidebar can hold the review while no Session is on screen.
const openReview = vi.fn()
const store = createPlanReviewStore().create()
const mounted = createSnapshotStore<SessionId | undefined>(undefined)
@@ -268,7 +267,7 @@ describe('plan entry points and document', () => {
const view = render(<PlanReviewOpen {...props} />)
expect(openReview).not.toHaveBeenCalled()
expect(store.getSnapshot().opened).toEqual({})
// The manual opener stays available without a seat; the service decides what to do.
// The manual opener stays available without a Session on screen; the service decides what to do.
fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' }))
expect(openReview).toHaveBeenCalledTimes(1)
mounted.set(target.session.sessionId)
@@ -40,7 +40,7 @@ async function boot() {
const rt = await SlotTestRuntime.create()
runtime = rt
rt.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) } } as never)
rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: rt.panelInfo } as never)
rt.ctx.provide('shortcuts', { register: () => () => {}, catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} } } as never)
const locale = new LocaleRuntime(rt.ctx)
rt.ctx.provide('locale', locale)
@@ -21,8 +21,8 @@
en: 6d7f145ecb1fd140
zh: 666d142ef249f6cb
/deepseek-ai-dsh-client-ui-sidebar-right/the-expand-button:
en: 28b0bc3d4050b0e0
zh: 733b44ad9309f49a
en: 2614d3f179290606
zh: 88c08ee260ba57b7
/deepseek-ai-dsh-client-ui-sidebar-right/state:
en: 2b15cdf5f1383291
zh: 57018036d77363ba
@@ -30,8 +30,8 @@
en: ef2af36136c4a919
zh: 48d992e2c923a130
/deepseek-ai-dsh-client-ui-sidebar-right/ctx-sidebarright:
en: 8cffef22360aa177
zh: a09eef5bb7fdbd61
en: 4519c4633d160c2a
zh: e4019a62532f885a
/deepseek-ai-dsh-client-ui-sidebar-right/the-tab-domain:
en: 717b86e05b44ebdd
zh: 0f4f694d0ea1d7f8
@@ -51,5 +51,5 @@
en: 76a763b4dd71babd
zh: fdc966dd427a82fb
/deepseek-ai-dsh-client-ui-sidebar-right/known-limitations-and-deferred-work/dev-note:
en: 33274d681fac9a21
zh: e024c1c4766b289f
en: 9fcbcccf09b43c5d
zh: 224b8a0881ab6890
+4 -4
View File
@@ -58,7 +58,7 @@ While the panel is hidden, one button in the conversation header's corner seat (
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.
The root-scoped `rightbar` entry renders the selected Session and background Sessions with initialized `keepMounted` bodies through separate `rightbar.session` subtrees. Each View owns its Session reference. Only the foreground Conversation reports a frame track and binds public navigation; other subtrees stay hidden without deleting tab state.
The root-scoped `rightbar` entry renders the selected Session and background Sessions with initialized `keepMounted` bodies through separate `rightbar.session` subtrees. Each View owns its Session reference. Only the foreground subtree reports a frame track; other subtrees stay hidden without deleting tab state.
<a id="state"></a>
## State
@@ -94,9 +94,9 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r
`preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference.
`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. 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. `mounted` is the observable of the session whose seat is on screen, `undefined` while a global panel replaces the Conversation or no session is selected. A component that opens content from its own mount effect reads it through a bound hook and acts once it is defined: the frame mounts the Conversation column ahead of this seat, and the seat publishes its binding from a passive effect of the same commit, so an effect that assumed a bound seat would run first and throw. The plan review's automatic open reads it this way.
`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. 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 on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook.
`focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the mounted Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection.
`focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the on-screen Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection.
<a id="the-tab-domain"></a>
## The Tab domain
@@ -156,4 +156,4 @@ 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.
**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 on-screen Session and the Tab domain's occurrence lifetimes are asserted directly by this package's specs, and no independent observation exists to diverge from them.
@@ -58,7 +58,7 @@ kind: "package-reference"
面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。
root 作用域的 `rightbar` 入口通过独立的 `rightbar.session` 子树渲染选中的 Session,以及拥有已初始化 `keepMounted` 正文的后台 Session。每个 View 拥有自身的 Session reference。只有前台 Conversation 上报框架列宽并绑定公共导航;其他子树保持隐藏,不删除 tab 状态。
root 作用域的 `rightbar` 入口通过独立的 `rightbar.session` 子树渲染选中的 Session,以及拥有已初始化 `keepMounted` 正文的后台 Session。每个 View 拥有自身的 Session reference。只有前台子树上报框架列宽;其他子树保持隐藏,不删除 tab 状态。
<a id="state"></a>
## 状态
@@ -94,9 +94,9 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包
`preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。
`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。`mounted` 是「席位正在屏幕上的那个会话」的可观察值,全局面板替代 Conversation 或没有选中会话时为 `undefined`。要在自己的挂载 effect 里打开内容的组件通过绑定的 hook 读取它,并在它有值后再行动:框架把 Conversation 列挂载在这个席位之前,而席位在同一次提交的 passive effect 里才发布绑定,一个假定席位已绑定的 effect 会先运行并 throw。计划审阅的自动打开就是这样读取它的。
`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。
`focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用已挂载会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。
`focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用屏幕上会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。
<a id="the-tab-domain"></a>
## Tab 域
@@ -156,4 +156,4 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止
</details>
**运行时不变量:** 不发布 companion。两个服务(`sidebarRight`、`sidebarRightTabs`)在同一个 effect 内经 `ctx.reflect.provide` 提供并随之拆除;席位绑定与 Tab 域 occurrence 的生命周期由本包的 spec 直接断言,不存在会与之分歧的独立观察。
**运行时不变量:** 不发布 companion。两个服务(`sidebarRight`、`sidebarRightTabs`)在同一个 effect 内经 `ctx.reflect.provide` 提供并随之拆除;屏幕上的会话与 Tab 域 occurrence 的生命周期由本包的 spec 直接断言,不存在会与之分歧的独立观察。
@@ -36,6 +36,7 @@ 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 { closeWithPaneFocus, openWithPaneFocus } from './shell/close-focus.ts'
import { RightbarRoot, type RightbarRootInjected } from './shell/RightbarRoot.tsx'
import { SidebarSessionViews } from './session-views.ts'
import { createSidebarRightController, type SidebarRightController } from './service.ts'
@@ -53,7 +54,7 @@ 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,
ISidebarRight, SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions,
SidebarRightPlacement, SidebarRightCloseHandler, SurfaceActions,
} from './service.ts'
export type {
@@ -115,10 +116,37 @@ export function apply(ctx: ClientContext): void {
sync()
return () => { unsubscribe(); views.dispose() }
}, 'ui-sidebar-right: retained Session views')
const { controller, adopt, forget } = createSidebarRightController(
const layout: ILayout = ctx.layout
// The automatic fullscreen rule as the seats last rendered it: the frame
// hands them its width as owner props, and every seat reports the same rule.
let autoFullscreen = false
const { controller, adopt, forget, show, measure } = createSidebarRightController(
tabs,
(address, signal) => { ctx.resources.pin(address, signal) },
{
autoFullscreen: () => autoFullscreen,
openWithFocus: (sessionId, open) => { openWithPaneFocus(document, sessionId, open) },
closeWithFocus: (sessionId, paneId, close) => { closeWithPaneFocus(document, sessionId, paneId, close) },
},
)
// The Session on screen: the selected one while the Conversation fills the
// main column. Both sources notify before React renders their change, so the
// service names the arriving Session before any component of that commit
// reads it, and its seat mints the Session's store in the same render.
ctx.effect(() => {
const sync = (): void => {
const selected = views.source.getSnapshot().find(view => view.selected)
show(layout.panelInfo.getSnapshot().activePanelId === null ? selected?.sessionId : undefined)
}
const unsubscribeViews = views.source.subscribe(sync)
const unsubscribePanel = layout.panelInfo.subscribe(sync)
sync()
return () => {
unsubscribeViews()
unsubscribePanel()
show(undefined)
}
}, 'ui-sidebar-right: on-screen Session')
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
@@ -157,13 +185,12 @@ 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' | 'measureRoom'> = {
syncPresentation({ shown, track, fullscreen }) {
if (shown) layout.openRightbar(track, fullscreen)
else layout.closeRightbar()
},
bindService: binding => controller.bind(binding),
reportAutoFullscreen: (value) => { autoFullscreen = value },
splitPane: (paneId) => { controller.split(paneId) },
toggleFullscreen: () => { const target = controller.commandTarget(); if (target !== undefined) controller.toggleFullscreen(target) },
openTab: (kind, options) => { controller.openTab(kind, options) },
@@ -194,6 +221,7 @@ export function apply(ctx: ClientContext): void {
store,
inject: (sessionId): SidebarRightInjected => ({
...injected,
measureRoom: (canSplitPane) => { measure(sessionId, canSplitPane) },
closeTab: (tabId) => {
try { controller.closeIn(sessionId, tabId) }
catch (error) { console.error('Sidebar tab close failed:', error) }
@@ -3,19 +3,22 @@
*
* 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. `mounted` publishes that
* binding's session as an observable, so a consumer that wants to open content
* as soon as a seat is on screen subscribes to it instead of assuming one is
* bound when its own effect runs. 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.
* reach on its own. 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, on screen or not.
*
* The plugin also names the Session on screen — the selected Session while the
* Conversation fills the main column — from the selection and the main panel,
* before React renders either change, and publishes it as `mounted`. Every
* command on the public face acts on that Session through its adopted store; a
* command with no Session on screen, or with one whose store the runtime has not
* minted, has nothing to act on and fails loudly rather than writing into a
* surface nobody is drawing. The seats never publish which Session they draw:
* they report only what they render with, the room their docking kit measured
* and the automatic fullscreen rule of their frame width.
*
* 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
* the on-screen 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.
*
@@ -27,8 +30,8 @@
* 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.
*
* The registration adopts Session stores and injects the mounted seat binding;
* callers use the service's navigation methods.
* The registration adopts Session stores, names the on-screen Session, and
* forwards the seats' reports; callers use the service's navigation methods.
*/
import { sidebarTargetFromElement, type SidebarRightTarget } from './focus.ts'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
@@ -61,24 +64,49 @@ interface Adoption {
readonly unsubscribe: () => void
}
/** The room rule's verdict for a docked pane, as a seat's docking kit last measured it: whether two working halves would fit. */
type RoomRule = (paneId: PaneId) => boolean
/** Plugin-owned Session state the controller reads; the plugin writes it through the factory's callbacks. */
interface SidebarRightSessions {
/** Each Session's latest adopted store. */
readonly adopted: ReadonlyMap<SessionId, Adoption>
/** Each Session's room rule as its seat last reported it; a Session without one has room in every pane. */
readonly rooms: ReadonlyMap<SessionId, RoomRule>
/** The Session the plugin names as on screen. */
readonly onScreen: ObservableSnapshot<SessionId | undefined>
/** Open tab metadata across saved and adopted Sessions. */
readonly openTabs: SidebarTabInventory['source']
}
/**
* Create the public controller and the plugin-private store adoption callback.
* Create the public controller and the plugin-private Session callbacks.
* Adoption reconciles restored records before any seat renders, then follows commits.
* @param tabs - registered tab types.
* @param pin - resource retention for an occurrence's lifetime.
* @returns the controller and plugin-owned adoption and scope-removal callbacks.
* @param host - the viewport rule and focus continuity the commands use.
* @returns the controller; store adoption and scope removal; naming the on-screen Session; and recording a seat's room rule.
*/
export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource): {
export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin: PinResource, host: SidebarRightHost): {
controller: SidebarRightController
adopt: (sessionId: SessionId, store: SidebarRightSurfaceStore) => () => void
forget: (sessionId: SessionId) => void
show: (sessionId: SessionId | undefined) => void
measure: (sessionId: SessionId, canSplitPane: RoomRule) => void
} {
const adopted = new Map<SessionId, Adoption>()
const rooms = new Map<SessionId, RoomRule>()
const onScreen = createSnapshotStore<SessionId | undefined>(undefined)
const inventory = new SidebarTabInventory()
const controller = new SidebarRightController(tabs, pin, adopted, inventory.source)
const controller = new SidebarRightController(tabs, pin, host, { adopted, rooms, onScreen, openTabs: inventory.source })
return {
controller,
forget: (sessionId) => { inventory.remove(sessionId) },
forget: (sessionId) => {
inventory.remove(sessionId)
rooms.delete(sessionId)
},
show: (sessionId) => { if (onScreen.getSnapshot() !== sessionId) onScreen.set(sessionId) },
measure: (sessionId, canSplitPane) => { rooms.set(sessionId, canSplitPane) },
adopt(sessionId, store) {
adopted.get(sessionId)?.unsubscribe()
const sync = (): void => {
@@ -97,29 +125,26 @@ export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin:
}
}
/** 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
/** What the controller needs from the page around it: the viewport rule and focus continuity. */
export interface SidebarRightHost {
/**
* 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.
* Whether the viewport is narrow enough that an expanded panel is presented fullscreen.
* @returns the rule's verdict for the frame width the seats last rendered at.
*/
readonly surfaces: Readonly<Record<string, SurfaceState>>
readonly autoFullscreen: () => boolean
/**
* The room rule's verdict for a docked pane, as the kit last measured it:
* whether two working halves would fit. Unmeasured panes fit.
* Commit a page operation and focus the pane it selects.
* @param sessionId - the Session whose page is opening.
* @param open - the synchronous operation; returns the selected pane, or `undefined` when unchanged.
*/
readonly canSplitPane: (paneId: PaneId) => boolean
/** Commit a keyboard/menu close and retain focus on a surviving visible pane. */
readonly closeWithFocus: (paneId: PaneId, close: () => void) => void
/** Commit a page operation and focus the pane it selects. */
readonly openWithFocus: (open: () => PaneId | undefined) => void
/** Narrow viewports present an expanded panel fullscreen. */
readonly autoFullscreen?: boolean
readonly openWithFocus: (sessionId: SessionId, open: () => PaneId | undefined) => void
/**
* Commit a keyboard/menu close and retain focus on a surviving visible pane.
* @param sessionId - the Session whose page is closing.
* @param paneId - the pane whose page is closing.
* @param close - the synchronous cleanup and removal.
*/
readonly closeWithFocus: (sessionId: SessionId, paneId: PaneId, close: () => void) => void
}
/** Where an open lands; every field is optional and the defaults are the common case. */
@@ -160,12 +185,13 @@ export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) =>
/** The outward right-Sidebar face (`ctx.sidebarRight`). */
export interface ISidebarRight {
/**
* The session whose seat is mounted, or `undefined` while no seat is on
* screen (a global panel is active, or no session is selected). Moves when a
* seat binds or releases, so a component that opens content from its own
* mount effect reads it through a bound hook and acts once it is defined:
* the frame mounts the Conversation column ahead of the seat, and the seat
* publishes its binding from a passive effect of the same commit.
* The Session on screen, or `undefined` while none is (a global panel fills
* the main column, or no Session is selected). It changes before React
* renders the selection or panel change that causes it, so every component
* rendered in that commit reads the arriving Session, and the commands act on
* it from that commit's effects: its seat mints its store in the same render.
* Moves only when the on-screen Session changes; the Session's own store
* commits are silent.
*/
readonly mounted: ObservableSnapshot<SessionId | undefined>
/**
@@ -189,13 +215,13 @@ export interface ISidebarRight {
*/
openTab<K extends string>(kind: K, options?: SidebarRightOpenTabOptions<K>): void
/**
* Close one tab of the mounted session; the sole docked guide remains open.
* Close one tab of the on-screen Session; the sole docked guide remains open.
* @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.
* @returns the record, or `undefined` with no Session on screen.
*/
active(): TabRecord | undefined
/**
@@ -236,10 +262,10 @@ export interface ISidebarRight {
export class SidebarRightController implements ISidebarRight {
/** Open tab metadata across saved and adopted Sessions, independent of visible seats. */
readonly openTabs: SidebarTabInventory['source']
private readonly mountedSession = createSnapshotStore<SessionId | undefined>(undefined)
/** The mounted seat's session; see {@link ISidebarRight.mounted}. */
readonly mounted: ObservableSnapshot<SessionId | undefined> = this.mountedSession
private binding: SidebarRightBinding | undefined
/** The on-screen Session; see {@link ISidebarRight.mounted}. */
readonly mounted: ObservableSnapshot<SessionId | undefined>
private readonly adopted: ReadonlyMap<SessionId, Adoption>
private readonly rooms: ReadonlyMap<SessionId, RoomRule>
private readonly closeHandlers = new Map<string, SidebarRightCloseHandler>()
/**
@@ -263,16 +289,19 @@ export class SidebarRightController implements ISidebarRight {
/**
* @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.
* @param openTabs - plugin-owned metadata source across saved and adopted layouts.
* @param host - the viewport rule and focus continuity the commands use.
* @param sessions - plugin-owned adopted stores, room rules, on-screen Session, and open tab metadata.
*/
constructor(
private readonly tabs: SidebarRightTabRegistry,
pin: PinResource,
private readonly adopted = new Map<SessionId, Adoption>(),
openTabs: SidebarTabInventory['source'] = new SidebarTabInventory().source,
private readonly host: SidebarRightHost,
sessions: SidebarRightSessions,
) {
this.openTabs = openTabs
this.adopted = sessions.adopted
this.rooms = sessions.rooms
this.mounted = sessions.onScreen
this.openTabs = sessions.openTabs
this.tabDomain = new TabDomain(this, pin)
}
@@ -285,31 +314,6 @@ export class SidebarRightController implements ISidebarRight {
return Object.values(this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout.tabs ?? {})
}
/**
* 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
this.publishMounted()
return () => {
// A newer seat may already have taken over; only the binding that is
// still ours may be cleared.
if (this.binding !== binding) return
this.binding = undefined
this.publishMounted()
}
}
/** Publish the mounted session only when it changes; a republished binding for the same session is silent. */
private publishMounted(): void {
const next = this.binding?.sessionId
if (this.mountedSession.getSnapshot() !== next) this.mountedSession.set(next)
}
/**
* Open a resource: claim it, place it, reveal the column, record the navigation.
* @param address - a `dsh-resource://<type>/…` address.
@@ -413,7 +417,6 @@ export class SidebarRightController implements ISidebarRight {
params: SidebarRightNavigationParams,
): void {
const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]
?? (this.binding?.sessionId === sessionId ? this.binding.surfaces[sessionId] : undefined)
const targetPane = surface === undefined ? undefined : placement.paneId ?? activeDockPaneId(surface.layout)
const target = targetPane === undefined ? undefined : surface?.layout.nodes[targetPane]
const preferNewPane = placement.preferNewPane === true
@@ -424,8 +427,8 @@ export class SidebarRightController implements ISidebarRight {
&& target.tabs.length > 0
&& canSplit(surface.layout)
&& dockPaneIds(surface.layout).length < 2
&& this.binding?.sessionId === sessionId
&& this.binding.canSplitPane(target.id)
&& this.mounted.getSnapshot() === sessionId
&& this.canSplitPane(sessionId, target.id)
const commit = (): void => { actions.openContent(sessionId, {
kind: claim.kind,
contentId: claim.contentId,
@@ -444,18 +447,16 @@ export class SidebarRightController implements ISidebarRight {
}
/**
* Close one tab of the mounted session; the sole docked guide remains open.
* Close one tab of the on-screen Session; the sole docked guide remains open.
* @param tabId - the tab to close.
*/
close(tabId: TabId): void {
const { sessionId, actions } = this.require()
if (this.adopted.has(sessionId)) { this.closeIn(sessionId, tabId); return }
actions.closeTab(sessionId, tabId)
this.closeIn(this.require().sessionId, tabId)
}
/**
* The active tab of the active pane.
* @returns the record, or `undefined` with no mounted surface.
* @returns the record, or `undefined` without an on-screen surface.
*/
active(): TabRecord | undefined {
const layout = this.mountedSurface()?.layout
@@ -466,7 +467,7 @@ export class SidebarRightController implements ISidebarRight {
/**
* Whether the column is currently showing its panel.
* @returns `true` while expanded; `false` while collapsed or with no mounted surface.
* @returns `true` while expanded; `false` while collapsed or without an on-screen surface.
*/
isExpanded(): boolean {
return this.mountedSurface()?.layout.expanded ?? false
@@ -474,8 +475,8 @@ export class SidebarRightController implements ISidebarRight {
/** Collapse the column, or expand it and focus its active dock pane after rendering. */
toggleExpanded(): void {
const { sessionId, actions, openWithFocus } = this.require()
openWithFocus(() => {
const { sessionId, actions } = this.require()
this.host.openWithFocus(sessionId, () => {
actions.toggleExpanded(sessionId)
const layout = this.mountedSurface()?.layout
return layout?.expanded ? activeDockPaneId(layout) : undefined
@@ -498,28 +499,27 @@ export class SidebarRightController implements ISidebarRight {
* @returns current focused page identity, or undefined outside a visible sidebar page.
*/
focusedTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined {
const binding = this.binding
const surface = this.mountedSurface()
return binding === undefined || surface === undefined ? undefined
: sidebarTargetFromElement(element, binding.sessionId, surface.layout,
tabId => this.tabDomain.occurrence(binding.sessionId, { id: tabId }))
const screen = this.screen()
return screen === undefined ? undefined
: sidebarTargetFromElement(element, screen.sessionId, screen.surface.layout,
tabId => this.tabDomain.occurrence(screen.sessionId, { id: tabId }))
}
/**
* Choose a focused sidebar pane, or the mounted Session's active dock pane for an outside open.
* Choose a focused sidebar pane, or the on-screen Session's active dock pane for an outside open.
* @param element - live command input target; stale sidebar markup never falls back to another pane.
* @returns captured target, or undefined without a mounted Session.
* @returns captured target, or undefined without an on-screen surface.
*/
commandTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined {
const focused = this.focusedTarget(element)
if (focused !== undefined || element?.closest('[data-sidebar-right-session]')) return focused
const binding = this.binding
const layout = this.mountedSurface()?.layout
if (binding === undefined || layout === undefined) return undefined
const screen = this.screen()
if (screen === undefined) return undefined
const { sessionId, surface: { layout } } = screen
const pane = getPane(layout, activeDockPaneId(layout))
const tabId = pane.activeTabId
const held = tabId === undefined ? undefined : this.tabDomain.occurrence(binding.sessionId, { id: tabId })
return { sessionId: binding.sessionId, paneId: pane.id, host: pane.host, tabId,
const held = tabId === undefined ? undefined : this.tabDomain.occurrence(sessionId, { id: tabId })
return { sessionId, paneId: pane.id, host: pane.host, tabId,
occurrence: held, navigationRevision: held?.navigation.getSnapshot().revision }
}
@@ -530,10 +530,11 @@ export class SidebarRightController implements ISidebarRight {
*/
openTabFromTarget(kind: string, target: SidebarRightTarget): void {
if (!this.isTargetCurrent(target)) return
this.require().openWithFocus(() => {
const { sessionId, actions } = this.require()
this.host.openWithFocus(sessionId, () => {
const tab = target.tabId === undefined ? undefined : this.mountedSurface()?.layout.tabs[target.tabId]
if (target.host === 'float' && tab?.kind === kind && this.tabs.get(kind)?.multiple !== true) {
this.require().actions.setExpanded(target.sessionId, true)
actions.setExpanded(sessionId, true)
this.focus(tab.id)
} else {
this.openTab(kind, {
@@ -565,10 +566,10 @@ export class SidebarRightController implements ISidebarRight {
const surface = this.mountedSurface()
if (surface === undefined || target.tabId === undefined) return 'unavailable'
const tabId = target.tabId
const binding = this.require()
binding.closeWithFocus(target.paneId, () => {
const { sessionId, actions } = this.require()
this.host.closeWithFocus(sessionId, target.paneId, () => {
if (canCloseTab(surface, tabId)) this.close(tabId)
else binding.actions.setExpanded(target.sessionId, false)
else actions.setExpanded(sessionId, false)
})
return 'closed'
}
@@ -576,10 +577,10 @@ export class SidebarRightController implements ISidebarRight {
/**
* Check a captured target before acting, including reopened records and intervening navigation.
* @param target - identity captured while resolving the input.
* @returns whether the mounted Session, pane, tab lifetime and navigation still match.
* @returns whether the on-screen Session, pane, tab lifetime and navigation still match.
*/
isTargetCurrent(target: SidebarRightTarget): boolean {
if (this.binding?.sessionId !== target.sessionId) return false
if (this.mounted.getSnapshot() !== target.sessionId) return false
const layout = this.mountedSurface()?.layout
const pane = layout?.nodes[target.paneId]
if (pane?.kind !== 'pane' || pane.host !== target.host) return false
@@ -603,7 +604,7 @@ export class SidebarRightController implements ISidebarRight {
if (!layout.expanded) return 'collapsed'
if (getPane(layout, target.paneId).tabs.length === 0) return 'empty'
if (!canSplit(layout) || dockPaneIds(layout).length >= 2) return 'budget'
if (!this.require().canSplitPane(target.paneId)) return 'width'
if (!this.canSplitPane(target.sessionId, target.paneId)) return 'width'
return undefined
}
@@ -613,9 +614,10 @@ export class SidebarRightController implements ISidebarRight {
*/
toggleFullscreen(target: SidebarRightTarget): void {
if (!this.isTargetCurrent(target) || target.host === 'float' || !this.isExpanded()) return
const { sessionId, actions, autoFullscreen } = this.require()
const fullscreen = autoFullscreen === true || this.mountedSurface()?.layout.mode === 'fullscreen'
if (fullscreen && autoFullscreen === true) actions.setExpanded(sessionId, false)
const { sessionId, actions } = this.require()
const autoFullscreen = this.host.autoFullscreen()
const fullscreen = autoFullscreen || this.mountedSurface()?.layout.mode === 'fullscreen'
if (fullscreen && autoFullscreen) actions.setExpanded(sessionId, false)
actions.setMode(sessionId, fullscreen ? 'push' : 'fullscreen')
}
@@ -625,15 +627,15 @@ export class SidebarRightController implements ISidebarRight {
* @returns the new pane's id, or `undefined` when nothing was split.
*/
split(paneId?: PaneId): PaneId | undefined {
const { sessionId, actions, canSplitPane } = this.require()
const { sessionId, actions } = this.require()
const layout = this.mountedSurface()?.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
if (!canSplit(layout) || dockPaneIds(layout).length >= 2 || !this.canSplitPane(sessionId, target)) return undefined
let created: PaneId | undefined
this.require().openWithFocus(() => {
this.host.openWithFocus(sessionId, () => {
actions.splitPane(sessionId, target, (id) => { created = id })
return created
})
@@ -665,7 +667,7 @@ export class SidebarRightController implements ISidebarRight {
}
/**
* Step the mounted session's surface back one intent.
* Step the on-screen 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.
@@ -676,7 +678,7 @@ export class SidebarRightController implements ISidebarRight {
}
/**
* Step the mounted session's surface forward one intent.
* Step the on-screen Session's surface forward one intent.
*
* @internal See `_undo`.
*/
@@ -685,11 +687,25 @@ export class SidebarRightController implements ISidebarRight {
actions.redo(sessionId)
}
/** The mounted session's surface; `undefined` without a seat or before its first open. */
/**
* The on-screen Session and its committed surface; `undefined` with no Session
* on screen, before the runtime mints its store, or before its first open.
*/
private screen(): { readonly sessionId: SessionId; readonly surface: SurfaceState } | undefined {
const sessionId = this.mounted.getSnapshot()
if (sessionId === undefined) return undefined
const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]
return surface === undefined ? undefined : { sessionId, surface }
}
/** The on-screen Session's surface; see {@link SidebarRightController.screen}. */
private mountedSurface(): SurfaceState | undefined {
const { binding } = this
return binding === undefined ? undefined
: this.adopted.get(binding.sessionId)?.store.getSnapshot().bySession[binding.sessionId] ?? binding.surfaces[binding.sessionId]
return this.screen()?.surface
}
/** Whether a Session's docked pane has room for two working halves, as its seat last measured; unmeasured panes do. */
private canSplitPane(sessionId: SessionId, paneId: PaneId): boolean {
return this.rooms.get(sessionId)?.(paneId) ?? true
}
/**
@@ -701,13 +717,15 @@ export class SidebarRightController implements ISidebarRight {
return this.adopted.get(sessionId)?.store.actions
}
private require(): SidebarRightBinding {
private require(): { readonly sessionId: SessionId; readonly actions: SurfaceActions } {
// 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) {
const sessionId = this.mounted.getSnapshot()
const actions = sessionId === undefined ? undefined : this.actionsFor(sessionId)
if (sessionId === undefined || actions === undefined) {
throw new Error('sidebarRight: no session surface is mounted')
}
return this.binding
return { sessionId, actions }
}
}
@@ -49,7 +49,6 @@ 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'
import { closeWithPaneFocus, openWithPaneFocus } from './close-focus.ts'
/** The store share the seat receives. */
type Store = PropsStore<ReturnType<typeof createSidebarRightStore>>
@@ -78,27 +77,21 @@ export interface SidebarRightInjected {
*/
readonly syncPresentation: (presentation: SidebarRightPresentation) => void
/**
* Publish this seat's session, actions, and the store's surfaces to `ctx.sidebarRight`.
* Record this seat's room rule for `ctx.sidebarRight`'s splits, replacing its previous one.
*
* 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;
* the service publishes the bound session through `ctx.sidebarRight.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.
* The docking kit measures its panes after it renders; the seat forwards each
* reading, and the service applies it when it splits this seat's Session.
* @param canSplitPane - whether two working halves of a docked pane would fit; unmeasured panes fit.
*/
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
/** Commit a keyboard/menu close and retain focus on a surviving visible pane. */
closeWithFocus: (paneId: PaneId, close: () => void) => void
/** Commit a page operation and focus the pane it selects. */
openWithFocus: (open: () => PaneId | undefined) => void
autoFullscreen?: boolean
}) => () => void
readonly measureRoom: (canSplitPane: (paneId: PaneId) => boolean) => void
/**
* Record whether the frame width this seat renders at presents an expanded panel fullscreen automatically.
*
* The fullscreen command reads the latest report: while it holds, the command
* closes the panel instead of switching its recorded mode.
* @param autoFullscreen - whether the frame is narrower than the automatic fullscreen width.
*/
readonly reportAutoFullscreen: (autoFullscreen: 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.
@@ -353,29 +346,26 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<H
/**
* 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.
* It is also where the frame learns the panel's presentation, because this is
* the seat that knows it. `ctx.sidebarRight` names the on-screen Session
* itself; this seat reports only what it renders with: the room its kit
* measured and the automatic fullscreen rule of its frame width.
*/
export function RightbarSeat({
sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab, closeTab,
useTabTypes, useTabNavigation, occurrence, retainTab, active, useShortcuts, splitPane, toggleFullscreen,
sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, measureRoom, reportAutoFullscreen,
openTab, closeTab, useTabTypes, useTabNavigation, occurrence, retainTab, active, useShortcuts, splitPane, toggleFullscreen,
}: 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 shortcuts = useShortcuts(entries => entries)
const surfaces = useStore(state => state.bySession)
const surface = surfaces[sessionId]
const surface = useStore(state => state.bySession[sessionId])
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)
// 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 }, [])
// A reading never re-renders anything: the service applies it when it splits.
const reportRoom = useCallback((fits: ReadonlyMap<PaneId, HalvesFit>): void => {
measureRoom(paneId => fits.get(paneId)?.row !== false)
}, [measureRoom])
const track = shown && !autoFullscreen
useEffect(() => {
@@ -386,6 +376,9 @@ export function RightbarSeat({
if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false)
}, [actions, sessionId, shown, fullscreen, canShow])
// Read only when the fullscreen command runs, after this commit has settled.
useLayoutEffect(() => { reportAutoFullscreen(autoFullscreen) }, [reportAutoFullscreen, autoFullscreen])
// The open/close slide needs no pulse of its own: the shell's window drag
// watcher (ui-web) measures the marked rows every frame the surface moves and
// sets the recall mark itself (electron#32341).
@@ -419,17 +412,6 @@ export function RightbarSeat({
? () => { 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(
() => active
? bindService({ sessionId, actions, surfaces, autoFullscreen,
closeWithFocus: (paneId, close) => { closeWithPaneFocus(document, sessionId, paneId, close) },
openWithFocus: (open) => { openWithPaneFocus(document, sessionId, open) },
canSplitPane: paneId => room.current.get(paneId)?.row !== false })
: undefined,
[bindService, sessionId, actions, surfaces, autoFullscreen, 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,
// on screen or not.
@@ -5,7 +5,7 @@
* means what those faces do; the slot, locale, frame, and resource faces are
* recorders, because what matters here is what was handed to them — two seats
* over one store, the guide's body under its own id, the frame reports, the
* service binding — and that every registration is gone after dispose, which
* on-screen Session — and that every registration is gone after dispose, which
* is what makes a reload safe. The seats' components have their own specs.
*/
import { describe, expect, it, onTestFinished, vi } from 'vitest'
@@ -63,7 +63,11 @@ async function boot(shortcuts: Partial<Shortcuts> = {}) {
return () => { dictionaries.delete(ns) }
}),
}
const layout = { openRightbar: vi.fn(), closeRightbar: vi.fn() }
const layout = {
openRightbar: vi.fn(), closeRightbar: vi.fn(),
panelInfo: createSnapshotStore<{ activePanelId: string | null }>({ activePanelId: null }),
}
const current = createSnapshotStore<{ key: SessionId | undefined }>({ key: undefined })
const resources = { pin: vi.fn<(address: string, signal: AbortSignal) => void>() }
ctx.provide('slots', slots as never)
ctx.provide('locale', locale as never)
@@ -71,8 +75,8 @@ async function boot(shortcuts: Partial<Shortcuts> = {}) {
catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} }, ...shortcuts } as never)
ctx.provide('layout', layout as never)
ctx.provide('resources', resources as never)
ctx.provide('sessions', { retain: vi.fn() } as never)
ctx.provide('uiSession', { adapter: { current: createSnapshotStore({ key: undefined }) } } as never)
ctx.provide('sessions', { retain: vi.fn(() => ({ ready: Promise.resolve(), release: vi.fn() })) } as never)
ctx.provide('uiSession', { adapter: { current } } as never)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const seat = (name: string): Recorded => {
@@ -84,7 +88,7 @@ async function boot(shortcuts: Partial<Shortcuts> = {}) {
if (entry.inject === undefined) throw new Error(`expected ${entry.name} to inject`)
return entry.inject(SESSION)
}
return { ctx, registered, dictionaries, layout, resources, fiber, seat, injectedOf }
return { ctx, registered, dictionaries, layout, current, resources, fiber, seat, injectedOf }
}
describe('ui-sidebar-right apply', () => {
@@ -143,8 +147,21 @@ describe('ui-sidebar-right apply', () => {
expect(seat('conversation.session.header.corner').store).toBe(seat('rightbar.session').store)
})
it('hands the panel seat the frame report, the service binding, the opens, the observable registry, and the Tab domain', async () => {
const { ctx, layout, resources, seat, injectedOf } = await boot()
it('names the selected Session as on screen while the Conversation fills the main column', async () => {
const { ctx, layout, current } = await boot()
expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined()
current.set({ key: SESSION })
expect(ctx.sidebarRight.mounted.getSnapshot()).toBe(SESSION)
layout.panelInfo.set({ activePanelId: 'plugins' })
expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined()
layout.panelInfo.set({ activePanelId: null })
expect(ctx.sidebarRight.mounted.getSnapshot()).toBe(SESSION)
current.set({ key: undefined })
expect(ctx.sidebarRight.mounted.getSnapshot()).toBeUndefined()
})
it('hands the panel seat the frame report, the room rule, the opens, the observable registry, and the Tab domain', async () => {
const { ctx, layout, current, resources, seat, injectedOf } = await boot()
const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected
// The frame learns the composition of expanded and presentation, nothing else.
injected.syncPresentation({ shown: true, track: true, fullscreen: false })
@@ -162,31 +179,42 @@ describe('ui-sidebar-right apply', () => {
ctx.sidebarRightTabs.register({ id: 'spec/text', kind: 'text', patterns: ['dsh-resource://file/**'], title: () => 'text' })
expect(seen).toHaveBeenCalledOnce()
unsubscribe()
// The binding makes the service act on this seat's session; the seat's
// store instance is minted here from the handle the registration declared.
// The runtime mints each Session's store from the handle the registration
// declared; selecting the Session puts it on screen, and the service acts on
// its adopted store.
const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
const instance = handle.create()
instance.clearPersisted()
vi.stubGlobal('document', { activeElement: null })
handle.create().clearPersisted()
const instance = handle.create(SESSION)
vi.stubGlobal('document', { activeElement: null, querySelectorAll: () => [] })
onTestFinished(() => { vi.unstubAllGlobals() })
injected.toggleFullscreen()
expect(injectedOf(seat('conversation.session.header.corner'))).toHaveProperty('hooks.shortcuts')
const release = injected.bindService({
sessionId: SESSION, actions: instance.actions, surfaces: {},
closeWithFocus: (_paneId, close) => { close() },
openWithFocus: (open) => { open() },
canSplitPane: () => true,
})
current.set({ key: SESSION })
injected.openTab('guide', { revealIfOpened: false })
const surface = instance.getSnapshot().bySession[SESSION]
expect(surface?.layout.expanded).toBe(true)
expect(Object.values(surface?.layout.tabs ?? {}).map(tab => tab.kind)).toEqual(['guide'])
// Holding a record pins its address through the resource model.
if (surface === undefined) throw new Error('expected a surface')
ctx.sidebarRight.tabDomain.sync(SESSION, surface.layout)
expect(resources.pin).toHaveBeenCalledWith('sidebar://guide', expect.any(AbortSignal))
// The seat reports the room rule its kit measured; a narrow pane is not split.
const panes = () => Object.values(instance.getSnapshot().bySession[SESSION]?.layout.nodes ?? {}).filter(node => node.kind === 'pane')
injected.measureRoom(() => false)
injected.splitPane(surface.layout.activePaneId)
release()
expect(panes()).toHaveLength(1)
injected.measureRoom(() => true)
injected.splitPane(surface.layout.activePaneId)
expect(panes()).toHaveLength(2)
// The fullscreen command follows the automatic fullscreen rule the seats report.
const layoutOf = () => instance.getSnapshot().bySession[SESSION]?.layout
injected.toggleFullscreen()
expect(layoutOf()?.mode).toBe('fullscreen')
injected.reportAutoFullscreen(true)
injected.toggleFullscreen()
expect(layoutOf()?.expanded).toBe(false)
expect(layoutOf()?.mode).toBe('push')
// A global panel takes the Conversation's place: no Session is on screen.
layout.panelInfo.set({ activePanelId: 'plugins' })
expect(() => { ctx.sidebarRight.toggleExpanded() }).toThrow('no session surface is mounted')
})
@@ -274,17 +302,12 @@ describe('ui-sidebar-right apply', () => {
})
it('takes every registration and both faces back when disposed, aborting the open records, so a reload registers again', async () => {
const { ctx, registered, dictionaries, fiber, seat, injectedOf } = await boot()
const { ctx, registered, dictionaries, current, fiber, seat, injectedOf } = await boot()
const injected = injectedOf(seat('rightbar.session')) as SidebarRightInjected
const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
// Minted under the session key, so the instance is adopted and the teardown releases it.
const instance = handle.create(SESSION)
injected.bindService({
sessionId: SESSION, actions: instance.actions, surfaces: {},
closeWithFocus: (_paneId, close) => { close() },
openWithFocus: (open) => { open() },
canSplitPane: () => true,
})
current.set({ key: SESSION })
injected.openTab('guide')
const surface = instance.getSnapshot().bySession[SESSION]
const guide = Object.values(surface?.layout.tabs ?? {})[0]
@@ -26,17 +26,22 @@ function harness() {
const ctx = new Context()
const tabs = new SidebarRightTabRegistry(ctx)
for (const kind of ['guide', 'files', 'terminal']) tabs.register({ id: kind, kind, title: () => kind, multiple: kind === 'terminal' })
const { controller, adopt } = createSidebarRightController(tabs, vi.fn())
const room = { allowed: true, autoFullscreen: false }
const { controller, adopt, show: showSession, measure } = createSidebarRightController(tabs, vi.fn(), {
autoFullscreen: () => room.autoFullscreen,
openWithFocus: (_sessionId, open) => { open() },
closeWithFocus: (_sessionId, _paneId, close) => { close() },
})
const store = createSidebarRightStore(() => ({ kind: 'guide', title: 'guide' })).create()
releases.push(adopt(SESSION, store), () => { controller.tabDomain.dispose() })
store.actions.setExpanded(SESSION, true)
const room = { allowed: true, autoFullscreen: false }
const bind = (sessionId = SESSION) => controller.bind({ sessionId, actions: store.actions,
surfaces: store.getSnapshot().bySession,
closeWithFocus: (_paneId, close) => { close() },
openWithFocus: (open) => { open() },
canSplitPane: () => room.allowed, autoFullscreen: room.autoFullscreen })
releases.push(bind())
/** Put a Session on screen the way the plugin does, with the room rule its seat reports. */
const show = (sessionId = SESSION) => {
showSession(sessionId)
measure(sessionId, () => room.allowed)
return () => { if (controller.mounted.getSnapshot() === sessionId) showSession(undefined) }
}
releases.push(show())
const layout = () => store.getSnapshot().bySession[SESSION]!.layout
const owner = document.createElement('section')
owner.dataset.sidebarRightSession = SESSION
@@ -57,7 +62,7 @@ function harness() {
parent.append(element)
return element
}
return { controller, store, layout, paneElement, tabElement, room, bind }
return { controller, store, layout, paneElement, tabElement, room, show }
}
describe('sidebar focus targets', () => {
@@ -121,10 +126,10 @@ describe('sidebar focus targets', () => {
expect(h.controller.focusedTarget(chip)).toBeUndefined()
expect(h.controller.commandTarget(chip)).toBeUndefined()
h.store.actions.setExpanded(SESSION, true)
releases.push(h.bind('another-session' as SessionId))
releases.push(h.show('another-session' as SessionId))
expect(h.controller.isTargetCurrent(target)).toBe(false)
expect(h.controller.focusedTarget(chip)).toBeUndefined()
releases.push(h.bind())
releases.push(h.show())
h.controller.float(files)
expect(h.controller.isTargetCurrent(target)).toBe(false)
h.controller.dock(findTabPane(h.layout(), files).id)
@@ -190,7 +195,7 @@ describe('sidebar focus targets', () => {
h.controller.toggleFullscreen(floated)
expect(h.layout().mode).toBe('push')
h.room.autoFullscreen = true
releases.push(h.bind())
releases.push(h.show())
h.controller.toggleFullscreen(h.controller.commandTarget(null)!)
expect(h.layout().expanded).toBe(false)
})
@@ -233,7 +238,7 @@ describe('sidebar focus targets', () => {
expect(findTabPane(h.layout(), floatingFile).id).toBe(floating.paneId)
expect(h.layout().expanded).toBe(true)
expect(h.controller.splitBlock(floating)).toBe('float')
releases.push(h.bind('missing-session' as SessionId))
releases.push(h.show('missing-session' as SessionId))
expect(h.controller.commandTarget(null)).toBeUndefined()
expect(h.controller.splitBlock(floating)).toBe('stale')
})
@@ -462,7 +467,7 @@ describe('sidebar keyboard commands', () => {
const pendingSplit = split.resolve(context)
const pendingFullscreen = fullscreen.resolve(context)
const pendingToggle = toggle.resolve(context)
releases.push(h.bind('missing-session' as SessionId))
releases.push(h.show('missing-session' as SessionId))
for (const pending of [pendingSplit, pendingFullscreen, pendingToggle]) {
expect(pending.status).toBe('handled')
if (pending.status === 'handled') pending.run()
@@ -470,7 +475,7 @@ describe('sidebar keyboard commands', () => {
expect(h.layout().mode).toBe('push')
expect(h.layout().expanded).toBe(true)
expect(toggle.resolve(context).status).toBe('blocked')
releases.push(h.bind())
releases.push(h.show())
const splitNow = split.resolve(context)
if (splitNow.status === 'handled') splitNow.run()
expect(h.controller.splitBlock(h.controller.commandTarget(null)!)).toBe('budget')
@@ -63,7 +63,11 @@ it('pins restored records during adoption before the first render or store mutat
first.actions.openContent(sessionId, { kind: 'text', contentId: address, title: 'a' }, (id) => { file = id })
const ctx = new Context()
const pin = vi.fn()
const { controller, adopt } = createSidebarRightController(new SidebarRightTabRegistry(ctx), pin)
const { controller, adopt } = createSidebarRightController(new SidebarRightTabRegistry(ctx), pin, {
autoFullscreen: () => false,
openWithFocus: (_sessionId, open) => { open() },
closeWithFocus: (_sessionId, _paneId, close) => { close() },
})
expect(controller.tabsIn(sessionId)).toEqual([])
const restored = createSidebarRightStore(seed).create(sessionId)
const release = adopt(sessionId, restored)
@@ -3,7 +3,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent } from '@testing-library/react'
import { useEffect, useState, useSyncExternalStore } from 'react'
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { SlotTestRuntime, type SlotView } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ShortcutCatalogEntry, ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
@@ -71,7 +71,7 @@ function transition(property = 'transform') {
async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, opener = false, keepMounted = false) {
const runtime = await SlotTestRuntime.create()
runtimes.push(runtime)
const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn() }
const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: runtime.panelInfo }
const pin = vi.fn<(address: string, signal: AbortSignal) => void>()
runtime.ctx.provide('layout', frame as never)
runtime.ctx.provide('resources', { pin } as never)
@@ -110,10 +110,12 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, o
return armed && visible ? <OpenerMount /> : null
}
let arm = (_armed: boolean): void => { throw new Error('mountSeat(opener = true) renders the opener') }
let openerView: SlotView<'sidebar-right.test.opener'> | undefined
if (opener) {
await act(async () => { runtime.slots.register({ name: 'sidebar-right.test.opener' }, Opener) })
const openerView = runtime.renderSlot('sidebar-right.test.opener', { armed: false }, { session: reference })
arm = (armed) => { openerView.update({ armed }) }
const view = runtime.renderSlot('sidebar-right.test.opener', { armed: false }, { session: reference })
openerView = view
arm = (armed) => { view.update({ armed }) }
}
const bodies = new Map<string, SidebarRightTabInfo>()
const titles = new Map<string, SidebarRightTabInfo>()
@@ -162,6 +164,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, o
return {
runtime, feature, controller, instance, actions: instance.actions, layout,
open, selectSession, frame, pin, bodies, titles, hooks, view, arm, opened, registerPage, catalog,
openerView: () => openerView!,
}
}
@@ -321,19 +324,20 @@ describe('RightbarSeat presentation', () => {
expect(h.layout()).toBe(retained)
})
it('publishes the mounted seat so a Conversation opener acts in the commit that returns from a global panel', async () => {
it('names the on-screen Session before a Conversation opener acts in the commit that returns from a global panel', async () => {
const h = await mountSeat(1440, true, 0, true)
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(h.controller.mounted.getSnapshot()).toBe(SESSION)
// Armed while the seat is already bound: the ordinary case.
// Armed while the Session is already on screen: the ordinary case.
act(() => { h.arm(true) })
expect(h.opened).toHaveLength(1)
expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toEqual(h.opened)
// A global panel hides the Sidebar and releases its public navigation binding.
// A global panel takes the Conversation's place and hides the Sidebar.
act(() => { h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) })
expect(element(h.view.container, '[data-sidebar-right-session]').hidden).toBe(true)
expect(h.controller.mounted.getSnapshot()).toBeUndefined()
// The opener waits until the foreground seat republishes its navigation binding.
// Returning names the Session before the Conversation renders, so the
// opener's effect in that commit opens into the Session's store.
act(() => { h.runtime.panelInfo.set({ activePanelId: null }) })
expect(h.controller.mounted.getSnapshot()).toBe(SESSION)
expect(h.opened).toHaveLength(2)
@@ -343,6 +347,47 @@ describe('RightbarSeat presentation', () => {
expect(document.querySelector('[data-slot-error]')).toBeNull()
})
it('keeps the on-screen Session through its own store commits', async () => {
const h = await mountSeat(1440, true, 0, true)
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
const published: (SessionId | undefined)[] = []
const stop = h.controller.mounted.subscribe(() => { published.push(h.controller.mounted.getSnapshot()) })
try {
// The opener mounts in the commit that also carries a store commit of the
// seat, and its effect runs before the seat's own effects.
act(() => {
h.actions.toggleExpanded(SESSION)
h.arm(true)
})
expect(published).toEqual([])
expect(h.opened).toHaveLength(1)
expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toContain(h.opened[0])
expect(errors.mock.calls.map(call => String(call[0])).filter(text => text.includes('slot entry crashed'))).toEqual([])
} finally { stop() }
})
it('names the arriving Session before a Conversation opener acts in the commit that switches Sessions', async () => {
const h = await mountSeat(1440, true, 0, true)
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
// The opener holds its own reference, as the Conversation does for its Session.
const conversation = h.runtime.sessions.retainFor(h.runtime.ctx, SESSION)
act(() => { h.openerView().update({ armed: false }, { session: conversation }) })
await act(async () => { await h.runtime.sessions.add({ id: OTHER }) })
await act(async () => { h.selectSession(OTHER) })
expect(h.controller.mounted.getSnapshot()).toBe(OTHER)
// Switching back swaps the seats in the commit that mounts the opener: the
// departing seat leaves, and the arriving one renders after the opener.
await act(async () => {
h.selectSession(SESSION)
h.openerView().update({ armed: true }, { session: conversation })
})
expect(h.controller.mounted.getSnapshot()).toBe(SESSION)
expect(h.opened).toHaveLength(1)
expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toContain(h.opened[0])
expect(errors.mock.calls.map(call => String(call[0])).filter(text => text.includes('slot entry crashed'))).toEqual([])
conversation.release()
})
it.each([0, 1, 2])('selects the default from %i guide entries and protects only a sole guide', async (entryCount) => {
const h = await mountSeat(1440, true, entryCount)
act(() => { h.controller.toggleExpanded() })
@@ -465,6 +510,22 @@ describe('RightbarSeat presentation', () => {
expect(h.layout().expanded).toBe(false)
})
it('applies the automatic fullscreen rule of the width it renders at to the fullscreen command', async () => {
const h = await mountSeat()
h.open()
// Narrowed below the automatic fullscreen width, the command closes the panel.
h.view.update({ width: 420, viewportWidth: 700, canShow: false })
fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]'))
expect(h.layout().expanded).toBe(false)
expect(h.layout().mode).toBe('push')
// Widened again, the command switches the recorded mode.
h.view.update({ width: 420, viewportWidth: 1440, canShow: true })
act(() => { h.controller.toggleExpanded() })
fireEvent.click(element(h.view.container, '[data-sidebar-right-mode]'))
expect(h.layout().expanded).toBe(true)
expect(h.layout().mode).toBe('fullscreen')
})
it('preserves manual fullscreen through narrow and wide viewport changes', async () => {
const h = await mountSeat()
h.open()
@@ -2,8 +2,8 @@
* What `ctx.sidebarRight` promises other plugins.
*
* The service is root-scoped and the surface is per session, so every command
* depends on a binding the mounted seat publishes; the interesting cases are all
* about that seam. Opening is asserted against a real store instance, because
* acts on the Session the plugin names as on screen, through the store the
* runtime minted for it; the interesting cases are all about that seam. Opening is asserted against a real store instance, because
* "already open" and "in that tab's place" mean whatever the planner means by
* them and nothing else, and against the Tab domain, because an open is not
* complete until the tab knows how it was navigated to.
@@ -31,7 +31,7 @@ const SESSION = 's-test' as SessionId
/** Key-echoing translate: this file asserts behaviour, not copy. */
const t = ((key: string) => key) as Parameters<typeof guideDefinition>[0]
/** A controller over a real registry and a real store, bound as a mounted seat would be. */
/** A controller over a real registry and a real store, adopted as the runtime mints it. */
function harness() {
const ctx = new Context()
const tabs = new SidebarRightTabRegistry(ctx)
@@ -47,25 +47,26 @@ function harness() {
title: address => address.slice(address.lastIndexOf('/') + 1),
})
const pin = vi.fn<(address: string, signal: AbortSignal) => void>()
const { controller, adopt } = createSidebarRightController(tabs, pin)
const { controller, adopt, show, measure } = createSidebarRightController(tabs, pin, {
autoFullscreen: () => false,
openWithFocus: (_sessionId, open) => { open() },
closeWithFocus: (_sessionId, _paneId, close) => { close() },
})
const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create()
// The runtime mints the Session's store before any seat renders, and the plugin adopts it.
const unadopt = adopt(SESSION, instance)
const layout = (): LayoutState => {
const surface = instance.getSnapshot().bySession[SESSION]
if (surface === undefined) throw new Error('expected a surface')
return surface.layout
}
/** Republish the way the seat does after each commit, and sync the domain the way it does too. */
/** The room rule's verdict the seat would report; a spec flips it to model a narrow pane. */
const room = { allowed: true }
/** Put the Session on screen the way the plugin does from the selection, with the room rule its seat reports. */
const publish = (): (() => void) => {
const surface = instance.getSnapshot().bySession[SESSION]
if (surface !== undefined) controller.tabDomain.sync(SESSION, surface.layout)
return controller.bind({
sessionId: SESSION, actions: instance.actions, surfaces: instance.getSnapshot().bySession,
closeWithFocus: (_paneId, close) => { close() },
openWithFocus: (open) => { open() },
canSplitPane: () => room.allowed,
})
show(SESSION)
measure(SESSION, () => room.allowed)
return () => { show(undefined) }
}
const titles = (): string[] => {
const surface = instance.getSnapshot().bySession[SESSION]
@@ -79,7 +80,7 @@ function harness() {
const entries = (): number => instance.getSnapshot().bySession[SESSION]?.history.entries.length ?? 0
// A surface starts empty; expanding seeds the default guide ('seed').
const expand = (): void => { instance.actions.setExpanded(SESSION, true) }
return { controller, adopt, tabs, instance, pin, publish, titles, tabOf, layout, entries, room, expand }
return { controller, adopt, unadopt, show, measure, tabs, instance, pin, publish, titles, tabOf, layout, entries, room, expand }
}
describe('SidebarRightController — opening', () => {
@@ -102,7 +103,7 @@ describe('SidebarRightController — opening', () => {
} finally { release() }
})
it('refuses every write while no seat is mounted', () => {
it('refuses every write while no Session is on screen', () => {
const { controller } = harness()
expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted')
expect(() => { controller.openTab('guide') }).toThrow('no session surface is mounted')
@@ -114,21 +115,19 @@ describe('SidebarRightController — opening', () => {
expect(() => { controller.dock('pane1' as PaneId) }).toThrow('no session surface is mounted')
})
it('publishes the mounted session only on real transitions of the seat binding', () => {
it('publishes the on-screen Session only when it changes', () => {
const h = harness()
const seen: (string | undefined)[] = []
const unsubscribe = h.controller.mounted.subscribe(() => { seen.push(h.controller.mounted.getSnapshot()) })
try {
expect(h.controller.mounted.getSnapshot()).toBeUndefined()
const first = h.publish()
h.show(SESSION)
expect(h.controller.mounted.getSnapshot()).toBe(SESSION)
// The seat republishes on every store commit; the same session is silent.
const second = h.publish()
// Naming the same Session again, and the Session's own store commits, are silent.
h.show(SESSION)
h.expand()
expect(seen).toEqual([SESSION])
// A stale release — the first seat's, after a newer one took over — changes nothing.
first()
expect(h.controller.mounted.getSnapshot()).toBe(SESSION)
second()
h.show(undefined)
expect(h.controller.mounted.getSnapshot()).toBeUndefined()
expect(seen).toEqual([SESSION, undefined])
} finally { unsubscribe() }
@@ -519,25 +518,14 @@ describe('SidebarRightController — a tab\'s own actions', () => {
const B_TXT = 'dsh-resource://file/session/s-test/b.txt'
it('opens into an adopted store before that store has created its session surface', () => {
const { controller, adopt, instance, titles } = harness()
const releaseOwn = adopt(SESSION, instance)
const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER)
const releaseBinding = controller.bind({
sessionId: OTHER,
actions: other.actions,
surfaces: other.getSnapshot().bySession,
closeWithFocus: (_paneId, close) => { close() },
openWithFocus: (open) => { open() }, canSplitPane: () => true,
})
const { controller, show, titles } = harness()
show(OTHER)
controller.openResourceIn(SESSION, A_TXT)
expect(titles()).toContain('a.txt')
releaseBinding()
releaseOwn()
})
it('land in the session the tab is in through its own adopted store, after another session\'s seat took over', () => {
const { controller, adopt, instance, publish, layout, expand } = harness()
const releaseOwn = adopt(SESSION, instance)
it('land in the session the tab is in through its own adopted store, after another session came on screen', () => {
const { controller, adopt, show, publish, layout, expand } = harness()
expand()
publish()
controller.openResource(A_TXT)
@@ -546,7 +534,7 @@ describe('SidebarRightController — a tab\'s own actions', () => {
if (own === undefined || guide === undefined) throw new Error('expected the opened tab and the seeded guide')
const fromOwn = controller.tabDomain.occurrence(SESSION, own).tabActions
const guideOccurrence = controller.tabDomain.occurrence(SESSION, guide)
// The user switches sessions: the other seat binds with the other session's
// The user switches sessions: the other Session comes on screen with its
// own instance, whose store knows nothing of this session.
const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER)
const releaseOther = adopt(OTHER, other)
@@ -554,12 +542,7 @@ describe('SidebarRightController — a tab\'s own actions', () => {
other.actions.setExpanded(OTHER, true)
const otherSurface = other.getSnapshot().bySession[OTHER]
if (otherSurface === undefined) throw new Error('expected the other surface')
controller.bind({
sessionId: OTHER, actions: other.actions, surfaces: other.getSnapshot().bySession,
closeWithFocus: (_paneId, close) => { close() },
openWithFocus: (open) => { open() },
canSplitPane: () => true,
})
show(OTHER)
fromOwn.openResource(B_TXT)
fromOwn.openTab('guide', { revealIfOpened: false })
expect(Object.values(layout().tabs).map(tab => tab.title)).toContain('b.txt')
@@ -574,17 +557,17 @@ describe('SidebarRightController — a tab\'s own actions', () => {
expect(other.getSnapshot().bySession[OTHER]).toBe(otherSurface)
expect(controller.active()?.kind).toBe('guide')
releaseOther()
releaseOwn()
})
it('do nothing for a session whose store is not adopted, and again once its adoption is released', () => {
const { controller, adopt, instance, publish, layout, titles } = harness()
const { controller, adopt, unadopt, instance, publish, layout, titles } = harness()
publish()
controller.openResource(A_TXT)
publish()
const own = Object.values(layout().tabs).find(tab => tab.title === 'a.txt')
if (own === undefined) throw new Error('expected the opened tab')
const { tabActions } = controller.tabDomain.occurrence(SESSION, own)
// The runtime disposed the Session's store generation.
unadopt()
const before = instance.getSnapshot().bySession
tabActions.openResource(B_TXT)
tabActions.openTab('guide')
@@ -661,17 +644,16 @@ describe('SidebarRightController — the readable slice', () => {
})
})
describe('SidebarRightController — binding lifetime', () => {
it('acts on the newest binding when a seat republishes', () => {
describe('SidebarRightController — the on-screen Session', () => {
it('acts on the on-screen Session through its adopted store', () => {
const { controller, publish, layout } = harness()
publish()
controller.toggleExpanded()
publish()
expect(controller.isExpanded()).toBe(true)
expect(layout().expanded).toBe(true)
})
it('goes back to refusing writes once the seat releases', () => {
it('goes back to refusing writes once no Session is on screen', () => {
const { controller, publish } = harness()
const release = publish()
release()
@@ -679,12 +661,20 @@ describe('SidebarRightController — binding lifetime', () => {
expect(controller.isExpanded()).toBe(false)
})
it('a stale release does not clear a newer binding', () => {
const { controller, publish } = harness()
const stale = publish()
publish()
stale()
expect(() => { controller.toggleExpanded() }).not.toThrow()
it('refuses writes for an on-screen Session whose store the runtime has not minted', () => {
const { controller, show } = harness()
show('s-unminted' as SessionId)
expect(() => { controller.openResource('dsh-resource://file/session/s-test/a.txt') }).toThrow('no session surface is mounted')
expect(controller.active()).toBeUndefined()
})
it('treats panes its seat has not measured as having room', () => {
const { controller, show, instance, layout, expand } = harness()
instance.actions.open(SESSION)
expand()
show(SESSION)
expect(controller.split()).toBeDefined()
expect(dockPaneIds(layout())).toHaveLength(2)
})
})