From 68640101e16c8a97f0dd6b7e46bf87178c1df5fe Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sun, 23 Aug 2026 22:59:34 +0800 Subject: [PATCH 1/3] feat: sidebar_open tool gates model-driven sidebar opens behind a global setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 5 +- README.md | 1 + README_EN.md | 1 + .../2026-08-23-agent-open-tools-design.md | 96 +++++++ src/agent-opens.ts | 262 ++++++++++++++++++ src/client/EditorHost.tsx | 23 +- src/client/SideCardSection.tsx | 11 + src/client/Sidebar.tsx | 72 +++++ src/client/locales-ar.ts | 2 + src/client/locales-de.ts | 2 + src/client/locales-fr.ts | 2 + src/client/locales-hi.ts | 2 + src/client/locales-id.ts | 2 + src/client/locales-it.ts | 2 + src/client/locales-ja.ts | 2 + src/client/locales-ko.ts | 2 + src/client/locales-nl.ts | 2 + src/client/locales-pl.ts | 2 + src/client/locales-pt.ts | 2 + src/client/locales-ru.ts | 2 + src/client/locales-sv.ts | 2 + src/client/locales-th.ts | 2 + src/client/locales-tr.ts | 2 + src/client/locales-vi.ts | 2 + src/client/locales-zh-HK.ts | 2 + src/client/locales-zh-MO.ts | 2 + src/client/locales-zh-TW.ts | 2 + src/client/locales.ts | 4 + src/client/prefs.ts | 3 + src/client/service.ts | 4 +- src/config.ts | 1 + src/index.ts | 96 ++++++- src/prefs-shared.ts | 9 + tests/agent-opens.spec.ts | 232 ++++++++++++++++ tests/editor-host.spec.tsx | 27 ++ tests/plugin-shape.spec.ts | 4 +- tests/prefs.spec.ts | 23 +- tests/side-card-section.spec.tsx | 8 +- tests/smoke.spec.ts | 59 +++- 39 files changed, 952 insertions(+), 27 deletions(-) create mode 100644 docs/plans/2026-08-23-agent-open-tools-design.md create mode 100644 src/agent-opens.ts create mode 100644 tests/agent-opens.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 9498adf..4c0bae0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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[]`(开放 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[]`(开放 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 的真实用法。 diff --git a/README.md b/README.md index 4e739c8..d89090b 100644 --- a/README.md +++ b/README.md @@ -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 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话 diff --git a/README_EN.md b/README_EN.md index cf78a79..9b641d9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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 diff --git a/docs/plans/2026-08-23-agent-open-tools-design.md b/docs/plans/2026-08-23-agent-open-tools-design.md new file mode 100644 index 0000000..405659b --- /dev/null +++ b/docs/plans/2026-08-23-agent-open-tools-design.md @@ -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: , id: 'editor:'+, 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 集相等(自动覆盖新键)。 diff --git a/src/agent-opens.ts b/src/agent-opens.ts new file mode 100644 index 0000000..fdaf737 --- /dev/null +++ b/src/agent-opens.ts @@ -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() + private subscribers = new Map>() + + /** 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 { + 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(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 } + }, + })) +} diff --git a/src/client/EditorHost.tsx b/src/client/EditorHost.tsx index a732a9f..edbb95d 100644 --- a/src/client/EditorHost.tsx +++ b/src/client/EditorHost.tsx @@ -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({ 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 (
{ applyPref({ interceptOpenPath: next }) }} />
+
+ + {t('settingsOpenToolsTitle')} + {t('settingsOpenToolsDesc')} + + { applyPref({ agentOpenTools: next }) }} + /> +
{t('settingsTitleBarTitle')} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 8369648..86e864d 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -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 diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index afa14fa..d92dc96 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -186,6 +186,8 @@ export const ar: Record = { settingsWidthSuffix: '%', settingsOpenPathTitle: 'فتح ملفات المحادثة في الشريط الجانبي', settingsOpenPathDesc: 'فتح روابط الملفات في المحادثة (صفوف الأدوات، الملفات الناتجة، الإشارات) في محرّر الشريط الجانبي بدلاً من التطبيق الافتراضي للنظام', + settingsOpenToolsTitle: 'حقن أداة فتح الشريط الجانبي للنموذج', + settingsOpenToolsDesc: 'عند التفعيل، يمكن للنموذج فتح الملفات والمجلدات وصفحات HTTP(S) في الشريط الجانبي عبر أداة sidebar_open (معطّل افتراضياً)', settingsTitleBarTitle: 'وضع توافق الموضع', settingsTitleBarDesc: 'اختر نظام توافق الشريط العلوي: كشف تلقائي (افتراضي، محافظ) / ويب DSH الرسمي / أغلفة سطح المكتب المعروفة / مخصّص (مسافة الإزاحة + CSS مخصّص)', settingsTitleBarStripTitle: 'مسافة الإزاحة', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index c533193..694552a 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -171,6 +171,8 @@ export const de: Record = { 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', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index 7c708dd..33f6024 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -178,6 +178,8 @@ export const fr: Record = { 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', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index 65728fb..f4dae4d 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -185,6 +185,8 @@ export const hi: Record = { settingsWidthSuffix: '%', settingsOpenPathTitle: 'चैट फ़ाइलें साइडबार में खोलें', settingsOpenPathDesc: 'चैट में फ़ाइल लिंक (टूल पंक्ति, उत्पादित फ़ाइलें, उल्लेख) क्लिक करने पर सिस्टम डिफ़ॉल्ट ऐप के बजाय साइडबार एडिटर में खोलें', + settingsOpenToolsTitle: 'मॉडल के लिए साइडबार ओपन टूल इंजेक्ट करें', + settingsOpenToolsDesc: 'चालू होने पर, मॉडल sidebar_open टूल से साइडबार में फ़ाइलें, फ़ोल्डर और HTTP(S) पेज खोल सकता है (डिफ़ॉल्ट रूप से बंद)', settingsTitleBarTitle: 'स्थिति संगतता मोड', settingsTitleBarDesc: 'टाइटल-बार संगतता योजना चुनें: ऑटो-डिटेक्ट (डिफ़ॉल्ट, रूढ़िवादी) / DSH आधिकारिक वेब / ज्ञात डेस्कटॉप शेल / कस्टम (शिफ्ट दूरी + कस्टम CSS)', settingsTitleBarStripTitle: 'शिफ्ट दूरी', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index e142582..464f817 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -183,6 +183,8 @@ export const id: Record = { 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', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index cd63c18..9f5a389 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -176,6 +176,8 @@ export const it: Record = { 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', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index 6728677..c1640d6 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -185,6 +185,8 @@ export const ja: Record = { settingsWidthSuffix: '%', settingsOpenPathTitle: 'チャット内ファイルをサイドバーで開く', settingsOpenPathDesc: 'チャット内のファイルリンク(ツール行、産物リスト、ファイル言及)クリック時に、システム既定アプリではなくサイドバーエディターで開く', + settingsOpenToolsTitle: 'モデルにサイドバー開くツールを注入', + settingsOpenToolsDesc: 'オンにすると、モデルは sidebar_open ツールでサイドバーにファイル・フォルダー・HTTP(S) ページを開ける(デフォルトオフ)', settingsTitleBarTitle: '位置互換モード', settingsTitleBarDesc: 'タイトルバー互換スキームを選択:自動検出(デフォルト、控えめ)/ DSH 公式 Web / 既知のデスクトップシェル / カスタムスキーム(下移動距離 + カスタム CSS)', settingsTitleBarStripTitle: '下移動距離', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index e1f4eca..6cae9d4 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -177,6 +177,8 @@ export const ko: Record = { settingsWidthSuffix: '%', settingsOpenPathTitle: '채팅 영역 파일을 사이드바에서 열기', settingsOpenPathDesc: '채팅에서 파일 링크(도구 행, 산출물 목록, 파일 언급)를 클릭하면 사이드바 편집기에서 열고, 시스템 기본 앱은 호출하지 않습니다', + settingsOpenToolsTitle: '모델에 사이드바 열기 도구 주입', + settingsOpenToolsDesc: '켜면 모델이 sidebar_open 도구로 사이드바에서 파일·폴더·HTTP(S) 페이지를 열 수 있습니다(기본 꺼짐)', settingsTitleBarTitle: '위치 호환 모드', settingsTitleBarDesc: '상단 바 호환 방식을 선택하세요: 자동 감지(기본, 보수적) / DSH 공식 Web / 알려진 데스크톱 셸 / 사용자 지정 방식(하강 거리 + 사용자 지정 CSS)', settingsTitleBarStripTitle: '하강 거리', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index bc357ed..0b330d8 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -183,6 +183,8 @@ export const nl: Record = { 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', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index abb087d..2384fbe 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -187,6 +187,8 @@ export const pl: Record = { 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', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 67b866c..a8a7cdf 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -168,6 +168,8 @@ export const pt: Record = { 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', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 2020b2e..1f79078 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -182,6 +182,8 @@ export const ru: Record = { settingsWidthSuffix: '%', settingsOpenPathTitle: 'Открывать файлы чата в боковой панели', settingsOpenPathDesc: 'При щелчке по ссылке на файл в чате (строки инструментов, списки результатов, упоминания) файл открывается в редакторе боковой панели, а не в системном приложении по умолчанию', + settingsOpenToolsTitle: 'Внедрить инструмент открытия в боковой панели для модели', + settingsOpenToolsDesc: 'Если включено, модель может открывать файлы, папки и HTTP(S)-страницы в боковой панели с помощью инструмента sidebar_open (по умолчанию выключено)', settingsTitleBarTitle: 'Режим совместимости заголовка', settingsTitleBarDesc: 'Выберите схему совместимости заголовка: автоопределение (по умолчанию, консервативно) / официальный Web DSH / известные десктоп-оболочки / своя схема (смещение + пользовательский CSS)', settingsTitleBarStripTitle: 'Величина смещения', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index cd5d6fa..6588fe6 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -168,6 +168,8 @@ export const sv: Record = { 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', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index f8c76cb..4024ec9 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -185,6 +185,8 @@ export const th: Record = { settingsWidthSuffix: '%', settingsOpenPathTitle: 'เปิดไฟล์แชทในแถบด้านข้าง', settingsOpenPathDesc: 'เปิดลิงก์ไฟล์ในแชท (แถวเครื่องมือ, ไฟล์ที่สร้าง, การกล่าวถึง) ในตัวแก้ไขแถบด้านข้างแทนแอปเริ่มต้นของระบบ', + settingsOpenToolsTitle: 'ฉีดเครื่องมือเปิดแถบด้านข้างสำหรับโมเดล', + settingsOpenToolsDesc: 'เมื่อเปิดใช้ โมเดลสามารถเปิดไฟล์ โฟลเดอร์ และหน้า HTTP(S) ในแถบด้านข้างผ่านเครื่องมือ sidebar_open (ปิดเป็นค่าเริ่มต้น)', settingsTitleBarTitle: 'โหมดความเข้ากันได้ของตำแหน่ง', settingsTitleBarDesc: 'เลือกโครงร่างความเข้ากันได้ของไตเติลบาร์: ตรวจจับอัตโนมัติ (ค่าเริ่มต้น, อนุรักษ์นิยม) / DSH เว็บอย่างเป็นทางการ / เชลล์เดสก์ท็อปที่รู้จัก / กำหนดเอง (ระยะการเลื่อน + CSS ที่กำหนดเอง)', settingsTitleBarStripTitle: 'ระยะการเลื่อน', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index 866dce5..498fc2d 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -185,6 +185,8 @@ export const tr: Record = { 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', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 588ec72..eb2595a 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -185,6 +185,8 @@ export const vi: Record = { 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', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 1e89d83..41f8dd4 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -200,6 +200,8 @@ export const zhHK: Record = { settingsWidthSuffix: '%', settingsOpenPathTitle: '聊天區檔案在側邊欄開啟', settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式', + settingsOpenToolsTitle: '為模型注入側邊欄開啟工具', + settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)', settingsTitleBarTitle: '位置相容模式', settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)', settingsTitleBarStripTitle: '下移距離', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index c08336e..49ab98d 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -200,6 +200,8 @@ export const zhMO: Record = { settingsWidthSuffix: '%', settingsOpenPathTitle: '聊天區檔案在側邊欄開啟', settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式', + settingsOpenToolsTitle: '為模型注入側邊欄開啟工具', + settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)', settingsTitleBarTitle: '位置相容模式', settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)', settingsTitleBarStripTitle: '下移距離', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 8c76385..fc8fc79 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -200,6 +200,8 @@ export const zhTW: Record = { settingsWidthSuffix: '%', settingsOpenPathTitle: '聊天區檔案在側邊欄開啟', settingsOpenPathDesc: '在聊天裡點擊檔案連結(工具行、產物列表、檔案提及)時,在側邊欄編輯器中開啟,不再呼叫系統預設應用程式', + settingsOpenToolsTitle: '為模型注入側邊欄開啟工具', + settingsOpenToolsDesc: '開啟後,模型可透過 sidebar_open 工具在側邊欄主動開啟檔案、資料夾和 HTTP(S) 網頁(預設關閉)', settingsTitleBarTitle: '位置相容模式', settingsTitleBarDesc: '選擇頂欄相容方案:自動偵測(預設,保守)/ DSH官方Web / 已知桌面殼 / 自訂方案(下移距離 + 自訂 CSS)', settingsTitleBarStripTitle: '下移距離', diff --git a/src/client/locales.ts b/src/client/locales.ts index 1eb8b34..37c2430 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -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 = { 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', diff --git a/src/client/prefs.ts b/src/client/prefs.ts index 02f33a2..fa4d4b1 100644 --- a/src/client/prefs.ts +++ b/src/client/prefs.ts @@ -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, diff --git a/src/client/service.ts b/src/client/service.ts index ff69670..6d29154 100644 --- a/src/client/service.ts +++ b/src/client/service.ts @@ -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[] /** diff --git a/src/config.ts b/src/config.ts index 5f4cae0..160c08c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -122,6 +122,7 @@ export const PrefsSchema: z = 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), diff --git a/src/index.ts b/src/index.ts index f0c490a..d42c167 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { + 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, diff --git a/src/prefs-shared.ts b/src/prefs-shared.ts index 027998d..f7e2579 100644 --- a/src/prefs-shared.ts +++ b/src/prefs-shared.ts @@ -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, diff --git a/tests/agent-opens.spec.ts b/tests/agent-opens.spec.ts new file mode 100644 index 0000000..1d31365 --- /dev/null +++ b/tests/agent-opens.spec.ts @@ -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; 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/) + }) +}) diff --git a/tests/editor-host.spec.tsx b/tests/editor-host.spec.tsx index 34bdf7f..2a2775e 100644 --- a/tests/editor-host.spec.tsx +++ b/tests/editor-host.spec.tsx @@ -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() + } + }) }) diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts index 56f7270..992c573 100644 --- a/tests/plugin-shape.spec.ts +++ b/tests/plugin-shape.spec.ts @@ -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 | undefined): Record })({ 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: {} }) }) }) diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index d1c1920..647b513 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -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) diff --git a/tests/side-card-section.spec.tsx b/tests/side-card-section.spec.tsx index 07cc983..3e55bc3 100644 --- a/tests/side-card-section.spec.tsx +++ b/tests/side-card-section.spec.tsx @@ -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('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') diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 5cabab8..6eef4d4 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -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) + }) }) From 7f634866bfcdc90ba2d213b275cb99dcf47058a7 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 24 Aug 2026 12:05:14 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(editor):=20Markdown=20=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E5=86=85=E5=B5=8C=20HTML=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=20+=20=E7=9B=AE=E5=BD=95=E5=A4=A7=E7=BA=B2?= =?UTF-8?q?=EF=BC=88TOC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 宿主 MarkdownText 出于聊天安全把 raw HTML 按字面文本渲染,预览 GitHub 风格 README(徽章墙 div、details 折叠块内嵌 markdown、表格单元格内联 标签、video/picture)时全是源码文本。本次在不动渲染器的前提下: - markdown-html.ts(纯函数):fence 感知切分器把文档切成 markdown/html 段(CommonMark type-6 块级标签名单 + 注释段 + 空行终止);段内标签 tokenizer 归约 open/close/html parts;全文档引用定义收集(每段追加 保持 [text][id] 跨段语义) - MarkdownHtml.tsx:DOMPurify 白名单消毒(显式禁 script/iframe/form 等;a 强制 _blank+noopener;本地媒体 src 经 /sidebar/file 重写); MarkdownDocument 帧栈状态机让
跨段嵌套内部 markdown; MarkdownSegment 内联替换(MutationObserver 幂等 pass,把表格单元格 等处标签文本消毒还原为元素,纯散文不误伤) - md-toc.tsx:零高度 sticky 浮动目录按钮(≥3 标题)+ 弹出大纲,跳转 平滑滚动 + 自动展开折叠 details + 高亮闪现。关键修复:子组件读父 ref 在 React layout 阶段恒为 null(子先于父),改为经自身 bar ref 的 parentElement 定位容器(jsdom 单测传预填 ref 掩盖了该 bug,仅 真实挂载 e2e 抓到) - 纯 markdown 文档走原单次解析路径零变化(现有 mermaid e2e 守卫一字 不动);新增 readme-style.md 种子 + 探针(消毒渲染/嵌套/内联/TOC 全链路);19 语言词典新增 toc 键;dompurify 提升为直接依赖(与 mermaid 传递依赖去重单版本) 设计文档 docs/plans/2026-08-24-markdown-html-toc-design.md --- AGENTS.md | 3 +- README.md | 4 +- .../2026-08-24-markdown-html-toc-design.md | 52 +++ package.json | 1 + pnpm-lock.yaml | 3 + src/client/MarkdownHtml.tsx | 295 ++++++++++++++++ src/client/TextEditor.tsx | 63 ++-- src/client/locales-ar.ts | 1 + src/client/locales-de.ts | 1 + src/client/locales-fr.ts | 1 + src/client/locales-hi.ts | 1 + src/client/locales-id.ts | 1 + src/client/locales-it.ts | 1 + src/client/locales-ja.ts | 1 + src/client/locales-ko.ts | 1 + src/client/locales-nl.ts | 1 + src/client/locales-pl.ts | 1 + src/client/locales-pt.ts | 1 + src/client/locales-ru.ts | 1 + src/client/locales-sv.ts | 1 + src/client/locales-th.ts | 1 + src/client/locales-tr.ts | 1 + src/client/locales-vi.ts | 1 + src/client/locales-zh-HK.ts | 1 + src/client/locales-zh-MO.ts | 1 + src/client/locales-zh-TW.ts | 1 + src/client/locales.ts | 2 + src/client/markdown-html.ts | 331 ++++++++++++++++++ src/client/markdown-images.ts | 45 ++- src/client/md-toc.tsx | 127 +++++++ src/client/mermaid-blocks.ts | 6 +- src/client/sidebar.module.css | 132 +++++++ tests/e2e/mount.e2e.ts | 109 ++++++ tests/markdown-html-render.spec.tsx | 176 ++++++++++ tests/markdown-html.spec.ts | 196 +++++++++++ tests/md-toc.spec.tsx | 152 ++++++++ 36 files changed, 1673 insertions(+), 43 deletions(-) create mode 100644 docs/plans/2026-08-24-markdown-html-toc-design.md create mode 100644 src/client/MarkdownHtml.tsx create mode 100644 src/client/markdown-html.ts create mode 100644 src/client/md-toc.tsx create mode 100644 tests/markdown-html-render.spec.tsx create mode 100644 tests/markdown-html.spec.ts create mode 100644 tests/md-toc.spec.tsx diff --git a/AGENTS.md b/AGENTS.md index 9498adf..db06d1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -437,7 +437,7 @@ interface FileViewerProps { > **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。 -> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。 +> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead,**README 级内嵌 HTML 支持**——块级 HTML 段按文档顺序 DOMPurify 白名单消毒内联渲染、`
` 跨段嵌套内部 markdown、markdown 表格单元格等处的内联标签消毒还原为元素、本地媒体 src 重写走 `/sidebar/file`;预览顶部浮动目录大纲按钮(≥3 标题出现,点击条目平滑滚动并自动展开折叠 `
`;纯 markdown 文档走原单次解析路径零变化。实现 `src/client/markdown-html.ts` / `MarkdownHtml.tsx` / `md-toc.tsx`,设计文档 `docs/plans/2026-08-24-markdown-html-toc-design.md`) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。 > code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。 ### 4.5 注册示例 @@ -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/client/markdown-html.ts`** / **`src/client/MarkdownHtml.tsx`** / **`src/client/md-toc.tsx`**:markdown 预览的内嵌 HTML 渲染(fence 感知切分器 + DOMPurify 消毒管线 + 跨段 `
` 嵌套状态机 + 内联标签 commit 后还原 + MutationObserver 迟到内容)与目录大纲(DOM 收集 + sticky 浮动按钮 + 跳转展开折叠块;注意 `md-toc.tsx` 头注释记录的「子组件读父 ref 为 null」React 时序陷阱)。测试 `tests/markdown-html.spec.ts` / `tests/markdown-html-render.spec.tsx` / `tests/md-toc.spec.tsx`,设计文档 `docs/plans/2026-08-24-markdown-html-toc-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 的真实用法。 diff --git a/README.md b/README.md index 4e739c8..127d74f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ ## ✨ 功能一览 -- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF +- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `
` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整) - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原 @@ -155,7 +155,7 @@ dsh registry enable dsh-external/dsh-better-sidebar | | | |---|---| -| **🗂️ 文件工作台:资源管理器**
支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。
文件资源管理器
| **📝 Markdown · 图片 · PDF 内联预览**
Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。
Markdown + Mermaid 预览
| +| **🗂️ 文件工作台:资源管理器**
支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。
文件资源管理器
| **📝 Markdown · 图片 · PDF 内联预览**
Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移)、**README 级内嵌 HTML**(徽章墙 `
`、`
` 折叠块内嵌 markdown、表格单元格内联标签——DOMPurify 白名单消毒真实渲染,`', + '
', + '', + '## Setup', + '', + '[docs link][def]', + '', + '
', + 'Steps', + '', + '### Inside', + '', + '```sh', + 'dsh plugin --profile web add x', + '```', + '', + '
', + '', + '## Table', + '', + '| col | note |', + '| --- | --- |', + '| alpha | line one
line two |', + '', + '[def]: https://example.com/def', + '', + ].join('\n')) const workspace = await api.post(`${BASE_URL}/api/workspace.create`, { data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } }, }) @@ -433,6 +473,75 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async ).toBeHidden() await assertNoCrash() + // README-style markdown (raw-HTML runs + TOC): open the seeded file and + // require the full round-trip — sanitized HTML leaves (badge image as a + // real element, active content stripped), markdown nested inside the + // unclosed
run, the inline pass turning the table cell's
+ // into an element, the reference link resolving across the HTML run, and + // the TOC outline jumping into the collapsed details (auto-expanding it). + await sidebar.locator('[title="Files"][draggable="true"]').first().click() + const readmeRow = sidebar.locator(`[role="button"][title$="${SEEDED_README_FILE}"]:visible`) + await expect( + readmeRow, + `the seeded "${SEEDED_README_FILE}" file must appear in the files window's tree`, + ).toHaveCount(1, { timeout: 30_000 }) + await readmeRow.click({ position: { x: 8, y: 8 } }) + await expect( + sidebar.locator(`[title="${SEEDED_README_FILE}"][draggable="true"]`), + 'separate mode opens a new tab for the README-style markdown file', + ).toHaveCount(1, { timeout: 30_000 }) + // The hero div renders as a sanitized leaf with the badge image as a real + // element (the remote src may not load in the sandboxed lane; the ELEMENT + // is the proof of rendering, not the bytes). + await expect( + sidebar.locator('[data-dsh-html-segment] img[src*="img.shields.io"]'), + 'the badge-wall div must render its image as a real element', + ).toHaveCount(1, { timeout: 30_000 }) + // Active content never survives sanitization. + await expect( + sidebar.locator('script'), + 'the embedded ', + ' link', + '
', + ].join('\n')) + expect(container.querySelector('script')).toBeNull() + const anchor = container.querySelector('a[href="https://example.com"]') + expect(anchor).not.toBeNull() + expect(anchor?.getAttribute('target')).toBe('_blank') + expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer') + await unmount(root) + }) + + it('rewrites local media sources through the /sidebar/file route', async () => { + const { container, root } = await renderDocument('shot') + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fshot.png&cwd=%2Fws') + await unmount(root) + }) +}) + +describe('MarkdownDocument (nesting + markdown runs)', () => { + it('nests markdown runs inside an unclosed
element', async () => { + const { container, root } = await renderDocument([ + '
', + '更新', + '', + '```sh', + 'dsh plugin --profile web add x', + '```', + '', + '
', + '', + 'after the fold', + ].join('\n')) + const details = container.querySelector('details') + expect(details, 'the open part must lower following runs into a details element').not.toBeNull() + expect(details?.querySelector('summary')).not.toBeNull() + expect(details?.textContent).toContain('dsh plugin --profile web add x') + // The close part pops the frame: the trailing markdown is a sibling. + expect(details?.textContent).not.toContain('after the fold') + expect(container.querySelector('details')?.nextElementSibling?.textContent).toContain('after the fold') + await unmount(root) + }) + + it('keeps reference definitions resolving across lifted HTML runs', async () => { + const { container, root } = await renderDocument([ + 'see [the docs][shared]', + '', + '
badges
', + '', + '[shared]: https://example.com/docs', + ].join('\n')) + const anchor = container.querySelector('a[href="https://example.com/docs"]') + expect(anchor, 'a definition after an HTML run must still resolve').not.toBeNull() + expect(anchor?.textContent).toContain('the docs') + await unmount(root) + }) + + it('renders a forbidden wrapper as transparent (children survive)', async () => { + const { container, root } = await renderDocument([ + '
', + '', + 'inner text', + '', + '
', + ].join('\n')) + expect(container.querySelector('form')).toBeNull() + expect(container.textContent).toContain('inner text') + await unmount(root) + }) +}) + +describe('MarkdownDocument (inline pass)', () => { + it('turns tag text in table cells back into elements', async () => { + const { container, root } = await renderDocument([ + '| feature | notes |', + '|---|---|', + '| alpha | line one
line two |', + ].join('\n')) + const cell = [...container.querySelectorAll('td')].find((td) => td.textContent?.includes('line one')) + expect(cell?.querySelector('[data-html-inline] br'), 'the
must render as a real element').not.toBeNull() + await unmount(root) + }) + + it('leaves rendered code and comparison prose untouched', async () => { + const { container, root } = await renderDocument([ + 'a < b comparison stays text', + '', + '```html', + '
demo
', + '```', + ].join('\n')) + expect(container.querySelectorAll('[data-html-inline]').length).toBe(0) + expect(container.textContent).toContain('a < b comparison stays text') + const code = container.querySelector('code') + expect(code?.textContent).toContain('
demo
') + await unmount(root) + }) + + it('rewrites local image src inside inline-swapped tags', async () => { + const { container, root } = await renderDocument('text i tail') + const img = container.querySelector('[data-html-inline] img') + expect(img?.getAttribute('src')).toContain('/sidebar/file?') + await unmount(root) + }) +}) diff --git a/tests/markdown-html.spec.ts b/tests/markdown-html.spec.ts new file mode 100644 index 0000000..b00eaa7 --- /dev/null +++ b/tests/markdown-html.spec.ts @@ -0,0 +1,196 @@ +/** + * markdown-html splitter spec: the pure lifting of raw-HTML runs out of the + * markdown stream (fence-aware), the tag-balance part analysis (unclosed + * `
` → wrapper opens, stray closes → pops), the doc-wide reference + * definition collection, and the inline-html gate. + */ +import { describe, expect, it } from 'vitest' +import { + analyzeHtmlSegment, + analyzeMarkdownHtml, + collectReferenceDefinitions, + splitHtmlBlocks, + type HtmlPart, +} from '../src/client/markdown-html.ts' + +/** Structural view of parts: html parts as [html ], opens/closes by kind. */ +function shape(parts: readonly HtmlPart[]): string[] { + return parts.map((part) => part.kind === 'html' + ? `html(${part.html})` + : part.kind === 'open' + ? `open(${part.tag}${part.attrs.trim() !== '' ? ` ${part.attrs.trim()}` : ''})` + : `close(${part.tag})`) +} + +describe('splitHtmlBlocks', () => { + it('returns one markdown segment for plain markdown (no HTML)', () => { + const text = '# Title\n\nsome **markdown**\n\n- a\n- b\n' + expect(splitHtmlBlocks(text)).toEqual([{ kind: 'markdown', text: text.replace(/\n$/, '') }]) + }) + + it('lifts a balanced block-tag run out of the markdown stream', () => { + const segments = splitHtmlBlocks([ + '# Title', + '', + '
', + ' badge', + '
', + '', + 'tail text', + ].join('\n')) + expect(segments).toEqual([ + { kind: 'markdown', text: '# Title' }, + { kind: 'html', text: '
\n badge\n
' }, + { kind: 'markdown', text: 'tail text' }, + ]) + }) + + it('ends an HTML run at a blank line', () => { + const segments = splitHtmlBlocks('

a

\n\nafter') + expect(segments).toEqual([ + { kind: 'html', text: '

a

' }, + { kind: 'markdown', text: 'after' }, + ]) + }) + + it('does not lift tag-like lines inside fenced code blocks', () => { + const segments = splitHtmlBlocks([ + '```html', + '
', + '

example

', + '
', + '```', + '', + '
real
', + ].join('\n')) + expect(segments).toEqual([ + { kind: 'markdown', text: '```html\n
\n

example

\n
\n```' }, + { kind: 'html', text: '
real
' }, + ]) + }) + + it('does not lift inline-only tags or prose starting with a comparison', () => { + const segments = splitHtmlBlocks([ + 'a < b and x inline', + '
alone on its line', + ].join('\n')) + expect(segments).toEqual([ + { kind: 'markdown', text: 'a < b and x inline\n
alone on its line' }, + ]) + }) + + it('treats a close-tag-only line as an HTML run', () => { + const segments = splitHtmlBlocks('text\n\n
\n\nmore') + expect(segments).toEqual([ + { kind: 'markdown', text: 'text' }, + { kind: 'html', text: '
' }, + { kind: 'markdown', text: 'more' }, + ]) + }) + + it('accumulates a comment run until the closing marker line', () => { + const segments = splitHtmlBlocks('\n\nbody') + expect(segments).toEqual([ + { kind: 'html', text: '' }, + { kind: 'markdown', text: 'body' }, + ]) + }) + + it('keeps an unterminated fence as markdown (CommonMark recovery)', () => { + const segments = splitHtmlBlocks('```\n
x
') + expect(segments).toEqual([{ kind: 'markdown', text: '```\n
x
' }]) + }) + + it('keeps the README details shape as three segments', () => { + const segments = splitHtmlBlocks([ + '
', + '更新', + '', + '```sh', + 'dsh plugin --profile web add x', + '```', + '', + '
', + ].join('\n')) + expect(segments.map((segment) => segment.kind)).toEqual(['html', 'markdown', 'html']) + expect(segments[0]).toEqual({ kind: 'html', text: '
\n更新' }) + expect(segments[2]).toEqual({ kind: 'html', text: '
' }) + }) +}) + +describe('analyzeHtmlSegment', () => { + it('reduces a balanced run to a single html part', () => { + expect(shape(analyzeHtmlSegment('
').parts)) + .toEqual(['html(
)']) + }) + + it('surfaces an unclosed open tag as a wrapper with its inner html', () => { + expect(shape(analyzeHtmlSegment('
\n更新').parts)) + .toEqual(['open(details)', 'html(\n更新)']) + }) + + it('surfaces an unmatched close as a pop', () => { + expect(shape(analyzeHtmlSegment('
').parts)).toEqual(['close(details)']) + }) + + it('orders mixed leading closes before following html and closes', () => { + expect(shape(analyzeHtmlSegment(' mid ').parts)) + .toEqual(['close(div)', 'html( mid )', 'close(details)']) + }) + + it('nests multiple unclosed opens outermost-first', () => { + expect(shape(analyzeHtmlSegment('
').parts)) + .toEqual(['open(div align="center")', 'open(details)']) + }) + + it('keeps attrs of the open token for the wrapper', () => { + const parts = analyzeHtmlSegment('
body').parts + expect(parts[0]).toEqual({ kind: 'open', tag: 'details', attrs: ' open class="x"' }) + }) + + it('ignores void and self-closing tags for balance', () => { + expect(shape(analyzeHtmlSegment('
after').parts)) + .toEqual(['html(
after)']) + }) + + it('ignores tags inside comments', () => { + expect(shape(analyzeHtmlSegment('').parts)) + .toEqual(['html()']) + }) + + it('pops implicitly through a mismatched close', () => { + //

is implicitly closed by

: nothing structural remains. + expect(shape(analyzeHtmlSegment('

text

').parts)) + .toEqual(['html(

text

)']) + }) +}) + +describe('collectReferenceDefinitions + analyzeMarkdownHtml', () => { + it('collects definitions from markdown runs only, in document order', () => { + const info = analyzeMarkdownHtml([ + 'intro [link][shared]', + '', + '
badges
', + '', + '[shared]: https://example.com', + '[img]: ./local.png', + ].join('\n')) + expect(info.referenceDefinitions).toBe('[shared]: https://example.com\n[img]: ./local.png') + expect(collectReferenceDefinitions(info.segments)).toBe(info.referenceDefinitions) + }) + + it('reports hasBlockHtml only when a run was lifted', () => { + expect(analyzeMarkdownHtml('# plain\ntext').hasBlockHtml).toBe(false) + expect(analyzeMarkdownHtml('
x
').hasBlockHtml).toBe(true) + // Inline-only tags: no block html, but the inline gate is on. + const inline = analyzeMarkdownHtml('| a | b |\n|---|---|\n| x
y | z |') + expect(inline.hasBlockHtml).toBe(false) + expect(inline.hasInlineHtml).toBe(true) + }) + + it('gates inline html on tag-like source text', () => { + expect(analyzeMarkdownHtml('a < b').hasInlineHtml).toBe(false) + expect(analyzeMarkdownHtml('a bold').hasInlineHtml).toBe(true) + expect(analyzeMarkdownHtml('plain markdown').hasInlineHtml).toBe(false) + }) +}) diff --git a/tests/md-toc.spec.tsx b/tests/md-toc.spec.tsx new file mode 100644 index 0000000..0216ce8 --- /dev/null +++ b/tests/md-toc.spec.tsx @@ -0,0 +1,152 @@ +/** + * MdToc spec: the markdown preview's outline button + popover. Covers the + * minimum-headings gate, collection from the rendered DOM (including headings + * inside HTML `
` runs, which appear late through the observer), + * jump behavior (smooth scroll + flash + auto-expanding collapsed ancestors) + * and Esc-to-close. + */ +// @vitest-environment jsdom +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { MdToc, TOC_MIN_HEADINGS } from '../src/client/md-toc.tsx' + +// The act() environment flag (React 18.2 reads it before flushing effects). +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +const scrollIntoView = vi.fn() +Element.prototype.scrollIntoView = scrollIntoView + +/** The preview-harness shape MdToc mounts in: the bar is a direct child of + * the scroll container, headings are siblings (inside or outside details). */ +interface HeadingSpec { + tag: string + text: string + insideClosedDetails?: boolean +} + +function Harness({ specs }: { specs: readonly HeadingSpec[] }): React.ReactElement { + return createElement( + 'div', + null, + createElement(MdToc), + ...specs.map((spec) => spec.insideClosedDetails === true + ? createElement('details', null, createElement(spec.tag, null, spec.text)) + : createElement(spec.tag, null, spec.text)), + ) +} + +/** Render the harness; returns the scroll container (the bar's parent). */ +async function mountToc(specs: readonly HeadingSpec[]): Promise<{ container: HTMLDivElement; root: Root }> { + const host = document.createElement('div') + document.body.appendChild(host) + const root = createRoot(host) + await act(async () => { + root.render(createElement(Harness, { specs })) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + }) + const container = host.firstElementChild as HTMLDivElement + return { container, root } +} + +async function unmount(root: Root): Promise { + await act(async () => { root.unmount() }) +} + +beforeEach(() => { + document.body.innerHTML = '' + scrollIntoView.mockClear() +}) + +describe('MdToc', () => { + it('exposes the minimum-headings threshold', () => { + expect(TOC_MIN_HEADINGS).toBe(3) + }) + + it('stays hidden below the heading threshold', async () => { + const { container, root } = await mountToc([ + { tag: 'h1', text: 'One' }, + { tag: 'h2', text: 'Two' }, + ]) + expect(container.querySelector('[data-dsh-md-toc]')).toBeNull() + await unmount(root) + }) + + it('shows the button and lists collected headings', async () => { + const { container, root } = await mountToc([ + { tag: 'h1', text: 'Title' }, + { tag: 'h2', text: 'Install' }, + { tag: 'h2', text: 'Usage' }, + { tag: 'h3', text: 'Deep' }, + ]) + const button = container.querySelector('[data-dsh-md-toc]') + expect(button).not.toBeNull() + await act(async () => { button!.click() }) + const panel = container.querySelector('[data-dsh-md-toc-panel]') + expect(panel).not.toBeNull() + const items = [...panel!.querySelectorAll('button')] + expect(items.map((item) => item.textContent)).toEqual(['1Title', '2Install', '2Usage', '3Deep']) + await unmount(root) + }) + + it('jumps: expands collapsed details ancestors, smooth-scrolls, flashes, closes', async () => { + // Mount with real timers (the settle await needs them), then fake only the + // flash-removal timeout window. + const { container, root } = await mountToc([ + { tag: 'h1', text: 'Title' }, + { tag: 'h2', text: 'Outside' }, + { tag: 'h3', text: 'Hidden', insideClosedDetails: true }, + ]) + vi.useFakeTimers() + try { + const button = container.querySelector('[data-dsh-md-toc]')! + await act(async () => { button.click() }) + const target = [...container.querySelectorAll('h3')].find((h) => h.textContent === 'Hidden')! + const item = [...container.querySelectorAll('[data-dsh-md-toc-panel] button')] + .find((candidate) => candidate.textContent?.includes('Hidden'))! + await act(async () => { item.click() }) + expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' }) + expect(target.closest('details')?.hasAttribute('open'), 'the collapsed ancestor must open').toBe(true) + expect(target.className, 'the jumped-to heading must flash').not.toBe('') + expect(container.querySelector('[data-dsh-md-toc-panel]'), 'the panel must close on jump').toBeNull() + vi.advanceTimersByTime(1300) + expect(target.className, 'the flash class must clear after the timeout').toBe('') + await unmount(root) + } finally { + vi.useRealTimers() + } + }) + + it('collects headings that appear after mount (observer path)', async () => { + const { container, root } = await mountToc([ + { tag: 'h1', text: 'Title' }, + { tag: 'h2', text: 'Only two so far' }, + ]) + expect(container.querySelector('[data-dsh-md-toc]')).toBeNull() + const late = document.createElement('h2') + late.textContent = 'Late' + await act(async () => { + container.appendChild(late) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + }) + expect(container.querySelector('[data-dsh-md-toc]'), 'late headings must surface the button').not.toBeNull() + await unmount(root) + }) + + it('closes the popover on Escape', async () => { + const { container, root } = await mountToc([ + { tag: 'h1', text: 'Title' }, + { tag: 'h2', text: 'A' }, + { tag: 'h2', text: 'B' }, + ]) + const button = container.querySelector('[data-dsh-md-toc]')! + await act(async () => { button.click() }) + expect(container.querySelector('[data-dsh-md-toc-panel]')).not.toBeNull() + await act(async () => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + }) + expect(container.querySelector('[data-dsh-md-toc-panel]')).toBeNull() + await unmount(root) + }) +}) From 2b757cfe24a154a746a0b1b5b9810dba7c325081 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 24 Aug 2026 14:18:18 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(test):=20=E6=B5=8B=E8=AF=95=E9=97=B4?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E4=B8=B2=E5=8F=B0=E4=B8=8E=20macOS=20?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E7=9B=AE=E5=BD=95=E8=B7=AF=E5=BE=84=E5=AF=BC?= =?UTF-8?q?=E8=87=B4=E7=9A=84=E7=94=A8=E4=BE=8B=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处既有测试基建问题(在慢 runner 的 CI 上表现为 free-window 用例 间歇性失败,阻塞 PR 合并): - free-window/sidebar-crash/bottom-auto-terminal 三个 spec 共用 session id 's1':store 会按 200ms 防抖把每会话状态持久化到 localStorage['dsh-sidebar:v1:s1'],前一个用例的迟写(drop 点 400,300 → 浮窗 left=205)被下一用例 setSession 恢复,新浮窗断言(期望 317) 被 querySelector 拿到的旧窗口炸掉——用例间隔是否超过 200ms 决定成 败,本地快时常过、CI 慢必挂。修复:挂载 helper 缺省生成唯一 session id(持久化往返用例显式传固定 id),afterEach 在 unstub 之后 清 localStorage 兜底 - git/git-worktree 两个用例在 macOS 上必挂:tmpdir() 返回 /var 符号 链接而 git 报告解析后的 /private/var 前缀,路径比较与 resolveWorktree 的恒等匹配失配(CI 的 Linux 路径规范所以一直绿)。 修复:期望值与传入 git API 的路径统一 realpathSync 规范化 本地全量 vitest 963 通过 0 失败(此前 free-window 间歇 0~2 失败 + git 2 个环境性失败);free-window 单文件连跑 8 次稳定。 --- tests/bottom-auto-terminal.spec.tsx | 16 +++++++++++++--- tests/free-window.spec.tsx | 23 ++++++++++++++++++----- tests/git-worktree.spec.ts | 13 ++++++++----- tests/git.spec.ts | 13 ++++++++----- tests/sidebar-crash.spec.tsx | 16 +++++++++++++--- 5 files changed, 60 insertions(+), 21 deletions(-) diff --git a/tests/bottom-auto-terminal.spec.tsx b/tests/bottom-auto-terminal.spec.tsx index dfba81d..e9d4a5a 100644 --- a/tests/bottom-auto-terminal.spec.tsx +++ b/tests/bottom-auto-terminal.spec.tsx @@ -44,6 +44,9 @@ interface MountedSidebar { } /** Mount the real Sidebar shell against a minimal context (real store + service). */ +/** Unique per-test session ids (see the comment inside). */ +let sessionSeq = 0 + function mountSidebar(): MountedSidebar { vi.stubGlobal('WebSocket', FakeWebSocket) const container = document.createElement('div') @@ -52,14 +55,18 @@ function mountSidebar(): MountedSidebar { const service = createBetterSidebarService(store) // Fresh-session seed: the right panel starts OPEN, the bottom panel closed // (bottomOpen false → the first expansion is a false→true TRANSITION). - store.setSession('s1') + // Unique session per test — the store persists per-session state to + // localStorage (200ms debounce); a shared id lets a previous test's late + // write leak into this store's setSession restore. + const sessionId = `s1-${++sessionSeq}` + store.setSession(sessionId) // useSyncExternalStore requires STABLE snapshots across calls (the real DSH // services return stable objects) — a fresh object per call loops forever. const localeSnapshot = { active: 'en' } const sessionsSnapshot = { - current: 's1', + current: sessionId, // cwd present → api.sessionCwd is never called in these tests. - byId: { s1: { cwd: '/tmp' } }, + byId: { [sessionId]: { cwd: '/tmp' } }, } const ctx = { locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, @@ -81,6 +88,9 @@ function mountSidebar(): MountedSidebar { afterEach(() => { document.body.innerHTML = '' + // Belt and braces: drop any persisted layout a pending 200ms debounce + // write left behind between tests (unique session ids already isolate). + localStorage.clear() vi.unstubAllGlobals() }) diff --git a/tests/free-window.spec.tsx b/tests/free-window.spec.tsx index c90d498..568bfe2 100644 --- a/tests/free-window.spec.tsx +++ b/tests/free-window.spec.tsx @@ -60,7 +60,10 @@ interface Mounted { unmount: () => void } -function mountSidebar(): Mounted { +/** Unique per-test session ids (see the comment inside). */ +let sessionSeq = 0 + +function mountSidebar(sessionId: string = `s1-${++sessionSeq}`): Mounted { vi.stubGlobal('WebSocket', FakeWebSocket) if (!('ResizeObserver' in globalThis)) { vi.stubGlobal('ResizeObserver', class { @@ -83,9 +86,15 @@ function mountSidebar(): Mounted { component: ({ visible }) => createElement('div', { 'data-visible': String(visible) }, 'notes body'), }) store.setPrefs({ ...store.getPrefs(), openByDefault: true }) - store.setSession('s1') + // The default session id is unique per test: the store persists + // per-session state to localStorage on a 200ms debounce, and a shared id + // let a PREVIOUS test's late timer write leak into this store's + // setSession restore (the portaled-menu/resize flakes — a stale float at + // the old drop point won the querySelector race on slow runners). The + // persistence round-trip test passes its own fixed id instead. + store.setSession(sessionId) const localeSnapshot = { active: 'en' } - const sessionsSnapshot = { current: 's1', byId: { s1: { cwd: '/tmp' } } } + const sessionsSnapshot = { current: sessionId, byId: { [sessionId]: { cwd: '/tmp' } } } const ctx = { locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } }, @@ -132,6 +141,10 @@ afterEach(() => { document.body.innerHTML = '' document.documentElement.style.cssText = '' vi.unstubAllGlobals() + // Belt and braces (after unstub, so a test's storage stub never sees it): + // drop any persisted layout a pending 200ms debounce write left behind + // between tests — unique session ids already isolate the stores. + localStorage.clear() }) describe('free windows: drag-out detection', () => { @@ -380,7 +393,7 @@ describe('free windows: the window', () => { const getItem = vi.fn((key: string): string | null => saved.get(key) ?? null) const setItem = vi.fn((key: string, value: string): void => { saved.set(key, value) }) vi.stubGlobal('localStorage', { getItem, setItem, removeItem: () => {} }) - const first = mountSidebar() + const first = mountSidebar('s1') try { act(() => { first.service.openTab({ type: 'notes', title: 'Notes' }) }) act(() => { first.store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) }) @@ -390,7 +403,7 @@ describe('free windows: the window', () => { } finally { first.unmount() } - const second = mountSidebar() + const second = mountSidebar('s1') try { const state = second.store.getSnapshot().state! expect(state.floats).toHaveLength(1) diff --git a/tests/git-worktree.spec.ts b/tests/git-worktree.spec.ts index b92c6fd..2f4aca3 100644 --- a/tests/git-worktree.spec.ts +++ b/tests/git-worktree.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { spawnSync } from 'node:child_process' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { parseWorktreeList, resolveWorktree, status, worktrees } from '../src/git.ts' @@ -67,13 +67,16 @@ describe('linked Git worktrees', () => { git(main, ['commit', '-q', '-m', 'base']) git(main, ['worktree', 'add', '-q', '-b', 'agent', agent]) writeFileSync(join(agent, 'tracked.txt'), 'changed by agent\n') + // macOS tmpdir() keeps the /var symlink while git reports the resolved + // /private/var prefix — canonicalize every path handed to git APIs. + const agentPath = realpathSync(agent) const listed = await worktrees(main) expect(listed).toHaveLength(2) expect(listed.find(entry => entry.current)).toMatchObject({ branch: 'main', changes: 0 }) expect(listed.find(entry => !entry.current)).toMatchObject({ branch: 'agent', changes: 1 }) - expect(resolve(await resolveWorktree(main, agent))).toBe(resolve(agent)) - expect((await status(await resolveWorktree(main, agent))).entries).toEqual([ + expect(resolve(await resolveWorktree(main, agentPath))).toBe(resolve(agentPath)) + expect((await status(await resolveWorktree(main, agentPath))).entries).toEqual([ { path: 'tracked.txt', xy: ' M' }, ]) await expect(resolveWorktree(main, root)).rejects.toThrow('unknown linked worktree') @@ -84,9 +87,9 @@ describe('linked Git worktrees', () => { rmSync(agent, { recursive: true, force: true }) const remaining = await worktrees(main) expect(remaining).toHaveLength(1) - expect(resolve(remaining[0]!.path)).toBe(resolve(main)) + expect(resolve(remaining[0]!.path)).toBe(resolve(realpathSync(main))) expect(remaining[0]!.current).toBe(true) - await expect(resolveWorktree(main, agent)).rejects.toThrow('unknown linked worktree') + await expect(resolveWorktree(main, agentPath)).rejects.toThrow('unknown linked worktree') } finally { try { git(main, ['worktree', 'remove', '--force', agent]) } catch { /* fixture may not be fully initialized */ } rmSync(root, { recursive: true, force: true }) diff --git a/tests/git.spec.ts b/tests/git.spec.ts index 655bde4..7d6e02d 100644 --- a/tests/git.spec.ts +++ b/tests/git.spec.ts @@ -1,5 +1,5 @@ import { execFile, execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,6 +10,9 @@ import { parseLogLines, parsePorcelainZ, repoRoots, status } from '../src/git.ts const execFileAsync = promisify(execFile) const normalizePath = (path: string): string => path.replaceAll('\\', '/') +// macOS tmpdir() is the /var symlink while git reports the resolved +// /private/var prefix — canonicalize both sides before comparing. +const canonical = (path: string): string => normalizePath(realpathSync(path)) describe('git parsing', () => { it('discovers and selects direct child repositories under a workspace directory', async () => { @@ -23,11 +26,11 @@ describe('git parsing', () => { execFileAsync('git', ['-C', second, 'init']), ]) - await expect(repoRoots(workspace)).resolves.toEqual([normalizePath(first), normalizePath(second)]) - await expect(status(workspace, normalizePath(second))).resolves.toMatchObject({ + await expect(repoRoots(workspace)).resolves.toEqual([canonical(first), canonical(second)]) + await expect(status(workspace, canonical(second))).resolves.toMatchObject({ isRepo: true, - root: normalizePath(second), - repositories: [normalizePath(first), normalizePath(second)], + root: canonical(second), + repositories: [canonical(first), canonical(second)], }) } finally { await rm(workspace, { recursive: true, force: true }) diff --git a/tests/sidebar-crash.spec.tsx b/tests/sidebar-crash.spec.tsx index 095ef86..6e47654 100644 --- a/tests/sidebar-crash.spec.tsx +++ b/tests/sidebar-crash.spec.tsx @@ -47,6 +47,9 @@ interface MountedSidebar { } /** Mount the real Sidebar shell against a minimal context (real store + service). */ +/** Unique per-test session ids (see the comment inside). */ +let sessionSeq = 0 + function mountSidebar(): MountedSidebar { vi.stubGlobal('WebSocket', FakeWebSocket) const container = document.createElement('div') @@ -55,14 +58,18 @@ function mountSidebar(): MountedSidebar { const service = createBetterSidebarService(store) // Fresh-session seed: open the panel explicitly (openByDefault defaults off). store.setPrefs({ ...store.getPrefs(), openByDefault: true }) - store.setSession('s1') + // Unique session per test — the store persists per-session state to + // localStorage (200ms debounce); a shared id lets a previous test's late + // write leak into this store's setSession restore. + const sessionId = `s1-${++sessionSeq}` + store.setSession(sessionId) // useSyncExternalStore requires STABLE snapshots across calls (the real DSH // services return stable objects) — a fresh object per call loops forever. const localeSnapshot = { active: 'en' } const sessionsSnapshot = { - current: 's1', + current: sessionId, // cwd present → api.sessionCwd is never called in these tests. - byId: { s1: { cwd: '/tmp' } }, + byId: { [sessionId]: { cwd: '/tmp' } }, } const ctx = { locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, @@ -84,6 +91,9 @@ function mountSidebar(): MountedSidebar { afterEach(() => { document.body.innerHTML = '' + // Belt and braces: drop any persisted layout a pending 200ms debounce + // write left behind between tests (unique session ids already isolate). + localStorage.clear() vi.unstubAllGlobals() })