feat: sidebar_open tool gates model-driven sidebar opens behind a global setting
New global Side card setting `agentOpenTools` (default off, 常规 group): when enabled, one `sidebar_open` tool is injected into the model so it can actively open a local file, a local folder (tree rooted there), or an HTTP(S) page in the calling session's sidebar. - host: AgentOpenRegistry + registerOpenTool (src/agent-opens.ts); the tool classifies http(s) URLs / stat-driven file/folder paths, refuses other schemes, resolves relative paths against the session cwd, and reports target-tab-disabled instead of silently no-oping. Gated like agentTerminalTools (register/drain on settings watch). - delivery: /sidebar/ws/agent-opens push (consume-on-send queue, replay on attach); the tool result reports `delivered` so queued opens for non-active sessions are honest. - client: Sidebar subscribes and routes pushes to editor (file), folder window (meta.dir, tree rooted at the folder — EditorHost), or browser (URL) tabs; settings row in 常规 + 20 locale dictionaries. - tests: agent-opens registry/tool spec, smoke gating + WS route list, prefs/plugin-shape defaults, editor-host folder window, side-card rows. - docs: design doc, AGENTS.md key lists + reference, README feature bullets.
This commit is contained in:
@@ -181,7 +181,7 @@ interface TabDescriptor {
|
|||||||
*/
|
*/
|
||||||
settings?: {
|
settings?: {
|
||||||
toggles?: readonly {
|
toggles?: readonly {
|
||||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'editorExplorer' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */
|
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'agentOpenTools' / 'terminalFontFamily' / 'editorExplorer' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks' / 'browserInterceptHttp' / 'browserInterceptHttps') */
|
||||||
key: string
|
key: string
|
||||||
title: string | (() => string)
|
title: string | (() => string)
|
||||||
desc?: string | (() => string)
|
desc?: string | (() => string)
|
||||||
@@ -571,7 +571,7 @@ interface OpenTabSeed {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,启用卡片最右端渲染紧凑品牌开关滑块,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片底部的「功能设置」设置条(齿轮图标 + 文字标签,hairline 分隔)在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时设置条隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——设置弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有设置条)。
|
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,启用卡片最右端渲染紧凑品牌开关滑块,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片底部的「功能设置」设置条(齿轮图标 + 文字标签,hairline 分隔)在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时设置条隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `agentOpenTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——设置弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有设置条)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -729,6 +729,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"
|
|||||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
|
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
|
||||||
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
|
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
|
||||||
- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git` / `node_modules` / 构建缓存 / symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
|
- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git` / `node_modules` / 构建缓存 / symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
|
||||||
|
- **`src/agent-opens.ts`** / **`/sidebar/ws/agent-opens`**:模型主动打开(`sidebar_open` 工具 + `agentOpenTools` 设置,默认关闭)——会话级队列推送注册表 + 工具注册;客户端在 Sidebar.tsx 订阅推送并路由到 editor(文件/文件夹窗口)/browser(HTTP(S) 网页)tab,文件夹窗口是 `meta.dir: true` 的 editor tab(全窗树以目录为根);设计文档 `docs/plans/2026-08-23-agent-open-tools-design.md`
|
||||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
|
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
|
||||||
|
|
||||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF
|
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF
|
||||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||||
|
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
|
||||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
|
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
|
||||||
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
|
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
|
||||||
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
|
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown (incl. Mermaid diagrams, strict-mode safe rendering + click-to-zoom) / HTML / PDF
|
- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown (incl. Mermaid diagrams, strict-mode safe rendering + click-to-zoom) / HTML / PDF
|
||||||
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
|
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
|
||||||
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
|
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
|
||||||
|
- **📂 Model-driven sidebar opens (opt-in)**: with the global setting on, the `sidebar_open` tool lets the model actively open files / folders (tree rooted there) / HTTP(S) pages in the sidebar
|
||||||
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
|
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
|
||||||
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
|
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
|
||||||
- **💬 Side Chat (beta)**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session
|
- **💬 Side Chat (beta)**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# 设计:模型主动在侧边栏打开(`sidebar_open` 工具 + `agentOpenTools` 设置)
|
||||||
|
|
||||||
|
> 日期:2026-08-23 · 状态:已实现 · 仓库:omdsh-dev/DSH-better-sidebar
|
||||||
|
|
||||||
|
## 需求
|
||||||
|
|
||||||
|
新增一个侧边栏**全局设置**(Side card 设置页「常规」分组):`agentOpenTools`,**默认关闭**。
|
||||||
|
开启后向模型注入**一个**工具 `sidebar_open`,允许模型主动在**调用方会话**的侧边栏中打开:
|
||||||
|
|
||||||
|
- 本地**文件** → 内置 editor tab(按 path 去重,聚焦既有 tab)
|
||||||
|
- 本地**文件夹** → 内置 editor tab 的**文件夹窗口**(文件树以该目录为根)
|
||||||
|
- **HTTP(S) 网页** → 内置 browser tab(URL 预填)
|
||||||
|
|
||||||
|
## 关键决策
|
||||||
|
|
||||||
|
### 1. 作用域 = 调用方 agent 会话
|
||||||
|
|
||||||
|
与 `terminal_*` 工具一致:工具只绑定 `exec.agent.session.id`,模型不传 sessionId。
|
||||||
|
子代理(或未激活会话)调用时若无客户端连接,打开请求排队,该会话侧边栏下次可见时重放。
|
||||||
|
工具结果通过 `delivered` 如实报告「当前是否已推送到已连接的侧边栏视图」。
|
||||||
|
|
||||||
|
### 2. 投递通道:host→client WS 推送(无 ack 协议)
|
||||||
|
|
||||||
|
- 新增 WS 升级路由 `/sidebar/ws/agent-opens?sessionId=...`(与 `/sidebar/ws/agent-terminals` 同模式,过同一 trust fence)。
|
||||||
|
- host 侧 `AgentOpenRegistry` 维护会话级队列 + 已连接视图集合:
|
||||||
|
- `enqueue`:有订阅者 → 即时推送并**消费**(`delivered: true`);无订阅者 → 保留队列(`delivered: false`)。
|
||||||
|
- `attach`:注册订阅者并**重放**队列(消费即出队),返回 disposer(close/error 时退订)。
|
||||||
|
- `drainAll`:特性关闭时丢弃未投递队列。
|
||||||
|
- **消费即出队**是关键:browser tab 无 per-URL 去重,重连重放会铸造重复 tab;消费语义保证每个请求恰好推送一次。
|
||||||
|
- 不加 ack 回执、不加 `/sidebar/api` 路由——结果的最佳努力语义(`delivered`)已足够,符合 KISS 与 terminal 先例。
|
||||||
|
|
||||||
|
### 3. `sidebar_open` 工具(唯一新增工具)
|
||||||
|
|
||||||
|
- 参数:`target`(必填:绝对/相对路径,或 http(s) URL)、`title`(可选:tab 标题)。
|
||||||
|
- 执行流程:`throwIfAborted` → 分类 → 目标 tab 启用检查 → 入队 → 返回规范结果。
|
||||||
|
- 分类规则:
|
||||||
|
- `http://` / `https://`(大小写不敏感)→ `url`,默认标题 = hostname;
|
||||||
|
- 其他 scheme(`file:` / `javascript:` / `vscode:` 等)→ 报错(Windows 盘符前缀 `C:\`/`C:/` 除外,它是路径不是 scheme);
|
||||||
|
- 本地路径 → 相对路径以会话 cwd(`sessionCwdOf`)解析为绝对路径,`stat` 定 `file`/`folder`;不存在/不可读 → 报错;
|
||||||
|
- 目标 tab 类型被用户禁用(`tabsEnabled['editor'|'browser'] === false`)→ 报错并提示启用(避免客户端静默 no-op)。
|
||||||
|
- 输出(canonical JSON,`render` 纯文本投影):`{ kind, target, title, delivered }`。
|
||||||
|
- 工具描述/错误消息为英文(与 8 个 `terminal_*` 一致)。
|
||||||
|
|
||||||
|
### 4. 文件夹窗口:editor tab 的 `meta.dir`
|
||||||
|
|
||||||
|
- 打开文件夹 = `openTab({ type: 'editor', path: <dir>, id: 'editor:'+<dir>, title, meta: { dir: true } })`。
|
||||||
|
- EditorHost 识别 `meta.dir === true`:不进入 viewer 加载流程,渲染**全窗 TreePanel**(`cwd = tab.path`);
|
||||||
|
FileTree 根部即 `cwd`(`fs.tree` 接受绝对路径),搜索结果/上传仍受会话工作区约束(已知限制,见下)。
|
||||||
|
- `meta` 为可持久化的 tab 状态(v0.12.0+),`sanitizeState` 原样保留——刷新后文件夹窗口恢复。
|
||||||
|
- 折叠/展开、右键打开、拖拽上传等全部复用 TreePanel 既有能力。
|
||||||
|
|
||||||
|
### 5. 设置与门控
|
||||||
|
|
||||||
|
- 新 prefs 字段 `agentOpenTools: boolean`(`SidebarPrefs` + `PrefsSchema` + 客户端 `parsePrefs`),默认 `false`。
|
||||||
|
- SideCardSection「常规」分组新增一行 Switch(标题/描述走新 locale key)。
|
||||||
|
- host 门控完全拷贝 `agentTerminalTools` 的 `syncToolsGate` 模式:
|
||||||
|
- 开启 → `registerOpenTool(...)`;关闭 → 注销工具 + `agentOpenRegistry.drainAll()`;
|
||||||
|
- 一次 `scope.watch` 同时驱动两个门控(各持有自己的 disposer,互不干扰);
|
||||||
|
- 工具不依赖 node-pty,降级模式下也可用。
|
||||||
|
- 客户端防御:Sidebar 的 agent-opens 推送订阅在 `prefs.agentOpenTools !== true` 时忽略消息(host 已门控,此为双保险)。
|
||||||
|
|
||||||
|
### 6. 与既有拦截开关的关系
|
||||||
|
|
||||||
|
- URL 打开**不受** `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps` 约束——那是用户点击外链的接管开关;
|
||||||
|
模型显式调用 `sidebar_open` 只受 browser tab 自身启用开关约束(openTab 既有 gating)。
|
||||||
|
- 文件打开**不受** `interceptOpenPath` 影响(那是聊天点击路径的接管开关)。
|
||||||
|
|
||||||
|
## 既有模式复用(零新公共 API)
|
||||||
|
|
||||||
|
| 新事物 | 复用/镜像的既有模式 |
|
||||||
|
|---|---|
|
||||||
|
| `agentOpenTools` 门控 | `agentTerminalTools` 门控(settings seam `watch` → 注册/注销) |
|
||||||
|
| `/sidebar/ws/agent-opens` | `/sidebar/ws/agent-terminals` 推送(升级注册、fence、每会话 attach) |
|
||||||
|
| 客户端推送订阅 | Sidebar 的 agent-terminals effect(重连上限 `FAILURE_LIMIT`、防抖/清理) |
|
||||||
|
| 文件/URL 打开 | `service.openFile` / `openTab({ type:'browser', url })`(link-intercept 同款) |
|
||||||
|
| 文件夹窗口 | path-less explorer 的全窗 TreePanel 分支(仅根目录改为 `tab.path`) |
|
||||||
|
|
||||||
|
不新增 `BetterSidebarService` 方法;不改变任何现有路由协议。
|
||||||
|
|
||||||
|
## 已知限制
|
||||||
|
|
||||||
|
- **多浏览器窗口**:推送按「有订阅者即发送并消费」执行,多窗口同时连接时后连接的窗口不会收到已消费的请求
|
||||||
|
(其本体状态由 localStorage 恢复——与 agent-terminals 的全量列表 fan-out 不同,属尽力而为语义)。
|
||||||
|
- **文件夹窗口的全局搜索/上传**:TreePanel 的搜索盒仍以**会话 cwd** 为根(`fs.search` 是有意的工作区限定),
|
||||||
|
目录内上传经 host 会话工作区校验(目录在 cwd 内可上传,cwd 外拒绝)。
|
||||||
|
- **无 ack**:`delivered: true` 只表示「推送已发出」,客户端处理失败(罕见)不重试。
|
||||||
|
- **关闭特性不清已打开 tab**:与 tab 启用开关语义一致,只注销工具并清空未投递队列。
|
||||||
|
|
||||||
|
## 测试覆盖
|
||||||
|
|
||||||
|
- `tests/agent-opens.spec.ts`(新):注册表语义(排队/重放/消费/作用域隔离/drain/dispose)+ 工具行为
|
||||||
|
(分类、相对路径、缺路径、非 http(s) scheme、tab 禁用、schema 校验、无 agent 错误)。
|
||||||
|
- `tests/smoke.spec.ts`:WS 升级路由清单 + `settings.get` 默认值 + 独立 gating 测试(默认 off → 0 工具;on → 1;off → 注销)。
|
||||||
|
- `tests/plugin-shape.spec.ts` / `tests/prefs.spec.ts`:schema/parse 默认值 + 全量 prefs 字面量。
|
||||||
|
- `tests/editor-host.spec.tsx`:`meta.dir` 文件夹窗口(树以目录为根、无编辑器 chrome、不触发 viewer 加载)。
|
||||||
|
- `tests/locales.spec.ts`:zh/en/ja 词典 key 集相等(自动覆盖新键)。
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
/**
|
||||||
|
* The model-facing `sidebar_open` tool and its delivery registry.
|
||||||
|
*
|
||||||
|
* One tool lets the model actively open a local file, a local folder (as a
|
||||||
|
* tree rooted there), or an HTTP(S) page in the CALLING session's sidebar.
|
||||||
|
* Mirroring the agent-terminal tools, the tool binds to the calling agent's
|
||||||
|
* session through `exec.agent.session.id` — the model never passes a
|
||||||
|
* sessionId, and opens for non-active sessions are queued until that
|
||||||
|
* session's sidebar view is next connected.
|
||||||
|
*
|
||||||
|
* Delivery is a host→browser push over the dedicated `/sidebar/ws/agent-opens`
|
||||||
|
* endpoint (the same pattern as `/sidebar/ws/agent-terminals`): the registry
|
||||||
|
* keeps a per-session queue; a push is consumed on send (`delivered: true`
|
||||||
|
* means a sidebar view was attached at call time), otherwise the request
|
||||||
|
* stays queued and is replayed when a view for that session attaches.
|
||||||
|
*
|
||||||
|
* Conventions (per plugin-development-guide.md §3):
|
||||||
|
* C1 — parameters schema-validated before `execute` runs.
|
||||||
|
* C4 — `execute` returns one canonical JSON value; `render` is a separate
|
||||||
|
* pure text projection.
|
||||||
|
* C6 — `exec.signal.throwIfAborted()` before any fs work.
|
||||||
|
* C10 — no UI/transport vocabulary in the canonical value.
|
||||||
|
*/
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import { stat } from 'node:fs/promises'
|
||||||
|
import { isAbsolute, join, resolve } from 'node:path'
|
||||||
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||||
|
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||||
|
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||||
|
import type { Context } from './context-types.ts'
|
||||||
|
import type { SidebarPrefs } from './prefs-shared.ts'
|
||||||
|
|
||||||
|
/** What the model asked to open. */
|
||||||
|
export type AgentOpenKind = 'file' | 'folder' | 'url'
|
||||||
|
|
||||||
|
/** One pending/broadcast open request (the wire face over the push socket). */
|
||||||
|
export interface AgentOpenRequest {
|
||||||
|
/** Opaque id (host-generated; the client ignores it beyond dedupe/debug). */
|
||||||
|
id: string
|
||||||
|
/** The session whose sidebar the open is targeted at. */
|
||||||
|
sessionId: string
|
||||||
|
kind: AgentOpenKind
|
||||||
|
/** Absolute local path (file/folder) or http(s) URL. */
|
||||||
|
target: string
|
||||||
|
/** Tab title the client should use (basename / hostname / caller-supplied). */
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One subscribed sidebar view's sender. */
|
||||||
|
type Sender = (request: AgentOpenRequest) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-session queue of open requests plus the connected sidebar views.
|
||||||
|
*
|
||||||
|
* Lifecycle: `enqueue` adds a request and — when at least one view for the
|
||||||
|
* session is attached — pushes it immediately and removes it from the queue
|
||||||
|
* (consume-on-send: a reconnect must never replay an open the client already
|
||||||
|
* applied, and the browser tab type has no per-URL dedupe, so replaying
|
||||||
|
* would mint duplicate tabs). With no attached view the request stays queued
|
||||||
|
* and `attach` replays it on connect. `drainAll` drops every queued request
|
||||||
|
* (the feature was turned off); `dispose` also drops every subscriber.
|
||||||
|
*/
|
||||||
|
export class AgentOpenRegistry {
|
||||||
|
private pending = new Map<string, AgentOpenRequest[]>()
|
||||||
|
private subscribers = new Map<string, Set<Sender>>()
|
||||||
|
|
||||||
|
/** Queue one open and deliver it immediately when a view is attached.
|
||||||
|
* @returns the request id and whether a connected view received it now. */
|
||||||
|
enqueue(sessionId: string, kind: AgentOpenKind, target: string, title: string): { id: string; delivered: boolean } {
|
||||||
|
const request: AgentOpenRequest = { id: randomUUID(), sessionId, kind, target, title }
|
||||||
|
const list = this.pending.get(sessionId) ?? []
|
||||||
|
list.push(request)
|
||||||
|
this.pending.set(sessionId, list)
|
||||||
|
const views = this.subscribers.get(sessionId)
|
||||||
|
if (views !== undefined && views.size > 0) {
|
||||||
|
for (const send of views) send(request)
|
||||||
|
this.pending.delete(sessionId)
|
||||||
|
return { id: request.id, delivered: true }
|
||||||
|
}
|
||||||
|
return { id: request.id, delivered: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attach one sidebar view (replays queued requests; consume-on-send).
|
||||||
|
* @returns the disposer detaching the view. */
|
||||||
|
attach(sessionId: string, send: Sender): () => void {
|
||||||
|
let views = this.subscribers.get(sessionId)
|
||||||
|
if (views === undefined) {
|
||||||
|
views = new Set()
|
||||||
|
this.subscribers.set(sessionId, views)
|
||||||
|
}
|
||||||
|
views.add(send)
|
||||||
|
const queued = this.pending.get(sessionId) ?? []
|
||||||
|
if (queued.length > 0) {
|
||||||
|
for (const request of queued) send(request)
|
||||||
|
this.pending.delete(sessionId)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
const current = this.subscribers.get(sessionId)
|
||||||
|
current?.delete(send)
|
||||||
|
if (current !== undefined && current.size === 0) this.subscribers.delete(sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop every queued request (the feature was turned off mid-session). */
|
||||||
|
drainAll(): void {
|
||||||
|
this.pending.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the queue and every subscriber (plugin teardown). */
|
||||||
|
dispose(): void {
|
||||||
|
this.pending.clear()
|
||||||
|
this.subscribers.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract the calling agent or throw the canonical "no agent" error. */
|
||||||
|
function requireAgent(agent: ToolRunContext['agent']): NonNullable<ToolRunContext['agent']> {
|
||||||
|
if (agent === undefined) {
|
||||||
|
throw new Error('sidebar_open requires an initiating agent')
|
||||||
|
}
|
||||||
|
return agent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve the calling agent's session id (the queue scope + ownership key). */
|
||||||
|
function sessionIdOf(exec: ToolRunContext): string {
|
||||||
|
return requireAgent(exec.agent).session.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure text projection helper (the canonical value is already structured). */
|
||||||
|
function textRender<T>(fn: (value: T) => string): (_args: unknown, value: unknown) => ContentBlock[] {
|
||||||
|
return (_args, value) => [{ type: 'text', text: fn(value as T) }]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classify a raw target: http(s) URL or a local path (stat-driven). */
|
||||||
|
async function classifyTarget(raw: string, cwd: string): Promise<{ kind: AgentOpenKind; target: string; title: string }> {
|
||||||
|
if (/^https?:\/\//i.test(raw)) {
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw)
|
||||||
|
} catch {
|
||||||
|
throw new Error(`"${raw}" is not a valid URL`)
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
throw new Error('sidebar_open only accepts http:// and https:// URLs')
|
||||||
|
}
|
||||||
|
const title = parsed.hostname !== '' ? parsed.hostname : raw
|
||||||
|
return { kind: 'url', target: raw, title }
|
||||||
|
}
|
||||||
|
// Any other scheme (`file:`, `javascript:`, `vscode:`, ...) is refused —
|
||||||
|
// the model must use the plain URL form or a local path. A Windows drive
|
||||||
|
// prefix (`C:\` / `C:/`) is a PATH, not a scheme, and must not be caught.
|
||||||
|
if (!isWindowsDrivePrefix(raw) && /^[a-z][a-z0-9+.-]*:/i.test(raw)) {
|
||||||
|
throw new Error('sidebar_open only accepts http:// and https:// URLs; use a local path for files')
|
||||||
|
}
|
||||||
|
const target = resolve(isAbsolute(raw) ? raw : join(cwd, raw))
|
||||||
|
let info
|
||||||
|
try {
|
||||||
|
info = await stat(target)
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code
|
||||||
|
if (code === 'ENOENT') throw new Error(`"${raw}" does not exist (resolved to "${target}")`)
|
||||||
|
if (code === 'EACCES' || code === 'EPERM') throw new Error(`"${target}" is not readable`)
|
||||||
|
throw new Error(`cannot open "${target}": ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
}
|
||||||
|
const title = basenameOf(target)
|
||||||
|
return { kind: info.isDirectory() ? 'folder' : 'file', target, title: title === '' ? raw : title }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last path segment (mirror of the client's FileTree baseName). */
|
||||||
|
function basenameOf(path: string): string {
|
||||||
|
const trimmed = path.replace(/[\\/]+$/, '')
|
||||||
|
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
|
||||||
|
return at === -1 ? trimmed : trimmed.slice(at + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a raw target starts with a Windows drive prefix (`C:\` / `C:/`). */
|
||||||
|
function isWindowsDrivePrefix(raw: string): boolean {
|
||||||
|
return /^[a-zA-Z]:[\\/]/.test(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the `sidebar_open` tool against the host tool registry. The tool
|
||||||
|
* is gated by the side-card setting `agentOpenTools` (the caller registers
|
||||||
|
* and unregisters it); `readPrefs` supplies the live prefs so a disabled
|
||||||
|
* target tab type (editor/browser) is reported to the model instead of
|
||||||
|
* silently no-oping on the client. `resolveCwd` threads the calling
|
||||||
|
* session's live cwd so relative paths resolve the same way the sidebar's
|
||||||
|
* own routes do.
|
||||||
|
* @param ctx - host plugin context (carries the tools service).
|
||||||
|
* @param registry - the open-request registry (per-session queue + views).
|
||||||
|
* @param resolveCwd - live cwd resolver for one session id.
|
||||||
|
* @param readPrefs - live resolved side card prefs (for tab enable gates).
|
||||||
|
* @returns a disposer that unregisters the tool.
|
||||||
|
*/
|
||||||
|
export function registerOpenTool(
|
||||||
|
ctx: Context,
|
||||||
|
registry: AgentOpenRegistry,
|
||||||
|
resolveCwd: (sessionId: string) => string,
|
||||||
|
readPrefs: () => SidebarPrefs,
|
||||||
|
): () => void {
|
||||||
|
return ctx.tools.register(defineTool({
|
||||||
|
name: 'sidebar_open',
|
||||||
|
description:
|
||||||
|
'Open a local file, a local folder, or an HTTP(S) page in the sidebar of the calling conversation. '
|
||||||
|
+ 'A file opens in the sidebar editor (per-path dedupe: an already-open file is focused); a folder opens a file window whose tree is rooted at that folder; '
|
||||||
|
+ 'a URL opens in the sidebar browser (sandboxed iframe). '
|
||||||
|
+ 'The panel auto-expands for content opens and the tab title defaults to the file/folder name or the URL hostname. '
|
||||||
|
+ 'The path may be absolute or relative to the session working directory. '
|
||||||
|
+ 'The open lands in the CALLING session\'s sidebar: while that session\'s sidebar view is not connected '
|
||||||
|
+ '(e.g. the session is not the active one), the open is queued and delivered when the session sidebar is next shown — '
|
||||||
|
+ 'the result reports `delivered` so you know whether it is visible right now. '
|
||||||
|
+ 'The side card setting "model opens files/folders/pages in the sidebar" must be on, and the target tab type must be enabled in that session\'s settings.',
|
||||||
|
parameters: {
|
||||||
|
target: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
description: 'Absolute or session-cwd-relative local path, or an http:// / https:// URL.',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Optional tab title (defaults to the file/folder name or the URL hostname).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
kind: { type: 'string', required: true, description: 'What was opened: file | folder | url.' },
|
||||||
|
target: { type: 'string', required: true, description: 'The absolute path or URL the open was requested for.' },
|
||||||
|
title: { type: 'string', required: true, description: 'The tab title used (provided title, basename, or hostname).' },
|
||||||
|
delivered: {
|
||||||
|
type: 'boolean',
|
||||||
|
required: true,
|
||||||
|
description: 'Whether the open was pushed to a connected sidebar at call time (false = queued until the session sidebar is next shown).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: textRender((v: { kind: AgentOpenKind; target: string; title: string; delivered: boolean }) =>
|
||||||
|
v.delivered
|
||||||
|
? `Opened ${v.kind} "${v.title}" (${v.target}) in the sidebar.`
|
||||||
|
: `Requested opening ${v.kind} "${v.title}" (${v.target}) in the sidebar — the session sidebar is not connected yet, so the open is queued and will appear when it is next shown.`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
execute: async (args: { target: string; title?: string }, exec: ToolRunContext) => {
|
||||||
|
exec.signal.throwIfAborted()
|
||||||
|
const sessionId = sessionIdOf(exec)
|
||||||
|
const cwd = resolveCwd(sessionId)
|
||||||
|
const { kind, target, title: defaultTitle } = await classifyTarget(args.target, cwd)
|
||||||
|
// A disabled target tab type would make the client no-op the open:
|
||||||
|
// report the real cause to the model instead of a silent success.
|
||||||
|
const prefs = readPrefs()
|
||||||
|
const tab = kind === 'url' ? 'browser' : 'editor'
|
||||||
|
if (prefs.tabsEnabled[tab] === false) {
|
||||||
|
throw new Error(`the built-in ${tab} tab is disabled in the side card settings; ask the user to enable it (or disable this tool)`)
|
||||||
|
}
|
||||||
|
const title = args.title !== undefined && args.title.trim() !== '' ? args.title : defaultTitle
|
||||||
|
const { delivered } = registry.enqueue(sessionId, kind, target, title)
|
||||||
|
return { kind, target, title, delivered }
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -103,6 +103,11 @@ export function EditorHost(props: {
|
|||||||
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
|
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
|
||||||
const path = tab.path ?? ''
|
const path = tab.path ?? ''
|
||||||
const title = tab.title
|
const title = tab.title
|
||||||
|
// A folder window: the model's `sidebar_open` (or any caller) opens a
|
||||||
|
// directory as an editor tab carrying `meta.dir: true` with the directory
|
||||||
|
// as its path. It renders the file tree rooted at that folder instead of
|
||||||
|
// the viewer loading flow (a directory is not a file).
|
||||||
|
const isDir = metaOf(tab).dir === true
|
||||||
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
||||||
|
|
||||||
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
|
// Reactive prefs read: flipping editorExplorer re-renders this tab with no
|
||||||
@@ -123,9 +128,12 @@ export function EditorHost(props: {
|
|||||||
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
|
const openWithConfig = useMemo(() => parseOpenWithConfig(editorBlob.openWith), [editorBlob])
|
||||||
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
|
const openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
|
||||||
// A path-less tab shows the empty-state hint in merged mode — and in split
|
// A path-less tab shows the empty-state hint in merged mode — and in split
|
||||||
// mode it is the standalone explorer (tree-only, see the render below).
|
// mode it is the standalone explorer (tree-only, see the render below). A
|
||||||
|
// folder tab is a folder window in BOTH modes: the tree rooted at the
|
||||||
|
// folder, no editor chrome.
|
||||||
const showEmpty = path === ''
|
const showEmpty = path === ''
|
||||||
const treeOnly = showEmpty && !inPlace
|
const treeOnly = showEmpty && !inPlace
|
||||||
|
const folderRoot = isDir ? path : undefined
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open a file from THIS window (tree click / search row / path input):
|
* Open a file from THIS window (tree click / search row / path input):
|
||||||
@@ -258,8 +266,9 @@ export function EditorHost(props: {
|
|||||||
// fresh viewer re-registers its own.
|
// fresh viewer re-registers its own.
|
||||||
setToolbar(null)
|
setToolbar(null)
|
||||||
// The seeded home tab (no path) never loads a viewer — the empty-state
|
// The seeded home tab (no path) never loads a viewer — the empty-state
|
||||||
// hint renders until the user picks a file.
|
// hint renders until the user picks a file. A folder tab never loads a
|
||||||
if (showEmpty) return
|
// viewer either — its tree is rooted at the folder.
|
||||||
|
if (showEmpty || isDir) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
// Aborts the matched viewer's `load` when the editor tears down (tab
|
// Aborts the matched viewer's `load` when the editor tears down (tab
|
||||||
// closed, path changed, session switched) or re-matches the viewer.
|
// closed, path changed, session switched) or re-matches the viewer.
|
||||||
@@ -311,7 +320,7 @@ export function EditorHost(props: {
|
|||||||
}
|
}
|
||||||
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
|
||||||
return () => { cancelled = true; controller.abort() }
|
return () => { cancelled = true; controller.abort() }
|
||||||
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty])
|
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty, isDir])
|
||||||
|
|
||||||
const treeOpen = treeOpenOf(tab)
|
const treeOpen = treeOpenOf(tab)
|
||||||
/** Persist the panel flag on the tab (survives reloads with the layout). */
|
/** Persist the panel flag on the tab (survives reloads with the layout). */
|
||||||
@@ -324,13 +333,15 @@ export function EditorHost(props: {
|
|||||||
// Split mode: the path-less window IS the standalone explorer — the tree
|
// Split mode: the path-less window IS the standalone explorer — the tree
|
||||||
// panel fills the whole tab (search + FileTree, full form), no editor
|
// panel fills the whole tab (search + FileTree, full form), no editor
|
||||||
// chrome. File opens land in new per-path tabs through openFile above.
|
// chrome. File opens land in new per-path tabs through openFile above.
|
||||||
if (treeOnly) {
|
// A folder window (meta.dir, any mode) renders the SAME surface rooted
|
||||||
|
// at the folder instead of the session cwd.
|
||||||
|
if (treeOnly || folderRoot !== undefined) {
|
||||||
return (
|
return (
|
||||||
<div className={css.editor}>
|
<div className={css.editor}>
|
||||||
<TreePanel
|
<TreePanel
|
||||||
full
|
full
|
||||||
sessionId={scope.sessionId}
|
sessionId={scope.sessionId}
|
||||||
cwd={scope.cwd}
|
cwd={folderRoot ?? scope.cwd}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onToggle={onToggleDir}
|
onToggle={onToggleDir}
|
||||||
onOpenFile={openFile}
|
onOpenFile={openFile}
|
||||||
|
|||||||
@@ -913,6 +913,17 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
|||||||
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
|
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={css.row}>
|
||||||
|
<span className={css.rowText}>
|
||||||
|
<span className={css.title}>{t('settingsOpenToolsTitle')}</span>
|
||||||
|
<span className={css.desc}>{t('settingsOpenToolsDesc')}</span>
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
label={t('settingsOpenToolsTitle')}
|
||||||
|
checked={prefs.agentOpenTools}
|
||||||
|
onChange={(next) => { applyPref({ agentOpenTools: next }) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className={css.row}>
|
<div className={css.row}>
|
||||||
<span className={css.rowText}>
|
<span className={css.rowText}>
|
||||||
<span className={css.title}>{t('settingsTitleBarTitle')}</span>
|
<span className={css.title}>{t('settingsTitleBarTitle')}</span>
|
||||||
|
|||||||
@@ -463,6 +463,78 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
|||||||
}
|
}
|
||||||
}, [sessionId, store])
|
}, [sessionId, store])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent opens push: subscribe to the host's `sidebar_open` requests for
|
||||||
|
* this session (the model actively opens a file / folder / HTTP(S) page).
|
||||||
|
* The host pushes one JSON request per open; the sidebar routes it to the
|
||||||
|
* matching built-in tab: a file opens in the editor (per-path dedupe), a
|
||||||
|
* folder opens a file window whose tree is rooted at the folder
|
||||||
|
* (`meta.dir`), and a URL opens in the browser tab. A disconnected socket
|
||||||
|
* retries with a short backoff (mirror of the agent-terminals loop): the
|
||||||
|
* host queue keeps undelivered requests and replays them on the first
|
||||||
|
* attach, so a refresh or a session switch lands the opens the model
|
||||||
|
* queued while no view was connected.
|
||||||
|
* While the side-card setting is off, pushes are ignored as a defensive
|
||||||
|
* gate — the host already unregisters the tool and drains the queue.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (sessionId === undefined) return
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
let retry: number | undefined
|
||||||
|
let closed = false
|
||||||
|
let failures = 0
|
||||||
|
const connect = (): void => {
|
||||||
|
if (closed) return
|
||||||
|
const url = new URL('/sidebar/ws/agent-opens', location.origin)
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
|
url.search = new URLSearchParams({ sessionId }).toString()
|
||||||
|
socket = new WebSocket(url.toString())
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
if (typeof event.data !== 'string') return
|
||||||
|
try {
|
||||||
|
const request = JSON.parse(event.data) as { kind?: unknown; target?: unknown; title?: unknown }
|
||||||
|
if (request === null || typeof request !== 'object') return
|
||||||
|
if (request.kind !== 'file' && request.kind !== 'folder' && request.kind !== 'url') return
|
||||||
|
if (typeof request.target !== 'string' || request.target === '') return
|
||||||
|
if (store.getPrefs().agentOpenTools !== true) return
|
||||||
|
const scope = { sessionId }
|
||||||
|
const title = typeof request.title === 'string' && request.title !== '' ? request.title : undefined
|
||||||
|
if (request.kind === 'url') {
|
||||||
|
ctx.betterSidebar?.openTab({ type: 'browser', url: request.target, title }, scope)
|
||||||
|
} else if (request.kind === 'folder') {
|
||||||
|
ctx.betterSidebar?.openTab({
|
||||||
|
type: 'editor',
|
||||||
|
title,
|
||||||
|
path: request.target,
|
||||||
|
id: `editor:${request.target}`,
|
||||||
|
meta: { dir: true },
|
||||||
|
}, scope)
|
||||||
|
} else {
|
||||||
|
ctx.betterSidebar?.openFile(scope, request.target, title)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed push: ignore (the next push carries its own request).
|
||||||
|
}
|
||||||
|
}
|
||||||
|
socket.onclose = () => {
|
||||||
|
if (closed) return
|
||||||
|
failures += 1
|
||||||
|
if (failures >= FAILURE_LIMIT) {
|
||||||
|
console.error('[dsh-better-sidebar] agent-opens connection failed; stopping reconnect loop', sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
retry = window.setTimeout(connect, 2000)
|
||||||
|
}
|
||||||
|
socket.onerror = () => { socket?.close() }
|
||||||
|
}
|
||||||
|
connect()
|
||||||
|
return () => {
|
||||||
|
closed = true
|
||||||
|
window.clearTimeout(retry)
|
||||||
|
socket?.close()
|
||||||
|
}
|
||||||
|
}, [sessionId, store])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subagent auto-activation: the moment the current conversation spawns its
|
* Subagent auto-activation: the moment the current conversation spawns its
|
||||||
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ export const ar: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي',
|
settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي',
|
||||||
settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام',
|
settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام',
|
||||||
|
settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج',
|
||||||
|
settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)',
|
||||||
settingsTitleBarTitle: 'وضع توافق الموضع',
|
settingsTitleBarTitle: 'وضع توافق الموضع',
|
||||||
settingsTitleBarDesc: 'اختر نظام توافق الشريط العلوي: كشف تلقائي (افتراضي، محافظ) / ويب DSH الرسمي / أغلفة سطح المكتب المعروفة / مخصّص (مسافة الإزاحة + CSS مخصّص)',
|
settingsTitleBarDesc: 'اختر نظام توافق الشريط العلوي: كشف تلقائي (افتراضي، محافظ) / ويب DSH الرسمي / أغلفة سطح المكتب المعروفة / مخصّص (مسافة الإزاحة + CSS مخصّص)',
|
||||||
settingsTitleBarStripTitle: 'مسافة الإزاحة',
|
settingsTitleBarStripTitle: 'مسافة الإزاحة',
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ export const de: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Chat-Dateien in der Seitenleiste öffnen',
|
settingsOpenPathTitle: 'Chat-Dateien in der Seitenleiste öffnen',
|
||||||
settingsOpenPathDesc: 'Dateilinks im Chat (Werkzeugzeilen, erstellte Dateien, Erwähnungen) werden im Seitenleisten-Editor geöffnet statt in der System-Standardanwendung',
|
settingsOpenPathDesc: 'Dateilinks im Chat (Werkzeugzeilen, erstellte Dateien, Erwähnungen) werden im Seitenleisten-Editor geöffnet statt in der System-Standardanwendung',
|
||||||
|
settingsOpenToolsTitle: 'Seitenleisten-Öffnungswerkzeug für das Modell bereitstellen',
|
||||||
|
settingsOpenToolsDesc: 'Wenn aktiviert, kann das Modell über das sidebar_open-Werkzeug Dateien, Ordner und HTTP(S)-Seiten in der Seitenleiste öffnen (standardmäßig deaktiviert)',
|
||||||
settingsTitleBarTitle: 'Kompatibilitätsmodus der Position',
|
settingsTitleBarTitle: 'Kompatibilitätsmodus der Position',
|
||||||
settingsTitleBarDesc: 'Kompatibilitätsschema der Titelleiste wählen: automatische Erkennung (Standard, konservativ) / offizielles DSH-Web / bekannte Desktop-Shells / benutzerdefiniert (Verschiebung + benutzerdefiniertes CSS)',
|
settingsTitleBarDesc: 'Kompatibilitätsschema der Titelleiste wählen: automatische Erkennung (Standard, konservativ) / offizielles DSH-Web / bekannte Desktop-Shells / benutzerdefiniert (Verschiebung + benutzerdefiniertes CSS)',
|
||||||
settingsTitleBarStripTitle: 'Verschiebungsabstand',
|
settingsTitleBarStripTitle: 'Verschiebungsabstand',
|
||||||
|
|||||||
@@ -178,6 +178,8 @@ export const fr: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Ouvrir les fichiers de discussion dans la barre latérale',
|
settingsOpenPathTitle: 'Ouvrir les fichiers de discussion dans la barre latérale',
|
||||||
settingsOpenPathDesc: 'À la place de l’application par défaut du système, ouvrir dans l’éditeur de la barre latérale les liens vers les fichiers du chat (lignes d’outils, listes de produits, mentions de fichiers)',
|
settingsOpenPathDesc: 'À la place de l’application par défaut du système, ouvrir dans l’éditeur de la barre latérale les liens vers les fichiers du chat (lignes d’outils, listes de produits, mentions de fichiers)',
|
||||||
|
settingsOpenToolsTitle: 'Injecter l\'outil d\'ouverture latérale pour le modèle',
|
||||||
|
settingsOpenToolsDesc: 'Une fois activé, le modèle peut ouvrir des fichiers, dossiers et pages HTTP(S) dans la barre latérale via l\'outil sidebar_open (désactivé par défaut)',
|
||||||
settingsTitleBarTitle: 'Mode de compatibilité de position',
|
settingsTitleBarTitle: 'Mode de compatibilité de position',
|
||||||
settingsTitleBarDesc: 'Choisissez le schéma de compatibilité de la barre de titre : détection automatique (par défaut, conservateur) / Web officiel DSH / coquilles de bureau connues / schéma personnalisé (distance de décalage + CSS personnalisé)',
|
settingsTitleBarDesc: 'Choisissez le schéma de compatibilité de la barre de titre : détection automatique (par défaut, conservateur) / Web officiel DSH / coquilles de bureau connues / schéma personnalisé (distance de décalage + CSS personnalisé)',
|
||||||
settingsTitleBarStripTitle: 'Distance de décalage',
|
settingsTitleBarStripTitle: 'Distance de décalage',
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ export const hi: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'चैट फ़ाइलें साइडबार में खोलें',
|
settingsOpenPathTitle: 'चैट फ़ाइलें साइडबार में खोलें',
|
||||||
settingsOpenPathDesc: 'चैट में फ़ाइल लिंक (टूल पंक्ति, उत्पादित फ़ाइलें, उल्लेख) क्लिक करने पर सिस्टम डिफ़ॉल्ट ऐप के बजाय साइडबार एडिटर में खोलें',
|
settingsOpenPathDesc: 'चैट में फ़ाइल लिंक (टूल पंक्ति, उत्पादित फ़ाइलें, उल्लेख) क्लिक करने पर सिस्टम डिफ़ॉल्ट ऐप के बजाय साइडबार एडिटर में खोलें',
|
||||||
|
settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें',
|
||||||
|
settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)',
|
||||||
settingsTitleBarTitle: 'स्थिति संगतता मोड',
|
settingsTitleBarTitle: 'स्थिति संगतता मोड',
|
||||||
settingsTitleBarDesc: 'टाइटल-बार संगतता योजना चुनें: ऑटो-डिटेक्ट (डिफ़ॉल्ट, रूढ़िवादी) / DSH आधिकारिक वेब / ज्ञात डेस्कटॉप शेल / कस्टम (शिफ्ट दूरी + कस्टम CSS)',
|
settingsTitleBarDesc: 'टाइटल-बार संगतता योजना चुनें: ऑटो-डिटेक्ट (डिफ़ॉल्ट, रूढ़िवादी) / DSH आधिकारिक वेब / ज्ञात डेस्कटॉप शेल / कस्टम (शिफ्ट दूरी + कस्टम CSS)',
|
||||||
settingsTitleBarStripTitle: 'शिफ्ट दूरी',
|
settingsTitleBarStripTitle: 'शिफ्ट दूरी',
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ export const id: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Buka berkas obrolan di sidebar',
|
settingsOpenPathTitle: 'Buka berkas obrolan di sidebar',
|
||||||
settingsOpenPathDesc: 'Buka tautan berkas di obrolan (baris alat, berkas yang dihasilkan, mention) di editor sidebar alih-alih aplikasi default sistem',
|
settingsOpenPathDesc: 'Buka tautan berkas di obrolan (baris alat, berkas yang dihasilkan, mention) di editor sidebar alih-alih aplikasi default sistem',
|
||||||
|
settingsOpenToolsTitle: 'Suntik alat buka sidebar untuk model',
|
||||||
|
settingsOpenToolsDesc: 'Saat diaktifkan, model dapat membuka file, folder, dan halaman HTTP(S) di sidebar melalui alat sidebar_open (nonaktif secara default)',
|
||||||
settingsTitleBarTitle: 'Mode kompatibilitas posisi',
|
settingsTitleBarTitle: 'Mode kompatibilitas posisi',
|
||||||
settingsTitleBarDesc: 'Pilih skema kompatibilitas title-bar: auto-detect (default, konservatif) / Web resmi DSH / shell desktop yang dikenal / kustom (jarak geser + CSS kustom)',
|
settingsTitleBarDesc: 'Pilih skema kompatibilitas title-bar: auto-detect (default, konservatif) / Web resmi DSH / shell desktop yang dikenal / kustom (jarak geser + CSS kustom)',
|
||||||
settingsTitleBarStripTitle: 'Jarak geser',
|
settingsTitleBarStripTitle: 'Jarak geser',
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ export const it: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Apri i file della chat nella barra laterale',
|
settingsOpenPathTitle: 'Apri i file della chat nella barra laterale',
|
||||||
settingsOpenPathDesc: 'Apre i collegamenti ai file nella chat (righe di strumenti, file prodotti, menzioni) nell’editor della barra laterale invece dell’app predefinita di sistema',
|
settingsOpenPathDesc: 'Apre i collegamenti ai file nella chat (righe di strumenti, file prodotti, menzioni) nell’editor della barra laterale invece dell’app predefinita di sistema',
|
||||||
|
settingsOpenToolsTitle: 'Iniettare lo strumento di apertura della barra laterale per il modello',
|
||||||
|
settingsOpenToolsDesc: 'Se attivato, il modello può aprire file, cartelle e pagine HTTP(S) nella barra laterale tramite lo strumento sidebar_open (disattivato per impostazione predefinita)',
|
||||||
settingsTitleBarTitle: 'Modalità di compatibilità della posizione',
|
settingsTitleBarTitle: 'Modalità di compatibilità della posizione',
|
||||||
settingsTitleBarDesc: 'Scelga lo schema di compatibilità della barra del titolo: rilevamento automatico (predefinito, prudente) / DSH Web ufficiale / shell desktop note / personalizzato (distanza di scorrimento + CSS personalizzato)',
|
settingsTitleBarDesc: 'Scelga lo schema di compatibilità della barra del titolo: rilevamento automatico (predefinito, prudente) / DSH Web ufficiale / shell desktop note / personalizzato (distanza di scorrimento + CSS personalizzato)',
|
||||||
settingsTitleBarStripTitle: 'Distanza di scorrimento',
|
settingsTitleBarStripTitle: 'Distanza di scorrimento',
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ export const ja: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く',
|
settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く',
|
||||||
settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く',
|
settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く',
|
||||||
|
settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入',
|
||||||
|
settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)',
|
||||||
settingsTitleBarTitle: '位置互換モード',
|
settingsTitleBarTitle: '位置互換モード',
|
||||||
settingsTitleBarDesc: 'タイトルバー互換スキームを選択:自動検出(デフォルト、控えめ)/ DSH 公式 Web / 既知のデスクトップシェル / カスタムスキーム(下移動距離 + カスタム CSS)',
|
settingsTitleBarDesc: 'タイトルバー互換スキームを選択:自動検出(デフォルト、控えめ)/ DSH 公式 Web / 既知のデスクトップシェル / カスタムスキーム(下移動距離 + カスタム CSS)',
|
||||||
settingsTitleBarStripTitle: '下移動距離',
|
settingsTitleBarStripTitle: '下移動距離',
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ export const ko: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: '채팅 영역 파일을 사이드바에서 열기',
|
settingsOpenPathTitle: '채팅 영역 파일을 사이드바에서 열기',
|
||||||
settingsOpenPathDesc: '채팅에서 파일 링크(도구 행, 산출물 목록, 파일 언급)를 클릭하면 사이드바 편집기에서 열고, 시스템 기본 앱은 호출하지 않습니다',
|
settingsOpenPathDesc: '채팅에서 파일 링크(도구 행, 산출물 목록, 파일 언급)를 클릭하면 사이드바 편집기에서 열고, 시스템 기본 앱은 호출하지 않습니다',
|
||||||
|
settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입',
|
||||||
|
settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)',
|
||||||
settingsTitleBarTitle: '위치 호환 모드',
|
settingsTitleBarTitle: '위치 호환 모드',
|
||||||
settingsTitleBarDesc: '상단 바 호환 방식을 선택하세요: 자동 감지(기본, 보수적) / DSH 공식 Web / 알려진 데스크톱 셸 / 사용자 지정 방식(하강 거리 + 사용자 지정 CSS)',
|
settingsTitleBarDesc: '상단 바 호환 방식을 선택하세요: 자동 감지(기본, 보수적) / DSH 공식 Web / 알려진 데스크톱 셸 / 사용자 지정 방식(하강 거리 + 사용자 지정 CSS)',
|
||||||
settingsTitleBarStripTitle: '하강 거리',
|
settingsTitleBarStripTitle: '하강 거리',
|
||||||
|
|||||||
@@ -183,6 +183,8 @@ export const nl: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Chatbestanden in de zijbalk openen',
|
settingsOpenPathTitle: 'Chatbestanden in de zijbalk openen',
|
||||||
settingsOpenPathDesc: 'Bestandslinks in de chat (toolrijen, geproduceerde bestanden, vermeldingen) openen in de zijbalk-editor in plaats van de standaard systeemapp',
|
settingsOpenPathDesc: 'Bestandslinks in de chat (toolrijen, geproduceerde bestanden, vermeldingen) openen in de zijbalk-editor in plaats van de standaard systeemapp',
|
||||||
|
settingsOpenToolsTitle: 'Zijbalk-openen-tool voor het model injecteren',
|
||||||
|
settingsOpenToolsDesc: 'Indien ingeschakeld kan het model bestanden, mappen en HTTP(S)-pagina\'s in de zijbalk openen via de sidebar_open-tool (standaard uit)',
|
||||||
settingsTitleBarTitle: 'Positiecompatibiliteitsmodus',
|
settingsTitleBarTitle: 'Positiecompatibiliteitsmodus',
|
||||||
settingsTitleBarDesc: 'Kies het titelbalk-compatibiliteitsschema: auto-detect (standaard, conservatief) / DSH officiële web / bekende desktop-shells / aangepast (verschuivingsafstand + aangepaste CSS)',
|
settingsTitleBarDesc: 'Kies het titelbalk-compatibiliteitsschema: auto-detect (standaard, conservatief) / DSH officiële web / bekende desktop-shells / aangepast (verschuivingsafstand + aangepaste CSS)',
|
||||||
settingsTitleBarStripTitle: 'Verschuivingsafstand',
|
settingsTitleBarStripTitle: 'Verschuivingsafstand',
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ export const pl: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Otwieraj pliki czatu w panelu bocznym',
|
settingsOpenPathTitle: 'Otwieraj pliki czatu w panelu bocznym',
|
||||||
settingsOpenPathDesc: 'Otwieraj linki plików na czacie (wiersze narzędzi, wyprodukowane pliki, wzmianki) w edytorze panelu bocznego zamiast w domyślnej aplikacji systemu',
|
settingsOpenPathDesc: 'Otwieraj linki plików na czacie (wiersze narzędzi, wyprodukowane pliki, wzmianki) w edytorze panelu bocznego zamiast w domyślnej aplikacji systemu',
|
||||||
|
settingsOpenToolsTitle: 'Wstrzyknij narzędzie otwierania panelu bocznego dla modelu',
|
||||||
|
settingsOpenToolsDesc: 'Po włączeniu model może otwierać pliki, foldery i strony HTTP(S) w panelu bocznym za pomocą narzędzia sidebar_open (domyślnie wyłączone)',
|
||||||
settingsTitleBarTitle: 'Tryb zgodności pozycji',
|
settingsTitleBarTitle: 'Tryb zgodności pozycji',
|
||||||
settingsTitleBarDesc: 'Wybierz schemat zgodności paska tytułu: automatyczne wykrywanie (domyślne, konserwatywne) / oficjalny web DSH / znane powłoki pulpitu / niestandardowy (przesunięcie + własne CSS)',
|
settingsTitleBarDesc: 'Wybierz schemat zgodności paska tytułu: automatyczne wykrywanie (domyślne, konserwatywne) / oficjalny web DSH / znane powłoki pulpitu / niestandardowy (przesunięcie + własne CSS)',
|
||||||
settingsTitleBarStripTitle: 'Przesunięcie',
|
settingsTitleBarStripTitle: 'Przesunięcie',
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ export const pt: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Abrir arquivos do chat na barra lateral',
|
settingsOpenPathTitle: 'Abrir arquivos do chat na barra lateral',
|
||||||
settingsOpenPathDesc: 'Abrir links de arquivos no chat (linhas de ferramentas, arquivos produzidos, menções) no editor da barra lateral em vez do aplicativo padrão do sistema',
|
settingsOpenPathDesc: 'Abrir links de arquivos no chat (linhas de ferramentas, arquivos produzidos, menções) no editor da barra lateral em vez do aplicativo padrão do sistema',
|
||||||
|
settingsOpenToolsTitle: 'Injetar a ferramenta de abertura na barra lateral para o modelo',
|
||||||
|
settingsOpenToolsDesc: 'Quando ativado, o modelo pode abrir arquivos, pastas e páginas HTTP(S) na barra lateral pela ferramenta sidebar_open (desativado por padrão)',
|
||||||
settingsTitleBarTitle: 'Modo de compatibilidade de posição',
|
settingsTitleBarTitle: 'Modo de compatibilidade de posição',
|
||||||
settingsTitleBarDesc: 'Escolha o esquema de compatibilidade da barra de título: detecção automática (padrão, conservador) / web oficial do DSH / shells de desktop conhecidos / personalizado (distância de deslocamento + CSS personalizado)',
|
settingsTitleBarDesc: 'Escolha o esquema de compatibilidade da barra de título: detecção automática (padrão, conservador) / web oficial do DSH / shells de desktop conhecidos / personalizado (distância de deslocamento + CSS personalizado)',
|
||||||
settingsTitleBarStripTitle: 'Distância de deslocamento',
|
settingsTitleBarStripTitle: 'Distância de deslocamento',
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ export const ru: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Открывать файлы чата в боковой панели',
|
settingsOpenPathTitle: 'Открывать файлы чата в боковой панели',
|
||||||
settingsOpenPathDesc: 'При щелчке по ссылке на файл в чате (строки инструментов, списки результатов, упоминания) файл открывается в редакторе боковой панели, а не в системном приложении по умолчанию',
|
settingsOpenPathDesc: 'При щелчке по ссылке на файл в чате (строки инструментов, списки результатов, упоминания) файл открывается в редакторе боковой панели, а не в системном приложении по умолчанию',
|
||||||
|
settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели',
|
||||||
|
settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)',
|
||||||
settingsTitleBarTitle: 'Режим совместимости заголовка',
|
settingsTitleBarTitle: 'Режим совместимости заголовка',
|
||||||
settingsTitleBarDesc: 'Выберите схему совместимости заголовка: автоопределение (по умолчанию, консервативно) / официальный Web DSH / известные десктоп-оболочки / своя схема (смещение + пользовательский CSS)',
|
settingsTitleBarDesc: 'Выберите схему совместимости заголовка: автоопределение (по умолчанию, консервативно) / официальный Web DSH / известные десктоп-оболочки / своя схема (смещение + пользовательский CSS)',
|
||||||
settingsTitleBarStripTitle: 'Величина смещения',
|
settingsTitleBarStripTitle: 'Величина смещения',
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ export const sv: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Öppna chattfiler i sidopanelen',
|
settingsOpenPathTitle: 'Öppna chattfiler i sidopanelen',
|
||||||
settingsOpenPathDesc: 'Öppna fillänkar i chatten (verktygsrader, producerade filer, omnämnanden) i sidopanelens editor i stället för systemets standardapp',
|
settingsOpenPathDesc: 'Öppna fillänkar i chatten (verktygsrader, producerade filer, omnämnanden) i sidopanelens editor i stället för systemets standardapp',
|
||||||
|
settingsOpenToolsTitle: 'Injicera sidopanel-öppningsverktyg för modellen',
|
||||||
|
settingsOpenToolsDesc: 'När aktiverat kan modellen öppna filer, mappar och HTTP(S)-sidor i sidopanelen via sidebar_open-verktyget (av som standard)',
|
||||||
settingsTitleBarTitle: 'Positions kompatibilitetsläge',
|
settingsTitleBarTitle: 'Positions kompatibilitetsläge',
|
||||||
settingsTitleBarDesc: 'Välj titelfälts kompatibilitetsschema: auto-detektera (standard, konservativt) / DSH officiella webb / kända skrivbordsskal / anpassat (förskjutningsavstånd + egen CSS)',
|
settingsTitleBarDesc: 'Välj titelfälts kompatibilitetsschema: auto-detektera (standard, konservativt) / DSH officiella webb / kända skrivbordsskal / anpassat (förskjutningsavstånd + egen CSS)',
|
||||||
settingsTitleBarStripTitle: 'Förskjutningsavstånd',
|
settingsTitleBarStripTitle: 'Förskjutningsavstånd',
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ export const th: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'เปิดไฟล์แชทในแถบด้านข้าง',
|
settingsOpenPathTitle: 'เปิดไฟล์แชทในแถบด้านข้าง',
|
||||||
settingsOpenPathDesc: 'เปิดลิงก์ไฟล์ในแชท (แถวเครื่องมือ, ไฟล์ที่สร้าง, การกล่าวถึง) ในตัวแก้ไขแถบด้านข้างแทนแอปเริ่มต้นของระบบ',
|
settingsOpenPathDesc: 'เปิดลิงก์ไฟล์ในแชท (แถวเครื่องมือ, ไฟล์ที่สร้าง, การกล่าวถึง) ในตัวแก้ไขแถบด้านข้างแทนแอปเริ่มต้นของระบบ',
|
||||||
|
settingsOpenToolsTitle: 'ฉีดเครื่องมือเปิดแถบด้านข้างสำหรับโมเดล',
|
||||||
|
settingsOpenToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถเปิดไฟล์ โฟลเดอร์ และหน้า HTTP(S) ในแถบด้านข้างผ่านเครื่องมือ sidebar_open (ปิดเป็นค่าเริ่มต้น)',
|
||||||
settingsTitleBarTitle: 'โหมดความเข้ากันได้ของตำแหน่ง',
|
settingsTitleBarTitle: 'โหมดความเข้ากันได้ของตำแหน่ง',
|
||||||
settingsTitleBarDesc: 'เลือกโครงร่างความเข้ากันได้ของไตเติลบาร์: ตรวจจับอัตโนมัติ (ค่าเริ่มต้น, อนุรักษ์นิยม) / DSH เว็บอย่างเป็นทางการ / เชลล์เดสก์ท็อปที่รู้จัก / กำหนดเอง (ระยะการเลื่อน + CSS ที่กำหนดเอง)',
|
settingsTitleBarDesc: 'เลือกโครงร่างความเข้ากันได้ของไตเติลบาร์: ตรวจจับอัตโนมัติ (ค่าเริ่มต้น, อนุรักษ์นิยม) / DSH เว็บอย่างเป็นทางการ / เชลล์เดสก์ท็อปที่รู้จัก / กำหนดเอง (ระยะการเลื่อน + CSS ที่กำหนดเอง)',
|
||||||
settingsTitleBarStripTitle: 'ระยะการเลื่อน',
|
settingsTitleBarStripTitle: 'ระยะการเลื่อน',
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ export const tr: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Sohbet dosyalarını kenar çubuğunda aç',
|
settingsOpenPathTitle: 'Sohbet dosyalarını kenar çubuğunda aç',
|
||||||
settingsOpenPathDesc: 'Sohbetteki dosya bağlantılarını (araç satırları, üretilen dosyalar, anılmalar) sistem varsayılan uygulaması yerine kenar çubuğu düzenleyicisinde aç',
|
settingsOpenPathDesc: 'Sohbetteki dosya bağlantılarını (araç satırları, üretilen dosyalar, anılmalar) sistem varsayılan uygulaması yerine kenar çubuğu düzenleyicisinde aç',
|
||||||
|
settingsOpenToolsTitle: 'Model için kenar çubuğu açma aracı enjekte et',
|
||||||
|
settingsOpenToolsDesc: 'Etkinleştirildiğinde model, sidebar_open aracıyla kenar çubuğunda dosyaları, klasörleri ve HTTP(S) sayfalarını açabilir (varsayılan kapalı)',
|
||||||
settingsTitleBarTitle: 'Konum uyumluluk modu',
|
settingsTitleBarTitle: 'Konum uyumluluk modu',
|
||||||
settingsTitleBarDesc: 'Başlık çubuğu uyumluluk düzenini seçin: otomatik algıla (varsayılan, tutucu) / DSH resmi web / bilinen masaüstü kabukları / özel (kaydırma mesafesi + özel CSS)',
|
settingsTitleBarDesc: 'Başlık çubuğu uyumluluk düzenini seçin: otomatik algıla (varsayılan, tutucu) / DSH resmi web / bilinen masaüstü kabukları / özel (kaydırma mesafesi + özel CSS)',
|
||||||
settingsTitleBarStripTitle: 'Kaydırma mesafesi',
|
settingsTitleBarStripTitle: 'Kaydırma mesafesi',
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ export const vi: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Mở tệp chat trong thanh bên',
|
settingsOpenPathTitle: 'Mở tệp chat trong thanh bên',
|
||||||
settingsOpenPathDesc: 'Khi nhấp link tệp trong chat (dòng công cụ, danh sách sản phẩm, nhắc tệp), mở trong trình soạn thảo thanh bên thay vì ứng dụng mặc định hệ thống',
|
settingsOpenPathDesc: 'Khi nhấp link tệp trong chat (dòng công cụ, danh sách sản phẩm, nhắc tệp), mở trong trình soạn thảo thanh bên thay vì ứng dụng mặc định hệ thống',
|
||||||
|
settingsOpenToolsTitle: 'Tiêm công cụ mở thanh bên cho mô hình',
|
||||||
|
settingsOpenToolsDesc: 'Khi bật, mô hình có thể mở tệp, thư mục và trang HTTP(S) trong thanh bên qua công cụ sidebar_open (mặc định tắt)',
|
||||||
settingsTitleBarTitle: 'Chế độ tương thích thanh tiêu đề',
|
settingsTitleBarTitle: 'Chế độ tương thích thanh tiêu đề',
|
||||||
settingsTitleBarDesc: 'Chọn phương án tương thích thanh tiêu đề: tự động phát hiện (mặc định, bảo thủ) / Web DSH chính thức / shell desktop đã biết / tùy chỉnh (khoảng dịch chuyển + CSS tùy chỉnh)',
|
settingsTitleBarDesc: 'Chọn phương án tương thích thanh tiêu đề: tự động phát hiện (mặc định, bảo thủ) / Web DSH chính thức / shell desktop đã biết / tùy chỉnh (khoảng dịch chuyển + CSS tùy chỉnh)',
|
||||||
settingsTitleBarStripTitle: 'Khoảng dịch chuyển',
|
settingsTitleBarStripTitle: 'Khoảng dịch chuyển',
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ export const zhHK: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
||||||
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
||||||
|
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
|
||||||
|
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
|
||||||
settingsTitleBarTitle: '位置相容模式',
|
settingsTitleBarTitle: '位置相容模式',
|
||||||
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
|
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
|
||||||
settingsTitleBarStripTitle: '下移距離',
|
settingsTitleBarStripTitle: '下移距離',
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ export const zhMO: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
||||||
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
||||||
|
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
|
||||||
|
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
|
||||||
settingsTitleBarTitle: '位置相容模式',
|
settingsTitleBarTitle: '位置相容模式',
|
||||||
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
|
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
|
||||||
settingsTitleBarStripTitle: '下移距離',
|
settingsTitleBarStripTitle: '下移距離',
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ export const zhTW: Record<string, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
|
||||||
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
|
||||||
|
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
|
||||||
|
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
|
||||||
settingsTitleBarTitle: '位置相容模式',
|
settingsTitleBarTitle: '位置相容模式',
|
||||||
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
|
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
|
||||||
settingsTitleBarStripTitle: '下移距離',
|
settingsTitleBarStripTitle: '下移距離',
|
||||||
|
|||||||
@@ -192,6 +192,8 @@ export const zh = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
||||||
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
||||||
|
settingsOpenToolsTitle: '为模型注入侧边栏打开工具',
|
||||||
|
settingsOpenToolsDesc: '开启后,模型可通过 sidebar_open 工具在侧边栏主动打开文件、文件夹和 HTTP(S) 网页(默认关闭)',
|
||||||
settingsTitleBarTitle: '位置兼容模式',
|
settingsTitleBarTitle: '位置兼容模式',
|
||||||
settingsTitleBarDesc: '选择顶栏兼容方案:自动检测(默认,保守)/ DSH官方Web / 已知桌面壳 / 自定义方案(下移距离 + 自定义 CSS)',
|
settingsTitleBarDesc: '选择顶栏兼容方案:自动检测(默认,保守)/ DSH官方Web / 已知桌面壳 / 自定义方案(下移距离 + 自定义 CSS)',
|
||||||
settingsTitleBarStripTitle: '下移距离',
|
settingsTitleBarStripTitle: '下移距离',
|
||||||
@@ -526,6 +528,8 @@ export const en: Record<keyof typeof zh, string> = {
|
|||||||
settingsWidthSuffix: '%',
|
settingsWidthSuffix: '%',
|
||||||
settingsOpenPathTitle: 'Open chat files in the sidebar',
|
settingsOpenPathTitle: 'Open chat files in the sidebar',
|
||||||
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
|
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
|
||||||
|
settingsOpenToolsTitle: 'Inject the sidebar-open tool for the model',
|
||||||
|
settingsOpenToolsDesc: 'When enabled, the model can actively open files, folders, and HTTP(S) pages in the sidebar through the sidebar_open tool (off by default)',
|
||||||
settingsTitleBarTitle: 'Position compatibility mode',
|
settingsTitleBarTitle: 'Position compatibility mode',
|
||||||
settingsTitleBarDesc: 'Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)',
|
settingsTitleBarDesc: 'Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)',
|
||||||
settingsTitleBarStripTitle: 'Shift distance',
|
settingsTitleBarStripTitle: 'Shift distance',
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
|||||||
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
|
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
|
||||||
? record.agentTerminalTools
|
? record.agentTerminalTools
|
||||||
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
|
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
|
||||||
|
agentOpenTools: typeof record.agentOpenTools === 'boolean'
|
||||||
|
? record.agentOpenTools
|
||||||
|
: SIDEBAR_PREFS_DEFAULTS.agentOpenTools,
|
||||||
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
|
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
|
||||||
? record.bottomPanelAutoTerminal
|
? record.bottomPanelAutoTerminal
|
||||||
: SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal,
|
: SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal,
|
||||||
|
|||||||
@@ -117,8 +117,8 @@ export interface SidebarSettingsDeclaration {
|
|||||||
* Extra settings rows rendered under the feature's own row in the
|
* Extra settings rows rendered under the feature's own row in the
|
||||||
* settings page (only while the feature is enabled). Keys must be fields
|
* settings page (only while the feature is enabled). Keys must be fields
|
||||||
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
|
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
|
||||||
* 'agentTerminalTools', 'terminalFontFamily'); unknown keys are dropped
|
* 'agentTerminalTools', 'agentOpenTools', 'terminalFontFamily'); unknown
|
||||||
* by the settings seam.
|
* keys are dropped by the settings seam.
|
||||||
*/
|
*/
|
||||||
toggles?: readonly SidebarSettingToggle[]
|
toggles?: readonly SidebarSettingToggle[]
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
|||||||
autoOpenSubagent: z.boolean().default(true),
|
autoOpenSubagent: z.boolean().default(true),
|
||||||
autoOpenJobs: z.boolean().default(true),
|
autoOpenJobs: z.boolean().default(true),
|
||||||
agentTerminalTools: z.boolean().default(false),
|
agentTerminalTools: z.boolean().default(false),
|
||||||
|
agentOpenTools: z.boolean().default(false),
|
||||||
bottomPanelAutoTerminal: z.boolean().default(true),
|
bottomPanelAutoTerminal: z.boolean().default(true),
|
||||||
terminalFontFamily: z.string().default(''),
|
terminalFontFamily: z.string().default(''),
|
||||||
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
|
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
|
||||||
|
|||||||
+95
-1
@@ -23,6 +23,7 @@ import {
|
|||||||
Config,
|
Config,
|
||||||
PrefsSchema,
|
PrefsSchema,
|
||||||
resolveSidebarConfig,
|
resolveSidebarConfig,
|
||||||
|
SIDEBAR_PREFS_DEFAULTS,
|
||||||
SIDEBAR_PREFS_NS,
|
SIDEBAR_PREFS_NS,
|
||||||
type ResolvedSidebarConfig,
|
type ResolvedSidebarConfig,
|
||||||
type SidebarConfig,
|
type SidebarConfig,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
PTY_DEPS_MISSING,
|
PTY_DEPS_MISSING,
|
||||||
} from './pty-deps.ts'
|
} from './pty-deps.ts'
|
||||||
import { registerTools } from './tools.ts'
|
import { registerTools } from './tools.ts'
|
||||||
|
import { AgentOpenRegistry, registerOpenTool, type AgentOpenRequest } from './agent-opens.ts'
|
||||||
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
|
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
|
||||||
import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts'
|
import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts'
|
||||||
import { buildSidechatApi } from './sidechat-routes.ts'
|
import { buildSidechatApi } from './sidechat-routes.ts'
|
||||||
@@ -588,6 +590,11 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
|||||||
const agentPtyRegistry = nodePty !== null
|
const agentPtyRegistry = nodePty !== null
|
||||||
? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty)
|
? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty)
|
||||||
: null
|
: null
|
||||||
|
// The model-facing open-request registry: queues `sidebar_open` requests
|
||||||
|
// per session and pushes them to connected sidebar views over the
|
||||||
|
// `/sidebar/ws/agent-opens` socket. Unlike the pty registry it has no
|
||||||
|
// native dependencies — the tool works even in node-pty degraded mode.
|
||||||
|
const agentOpenRegistry = new AgentOpenRegistry()
|
||||||
|
|
||||||
// ── User-facing "Side card" preferences ──────────────────────────────────
|
// ── User-facing "Side card" preferences ──────────────────────────────────
|
||||||
// Register the namespace with the settings provider so the Settings page
|
// Register the namespace with the settings provider so the Settings page
|
||||||
@@ -603,6 +610,10 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
|||||||
// turns the feature on, and turning it off mid-session unregisters the
|
// turns the feature on, and turning it off mid-session unregisters the
|
||||||
// tools and releases the agent terminals they created.
|
// tools and releases the agent terminals they created.
|
||||||
let toolsDisposers: (() => void) | null = null
|
let toolsDisposers: (() => void) | null = null
|
||||||
|
// The model-facing `sidebar_open` tool is gated the same way (see
|
||||||
|
// syncOpenToolsGate below); separate disposer (no native deps, and turning
|
||||||
|
// the feature off must not release user terminals).
|
||||||
|
let openToolsDisposers: (() => void) | null = null
|
||||||
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
|
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
|
||||||
if (scope.get().agentTerminalTools) {
|
if (scope.get().agentTerminalTools) {
|
||||||
if (toolsDisposers === null) {
|
if (toolsDisposers === null) {
|
||||||
@@ -657,7 +668,38 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
|||||||
// Register (or unregister) the terminal tools from the current setting,
|
// Register (or unregister) the terminal tools from the current setting,
|
||||||
// and keep them in sync with every settings commit.
|
// and keep them in sync with every settings commit.
|
||||||
syncToolsGate(scope)
|
syncToolsGate(scope)
|
||||||
scope.watch(() => { syncToolsGate(scope) })
|
// The model-facing open tool is gated the same way on `agentOpenTools`
|
||||||
|
// (default off): nothing is injected until the user turns the feature
|
||||||
|
// on, and turning it off mid-session unregisters the tool and drops the
|
||||||
|
// queued (undelivered) open requests. Already-delivered opens keep their
|
||||||
|
// tabs — the tools' only lever is the queue, not the rendered state.
|
||||||
|
const syncOpenToolsGate = (): void => {
|
||||||
|
if (scope.get().agentOpenTools) {
|
||||||
|
if (openToolsDisposers === null) {
|
||||||
|
openToolsDisposers = registerOpenTool(
|
||||||
|
ctx,
|
||||||
|
agentOpenRegistry,
|
||||||
|
(sessionId) => sessionCwdOf(ctx, sessionId),
|
||||||
|
() => {
|
||||||
|
const view = settingsFace?.get()
|
||||||
|
const value = view?.value
|
||||||
|
return value !== null && typeof value === 'object'
|
||||||
|
? value as SidebarPrefs
|
||||||
|
: SIDEBAR_PREFS_DEFAULTS
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (openToolsDisposers !== null) {
|
||||||
|
openToolsDisposers()
|
||||||
|
openToolsDisposers = null
|
||||||
|
agentOpenRegistry.drainAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
syncOpenToolsGate()
|
||||||
|
// ONE watch subscription drives both gates: settings commits re-evaluate
|
||||||
|
// the terminal tools AND the open tool together (each gate is idempotent
|
||||||
|
// and owns its own disposer).
|
||||||
|
scope.watch(() => { syncToolsGate(scope); syncOpenToolsGate() })
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── JSON API ────────────────────────────────────────────────────────────
|
// ── JSON API ────────────────────────────────────────────────────────────
|
||||||
@@ -890,15 +932,67 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
|||||||
},
|
},
|
||||||
}), 'dsh-better-sidebar: agent-terminals push WebSocket')
|
}), 'dsh-better-sidebar: agent-terminals push WebSocket')
|
||||||
|
|
||||||
|
// ── Agent opens push WebSocket ─────────────────────────────────────────
|
||||||
|
// Pushes `sidebar_open` requests for one session to the sidebar view: the
|
||||||
|
// host queues each request in the registry (consume-on-send), so a
|
||||||
|
// connected view applies it immediately and a disconnected one gets the
|
||||||
|
// replay when it attaches. The client mirrors each request into an
|
||||||
|
// editor / folder-window / browser tab open.
|
||||||
|
const agentOpenWss = new WebSocketServer({ noServer: true })
|
||||||
|
ctx.effect(() => ctx.webServer.registerUpgrade({
|
||||||
|
path: '/sidebar/ws/agent-opens',
|
||||||
|
handler: (req, socket, head) => {
|
||||||
|
if (!fence(req)) {
|
||||||
|
socket.destroy()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agentOpenWss.handleUpgrade(req as unknown as IncomingMessage, socket as unknown as Duplex, head as Buffer, (ws) => {
|
||||||
|
void attachAgentOpen(agentOpenRegistry, ws, req)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}), 'dsh-better-sidebar: agent-opens push WebSocket')
|
||||||
|
|
||||||
ctx.effect(() => () => {
|
ctx.effect(() => () => {
|
||||||
toolsDisposers?.()
|
toolsDisposers?.()
|
||||||
|
openToolsDisposers?.()
|
||||||
ptyManager?.disposeAll()
|
ptyManager?.disposeAll()
|
||||||
agentPtyRegistry?.disposeAll()
|
agentPtyRegistry?.disposeAll()
|
||||||
|
agentOpenRegistry.dispose()
|
||||||
wss.close()
|
wss.close()
|
||||||
agentListWss.close()
|
agentListWss.close()
|
||||||
|
agentOpenWss.close()
|
||||||
}, 'dsh-better-sidebar: teardown')
|
}, 'dsh-better-sidebar: teardown')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Push queued `sidebar_open` requests for one session to a connected view. */
|
||||||
|
async function attachAgentOpen(
|
||||||
|
registry: AgentOpenRegistry,
|
||||||
|
ws: WebSocket,
|
||||||
|
req: SidebarHttpRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||||
|
const sessionId = url.searchParams.get('sessionId')
|
||||||
|
if (sessionId === null) {
|
||||||
|
ws.close(1008, 'sessionId is required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const send = (request: AgentOpenRequest): void => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(request))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Attach replays the queued (undelivered) requests for this session; the
|
||||||
|
// disposer detaches the view on socket close/error so later opens queue
|
||||||
|
// instead of accumulating on a dead socket.
|
||||||
|
const unsubscribe = registry.attach(sessionId, send)
|
||||||
|
ws.on('close', () => { unsubscribe() })
|
||||||
|
ws.on('error', () => { unsubscribe() })
|
||||||
|
} catch (error) {
|
||||||
|
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Push the live agent-terminal list for one session to a connected sidebar view. */
|
/** Push the live agent-terminal list for one session to a connected sidebar view. */
|
||||||
async function attachAgentList(
|
async function attachAgentList(
|
||||||
registry: AgentPtyRegistry | null,
|
registry: AgentPtyRegistry | null,
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ export interface SidebarPrefs {
|
|||||||
* user explicitly enables it in the side card settings.
|
* user explicitly enables it in the side card settings.
|
||||||
*/
|
*/
|
||||||
agentTerminalTools: boolean
|
agentTerminalTools: boolean
|
||||||
|
/**
|
||||||
|
* Whether the model-facing `sidebar_open` tool is injected into the
|
||||||
|
* model's toolset — one tool that lets the model actively open a local
|
||||||
|
* file, a local folder (as a tree rooted there), or an HTTP(S) page in
|
||||||
|
* the calling session's sidebar. Off by default: the feature stays
|
||||||
|
* dormant until the user explicitly enables it in the side card settings.
|
||||||
|
*/
|
||||||
|
agentOpenTools: boolean
|
||||||
/**
|
/**
|
||||||
* Custom terminal font-family stack (a CSS font-family value, e.g.
|
* Custom terminal font-family stack (a CSS font-family value, e.g.
|
||||||
* `'JetBrains Mono', monospace`). Empty string follows the app's theme
|
* `'JetBrains Mono', monospace`). Empty string follows the app's theme
|
||||||
@@ -229,6 +237,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
|||||||
autoOpenSubagent: true,
|
autoOpenSubagent: true,
|
||||||
autoOpenJobs: true,
|
autoOpenJobs: true,
|
||||||
agentTerminalTools: false,
|
agentTerminalTools: false,
|
||||||
|
agentOpenTools: false,
|
||||||
bottomPanelAutoTerminal: true,
|
bottomPanelAutoTerminal: true,
|
||||||
terminalFontFamily: '',
|
terminalFontFamily: '',
|
||||||
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
|
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the `sidebar_open` tool layer: registry semantics
|
||||||
|
* (per-session queue, consume-on-send delivery, attach replay, drain) and
|
||||||
|
* the tool itself (URL/file/folder classification, relative-path resolution,
|
||||||
|
* schema-valid canonical output, target-tab gating). The registry is real;
|
||||||
|
* no WebSocket or browser is needed — the sender callbacks are captured.
|
||||||
|
*/
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { validateJsonSchemaValue, type ToolDefinition, type ToolRunContext } from '@deepseek-ai/dsh-tools'
|
||||||
|
import { AgentOpenRegistry, registerOpenTool } from '../src/agent-opens.ts'
|
||||||
|
import type { Context } from '../src/context-types.ts'
|
||||||
|
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../src/prefs-shared.ts'
|
||||||
|
|
||||||
|
/** One registration captured by the fake tools service. */
|
||||||
|
interface CapturedTool {
|
||||||
|
definition: ToolDefinition
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal ToolRunContext for one calling session. */
|
||||||
|
function exec(sessionId: string): ToolRunContext {
|
||||||
|
return {
|
||||||
|
signal: { throwIfAborted: () => {}, aborted: false },
|
||||||
|
agent: { session: { id: sessionId } },
|
||||||
|
} as unknown as ToolRunContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register the tool against a fake service + real registry. */
|
||||||
|
function mount(overrides: { prefs?: Partial<SidebarPrefs>; resolveCwd?: (sessionId: string) => string } = {}) {
|
||||||
|
const captured: CapturedTool[] = []
|
||||||
|
let disposeCount = 0
|
||||||
|
const ctx = {
|
||||||
|
tools: {
|
||||||
|
register: (tool: unknown): (() => void) => {
|
||||||
|
captured.push({ definition: tool as ToolDefinition })
|
||||||
|
return () => { disposeCount += 1 }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as Context
|
||||||
|
const registry = new AgentOpenRegistry()
|
||||||
|
const prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS, ...overrides.prefs }
|
||||||
|
const dispose = registerOpenTool(
|
||||||
|
ctx,
|
||||||
|
registry,
|
||||||
|
overrides.resolveCwd ?? (() => '/cwd'),
|
||||||
|
() => prefs,
|
||||||
|
)
|
||||||
|
return { captured, registry, prefs, dispose: () => { dispose(); return disposeCount } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The captured definition for one tool name. */
|
||||||
|
function toolOf(captured: CapturedTool[], name: string): ToolDefinition {
|
||||||
|
const found = captured.find(candidate => candidate.definition.name === name)
|
||||||
|
if (found === undefined) throw new Error(`tool "${name}" was not registered`)
|
||||||
|
return found.definition
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AgentOpenRegistry', () => {
|
||||||
|
it('queues without a subscriber, replays on attach (consume-on-send), and reports delivered', () => {
|
||||||
|
const registry = new AgentOpenRegistry()
|
||||||
|
const queued = registry.enqueue('s1', 'folder', '/work/src', 'src')
|
||||||
|
expect(queued.delivered).toBe(false)
|
||||||
|
|
||||||
|
const received: unknown[] = []
|
||||||
|
const detach = registry.attach('s1', (request) => { received.push(request) })
|
||||||
|
expect(received).toHaveLength(1)
|
||||||
|
expect(received[0]).toMatchObject({ kind: 'folder', target: '/work/src', title: 'src', sessionId: 's1' })
|
||||||
|
|
||||||
|
// The request was consumed on send: a second attach sees nothing.
|
||||||
|
const later: unknown[] = []
|
||||||
|
const detach2 = registry.attach('s1', (request) => { later.push(request) })
|
||||||
|
expect(later).toHaveLength(0)
|
||||||
|
|
||||||
|
detach()
|
||||||
|
detach2()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delivers immediately and does not queue when a subscriber is attached', () => {
|
||||||
|
const registry = new AgentOpenRegistry()
|
||||||
|
const received: unknown[] = []
|
||||||
|
const detach = registry.attach('s2', (request) => { received.push(request) })
|
||||||
|
const result = registry.enqueue('s2', 'url', 'https://example.com/', 'example.com')
|
||||||
|
expect(result.delivered).toBe(true)
|
||||||
|
expect(received).toHaveLength(1)
|
||||||
|
// Consumed on send: a reconnect never replays (no duplicate browser tabs).
|
||||||
|
const later: unknown[] = []
|
||||||
|
const detach2 = registry.attach('s2', (request) => { later.push(request) })
|
||||||
|
expect(later).toHaveLength(0)
|
||||||
|
detach()
|
||||||
|
detach2()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('owner-scopes the queue: one session never leaks into another', () => {
|
||||||
|
const registry = new AgentOpenRegistry()
|
||||||
|
registry.enqueue('s1', 'file', '/a.txt', 'a.txt')
|
||||||
|
|
||||||
|
const other: unknown[] = []
|
||||||
|
const detach = registry.attach('s2', (request) => { other.push(request) })
|
||||||
|
expect(other).toHaveLength(0)
|
||||||
|
|
||||||
|
const own: unknown[] = []
|
||||||
|
const detach2 = registry.attach('s1', (request) => { own.push(request) })
|
||||||
|
expect(own).toHaveLength(1)
|
||||||
|
detach()
|
||||||
|
detach2()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drainAll drops the queue and dispose drops subscribers', () => {
|
||||||
|
const registry = new AgentOpenRegistry()
|
||||||
|
registry.enqueue('s1', 'file', '/a.txt', 'a.txt')
|
||||||
|
registry.drainAll()
|
||||||
|
const received: unknown[] = []
|
||||||
|
const detach = registry.attach('s1', (request) => { received.push(request) })
|
||||||
|
expect(received).toHaveLength(0)
|
||||||
|
|
||||||
|
registry.dispose()
|
||||||
|
// After dispose the previously-returned disposer is a no-op (idempotent).
|
||||||
|
detach()
|
||||||
|
detach()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sidebar_open tool', () => {
|
||||||
|
it('registers the single tool and the disposer unregisters it', () => {
|
||||||
|
const { captured, dispose } = mount()
|
||||||
|
expect(captured.map(candidate => candidate.definition.name)).toEqual(['sidebar_open'])
|
||||||
|
expect(dispose()).toBe(1)
|
||||||
|
expect(() => mount().dispose()).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens an existing file (schema-valid output, delivered with a subscriber)', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'sidebar-open-'))
|
||||||
|
try {
|
||||||
|
const file = join(dir, 'a.txt')
|
||||||
|
writeFileSync(file, 'hello')
|
||||||
|
const { captured, registry } = mount({ resolveCwd: () => dir })
|
||||||
|
const received: unknown[] = []
|
||||||
|
const detach = registry.attach('s1', (request) => { received.push(request) })
|
||||||
|
const tool = toolOf(captured, 'sidebar_open')
|
||||||
|
const value = await tool.execute({ target: file }, exec('s1'))
|
||||||
|
expect(value).toEqual({ kind: 'file', target: file, title: 'a.txt', delivered: true })
|
||||||
|
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||||
|
expect(received).toHaveLength(1)
|
||||||
|
detach()
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves a relative path against the session cwd and queues when no view is attached', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'sidebar-open-'))
|
||||||
|
try {
|
||||||
|
writeFileSync(join(dir, 'note.md'), '# hi')
|
||||||
|
const { captured, registry } = mount({ resolveCwd: () => dir })
|
||||||
|
const tool = toolOf(captured, 'sidebar_open')
|
||||||
|
const value = await tool.execute({ target: 'note.md' }, exec('s1'))
|
||||||
|
expect(value).toMatchObject({ kind: 'file', target: join(dir, 'note.md'), title: 'note.md', delivered: false })
|
||||||
|
// Queued: a later attach replays it.
|
||||||
|
const received: unknown[] = []
|
||||||
|
const detach = registry.attach('s1', (request) => { received.push(request) })
|
||||||
|
expect(received).toHaveLength(1)
|
||||||
|
detach()
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies a directory as folder and applies a custom title', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'sidebar-open-'))
|
||||||
|
try {
|
||||||
|
const sub = join(dir, 'src')
|
||||||
|
mkdirSync(sub)
|
||||||
|
const { captured } = mount({ resolveCwd: () => dir })
|
||||||
|
const tool = toolOf(captured, 'sidebar_open')
|
||||||
|
const value = await tool.execute({ target: sub, title: 'Sources' }, exec('s1'))
|
||||||
|
expect(value).toEqual({ kind: 'folder', target: sub, title: 'Sources', delivered: false })
|
||||||
|
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('classifies an http(s) URL, titles it by hostname, and never stats it', async () => {
|
||||||
|
const { captured } = mount()
|
||||||
|
const tool = toolOf(captured, 'sidebar_open')
|
||||||
|
const value = await tool.execute({ target: 'https://example.com/docs' }, exec('s1'))
|
||||||
|
expect(value).toEqual({ kind: 'url', target: 'https://example.com/docs', title: 'example.com', delivered: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects non-http(s) schemes', async () => {
|
||||||
|
const { captured } = mount()
|
||||||
|
const tool = toolOf(captured, 'sidebar_open')
|
||||||
|
for (const target of ['javascript:alert(1)', 'file:///etc/passwd', 'vscode://file/x']) {
|
||||||
|
await expect(tool.execute({ target }, exec('s1'))).rejects.toThrow(/only accepts http/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a missing local path instead of opening it', async () => {
|
||||||
|
const { captured } = mount({ resolveCwd: () => '/definitely/missing' })
|
||||||
|
const tool = toolOf(captured, 'sidebar_open')
|
||||||
|
await expect(tool.execute({ target: '/definitely/missing/nope.txt' }, exec('s1')))
|
||||||
|
.rejects.toThrow(/does not exist/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses a disabled target tab type with a clear cause', async () => {
|
||||||
|
const { captured } = mount({ prefs: { tabsEnabled: { browser: false } } })
|
||||||
|
const tool = toolOf(captured, 'sidebar_open')
|
||||||
|
await expect(tool.execute({ target: 'https://example.com/' }, exec('s1')))
|
||||||
|
.rejects.toThrow(/browser tab is disabled/)
|
||||||
|
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'sidebar-open-'))
|
||||||
|
try {
|
||||||
|
writeFileSync(join(dir, 'f.txt'), 'x')
|
||||||
|
const { captured: fileCaptured } = mount({ resolveCwd: () => dir, prefs: { tabsEnabled: { editor: false } } })
|
||||||
|
const fileTool = toolOf(fileCaptured, 'sidebar_open')
|
||||||
|
// A URL is unaffected by the editor gate; a path is refused with cause.
|
||||||
|
await expect(fileTool.execute({ target: 'https://example.com/' }, exec('s1'))).resolves.toMatchObject({ kind: 'url' })
|
||||||
|
await expect(fileTool.execute({ target: 'f.txt' }, exec('s1'))).rejects.toThrow(/editor tab is disabled/)
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws the canonical no-agent error when executed without an agent', async () => {
|
||||||
|
const { captured } = mount()
|
||||||
|
const tool = toolOf(captured, 'sidebar_open')
|
||||||
|
await expect(tool.execute({ target: '/tmp/x' }, { signal: { throwIfAborted: () => {}, aborted: false } } as unknown as ToolRunContext))
|
||||||
|
.rejects.toThrow(/requires an initiating agent/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -293,4 +293,31 @@ describe('EditorHost (files window)', () => {
|
|||||||
unmount()
|
unmount()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('a folder tab (meta.dir) renders the tree rooted at the folder, no editor chrome', () => {
|
||||||
|
const { store, ctx } = setup()
|
||||||
|
ctx.betterSidebar!.openTab({
|
||||||
|
type: 'editor',
|
||||||
|
title: 'src',
|
||||||
|
path: '/work/src',
|
||||||
|
id: 'editor:/work/src',
|
||||||
|
meta: { dir: true },
|
||||||
|
}, { sessionId: 'editor-home-session' })
|
||||||
|
const dirTab = (): SidebarTab =>
|
||||||
|
allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||||
|
.find(tab => tab.path === '/work/src')!
|
||||||
|
const { container, unmount } = mountHost(ctx, store, dirTab)
|
||||||
|
try {
|
||||||
|
const html = container.innerHTML
|
||||||
|
// The folder window is the full tree surface: the folder basename is
|
||||||
|
// the tree root row and the search box is present; the editor empty
|
||||||
|
// hint and the file path input are NOT.
|
||||||
|
expect(html).toContain('src')
|
||||||
|
expect(html).toContain('Search files by name…')
|
||||||
|
expect(html).not.toContain('Pick a file from the tree panel')
|
||||||
|
expect(html).not.toContain('File path (relative')
|
||||||
|
} finally {
|
||||||
|
unmount()
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
|||||||
// The terminal tools default OFF (the feature is dormant until the user
|
// The terminal tools default OFF (the feature is dormant until the user
|
||||||
// enables it in the side card settings).
|
// enables it in the side card settings).
|
||||||
expect(resolved.agentTerminalTools).toBe(false)
|
expect(resolved.agentTerminalTools).toBe(false)
|
||||||
|
// The sidebar-open tool defaults OFF too (same dormant-until-enabled rule).
|
||||||
|
expect(resolved.agentOpenTools).toBe(false)
|
||||||
// The terminal font customizations default to the theme (empty family)
|
// The terminal font customizations default to the theme (empty family)
|
||||||
// and 13px.
|
// and 13px.
|
||||||
expect(resolved.terminalFontFamily).toBe('')
|
expect(resolved.terminalFontFamily).toBe('')
|
||||||
@@ -92,6 +94,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
|||||||
const overridden = (PrefsSchema as unknown as {
|
const overridden = (PrefsSchema as unknown as {
|
||||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+15
-8
@@ -37,7 +37,7 @@ describe('side card preferences', () => {
|
|||||||
defaultWidthPercent: 60,
|
defaultWidthPercent: 60,
|
||||||
autoOpenSubagent: false,
|
autoOpenSubagent: false,
|
||||||
autoOpenJobs: true,
|
autoOpenJobs: true,
|
||||||
agentTerminalTools: true,
|
agentTerminalTools: true, agentOpenTools: false,
|
||||||
bottomPanelAutoTerminal: true,
|
bottomPanelAutoTerminal: true,
|
||||||
terminalFontFamily: '',
|
terminalFontFamily: '',
|
||||||
terminalFontSize: 13,
|
terminalFontSize: 13,
|
||||||
@@ -69,7 +69,7 @@ describe('side card preferences', () => {
|
|||||||
defaultWidthPercent: 33,
|
defaultWidthPercent: 33,
|
||||||
autoOpenSubagent: true,
|
autoOpenSubagent: true,
|
||||||
autoOpenJobs: true,
|
autoOpenJobs: true,
|
||||||
agentTerminalTools: false,
|
agentTerminalTools: false, agentOpenTools: false,
|
||||||
bottomPanelAutoTerminal: true,
|
bottomPanelAutoTerminal: true,
|
||||||
terminalFontFamily: '',
|
terminalFontFamily: '',
|
||||||
terminalFontSize: 13,
|
terminalFontSize: 13,
|
||||||
@@ -101,7 +101,7 @@ describe('side card preferences', () => {
|
|||||||
defaultWidthPercent: 40,
|
defaultWidthPercent: 40,
|
||||||
autoOpenSubagent: true,
|
autoOpenSubagent: true,
|
||||||
autoOpenJobs: true,
|
autoOpenJobs: true,
|
||||||
agentTerminalTools: false,
|
agentTerminalTools: false, agentOpenTools: false,
|
||||||
bottomPanelAutoTerminal: true,
|
bottomPanelAutoTerminal: true,
|
||||||
terminalFontFamily: '',
|
terminalFontFamily: '',
|
||||||
terminalFontSize: 13,
|
terminalFontSize: 13,
|
||||||
@@ -133,6 +133,13 @@ describe('side card preferences', () => {
|
|||||||
.toBe(false)
|
.toBe(false)
|
||||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
|
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
|
// The sidebar-open tool is OFF by default too; only an explicit true turns it on.
|
||||||
|
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40 }))).agentOpenTools)
|
||||||
|
.toBe(false)
|
||||||
|
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentOpenTools: 1 }))).agentOpenTools)
|
||||||
|
.toBe(false)
|
||||||
|
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentOpenTools: true }))).agentOpenTools)
|
||||||
|
.toBe(true)
|
||||||
// The job auto-open is ON by default; only an explicit false turns it off.
|
// The job auto-open is ON by default; only an explicit false turns it off.
|
||||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs)
|
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs)
|
||||||
.toBe(true)
|
.toBe(true)
|
||||||
@@ -260,9 +267,9 @@ describe('side card preferences', () => {
|
|||||||
const store = createSidebarStore()
|
const store = createSidebarStore()
|
||||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||||
// while the open flag still follows the preference.
|
// while the open flag still follows the preference.
|
||||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||||
store.setSession('fresh-session')
|
store.setSession('fresh-session')
|
||||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||||
const snapshot = store.getSnapshot()
|
const snapshot = store.getSnapshot()
|
||||||
expect(snapshot.sessionId).toBe('fresh-session')
|
expect(snapshot.sessionId).toBe('fresh-session')
|
||||||
expect(snapshot.state?.panelOpen).toBe(false)
|
expect(snapshot.state?.panelOpen).toBe(false)
|
||||||
@@ -298,7 +305,7 @@ describe('side card preferences', () => {
|
|||||||
|
|
||||||
it('skips the default seed tab when the editor (files window) type is disabled', () => {
|
it('skips the default seed tab when the editor (files window) type is disabled', () => {
|
||||||
const store = createSidebarStore()
|
const store = createSidebarStore()
|
||||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||||
store.setSession('no-editor')
|
store.setSession('no-editor')
|
||||||
const state = store.getSnapshot().state!
|
const state = store.getSnapshot().state!
|
||||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||||
@@ -308,7 +315,7 @@ describe('side card preferences', () => {
|
|||||||
// editorExplorer modes.
|
// editorExplorer modes.
|
||||||
for (const editorExplorer of [true, false]) {
|
for (const editorExplorer of [true, false]) {
|
||||||
const openStore = createSidebarStore()
|
const openStore = createSidebarStore()
|
||||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||||
openStore.setSession(`with-editor-${editorExplorer}`)
|
openStore.setSession(`with-editor-${editorExplorer}`)
|
||||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||||
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
|
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
|
||||||
@@ -318,7 +325,7 @@ describe('side card preferences', () => {
|
|||||||
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
|
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
|
||||||
for (const editorExplorer of [true, false]) {
|
for (const editorExplorer of [true, false]) {
|
||||||
const store = createSidebarStore()
|
const store = createSidebarStore()
|
||||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||||
store.setSession(`fresh-${editorExplorer}`)
|
store.setSession(`fresh-${editorExplorer}`)
|
||||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||||
expect(tabs).toHaveLength(1)
|
expect(tabs).toHaveLength(1)
|
||||||
|
|||||||
@@ -178,10 +178,10 @@ describe('SideCardSection declarative inventory', () => {
|
|||||||
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
|
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
|
||||||
expect(html).not.toContain('<select')
|
expect(html).not.toContain('<select')
|
||||||
expect(html).toContain('>Auto-detect<')
|
expect(html).toContain('>Auto-detect<')
|
||||||
// Two general-row switches remain (openByDefault + interceptOpenPath),
|
// Three general-row switches remain (openByDefault + interceptOpenPath
|
||||||
// only interceptOpenPath checked by default — the scheme row is a
|
// + agentOpenTools), only interceptOpenPath checked by default — the
|
||||||
// dropdown, not a switch.
|
// scheme row is a dropdown, not a switch.
|
||||||
expect(html.match(/type="checkbox"/g)?.length).toBe(2)
|
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
|
||||||
expect(html.match(/checked=""/g)?.length).toBe(1)
|
expect(html.match(/checked=""/g)?.length).toBe(1)
|
||||||
// Auto (default) needs no further settings → no gear.
|
// Auto (default) needs no further settings → no gear.
|
||||||
expect(html).not.toContain('Position compatibility mode Feature settings')
|
expect(html).not.toContain('Position compatibility mode Feature settings')
|
||||||
|
|||||||
+56
-3
@@ -110,7 +110,7 @@ describe('host plugin smoke', () => {
|
|||||||
'/sidebar/file',
|
'/sidebar/file',
|
||||||
'/sidebar/html',
|
'/sidebar/html',
|
||||||
])
|
])
|
||||||
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
|
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals', '/sidebar/ws/agent-opens'])
|
||||||
// Teardown runs without throwing (pty manager has nothing open).
|
// Teardown runs without throwing (pty manager has nothing open).
|
||||||
for (const cleanup of effects) cleanup()
|
for (const cleanup of effects) cleanup()
|
||||||
})
|
})
|
||||||
@@ -839,7 +839,7 @@ describe('side card settings routes', () => {
|
|||||||
defaultWidthPercent: 35,
|
defaultWidthPercent: 35,
|
||||||
autoOpenSubagent: true,
|
autoOpenSubagent: true,
|
||||||
autoOpenJobs: true,
|
autoOpenJobs: true,
|
||||||
agentTerminalTools: false,
|
agentTerminalTools: false, agentOpenTools: false,
|
||||||
bottomPanelAutoTerminal: true,
|
bottomPanelAutoTerminal: true,
|
||||||
terminalFontFamily: '',
|
terminalFontFamily: '',
|
||||||
terminalFontSize: 13,
|
terminalFontSize: 13,
|
||||||
@@ -1015,6 +1015,59 @@ describe('agent terminal tool gating', () => {
|
|||||||
expect(live()).toBe(8)
|
expect(live()).toBe(8)
|
||||||
expect(registered).toBe(16)
|
expect(registered).toBe(16)
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('agent sidebar-open tool gating', () => {
|
||||||
|
it('injects the one open tool only when the side-card setting is enabled (default off)', () => {
|
||||||
|
let registered = 0
|
||||||
|
let disposed = 0
|
||||||
|
const live = (): number => registered - disposed
|
||||||
|
const watcherRef: { current: (() => void) | null } = { current: null }
|
||||||
|
let enabled = false
|
||||||
|
const settings = {
|
||||||
|
register() {
|
||||||
|
return {
|
||||||
|
get: () => ({ agentOpenTools: enabled, tabsEnabled: {} }),
|
||||||
|
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
|
||||||
|
update: async () => {},
|
||||||
|
replace: async () => {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
describe: () => [],
|
||||||
|
async update() {},
|
||||||
|
}
|
||||||
|
const ctx = {
|
||||||
|
webRuntime: { trustedHosts: [] },
|
||||||
|
webServer: {
|
||||||
|
register: (route: SidebarWebRoute) => { void route; return () => {} },
|
||||||
|
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||||
|
},
|
||||||
|
sessions: { get: () => undefined },
|
||||||
|
tools: { register: () => { registered += 1; return () => { disposed += 1 } } },
|
||||||
|
effect: (fn: () => void | (() => void)) => { fn() },
|
||||||
|
inject: (deps: readonly string[], callback: (sctx: { settings: unknown }) => void) => {
|
||||||
|
if (deps.includes('settings')) callback({ settings })
|
||||||
|
return () => {}
|
||||||
|
},
|
||||||
|
get: () => undefined,
|
||||||
|
}
|
||||||
|
apply(ctx as never)
|
||||||
|
// Default off: no open tool is registered even though the settings service is mounted.
|
||||||
|
expect(live()).toBe(0)
|
||||||
|
// Flipping the setting on registers the single sidebar_open tool.
|
||||||
|
enabled = true
|
||||||
|
watcherRef.current?.()
|
||||||
|
expect(live()).toBe(1)
|
||||||
|
expect(disposed).toBe(0)
|
||||||
|
// Flipping it back off unregisters it (and drains the undelivered queue).
|
||||||
|
enabled = false
|
||||||
|
watcherRef.current?.()
|
||||||
|
expect(live()).toBe(0)
|
||||||
|
expect(disposed).toBe(1)
|
||||||
|
// And a redundant toggle registers it fresh (no double-registration).
|
||||||
|
enabled = true
|
||||||
|
watcherRef.current?.()
|
||||||
|
expect(live()).toBe(1)
|
||||||
|
expect(registered).toBe(2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user