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:
Menghuan1918
2026-08-24 01:19:43 +08:00
parent 36a4119253
commit 68640101e1
39 changed files with 952 additions and 27 deletions
+3 -2
View File
@@ -181,7 +181,7 @@ interface TabDescriptor {
*/
settings?: {
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
title: 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 + 声明式元数据)
- **`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/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`**:设计文档(含实施偏差记录)
调试时直接读这些文件即可看到所有 API 的真实用法。
+1
View File
@@ -43,6 +43,7 @@
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
+1
View File
@@ -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
- **🌐 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
- **📂 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
- **🧩 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
@@ -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 集相等(自动覆盖新键)。
+262
View File
@@ -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 }
},
}))
}
+17 -6
View File
@@ -103,6 +103,11 @@ export function EditorHost(props: {
const { ctx, store, scope, tab, expanded, onToggleDir, onReferenceFile } = props
const path = tab.path ?? ''
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' })
// 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 openWithTargets = useMemo(() => resolveOpenWithTargets(openWithConfig), [openWithConfig])
// 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 treeOnly = showEmpty && !inPlace
const folderRoot = isDir ? path : undefined
/**
* 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.
setToolbar(null)
// The seeded home tab (no path) never loads a viewer — the empty-state
// hint renders until the user picks a file.
if (showEmpty) return
// hint renders until the user picks a file. A folder tab never loads a
// viewer either — its tree is rooted at the folder.
if (showEmpty || isDir) return
let cancelled = false
// Aborts the matched viewer's `load` when the editor tears down (tab
// 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))
return () => { cancelled = true; controller.abort() }
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty])
}, [scope.sessionId, scope.cwd, path, ctx, showEmpty, isDir])
const treeOpen = treeOpenOf(tab)
/** 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
// panel fills the whole tab (search + FileTree, full form), no editor
// 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 (
<div className={css.editor}>
<TreePanel
full
sessionId={scope.sessionId}
cwd={scope.cwd}
cwd={folderRoot ?? scope.cwd}
expanded={expanded}
onToggle={onToggleDir}
onOpenFile={openFile}
+11
View File
@@ -913,6 +913,17 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
/>
</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}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsTitleBarTitle')}</span>
+72
View File
@@ -463,6 +463,78 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
}
}, [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
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
+2
View File
@@ -186,6 +186,8 @@ export const ar: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي',
settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام',
settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج',
settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)',
settingsTitleBarTitle: 'وضع توافق الموضع',
settingsTitleBarDesc: 'اختر نظام توافق الشريط العلوي: كشف تلقائي (افتراضي، محافظ) / ويب DSH الرسمي / أغلفة سطح المكتب المعروفة / مخصّص (مسافة الإزاحة + CSS مخصّص)',
settingsTitleBarStripTitle: 'مسافة الإزاحة',
+2
View File
@@ -171,6 +171,8 @@ export const de: Record<string, string> = {
settingsWidthSuffix: '%',
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',
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',
settingsTitleBarDesc: 'Kompatibilitätsschema der Titelleiste wählen: automatische Erkennung (Standard, konservativ) / offizielles DSH-Web / bekannte Desktop-Shells / benutzerdefiniert (Verschiebung + benutzerdefiniertes CSS)',
settingsTitleBarStripTitle: 'Verschiebungsabstand',
+2
View File
@@ -178,6 +178,8 @@ export const fr: Record<string, string> = {
settingsWidthSuffix: '%',
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)',
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',
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',
+2
View File
@@ -185,6 +185,8 @@ export const hi: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'चैट फ़ाइलें साइडबार में खोलें',
settingsOpenPathDesc: 'चैट में फ़ाइल लिंक (टूल पंक्ति, उत्पादित फ़ाइलें, उल्लेख) क्लिक करने पर सिस्टम डिफ़ॉल्ट ऐप के बजाय साइडबार एडिटर में खोलें',
settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें',
settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)',
settingsTitleBarTitle: 'स्थिति संगतता मोड',
settingsTitleBarDesc: 'टाइटल-बार संगतता योजना चुनें: ऑटो-डिटेक्ट (डिफ़ॉल्ट, रूढ़िवादी) / DSH आधिकारिक वेब / ज्ञात डेस्कटॉप शेल / कस्टम (शिफ्ट दूरी + कस्टम CSS)',
settingsTitleBarStripTitle: 'शिफ्ट दूरी',
+2
View File
@@ -183,6 +183,8 @@ export const id: Record<string, string> = {
settingsWidthSuffix: '%',
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',
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',
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',
+2
View File
@@ -176,6 +176,8 @@ export const it: Record<string, string> = {
settingsWidthSuffix: '%',
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',
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',
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',
+2
View File
@@ -185,6 +185,8 @@ export const ja: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く',
settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く',
settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入',
settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)',
settingsTitleBarTitle: '位置互換モード',
settingsTitleBarDesc: 'タイトルバー互換スキームを選択:自動検出(デフォルト、控えめ)/ DSH 公式 Web / 既知のデスクトップシェル / カスタムスキーム(下移動距離 + カスタム CSS)',
settingsTitleBarStripTitle: '下移動距離',
+2
View File
@@ -177,6 +177,8 @@ export const ko: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: '채팅 영역 파일을 사이드바에서 열기',
settingsOpenPathDesc: '채팅에서 파일 링크(도구 행, 산출물 목록, 파일 언급)를 클릭하면 사이드바 편집기에서 열고, 시스템 기본 앱은 호출하지 않습니다',
settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입',
settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)',
settingsTitleBarTitle: '위치 호환 모드',
settingsTitleBarDesc: '상단 바 호환 방식을 선택하세요: 자동 감지(기본, 보수적) / DSH 공식 Web / 알려진 데스크톱 셸 / 사용자 지정 방식(하강 거리 + 사용자 지정 CSS)',
settingsTitleBarStripTitle: '하강 거리',
+2
View File
@@ -183,6 +183,8 @@ export const nl: Record<string, string> = {
settingsWidthSuffix: '%',
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',
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',
settingsTitleBarDesc: 'Kies het titelbalk-compatibiliteitsschema: auto-detect (standaard, conservatief) / DSH officiële web / bekende desktop-shells / aangepast (verschuivingsafstand + aangepaste CSS)',
settingsTitleBarStripTitle: 'Verschuivingsafstand',
+2
View File
@@ -187,6 +187,8 @@ export const pl: Record<string, string> = {
settingsWidthSuffix: '%',
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',
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',
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',
+2
View File
@@ -168,6 +168,8 @@ export const pt: Record<string, string> = {
settingsWidthSuffix: '%',
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',
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',
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',
+2
View File
@@ -182,6 +182,8 @@ export const ru: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'Открывать файлы чата в боковой панели',
settingsOpenPathDesc: 'При щелчке по ссылке на файл в чате (строки инструментов, списки результатов, упоминания) файл открывается в редакторе боковой панели, а не в системном приложении по умолчанию',
settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели',
settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)',
settingsTitleBarTitle: 'Режим совместимости заголовка',
settingsTitleBarDesc: 'Выберите схему совместимости заголовка: автоопределение (по умолчанию, консервативно) / официальный Web DSH / известные десктоп-оболочки / своя схема (смещение + пользовательский CSS)',
settingsTitleBarStripTitle: 'Величина смещения',
+2
View File
@@ -168,6 +168,8 @@ export const sv: Record<string, string> = {
settingsWidthSuffix: '%',
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',
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',
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',
+2
View File
@@ -185,6 +185,8 @@ export const th: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: 'เปิดไฟล์แชทในแถบด้านข้าง',
settingsOpenPathDesc: 'เปิดลิงก์ไฟล์ในแชท (แถวเครื่องมือ, ไฟล์ที่สร้าง, การกล่าวถึง) ในตัวแก้ไขแถบด้านข้างแทนแอปเริ่มต้นของระบบ',
settingsOpenToolsTitle: 'ฉีดเครื่องมือเปิดแถบด้านข้างสำหรับโมเดล',
settingsOpenToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถเปิดไฟล์ โฟลเดอร์ และหน้า HTTP(S) ในแถบด้านข้างผ่านเครื่องมือ sidebar_open (ปิดเป็นค่าเริ่มต้น)',
settingsTitleBarTitle: 'โหมดความเข้ากันได้ของตำแหน่ง',
settingsTitleBarDesc: 'เลือกโครงร่างความเข้ากันได้ของไตเติลบาร์: ตรวจจับอัตโนมัติ (ค่าเริ่มต้น, อนุรักษ์นิยม) / DSH เว็บอย่างเป็นทางการ / เชลล์เดสก์ท็อปที่รู้จัก / กำหนดเอง (ระยะการเลื่อน + CSS ที่กำหนดเอง)',
settingsTitleBarStripTitle: 'ระยะการเลื่อน',
+2
View File
@@ -185,6 +185,8 @@ export const tr: Record<string, string> = {
settingsWidthSuffix: '%',
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ç',
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',
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',
+2
View File
@@ -185,6 +185,8 @@ export const vi: Record<string, string> = {
settingsWidthSuffix: '%',
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',
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 đề',
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',
+2
View File
@@ -200,6 +200,8 @@ export const zhHK: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
settingsTitleBarStripTitle: '下移距離',
+2
View File
@@ -200,6 +200,8 @@ export const zhMO: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
settingsTitleBarStripTitle: '下移距離',
+2
View File
@@ -200,6 +200,8 @@ export const zhTW: Record<string, string> = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天區檔案在側邊欄開啟',
settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式',
settingsOpenToolsTitle: '為模型注入側邊欄開啟工具',
settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)',
settingsTitleBarTitle: '位置相容模式',
settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)',
settingsTitleBarStripTitle: '下移距離',
+4
View File
@@ -192,6 +192,8 @@ export const zh = {
settingsWidthSuffix: '%',
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
settingsOpenToolsTitle: '为模型注入侧边栏打开工具',
settingsOpenToolsDesc: '开启后,模型可通过 sidebar_open 工具在侧边栏主动打开文件、文件夹和 HTTP(S) 网页(默认关闭)',
settingsTitleBarTitle: '位置兼容模式',
settingsTitleBarDesc: '选择顶栏兼容方案:自动检测(默认,保守)/ DSH官方Web / 已知桌面壳 / 自定义方案(下移距离 + 自定义 CSS)',
settingsTitleBarStripTitle: '下移距离',
@@ -526,6 +528,8 @@ export const en: Record<keyof typeof zh, string> = {
settingsWidthSuffix: '%',
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',
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',
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',
+3
View File
@@ -58,6 +58,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
agentOpenTools: typeof record.agentOpenTools === 'boolean'
? record.agentOpenTools
: SIDEBAR_PREFS_DEFAULTS.agentOpenTools,
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
? record.bottomPanelAutoTerminal
: SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal,
+2 -2
View File
@@ -117,8 +117,8 @@ export interface SidebarSettingsDeclaration {
* Extra settings rows rendered under the feature's own row in the
* settings page (only while the feature is enabled). Keys must be fields
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
* 'agentTerminalTools', 'terminalFontFamily'); unknown keys are dropped
* by the settings seam.
* 'agentTerminalTools', 'agentOpenTools', 'terminalFontFamily'); unknown
* keys are dropped by the settings seam.
*/
toggles?: readonly SidebarSettingToggle[]
/**
+1
View File
@@ -122,6 +122,7 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
autoOpenSubagent: z.boolean().default(true),
autoOpenJobs: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
agentOpenTools: z.boolean().default(false),
bottomPanelAutoTerminal: z.boolean().default(true),
terminalFontFamily: z.string().default(''),
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
+95 -1
View File
@@ -23,6 +23,7 @@ import {
Config,
PrefsSchema,
resolveSidebarConfig,
SIDEBAR_PREFS_DEFAULTS,
SIDEBAR_PREFS_NS,
type ResolvedSidebarConfig,
type SidebarConfig,
@@ -48,6 +49,7 @@ import {
PTY_DEPS_MISSING,
} from './pty-deps.ts'
import { registerTools } from './tools.ts'
import { AgentOpenRegistry, registerOpenTool, type AgentOpenRequest } from './agent-opens.ts'
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts'
import { buildSidechatApi } from './sidechat-routes.ts'
@@ -588,6 +590,11 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
const agentPtyRegistry = nodePty !== null
? new AgentPtyRegistry(terminalShell, resolved.shellArgs, nodePty)
: 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 ──────────────────────────────────
// 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
// tools and releases the agent terminals they created.
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 => {
if (scope.get().agentTerminalTools) {
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,
// and keep them in sync with every settings commit.
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 ────────────────────────────────────────────────────────────
@@ -890,15 +932,67 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
},
}), '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(() => () => {
toolsDisposers?.()
openToolsDisposers?.()
ptyManager?.disposeAll()
agentPtyRegistry?.disposeAll()
agentOpenRegistry.dispose()
wss.close()
agentListWss.close()
agentOpenWss.close()
}, '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. */
async function attachAgentList(
registry: AgentPtyRegistry | null,
+9
View File
@@ -33,6 +33,14 @@ export interface SidebarPrefs {
* user explicitly enables it in the side card settings.
*/
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.
* `'JetBrains Mono', monospace`). Empty string follows the app's theme
@@ -229,6 +237,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
agentOpenTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
+232
View File
@@ -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/)
})
})
+27
View File
@@ -293,4 +293,31 @@ describe('EditorHost (files window)', () => {
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()
}
})
})
+3 -1
View File
@@ -65,6 +65,8 @@ describe('dsh-better-sidebar plugin export shape', () => {
// The terminal tools default OFF (the feature is dormant until the user
// enables it in the side card settings).
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)
// and 13px.
expect(resolved.terminalFontFamily).toBe('')
@@ -92,6 +94,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ 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
View File
@@ -37,7 +37,7 @@ describe('side card preferences', () => {
defaultWidthPercent: 60,
autoOpenSubagent: false,
autoOpenJobs: true,
agentTerminalTools: true,
agentTerminalTools: true, agentOpenTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
@@ -69,7 +69,7 @@ describe('side card preferences', () => {
defaultWidthPercent: 33,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
agentTerminalTools: false, agentOpenTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
@@ -101,7 +101,7 @@ describe('side card preferences', () => {
defaultWidthPercent: 40,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
agentTerminalTools: false, agentOpenTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
@@ -133,6 +133,13 @@ describe('side card preferences', () => {
.toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
.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.
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs)
.toBe(true)
@@ -260,9 +267,9 @@ describe('side card preferences', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// 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')
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()
expect(snapshot.sessionId).toBe('fresh-session')
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', () => {
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')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -308,7 +315,7 @@ describe('side card preferences', () => {
// editorExplorer modes.
for (const editorExplorer of [true, false]) {
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}`)
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
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', () => {
for (const editorExplorer of [true, false]) {
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}`)
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(tabs).toHaveLength(1)
+4 -4
View File
@@ -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).not.toContain('<select')
expect(html).toContain('>Auto-detect<')
// Two general-row switches remain (openByDefault + interceptOpenPath),
// only interceptOpenPath checked by default — the scheme row is a
// dropdown, not a switch.
expect(html.match(/type="checkbox"/g)?.length).toBe(2)
// Three general-row switches remain (openByDefault + interceptOpenPath
// + agentOpenTools), only interceptOpenPath checked by default — the
// scheme row is a dropdown, not a switch.
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
expect(html.match(/checked=""/g)?.length).toBe(1)
// Auto (default) needs no further settings → no gear.
expect(html).not.toContain('Position compatibility mode Feature settings')
+56 -3
View File
@@ -110,7 +110,7 @@ describe('host plugin smoke', () => {
'/sidebar/file',
'/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).
for (const cleanup of effects) cleanup()
})
@@ -839,7 +839,7 @@ describe('side card settings routes', () => {
defaultWidthPercent: 35,
autoOpenSubagent: true,
autoOpenJobs: true,
agentTerminalTools: false,
agentTerminalTools: false, agentOpenTools: false,
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
@@ -1015,6 +1015,59 @@ describe('agent terminal tool gating', () => {
expect(live()).toBe(8)
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)
})
})