Merge branch 'main' into fix/markdown-preview-local-images

This commit is contained in:
Lin
2026-08-22 15:37:55 +08:00
committed by GitHub
44 changed files with 6545 additions and 1362 deletions
+3 -3
View File
@@ -37,9 +37,9 @@ body:
attributes:
label: DSH Web 版本 + 浏览器 / DSH Web version + Browser
description: |
例:DSH 0.1.0-rc.8 + Chrome 151(macOS)。
Example: DSH 0.1.0-rc.8 + Chrome 151 (macOS).
placeholder: "DSH 版本 / version + 浏览器 / browser(如 0.1.0-rc.8 + Chrome 151)"
例:DSH 0.1.1-rc.1 + Chrome 151(macOS)。
Example: DSH 0.1.1-rc.1 + Chrome 151 (macOS).
placeholder: "DSH 版本 / version + 浏览器 / browser(如 0.1.1-rc.1 + Chrome 151)"
validations:
required: false
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
# step with the plugin's @deepseek-ai/* peer range when bumping.
- name: Install dsh CLI (pinned)
run: npm install -g @deepseek-ai/dsh@0.1.0-rc.8
run: npm install -g @deepseek-ai/dsh@0.1.1-rc.2
# lib/ is gitignored; the tarball is produced from this build.
- name: Build and pack the npm tarball
+3 -2
View File
@@ -21,7 +21,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
2. `scripts/e2e-mount.sh` 用官方 CLI 把它装进一个**全新 scratch profile**(`dsh plugin --profile web add file:<tarball>`,触发 `dsh.profile.bundles` 协调),然后启动真实 `dsh web`(keyless,`--port 0`)。
3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,显式展开面板(`openByDefault` 默认关)后通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Files 文件窗口的树打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`,独立模式:每个文件新开 tab,seed 的 home tab 保持资源管理器)——缺失的内置 tab 或 chunk 都会使门禁变红。
本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.0-rc.8`(与插件 peer 范围同步)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。
本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.1-rc.2`(挂载冒烟验证基线;peer 下限保持 `^0.1.0-rc.8`,rc.8 与 0.1.1-rc.x 双向兼容)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。
### npm 发版(GitHub Release → npm publish)
@@ -353,6 +353,7 @@ ctx.effect(() => {
| `editor` | 10 | 否(按 path 去重) | 否 | 唯一的「文件窗口」(文件编辑/预览 + 文件资源管理):文件 tab(有 path)在两种 `editorExplorer` 模式下 chrome 恒为合并形态——头部路径输入框 + 文本编辑器预览/编辑/保存控件 + 可开关的内嵌文件树面板(含全局文件名搜索,走 host `fs.search` 路由;左缘拖拽调宽),状态存 `tab.meta.treeOpen` / `tab.meta.treeWidth`;pref 控制**打开行为与无路径窗口形态**:关(默认,独立)= 走 `openSidebarFile` 按 path 新开,**无路径窗口即独立资源管理器——只渲染文件树面板**(搜索 + FileTree 撑满全窗,无编辑器 chrome);开(合并)= 树点击/输入框 Enter 原地切换当前 tab(`updateTab` 重写 path/title,id 与 meta 不变),无路径窗口 = 带 chrome 的空文件窗口(树默认展开)。树右键菜单提供「在新 Tab 中打开」「在侧边打开」(后者在当前 pane 右侧 split 出新 editor tab)。新会话在两种模式下都默认 seed 空文件窗口(`title: 'Files'`,无 path,树面板展开);持久化的旧 `explorer` tab 经 `sanitizeState` 迁移为该 home tab |
| `git` | 20 | 是 | 否 | Git 面板 |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `sidechat` | 35 | 否(createTab 铸造 `sidechat:<uuid>`/按 `meta.threadId` 去重) | 否 | 侧边对话(Codex 风格,**每个对话一个独立 Tab**):打开 Tab 即自动创建空线程(composer 拥有首条消息,host 侧包裹边界提示 + 创建时停泊的进行中快照,首条消息赢得真实标签并同步 Tab 标题);线程 = 插件自建子会话(自定义种子继承父会话完整上下文,进行中回合以 `interrupted` 冻结诚实闭合;种子尾部带合法 `subagent/descriptor`——否则 cold 线程在宿主 subagents.list 里是 corrupt 诊断行——SubagentView/subagent-detect 按 `Side: ` 前缀过滤保持拓扑零噪音),`origin:'subagent'` 隐藏于主列表;生命周期走自有 `/sidebar/api/sidechat.*` 路由(`ctx.agents.create/resume` + `agent.followup/cancel` + `sidechat.info` 读 Agent 身份/状态);头部菜单可切换/重开既有线程(`parkSidechatReopen` + `sidechat:<threadId>` 确定性 id),关闭 Tab 释放 live agent(历史保留);重开 Tab 经 `collectOwnEvents` 大页回源到种子边界(cold 读会展开 chunk 压缩行,小窗口会丢早期 tool/call 行);「保存为新会话」= `session.fork` 提升顶层会话(必须方法调用形态,`this` 敏感)。UI 对齐主对话区(用户气泡 `--dsw-specific-bubble`、assistant 通栏 markdown、胶囊 composer + 圆形发送/停止钮、运行扫光状态行)。见 [设计文档](docs/plans/2026-08-20-sidechat-tab-design.md) |
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
@@ -568,7 +569,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`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,启用卡片最右端渲染紧凑品牌开关滑块,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片底部的「功能设置」设置条(齿轮图标 + 文字标签,hairline 分隔)在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时设置条隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——设置弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有设置条)。
---
+300 -19
View File
@@ -3,9 +3,15 @@
<!-- Hero -->
<div align="center">
<b style="font-size: 1.15em;">一个服务化的侧边栏框架,一套开箱即用的完整工作台</b><br /><br />
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-better-sidebar" /></a>
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-better-sidebar" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml/badge.svg" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/zh/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=zh" /></a><br /><br />
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="内嵌浏览器" src="https://img.shields.io/badge/-内嵌浏览器-4d6bfe" /> <img alt="真实终端" src="https://img.shields.io/badge/-真实终端-4d6bfe" /> <img alt="Git 面板" src="https://img.shields.io/badge/-Git%20面板-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="插件生态:GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/%E6%8F%92%E4%BB%B6%E7%94%9F%E6%80%81-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="文件管理" src="https://img.shields.io/badge/-文件管理-4d6bfe" /> <img alt="编辑预览" src="https://img.shields.io/badge/-编辑预览-4d6bfe" /> <img alt="内嵌浏览器" src="https://img.shields.io/badge/-内嵌浏览器-4d6bfe" /> <img alt="真实终端" src="https://img.shields.io/badge/-真实终端-4d6bfe" /> <img alt="Git 面板" src="https://img.shields.io/badge/-Git%20面板-4d6bfe" /> <img alt="后台任务" src="https://img.shields.io/badge/-后台任务-4d6bfe" /> <img alt="侧边对话" src="https://img.shields.io/badge/-侧边对话-4d6bfe" /> <img alt="插件接入" src="https://img.shields.io/badge/-插件接入-4d6bfe" /><br /><br />
<b>右侧栏 + 底部面板双工作台</b>,并把 <code>ctx.betterSidebar</code> 服务开放给所有插件——<br />
通过 <code>registerTab</code> / <code>registerFileViewer</code> 注册新的侧边栏页面与文件预览器。
</div>
@@ -19,13 +25,27 @@
<video src="https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86" muted autoplay loop playsinline controls width="100%"></video>
</div>
## 📑 目录
- [✨ 功能一览](#-功能一览)
- [🚀 安装](#-安装)
- [🖼️ 特性巡礼](#-特性巡礼)
- [🌐 插件生态](#-插件生态)
- [🆕 最近更新](#-最近更新)
- [⌨️ 快捷键](#-快捷键)
- [🔌 服务化扩展](#-服务化扩展)
- [🛠️ 开发与构建](#-开发与构建)
- [🔐 安全](#-安全) · [⚠️ 已知限制](#-已知限制) · [🖥️ 平台支持](#-平台支持)
- [🤝 参与贡献](#-参与贡献) · [⭐ Star History](#-star-history) · [🔗 友情链接](#-友情链接)
## ✨ 功能一览
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF / Office 内联预览
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
@@ -110,16 +130,34 @@
- 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过
- 🧪 测试工程:单元测试拆分(#141)+ smoke 偶发失败修复
> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现(已有 **26+ 生态插件**,见下方「🌐 插件生态」)。接入文档见「🔌 服务化扩展」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
## 🚀 安装
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
**支持的 DSH 版本**:
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
```sh
dsh plugin --profile web add dsh-better-sidebar@latest
dsh plugin --profile web add dsh-better-sidebar@latest # 首次会因 pnpm 11 拦截 node-pty 构建脚本而失败(依赖已写入)
cd ~/.dsh/profiles/web && pnpm approve-builds --all # 放行构建脚本(自动重跑安装)
dsh plugin --profile web add dsh-better-sidebar@latest # 重跑即成功
```
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
**方式二:让 DSH 自己装**——把下面这段提示词发给任意一个 DSH 会话:
```text
帮我安装 dsh-better-sidebar 插件(DSH 侧边栏工作台),步骤:
1. 执行 dsh plugin --profile web add dsh-better-sidebar@latest(首次会被 pnpm 11 拦截 node-pty 构建脚本而失败,属正常)
2. 在 ~/.dsh/profiles/web 下执行 pnpm approve-builds --all(放行构建脚本,会自动重跑安装)
3. 再次执行 dsh plugin --profile web add dsh-better-sidebar@latest
4. 完成后提醒我硬刷新浏览器(Cmd/Ctrl+Shift+R)
遇到报错先查 https://github.com/omdsh-dev/DSH-better-sidebar README 的常见问题表。
```
<details>
<summary><b>更新</b></summary>
@@ -168,7 +206,7 @@ dsh plugin --profile web add dsh-better-sidebar@latest
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
```
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.13.0"` 再 `pnpm install`。
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.15.0"` 再 `pnpm install`。
</details>
@@ -189,6 +227,231 @@ dsh registry enable dsh-external/dsh-better-sidebar
</details>
## 🖼️ 特性巡礼
> 以下均为真实界面实拍。
### 🗂️ 文件工作台:资源管理器
支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。
<div align="center"><img width="880" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div>
<div align="center"><img width="880" alt="CodeMirror 代码编辑器" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div>
### 📝 Markdown · 图片 · PDF 内联预览
Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。
<div align="center"><img width="880" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div>
<div align="center"><img width="880" alt="图片内联预览" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div>
### 💻 真实终端
xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。
<div align="center"><img width="880" alt="真实终端" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div>
### 🌿 Git 面板
暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。
<div align="center"><img width="880" alt="Git 面板" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div>
### 🌐 内嵌浏览器
多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。
<div align="center"><img width="880" alt="内嵌浏览器" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div>
### 🧩 任务页:子代理拓扑 + 后台任务
子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。
<div align="center"><img width="880" alt="任务页:子代理拓扑" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div>
### 💬 侧边对话(beta)
Codex 风格侧边线程:**每个对话一个独立 Tab**;线程继承主会话完整上下文(含进行中回合,以 interrupted 诚实冻结)独立运行,不污染主会话;可持续追问、重启冷恢复;一键「保存为新会话」提升为顶层会话。
<div align="center"><img width="880" alt="侧边对话(beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div>
### 🪟 双工作台:右侧栏 + 底部面板 + 分栏
右侧栏与底部面板可同时展开;拖 Tab 到分栏边缘**拆分**、拖到中间**合并**(可跨面板);面板宽高左缘/上缘拖拽调节;移动端自动合并为全宽抽屉。
<div align="center"><img width="880" alt="双工作台(右侧栏 + 底部面板)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div>
### ⚙️ 声明式设置
设置页「侧边卡片」分区:每个 tab / 预览器一张小卡片,独立开关(高亮启用态 + 品牌开关滑块);二级设置经卡片底部「功能设置」条弹窗(开关 / 文本 / 数字 / 下拉);插件自有设置持久化在 `pluginSettings`。
<div align="center"><img width="880" alt="声明式设置:侧边卡片" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div>
### 📱 移动端
窄屏(<768px)自动切换为全宽抽屉:底栏 tab 一次性并入右侧栏,触屏拖拽可调。
<div align="center"><img width="360" alt="移动端全宽抽屉" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div>
## 🌐 插件生态
`ctx.betterSidebar` 服务向所有插件开放两个扩展点:**`registerTab`(注册侧边栏页面)** 与 **`registerFileViewer`(注册文件预览器)**。内置的 7 tab + 6 viewer 与第三方插件走同一套 API,能力完全对等。
```ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
}))
ctx.effect(() => ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom',
load: async (path, scope) => parseCsv(await fetchText(scope, path)),
component: ({ customData }) => <CsvGrid rows={customData} />,
}))
}
```
GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) 下已有 **26+ 生态插件**(持续增长中):
<div align="center">
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="66%" alt="设置页「添加插件」弹窗:推荐插件目录 + 一键复制安装命令" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a><br />
<i>设置页「侧边卡片」内置「添加插件」弹窗:推荐目录 + 一键复制安装命令 + 直达 GitHub topic</i>
</div>
### 📑 Tab 插件(注册侧边栏页面)
| 插件 | ⭐ | 简介 |
|---|---|---|
| [ChenRuoT/dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) | <img alt="stars" src="https://img.shields.io/github/stars/ChenRuoT/dsh-sidebar-qa?style=flat&color=4d6bfe" /> | 划选追问侧边页:类 Codex 侧边提问 / Claude Code `/btw` |
| [fuhefei/dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) | <img alt="stars" src="https://img.shields.io/github/stars/fuhefei/dsh-sentinel?style=flat&color=4d6bfe" /> | 条件驱动唤醒系统:文件 / 命令 / HTTP / 进程 / Webhook 监视,到点唤醒 agent;dock + 侧栏分支 + 全局仪表盘 |
| [jiuge2467/dsh-studio](https://github.com/jiuge2467/dsh-studio) | <img alt="stars" src="https://img.shields.io/github/stars/jiuge2467/dsh-studio?style=flat&color=4d6bfe" /> | 全栈增强工作台:多源 MCP 可视化调试中枢、视觉思考引擎 |
| [Iwctwbh/dsh-flowglass](https://github.com/Iwctwbh/dsh-flowglass) | <img alt="stars" src="https://img.shields.io/github/stars/Iwctwbh/dsh-flowglass?style=flat&color=4d6bfe" /> | 流镜 Flowglass:会话流程图实时可视化(消息 / 工具组 / 子代理分支) |
| [FeatherHunter/dsh-mattpocock-skills-deck](https://github.com/FeatherHunter/dsh-mattpocock-skills-deck) | <img alt="stars" src="https://img.shields.io/github/stars/FeatherHunter/dsh-mattpocock-skills-deck?style=flat&color=4d6bfe" /> | mattpocock/skills 游戏化任务系统:地图拨迷雾、任务栏推进 |
| [GULI-lab/DSH-element-source](https://github.com/GULI-lab/DSH-element-source) | <img alt="stars" src="https://img.shields.io/github/stars/GULI-lab/DSH-element-source?style=flat&color=4d6bfe" /> | 点击页面任意 UI 元素直达 Vue / React / Svelte / Angular 源码并送入会话 |
| [Lzh3070/dsh-file-review-tab](https://github.com/Lzh3070/dsh-file-review-tab) | <img alt="stars" src="https://img.shields.io/github/stars/Lzh3070/dsh-file-review-tab?style=flat&color=4d6bfe" /> | 文件改动审查页:行级红绿 diff + 撤销 + chat 行深链 |
| [yq04/dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-git-remotes?style=flat&color=4d6bfe" /> | Git 远程页:分支 / 上游 / ahead-behind,fetch 可 prune、ff-only pull、确认后 push |
| [ztyhehe/dsh-better-sidebar-svn](https://github.com/ztyhehe/dsh-better-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-better-sidebar-svn?style=flat&color=4d6bfe" /> | SVN 源码管理页:status / diff / log / commit / update / revert / 冲突解决,与内置 Git 面板对称 |
| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | <img alt="stars" src="https://img.shields.io/github/stars/Melody-max114/dsh-excel-panel?style=flat&color=4d6bfe" /> | Excel 编辑页:xlsx 预览 / 编辑、公式实时计算、合并单元格、保存回原文件 |
| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | <img alt="stars" src="https://img.shields.io/github/stars/v587d/dsh-anysearch-refs?style=flat&color=4d6bfe" /> | AnySearch 搜索结果引用卡片:搜索词、来源摘要、关键词高亮 |
| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | <img alt="stars" src="https://img.shields.io/github/stars/mlosun/dsh-docs-panel?style=flat&color=4d6bfe" /> | 全局文档面板:随身 Markdown 笔记,任何工作区随时可读 |
| [lnyuqian/dsh-skill-sidebar](https://github.com/lnyuqian/dsh-skill-sidebar) | <img alt="stars" src="https://img.shields.io/github/stars/lnyuqian/dsh-skill-sidebar?style=flat&color=4d6bfe" /> | 技能面板:扫描本机技能目录,4-6 字功能短语 + 一键复制调用 + 置顶 |
| [g-yixuan/dsh-sidechat](https://github.com/g-yixuan/dsh-sidechat) | <img alt="stars" src="https://img.shields.io/github/stars/g-yixuan/dsh-sidechat?style=flat&color=4d6bfe" /> | Codex 风格侧边对话 + 划选引用注释(轻量消费插件) |
| [thirsty5034/dsh-ssh-tunnel](https://github.com/thirsty5034/dsh-ssh-tunnel) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-ssh-tunnel?style=flat&color=4d6bfe" /> | 多主机 SSH 隧道 + SSH 管理器页 |
| [thirsty5034/dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-git-forge?style=flat&color=4d6bfe" /> | GitHub / Gitea 账号、项目授权与推送策略 |
| [YesSanSan/dsh-conversation-outline](https://github.com/YesSanSan/dsh-conversation-outline) | <img alt="stars" src="https://img.shields.io/github/stars/YesSanSan/dsh-conversation-outline?style=flat&color=4d6bfe" /> | 对话大纲页:按轮次结构化展示、一键跳转、LLM 一句话标题 |
| [Wulabalabo/dsh-sidebar-Explorer-Plus](https://github.com/Wulabalabo/dsh-sidebar-Explorer-Plus) | <img alt="stars" src="https://img.shields.io/github/stars/Wulabalabo/dsh-sidebar-Explorer-Plus?style=flat&color=4d6bfe" /> | 文件管理页:上传 / 移动 / 删除 / 重命名 / 新建文件夹(补全写操作) |
| [yq04/dsh-turn-review](https://github.com/yq04/dsh-turn-review) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-turn-review?style=flat&color=4d6bfe" /> | 本轮审查:逐回合审查 agent 改动 |
| [Ghz114514/dsh-refpics](https://github.com/Ghz114514/dsh-refpics) | <img alt="stars" src="https://img.shields.io/github/stars/Ghz114514/dsh-refpics?style=flat&color=4d6bfe" /> | Pinterest 风格参考图搜索:瀑布流、侧栏画板、下载与 Eagle 收藏 |
| [yzlin499/dsh-yzlin499-easy-plugins](https://github.com/yzlin499/dsh-yzlin499-easy-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/yzlin499/dsh-yzlin499-easy-plugins?style=flat&color=4d6bfe" /> | 实用小工具集(毛坯房 DSH 友好) |
### 🖼️ 预览插件(注册文件预览器)
| 插件 | ⭐ | 简介 |
|---|---|---|
| [HuanLinOTO/dsh-plugin-better-sidebar-plugin-office](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) | <img alt="stars" src="https://img.shields.io/github/stars/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office?style=flat&color=4d6bfe" /> | Office 三件套预览(.docx / .xlsx / .pptx),独立 bundle 瘦身主体(官方推荐目录收录) |
| [zemul/dsh-video-preview](https://github.com/zemul/dsh-video-preview) | <img alt="stars" src="https://img.shields.io/github/stars/zemul/dsh-video-preview?style=flat&color=4d6bfe" /> | 视频内联预览:.mp4 / .webm / .mov / .mkv / .avi,自带 /video 路由支持 HTTP Range 拖进度条 |
| [dong-victor/dsh-better-sidebar-jupyter](https://github.com/dong-victor/dsh-better-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-jupyter?style=flat&color=4d6bfe" /> | `.ipynb` 可运行 Notebook 视图:懒启动 Python kernel、流式输出、保存回写 |
### 🧰 增强与工具
| 插件 | ⭐ | 简介 |
|---|---|---|
| [dong-victor/dsh-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-terminal-plus?style=flat&color=4d6bfe" /> | 终端增强:内嵌 Nerd Font 图标字体、修复 xterm 图标渲染、稳定终端 cwd |
| [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | <img alt="stars" src="https://img.shields.io/github/stars/Max-Null/dsh-sidebar-preview-select?style=flat&color=4d6bfe" /> | 预览划选增强:侧边栏预览里划选文本 → 浮动「发送到会话」 |
> 📣 **上架你的插件**:给仓库打上 `dsh-better-sidebar` topic 即出现在 [topic 页](https://github.com/topics/dsh-better-sidebar);再向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提一条 `PluginEntry` PR,即可进入设置页内置推荐目录(数据完整性由 `tests/plugin-list.spec.ts` 守护)。
## 🆕 最近更新
<div align="center">
<a href="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336"><img width="33%" alt="侧边栏" src="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336" /></a>
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="33%" alt="服务化基座截图" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
</div>
**支持的 DSH 版本**:<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="支持的 DSH 版本:0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a> · 完整发布历史见 [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases)
### v0.15.0
自 v0.14.0 以来的全部更改:
**✨ 新功能**
- 💬 **侧边对话(beta) Tab**([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)):Codex 风格的侧边线程,**每个对话一个独立 Tab**——子会话继承主会话完整上下文(已完成回合 + 未回答消息 + 进行中回合的 assistant 输出与工具调用,以「interrupted」冻结标记诚实继承);同组合创建(同 preset / provider / model)复用前缀输入缓存;线程对主会话列表不可见、零子代理目录噪音;线程内可持续追问(重启后自动冷恢复);一键「保存为新会话」提升为顶层会话([设计文档](docs/plans/2026-08-20-sidechat-tab-design.md))
- 📤 **文件窗口上传**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):头部「上传文件 / 上传文件夹」按钮 + 拖放上传(拖到树区 = 工作区根,目录行 = 进该目录,文件行 = 进其所在目录,对齐 VSCode);上传时全屏模糊进度弹层(文件级进度 + 取消 / Esc);上传中按钮禁用、成功后文件树自动刷新
- 🧩 **桌面兼容四选项**([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)):位置兼容模式改为**主行下拉**——**自动检测**(默认,保守:仅使用标准的 Window Controls Overlay 几何,32/36px 等各壳差异自动跟随、最大化/还原实时更新,网页环境零修改)/ **DSH官方Web**(显式零适配)/ **壳兼容方案**(内置预设,手动启用;只收录 issue/PR 中出现过且 100+ star 的壳,命中环境带「已检测」提示)/ **自定义方案**(自定义 CSS + 下移距离)。旧版本已有兼容配置的用户自动落到自定义方案;交互控件统一退出桌面拖拽区(`no-drag`);底栏推挤锚点复合选择器双保险(`[data-pane]` 与 `:has(> [data-slot])`)
- 🎛️ **设置页 UI/UX 现代化**([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)):侧边卡片二级设置入口改为卡片底部「功能设置」设置条(替代右下角隐形齿轮,可发现性提升);协调双色启用态(brand 激活强调 + success 绿勾选徽标);全部颜色仍为 `--dsw-alias-*` 令牌派生,皮肤体系自动跟随
- ➕ **推荐插件目录新增**:`dsh-docs-panel` 全局文档面板([#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230))、`dsh-flowglass`([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261))、`dsh-git-forge` 与 `dsh-ssh-tunnel`([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204))、`dsh-turn-review`([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
**🐛 修复**
- ⚡ **子代理页实时预览批量接口**([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)):旧实现每个 running 子代理独立轮询 `subagents.history`,host 侧每次触发全量子代理枚举形成 O(N²) 放大、多子代理并发时页面卡顿——改为单个批量接口 `subagents.live`(一次枚举整棵子代理树)+ 客户端单轮询、单在途请求;展示逻辑与文案不变
- 🖱️ **拖拽中断 / 快速释放不再回滚**([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249),关闭 [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)):中断 / 快速释放提交最后已知位置;HMR 后中心列重定位兜底(修复热更新后底栏空白)
- 📐 **推挤变量挂载期持续有效**([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259),修复 [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)):拖拽松手后底边栏不再闪全宽
- 🔧 **适配 DSH 0.1.1-rc.1 / rc.2(@next)**([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)):无代码逻辑改动
- 🔒 **上传链路安全加固**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):`relativePath` 空段 / 绝对路径显式拒绝;临时文件唯一命名(并发上传互不干扰、崩溃不阻塞);写流错误监听(磁盘失败不崩溃进程);客户端错误码与服务端统一、413 本地化
<details>
<summary><b>历史版本(v0.12.0 – v0.14.0)</b></summary>
### v0.14.0
> ⚠️ 本版起需要 DSH ≥ 0.1.0-rc.8。自 v0.13.1 以来的全部更改:
**✨ 新功能**
- 🖼️ **统一面板宿主注入重构**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):面板/开关簇迁入 `[data-dsh-panel-host]` 固定含块层(`fixed inset-0 z-40`),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;挂载自检(页面级 transform → `data-dsh-panel-host-degraded` 降级同步,按未修正几何判定、祖先变换消失才退出);推挤锚点改 `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc 宽度防桌面壳加性溢出;chunk 激活重验证(HEAD+ETag 保留未变 chunk,5s 超时兜底 fail-open);`visualViewport` 键盘 inset + `env(safe-area-inset-*)` 移动端适配
- 📂 **文件打开方式默认独立**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):`editorExplorer` 默认从「合并」改为「独立」——新会话树点击 / 打开文件按路径**新开**文件 tab,无路径窗口即纯资源管理器;合并模式保留为可选手动开启
- 🖥️ **终端 shell / shellArgs 设置页可配**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):终端卡齿轮二级页面新增「Shell 路径」「Shell 参数」两行配置(此前只能通过 `cordis.patch.yml` 配置)——设置页写入后对**之后打开的** UI 终端与模型终端(`terminal_create`)即时生效;留空保持 yaml → `$SHELL` / 登录 shell / `powershell.exe` 的既有解析顺序
- 🏷️ **设置页版本徽标**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):侧边卡片设置页顶部新增 `DSH-better-sidebar v0.14.0` 身份徽标(版本与服务实例同步,由测试守护)
- 🔍 **添加插件目录搜索 / 分组 / 独立滚动**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):为插件生态增长做准备——目录列表顶部加实时搜索(按名称 / id / 描述过滤),条目支持可选 `category` 分组渲染,列表独立滚动(弹窗不再随条目数无限增长)
**🐛 修复**
- 🧩 **rc.8 模块系统迁移**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):rc.8 不再暴露 `window.__DSH_MODULES__` 页面全局(改由 `ctx.modules` 服务提供),懒加载 chunk 的外部依赖解析全面失效——client 注入 `modules` 服务 + 插件自有全局共享给 chunk 副本(终端 / 编辑器 / Mermaid 恢复正常按需加载)
- 🧩 **chunk 重验证屏障健壮性**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):HEAD 重验证加 5s 超时兜底(路由挂起时 fail-open 重取,屏障不再可能无限期阻塞懒加载);`resetChunks` 清挂起的重验证屏障
- 🖱️ **拖拽健壮性**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):快速释放(浏览器合并 / 丢失 pointermove 突发)时提交最后已知拖动位置而非回退;`pointercancel` / 捕获丢失中断同样保留拖动结果;提交后立即重测中心列(消除底栏宽度中间帧抖动);HMR 重激活后中心列重定位兜底(`<html>` 样式观察 + 底栏打开重测),修复热更新后底栏空白 / 输入框位移
### v0.13.1
**✨ 新功能**
- 📊 **Markdown 预览安全渲染 Mermaid 图表**([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)):预览的 md 含 mermaid fence 时按需下发 `client-mermaid.js` chunk(~7MB,无 mermaid 文件零加载);纵深防御渲染——`securityLevel: 'strict'` + `htmlLabels: false`(节点文字走真实 SVG `<text>`)+ SVG 注入前二次清洗(删 `foreignObject`/`script`/外来 HTML 元素、剥 `@*`/`on*`/`href` 属性);点击图表在弹窗中放大(滚轮以鼠标为中心缩放、拖拽平移、工具栏与快捷键),深浅色跟随重渲、解析失败回退原码
- 🖥️ **终端 shell 与 shellArgs 可配置**([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)):`cordis.patch.yml` 的 `better-sidebar.config` 可指定 `shell` / `shellArgs`(`shellArgs` 非空时完全替换默认参数;未配置维持自动解析 `$SHELL` / 登录 shell / `powershell.exe` 原行为),UI 终端与 agent 终端(`terminal_create`)同时生效;终端 tab 标题改用 shell 名(bash / zsh / powershell),内部标识改 UUID,同 shell 可开多个终端
**🐛 修复**
- 🔗 **聚合双挂载自动退让**([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)):聚合包(如 dsh-web-ui-all)以独立条目 id 挂载同包时,`cordis.patch.yml` 的守卫表达式自动禁用自身 `better-sidebar` 行,不再重复注册 `/sidebar/api` 导致 `duplicate prefix route` 整个插件树启动失败(`dsh web` 崩溃);独立安装行为不变
- 🔧 **适配 DSH 0.1.0-rc.7**([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207),修复 [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)):修复 DSH 主框架升至 rc.7 后选模型 / 发消息报 `agent-presets: refusing to compose an unscoped context` 的问题
### v0.13.0
**✨ 新功能**
- 📁 **文件窗口与资源管理器二合一**([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)):新 `editorExplorer` 设置(编辑器卡齿轮)——文件 tab 增加路径输入框头部 + 可开关的右侧停靠文件树(每 tab 记忆展开/宽度,左缘拖拽调宽 160~480px,全局文件名搜索走 host `fs.search` 路由,预算封顶并跳过 `.git` / 符号链接目录);独立模式(默认)树点击 / 输入框 Enter **按路径新开**文件 tab,合并模式**原地切换**当前 tab;新会话默认 seed 空文件窗口(`Files`)替代 explorer tab,无路径窗口在独立模式为纯资源管理器、合并模式为带 chrome 的空文件窗口;树右键提供「在新 Tab 中打开」「在侧边打开」(split)
- 🎛️ **声明式设置 select 行**([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)):设置项新增 `type: 'select'`(`options` 支持 value/title/desc/icon,`multi` 多选存数组);带图标的选项渲染大图标选项卡、收起态同样显示图标;`editorExplorer` 改为图标化下拉(合并 / 独立);能力清单新增 `settingSelect`
- 🔀 **与 dsh-web-ui 家族右侧面板互斥**([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)):读取 `aionui-panel` 设置命名空间的提供方选择——当选择「使用 aionui-panel」时,整个 better-sidebar(右侧栏 / 底部面板 / 浮动入口 / 各类接管)不再挂载;选择 DSH-better-sidebar(或未安装 aionui)时正常。设置页保存后实时生效(settings-document 推送),无需刷新
### v0.12.3
**✨ 新功能**
- 🎨 **皮肤兼容(令牌驱动)**:全面消费 DSH 设计令牌,与 dsh-web-ui 皮肤中心 10 款皮肤兼容,换肤自动跟随;终端/编辑器表面在透明/半透明玻璃值下回退不透明底色,文字不叠在皮肤背景上([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110),修复 #106 #105 #90 #60,附带 #52 #57 #92)
- 🗂️ **统一路径处理**:UNC 路径 / 软链接分类(目录软链接可展开、失效链接标红)、HTML 路由平台守卫([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134),#65 #67 #43 #79 #115)
- 🖥️ **终端 shell 可配置**:设置项自定义 shell,Windows 自动探测 pwsh([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95))
- 📝 **编辑器新增语言**:C# / Kotlin / Swift 语法高亮([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120))
- 🧭 **设置页导航图标**:设置页导航图标与布局优化([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114))
- ➕ **推荐插件目录新增**:`dsh-git-remotes`——Git 远程 Tab(分支/上游/ahead-behind、fetch 可 prune、ff-only pull、确认后才 push,不替换内置暂存/提交)([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91));`dsh-video-preview`——视频内联预览(.mp4/.webm/.mov/.mkv/.avi 等,自带 /video 宿主路由支持 HTTP Range 206 拖进度条,不受 20MB mediaLimit 限制)([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126))
**🐛 修复**
- 🔧 **xterm 依赖迁移**:弃用的 xterm 迁移至 `@xterm/xterm`(Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122),[#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
- 📝 **Markdown 编辑器**:选区转对话弹窗恢复可用([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
- 🐛 **node-pty 加载失败不再拖垮 server**([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)):宿主半改为懒加载 node-pty,缺失时插件照常挂载,终端以修复提示横幅(可复制命令 + 重试按钮)呈现,agent 终端工具自动跳过
- 🧪 测试工程:单元测试拆分(#141)+ smoke 偶发失败修复
</details>
## ⌨️ 快捷键
| 操作 | 按键 |
@@ -200,21 +463,9 @@ dsh registry enable dsh-external/dsh-better-sidebar
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
## 🔌 服务化:注册 tab 与文件预览器
## 🔌 服务化扩展
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册):
```ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
}))
}
```
v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等),详见下方接入文档。
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 6 viewer 亦通过同一服务注册)。v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、tab 角标、生命周期回调、定向打开、插件自有设置等)。
完整接入文档:
- **[`AGENTS.md`](./AGENTS.md)**——仓库内维护的接入文档(全字段、匹配算法、HMR 陷阱、声明式设置、版本探测);
@@ -229,7 +480,7 @@ v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.8),直接解析、无需令牌
pnpm install # @deepseek-ai/* devDependencies 已发布 0.1.1-rc.1,直接解析、无需令牌
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
@@ -257,9 +508,39 @@ pnpm watch # tsdown --watch
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
## 🤝 参与贡献
- **代码改动走 PR**:`feat/*` / `fix/*` 分支开发 → `gh pr create`;纯文档改动可直接推 main
- **收录生态插件**:给仓库打 `dsh-better-sidebar` topic + 向 [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts) 提 PR
- **提交前自检**:`pnpm typecheck && pnpm build && pnpm test`(CI 另有 npm 打包 → 真实挂载 → 无头渲染门禁 `pnpm test:mount`)
- 仓库工作规范见 [`AGENTS.md`](./AGENTS.md)(含仓库硬约束与 CI 说明)
## ⭐ Star History
<a href="https://star-history.com/#omdsh-dev/DSH-better-sidebar&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date&theme=dark" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date" />
</picture>
</a>
## 👥 贡献者
感谢每一位贡献者:
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/graphs/contributors">
<img src="https://contrib.rocks/image?repo=omdsh-dev/DSH-better-sidebar" alt="贡献者" />
</a>
## 🔗 友情链接
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI):Claude Code 风格全屏交互终端插件——像素鲸鱼顶栏、实时工作状态行、思考流式展开、双击 Esc 回滚、上下文进度条 + TPS 仪表,npm 一键安装
- [dshfind 插件超市](https://dshfind.com/zh/plugins):三方插件市场——GitHub topic `dsh-plugin` 下的公开仓库清单,每日同步 star、贡献者与增长数据
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop):为 DeepSeek Harness 生态打造的现代化桌面端——无需配置 Node.js 或执行命令即可启动和管理本地 Harness 服务;[官网](https://www.dshdesktop.cn)
---
<div align="center">
<sub>MIT License · Built for the <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> ecosystem · 在 <a href="https://github.com/topics/dsh-better-sidebar">topic dsh-better-sidebar</a> 发现更多生态插件</sub>
</div>
+301 -20
View File
@@ -3,9 +3,15 @@
<!-- Hero -->
<div align="center">
<b style="font-size: 1.15em;">A service-oriented sidebar framework, and a complete workbench out of the box</b><br /><br />
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm version" src="https://img.shields.io/npm/v/dsh-better-sidebar" /></a>
<a href="https://www.npmjs.com/package/dsh-better-sidebar"><img alt="npm downloads" src="https://img.shields.io/npm/dm/dsh-better-sidebar" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/omdsh-dev/DSH-better-sidebar/actions/workflows/ci.yml/badge.svg" /></a>
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/omdsh-dev/DSH-better-sidebar" /></a>
<a href="https://opensource.org/licenses/MIT"><img alt="License: MIT" src="https://img.shields.io/badge/License-MIT-yellow.svg" /></a>
<a href="https://dshfind.com/en/plugins/omdsh-dev/DSH-better-sidebar?ref=badge"><img alt="dshfind" src="https://dshfind.com/api/badge/omdsh-dev/DSH-better-sidebar?lang=en" /></a><br /><br />
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit &amp; preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Embedded browser" src="https://img.shields.io/badge/-Embedded%20browser-4d6bfe" /> <img alt="Real terminal" src="https://img.shields.io/badge/-Real%20terminal-4d6bfe" /> <img alt="Git panel" src="https://img.shields.io/badge/-Git%20panel-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
<a href="https://github.com/topics/dsh-better-sidebar"><img alt="Plugin ecosystem: GitHub topic dsh-better-sidebar" src="https://img.shields.io/badge/plugin%20ecosystem-topic%20dsh--better--sidebar-4d6bfe" /></a><br /><br />
<img alt="File management" src="https://img.shields.io/badge/-File%20management-4d6bfe" /> <img alt="Edit &amp; preview" src="https://img.shields.io/badge/-Edit%20%26%20preview-4d6bfe" /> <img alt="Embedded browser" src="https://img.shields.io/badge/-Embedded%20browser-4d6bfe" /> <img alt="Real terminal" src="https://img.shields.io/badge/-Real%20terminal-4d6bfe" /> <img alt="Git panel" src="https://img.shields.io/badge/-Git%20panel-4d6bfe" /> <img alt="Background tasks" src="https://img.shields.io/badge/-Background%20tasks-4d6bfe" /> <img alt="Side Chat" src="https://img.shields.io/badge/-Side%20Chat-4d6bfe" /> <img alt="Plugin integration" src="https://img.shields.io/badge/-Plugin%20integration-4d6bfe" /><br /><br />
<b>A dual workbench (right sidebar + bottom panel)</b> that opens its <code>ctx.betterSidebar</code> service to every plugin —<br />
register new sidebar pages and file viewers via <code>registerTab</code> / <code>registerFileViewer</code>.
</div>
@@ -19,13 +25,27 @@
<img alt="dsh-better-sidebar workbench" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
</div>
## 📑 Contents
- [✨ Features](#-features)
- [🚀 Installation](#-installation)
- [🖼️ Feature Tour](#-feature-tour)
- [🌐 Plugin Ecosystem](#-plugin-ecosystem)
- [🆕 Recent Updates](#-recent-updates)
- [⌨️ Keyboard Shortcuts](#-keyboard-shortcuts)
- [🔌 Service API](#-service-api)
- [🛠️ Development & Build](#-development--build)
- [🔐 Security](#-security) · [⚠️ Known Limitations](#-known-limitations) · [🖥️ Platform Support](#-platform-support)
- [🤝 Contributing](#-contributing) · [⭐ Star History](#-star-history) · [🔗 Friends](#-friends)
## ✨ Features
- **🗂️ 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 / Office
- **🗂️ 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
- **🌿 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
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
@@ -141,16 +161,34 @@
- 🖱️ **Tab-bar scroll**: mouse-wheel horizontal scrolling on the tab bar
- 🐛 **Fixes**: remote access 403 (trust fence now uses `trustedHosts`), sidebar crash [#31](https://github.com/omdsh-dev/DSH-better-sidebar/issues/31), Windows HTML-preview drive-path
> 🔌 **Core principle**: service-first — the 7 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins (**26+ ecosystem plugins** already — see "🌐 Plugin Ecosystem" below). See "🔌 Service API" and the [external plugin guide](./docs/external-plugin-guide.md).
## 🚀 Installation
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
**Supported DSH versions**:
<a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a>
```sh
dsh plugin --profile web add dsh-better-sidebar@latest
dsh plugin --profile web add dsh-better-sidebar@latest # first run fails: pnpm 11 blocks node-pty build scripts (the dependency is still written)
cd ~/.dsh/profiles/web && pnpm approve-builds --all # allow the build scripts (re-runs the install automatically)
dsh plugin --profile web add dsh-better-sidebar@latest # re-run succeeds
```
Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart).
**Or let DSH install it for you** — paste this prompt into any DSH session:
```text
Install the dsh-better-sidebar plugin (a sidebar workbench for DSH):
1. Run: dsh plugin --profile web add dsh-better-sidebar@latest (the first run fails because pnpm 11 blocks node-pty build scripts — that's expected)
2. In ~/.dsh/profiles/web run: pnpm approve-builds --all (allows the build scripts and re-runs the install)
3. Run the add command again: dsh plugin --profile web add dsh-better-sidebar@latest
4. When done, remind me to hard-refresh the browser (Cmd/Ctrl+Shift+R)
If anything fails, check the troubleshooting table in the README at https://github.com/omdsh-dev/DSH-better-sidebar
```
<details>
<summary><b>Updating</b></summary>
@@ -158,7 +196,7 @@ Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot
dsh plugin --profile web add dsh-better-sidebar@latest
```
or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.13.0"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.15.0"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
</details>
@@ -199,7 +237,7 @@ To debug local changes or track the dev branch, point the dependency at a local
5. Restart DSH and hard-refresh
```
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.13.0"` and re-run `pnpm install`.
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.15.0"` and re-run `pnpm install`.
</details>
@@ -220,6 +258,231 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
</details>
## 🖼️ Feature Tour
> Below are real UI screenshots.
### 🗂️ File Workbench: Explorer
Two explorer modes: embedded in the file preview / standalone file tree. Lazy-loading directory tree, symlinks classified by target kind (directory links expand, dangling links flagged), global filename search, file/folder upload buttons plus drag-drop upload, context menu (open in new tab / open to the side / copy paths), and a hover `@file` button that references a file straight into the composer.
<div align="center"><img width="880" alt="File explorer" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div>
<div align="center"><img width="880" alt="CodeMirror editor" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div>
### 📝 Inline Preview: Markdown · Images · PDF
The Markdown preview renders **Mermaid diagrams** (strict-mode safe rendering + a second sanitize pass; click a diagram for a zoom modal with wheel-zoom and drag-pan); images / PDFs display inline via the media route; the Office suite is covered by an ecosystem plugin.
<div align="center"><img width="880" alt="Markdown + Mermaid preview" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div>
<div align="center"><img width="880" alt="Inline image preview" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div>
### 💻 Real Terminal
xterm.js + node-pty real shell (not an emulator): transcript replay on reconnect, configurable shell / shellArgs (settings page or `cordis.patch.yml`), and optional `terminal_*` model tools so the agent can open terminals and run commands itself.
<div align="center"><img width="880" alt="Real terminal" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div>
### 🌿 Git Panel
Stage / unstage / commit (`Ctrl+Enter`) / revert, plus a history list; clicking a changed file opens a **VSCode-style diff tab** (line-level red/green).
<div align="center"><img width="880" alt="Git panel" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div>
### 🌐 Embedded Browser
Multiple web tabs with back / forward / reload / address bar; content runs in an **opaque-origin sandboxed iframe** (live sandbox status in the UI, per-page temporary unlock available); external-link clicks in the chat can be taken over into the sidebar (protocol-based routing, configurable).
<div align="center"><img width="880" alt="Embedded browser" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div>
### 🧩 Tasks: Agent Topology + Background Jobs
Live subagent-tree topology (run states, batched live previews) plus the background-jobs list (exit codes / live output / force-kill); new subagents / jobs can auto-expand the sidebar (configurable).
<div align="center"><img width="880" alt="Tasks: subagent topology" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div>
### 💬 Side Chat (beta)
Codex-style side threads: **one independent tab per conversation**; the thread inherits the parent's full context (including the in-progress turn, honestly frozen as "interrupted") and runs independently without polluting the main session; follow-ups survive restarts; one click promotes the thread to a top-level session.
<div align="center"><img width="880" alt="Side Chat (beta)" src="https://github.com/user-attachments/assets/3a338c36-f5de-4000-95f3-4b1cd04f60fc" /></div>
### 🪟 Dual Workbench: Sidebar + Bottom Panel + Split Panes
The right sidebar and the bottom panel can stay open together; drag a tab to a pane edge to **split**, to the middle to **merge** (works across panels); panel width/height drag from the left/top edge; on mobile everything merges into a full-width drawer.
<div align="center"><img width="880" alt="Dual workbench (right sidebar + bottom panel)" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" /></div>
### ⚙️ Declarative Settings
The "Side card" section in DSH settings: one small card per tab / viewer with an independent toggle (highlighted enabled state + brand switch); secondary settings open from the "Feature settings" strip at the card bottom (switch / text / number / select rows); plugin-owned settings persist under `pluginSettings`.
<div align="center"><img width="880" alt="Declarative settings: side cards" src="https://github.com/user-attachments/assets/0800ca64-621e-48da-b7df-aecfddc3ec29" /></div>
### 📱 Mobile
On narrow screens (<768px) the panels become a full-width drawer: bottom-panel tabs merge into the sidebar once, with touch-friendly dragging.
<div align="center"><img width="360" alt="Mobile full-width drawer" src="https://github.com/user-attachments/assets/a82ba78a-f4cf-4d85-80e8-050a05beb144" /></div>
## 🌐 Plugin Ecosystem
The `ctx.betterSidebar` service opens two extension points to every plugin: **`registerTab` (sidebar pages)** and **`registerFileViewer` (file previewers)**. The 7 built-in tabs + 6 viewers register through the exact same API — fully equal capabilities.
```ts
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
}))
ctx.effect(() => ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv', exts: ['csv'], fetchStrategy: 'custom',
load: async (path, scope) => parseCsv(await fetchText(scope, path)),
component: ({ customData }) => <CsvGrid rows={customData} />,
}))
}
```
The GitHub topic [`dsh-better-sidebar`](https://github.com/topics/dsh-better-sidebar) already hosts **26+ ecosystem plugins** (and growing):
<div align="center">
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="66%" alt="The built-in Add Plugins modal: recommended catalog + one-click install command" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a><br />
<i>The built-in "Add plugins" modal in settings: recommended catalog + one-click install command + a direct link to the GitHub topic</i>
</div>
### 📑 Tab Plugins (sidebar pages)
| Plugin | ⭐ | Description |
|---|---|---|
| [ChenRuoT/dsh-sidebar-qa](https://github.com/ChenRuoT/dsh-sidebar-qa) | <img alt="stars" src="https://img.shields.io/github/stars/ChenRuoT/dsh-sidebar-qa?style=flat&color=4d6bfe" /> | Selection-based side Q&A — Codex-style side questions / Claude Code `/btw` |
| [fuhefei/dsh-sentinel](https://github.com/fuhefei/dsh-sentinel) | <img alt="stars" src="https://img.shields.io/github/stars/fuhefei/dsh-sentinel?style=flat&color=4d6bfe" /> | Condition-driven wakeup: file / command / HTTP / process / webhook watches that wake the agent; dock + sidebar branch + global dashboard |
| [jiuge2467/dsh-studio](https://github.com/jiuge2467/dsh-studio) | <img alt="stars" src="https://img.shields.io/github/stars/jiuge2467/dsh-studio?style=flat&color=4d6bfe" /> | Full-stack enhancement workbench: multi-source MCP visual debugging hub, visual thinking engine |
| [Iwctwbh/dsh-flowglass](https://github.com/Iwctwbh/dsh-flowglass) | <img alt="stars" src="https://img.shields.io/github/stars/Iwctwbh/dsh-flowglass?style=flat&color=4d6bfe" /> | Flowglass: live session flowgraph (messages / tool groups / subagent branches) |
| [FeatherHunter/dsh-mattpocock-skills-deck](https://github.com/FeatherHunter/dsh-mattpocock-skills-deck) | <img alt="stars" src="https://img.shields.io/github/stars/FeatherHunter/dsh-mattpocock-skills-deck?style=flat&color=4d6bfe" /> | Game-like mission system for mattpocock/skills: fog-of-war map + task bar |
| [GULI-lab/DSH-element-source](https://github.com/GULI-lab/DSH-element-source) | <img alt="stars" src="https://img.shields.io/github/stars/GULI-lab/DSH-element-source?style=flat&color=4d6bfe" /> | Click any UI element on your dev page to jump to its Vue / React / Svelte / Angular source, straight into the chat |
| [Lzh3070/dsh-file-review-tab](https://github.com/Lzh3070/dsh-file-review-tab) | <img alt="stars" src="https://img.shields.io/github/stars/Lzh3070/dsh-file-review-tab?style=flat&color=4d6bfe" /> | File-change review tab: line-level red/green diffs + undo + chat-line deep links |
| [yq04/dsh-git-remotes](https://github.com/yq04/dsh-git-remotes) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-git-remotes?style=flat&color=4d6bfe" /> | Git remotes tab: branches / upstream / ahead-behind, fetch with prune, ff-only pull, confirm-before-push |
| [ztyhehe/dsh-better-sidebar-svn](https://github.com/ztyhehe/dsh-better-sidebar-svn) | <img alt="stars" src="https://img.shields.io/github/stars/ztyhehe/dsh-better-sidebar-svn?style=flat&color=4d6bfe" /> | SVN source-control tab: status / diff / log / commit / update / revert / conflict resolution — symmetric to the built-in Git panel |
| [Melody-max114/dsh-excel-panel](https://github.com/Melody-max114/dsh-excel-panel) | <img alt="stars" src="https://img.shields.io/github/stars/Melody-max114/dsh-excel-panel?style=flat&color=4d6bfe" /> | Excel editing: xlsx preview/edit, live formula evaluation, merged cells, save back to the original file |
| [v587d/dsh-anysearch-refs](https://github.com/v587d/dsh-anysearch-refs) | <img alt="stars" src="https://img.shields.io/github/stars/v587d/dsh-anysearch-refs?style=flat&color=4d6bfe" /> | AnySearch results as sidebar cards: query, source snippets, highlighted keywords |
| [mlosun/dsh-docs-panel](https://github.com/mlosun/dsh-docs-panel) | <img alt="stars" src="https://img.shields.io/github/stars/mlosun/dsh-docs-panel?style=flat&color=4d6bfe" /> | Global docs panel: portable Markdown notes, readable from any workspace |
| [lnyuqian/dsh-skill-sidebar](https://github.com/lnyuqian/dsh-skill-sidebar) | <img alt="stars" src="https://img.shields.io/github/stars/lnyuqian/dsh-skill-sidebar?style=flat&color=4d6bfe" /> | Skills panel: scans local skill directories, one-click invocation copy, pinning |
| [g-yixuan/dsh-sidechat](https://github.com/g-yixuan/dsh-sidechat) | <img alt="stars" src="https://img.shields.io/github/stars/g-yixuan/dsh-sidechat?style=flat&color=4d6bfe" /> | Codex-style side chat + selection annotations (a thin consumer plugin) |
| [thirsty5034/dsh-ssh-tunnel](https://github.com/thirsty5034/dsh-ssh-tunnel) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-ssh-tunnel?style=flat&color=4d6bfe" /> | Multi-host SSH tunnels + SSH manager tab |
| [thirsty5034/dsh-git-forge](https://github.com/thirsty5034/dsh-git-forge) | <img alt="stars" src="https://img.shields.io/github/stars/thirsty5034/dsh-git-forge?style=flat&color=4d6bfe" /> | GitHub / Gitea accounts, project grants and push policy |
| [YesSanSan/dsh-conversation-outline](https://github.com/YesSanSan/dsh-conversation-outline) | <img alt="stars" src="https://img.shields.io/github/stars/YesSanSan/dsh-conversation-outline?style=flat&color=4d6bfe" /> | Conversation outline tab: per-turn structure, quick jump, one-line LLM titles |
| [Wulabalabo/dsh-sidebar-Explorer-Plus](https://github.com/Wulabalabo/dsh-sidebar-Explorer-Plus) | <img alt="stars" src="https://img.shields.io/github/stars/Wulabalabo/dsh-sidebar-Explorer-Plus?style=flat&color=4d6bfe" /> | File-manager tab: upload / move / delete / rename / new folder (write operations) |
| [yq04/dsh-turn-review](https://github.com/yq04/dsh-turn-review) | <img alt="stars" src="https://img.shields.io/github/stars/yq04/dsh-turn-review?style=flat&color=4d6bfe" /> | Turn review: review agent changes turn by turn |
| [Ghz114514/dsh-refpics](https://github.com/Ghz114514/dsh-refpics) | <img alt="stars" src="https://img.shields.io/github/stars/Ghz114514/dsh-refpics?style=flat&color=4d6bfe" /> | Pinterest-style reference-image search: masonry wall, sidebar board, downloads, save-to-Eagle |
| [yzlin499/dsh-yzlin499-easy-plugins](https://github.com/yzlin499/dsh-yzlin499-easy-plugins) | <img alt="stars" src="https://img.shields.io/github/stars/yzlin499/dsh-yzlin499-easy-plugins?style=flat&color=4d6bfe" /> | A handy utility bundle for a bare-bones DSH |
### 🖼️ Viewer Plugins (file previewers)
| Plugin | ⭐ | Description |
|---|---|---|
| [HuanLinOTO/dsh-plugin-better-sidebar-plugin-office](https://github.com/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office) | <img alt="stars" src="https://img.shields.io/github/stars/HuanLinOTO/dsh-plugin-better-sidebar-plugin-office?style=flat&color=4d6bfe" /> | Office-suite preview (.docx / .xlsx / .pptx) as a separate bundle to slim the core (in the official recommended catalog) |
| [zemul/dsh-video-preview](https://github.com/zemul/dsh-video-preview) | <img alt="stars" src="https://img.shields.io/github/stars/zemul/dsh-video-preview?style=flat&color=4d6bfe" /> | Inline video preview: .mp4 / .webm / .mov / .mkv / .avi with a /video host route supporting HTTP Range scrubbing |
| [dong-victor/dsh-better-sidebar-jupyter](https://github.com/dong-victor/dsh-better-sidebar-jupyter) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-jupyter?style=flat&color=4d6bfe" /> | Runnable `.ipynb` notebook view: lazy-start Python kernel, streaming outputs, save-back |
### 🧰 Enhancements & Tools
| Plugin | ⭐ | Description |
|---|---|---|
| [dong-victor/dsh-better-sidebar-terminal-plus](https://github.com/dong-victor/dsh-better-sidebar-terminal-plus) | <img alt="stars" src="https://img.shields.io/github/stars/dong-victor/dsh-better-sidebar-terminal-plus?style=flat&color=4d6bfe" /> | Terminal enhancement: bundled Nerd Font icons, xterm glyph fixes, stable terminal cwd |
| [Max-Null/dsh-sidebar-preview-select](https://github.com/Max-Null/dsh-sidebar-preview-select) | <img alt="stars" src="https://img.shields.io/github/stars/Max-Null/dsh-sidebar-preview-select?style=flat&color=4d6bfe" /> | Preview selection boost: select text in any sidebar preview → floating "send to session" |
> 📣 **List your plugin**: tag your repo with the `dsh-better-sidebar` topic to appear on the [topic page](https://github.com/topics/dsh-better-sidebar); then PR one `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`src/client/plugins-viewers.ts`](./src/client/plugins-viewers.ts) to join the built-in recommended catalog (data integrity is guarded by `tests/plugin-list.spec.ts`).
## 🆕 Recent Updates
<div align="center">
<a href="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336"><img width="33%" alt="Sidebar" src="https://github.com/user-attachments/assets/d2aea86b-a776-4f01-a6b8-b26b27314336" /></a>
<a href="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0"><img width="33%" alt="Service API base screenshot" src="https://github.com/user-attachments/assets/946f7028-4967-461e-a750-d1b5056b62d0" /></a>
</div>
**Supported DSH versions**: <a href="https://www.npmjs.com/package/@deepseek-ai/dsh?activeTab=versions"><img alt="Supported DSH versions: 0.1.0-rc.8 · 0.1.1-rc.1 · 0.1.1-rc.2" src="https://img.shields.io/badge/DSH-0.1.0--rc.8_%C2%B7_0.1.1--rc.1_%C2%B7_0.1.1--rc.2-4d6bfe" /></a> · full release history on the [Releases](https://github.com/omdsh-dev/DSH-better-sidebar/releases) page
### v0.15.0
All changes since v0.14.0:
**✨ New features**
- 💬 **Side Chat (beta) tab** ([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)): Codex-style side threads, **one independent tab per conversation** — the child inherits the parent's full context (completed turns + pending messages + the in-progress turn's assistant output and tool activity, honestly frozen with an "interrupted" marker); created with an identical composition (same preset / provider / model) so the first request reuses the parent's input prefix cache; threads stay invisible in the main session list with zero subagent-catalog noise; follow-ups survive DSH restarts (auto cold-resume); one-click "Save as new session" promotes the thread to a top-level session ([design](docs/plans/2026-08-20-sidechat-tab-design.md))
- 📤 **Upload into the files window** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): header "upload file / upload folder" buttons plus drag-drop (drop on the tree body = workspace root, on a directory row = that directory, on a file row = its parent directory, VSCode semantics); full-window blurred progress overlay while uploading (per-file progress + cancel / Esc); buttons disabled while busy, tree refreshes after the upload settles
- 🧩 **Desktop compatibility in four options** ([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)): "Position compatibility mode" is now a main-row dropdown — **Auto-detect** (default, conservative: only the standard Window Controls Overlay geometry contributes; real 32/36px caption-overlay heights per shell, live on maximize/restore; zero modification on plain web) / **DSH official web** (explicitly no adaptation) / **Shell preset** (built-in, opt-in; only shells that appeared in this repo's issues/PRs with 100+ stars, "detected" badge when the environment matches) / **Custom** (free-form CSS + shift distance). Documents that already carried compatibility values migrate to the custom scheme; interactive chrome opts out of desktop drag regions (`no-drag`); the bottom-push anchor is a composite selector (`[data-pane]` and `:has(> [data-slot])`)
- 🎛️ **Settings page UI/UX modernization** ([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)): the side-card secondary-settings entry is now a full-width "Feature settings" strip at the card bottom (replacing the invisible corner gear — much easier to discover); coordinated two-tone enabled state (brand activation accent + success-green check badge); every color is still `--dsw-alias-*` token-derived so skins follow automatically
- ➕ **New entries in the recommended-plugin catalog**: `dsh-docs-panel` (global docs, [#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230)), `dsh-flowglass` ([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261)), `dsh-git-forge` and `dsh-ssh-tunnel` ([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204)), `dsh-turn-review` ([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
**🐛 Fixes**
- ⚡ **Batched live preview for the subagent page** ([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)): the old implementation polled `subagents.history` per running subagent, each poll triggering a full host-side enumeration — an O(N²) amplification that stalled the page with many concurrent subagents; now a single batch route `subagents.live` (one enumeration of the whole tree) plus one client poller with a single in-flight request; display logic and copy unchanged
- 🖱️ **Interrupted / fast-release drags no longer roll back** ([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249), closes [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)): interrupted or fast-released drags commit the last known position; HMR re-activation re-locates the center column (fixes the blank bottom panel after a hot reload)
- 📐 **Push variables stay effective while mounted** ([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259), fixes [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)): the bottom panel no longer flashes full-width after a drag is released
- 🔧 **Adapted to DSH 0.1.1-rc.1 / rc.2 (@next)** ([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)): no code changes needed
- 🔒 **Upload-chain hardening** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): empty and absolute `relativePath` segments are refused outright; uniquely named temp files (concurrent uploads stay independent, crashed processes never block later uploads); write-stream error listeners (a failing disk can no longer crash the host); client error codes unified with the wire (`too-large`), 413s localized
<details>
<summary><b>Older releases (v0.12.0 – v0.14.0)</b></summary>
### v0.14.0
> ⚠️ This release requires DSH ≥ 0.1.0-rc.8. All changes since v0.13.1:
**✨ New features**
- 🖼️ **Unified panel-host injection refactor** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): panels/toggle clusters moved into a `[data-dsh-panel-host]` fixed containing block (`fixed inset-0 z-40`), immune to desktop-shell intermediate transforms hijacking `fixed`; mount self-check (page-level transform → `data-dsh-panel-host-degraded` degraded sync, judged on uncorrected geometry, exits only when the ancestor transform is gone); push anchor switched to `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc width against desktop-shell additive overflow; chunk revalidation on activation (HEAD+ETag keeps unchanged chunks, 5s timeout fails open); `visualViewport` keyboard inset + `env(safe-area-inset-*)` mobile adaptation
- 📂 **Separate file windows by default** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): `editorExplorer` now defaults to **separate** — tree clicks / file opens create a new tab per path and the path-less window is a pure file manager; merged mode stays available as an opt-in
- 🖥️ **Terminal shell / shellArgs configurable from the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the terminal card's gear popup gains "Shell path" and "Shell arguments" rows (previously yaml-only via `cordis.patch.yml`) — saved values take effect immediately for terminals opened afterwards (UI terminals and model `terminal_create` alike); empty keeps the existing yaml → `$SHELL` / login shell / `powershell.exe` resolution order
- 🏷️ **Version badge on the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the side-card settings section now opens with a `DSH-better-sidebar v0.14.0` identity badge (version synced with the service instance, test-guarded)
- 🔍 **Add-plugin catalog: search / grouping / independent scroll** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): built for a growing plugin ecosystem — a live search box (filters by name / id / description), optional `category` grouping for entries, and an independently scrolling list (the modal no longer grows unbounded with catalog size)
**🐛 Fixes**
- 🧩 **rc.8 module-system migration** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): rc.8 no longer exposes the `window.__DSH_MODULES__` page global (it moved to the `ctx.modules` service), which broke every lazy chunk's externals resolution — the client now injects the `modules` service and shares it with chunk-bundle copies through a plugin-owned global (terminal / editor / Mermaid on-demand loading restored)
- 🧩 **Chunk revalidation barrier hardening** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): HEAD revalidation gains a 5s timeout (fails open on a stuck route so the barrier can never wedge lazy loads); `resetChunks` clears a pending revalidation barrier
- 🖱️ **Drag robustness** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): fast releases (browsers merge/lose pointermove bursts) commit the last known dragged position instead of rolling back; `pointercancel` / lost-capture interruptions keep the drag result too; the center column is re-measured right after commit (no mid-frame bottom-panel width jump); HMR re-activation re-locates the center column via an `<html>` style observer plus a retry when the bottom panel opens (fixes the blank bottom panel / shifted input bar after a hot reload)
### v0.13.1
**✨ New features**
- 📊 **Safe Mermaid rendering in the Markdown preview** ([#164](https://github.com/omdsh-dev/DSH-better-sidebar/pull/164)): when a previewed md file contains mermaid fences, a `client-mermaid.js` chunk (~7MB) is served on demand (zero load without mermaid); defense-in-depth rendering — `securityLevel: 'strict'` + `htmlLabels: false` (node labels use real SVG `<text>`) + a second sanitize pass before SVG injection (foreignObject/script/foreign HTML elements removed, `@*`/`on*`/`href` attributes stripped); click a diagram to zoom in a modal overlay (wheel zoom centered on the cursor, drag pan, toolbar & shortcuts), re-renders with light/dark theme, falls back to the raw code block on parse failure
- 🖥️ **Configurable terminal shell & shellArgs** ([#125](https://github.com/omdsh-dev/DSH-better-sidebar/pull/125)): `cordis.patch.yml` `better-sidebar.config` can set `shell` / `shellArgs` (a non-empty `shellArgs` fully replaces the defaults; unset keeps the previous auto-resolution of `$SHELL` / login shell / `powershell.exe`), applied to both UI terminals and agent terminals (`terminal_create`); terminal tab titles now show the shell name (bash/zsh/powershell) and internal tab ids use UUIDs so the same shell can open multiple terminals
**🐛 Fixes**
- 🔗 **Aggregate double-mount auto-yield** ([#200](https://github.com/omdsh-dev/DSH-better-sidebar/pull/200)): when an aggregate package (e.g. dsh-web-ui-all) mounts the same package under its own entry id, the guard expression in `cordis.patch.yml` disables the plugin's own `better-sidebar` row so `/sidebar/api` is no longer registered twice (`duplicate prefix route` crashing the whole plugin tree / `dsh web`); standalone installs behave as before
- 🔧 **Adapted to DSH 0.1.0-rc.7** ([#207](https://github.com/omdsh-dev/DSH-better-sidebar/pull/207), fixes [#206](https://github.com/omdsh-dev/DSH-better-sidebar/issues/206)): fixes the `agent-presets: refusing to compose an unscoped context` error when picking a model / sending a message after DSH moved to rc.7
### v0.13.0
**✨ New features**
- 📁 **Files window merged with the explorer** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): new `editorExplorer` setting (editor card gear) — file tabs gain a path-input header plus a toggleable right-docked file tree (per-tab open/width memory, drag-resize 160–480px from the left edge, global filename search via the host `fs.search` route with a hard budget, skipping `.git` and symlink dirs); in separate mode (default) tree clicks / Enter in the path input open each file **in its own new tab**, merged mode switches the current tab **in place**; fresh sessions seed an empty Files window instead of the explorer tab, and a path-less window is a bare file manager in separate mode / a chrome'd empty file window in merged mode; the tree context menu offers "Open in new tab" and "Open to the side" (split)
- 🎛️ **Select rows for declarative settings** ([#151](https://github.com/omdsh-dev/DSH-better-sidebar/pull/151)): settings rows gain `type: 'select'` (`options` with value/title/desc/icon, `multi` stores the picked values as an array); options with icons render big-icon option cards and keep the icon in the closed anchor; `editorExplorer` became an iconed select (merged vs separate); the capability list gained `settingSelect`
- 🔀 **Mutual exclusion with the dsh-web-ui family right panel** ([#181](https://github.com/omdsh-dev/DSH-better-sidebar/pull/181)): reads the `aionui-panel` settings namespace's provider choice — when "Use aionui-panel" is selected, the whole better-sidebar (right sidebar / bottom panel / floating entry / all takeovers) does not mount; with DSH-better-sidebar (or no aionui installed) it behaves as before. Takes effect live after a settings save (settings-document push), no reload needed
### v0.12.3
**✨ New features**
- 🎨 **Skin compatibility (token-driven)**: fully consumes DSH design tokens and follows the dsh-web-ui skin center's 10 skins automatically; terminal/editor surfaces fall back to opaque backgrounds under transparent/translucent-glass token values so text never scrolls over the skin art ([#110](https://github.com/omdsh-dev/DSH-better-sidebar/pull/110), fixes #106 #105 #90 #60, also #52 #57 #92)
- 🗂️ **Unified path handling**: UNC / symlink classification (directory symlinks expandable, broken links highlighted) + HTML-route platform guards ([#134](https://github.com/omdsh-dev/DSH-better-sidebar/pull/134), #65 #67 #43 #79 #115)
- 🖥️ **Configurable terminal shell**: custom shell setting with Windows pwsh auto-probe ([#95](https://github.com/omdsh-dev/DSH-better-sidebar/pull/95))
- 📝 **Editor languages**: C# / Kotlin / Swift syntax highlighting ([#120](https://github.com/omdsh-dev/DSH-better-sidebar/pull/120))
- 🧭 **Settings nav icon**: settings-page navigation icon and layout polish ([#114](https://github.com/omdsh-dev/DSH-better-sidebar/pull/114))
- ➕ **Recommended-plugin catalog**: added `dsh-git-remotes` — Git Remotes tab (branches/upstream/ahead-behind, fetch with prune, ff-only pull, confirm-before-push; does not replace the built-in stage/commit tab) ([#91](https://github.com/omdsh-dev/DSH-better-sidebar/pull/91)); and `dsh-video-preview` — inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) backed by a /video host route with HTTP Range (206) scrubbing, not capped by the 20MB mediaLimit ([#126](https://github.com/omdsh-dev/DSH-better-sidebar/pull/126))
**🐛 Fixes**
- 🔧 **xterm migration**: deprecated xterm dependency migrated to `@xterm/xterm` (Closes [#122](https://github.com/omdsh-dev/DSH-better-sidebar/issues/122), [#128](https://github.com/omdsh-dev/DSH-better-sidebar/pull/128))
- 📝 **Markdown editor**: selection-to-conversation popup restored ([#24](https://github.com/omdsh-dev/DSH-better-sidebar/pull/24))
- 🐛 **node-pty load failure no longer crashes the server** ([#140](https://github.com/omdsh-dev/DSH-better-sidebar/issues/140)): the host half now lazy-loads node-pty — when it is missing the plugin still mounts, the terminal shows a repair banner (copyable command + Retry button), and agent terminal tools are skipped
- 🧪 Test engineering: unit spec split (#141) + flaky smoke cleanup fix
</details>
## ⌨️ Keyboard Shortcuts
| Action | Keys |
@@ -231,21 +494,9 @@ Update: `git pull && pnpm install && pnpm build` → `node scripts/package-regis
| Reference file to input | Hover the `@file` button at end of line |
| Copy file path | Right-click row → copy relative/absolute path |
## 🔌 Service: register tabs & file viewers
## 🔌 Service API
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 6 viewers register through the same service):
```ts
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
}))
}
```
v0.12.1+ base capabilities (complete type exports, capability detection, state subscription, tab badge, lifecycle callbacks, targeted open, plugin-owned settings, etc.) — see the integration docs below.
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 6 viewers register through the same service). v0.12.1 completed the base capabilities (complete type exports, capability detection, state subscription, tab badges, lifecycle callbacks, targeted open, plugin-owned settings, etc.).
Full integration docs:
- **[`AGENTS.md`](./AGENTS.md)** — the in-repo integration doc (full fields, matching algorithm, HMR pitfalls, declarative settings, version detection);
@@ -260,7 +511,7 @@ The dashed cards at the end of the "Sidebar content" / "File viewers" grids in t
## 🛠️ Development & Build
```sh
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.8, published) — no token needed
pnpm install # @deepseek-ai/* devDependencies resolve to 0.1.1-rc.1 (published) — no token needed
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest (includes manifest consistency guard; build first)
@@ -288,9 +539,39 @@ pnpm watch # tsdown --watch
Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests); `node-pty` prefers prebuilt binaries, otherwise a build toolchain is required (Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT).
## 🤝 Contributing
- **Code changes go through PRs**: develop on a `feat/*` / `fix/*` branch, then `gh pr create`; docs-only changes may be pushed to main directly
- **Curate an ecosystem plugin**: tag your repo with `dsh-better-sidebar` + PR a `PluginEntry` into [`src/client/plugins-tabs.ts`](./src/client/plugins-tabs.ts) / [`plugins-viewers.ts`](./src/client/plugins-viewers.ts)
- **Before submitting**: `pnpm typecheck && pnpm build && pnpm test` (CI additionally gates on npm-pack → real-mount → headless-render via `pnpm test:mount`)
- See [`AGENTS.md`](./AGENTS.md) for the repository rules (hard constraints, CI lanes, release flow)
## ⭐ Star History
<a href="https://star-history.com/#omdsh-dev/DSH-better-sidebar&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date&theme=dark" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=omdsh-dev/DSH-better-sidebar&type=Date" />
</picture>
</a>
## 👥 Contributors
Thanks to everyone who contributed:
<a href="https://github.com/omdsh-dev/DSH-better-sidebar/graphs/contributors">
<img src="https://contrib.rocks/image?repo=omdsh-dev/DSH-better-sidebar" alt="Contributors" />
</a>
## 🔗 Friends
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness
- [dsh-TUI](https://github.com/ccch1mneyyy/dsh-TUI): a Claude Code-style fullscreen interactive TUI plugin — pixel-whale top bar, live working-status row, streaming thought expansion, double-Esc rollback, context progress bar + TPS meter; one-command npm install
- [dshfind Plugin Market](https://dshfind.com/zh/plugins): a third-party plugin marketplace — a listing of public repos under the GitHub topic `dsh-plugin`, with stars, contributors and growth data synced daily
- [DeepSeek Harness Desktop](https://github.com/anywhere-labs/deepseek-harness-desktop): a modern desktop client for the DeepSeek Harness ecosystem — start and manage a local Harness service without configuring Node.js or running commands; [official site](https://www.dshdesktop.cn)
---
<div align="center">
<sub>MIT License · Built for the <a href="https://github.com/deepseek-ai/deepseek-harness">DeepSeek Harness</a> ecosystem · discover more on the <a href="https://github.com/topics/dsh-better-sidebar">dsh-better-sidebar topic</a></sub>
</div>
@@ -146,3 +146,17 @@ isViewerEnabled(id: string): boolean
- **声明**:`SidebarSettingsDeclaration` 增加 `pluginToggles`(声明式行,控件同 toggles 的 switch/text/number,值须 JSON 可序列化)与 `render?: (props: SidebarSettingsRenderProps) => ReactNode`(自定义面板:store/service/prefs/pluginSettings/updatePluginSetting/close;抛错被吞并显示内联错误)。
- **渲染**:齿轮弹窗体抽为 `SettingsBody`(`render` 存在时优先,否则 toggles 行 + pluginToggles 行,后者把 pluginSettings 投影到 prefs 面复用 `FeatureSettingsRows`);`settingsFor` 状态从 `TabDescriptor | null` 扩为 `TabDescriptor | FileViewerDescriptor | null`——viewer 卡片 v0.12.0 起同样有齿轮设置弹窗(§4.6 原仅 tab)。
- **向后兼容**:旧设置文档无 `pluginSettings` 字段 → schema default `{}`;`toggles` 行为与 v0.11.0 完全一致。
---
## 9. 设置页 UI/UX 现代化(feat/settings-sidecard-uiux)
纯视觉/交互入口改造,无 API 与持久化语义变化(`settings.toggles` / `pluginToggles` / `render` / Modal 条件挂载契约全部不变):
- **二级设置入口:齿轮角标 → 卡片底部设置条**。原 `.cardGear`(16×16px 三级色透明幽灵按钮,`absolute; top:46px` 像素钉位 + `.cardWithGear` padding 耦合)可发现性不足;改为卡片底缘的**全宽设置条** `.cardSettings`(hairline 分隔、齿轮图标 12px + 「功能设置」文字标签 11px/500,hover 换 `--dsw-alias-interactive-bg-hover-accent` 品牌淡底 + brand 字色)。可见性条件不变(仅父级启用且声明 settings);aria-label 模式 `"${title} ${t('settingsPopup')}"` 不变(测试契约)。常规行齿轮 `.rowGear` 升级为 22×22 带 border-l2 边框的芯片按钮(hover 同样 accent 引导)。
- **启用态指示(后续迭代:勾选 → 紧凑开关滑块)**:启用卡片描边从全强度品牌色柔化为 `color-mix(button-primary-fill 45%)`。第一版曾把勾选徽标改为 `--dsw-alias-state-success-primary` 实心绿(绿 = 已开启确认,brand = 激活强调);按反馈再迭代为**紧凑品牌开关滑块** `.cardSwitch`(30×16 track + 10px thumb,开 = brand 填充 + thumb 反色,关 = 中性且不渲染滑块)——纯视觉指示,卡片本身仍是开关(`aria-pressed` 语义不变),不再使用 success 色。全部颜色仍为 `--dsw-alias-*` 令牌派生,零硬编码。
- **卡片等高**:`.card` 统一 `min-height: 106px` + `.cardMain { flex: 1 }` + `.cardTop { min-height: 28px }`——带设置条的卡片(天然更高)与不带设置条的卡片、无图标卡片全部等高;`prefers-reduced-motion` 覆盖新增的 `.cardSwitchTrack/Thumb` 过渡。
- **二级弹窗长列表**:`.popupRows` 增加 `max-height: min(56vh, 480px)` + 内部 `overflow-y: auto`(终端等声明 6 行设置的场景下弹窗壳体与 Done 按钮固定,仅行区滚动);行密度收紧(padding 12/14、gap 8)。
- **节奏**:section gap 16 / group padding 20 / 网格 `minmax(180px,1fr)` gap 12;计数徽章改为 `accent-soft` 淡底 pill(versionBadgeTag 同款令牌配方);开关关态 thumb 降一档(label-tertiary)+ track 底 layer-2。
- **动效纪律**:全部过渡 ≤160ms 且仅 background/border-color/color;`prefers-reduced-motion` 扩展覆盖 `.cardSettings` / `.rowGear` / `.popupRow`。
- **清理**:合并 `.pluginEntries` 重复定义(原 L598 与 L795 两处,保留带 max-height/overflow 的一处)。
@@ -0,0 +1,97 @@
# 侧边对话(Side Chat)Tab 设计
**日期**:2026-08-20
**状态**:已批准,实施中
**作者**:opencode + 用户
**当前版本**:v0.14.1(分支 `feat/sidechat-tab`)
**目标版本**:v0.14.1(不 bump)
## 1. 目标
1. 对标 Codex 的 side 对话,为 better-sidebar 新增内置 Tab **sidechat**:在当前主会话下开一个**继承主会话完整上下文**的独立子会话线程,线程在 Tab 内可续聊、可查看完整时间线(user / assistant / reasoning / tool)。
2. **继承必须是结构化的**:线程种子 = 父会话点击时刻的全部事件——已完成回合 + 未回答的 user 消息 + **进行中回合的 assistant 流式输出与工具调用**(原样复制,合成 `step/end`+`turn/end{reason:'interrupted'}` 诚实闭合,子会话看到的是「被切断的进行中回合」而非完整定稿)。
3. **前缀缓存复用**:子会话与父会话同组合(同 preset / 同 provider/model、无 persona/toolFilter、非 continuable 无 report 增量)→ 子会话首个请求的 token 前缀与父会话当前请求一致(到未回答 user 消息为止),命中 provider 前缀缓存。
4. **即用即销语义 + 保存为新会话**:线程以 `origin:'subagent'` 创建(主会话列表不可见、子代理目录零噪音);用户可一键「保存为新会话」——`session.fork` 提升为顶层会话(自动命名、挂工作区、打开)。
5. 零 dsh-subagent 依赖;dsh-subagent 缺失时功能不受影响。
## 2. 非目标(Out of Scope)
- 不做 `/btw` 一次性侧问(只做持续可续聊线程)。
- 不做合并回主会话 / 重命名线程。
- **不改 DSH 源码**:不加 promote RPC、不改 fork 边界、不改 continuation 机制。
- 工具执行中途(悬挂 tool/call)的事件级继承不可行(provider 拒绝悬挂 assistant 调用)→ 该窗口回退为「截断 + 结构化文本转储」(见 §4)。
## 3. 为什么自建机制(重实现)
用户明确拍板:不走 `ctx.subagents.startContinuable`。原因(已核实源码):
1. **种子粒度受限**:fork provider 的 `completedTurnPrefix` 固定切在最后一个 `turn/end`(`subagent-fork-in-process/src/index.ts`),`session.fork` RPC 显式拒绝 open-turn 锚点(`core/session` `OPEN_TURN`)——进行中回合永远进不了种子。
2. **continuable 组合破坏前缀复用**:continuable 子会话的请求头部在继承历史之前插入 `report` 工具 + `tool:report` 提示段(`.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md`,issue #2124)——前缀从请求头即分叉,重发整个转录却零 cache 收益。DSH 官方因此把 fork 绑定为 one-shot。
3. **无法注入自定义种子**:`ContinuableStartSpec` 无 seed 字段,创建路径无注入点。
自建机制只复用 DSH 公开 host 能力:`ctx.agents.create/resume`(AgentRegistry 公开方法,api-proxy fork 与 subagent driver 同用)、`agent.followup/cancel`(公开方法)、`ctx.get('agentPresets')`、`ctx.get('sessionTitle')`、`ctx.get('sessionPersistence')`。客户端复用 `ctx.sessions.list / fork / binding / open` 与通用 `session.history` RPC。
## 4. 种子算法(`src/sidechat-core.ts`)
1. 复制父会话**全部事件**(点击时刻快照;live 日志 seq === 数组下标,保持连续)。
2. 日志结束于 turn 外(最后 turn 边界是 turn/end 或无 turn):种子 = 全部复制事件(可能以未回答 user 消息结尾,合法)。
3. 结束于 open turn 内:
- **当前 step 存在无配对 tool/call(悬挂)** → 回退:切到该 turn 的 `turn/start` 之前(保留已完成回合 + 未回答 user 消息);进行中内容(含执行中的工具调用,标注 executing)走 `buildOpenTurnSnapshot` 结构化文本转储并入 boundary 消息。原因:validator 不拒悬挂调用(step/end 清空 pendingCalls),但 **provider 拒绝悬挂 assistant 调用**(`core/session/src/repair.ts:108`)。
- 否则:追加合成 `step/end{turn,step}`(若 step 未闭合)+ `turn/end{turn, reason:{kind:'interrupted'}}`(编号取自已复制事件的 turn/start、step/start)→ 种子平衡合法(种子逐事件过同一不变式检查器,`core/session/src/index.ts:517-541`;`turn/end` schema 要求 reason,`interrupted` 是合法持久化变体,语义即「回合未完成被切断」——诚实标记,客户端对冻结局部渲染「已停止」)。
子会话首个请求(常见路径)= [已完成回合][未回答 user][冻结的进行中回合:chunk 文本 + 已完成工具调用/结果][boundary+question]。前缀复用不受影响:共享前缀到未回答 user 消息为止,冻结回合位于共享前缀之后。
## 5. 线程生命周期(host 路由,`/sidebar/api/sidechat.*`,既有信任 fence)
- **start** `{sessionId, question}`:父 Agent 必须 live;`buildSidechatInheritance` 出种子 + 转储;`ctx.agents.create({meta:{cwd,parentSession,seedLength,origin:'subagent',delegationDepth:父+1,agentPreset}, seed, agentOptions:父 provider/model, setup:preset 组合})`;`agent.followup(createUserMessage({content:[boundary+转储?+question], source:{kind:'user'}}))`;`sessionTitle.rename(session,'Side: '+截断(question,48))` 钉死线程标签;返回 `{childId}`。
- **prompt** `{childId, text}`:live → `followup`;absent(重启/关闭后)→ `ctx.agents.resume`(`resolvePresetId` 从 persisted header/events 解析 preset,`agentPresets.resolve/mount` 组合)→ `followup`。
- **cancel** `{childId}`:`agent.cancel({kind:'user'},{keepInbox:true})`。
- **dispose** `{childId}`:释放创建/恢复时保存的 AgentHandle disposer(会话与历史保留)。
为什么全走自有路由:`origin:'subagent'` 的会话被通用 RPC 的 agent-lookup 所有权 fence 全面封锁(`api/remotes/agent-lookup.ts` `hasApiRemoteSubagentOwner`),`subagents.prompt` 对我们的线程也无法寻址——自有路由直接调 registry/agent 绕开 fence,且只依赖公开方法。~~线程没有 subagent/descriptor(无目录注册、零噪音:`list-children.ts` 投影为 undefined 即丢弃)~~ **修正(v3 起)**:该判断只对 live 线程成立——cold 线程无 descriptor 时 `resolveColdIdentity` 确定性产出 `corrupt` 诊断行(`list-children.ts:405`),用户实测在 Subagent 页看到「目录损坏」。修复 = 线程种子尾部追加合法的 `subagent/descriptor`(`snapshotSubagentDescriptor`,version 2 / continuable / provider `'sidechat'` / label 即 `Side: ` 标题),线程成为目录里的健康行;SubagentView 与 `subagent-detect.ts` 按 `Side: ` 标签/标题前缀过滤,拓扑 UI 与自动打开触发保持零噪音(模型侧 `list_agents` 也会列出带标签的健康行,而非 corrupt 垃圾)。
## 6. 客户端 Tab(`src/client/SideChatView.tsx`)
- 内置 descriptor:`id:'sidechat'`、`order:35`、`single:true`、图标 `IconNewChatOutline16`。
- 两栏布局:左线程列表(`sideThreadRows`:`origin==='subagent' && parentId===当前 && displayTitle 前缀 'Side: '`;订阅 `ctx.sessions.list`);右详情(时间线 + 输入框 + 保存/停止/关闭按钮)。
- 时间线:轮询 `connection.api.sessions.history`(通用 RPC,未 fence;`session/end-seed` 截断 + boundary 行丢弃 + chunk 流式累加 + 工具配对——`src/client/sidechat-transcript.ts` 纯映射);可见且运行中 ~2s,seq 去重缓存;`visible=false` 停轮询。
- 保存为新会话:`ctx.sessions.fork({sessionId: threadId, increaseTitle:true})` → `binding(newId).session.rename(标题去前缀)` → `ctx.sessions.open(newId)`。无已完成回合 → 禁用 + 提示;末条追问未完成 → 提示不包含(fork 边界=最后 turn/end)。
- 选中线程持久化:`tab.meta.threadId`(既有 per-session localStorage 通道)。
## 7. 边界情况与失败模式
| 场景 | 行为 |
|---|---|
| 父会话无 live agent | 409 `sidechat-error` 内联提示 |
| 父会话进行中(流式/思考/工具已返回) | 完整事件入种子 + interrupted 合成闭合 |
| 父会话工具执行中途点击新建 | 悬挂 tool/call → 截断 + 结构化转储(工具标注 executing) |
| seam 缺失(agentPresets/sessionTitle/sessionPersistence) | 降级:默认组合 / 自动标题 / 无 preset 组合的冷恢复 |
| DSH 重启后续聊 / 线程关闭后追问 | `ctx.agents.resume`(persisted preset 组合) |
| 线程无已完成回合 | 保存禁用 + 提示 |
| 线程末条追问未完成 | 保存前提示不包含 |
| 与 dsh-sidechain 并存 | 各自独立;双方线程都有 descriptor(目录健康行),SubagentView 按 `Side: ` 前缀过滤均不可见;双方共享 `Side: ` 标签约定,线程互见 |
## 8. 测试与自验
- 单测:`sidechat-core.spec.ts`(种子全分支:turn 外 / 空 / 仅 pending user / open turn 无工具 / 成对工具 / 悬挂回退 / 多 step 闭合 / seq 连续性;转储保真;线程过滤;保存资格;preset 解析;boundaryDelivered)、`sidechat-seed-validation.spec.ts`(真实 `Session.create` 校验器接受种子产物)、`sidechat-transcript.spec.ts`(种子截断、boundary 丢弃、chunk 流式、工具配对、孤儿失败工具)、`sidechat-routes.spec.ts`(start 带问/空问立即创建、首条 prompt 包裹边界+快照并改名、prompt live+cold、cancel、dispose、info live/cold/未知)、`builtins.spec.ts` 7 tab + sidechat 每线程一 Tab 铸造/去重。
- e2e 挂载冒烟:+ 菜单深扫含 Side Chat;**host 路由级自验**(真实 keyless dsh web 中 sidechat.start/prompt/cancel/dispose/info 全链路 + 空问题立即创建流程,种子会话为父)。
- 真实环境手测(实施后勾选):新建线程 → 追问往返 → 流式渲染 → 工具行 → 主会话流式中新建(冻结继承)→ 工具执行中途新建(回退转储)→ 重启后续聊 → 保存为新会话 → 刷新持久。
## 9. 复用门记录
搜索面:DSH checkout `docs/` + `packages/`(关键词 side conversation / sidetrack / thread / promote)+ dsh-external hub catalog(`search-org.mjs`,323 repos)。命中:`dsh-external/dsh-sidechain`(Codex 风格侧会话,/side & /btw,临时 fork + 侧栏面板),但**无 better-sidebar Tab、无保存为新会话**;用户拍板独立实现(「我们做独立实现」),sidechain 保持不动。本文档与 PR 记录该结论。
## 10. 实施偏差记录
- 路由键命名:`buildSidechatApi` 返回对象的键必须是**完整 wire 方法名**(`'sidechat.start'` 等,/sidebar/api 分发器按 `api[method]` 查找),而非短名 `start/prompt/…`。首次实现用了短名,单测直接调对象方法未暴露,真实挂载冒烟以 404 抓出(该冒烟因此新增了路由级自验,见 §8)。
- 种子函数入参类型:sidechat-core 使用宽松结构类型 `SidechatLogEvent {type, seq, time, data: unknown}`(同时接受 host 真实 `SessionEvent` 与 client 的 `SidebarSessionEvent` 镜像),避免两半的类型图互相污染。
- keyless 挂载冒烟无法验证 boundary 消息落盘:`agent.followup` 的消息先入 inbox,被 turn claim 后才写入 session 日志——keyless(无模型路由)下 turn 不会 claim。挂载冒烟的深度断言因此改为 **session.list 成员校验**(证明子会话真实创建,provider 无关);boundary 落盘与流式/回答在真实 provider 环境手测(§8 清单)。
- **UI 两轮返工(用户实测驱动)**:
1. 种子事件信封字段丢失(用户实测报 `invalid seed event … requires a surfaceOp marker`):`copyEvents` 重建事件时只保留 `{type,seq,time,data}`,剥掉了 `surfaceOp`/`sourceEventSeqs`/`ignorable`,而种子校验器要求 surface-eligible 事件必带 `surfaceOp`。修复 = 信封字段原样透传 + 新增 `sidechat-seed-validation.spec.ts`(用真实 `@deepseek-ai/dsh-session` 的 `Session.create` 校验器跑种子产物,回归守护)。
2. 交互模型从「单 Tab + 内嵌线程列表 + 首问表单」改为 **Codex app 对齐的「每线程一 Tab」**:`createTab` 铸造 `sidechat:new-<uuid>`(meta `autoCreate`,视图挂载即调 `sidechat.start` 空问题立即建线程)或消费 `parkSidechatReopen` 停泊的 `sidechat:<threadId>` 重连 Tab;`dedupeKey = meta.threadId` 保证同线程聚焦不重复;`onClose` 释放 live agent(历史保留,头部菜单可重开)。配套后端变化:`sidechat.start` 的 `question` 变为可选(空 = 仅建线程,快照停泊在 `pendingSnapshots`);`sidechat.prompt` 检测边界未送达时(`boundaryDelivered` 扫日志)自动包裹边界 + 停泊快照并用首条消息改名;新增 `sidechat.info` 路由(live 状态 + provider/model/preset 身份,供头部 Agent 徽标)。UI 对齐主对话区:用户右对齐气泡(`--dsw-specific-bubble`)、assistant 通栏 markdown、胶囊 composer(`--dsw-specific-input-major` + 圆形 accent 发送/停止钮 + 自动增高 textarea)、运行扫光状态行与工具行(`StateDot` + shimmer),动效全部 ≤200ms 交叉淡化并受 `prefers-reduced-motion` 收敛。
- (预留)实施过程中如有与本文档的其他偏差,在此记录。
- **第三轮实测修复(4 bug)**:
1. 「保存为新会话」报 `Cannot read properties of undefined (reading 'list')`:`handleSave` 把 `ctx.sessions.fork` 解构成自由函数再调用,丢失 `this`——client-runtime 的 `service.fork` 内部读 `this.list.getSnapshot()`(标题递增)。修复 = 保持方法调用形态(`ctx.sessions.fork({...})`)。
2. 图标语义:线程切换菜单改自绘 `IconHistoryOutline16`(逆时针时钟)、保存按钮改自绘 `IconSaveOutline16`(软盘),沿用 1.5px 描边规范(primitives 无现成 history/save 图标)。
3. 重开线程 Tab 丢工具行:根因**不在宿主**(`session.history` cold 路径原样返回 `tool/call`,见 `api-proxy.ts:749` 与 `chunk-rows.ts` 的无损 chunk 压缩)而在首挂回源窗口——旧实现 8 事件/页 × 32 页 = 256 事件上限,而 cold 读会把 chunk-rows 展开回每个流式 delta 一条事件,一轮回答动辄数百事件,较早的 `tool/call` 掉出窗口(settled 文本靠最终 `assistant/message` 幸存)。修复 = 抽出 `collectOwnEvents`(200 事件/页 × 40 页帽、日志穷尽/无标记时落定 `seedBoundary:0` 停止每轮重走),单测覆盖四分支。
4. Subagent 页「目录损坏」行:见 §5 修正——种子追加 descriptor + SubagentView/subagent-detect 按前缀过滤(`isSideThreadSummary` 同时修好 autoOpenSubagent 误触发与计数膨胀)。旧版创建的线程仍是 corrupt 诊断行,由 SubagentView 的标题前缀过滤兜底隐藏。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.14.1",
"version": "0.15.0",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
+16 -14
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.14.1",
"version": "0.15.0",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"type": "module",
"repository": {
@@ -96,6 +96,7 @@
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
"cordis": "^4.0.0-rc.8",
@@ -138,19 +139,20 @@
},
"devDependencies": {
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-agent": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-locale": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.8",
"@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.8",
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.8",
"@deepseek-ai/dsh-invariants": "0.1.0-rc.8",
"@deepseek-ai/dsh-llm": "0.1.0-rc.8",
"@deepseek-ai/dsh-session": "0.1.0-rc.8",
"@deepseek-ai/dsh-settings": "0.1.0-rc.8",
"@deepseek-ai/dsh-tools": "0.1.0-rc.8",
"@deepseek-ai/dsh-agent": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-locale": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-runtime": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "0.1.1-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "0.1.1-rc.1",
"@deepseek-ai/dsh-host-webserver": "0.1.1-rc.1",
"@deepseek-ai/dsh-invariants": "0.1.1-rc.1",
"@deepseek-ai/dsh-llm": "0.1.1-rc.1",
"@deepseek-ai/dsh-session": "0.1.1-rc.1",
"@deepseek-ai/dsh-settings": "0.1.1-rc.1",
"@deepseek-ai/dsh-subagent": "0.1.1-rc.1",
"@deepseek-ai/dsh-tools": "0.1.1-rc.1",
"@playwright/test": "^1.62.1",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
+1009 -981
View File
File diff suppressed because it is too large Load Diff
+142 -64
View File
@@ -11,9 +11,13 @@
* - the feature inventories are responsive GRIDS of small toggle cards
* (auto-fill columns — several cards per row, wrapping): icon chip +
* title + type id, the card's on/off state IS its visual state
* (enabled = brand border + tinted fill + circular check badge,
* disabled = neutral hairline + dimmed);
* - features that declare related settings carry a gear corner button
* (enabled = softened brand border + tinted fill + a compact brand
* switch knob at the far right — the card itself is the toggle;
* disabled = neutral hairline + dimmed, no knob); every card shares a
* uniform min-height so rows stay equal whether or not they carry a
* settings strip;
* - features that declare related settings carry a labeled SETTINGS STRIP
* across the card's bottom edge (gear icon + text, hairline-separated)
* opening a native Modal (wider than the primitive default) whose rows
* follow the same row recipe with switch controls and a Done footer;
* - the width input stays a native settings row (a value, not a switch).
@@ -22,7 +26,7 @@
.section {
display: flex;
flex-direction: column;
gap: 14px;
gap: 16px;
width: 100%;
max-width: 760px;
/* The DSH settings shell's `.options` area owns vertical scrolling. Keep
@@ -47,7 +51,7 @@
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px 20px 20px;
padding: 20px;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 16px;
@@ -67,26 +71,36 @@
color: var(--dsw-alias-label-primary);
}
/* The inventory count: a soft-accent pill (the versionBadgeTag token recipe)
so the count reads as metadata, not as part of the heading text. */
.count {
font-size: 12px;
line-height: 18px;
font-weight: 400;
color: var(--dsw-alias-label-tertiary);
padding: 1px 8px;
border-radius: 999px;
background: var(--dsw-alias-accent-soft, var(--dsw-alias-bg-layer-2));
font-size: 11px;
line-height: 16px;
font-weight: 500;
color: var(--dsw-alias-label-secondary);
font-variant-numeric: tabular-nums;
}
/* Responsive inventory: as many small cards per row as the width allows. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 10px;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
/* One small toggle card (visual chrome; the interactive part is .cardMain).
A uniform min-height keeps every inventory card the same height across
rows whether or not it carries a settings strip (strip cards are
naturally taller); overflow:hidden clips the strip to the card's radius. */
.card {
position: relative;
display: flex;
flex-direction: column;
min-height: 106px;
overflow: hidden;
border-radius: 12px;
border: 1px solid var(--dsw-alias-border-l2);
background: transparent;
@@ -102,15 +116,19 @@
border-color: var(--dsw-alias-label-dimmed);
}
/* Enabled = highlighted: brand border + tinted fill. */
/* Enabled = highlighted: a SOFTENED brand border (color-mix, the icon-chip
precedent) + tinted fill — full-strength brand reads harsh at card scale. */
.cardOn {
border-color: var(--dsw-alias-button-primary-fill);
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 45%, transparent);
background: var(--dsw-alias-interactive-bg-active);
}
/* The card's switch: the whole main area (top row + desc) flips the feature. */
/* The card's switch: the whole main area (top row + desc) flips the feature.
flex:1 lets the main area absorb the extra height of the uniform card
(cards without a settings strip stretch to match the strip cards). */
.cardMain {
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
width: 100%;
@@ -125,7 +143,7 @@
}
.cardMain:focus-visible,
.cardGear:focus-visible,
.cardSettings:focus-visible,
.rowGear:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
@@ -136,6 +154,7 @@
align-items: center;
gap: 8px;
min-width: 0;
min-height: 28px;
}
/* The icon sits in a rounded chip: brand-tinted while the card is on,
@@ -175,18 +194,46 @@
color: var(--dsw-alias-label-primary);
}
/* The check badge: a circular brand-filled pin at the card's far right
(enabled only), the icon in the inverted surface token (the .save recipe). */
.cardCheck {
/* The compact on/off switch knob at the card's far right (enabled only):
the card itself is the toggle (aria-pressed on .cardMain), so this is a
PURELY VISUAL switch — same track/thumb geometry as the row switches,
one size down. Off cards show no knob (neutral card = off). */
.cardSwitch {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 16px;
}
.cardSwitchTrack {
display: inline-flex;
align-items: center;
width: 30px;
height: 16px;
padding: 2px;
box-sizing: border-box;
border-radius: 8px;
background: var(--dsw-alias-bg-layer-2);
border: 1px solid var(--dsw-alias-border-l2);
transition: background 0.15s ease, border-color 0.15s ease;
}
.cardSwitchThumb {
display: block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--dsw-alias-label-tertiary);
transition: transform 0.15s ease, background 0.15s ease;
}
.cardOn .cardSwitchTrack {
border-color: var(--dsw-alias-button-primary-fill);
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-bg-layer-3);
}
.cardOn .cardSwitchThumb {
transform: translateX(14px);
background: var(--dsw-alias-bg-layer-3);
}
.cardDesc {
@@ -234,41 +281,42 @@
color: var(--dsw-alias-label-secondary);
}
/* Cards with a settings gear keep the desc clear of it: the gear sits in
the desc row, sharing the check badge's right edge (a vertical column of
controls at the card's right side). */
.cardWithGear .cardDesc {
padding-right: 30px;
}
/* The gear corner button: directly UNDER the check badge on the card's
right edge — the two controls are vertically aligned (same right inset,
one row apart) and the SAME 16px size, not side by side. */
.cardGear {
position: absolute;
right: 12px;
top: 46px;
display: inline-flex;
/* The settings strip: a labeled full-width footer across the card's bottom
edge (gear icon + "功能设置" text), hairline-separated from the toggle
area. This is the SECONDARY settings entry — it must be discoverable at
rest (not a hover-only ghost), so it keeps a steady secondary-ink label
and answers hover with the brand-accent tint + brand ink. */
.cardSettings {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
gap: 6px;
width: 100%;
padding: 6px 12px;
border: 0;
border-radius: 50%;
border-top: 1px solid var(--dsw-alias-border-l1);
background: transparent;
color: var(--dsw-alias-label-tertiary);
color: var(--dsw-alias-label-secondary);
font: inherit;
font-size: 11px;
line-height: 16px;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.cardGear:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
.cardOn .cardSettings {
border-top-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 18%, transparent);
}
.cardSettings:hover {
background: var(--dsw-alias-interactive-bg-hover-accent);
color: var(--dsw-alias-brand-primary);
}
/* The gear icon button on a general settings ROW (the position-compat
row's "customize the strip height" entry): the same 16px icon-button
recipe as the card gear, sitting in the row's control area. */
row's "customize the strip height" entry): a bordered chip (not a ghost)
so it reads as a control at rest; hover answers with the accent tint. */
.rowGear {
display: inline-flex;
align-items: center;
@@ -276,17 +324,19 @@
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 50%;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.rowGear:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
border-color: var(--dsw-alias-interactive-bg-hover-accent);
background: var(--dsw-alias-interactive-bg-hover-accent);
color: var(--dsw-alias-brand-primary);
}
/* Settings-row recipe (the DSH General section rows): title/desc left,
@@ -349,7 +399,7 @@
box-sizing: border-box;
border-radius: 10px;
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
background: var(--dsw-alias-bg-layer-2);
transition: background 0.15s ease, border-color 0.15s ease;
}
@@ -358,7 +408,7 @@
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-label-tertiary);
transition: transform 0.15s ease, background 0.15s ease;
}
@@ -493,21 +543,49 @@
/* Popup rows: each setting gets the same outer chrome as a plugin-list
PluginCard (l2 hairline, 12px radius, layer-3 fill). The row's title,
description and control geometry stay unchanged inside that frame. */
description and control geometry stay unchanged inside that frame.
The body scrolls INTERNALLY once a feature declares many rows (e.g.
Terminal's six) — the dialog shell and its Done footer stay put; a thin
token-driven scrollbar makes the scrollable region discoverable.
box-sizing:border-box keeps width:100% + the scrollbar gutter from
overflowing horizontally (a content-box width would add the 4px pad and
show a horizontal scrollbar); overflow-x:hidden is the belt-and-braces
guarantee that rows always FILL the width and never slide sideways. */
.popupRows {
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
width: 100%;
box-sizing: border-box;
max-height: min(52vh, 440px);
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: var(--dsw-alias-scrollbar-bg-l2, transparent) transparent;
}
.popupRows::-webkit-scrollbar {
width: 6px;
}
.popupRows::-webkit-scrollbar-thumb {
border-radius: 3px;
background: var(--dsw-alias-scrollbar-bg-l2, var(--dsw-alias-border-l2));
}
.popupRows::-webkit-scrollbar-thumb:hover {
background: var(--dsw-alias-scrollbar-hover-l2, var(--dsw-alias-label-dimmed));
}
.popupRow {
flex: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 0;
padding: 14px 16px;
padding: 12px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
@@ -595,12 +673,6 @@
color: var(--dsw-alias-label-tertiary);
}
.pluginEntries {
display: flex;
flex-direction: column;
gap: 10px;
}
/* One catalog entry: name + install button on the head row, description,
then the install command in a mono block (the exact script that gets
pre-typed into the terminal — review it here before installing). */
@@ -730,6 +802,11 @@
@media (prefers-reduced-motion: reduce) {
.card,
.cardSettings,
.cardSwitchTrack,
.cardSwitchThumb,
.rowGear,
.popupRow,
.switchTrack,
.switchThumb {
transition: none;
@@ -798,6 +875,7 @@
gap: 10px;
max-height: 46vh;
overflow-y: auto;
overflow-x: hidden;
padding-right: 2px;
}
+20 -16
View File
@@ -21,13 +21,15 @@
* one-line intro (the DSH section heading+intro recipe).
*
* A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
* border + tinted fill + a circular check badge pinned to the card's far
* right), disabled = neutral and dimmed. Features that declare
* `settings.toggles` carry a gear corner button that opens a native Modal
* (wider than the primitive default) with the related settings as
* title/desc + custom-switch rows and a Done footer. The toggles
* themselves are custom switches: a real checkbox (native semantics and
* focus) driving a styled track/thumb.
* border + tinted fill + a compact switch knob at the card's far right),
* disabled = neutral and dimmed. Features that declare
* `settings.toggles` carry a labeled settings strip at the card's bottom
* edge that opens a native Modal (wider than the primitive default) with
* the related settings as title/desc + custom-switch rows and a Done
* footer; the popup body scrolls internally when a feature declares many
* rows (e.g. Terminal's six). The toggles themselves are custom
* switches: a real checkbox (native semantics and focus) driving a styled
* track/thumb.
*
* Writes ride the plugin's own fenced settings route (the host calls the
* settings seam in-process — the DSH settings RPC domain does not serve
@@ -40,7 +42,6 @@
*/
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline16,
IconChevronDownOutline14,
IconPlusOutline16,
IconSettingsOutline16,
@@ -793,8 +794,9 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
* One SMALL toggle card for the responsive inventory grid: the card's main
* area is the switch (click to flips, visual state IS the state), the icon
* sits in a rounded chip, the check badge pins to the far right, and a
* feature that declares related settings carries a gear corner button
* opening its settings popup.
* feature that declares related settings gets a labeled SETTINGS STRIP
* across the card's bottom edge (gear icon + text) opening its settings
* popup — discoverable at rest, not a hover-only ghost corner button.
*/
const renderCard = (props: {
title: string
@@ -802,13 +804,13 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
icon?: ReactNode
enabled: boolean
onToggle: (next: boolean) => void
/** A feature with declared related settings shows the gear corner button. */
/** A feature with declared related settings shows the settings strip. */
onOpenSettings?: () => void
}) => {
const hasSettings = props.onOpenSettings !== undefined
return (
<div
className={clsx(css.card, props.enabled && css.cardOn, hasSettings && css.cardWithGear)}
className={clsx(css.card, props.enabled && css.cardOn)}
>
<button
type="button"
@@ -823,8 +825,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
)}
<span className={css.cardTitle}>{props.title}</span>
{props.enabled && (
<span className={css.cardCheck}>
<IconCheckOutline16 size={12} />
<span className={css.cardSwitch} aria-hidden="true">
<span className={css.cardSwitchTrack}>
<span className={css.cardSwitchThumb} />
</span>
</span>
)}
</span>
@@ -833,12 +837,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
{hasSettings && (
<button
type="button"
className={css.cardGear}
className={css.cardSettings}
aria-label={`${props.title} ${t('settingsPopup')}`}
title={t('settingsPopup')}
onClick={props.onOpenSettings}
>
<IconSettingsOutline16 size={12} />
<span>{t('settingsPopup')}</span>
</button>
)}
</div>
+437
View File
@@ -0,0 +1,437 @@
/* Side Chat tab: ONE side conversation per tab (Codex-style). Visual
* language mirrors the main conversation pane: right-aligned user bubbles
* on --dsw-specific-bubble, full-width assistant markdown, a capsule
* composer card on --dsw-specific-input-major with a circular accent
* send/stop button. Tool calls, thinking and context injections share one
* collapsible-line chrome (no cards); shimmer is reserved for generating
* states (running status line, streaming rows, the creating hero). Motion
* is crossfade-only (≤200ms entrances, no springs); reduced-motion disables
* every loop. All values consume DSW tokens (fallbacks point at older alias
* tokens for rc-era hosts). */
.sidechat {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* ── thread header (status dot · agent badge · threads menu · save) ─── */
.sidechatDetailHeader {
flex: none;
display: flex;
align-items: center;
gap: 4px;
min-height: 36px;
padding: 4px 8px 4px 12px;
border-bottom: 1px solid var(--dsw-alias-hairline);
}
.sidechatHeaderDot {
flex: none;
}
.sidechatHeaderSpacer {
flex: 1;
min-width: 0;
}
.sidechatAgentBadge {
flex: none;
max-width: 55%;
padding: 1px 8px;
border: 1px solid var(--dsw-alias-hairline);
border-radius: 999px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidechatIconBtn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
transition: background-color 100ms ease-out, color 100ms ease-out;
}
.sidechatIconBtn:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.sidechatIconBtn:disabled {
opacity: 0.4;
cursor: default;
}
/* ── hero (unbound tab: the one brand moment) ───────────────────────── */
.sidechatHero {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 24px 20px;
color: var(--dsw-alias-label-tertiary);
text-align: center;
animation: sidechatFadeIn 200ms ease-out;
}
.sidechatHeroTitle {
font: var(--dsw-font-s-14);
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.sidechatHeroDesc {
max-width: 300px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
line-height: 1.6;
}
.sidechatPrimaryBtn {
flex: none;
margin-top: 4px;
padding: 6px 14px;
border: none;
border-radius: 999px;
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
font: var(--dsw-font-s-13);
cursor: pointer;
transition: opacity 100ms ease-out;
}
.sidechatPrimaryBtn:hover:not(:disabled) {
opacity: 0.88;
}
.sidechatPrimaryBtn:disabled {
opacity: 0.4;
cursor: default;
}
.sidechatHint {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.sidechatError {
flex: none;
padding: 4px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-danger);
}
/* ── transcript ─────────────────────────────────────────────────────── */
.sidechatScroll {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* Rows crossfade in on append (keys are stable, so only NEW rows play). */
.sidechatScroll > * {
animation: sidechatRowIn 180ms ease-out;
}
/* User message: right-aligned bubble, same surface as the main pane. */
.sidechatUser {
align-self: flex-end;
max-width: 88%;
padding: 8px 14px;
border-radius: 18px;
background: var(--dsw-specific-bubble, var(--dsw-alias-bg-base));
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
overflow-wrap: anywhere;
}
/* Assistant message: full-width narration, no bubble — the main pane way. */
.sidechatAssistant {
align-self: stretch;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
overflow-wrap: anywhere;
}
/* ── collapsible context rows (tool / thinking / injection, Codex-style) ──
* One quiet line — chevron + label + one-line summary — that expands into
* an indented body hung on a hairline thread. No cards, no fills: hierarchy
* rides ink weight alone. Streaming rows shimmer the label (shimmer =
* generating); failed rows go danger; reduced-motion stills every loop. */
.sidechatRow {
align-self: stretch;
}
.sidechatRowLine {
display: flex;
align-items: center;
gap: 6px;
padding: 1px 0;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.sidechatRowSummary {
cursor: pointer;
list-style: none;
user-select: none;
}
.sidechatRowSummary::-webkit-details-marker {
display: none;
}
.sidechatRowSummary:hover {
color: var(--dsw-alias-label-secondary);
}
.sidechatRowStatic {
cursor: default;
}
.sidechatRowChevron {
flex: none;
display: inline-flex;
align-items: center;
transition: transform 100ms ease-out;
}
.sidechatRow[open] .sidechatRowChevron {
transform: rotate(90deg);
}
.sidechatRowLabel {
flex: none;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-secondary);
}
.sidechatRowMono {
font-family: var(--dsw-font-mono);
}
.sidechatRowMeta {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--dsw-font-mono);
color: var(--dsw-alias-label-tertiary);
}
.sidechatRowFailed .sidechatRowLabel,
.sidechatRowFailed .sidechatRowMeta {
color: var(--dsw-alias-danger);
}
.sidechatRowBody {
margin: 2px 0 4px 7px;
padding: 2px 0 2px 10px;
border-left: 1px solid var(--dsw-alias-hairline);
}
.sidechatRowProse {
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 240px;
overflow-y: auto;
}
.sidechatRowCode {
margin: 0;
padding: 4px 0;
font: var(--dsw-font-xxs-12);
font-family: var(--dsw-font-mono);
color: var(--dsw-alias-label-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
max-height: 220px;
overflow-y: auto;
}
.sidechatRowCode + .sidechatRowCode {
border-top: 1px solid var(--dsw-alias-hairline);
}
/* Shimmer sweep text: generating (streaming row labels, creating hero). */
.sidechatShimmerText {
background-image: linear-gradient(
90deg,
var(--dsw-alias-label-tertiary) 0%,
var(--dsw-alias-label-primary) 50%,
var(--dsw-alias-label-tertiary) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: sidechatSweep 2.6s linear infinite;
}
/* ── running status line (above the composer, main-pane style) ──────── */
.sidechatStatus {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 2px 14px 6px;
animation: sidechatFadeIn 160ms ease-out;
}
.sidechatStatusText {
font: var(--dsw-font-xxs-12);
background-image: linear-gradient(
90deg,
var(--dsw-alias-label-tertiary) 0%,
var(--dsw-alias-label-primary) 50%,
var(--dsw-alias-label-tertiary) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: sidechatSweep 2.6s linear infinite;
}
/* ── composer (capsule card, InputBar-aligned) ──────────────────────── */
.sidechatComposer {
flex: none;
display: flex;
flex-direction: column;
gap: 4px;
margin: 0 8px 8px;
padding: 8px 8px 6px 14px;
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin, var(--dsw-alias-hairline));
border-radius: 16px;
background: var(--dsw-specific-input-major, var(--dsw-alias-bg-base));
box-shadow: var(--dsw-shadow-lv2, none);
}
.sidechatComposerInput {
width: 100%;
box-sizing: border-box;
padding: 2px 0;
border: none;
background: transparent;
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-s-14);
line-height: 22px;
outline: none;
resize: none;
max-height: 132px;
}
.sidechatComposerInput::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.sidechatComposerBar {
flex: none;
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
}
.sidechatComposerMeta {
flex: 1;
min-width: 0;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sidechatSendBtn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
cursor: pointer;
/* send ⇄ stop swap: keyed remount plays this, hover only fades. */
animation: sidechatBtnIn 120ms ease-out;
transition: opacity 100ms ease-out;
}
.sidechatSendBtn:hover:not(:disabled) {
opacity: 0.88;
}
.sidechatSendBtn:disabled {
opacity: 0.35;
cursor: default;
}
/* ── motion vocabulary (crossfade only, ≤200ms; loops reduced-motion off) */
@keyframes sidechatRowIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes sidechatFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes sidechatBtnIn {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
@keyframes sidechatSweep {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) {
.sidechatScroll > *,
.sidechatHero,
.sidechatStatus,
.sidechatSendBtn {
animation: none;
}
.sidechatStatusText,
.sidechatShimmerText {
animation: none;
background-image: none;
color: var(--dsw-alias-label-tertiary);
}
.sidechatRowChevron {
transition: none;
}
}
+644
View File
@@ -0,0 +1,644 @@
/**
* Side Chat page: Codex-style side conversations for the current session.
*
* EVERY side conversation is its own sidebar tab (侧边对话1/2/3 …): the
* descriptor's createTab mints a fresh tab flagged `autoCreate` and this
* view creates the EMPTY thread on mount (one click = one conversation,
* exactly like the Codex app); the composer owns the first message (the
* host wraps it with the side boundary + the in-progress snapshot parked
* at creation, and the thread earns its real label — and the tab its
* title — from that first message). Closing the tab releases the thread's
* live agent (its history stays persisted); the header menu reopens any
* existing thread into a tab (deduped by threadId).
*
* Each side thread is a child session the plugin created itself with a
* custom seed (the parent's full log up to the click moment — see
* sidechat-core.ts). Transport: thread creation/follow-up/cancel/dispose/
* info go through the plugin's own /sidebar/api sidechat.* routes
* (subagent-origin identities are fenced from the generic session RPCs);
* the transcript is polled from the generic session.history RPC (seed-cut
* at session/end-seed, boundary row dropped, chunk streaming accumulated)
* — see sidechat-transcript.ts.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import {
IconChevronRightOutline14,
IconNewChatOutline16,
IconPlusOutline16,
IconSendOutline16,
IconStopFill16,
MarkdownText,
Menu,
StateDot,
type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
import type { Context, SidebarHistoryEntry } from '../context-types.ts'
import {
SIDE_LABEL_PREFIX,
SIDE_NEW_THREAD_TITLE,
sideThreadRows,
threadHasCompletedTurn,
threadTrailingPending,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import type { SidebarTab } from './state.ts'
import css from './SideChatView.module.css'
/** Tail-page size for one transcript poll (events per page). Small on
* purpose: streaming polls ride the tail and merge by seq. */
const PAGE_MESSAGES = 8
/** First-attach walk page size: cold reads re-expand chunk-rows into one
* event per streamed delta, so a single answer can be hundreds of events —
* the walk must page big or earlier tool/call rows fall out of the window. */
const WALK_PAGE_EVENTS = 200
/** Poll cadence while the selected thread is running and the tab visible. */
const POLL_MS = 2000
/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */
const COMPOSER_MAX_HEIGHT = 132
/** The thread a tab is bound to (durable in tab.meta across refreshes). */
export function sidechatThreadIdOf(tab: SidebarTab): string | undefined {
const meta = tab.meta as { threadId?: unknown } | undefined
return typeof meta?.threadId === 'string' ? meta.threadId : undefined
}
/** The parked reopen target consumed by the descriptor's createTab (the
* service's createTab receives no seed, so a thread-switch parks the id
* here and openTab picks it up synchronously — exactly one consume per
* park). */
let parkedReopen: string | undefined
/** Park a thread id for the NEXT sidechat openTab to reattach. */
export function parkSidechatReopen(threadId: string): void {
parkedReopen = threadId
}
/** Consume the parked reopen target (undefined = mint a fresh thread tab). */
export function consumeSidechatSeed(): string | undefined {
const value = parkedReopen
parkedReopen = undefined
return value
}
/** In-flight thread creations keyed by tab id (double-mount guard: React
* StrictMode / HMR must not mint two threads for one tab). */
const inFlightStarts = new Set<string>()
/** Per-thread transcript cache: seed boundary + thread-own events merged by
* seq (streaming polls never re-download the inherited seed). */
interface ThreadCache {
seedBoundary: number | null
entries: SidebarHistoryEntry[]
}
/** Row-render labels (locale-dependent, memoized once per mount). */
interface RowLabels {
copyLabel: string
copiedLabel: string
thinkLabel: string
injectionLabel: string
}
/** Merge history entries by event seq (newest wins), log order preserved. */
function mergeBySeq(
previous: readonly SidebarHistoryEntry[],
incoming: readonly SidebarHistoryEntry[],
): SidebarHistoryEntry[] {
const bySeq = new Map<number, SidebarHistoryEntry>()
for (const entry of previous) bySeq.set(entry.event.seq, entry)
for (const entry of incoming) bySeq.set(entry.event.seq, entry)
return [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq)
}
/** The display title of a thread: the durable label minus the 'Side: '
* prefix, with the fresh-thread placeholder localized. */
function threadDisplayTitle(title: string): string {
if (title === SIDE_NEW_THREAD_TITLE) return t('sideChatUntitled')
return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title
}
/**
* One collapsible context row — the shared Codex-style chrome of tool
* calls, thinking and context injections: a single quiet line (chevron +
* label + one-line summary) that expands into an indented body hung on a
* hairline thread. Rows with nothing to reveal render as a static line.
*/
function CollapsibleRow(props: {
label: string
meta?: string
mono?: boolean
streaming?: boolean
failed?: boolean
children?: React.ReactNode
}): React.ReactNode {
const label = (
<span
className={clsx(
css.sidechatRowLabel,
props.mono === true && css.sidechatRowMono,
props.streaming === true && css.sidechatShimmerText,
)}
>
{props.label}
</span>
)
const meta = props.meta !== undefined && props.meta !== ''
? <span className={css.sidechatRowMeta}>{props.meta}</span>
: null
if (props.children === undefined) {
return (
<div className={clsx(css.sidechatRowLine, css.sidechatRowStatic, props.failed === true && css.sidechatRowFailed)}>
{label}
{meta}
</div>
)
}
return (
<details className={css.sidechatRow}>
<summary
className={clsx(
css.sidechatRowLine,
css.sidechatRowSummary,
props.failed === true && css.sidechatRowFailed,
)}
>
<span className={css.sidechatRowChevron}>
<IconChevronRightOutline14 size={12} />
</span>
{label}
{meta}
</summary>
<div className={css.sidechatRowBody}>{props.children}</div>
</details>
)
}
/** One row renderer (React keys ride the source event seq). */
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
switch (row.kind) {
case 'user':
return (
<div key={`${row.kind}:${row.seq}`} className={css.sidechatUser}>
<MarkdownText text={row.text} codeLabels={labels} />
</div>
)
case 'assistant':
return (
<div key={`${row.kind}:${row.seq}`} className={css.sidechatAssistant}>
<MarkdownText text={row.text} codeLabels={labels} />
</div>
)
case 'reasoning':
return (
<CollapsibleRow
key={`${row.kind}:${row.seq}`}
label={labels.thinkLabel}
streaming={!row.settled}
>
<div className={css.sidechatRowProse}>{row.text}</div>
</CollapsibleRow>
)
case 'injection':
return (
<CollapsibleRow key={`${row.kind}:${row.seq}`} label={labels.injectionLabel}>
<div className={css.sidechatRowProse}>{row.text}</div>
</CollapsibleRow>
)
case 'tool': {
const body = (
<>
{row.args !== undefined && <pre className={css.sidechatRowCode}>{row.args}</pre>}
{row.resultText !== undefined && <pre className={css.sidechatRowCode}>{row.resultText}</pre>}
</>
)
return (
<CollapsibleRow
key={`${row.kind}:${row.seq}`}
label={row.name}
meta={toolArgsSummary(row.args)}
mono
streaming={row.executing === true}
failed={row.failed}
{...(row.args === undefined && row.resultText === undefined ? {} : { children: body })}
/>
)
}
}
}
/** One side conversation tab (one thread per tab, Codex-style). */
export function SideChatView(props: {
ctx: Context
scope: SessionScope
tab: SidebarTab
visible: boolean
}): React.ReactNode {
const { ctx, scope, tab, visible } = props
const rowLabels = useMemo<RowLabels>(
() => ({
copyLabel: t('copy'),
copiedLabel: t('copied'),
thinkLabel: t('sideChatThink'),
injectionLabel: t('sideChatInjection'),
}),
[],
)
// The session list feed: thread rows (the header menu) + running states.
const list = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
)
const threads = useMemo(
() => sideThreadRows(list.byId, scope.sessionId),
[list, scope.sessionId],
)
// The thread this tab is bound to rides tab.meta (refresh-restored).
const threadId = sidechatThreadIdOf(tab)
const autoCreate = (tab.meta as { autoCreate?: unknown } | undefined)?.autoCreate === true
const [composer, setComposer] = useState('')
const [busy, setBusy] = useState<'starting' | 'sending' | 'saving' | null>(null)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [revision, setRevision] = useState(0)
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ seedBoundary: null, entries: [] })
const controllerRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null)
const summary = threadId === undefined ? undefined : list.byId[threadId]
const running = summary?.running === true
/** The agent-identity badge of the thread header (preset · model). */
const agentBadge = useMemo(() => {
if (info === null) return ''
return [info.preset, info.model ?? info.provider].filter(Boolean).join(' · ')
}, [info])
/** Create this tab's thread (immediate-create tabs and hero retries). */
const startThread = useCallback(async (): Promise<void> => {
if (inFlightStarts.has(tab.id)) return
inFlightStarts.add(tab.id)
setBusy('starting')
setError(null)
try {
const { childId } = await api.sidechatStart(scope.sessionId)
ctx.betterSidebar?.updateTab(tab.id, { meta: { threadId: childId } })
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
inFlightStarts.delete(tab.id)
setBusy(null)
}
}, [ctx, scope.sessionId, tab.id])
// Codex-style immediate create: an autoCreate tab spawns its thread as
// soon as it first renders.
useEffect(() => {
if (threadId !== undefined || !autoCreate || !visible) return
void startThread()
}, [threadId, autoCreate, visible, startThread])
// The tab title follows the thread's durable label (the first prompt
// renames the thread; the strip picks it up here).
useEffect(() => {
const display = summary?.displayTitle
if (display === undefined) return
const title = threadDisplayTitle(display)
if (title !== '' && title !== tab.title) {
try {
ctx.betterSidebar?.updateTab(tab.id, { title })
} catch {
// A stale title is cosmetic; the thread keeps working.
}
}
}, [summary, tab.id, tab.title, ctx])
/** One transcript pull: the first read walks back to the seed boundary
* (big pages — chunk deltas re-expand on cold reads), later reads fetch
* one tail page and merge (seq-deduped). */
const fetchThread = useCallback(async (childId: string): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
const cache = cacheRef.current
try {
if (cache.seedBoundary === null) {
const walk = await collectOwnEvents(async (beforeSeq) => {
const response = await ctx.connection.api.sessions.history(
{
sessionId: childId,
maxMessages: WALK_PAGE_EVENTS,
...(beforeSeq === undefined ? {} : { beforeSeq }),
},
controller.signal,
)
if (!response.result.ok) throw new Error('history walk failed')
return response.result.value.events
})
cache.seedBoundary = walk.seedBoundary
cache.entries = mergeBySeq(cache.entries, walk.entries)
} else {
const response = await ctx.connection.api.sessions.history(
{ sessionId: childId, maxMessages: PAGE_MESSAGES },
controller.signal,
)
if (!response.result.ok) return
cache.entries = mergeBySeq(cache.entries, response.result.value.events)
}
setRevision(value => value + 1)
} catch {
// Aborted by a newer pull or a wire failure: keep the last rows.
}
}, [ctx])
/** The thread header badge pull (live state + preset/model identity). */
const fetchInfo = useCallback(async (childId: string): Promise<void> => {
try {
setInfo(await api.sidechatInfo(childId))
} catch {
// The badge is decorative; a wire failure keeps the last value.
}
}, [])
// Reset the transcript cache whenever the binding changes, then focus
// the composer — it owns the first message of a fresh thread.
useEffect(() => {
cacheRef.current = { seedBoundary: null, entries: [] }
controllerRef.current?.abort()
setError(null)
setSaved(false)
setInfo(null)
if (threadId !== undefined) {
void fetchInfo(threadId)
window.setTimeout(() => composerRef.current?.focus(), 0)
}
}, [threadId, fetchInfo])
// Poll while the tab is visible and the thread runs.
useEffect(() => {
if (!visible || threadId === undefined) return
void fetchThread(threadId)
if (!running) return
const timer = window.setInterval(() => {
void fetchThread(threadId)
void fetchInfo(threadId)
}, POLL_MS)
return () => { window.clearInterval(timer) }
}, [visible, threadId, running, fetchThread, fetchInfo])
useEffect(() => () => { controllerRef.current?.abort() }, [])
const rows = useMemo(
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
// The cache is a ref; revision bumps on every successful pull.
// eslint-disable-next-line react-hooks/exhaustive-deps
[threadId, revision],
)
const canSave = threadId !== undefined && threadHasCompletedTurn(cacheRef.current.entries)
const trailingPending = threadId !== undefined && threadTrailingPending(cacheRef.current.entries)
const freshThread = threadId !== undefined && rows.length === 0
// Follow the stream: stick to the bottom while the log grows.
useEffect(() => {
const scroller = scrollRef.current
if (scroller === null) return
scroller.scrollTop = scroller.scrollHeight
}, [rows.length, threadId])
/** Open a NEW thread tab (createTab mints the autoCreate tab; its view
* creates the thread on mount). */
const openNewThread = (): void => {
setMenuOpen(false)
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
}
/** Switch to an existing thread: parked for createTab, deduped to the
* already-open tab when there is one. */
const openExistingThread = (id: string): void => {
setMenuOpen(false)
if (id === threadId) return
parkSidechatReopen(id)
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
}
const menuItems = useMemo<MenuEntry[]>(() => {
const items: MenuEntry[] = [
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutline16 /> },
]
if (threads.length > 0) {
items.push({ type: 'separator', id: '$sep' })
for (const row of threads) {
items.push({
id: row.id,
label: threadDisplayTitle(row.title),
...(row.running ? { icon: <StateDot state="ongoing" size={8} /> } : {}),
})
}
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threads])
const growComposer = (): void => {
const field = composerRef.current
if (field === null) return
field.style.height = '0px'
field.style.height = `${Math.min(field.scrollHeight, COMPOSER_MAX_HEIGHT)}px`
}
const handleSend = async (): Promise<void> => {
const text = composer.trim()
if (text === '' || threadId === undefined || busy !== null) return
setBusy('sending')
setError(null)
try {
await api.sidechatPrompt(threadId, text)
setComposer('')
const field = composerRef.current
if (field !== null) field.style.height = ''
void fetchThread(threadId)
void fetchInfo(threadId)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
setBusy(null)
}
}
const handleCancel = async (): Promise<void> => {
if (threadId === undefined || busy !== null) return
try {
await api.sidechatCancel(threadId)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
}
}
const handleSave = async (): Promise<void> => {
if (threadId === undefined || !canSave || busy !== null) return
setBusy('saving')
setError(null)
setSaved(false)
try {
// NOTE: fork must stay a METHOD call — `ctx.sessions.fork` is the
// client-runtime sessions service, and an unbound reference loses
// `this` (its fork reads this.list for the title bump).
if (ctx.sessions.fork === undefined) throw new Error('session fork is unavailable')
const newId = await ctx.sessions.fork({ sessionId: threadId, increaseTitle: true })
const title = summary === undefined ? '' : threadDisplayTitle(summary.displayTitle).trim()
const binding = ctx.sessions.binding?.(newId)
if (binding !== undefined && title !== '') {
await binding.session.rename(title)
}
ctx.sessions.open?.(newId)
setSaved(true)
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause))
} finally {
setBusy(null)
}
}
// ── unbound tab: the hero (fresh autoCreate tabs flash a creating state
// until the thread lands; legacy persisted tabs offer a manual start) ──
if (threadId === undefined) {
return (
<div className={css.sidechat}>
<div className={css.sidechatHero}>
<IconNewChatOutline16 />
<div
className={clsx(
css.sidechatHeroTitle,
busy === 'starting' && css.sidechatShimmerText,
)}
>
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
</div>
<div className={css.sidechatHeroDesc}>{t('sideChatEmptyDesc')}</div>
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
{busy !== 'starting' && (
<button
type="button"
className={css.sidechatPrimaryBtn}
onClick={() => void startThread()}
>
{error === null ? t('sideChatNew') : t('sideChatRetry')}
</button>
)}
</div>
</div>
)
}
return (
<div className={css.sidechat}>
<div className={css.sidechatDetailHeader}>
{running && <StateDot state="ongoing" size={8} className={css.sidechatHeaderDot} />}
{agentBadge !== '' && <span className={css.sidechatAgentBadge}>{agentBadge}</span>}
<span className={css.sidechatHeaderSpacer} />
<Menu
open={menuOpen}
anchor={(
<button
type="button"
className={css.sidechatIconBtn}
onClick={() => { setMenuOpen(value => !value) }}
title={t('sideChatThreads')}
>
<IconHistoryOutline16 />
</button>
)}
items={menuItems}
selectedId={threadId}
onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }}
onClose={() => { setMenuOpen(false) }}
align="end"
portal
dense
/>
<button
type="button"
className={css.sidechatIconBtn}
onClick={() => void handleSave()}
disabled={!canSave || busy !== null}
title={`${t('sideChatSave')} — ${t('sideChatSaveTitle')}`}
>
<IconSaveOutline16 />
</button>
</div>
{!canSave && !freshThread && <div className={css.sidechatHint}>{t('sideChatNoTurn')}</div>}
{canSave && trailingPending
&& <div className={css.sidechatHint}>{t('sideChatPendingDrop')}</div>}
{saved && <div className={css.sidechatHint}>{t('sideChatSaved')}</div>}
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
<div ref={scrollRef} className={css.sidechatScroll}>
{rows.map(row => renderRow(row, rowLabels))}
</div>
{running && (
<div className={css.sidechatStatus}>
<StateDot state="ongoing" size={8} />
<span className={css.sidechatStatusText}>{t('sideChatThinking')}</span>
</div>
)}
<div className={css.sidechatComposer}>
<textarea
ref={composerRef}
className={css.sidechatComposerInput}
value={composer}
placeholder={freshThread ? t('sideChatFirstPlaceholder') : t('sideChatComposerPlaceholder')}
rows={1}
onChange={event => {
setComposer(event.target.value)
growComposer()
}}
onKeyDown={event => {
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
event.preventDefault()
void handleSend()
}}
/>
<div className={css.sidechatComposerBar}>
<span className={css.sidechatComposerMeta}>
{running ? '' : agentBadge}
</span>
{running ? (
<button
key="stop"
type="button"
className={css.sidechatSendBtn}
onClick={() => void handleCancel()}
disabled={busy !== null}
title={t('sideChatCancelTitle')}
>
<IconStopFill16 />
</button>
) : (
<button
key="send"
type="button"
className={css.sidechatSendBtn}
onClick={() => void handleSend()}
disabled={composer.trim() === '' || busy !== null}
title={t('sideChatSend')}
>
<IconSendOutline16 />
</button>
)}
</div>
</div>
</div>
)
}
+40 -8
View File
@@ -62,6 +62,16 @@ import css from './sidebar.module.css'
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
const FAILURE_LIMIT = 3
/**
* Subagent auto-open debounce (ms). The host delivers a new child's origin
* and its title in SEPARATE frames: a Side Chat thread's first visible
* frame still shows a fallback title (no 'Side: ' prefix), so an immediate
* 0→N decision mistakes it for a genuine subagent and pops the task page.
* The trigger therefore re-evaluates against the live snapshot once the
* title frame has had time to land.
*/
const AUTO_OPEN_DEBOUNCE_MS = 500
/**
* OS file drags over the sidebar belong to the sidebar, not to the chat:
* DSH's composer (InputBar) listens for file drags on the DOCUMENT and
@@ -395,23 +405,45 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* Switching to a session that already has subagents never triggers — its
* baseline starts at the current count — so a deliberate layout is never
* fought.
*
* The decision is DEBOUNCED (AUTO_OPEN_DEBOUNCE_MS): a Side Chat thread
* is also a subagent-origin child, and its 'Side: ' title lands one frame
* after its origin — an immediate check would misread that first frame as
* a new subagent and pop this page on every thread creation. The timer
* re-evaluates the ORIGINAL baseline against the live snapshot; by then
* the title filter (isSideThreadSummary) sees the settled label.
*/
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
const autoOpenPendingRef = useRef<{ baseline: SidebarSessionList; timer: number } | null>(null)
useEffect(() => {
const prev = listBaselineRef.current
listBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return
if (autoOpenPendingRef.current !== null) return
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
// Pin the landing to the right panel: the auto-opened Subagent page must
// appear where the panel just expanded, not in a bottom-panel pane the
// user last touched.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
const baseline = prev
const timer = window.setTimeout(() => {
autoOpenPendingRef.current = null
if (!detectNewDirectSubagent(baseline, ctx.sessions.list.getSnapshot(), sessionId)) return
if (!store.getPrefs().autoOpenSubagent) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
// Pin the landing to the right panel: the auto-opened Subagent page must
// appear where the panel just expanded, not in a bottom-panel pane the
// user last touched.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, AUTO_OPEN_DEBOUNCE_MS)
autoOpenPendingRef.current = { baseline, timer }
}, [sessionList, sessionId, store, ctx])
// A session switch (or unmount) voids any armed auto-open recheck.
useEffect(() => () => {
const pending = autoOpenPendingRef.current
if (pending !== null) window.clearTimeout(pending.timer)
autoOpenPendingRef.current = null
}, [sessionId])
/**
* Job auto-activation: the moment a NEW background job appears for the
* current conversation (a job id the previous snapshot lacked), the
+86 -69
View File
@@ -38,9 +38,11 @@ import type {
import {
collectBranchIds,
countSubagentDescendants,
isSideThreadSummary,
rootAncestor,
} from './subagent-detect.ts'
import { lastActivity } from './subagent-activity.ts'
import { type LastActivity } from '../subagent-activity.ts'
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
import {
collectTreeJobs,
formatJobDuration,
@@ -64,13 +66,16 @@ const JOB_POLL_MS = 2000
/** How long the kill button stays armed before it needs re-confirming. */
const JOB_KILL_ARM_MS = 3000
/** The direct subagent children of one parent (durable `origin` rows). */
/** The direct subagent children of one parent (durable `origin` rows;
* Side Chat threads ride the same origin but are tab-strip conversations,
* never topology). */
function directChildren(
byId: Readonly<Record<string, SidebarSessionSummary>>,
parentSessionId: string,
): SidebarSessionSummary[] {
return Object.values(byId).filter(
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId,
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId
&& !isSideThreadSummary(summary),
)
}
@@ -145,56 +150,14 @@ function CatalogLoadingRows(props: {
}
/**
* The live lines of one RUNNING subagent card: the last text output and the
* last tool call of the child's history tail, refreshed every few seconds
* while the page is visible. Idle cards render nothing (a quiet topology); a
* running child with neither output yet reads "thinking…".
* The live lines of one RUNNING subagent card: a pure presentation of the
* batch `subagents.live` activity. The polling lives in one place (the
* SubagentView hook), not per card. A running child with neither output yet
* reads "thinking…".
*/
function SubagentLiveLines(props: {
ctx: Context
parentSessionId: string
childSessionId: string
mode: SidebarSubagentAddress['mode']
running: boolean
/** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean
}) {
const { ctx, parentSessionId, childSessionId, mode, running, active } = props
const [live, setLive] = useState<ReturnType<typeof lastActivity>>({})
const controllerRef = useRef<AbortController | undefined>(undefined)
const address = useMemo(
() => ({ parentSessionId, childSessionId, mode }),
[parentSessionId, childSessionId, mode],
)
const load = useCallback(async (): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
try {
const response = await ctx.connection.api.subagents.history(
{ ...address, maxMessages: 12 },
controller.signal,
)
if (!response.result.ok) return
setLive(lastActivity(response.result.value.events))
} catch {
// Aborted by a newer pull or a wire failure: keep the last known lines.
}
}, [ctx, address])
useEffect(() => {
if (!active) return
void load()
if (!running) return
const timer = window.setInterval(() => { void load() }, POLL_MS)
return () => { window.clearInterval(timer) }
}, [load, running, active])
useEffect(() => () => { controllerRef.current?.abort() }, [])
if (!running) return null
if (live.text === undefined && live.tool === undefined) {
function SubagentLiveLines(props: { live: LastActivity | undefined }) {
const { live } = props
if (live?.text === undefined && live?.tool === undefined) {
return <span className={css.subagentLive}>{t('subagentThinking')}</span>
}
return (
@@ -214,6 +177,59 @@ function SubagentLiveLines(props: {
)
}
/**
* One shared live-preview poller for the whole Subagent tree. Unlike the old
* per-card `subagents.history` timers, this sends at most ONE `subagents.live`
* request at a time: a recursive timeout starts only after the previous
* request settles, so a slow host never sees abort/restart storms.
*/
function useSubagentLive(
rootId: string | undefined,
active: boolean,
): Readonly<Record<string, LastActivity>> {
const [live, setLive] = useState<Record<string, LastActivity>>({})
const controllerRef = useRef<AbortController | undefined>(undefined)
// A new tree must never inherit another root's live previews.
useEffect(() => { setLive({}) }, [rootId])
useEffect(() => {
if (rootId === undefined || !active) return
const targetRootId = rootId
let disposed = false
let timer: number | undefined
const schedule = (): void => {
if (disposed) return
timer = window.setTimeout(() => { void load() }, POLL_MS)
}
async function load(): Promise<void> {
if (disposed) return
const controller = new AbortController()
controllerRef.current = controller
try {
const result = await api.subagentsLive(targetRootId, controller.signal)
if (!disposed) setLive(result.live)
} catch {
// Keep the last known live map; the next scheduled poll retries.
} finally {
if (controllerRef.current === controller) controllerRef.current = undefined
if (!disposed) schedule()
}
}
void load()
return () => {
disposed = true
if (timer !== undefined) window.clearTimeout(timer)
controllerRef.current?.abort()
controllerRef.current = undefined
}
}, [rootId, active])
return live
}
interface RowsProps {
parentSessionId: string
catalog: SidebarSubagentCatalog | undefined
@@ -222,19 +238,26 @@ interface RowsProps {
level: number
/** The currently-open session id (highlighted in the topology). */
currentSessionId: string
/** The page is visible (active tab + open panel): live polling pauses otherwise. */
active: boolean
ctx: Context
/** The batch live-preview map (child id → latest activity). */
live: Readonly<Record<string, LastActivity>>
openChild: (address: SidebarSubagentAddress) => void
refresh: (parentSessionId: string) => void
}
/** Render one topology level; branches are always expanded (lazy catalogs). */
function CatalogRows({
parentSessionId, catalog, catalogs, byId, level, currentSessionId, active, ctx,
parentSessionId, catalog, catalogs, byId, level, currentSessionId, live,
openChild, refresh,
}: RowsProps) {
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
// Side Chat threads are honest catalog citizens (durable descriptor, 'Side: '
// label) but they are NOT subagent topology — filter them out here (the tab
// strip owns them). Legacy threads created before the descriptor fix still
// arrive as corrupt diagnostics; they are recognized by summary title.
const visibleEntries = (catalog?.entries ?? []).filter((entry) => {
if (entry.kind === 'child') return !(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false)
return !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false)
})
return (
<>
{emptyLoading && (
@@ -253,7 +276,7 @@ function CatalogRows({
</button>
</div>
)}
{(catalog?.entries ?? []).map((entry) => {
{visibleEntries.map((entry) => {
if (entry.kind === 'diagnostic') {
return (
<div key={entry.id} className={css.subagentNode}>
@@ -314,14 +337,9 @@ function CatalogRows({
<span className={css.subagentContent}>
<span className={css.subagentLabel}>{label}</span>
<span className={css.subagentSecondary}>{secondary}</span>
<SubagentLiveLines
ctx={ctx}
parentSessionId={parentSessionId}
childSessionId={entry.id}
mode={entry.mode}
running={entry.activity === 'running'}
active={active}
/>
{entry.activity === 'running' && (
<SubagentLiveLines live={live[entry.id]} />
)}
</span>
</div>
{!knownLeaf && (
@@ -342,8 +360,7 @@ function CatalogRows({
byId={byId}
level={level + 1}
currentSessionId={currentSessionId}
active={active}
ctx={ctx}
live={live}
openChild={openChild}
refresh={refresh}
/>
@@ -645,6 +662,7 @@ export function SubagentView(props: {
const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
const rootCatalog = rootId === undefined ? undefined : catalogs[rootId]
const rootSummary = rootId === undefined ? undefined : byId[rootId]
const live = useSubagentLive(rootId, active)
/** Catalog owners currently consuming live membership updates. */
const observedRef = useRef(new Set<string>())
@@ -839,8 +857,7 @@ export function SubagentView(props: {
byId={byId}
level={1}
currentSessionId={sessionId}
active={active}
ctx={ctx}
live={live}
openChild={openChild}
refresh={refresh}
/>
+29
View File
@@ -7,6 +7,8 @@
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { SidechatThreadInfo } from '../sidechat-core.ts'
import type { BrowserProbeResult } from './browser.ts'
/** One wire failure. */
@@ -79,6 +81,9 @@ export interface JobOutputResult {
read: boolean
}
/** The `subagents.live` response: running child id → latest activity. */
export type SubagentLiveResult = { live: Record<string, LastActivity> }
/** Terminal dependency status (mirror of the host's depsStatus; issue #140). */
export type TerminalDepsStatus =
| { ok: true }
@@ -244,6 +249,30 @@ export const api = {
id,
...(reason !== undefined ? { reason } : {}),
})),
/**
* One batch live-preview fetch for the whole Subagent tree. The payload is
* the already-resolved topology ROOT (not a session scope); the host
* enumerates descendants once and folds running children's activity.
*/
subagentsLive: (rootSessionId: string, signal?: AbortSignal) =>
call<SubagentLiveResult>('subagents.live', { rootSessionId }, signal),
/** Create a Side Chat thread: a child session seeded with the parent's
* full log up to now. Empty question = immediate create (Codex-style):
* the thread opens empty, the first prompt carries the boundary. */
sidechatStart: (sessionId: string, question?: string) =>
call<{ childId: string }>('sidechat.start', { sessionId, question: question ?? '' }),
/** Deliver one follow-up message to a Side Chat thread. */
sidechatPrompt: (childId: string, text: string) =>
call<{ accepted: true }>('sidechat.prompt', { childId, text }),
/** Abort a Side Chat thread's running turn (queued work is preserved). */
sidechatCancel: (childId: string) =>
call<{ accepted: true }>('sidechat.cancel', { childId }),
/** Release a Side Chat thread's live agent (history stays persisted). */
sidechatDispose: (childId: string) =>
call<{ accepted: true }>('sidechat.dispose', { childId }),
/** Live state + agent identity (provider/model/preset) of a thread. */
sidechatInfo: (childId: string) =>
call<SidechatThreadInfo>('sidechat.info', { childId }),
/** The effective terminal shell and its display name (plugin-global). */
shellGet: () =>
call<{ shell: string; name: string }>('shell.get', {}),
+50 -3
View File
@@ -1,13 +1,13 @@
/**
* The 6 built-in tab descriptors: the plugin registers its own pages
* (editor / git / terminal / browser / subagent / diff) through
* The 7 built-in tab descriptors: the plugin registers its own pages
* (editor / git / subagent / sidechat / terminal / browser / diff) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
@@ -17,6 +17,8 @@ import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
@@ -165,6 +167,51 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
/>
),
},
{
id: 'sidechat',
title: () => t('sideChat'),
icon: (size: number) => <IconNewChatOutline16 size={size} />,
order: 35,
// Codex-style: EVERY side conversation is its own tab. A plain open
// mints a fresh tab flagged `autoCreate` (the view creates the EMPTY
// thread on mount); a thread switch from the header menu parks the
// target id for a deterministic `sidechat:<threadId>` reattach tab.
createTab: () => {
const threadId = consumeSidechatSeed()
if (threadId !== undefined) {
return {
tab: {
id: `sidechat:${threadId}`,
type: 'sidechat',
title: t('sideChat'),
meta: { threadId },
},
}
}
return {
tab: {
id: `sidechat:new-${crypto.randomUUID()}`,
type: 'sidechat',
title: t('sideChatUntitled'),
meta: { autoCreate: true },
},
}
},
// One tab per thread: an already-open thread focuses instead of
// duplicating; unbound fresh tabs never dedupe (each mints its own).
dedupeKey: (tab) => sidechatThreadIdOf(tab),
// Closing the tab releases the thread's live agent; the session and
// its history stay persisted (reopen from any thread's header menu).
onClose: (tab) => {
const threadId = sidechatThreadIdOf(tab)
if (threadId !== undefined) {
void api.sidechatDispose(threadId).catch(() => {})
}
},
component: ({ ctx, scope, tab, visible }) => (
<SideChatView ctx={ctx} scope={scope} tab={tab} visible={visible} />
),
},
{
id: 'terminal',
title: () => t('terminal'),
+20
View File
@@ -117,3 +117,23 @@ export const IconGlobeOutline16 = ({ size = 16, className }: IconProps) => (
<path d="M1.5 8h13M8 1.5c-2.4 1.8-2.4 11.2 0 13M8 1.5c2.4 1.8 2.4 11.2 0 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
/** History glyph (thread switcher): a clock with a counterclockwise arrow,
* in the app's outline style — the "past conversations" mark. */
export const IconHistoryOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.4 6.8A5.6 5.6 0 1 1 2.4 9.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M2.2 3.4v3.4h3.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8 5.4V8l1.9 1.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
/** Save glyph (save-as-new-session): the classic floppy disk, in the app's
* outline style. */
export const IconSaveOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.2 14.5h7.6a1.2 1.2 0 0 0 1.2-1.2V4.9L10.6 2.5H4.2A1.2 1.2 0 0 0 3 3.7v9.6a1.2 1.2 0 0 0 1.2 1.2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M10 2.5v2.6H5.6V2.5" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M5.4 14.5v-4.2h5.2v4.2" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
)
+58
View File
@@ -247,6 +247,30 @@ export const zh = {
subagentDiagUnsupported: '不支持的条目',
subagentDiagUnavailable: '不可用',
subagentThinking: '思考中…',
sideChat: '侧边对话(beta)',
sideChatNew: '新建对话',
sideChatUntitled: '新对话',
sideChatEmpty: '暂无侧边对话',
sideChatEmptyDesc: '每个侧边对话是标签栏里的独立 Tab,继承当前会话的上下文运行,不会进入主会话',
sideChatCreating: '正在创建侧边对话…',
sideChatRetry: '重试',
sideChatThreads: '切换线程 / 新建',
sideChatSave: '保存为新会话',
sideChatSaveTitle: '把该线程提升为顶层会话,出现在主会话列表中',
sideChatSaved: '已保存为新会话',
sideChatNoTurn: '至少完成一轮对话后才能保存',
sideChatPendingDrop: '最后一条未完成的追问不会包含在新会话中',
sideChatFirstPlaceholder: '输入第一个问题,已继承当前会话上下文…',
sideChatComposerPlaceholder: '追问…',
sideChatThinking: '正在深入…',
sideChatThink: '思考过程',
sideChatInjection: '已注入上下文',
sideChatSend: '发送',
sideChatCancel: '停止',
sideChatCancelTitle: '中止当前回合(保留队列)',
sideChatClose: '关闭线程',
sideChatCloseTitle: '释放线程的 agent(历史保留)',
sideChatError: '侧边对话出错:{message}',
jobs: '后台任务',
jobsCount: '{count} 个后台任务',
jobsCountRunning: '{count} 个后台任务 · {running} 运行中',
@@ -281,10 +305,15 @@ export const zh = {
openPlugin: '跳转',
copyInstall: '复制安装命令',
pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装',
pluginFlowglassDesc: '实时会话流程图:三列泳道展示用户、助手与工具调用,支持并行分组、子代理支线、逐层钻取和实时状态;安装 better-sidebar 后注册原生「流镜」Tab,未安装时保留独立抽屉',
pluginGitForgeDesc: 'better-sidebar「Git 凭据」Tab:GitHub/Gitea 等 Forge 账号库 + 按项目授权 + push 策略硬拦;token 仅存本地 secrets,不进模型上下文;提供只读 GitForge 工具与 agent HTTPS credential helper',
pluginGitRemotesDesc: 'better-sidebar Git 远程 Tab:看分支/上游/ahead-behind,fetch(可 prune)、ff-only pull、确认后才 push。不替换内置 Git 的暂存/提交,也不提供 force-push 或模型自动推送',
pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表',
pluginSidebarQaDesc: '基于 better-sidebar 的划选提问tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档',
pluginSshTunnelDesc: 'better-sidebar「SSH 隧道」Tab:多机主机清单 + 按项目授权 + 密钥本地保管;模型工具 SSHManager(exec/SFTP/会话策略);中央交互终端与双栏 SFTP',
pluginTurnReviewDesc: '对「刚刚这一回合」的 diff 做 Approve / Request changes 的人闸门:只审上一回合,不 fork 会话;文件按主会话/子代理/未归因分组,按文件勾选打回 + 可选评语,点文件先看回合开始快照 vs 现在的 diff。不是 /rewind',
pluginVideoPreviewDesc: '在 better-sidebar 编辑器内联预览视频文件(.mp4/.webm/.mov/.mkv/.avi 等),自带支持 HTTP Range(206)的 /video 宿主路由,可拖动进度条、不受 20MB mediaLimit 限制',
pluginDocsPanelDesc: 'DSH 侧边栏里的「全局文档」:全局 Markdown 笔记,任何工作区随时可读——列表点选阅读、悬浮大纲跳转、Chrome / VS Code 外部打开、代码复制,目录可配置(默认 ~/.dsh/docs)',
}
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
@@ -525,6 +554,30 @@ export const en: Record<keyof typeof zh, string> = {
subagentDiagUnsupported: 'Unsupported',
subagentDiagUnavailable: 'Unavailable',
subagentThinking: 'Thinking…',
sideChat: 'Side Chat (beta)',
sideChatNew: 'New thread',
sideChatUntitled: 'New thread',
sideChatEmpty: 'No side conversations',
sideChatEmptyDesc: 'Every side conversation is its own tab in the tab strip — it inherits the current session\'s context and never enters the main conversation',
sideChatCreating: 'Creating side conversation…',
sideChatRetry: 'Retry',
sideChatThreads: 'Switch thread / new',
sideChatSave: 'Save as new session',
sideChatSaveTitle: 'Promote this thread to a top-level session in the main session list',
sideChatSaved: 'Saved as a new session',
sideChatNoTurn: 'Save is available after the first completed turn',
sideChatPendingDrop: 'The last unanswered follow-up will not be included in the saved session',
sideChatFirstPlaceholder: 'Ask the first question — context inherited…',
sideChatComposerPlaceholder: 'Ask a follow-up…',
sideChatThinking: 'Deep diving…',
sideChatThink: 'Thinking',
sideChatInjection: 'Context injected',
sideChatSend: 'Send',
sideChatCancel: 'Stop',
sideChatCancelTitle: 'Abort the running turn (queued work is kept)',
sideChatClose: 'Close thread',
sideChatCloseTitle: 'Release the thread\'s agent (history is kept)',
sideChatError: 'Side Chat error: {message}',
jobs: 'Background jobs',
jobsCount: '{count} background jobs',
jobsCountRunning: '{count} background jobs · {running} running',
@@ -559,10 +612,15 @@ export const en: Record<keyof typeof zh, string> = {
openPlugin: 'Open',
copyInstall: 'Copy install command',
pluginOfficeDesc: 'Office-suite preview (.docx / .xlsx / .pptx) for the better-sidebar editor, keeping the heavy Office render libraries out of the core bundle',
pluginFlowglassDesc: 'Live session flowgraph with three lanes for user, assistant, and tool calls, plus parallel groups, sub-agent branches, drill-down, and live status; registers a native Flowglass tab when better-sidebar is installed and keeps its standalone drawer as a fallback',
pluginGitForgeDesc: 'Git Forge tab: GitHub/Gitea (and other forge) account library + per-project grants + hard push policy; tokens stay in local secrets (never in model context); read-only GitForge tool and agent HTTPS credential helper',
pluginGitRemotesDesc: 'Git Remotes tab: branch/upstream/ahead-behind, fetch (optional prune), ff-only pull, and push only after an in-tab confirm. Does not replace the built-in Git stage/commit tab, and does not offer force-push or a model auto-push tool',
pluginSentinelDesc: 'Condition-driven agent wakeup: file/process/port/http/command/webhook sensors wake dormant sessions when conditions fire; registers a "Sentinel" tab with the server-wide watch table',
pluginSidebarQaDesc: 'Select-and-ask: Select conversation text → ask in the right-side panel → a dedicated follow-up session (❓追问) in the same workspace; a fast no-thinking model compresses the main context and injects it with the quote, without interrupting the main conversation. Follow-ups nest, continue, and archive',
pluginSshTunnelDesc: 'SSH Tunnel tab: multi-host inventory + per-project grants + local secrets; SSHManager tool (exec/SFTP/session strategies); center interactive terminal and dual-pane SFTP',
pluginTurnReviewDesc: 'A human gate on the just-finished turn: Approve / Request changes per path with an optional comment; paths grouped by main session / subagent / unattributed; inline snapshot-vs-now diff before you decide. No fork, no /rewind',
pluginVideoPreviewDesc: 'Inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) for the better-sidebar editor, backed by a dedicated /video host route with HTTP Range (206) support — scrubbing works and files are not capped by the 20MB mediaLimit',
pluginDocsPanelDesc: 'Global docs in the DSH sidebar: read your own Markdown notes from any workspace — a file list, an outline, open in Chrome / VS Code, and copy buttons; the docs directory is configurable (default ~/.dsh/docs)',
}
/**
+44
View File
@@ -24,6 +24,33 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
// context consistent with the other entries.
install: 'cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"',
},
{
id: 'dsh-docs-panel',
name: 'dsh-docs-panel 全局文档',
url: 'https://github.com/mlosun/dsh-docs-panel',
description: () => t('pluginDocsPanelDesc'),
// dsh-docs-panel hard-depends on dsh-better-sidebar (required peer), so
// the install line installs the prerequisite first, then the plugin.
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add dsh-docs-panel',
},
{
id: 'dsh-flowglass',
name: 'dsh-flowglass 流镜',
url: 'https://github.com/Iwctwbh/dsh-flowglass',
description: () => t('pluginFlowglassDesc'),
// Flowglass keeps its standalone drawer as a fallback and registers the
// native tab automatically when better-sidebar is present.
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add dsh-flowglass',
},
{
id: 'dsh-git-forge',
name: 'dsh-git-forge Git 凭据',
url: 'https://github.com/thirsty5034/dsh-git-forge',
description: () => t('pluginGitForgeDesc'),
// Peer-depends on dsh-better-sidebar (Git Forge tab). Install the
// prerequisite first; package is GitHub-sourced until npm publish.
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add "dsh-git-forge@github:thirsty5034/dsh-git-forge"',
},
{
id: 'dsh-git-remotes',
name: 'dsh-git-remotes Git 远程',
@@ -40,4 +67,21 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
// the install line installs the prerequisite first, then the plugin.
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/ChenRuoT/dsh-sidebar-qa.git',
},
{
id: 'dsh-ssh-tunnel',
name: 'dsh-ssh-tunnel SSH 隧道',
url: 'https://github.com/thirsty5034/dsh-ssh-tunnel',
description: () => t('pluginSshTunnelDesc'),
// Peer-depends on dsh-better-sidebar (SSH Tunnel tab + center terminal/SFTP).
// Install the prerequisite first; package is GitHub-sourced until npm publish.
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add "dsh-ssh-tunnel@github:thirsty5034/dsh-ssh-tunnel"',
},
{
id: 'dsh-turn-review',
name: 'dsh-turn-review 本轮审查',
url: 'https://github.com/yq04/dsh-turn-review',
description: () => t('pluginTurnReviewDesc'),
// Needs dsh-better-sidebar (optional peer) for the tab; no model tools.
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/yq04/dsh-turn-review.git',
},
]
+1 -1
View File
@@ -470,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
* The plugin version this service instance reports. Keep in lockstep with
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
*/
export const SIDEBAR_SERVICE_VERSION = '0.14.1'
export const SIDEBAR_SERVICE_VERSION = '0.15.0'
/**
* Monotonic capability list consumers use to gate new API usage (features
+321
View File
@@ -0,0 +1,321 @@
/**
* Side Chat transcript mapping (browser half): turns a thread child's
* history rows (`session.history` — the generic RPC, which reads the durable
* log without activating the child) into compact display rows.
*
* A thread child's log starts with the ENTIRE inherited parent log as its
* fork seed. The mapping therefore cuts everything up to the LAST
* `session/end-seed` marker and maps context injections (the "Side
* conversation boundary" prompt, plugin-sourced context) onto a collapsible
* injection row, so the view shows only the thread's own conversation.
*
* Live streaming: `assistant/message` events only land when a step
* completes, but `assistant/chunk` events stream token-level text and
* reasoning deltas. The mapping accumulates both per block and supersedes
* them with the assembled message once it lands (settled rows).
*/
import type { SidebarHistoryEntry } from '../context-types.ts'
import { isContextInjectionMessage, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts'
/** One compact transcript row rendered in the thread view. `seq` is the
* source event's log sequence — stable row identity for React keys across
* polls (streaming caches ride the key, so window slides must not re-key
* rows). */
export type SidechatTranscriptRow =
| { kind: 'user'; seq: number; text: string }
/** A context injection (the side boundary prompt + the parked in-progress
* snapshot, or any plugin-sourced context): rendered as one collapsible
* row, never as a user bubble. */
| { kind: 'injection'; seq: number; text: string }
/** `settled` distinguishes an assembled message from a still-streaming
* chunk accumulation (streaming rows are superseded by the settle). */
| { kind: 'assistant'; seq: number; text: string; settled: boolean }
| { kind: 'reasoning'; seq: number; text: string; settled: boolean }
| {
kind: 'tool'
seq: number
name: string
failed: boolean
/** Raw arguments JSON as the model produced it. */
args?: string
/** Plain text of the paired result. */
resultText?: string
/** True while the call's result has not landed yet. */
executing?: boolean
}
/** Extract the visible text of a content-block list (`text` blocks verbatim,
* joined by blank lines); empty reads `…` so rows never render blank. */
export function blockText(content: readonly unknown[]): string {
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'text' && typeof candidate.text === 'string') {
parts.push(candidate.text)
}
}
const text = parts.join('\n\n')
return text === '' ? '…' : text
}
/** Cap for a tool row's one-line argument summary (display only). */
const ARGS_SUMMARY_MAX = 80
/** The most identifying argument keys, in priority order (bash's command,
* fs tools' paths, search's pattern, …). */
const ARGS_SUMMARY_KEYS = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'prompt'] as const
function flatTruncate(text: string): string {
const flat = text.replace(/\s+/g, ' ').trim()
return flat.length > ARGS_SUMMARY_MAX ? `${flat.slice(0, ARGS_SUMMARY_MAX - 1)}…` : flat
}
/**
* One-line summary of a tool call's raw arguments JSON for the collapsed
* row: the first identifying string field when the JSON parses, else the
* flattened raw text; empty when there is nothing worth showing.
*/
export function toolArgsSummary(args: string | undefined): string {
if (args === undefined) return ''
try {
const parsed = JSON.parse(args) as Record<string, unknown> | null
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
for (const key of ARGS_SUMMARY_KEYS) {
const value = parsed[key]
if (typeof value === 'string' && value.trim() !== '') return flatTruncate(value)
}
}
} catch {
// Raw text fallthrough.
}
return flatTruncate(args)
}
/** The plain text of a tool/result message (text blocks inside its
* `tool-result` content block). */
function resultTextOf(data: Record<string, unknown>): string {
const message = data.message as { content?: unknown } | undefined
const content = message?.content
if (!Array.isArray(content)) return ''
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; content?: unknown }
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') {
parts.push(textItem.text)
}
}
}
return parts.join('\n')
}
/** Index of the last `session/end-seed` event (fork seed marker), or -1. */
function lastSeedEnd(events: readonly { type: string }[]): number {
for (let index = events.length - 1; index >= 0; index--) {
if (events[index]?.type === 'session/end-seed') return index
}
return -1
}
/**
* Collect the thread's OWN events on first attach: walk backward from the
* log tail (oldest-first accumulation) until the `session/end-seed` marker
* surfaces, then keep everything after it.
*
* Page size matters: cold reads re-expand persisted chunk-rows into one
* `assistant/chunk` event per delta, so a single streamed answer can be
* HUNDREDS of events. A small walk window (the old 8×32 = 256 events) let
* earlier `tool/call` events fall out of the loaded window — the tool rows
* vanished on re-entry while the settled text survived. The walk therefore
* pages big; tail polls stay small.
*
* Exhaustion (log start reached without a marker — a thread created before
* seeding existed, or a pathological log) returns `seedBoundary: 0` so the
* caller stops re-walking and renders the window as-is.
*
* @param fetchPage - one history page (newest-first window ending at
* `beforeSeq`, exclusive; omit for the tail page).
* @param pageCap - safety bound on backward pages.
*/
export async function collectOwnEvents(
fetchPage: (beforeSeq?: number) => Promise<readonly SidebarHistoryEntry[]>,
pageCap = 40,
): Promise<{ seedBoundary: number; entries: SidebarHistoryEntry[] }> {
const collected: SidebarHistoryEntry[] = []
let beforeSeq: number | undefined
for (let page = 0; page < pageCap; page++) {
const events = await fetchPage(beforeSeq)
if (events.length === 0) {
// Log start reached without a marker: the window IS the whole log.
return { seedBoundary: 0, entries: collected }
}
const olderThan = collected.length > 0 ? collected[0]!.event.seq : undefined
const fresh = olderThan === undefined
? [...events]
: events.filter(entry => entry.event.seq < olderThan)
const seedEnd = fresh.findLastIndex(entry => entry.event.type === 'session/end-seed')
if (seedEnd >= 0) {
collected.unshift(...fresh.slice(seedEnd + 1))
return { seedBoundary: fresh[seedEnd]!.event.seq, entries: collected }
}
collected.unshift(...fresh)
if (fresh.length === 0) {
// The page overlaps entirely with what we have: nothing older exists.
return { seedBoundary: 0, entries: collected }
}
beforeSeq = fresh[0]!.event.seq
}
// Cap hit: accept the window (it is overwhelmingly the thread's own tail)
// rather than re-walking on every poll.
return { seedBoundary: 0, entries: collected }
}
/**
* Map a thread child's history rows onto compact transcript rows: the
* inherited fork seed is cut at the last `session/end-seed`, context
* injections map onto a collapsible injection row, `assistant/chunk`
* deltas accumulate into streaming rows per (turn, step, block) and are
* superseded by the assembled `assistant/message`, and tool invocations
* render one expandable line each (arguments, paired result text, failure
* marker; a still-executing call is marked until its result lands).
* @param entries - history rows (event + host-computed view) in seq order.
* @returns display rows in log order.
*/
export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] {
const events = entries.map(entry => entry.event)
const seedEnd = lastSeedEnd(events)
const rows: SidechatTranscriptRow[] = []
/** (turn, step, index, kind) key → index of its accumulating stream row. */
const streamRows = new Map<string, number>()
/** tool callId → index of its tool row in `rows` (result pairing). */
const callRows = new Map<string, number>()
for (let index = 0; index < events.length; index++) {
if (index <= seedEnd) continue
const event = events[index]
if (event === undefined) continue
const data = event.data as Record<string, unknown>
switch (event.type) {
case 'user/message': {
const text = blockText(Array.isArray(data.content) ? data.content : [])
// Context injections (the boundary prompt + snapshot, plugin-sourced
// context) collapse into an injection row; genuine user messages —
// including the FIRST one, which the host now delivers as its own
// event — render as user rows.
if (isContextInjectionMessage(data)) {
const source = data.source as { kind?: unknown } | undefined
// Threads logged BEFORE the host split carry boundary(+snapshot)+
// question in ONE 'user' message. The boundary prompt is a known
// constant, so the message splits THERE: the injection row keeps
// the prompt, the remainder (snapshot + question if any — pure
// question in the common case) renders as the user's real message.
if (source?.kind === 'user' && text.startsWith(`${SIDE_BOUNDARY_PROMPT}\n\n`)) {
rows.push({ kind: 'injection', seq: event.seq, text: SIDE_BOUNDARY_PROMPT })
const body = text.slice(SIDE_BOUNDARY_PROMPT.length + 2)
if (body !== '') rows.push({ kind: 'user', seq: event.seq, text: body })
break
}
rows.push({ kind: 'injection', seq: event.seq, text })
break
}
rows.push({ kind: 'user', seq: event.seq, text })
break
}
case 'assistant/chunk': {
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
if (chunk === null || typeof chunk !== 'object') break
const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null
if (kind === null || typeof chunk.text !== 'string' || chunk.text === '') break
const turn = data.turn
const step = data.step
const blockIndex = (chunk as { index?: unknown }).index
const key = `${String(turn)}:${String(step)}:${String(blockIndex)}:${kind}`
const existing = streamRows.get(key)
if (existing !== undefined) {
const row = rows[existing]
if (row !== undefined && row.kind === kind && !row.settled) {
rows[existing] = { ...row, text: row.text + chunk.text }
}
} else {
streamRows.set(key, rows.length)
rows.push({ kind, seq: event.seq, text: chunk.text, settled: false })
}
break
}
case 'assistant/message': {
const prefix = `${String(data.turn)}:${String(data.step)}:`
const streamed = [...streamRows.entries()]
.filter(([key]) => key.startsWith(prefix))
.map(([, rowIndex]) => rowIndex)
for (const key of [...streamRows.keys()]) {
if (key.startsWith(prefix)) streamRows.delete(key)
}
const content = Array.isArray((data.message as { content?: unknown } | undefined)?.content)
? (data.message as { content: readonly unknown[] }).content
: []
const settled: SidechatTranscriptRow[] = content.flatMap((block): SidechatTranscriptRow[] => {
if (block === null || typeof block !== 'object') return []
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'reasoning' && typeof candidate.text === 'string' && candidate.text !== '') {
return [{ kind: 'reasoning', seq: event.seq, text: candidate.text, settled: true }]
}
if (candidate.type === 'text' && typeof candidate.text === 'string' && candidate.text !== '') {
return [{ kind: 'assistant', seq: event.seq, text: candidate.text, settled: true }]
}
return []
})
if (streamed.length === 0) rows.push(...settled)
else rows.splice(Math.min(...streamed), streamed.length, ...settled)
break
}
case 'tool/call': {
const callId = data.callId
const name = typeof data.name === 'string' ? data.name : 'tool'
const args = typeof data.arguments === 'string' ? data.arguments : undefined
const rowIndex = rows.length
if (typeof callId === 'string') callRows.set(callId, rowIndex)
rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true })
break
}
case 'tool/result': {
const source = data.message as { source?: { callId?: unknown } } | undefined
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
const rowIndex = callId === undefined ? undefined : callRows.get(callId)
const failed = data.error !== undefined
const resultText = resultTextOf(data)
if (rowIndex !== undefined) {
const row = rows[rowIndex]
if (row !== undefined && row.kind === 'tool') {
rows[rowIndex] = {
...row,
failed: row.failed || failed,
resultText: resultText === '' ? row.resultText : resultText,
executing: false,
}
}
} else if (failed || resultText !== '') {
// Orphan result (no call row in the window): surface it so the row
// stays informative and expandable.
rows.push({
kind: 'tool',
seq: event.seq,
name: callId === undefined ? 'tool' : `tool:${callId.slice(0, 8)}`,
failed,
resultText: resultText === '' ? undefined : resultText,
})
}
break
}
default: {
break
}
}
}
return rows
}
-67
View File
@@ -1,67 +0,0 @@
/**
* Pure derivation of the compact LIVE line shown on a running subagent card:
* the last text output and the last tool call of the child's history tail
* (`subagent.history` events, oldest → newest). Renders nothing itself — the
* SubagentLiveLine component turns this into the card's status lines.
* Kept framework-free so the parser is unit-testable in the node environment.
*/
import type { SidebarHistoryEntry } from '../context-types.ts'
/**
* Extract the concatenated plain text of a content-block list (the durable
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
* `text`; anything else — tool_use, image, … — contributes nothing).
* @param content - the raw `content` field of a message event.
* @returns the joined text, or undefined when the message carries no text.
*/
export function contentText(content: unknown): string | undefined {
if (!Array.isArray(content)) return undefined
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'text' && typeof candidate.text === 'string') {
parts.push(candidate.text)
}
}
return parts.length > 0 ? parts.join('\n') : undefined
}
/** The live status of one subagent card (both fields optional). */
export interface LastActivity {
/** The latest assembled assistant text output in the tail. */
text?: string
/** The latest tool call in the tail. */
tool?: { name: string; args: string }
}
/**
* Fold a history tail into the last text output + last tool call (each is
* the LAST occurrence in event order). Lifecycle events and raw
* `assistant/chunk` rows are ignored — the card shows what the subagent is
* doing right now, not its plumbing.
* @param entries - the tail page from `subagent.history` (oldest → newest).
* @returns the last text and/or tool call; an empty object when the tail has neither.
*/
export function lastActivity(entries: SidebarHistoryEntry[]): LastActivity {
let text: string | undefined
let tool: { name: string; args: string } | undefined
for (const entry of entries) {
const { type, data } = entry.event
if (type === 'assistant/message') {
const message = data.message as { content?: unknown } | undefined
const extracted = contentText(message?.content)
if (extracted !== undefined) text = extracted
} else if (type === 'tool/call') {
tool = {
name: typeof data.name === 'string' ? data.name : 'tool',
args: typeof data.arguments === 'string' ? data.arguments : '',
}
}
}
if (text === undefined && tool === undefined) return {}
return {
...(text === undefined ? {} : { text }),
...(tool === undefined ? {} : { tool }),
}
}
+14 -2
View File
@@ -15,6 +15,17 @@ import type {
SidebarSessionSummary,
SidebarSubagentCatalog,
} from '../context-types.ts'
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
/**
* Side Chat threads ride the subagent origin (main-list hiding + the RPC
* ownership fence) but they are NOT subagent topology: they carry the
* durable 'Side: ' label and live as sidebar tabs. Excluding them here
* keeps the auto-open trigger and the Subagent page counts clean.
*/
export function isSideThreadSummary(summary: SidebarSessionSummary): boolean {
return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX)
}
/** Count the direct subagent children of one session (durable `origin` rows). */
export function directSubagentCount(
@@ -23,7 +34,8 @@ export function directSubagentCount(
): number {
let count = 0
for (const summary of Object.values(byId)) {
if (summary.origin === 'subagent' && summary.parentId === sessionId) count += 1
if (summary.origin === 'subagent' && summary.parentId === sessionId
&& !isSideThreadSummary(summary)) count += 1
}
return count
}
@@ -107,7 +119,7 @@ export function countSubagentDescendants(
): SubagentDescendantTotals {
const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 }
for (const descendant of Object.values(byId)) {
if (descendant.origin !== 'subagent') continue
if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue
const seen = new Set<string>()
let current: SidebarSessionSummary | undefined = descendant
while (current?.origin === 'subagent' && current.parentId !== undefined
+126 -1
View File
@@ -225,6 +225,80 @@ export interface SidebarJobsService {
export interface SidebarAgentsService {
/** The live agent registered under a session id, or undefined when not live. */
get(id: string): SidebarAgent | undefined
/**
* Create a session + agent with a custom seed (mirror of the runtime
* AgentRegistry.create) — the Side Chat thread-creation seam: the SAME
* public seam api-proxy's session.fork and the subagent fork provider use.
*/
create?(options: unknown): Promise<{ agent: SidebarAgent; dispose(): Promise<void> }>
/**
* Resume an agent on a persisted session (mirror of the runtime
* AgentRegistry.resume) — the Side Chat cold-continuation seam after a
* DSH restart or a closed thread.
*/
resume?(options: unknown): Promise<{ agent: SidebarAgent; dispose(): Promise<void> }>
}
/** The host subagent runtime face (`ctx.subagents`; optional — the live
* batch route degrades to a 503 when the deployment lacks it). Only the
* read-only descendant enumeration this plugin needs is mirrored. */
export interface SidebarSubagentsService {
/**
* Enumerate the root's complete session-backed subagent tree in stable
* pre-order without loading or resuming an Agent (mirror of
* `SubagentRuntime.listDescendants`).
*/
listDescendants(
rootSessionId: string,
signal?: AbortSignal,
): Promise<SidebarSubagentDescendantEntry[]>
}
/** One descendant row of `ctx.subagents.listDescendants` (structural mirror). */
export type SidebarSubagentDescendantEntry =
| {
kind: 'child'
id: string
activity: 'running' | 'inactive'
hasChildren: boolean
mode: 'one-shot' | 'continuable'
label?: string
parentId: string
depth: number
}
| {
kind: 'diagnostic'
id: string
reason: 'corrupt' | 'unsupported' | 'unavailable'
parentId: string
depth: number
}
/** The host agent-presets service face (mirror of the runtime agentPresets
* service): resolves and mounts the preset composition a session recorded,
* so a resumed or forked session rebuilds the same tool/prompt world its
* history was produced under. */
export interface SidebarAgentPresetsService {
/** Resolve a preset id; undefined resolves the deployment default. */
resolve(presetId?: string): Promise<{ id: string }>
/** Mount a preset's composition into an agent scope before publication. */
mount(agentCtx: unknown, presetId: string): Promise<void>
}
/** The host session-title service face (mirror of the sessionTitle service). */
export interface SidebarSessionTitleService {
/** Rename one live session's title (pins it against auto-regeneration). */
rename(session: unknown, title: string): { title: string; eventSeq: number }
}
/** The host session-persistence face (mirror of the sessionPersistence
* service): detached inspection of a persisted session, used to compose the
* recorded preset when a Side Chat thread cold-resumes. */
export interface SidebarSessionPersistenceService {
inspect(sessionId: string): Promise<{
meta: { cwd?: string; agentPreset?: string }
events: readonly SidebarSessionEvent[]
}>
}
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
@@ -236,9 +310,21 @@ export interface SidebarRpcResponse<T> {
result: SidebarRpcResult<T>
}
/** The generic session-history RPC face the Side Chat transcript polls
* (subagent.history verifies subagent-catalog membership, which our custom
* side-thread children do not have — the generic session.history reads any
* durable log directly). */
export interface SidebarSessionHistoryRpc {
history(
payload: { sessionId: string; beforeSeq?: number; maxMessages?: number },
signal?: AbortSignal,
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
}
/** The wire face the Subagent activity summary needs (subset of `ctx.connection`). */
export interface SidebarConnectionHandle {
api: {
sessions: SidebarSessionHistoryRpc
subagents: {
history(
payload: SidebarSubagentAddress & { beforeSeq?: number; maxMessages?: number },
@@ -273,6 +359,23 @@ export interface SidebarSessionsService {
* — used to jump back to the main agent from the topology root node.
*/
open?(id: string): void
/**
* Fork a session from a completed-turn prefix of the source and resolve
* the child session id (mirror of the runtime ISessions.fork — throws on
* failure). The Side Chat "save as new session" action uses this to
* promote a hidden side thread into a top-level session.
*/
fork?(opts: { sessionId: string; atSeq?: number; increaseTitle?: boolean }): Promise<string>
/**
* Resolve the stable session binding of one listed session (mirror of the
* runtime ISessions.binding); the saved-session rename uses the face's
* behavior verbs.
*/
binding?(id: string): {
session: {
rename(title: string): Promise<unknown>
}
} | undefined
/**
* Resolve an Agent-scoped context view for one session (mirror of the
* runtime ISessions.scope) — the ticket `ctx.conversation.input.for`
@@ -442,9 +545,31 @@ declare module 'cordis' {
jobs: SidebarJobsService
/**
* The host live-agent registry (`ctx.get('agents')`; optional — used to
* resolve the caller the jobs fence compares against).
* resolve the caller the jobs fence compares against, and to create /
* resume the Side Chat thread agents).
*/
agents: SidebarAgentsService
/**
* The host subagent runtime (`ctx.subagents`; optional — the Subagent
* page's live batch route reads descendant catalogs through it).
*/
subagents: SidebarSubagentsService
/**
* The host agent-presets service (`ctx.get('agentPresets')`; optional —
* absent deployments compose nothing and every session shares the host
* composition).
*/
agentPresets: SidebarAgentPresetsService
/**
* The host session-title service (`ctx.get('sessionTitle')`; optional —
* the Side Chat thread label pin degrades to the auto-generated title).
*/
sessionTitle: SidebarSessionTitleService
/**
* The host session-persistence service (`ctx.get('sessionPersistence')`;
* optional — needed only for the Side Chat cold-resume composition).
*/
sessionPersistence: SidebarSessionPersistenceService
/**
* The client-side sidebar registry: external plugins register tab types
* and file previewers here. Provided by the client half (see
+17
View File
@@ -47,6 +47,8 @@ import {
} from './pty-deps.ts'
import { registerTools } from './tools.ts'
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts'
import { buildSidechatApi } from './sidechat-routes.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
export { Config }
@@ -233,6 +235,10 @@ function buildApi(
// job_output cursor is never consumed) and kill (the registry's stock
// API). A deployment without the jobs registry downgrades kill to a 503.
const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit)
// Subagent live previews: one batch request instead of N per-child
// `subagents.history` calls. The route degrades to a 503 when the host
// subagent runtime is absent (the page has no topology to show anyway).
const subagentLiveApi: SidebarSubagentLiveRoutes = buildSubagentLiveApi(ctx)
return {
'session.cwd': (payload) => {
const { sessionId, cwd } = cwdOf(payload)
@@ -385,6 +391,9 @@ function buildApi(
// exists. Kill is fenced to the owning session by the jobs registry.
'jobs.output': (payload) => jobsApi.output(payload),
'jobs.kill': (payload) => jobsApi.kill(payload),
// Subagent live previews: one batch request per refresh; the route folds
// the newest text/tool activity of every running child in the tree.
'subagents.live': (payload) => subagentLiveApi.live(payload),
// The effective terminal shell and its display name. The client uses
// this to title terminal tabs with the shell name instead of a numbered
// "Terminal N" label; the shell itself is configured through
@@ -470,6 +479,14 @@ function buildApi(
clearTimeout(timer)
}
},
// Side Chat: create a side-thread child seeded with the parent's full
// log up to now, deliver follow-ups (cold-resuming when the thread's
// agent is gone), abort a running thread, and release a thread's agent.
// Every operation runs through these routes because subagent-origin
// identities are fenced from the generic session RPCs (agent-lookup
// ownership), and the thread is created with a CUSTOM seed the stock
// fork APIs cannot express.
...buildSidechatApi(ctx),
}
}
+468
View File
@@ -0,0 +1,468 @@
/**
* Pure side-conversation ("Side Chat") logic shared by the host routes and
* the client tab. Framework-free (no React, no Node) so both halves and the
* node test environment can import it.
*
* A side thread is a child session the plugin creates ITSELF with a custom
* seed — the parent session's FULL event log up to the click moment
* (completed turns, the unanswered user message, and — when the parent is
* mid-turn — the in-progress assistant output and tool activity). The log
* model forbids open-turn seeds, so an in-flight parent turn is copied
* verbatim and CLOSED with synthetic `step/end` + `turn/end{reason:
* 'interrupted'}` events: the child sees the partial turn as honestly
* frozen ("cut off"), never as a completed answer. The one case that cannot
* be closed honestly — a tool call still executing (no `tool/result` yet;
* providers reject dangling assistant calls) — falls back to cutting before
* the open turn and carrying the partial content as a structured text
* snapshot inside the boundary prompt.
*/
import type { SidebarHistoryEntry, SidebarSessionSummary } from './context-types.ts'
/** The durable thread-label prefix (also the row filter in the client list). */
export const SIDE_LABEL_PREFIX = 'Side: '
/** The pinned label of a freshly created thread that no prompt has reached
* yet (Codex-style immediate create: the tab opens an EMPTY thread, the
* first composer message carries the boundary and earns the real label).
* The client renders it localized; the prefix keeps the row filter honest. */
export const SIDE_NEW_THREAD_TITLE = 'Side: New thread'
/** Maximum code points kept in a durable thread label (matches subagent labels). */
export const LABEL_MAX_CHARS = 48
/** The boundary message's opening line — the transcript mapping drops user
* rows starting with it (same first line as dsh-sidechain's boundary, so
* the two plugins' threads render consistently in either UI). */
export const SIDE_BOUNDARY_PREFIX = 'Side conversation boundary'
/** The plugin identity stamped on the source of context-injection messages
* (boundary prompt + parked snapshot), so the transcript recognizes them
* structurally — not by text prefix. */
export const SIDE_INJECTION_PLUGIN = 'dsh-better-sidebar'
/**
* The boundary prompt delivered as the thread's first user message: the
* inherited seed is reference context only, never active instruction.
* Model-facing contract — change only with intent, tests pin the sentences.
*/
export const SIDE_BOUNDARY_PROMPT = `Side conversation boundary.
Everything before this boundary is inherited history from the parent session: its completed turns, its pending question, and — if the parent was mid-turn — its in-progress output frozen at the moment this side conversation started. It is reference context only. It is not your current task.
Do not continue, execute, or complete any instructions, plans, tool calls, approvals, edits, or requests from before this boundary. Only messages submitted after this boundary are active user instructions for this side conversation.
Mode: this is a continuable side conversation. Your answers stay in this side thread and are viewed in the side panel; they are never delivered into the parent session.`
/** One seed event (structural mirror of the durable SessionEvent). The
* envelope fields are preserved verbatim: surface-eligible events
* (user/message, assistant/message, tool/result) REQUIRE the `surfaceOp`
* marker (and may carry `sourceEventSeqs`) — the seed validator rejects
* them without it. */
export interface SeedEvent {
type: string
seq: number
time: number
data: Record<string, unknown>
/** Surface marker of message-producing events ('append' | replace op). */
surfaceOp?: unknown
/** Seq numbers of earlier events this event cites as sources. */
sourceEventSeqs?: unknown
/** Reader-skip marker of purely informational events. */
ignorable?: true
}
/** The minimal structural face of a session-log event this module reads
* (loose enough to accept both the host's real SessionEvent and the
* client's SidebarSessionEvent mirror). */
export interface SidechatLogEvent {
type: string
seq: number
time: number
data: unknown
}
/** The result of cutting a parent log into a side-thread inheritance. */
export interface SidechatInheritance {
/** The child seed: contiguous from seq 0, ends outside any open turn. */
seed: SeedEvent[]
/**
* Structured snapshot of the parent's in-progress turn when it could NOT
* be included as events (a tool call was still executing); null when the
* seed already carries the whole picture.
*/
snapshot: string | null
}
/** The data record of one event (narrowed from the loose face). */
function dataOf(event: SidechatLogEvent): Record<string, unknown> {
return event.data as Record<string, unknown>
}
/** Copy parent events verbatim (their live seq === array index contract).
* The FULL envelope is preserved — stripping `surfaceOp` would make the
* seed validator reject every surface-eligible message event. */
function copyEvents(events: readonly SidechatLogEvent[]): SeedEvent[] {
return events.map(event => {
const source = event as SidechatLogEvent & {
surfaceOp?: unknown
sourceEventSeqs?: unknown
ignorable?: true
}
return {
type: source.type,
seq: source.seq,
time: source.time,
data: dataOf(source),
...(source.surfaceOp === undefined ? {} : { surfaceOp: source.surfaceOp }),
...(source.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: source.sourceEventSeqs }),
...(source.ignorable === undefined ? {} : { ignorable: source.ignorable }),
}
})
}
/** Index of the last `turn/start` or `turn/end`, or -1. */
function lastTurnBoundary(events: readonly SidechatLogEvent[]): number {
for (let index = events.length - 1; index >= 0; index--) {
const type = events[index]?.type
if (type === 'turn/start' || type === 'turn/end') return index
}
return -1
}
/** Numeric field of an event's data (turn / step numbers). */
function numberAt(data: Record<string, unknown>, key: string): number {
const value = data[key]
return typeof value === 'number' && Number.isSafeInteger(value) ? value : 0
}
/** The step number still open at the log tail inside the turn starting at
* `turnStart` (undefined when no step is open). */
function openStepInTurn(events: readonly SidechatLogEvent[], turnStart: number): number | undefined {
let open: number | undefined
for (let index = turnStart + 1; index < events.length; index++) {
const event = events[index]
if (event === undefined) continue
if (event.type === 'step/start') open = numberAt(dataOf(event), 'step')
else if (event.type === 'step/end') open = undefined
}
return open
}
/**
* Whether the open turn ending the log has a `tool/call` without its paired
* `tool/result` in the CURRENT open step. Providers reject dangling
* assistant calls, so such a turn cannot be honestly closed and the
* inheritance must fall back to the snapshot.
*/
export function hasDanglingToolCall(events: readonly SidechatLogEvent[], turnStart: number): boolean {
const pending = new Set<string>()
for (let index = turnStart + 1; index < events.length; index++) {
const event = events[index]
if (event === undefined) continue
const data = dataOf(event)
if (event.type === 'step/end') {
pending.clear()
continue
}
if (event.type === 'tool/call') {
const callId = data.callId
if (typeof callId === 'string') pending.add(callId)
continue
}
if (event.type === 'tool/result') {
const source = data.message as { source?: { callId?: unknown } } | undefined
const callId = source?.source?.callId
if (typeof callId === 'string') pending.delete(callId)
}
}
return pending.size > 0
}
/** The plain text of one tool/result message (text blocks inside its
* `tool-result` content block). */
function toolResultText(data: Record<string, unknown>): string {
const message = data.message as { content?: unknown } | undefined
const content = message?.content
if (!Array.isArray(content)) return ''
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; content?: unknown }
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') {
parts.push(textItem.text)
}
}
}
return parts.join('\n')
}
/** Cap applied to one tool-result's text inside a snapshot (prompt budget). */
const SNAPSHOT_RESULT_CAP = 2000
/** Cap applied to the whole snapshot (prompt budget). */
const SNAPSHOT_TOTAL_CAP = 8000
/**
* Build the side-thread inheritance for one parent log: the full event log
* up to the click moment, honestly closed when it ends inside an open turn.
*/
export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): SidechatInheritance {
if (events.length === 0) return { seed: [], snapshot: null }
const boundary = lastTurnBoundary(events)
if (boundary < 0 || events[boundary]?.type === 'turn/end') {
// Ends outside any turn (or has no turns at all): the whole log is a
// valid, balanced seed — possibly ending with a pending user message.
return { seed: copyEvents(events), snapshot: null }
}
// Ends inside the open turn starting at `boundary`.
if (hasDanglingToolCall(events, boundary)) {
// Cannot close honestly: cut before the open turn; the caller attaches
// the structured snapshot to the boundary prompt instead.
return {
seed: copyEvents(events.slice(0, boundary)),
snapshot: buildOpenTurnSnapshot(events),
}
}
const seed = copyEvents(events)
const last = events[events.length - 1]
const turn = numberAt(dataOf(events[boundary]!), 'turn')
const now = last?.time ?? 0
const openStep = openStepInTurn(events, boundary)
if (openStep !== undefined) {
seed.push({ type: 'step/end', seq: seed.length, time: now, data: { turn, step: openStep } })
}
seed.push({
type: 'turn/end',
seq: seed.length,
time: now,
data: { turn, reason: { kind: 'interrupted' } },
})
return { seed, snapshot: null }
}
/** The seed half of {@link buildSidechatInheritance} (test convenience). */
export function sidechatSeed(events: readonly SidechatLogEvent[]): SeedEvent[] {
return buildSidechatInheritance(events).seed
}
/**
* Structured text snapshot of the parent's OPEN turn (from its `turn/start`
* to the log tail): the accumulated assistant/reasoning output verbatim
* (code blocks ride the raw deltas) and the tool activity — executed tools
* with their result text, the still-executing one marked. Returns null when
* there is no open turn or nothing to show.
*/
export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): string | null {
const boundary = lastTurnBoundary(events)
if (boundary < 0 || events[boundary]?.type !== 'turn/start') return null
let text = ''
let reasoning = ''
const tools: string[] = []
const pendingCalls = new Map<string, { name: string; args: string }>()
let total = 0
for (let index = boundary + 1; index < events.length; index++) {
const event = events[index]
if (event === undefined) continue
const data = dataOf(event)
if (event.type === 'step/end') {
pendingCalls.clear()
continue
}
if (event.type === 'assistant/chunk') {
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
if (chunk === null || typeof chunk !== 'object') continue
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
else if (chunk.type === 'reasoning-delta' && typeof chunk.text === 'string') reasoning += chunk.text
continue
}
if (event.type === 'tool/call') {
const callId = data.callId
if (typeof callId === 'string') {
pendingCalls.set(callId, {
name: typeof data.name === 'string' ? data.name : 'tool',
args: typeof data.arguments === 'string' ? data.arguments : '',
})
}
continue
}
if (event.type === 'tool/result') {
const source = data.message as { source?: { callId?: unknown } } | undefined
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
const name = callId !== undefined ? pendingCalls.get(callId)?.name : undefined
const args = callId !== undefined ? pendingCalls.get(callId)?.args : undefined
if (callId !== undefined) pendingCalls.delete(callId)
const result = toolResultText(data).slice(0, SNAPSHOT_RESULT_CAP)
const failed = data.error !== undefined
const line = [
`- \`${name ?? 'tool'}\`${failed ? ' (failed)' : ''}`
+ (args !== undefined && args !== '' ? ` — arguments: \`${args}\`` : ''),
...(result === '' ? [] : [` Result: ${result}`]),
].join('\n')
tools.push(line)
total += line.length
}
}
for (const [, call] of pendingCalls) {
const line = `- \`${call.name}\` (executing) — arguments: \`${call.args}\``
tools.push(line)
total += line.length
}
const sections: string[] = []
if (text.trim() !== '') sections.push(`Assistant output so far:\n\n${text}`)
if (reasoning.trim() !== '') sections.push(`Reasoning so far:\n\n${reasoning}`)
if (tools.length > 0) sections.push(`Tool activity:\n${tools.join('\n')}`)
if (sections.length === 0) return null
const body = sections.join('\n\n')
return body.length > SNAPSHOT_TOTAL_CAP
? `Parent session in-progress turn (reference only):\n\n${body.slice(0, SNAPSHOT_TOTAL_CAP)}…`
: `Parent session in-progress turn (reference only):\n\n${body}`
}
/** One side-thread row in the client's thread list. */
export interface SideThreadRow {
id: string
/** The durable thread title ('Side: …'). */
title: string
/** Whether the thread's agent is currently running. */
running: boolean
}
/**
* Derive the side threads of one parent session from the client session list:
* durable `origin: 'subagent'` children of the parent whose pinned title
* carries the thread label prefix (our creation path pins it via
* sessionTitle.rename; dsh-sidechain threads share the convention, so they
* are visible here too).
*/
export function sideThreadRows(
byId: Readonly<Record<string, SidebarSessionSummary>>,
sessionId: string,
): SideThreadRow[] {
const rows: SideThreadRow[] = []
for (const summary of Object.values(byId)) {
if (summary.origin !== 'subagent' || summary.parentId !== sessionId) continue
if (!summary.displayTitle.startsWith(SIDE_LABEL_PREFIX)) continue
rows.push({ id: summary.id, title: summary.displayTitle, running: summary.running === true })
}
return rows
}
/** Truncate + prefix a question into a durable thread label. */
export function sideLabel(question: string): string {
const flat = question.replace(/\s+/g, ' ').trim()
const max = Math.max(1, LABEL_MAX_CHARS - SIDE_LABEL_PREFIX.length)
const body = flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
return `${SIDE_LABEL_PREFIX}${body}`
}
/**
* Whether the thread log already carries the side boundary message — i.e.
* the first prompt was delivered. Tolerant to the content shape (block
* array or bare string) and to inherited seed messages (only an OWN
* boundary message starts with the prefix; seed messages came from the
* parent's log, which never contains one).
*/
export function boundaryDelivered(events: readonly SidechatLogEvent[]): boolean {
for (const event of events) {
if (event.type !== 'user/message') continue
if (messageLeadText(dataOf(event)).startsWith(SIDE_BOUNDARY_PREFIX)) return true
}
return false
}
/** The leading text of a user/message's content (block array or bare string). */
function messageLeadText(data: Record<string, unknown>): string {
const content = data.content
const first = Array.isArray(content) ? content[0] : content
return typeof first === 'string'
? first
: (typeof first === 'object' && first !== null && 'text' in first
? String((first as { text: unknown }).text)
: '')
}
/**
* Whether a logged user/message is a CONTEXT INJECTION (the boundary prompt
* plus the parked in-progress snapshot) rather than a real user message.
* New threads deliver the injection via `agent.inject` stamped with a
* non-'user' source kind; threads created before that split carry
* boundary+question in ONE 'user' message, recognized by the boundary
* prefix. Both render as one collapsible injection row — never as a user
* bubble.
*/
export function isContextInjectionMessage(data: Record<string, unknown>): boolean {
const source = data.source as { kind?: unknown } | null | undefined
if (source?.kind !== undefined && source.kind !== 'user') return true
return messageLeadText(data).startsWith(SIDE_BOUNDARY_PREFIX)
}
/** The info the thread header shows (live runtime state + agent identity). */
export interface SidechatThreadInfo {
/** A live agent drives the thread right now (false = cold/persisted). */
live: boolean
/** Live lifecycle state; absent on cold threads. */
status?: 'idle' | 'running'
/** Provider route of the live agent. */
provider?: string
/** Model id of the live agent. */
model?: string
/** The recorded agent preset (live header, or persisted on cold reads). */
preset?: string
}
/** The events a thread produced itself: everything after the LAST
* `session/end-seed` marker (the fork-seed boundary). */
export function threadOwnEvents(entries: readonly SidebarHistoryEntry[]): SidechatLogEvent[] {
const events = entries.map(entry => entry.event)
for (let index = events.length - 1; index >= 0; index--) {
if (events[index]?.type === 'session/end-seed') return events.slice(index + 1)
}
return events
}
/**
* Whether the thread has at least one completed turn — the save-as-new-
* session precondition (`session.fork` refuses to fork before the first
* `turn/end`).
*/
export function threadHasCompletedTurn(entries: readonly SidebarHistoryEntry[]): boolean {
return threadOwnEvents(entries).some(event => event.type === 'turn/end')
}
/** Whether the thread ends with a user message that no completed turn
* answered yet — such a pending follow-up is NOT carried into the saved
* session (the fork cut is the last `turn/end`). */
export function threadTrailingPending(entries: readonly SidebarHistoryEntry[]): boolean {
const own = threadOwnEvents(entries)
let lastUser = -1
let lastTurnEnd = -1
own.forEach((event, index) => {
if (event.type === 'user/message') lastUser = index
if (event.type === 'turn/end') lastTurnEnd = index
})
return lastUser > lastTurnEnd
}
/**
* The agent preset a session actually runs: newest `agent-preset/selected`
* event wins, else the creation header (mirror of the dsh-agent-presets
* resolveSessionPreset helper — replicated here to avoid a host dependency
* on that package).
*/
export function resolvePresetId(
header: { agentPreset?: string },
events: readonly SidechatLogEvent[],
): string | undefined {
for (let index = events.length - 1; index >= 0; index--) {
const event = events[index]
if (event?.type !== 'agent-preset/selected') continue
const preset = dataOf(event).agentPreset
if (typeof preset === 'string') return preset
}
return header.agentPreset
}
+344
View File
@@ -0,0 +1,344 @@
/**
* Side Chat routes of the /sidebar JSON API ('sidechat.start' /
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose').
*
* A side thread is a child session the plugin creates ITSELF with a custom
* seed — the parent's full event log up to the click moment, honestly closed
* at an in-progress turn (see sidechat-core.ts). The child is marked
* `origin: 'subagent'` so the main session list hides it, and EVERY
* operation goes through these routes because the generic session RPCs are
* fenced away from subagent-origin identities (the api-remotes
* agent-lookup ownership fence). No DSH source is touched:
*
* - creation uses the public AgentRegistry.create seam (the same one
* api-proxy's session.fork and the subagent fork provider use), with the
* parent's preset composition and provider/model selection so the child's
* first request shares the parent's token prefix (provider-side prefix
* cache reuse);
* - the first prompt (boundary + question) and every follow-up are admitted
* with the stock `agent.followup`;
* - a cold thread (DSH restart, or a closed thread) is resumed with
* AgentRegistry.resume, composing the preset the child recorded.
*/
import { randomUUID } from 'node:crypto'
import { createUserMessage, type ContentBlock, type UserMessage } from '@deepseek-ai/dsh-llm'
import type { Agent, AgentSetup, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { Context as CordisContext } from '@deepseek-ai/cordis'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {
Context,
SidebarAgentPresetsService,
SidebarSessionPersistenceService,
SidebarSessionTitleService,
} from './context-types.ts'
import {
boundaryDelivered,
buildSidechatInheritance,
resolvePresetId,
SIDE_BOUNDARY_PROMPT,
SIDE_INJECTION_PLUGIN,
SIDE_NEW_THREAD_TITLE,
sideLabel,
type SeedEvent,
type SidechatLogEvent,
type SidechatThreadInfo,
} from './sidechat-core.ts'
import { requireString, SidebarError } from './wire.ts'
/** The five Side Chat routes of the sidebar API (wire method names). */
export interface SidechatRoutes {
/** Create a side thread child seeded with the parent's log up to now.
* `question` is optional: empty creates an EMPTY thread (Codex-style
* immediate create); the first `sidechat.prompt` then carries the
* boundary + snapshot and earns the thread its real label. */
'sidechat.start'(payload: unknown): Promise<{ childId: string }>
/** Deliver one follow-up message to a thread (live, or cold-resumed). */
'sidechat.prompt'(payload: unknown): Promise<{ accepted: true }>
/** Abort the thread's running turn (queued work is preserved). */
'sidechat.cancel'(payload: unknown): Promise<{ accepted: true }>
/** Release the thread's live agent (session and history stay persisted). */
'sidechat.dispose'(payload: unknown): Promise<{ accepted: true }>
/** Live state + agent identity for the thread header. */
'sidechat.info'(payload: unknown): Promise<SidechatThreadInfo>
}
/** Timeout guarding the create call (the registry detaches it before the
* handle becomes visible, so the child is never cancelled by it). */
const CREATE_TIMEOUT_MS = 15_000
/** Per-activation disposers of created thread agents (the dispose route
* releases them; the session and its history always stay persisted). */
const threadDisposers = new Map<string, () => Promise<void>>()
/** The in-progress-turn snapshot captured at creation of an EMPTY thread,
* waiting to ride the first prompt (lost on a host restart — the boundary
* prompt is then delivered alone, a logged degradation). */
const pendingSnapshots = new Map<string, string>()
/** Resolve the parent's preset and build the child's composition setup
* (mirror of api-proxy's composeAgent minus the model-selection install —
* the child carries the parent's provider/model in agentOptions). */
async function composeChildSetup(
ctx: Context,
presetId: string | undefined,
): Promise<{ agentPreset?: string; setup: AgentSetup }> {
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
if (presets === undefined) {
return { setup: () => Promise.resolve() }
}
const resolved = await presets.resolve(presetId)
return {
agentPreset: resolved.id,
setup: async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) },
}
}
/** Build the cold-resume setup from the thread's PERSISTED record (the
* recorded preset wins, newest selection event first). */
async function composePersistedSetup(
ctx: Context,
childId: string,
): Promise<AgentSetup> {
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
if (persistence === undefined) {
return () => Promise.resolve()
}
const inspected = await persistence.inspect(childId)
const presetId = resolvePresetId(inspected.meta, inspected.events)
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
if (presets === undefined || presetId === undefined) {
return () => Promise.resolve()
}
const resolved = await presets.resolve(presetId)
return async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) }
}
/** One text-block prompt (the thread boundary + question, or a follow-up). */
function textPrompt(text: string): ContentBlock[] {
return [{ type: 'text', text }]
}
/** Admit one user message to a live agent through the stock followup path. */
function admitFollowup(agent: Agent, blocks: ContentBlock[]): void {
const message: UserMessage = createUserMessage({ content: blocks, source: { kind: 'user' } })
agent.followup(message)
}
/**
* Deliver the thread's FIRST contact as TWO log-separated messages: the
* boundary prompt (+ the parked in-progress snapshot) rides `agent.inject`
* — queued model-facing context that does NOT wake the driver and is
* claimed FIRST at the opening step (Inbox.claim drains next-step before
* next-turn) — and the user's question is the follow-up that wakes it. The
* log therefore records two user/message events (injection, then question)
* instead of one wrapped blob: the transcript shows the question as a user
* bubble and collapses the injection as a context row. The injection source
* is stamped `kind: 'plugin'` so recognition is structural; its text still
* opens with SIDE_BOUNDARY_PREFIX, keeping boundaryDelivered intact.
*/
function admitFirstContact(agent: Agent, injectionText: string, question: string): void {
agent.inject(createUserMessage({
content: textPrompt(injectionText),
source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN },
}))
admitFollowup(agent, textPrompt(question))
}
/** The live thread agent, or undefined (cold — the caller resumes). */
function liveThreadAgent(ctx: Context, childId: string): Agent | undefined {
const agents = ctx.get('agents') as { get(id: string): Agent | undefined } | undefined
return agents?.get(childId)
}
/** Build the Side Chat routes (all optional services degrade to a wire
* error the tab surfaces inline). The record keys are the FULL wire method
* names the /sidebar/api dispatcher looks up (`api[method]`). */
export function buildSidechatApi(ctx: Context): SidechatRoutes {
return {
'sidechat.start': async (payload: unknown) => {
const sessionId = requireString(payload, 'sessionId')
const rawQuestion = (payload as { question?: unknown }).question
const question = typeof rawQuestion === 'string' ? rawQuestion.trim() : ''
const parent = liveThreadAgent(ctx, sessionId)
if (parent === undefined) {
throw new SidebarError('sidechat-error', `parent session "${sessionId}" is not running`, 409)
}
const parentSession = parent.session
const inheritance = buildSidechatInheritance(
parentSession.events as unknown as readonly SidechatLogEvent[],
)
const { agentPreset, setup } = await composeChildSetup(
ctx,
resolvePresetId(parentSession.header, parentSession.events),
)
const childId = `session-${randomUUID()}` as SessionId
const label = question === '' ? SIDE_NEW_THREAD_TITLE : sideLabel(question)
// Honest catalog citizenship: the durable descriptor keeps the thread
// a HEALTHY row in the host's subagents.list — a cold child without
// one is deterministically rendered as a 'corrupt' diagnostic. The
// SubagentView filters the 'Side: ' label out, so the topology UI
// stays noise-free; the row only serves enumeration correctness.
const descriptor = snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'sidechat',
label,
...(parent.options.provider === undefined ? {} : { agentProvider: parent.options.provider }),
...(parent.options.model === undefined ? {} : { agentModel: parent.options.model }),
})
const descriptorEvent: SeedEvent = {
type: 'subagent/descriptor',
seq: inheritance.seed.length,
time: Date.now(),
data: descriptor as unknown as Record<string, unknown>,
}
const seed = [...inheritance.seed, descriptorEvent]
const options: CreateAgentOptions = {
sessionId: childId,
meta: {
...(parentSession.header.cwd === undefined ? {} : { cwd: parentSession.header.cwd }),
parentSession: parentSession.id,
seedLength: seed.length,
origin: 'subagent',
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
...(agentPreset === undefined ? {} : { agentPreset }),
},
seed: seed as unknown as readonly SessionEvent[],
agentOptions: { ...parent.options },
setup,
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),
}
const agents = ctx.get('agents') as { create(options: CreateAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
if (agents?.create === undefined) {
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
}
let handle: { agent: Agent; dispose(): Promise<void> }
try {
handle = await agents.create(options)
} catch (error) {
throw new SidebarError('sidechat-error', `thread creation failed: ${error instanceof Error ? error.message : String(error)}`, 500)
}
threadDisposers.set(childId, () => handle.dispose())
// Pin the thread label so the client can identify its threads by
// title prefix (the rename is a live-session op, no RPC fence).
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
const pinTitle = (label: string): void => {
if (titles === undefined) return
try {
titles.rename(handle.agent.session, label)
} catch {
// Keep the auto-generated title; the thread stays usable.
}
}
if (question === '') {
// Codex-style immediate create: no prompt yet — the composer owns
// the first message; the snapshot waits for it.
if (inheritance.snapshot !== null) pendingSnapshots.set(childId, inheritance.snapshot)
pinTitle(SIDE_NEW_THREAD_TITLE)
} else {
const promptParts = [SIDE_BOUNDARY_PROMPT]
if (inheritance.snapshot !== null) promptParts.push(inheritance.snapshot)
admitFirstContact(handle.agent, promptParts.join('\n\n'), question)
pinTitle(sideLabel(question))
}
return { childId }
},
'sidechat.prompt': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const text = requireString(payload, 'text').trim()
if (text === '') {
throw new SidebarError('bad-request', 'text is required')
}
let agent = liveThreadAgent(ctx, childId)
if (agent === undefined) {
// Cold thread: resume the persisted session under its recorded
// composition, then deliver the follow-up.
const agents = ctx.get('agents') as { resume(options: ResumeAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
if (agents?.resume === undefined) {
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
}
const setup = await composePersistedSetup(ctx, childId)
try {
const handle = await agents.resume({ resumeSessionId: childId as SessionId, setup })
threadDisposers.set(childId, () => handle.dispose())
agent = handle.agent
} catch (error) {
throw new SidebarError('sidechat-error', `thread resume failed: ${error instanceof Error ? error.message : String(error)}`, 500)
}
}
if (boundaryDelivered(agent.session.events as unknown as readonly SidechatLogEvent[])) {
admitFollowup(agent, textPrompt(text))
} else {
// First message of an immediately-created thread: it carries the
// boundary (+ the snapshot parked at creation, if still around)
// and earns the thread its real label.
const parts = [SIDE_BOUNDARY_PROMPT]
const snapshot = pendingSnapshots.get(childId)
pendingSnapshots.delete(childId)
if (snapshot !== undefined) parts.push(snapshot)
admitFirstContact(agent, parts.join('\n\n'), text)
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
if (titles !== undefined) {
try {
titles.rename(agent.session, sideLabel(text))
} catch {
// Keep the placeholder title; the thread stays usable.
}
}
}
return { accepted: true as const }
},
'sidechat.cancel': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const agent = liveThreadAgent(ctx, childId)
if (agent !== undefined) {
agent.cancel({ kind: 'user' }, { keepInbox: true })
}
return { accepted: true as const }
},
'sidechat.dispose': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
pendingSnapshots.delete(childId)
const dispose = threadDisposers.get(childId)
if (dispose !== undefined) {
threadDisposers.delete(childId)
try {
await dispose()
} catch {
// The agent may already be gone (restart); the session persists.
}
}
return { accepted: true as const }
},
'sidechat.info': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const agent = liveThreadAgent(ctx, childId)
if (agent !== undefined) {
const preset = agent.session.header.agentPreset
return {
live: true,
status: agent.status,
...(agent.options.provider === undefined ? {} : { provider: agent.options.provider }),
...(agent.options.model === undefined ? {} : { model: agent.options.model }),
...(preset === undefined ? {} : { preset }),
}
}
// Cold thread: only the persisted preset is worth reading back.
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
if (persistence !== undefined) {
try {
const inspected = await persistence.inspect(childId)
const preset = resolvePresetId(inspected.meta, inspected.events)
return { live: false, ...(preset === undefined ? {} : { preset }) }
} catch {
// Unknown/gone session: report a bare cold info.
}
}
return { live: false }
},
}
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Pure derivation of the compact LIVE line shown on a running subagent card:
* the last text output and the last tool call of the child's session events.
* The host batch route feeds raw session events into this parser; the client
* only receives the already-folded `LastActivity` map. Renders nothing
* itself — the SubagentView component turns this into the card's status
* lines. Kept framework-free so the parser is unit-testable in the node
* environment.
*/
import type { SidebarSessionEvent } from './context-types.ts'
/**
* Extract the concatenated plain text of a content-block list (the durable
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
* `text`; anything else — tool_use, image, … — contributes nothing).
* @param content - the raw `content` field of a message event.
* @returns the joined text, or undefined when the message carries no text.
*/
export function contentText(content: unknown): string | undefined {
if (!Array.isArray(content)) return undefined
const parts: string[] = []
for (const block of content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as { type?: unknown; text?: unknown }
if (candidate.type === 'text' && typeof candidate.text === 'string') {
parts.push(candidate.text)
}
}
return parts.length > 0 ? parts.join('\n') : undefined
}
/** The live status of one subagent card (both fields optional). */
export interface LastActivity {
/** The latest assembled assistant text output in the tail. */
text?: string
/** The latest tool call in the tail. */
tool?: { name: string; args: string }
}
/**
* Fold a session event log into the last text output + last tool call (each
* is the LAST occurrence in event order). Lifecycle events and raw
* `assistant/chunk` rows are ignored — the card shows what the subagent is
* doing right now, not its plumbing. The scan runs BACKWARD from the newest
* event and stops once both fields are found, so a long history costs only
* the recent tail in the common case.
* @param events - the session's append-only event log (oldest → newest).
* @param maxMessages - optional message-boundary window: only the tail's
* last `maxMessages` surface messages (`user/message`, `assistant/message`)
* and the events between them are considered, mirroring the old
* `subagents.history({ maxMessages })` window. Stale activity older than
* the window is never surfaced, and a long log is never scanned in full.
* @returns the last text and/or tool call; an empty object when the log has neither.
*/
export function lastActivity(
events: readonly SidebarSessionEvent[],
maxMessages = Infinity,
): LastActivity {
let text: string | undefined
let tool: { name: string; args: string } | undefined
let messagesSeen = 0
for (let index = events.length - 1; index >= 0; index -= 1) {
if (text !== undefined && tool !== undefined) break
const event = events[index]
if (event === undefined) continue
const { type, data } = event
if (type === 'user/message' || type === 'assistant/message') {
messagesSeen += 1
if (messagesSeen > maxMessages) break
} else if (messagesSeen >= maxMessages) {
// The window already holds its `maxMessages` messages: anything older
// than the oldest in-window message sits outside the recent window.
continue
}
if (text === undefined && type === 'assistant/message') {
const message = data.message as { content?: unknown } | undefined
const extracted = contentText(message?.content)
if (extracted !== undefined) text = extracted
} else if (tool === undefined && type === 'tool/call') {
tool = {
name: typeof data.name === 'string' ? data.name : 'tool',
args: typeof data.arguments === 'string' ? data.arguments : '',
}
}
}
if (text === undefined && tool === undefined) return {}
return {
...(text === undefined ? {} : { text }),
...(tool === undefined ? {} : { tool }),
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* The live-preview route of the Subagent page ('subagents.live'): one
* request per refresh instead of N per-child `subagents.history` calls.
*
* The route takes the already-resolved topology root (`rootSessionId`),
* enumerates the whole descendant tree ONCE through the host subagent
* runtime (`ctx.get('subagents')` / `listDescendants`), keeps only rows the
* catalog reports running (`activity: 'running'` — the same gate the client
* renders cards on), and folds the newest text/tool activity from each
* child's attached session event log. It never touches DSH source and never
* reads the model's `job_output` cursor.
*
* Degradation contract:
* - `ctx.get('subagents')` missing or `listDescendants` failure → 503 (the
* Subagent page has no topology to show in such deployments anyway).
* - One child's events missing/corrupt → that child is skipped, the rest of
* the batch still returns.
*/
import type { Context, SidebarSubagentsService } from './context-types.ts'
import { SIDE_LABEL_PREFIX } from './sidechat-core.ts'
import { lastActivity, type LastActivity } from './subagent-activity.ts'
import { requireString, SidebarError } from './wire.ts'
/** The live-preview routes of the /sidebar JSON API. */
export interface SidebarSubagentLiveRoutes {
/**
* Fold one tree's running subagent histories into a compact live map.
* @param payload - `{ rootSessionId }`.
* @returns `{ live: Record<childSessionId, LastActivity> }`; children with
* no text/tool yet are omitted.
*/
live(payload: unknown): Promise<{ live: Record<string, LastActivity> }>
}
/**
* The recent-message window of the live preview: only the last 12 surface
* messages of a child's log are folded, matching the old per-card
* `subagents.history({ maxMessages: 12 })` window. Keeps stale tool calls
* out of the preview and bounds the backward scan per child.
*/
export const LIVE_WINDOW_MESSAGES = 12
/**
* Build the live-preview routes bound to the plugin context.
* @param ctx - host plugin context.
*/
export function buildSubagentLiveApi(ctx: Context): SidebarSubagentLiveRoutes {
return {
async live(payload) {
const rootSessionId = requireString(payload, 'rootSessionId')
const subagents = ctx.get('subagents') as SidebarSubagentsService | undefined
if (subagents === undefined || typeof subagents.listDescendants !== 'function') {
throw new SidebarError(
'subagents-unavailable',
'the subagent service is not mounted in this deployment',
503,
)
}
let descendants
try {
descendants = await subagents.listDescendants(rootSessionId)
} catch (error) {
throw new SidebarError(
'subagents-unavailable',
`subagent catalog read failed: ${error instanceof Error ? error.message : String(error)}`,
503,
)
}
const live: Record<string, LastActivity> = {}
for (const entry of descendants) {
// Same gate the client renders cards on: only catalog-running
// children get live lines (spec: "仅对 running 且非 Side Chat").
if (entry.kind !== 'child' || entry.activity !== 'running') continue
// Side Chat threads ride the subagent origin but are sidebar tabs,
// never topology — keep them out of the live map too.
if (entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false) continue
try {
const activity = lastActivity(
ctx.sessions.get(entry.id)?.events ?? [],
LIVE_WINDOW_MESSAGES,
)
if (activity.text !== undefined || activity.tool !== undefined) {
live[entry.id] = activity
}
} catch {
// One child's event log is not readable: skip only that child.
}
}
return { live }
},
}
}
+2
View File
@@ -18,6 +18,8 @@ export type SidebarErrorCode =
| 'pty-error'
| 'pty-deps-missing'
| 'job-error'
| 'sidechat-error'
| 'subagents-unavailable'
| 'settings-rejected'
| 'settings-conflict'
| 'internal'
+32 -3
View File
@@ -1,5 +1,5 @@
/**
* Built-in registration tests: the plugin registers 6 tabs and 6 file
* Built-in registration tests: the plugin registers 7 tabs and 6 file
* viewers through the same service external plugins use (dogfooding);
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
* and the html sandbox settings pin the registry's behavior. (Office
@@ -16,6 +16,7 @@ import { createSidebarStore } from '../src/client/state.ts'
import { allLeaves } from '../src/client/state.ts'
import { registerBuiltins } from '../src/client/builtins/index.ts'
import type { BuiltinTabOptions } from '../src/client/builtins/tabs.tsx'
import { parkSidechatReopen } from '../src/client/SideChatView.tsx'
import { t } from '../src/client/locales.ts'
function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
@@ -26,10 +27,10 @@ function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof cr
}
describe('built-in tab registrations', () => {
it('registers the 6 built-in tabs', () => {
it('registers the 7 built-in tabs', () => {
const { service } = setup()
expect(service.getTabs().map(t => t.id).sort()).toEqual(
['browser', 'diff', 'editor', 'git', 'subagent', 'terminal'],
['browser', 'diff', 'editor', 'git', 'sidechat', 'subagent', 'terminal'],
)
})
@@ -48,6 +49,34 @@ describe('built-in tab registrations', () => {
}
})
it('the side chat tab sits between tasks and terminal in the + menu', () => {
const { service } = setup()
const sidechat = service.getTab('sidechat')
expect(sidechat?.order).toBe(35)
expect(sidechat?.hidden).not.toBe(true)
})
it('side chat mints one tab per thread (Codex-style multi-instance)', () => {
const { service } = setup()
const sidechat = service.getTab('sidechat')
expect(sidechat?.single).not.toBe(true)
// A plain open mints a fresh autoCreate tab (the view creates the
// thread on mount); two opens never collide. (createTab ignores the
// state argument for sidechat — the cast stands in for it.)
const first = sidechat?.createTab?.(undefined as never)
const second = sidechat?.createTab?.(undefined as never)
expect(first?.tab.meta).toEqual({ autoCreate: true })
expect(first?.tab.id).not.toBe(second?.tab.id)
// A parked reopen target mints the deterministic reattach tab, and
// dedupeKey focuses an already-open thread instead of duplicating it.
parkSidechatReopen('session-t1')
const reopen = sidechat?.createTab?.(undefined as never)
expect(reopen?.tab.id).toBe('sidechat:session-t1')
expect(reopen?.tab.meta).toEqual({ threadId: 'session-t1' })
expect(sidechat?.dedupeKey?.(reopen!.tab)).toBe('session-t1')
expect(sidechat?.dedupeKey?.(first!.tab)).toBeUndefined()
})
it('the subagent tab declares its auto-open related settings', () => {
const { service } = setup()
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
-76
View File
@@ -536,82 +536,6 @@ test('a capture-lost drag with no usable coordinates keeps the last applied widt
await page.mouse.up()
})
/* ── issue #248: HMR center-column replacement must be re-located ────── */
test('bottom panel re-locates a center column replaced deep inside #root (issue #248)', async ({ page }) => {
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
const sidebar = page.locator('[data-dsh-better-sidebar]')
await expect(sidebar).toBeAttached({ timeout: 90_000 })
await dismissOnboarding(page)
await expandSidebar(page, sidebar)
// Open the bottom panel; it must become visible with the center column
// measured (its right edge tracking the column's right edge).
const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').toHaveCount(1)
await bottomExpand.click()
await expect
.poll(async () => {
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-height'))
return value !== '' && value !== '0px'
}, { timeout: 90_000 })
.toBe(true)
const probe = (): Promise<{ visible: boolean; drift: number } | null> => page.evaluate(() => {
const el = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
if (el === null || col == null) return null
const panel = el.getBoundingClientRect()
const center = col.getBoundingClientRect()
return {
visible: getComputedStyle(el).visibility !== 'hidden',
drift: Math.abs(panel.right - center.right),
}
})
await expect
.poll(async () => {
const p = await probe()
return p !== null && p.visible && p.drift <= 8
}, { timeout: 30_000 })
.toBe(true)
// Simulate the HMR re-render: the shell swaps the center column node
// DEEP inside #root (a mutation the old childList-only watcher never
// saw — #root's own children don't change), first with a zero-size stub
// (mid-teardown), then with a re-created column of different geometry.
await page.evaluate(() => {
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
if (col == null || col.parentElement === null) throw new Error('center column not found')
const stub = document.createElement('div')
const slot = document.createElement('div')
slot.setAttribute('data-slot', 'conversation')
stub.appendChild(slot)
col.parentElement.replaceChild(stub, col)
})
await page.evaluate(() => {
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
if (col == null || col.parentElement === null) throw new Error('center column stub not found')
const fresh = document.createElement('div')
fresh.style.width = '300px'
fresh.style.height = '200px'
const slot = document.createElement('div')
slot.setAttribute('data-slot', 'conversation')
fresh.appendChild(slot)
col.parentElement.replaceChild(fresh, col)
})
// The locate chain (subtree watcher / html-style watcher / 1.5s retry)
// must converge on the new column within a couple of seconds; the old
// code kept observing the detached column and never recovered.
await expect
.poll(async () => {
const p = await probe()
return p !== null && p.visible && p.drift <= 8
}, { timeout: 15_000 })
.toBe(true)
})
/* ── issue #258: the bottom panel must not flash full-width after a width
drag release ─────────────────────────────────────────────────────── */
+74 -1
View File
@@ -56,9 +56,11 @@ const SEEDED_MD_FILE = 'diagram.md'
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Side Chat (beta)', 'Terminal', 'Browser']
let api: APIRequestContext
/** The seeded session id (captured by seedSession; the Side Chat smoke's parent). */
let seededSessionId: string
/** Seed one workspace + one session (plus files for the editor/mermaid-chunk
* probes) through the host's unary RPC surface. */
@@ -99,6 +101,11 @@ async function seedSession(): Promise<void> {
data: { type: 'client-request', rpcId: 'e2e-session', method: 'session.create', payload: { workspaceId } },
})
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
const sessionBody = (await session.json()) as {
result: { ok: true; value: { sessionId: string } } | { ok: false; error: unknown }
}
expect(sessionBody.result.ok).toBe(true)
seededSessionId = (sessionBody.result as { value: { sessionId: string } }).value.sessionId
}
test.beforeAll(async () => {
@@ -228,6 +235,72 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
await assertNoCrash()
}
// Side Chat host-route smoke against the REAL host: create a thread child
// under the seeded session (custom-seed creation through AgentRegistry),
// deliver a follow-up, cancel, and release it. The turn itself cannot run
// (keyless boot has no model route), but admission + creation + the wire
// envelope must all succeed — this is the deepest functional proof the
// mount lane can make without a provider.
const start = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
data: { sessionId: seededSessionId, question: 'mount lane smoke' },
})
expect(start.ok(), `sidechat.start: ${start.status()} ${await start.text()}`).toBe(true)
const startBody = (await start.json()) as {
ok: boolean
value?: { childId?: string }
error?: { code?: string; message?: string }
}
expect(startBody.ok, `sidechat.start envelope: ${JSON.stringify(startBody)}`).toBe(true)
const childId = startBody.value?.childId
expect(childId, 'sidechat.start must return a child session id').toMatch(/^session-/)
// The child must be a REAL session in the host's store (provider-free
// proof of the custom-seed creation; the boundary message itself only
// becomes durable when a turn claims it, which needs a model route).
const list = await api.post(`${BASE_URL}/api/session.list`, {
data: { type: 'client-request', rpcId: 'e2e-sidechat-list', method: 'session.list', payload: {} },
})
expect(list.ok(), `session.list: ${list.status()} ${await list.text()}`).toBe(true)
const listBody = (await list.json()) as {
result: { ok: true; value: { items: Array<{ sessionId: string }> } } | { ok: false; error: unknown }
}
expect(listBody.result.ok, `session.list envelope: ${JSON.stringify(listBody)}`).toBe(true)
const listedItems = (listBody.result as { value: { items: Array<{ sessionId: string }> } }).value.items
expect(
listedItems.some(item => item.sessionId === childId),
'the thread child must appear in the host session list',
).toBe(true)
for (const method of ['sidechat.prompt', 'sidechat.cancel', 'sidechat.dispose']) {
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
data: method === 'sidechat.prompt' ? { childId, text: 'follow-up' } : { childId },
})
expect(response.ok(), `${method}: ${response.status()} ${await response.text()}`).toBe(true)
}
// The Codex-style immediate-create flow: a blank question creates an
// EMPTY thread (no prompt admitted), sidechat.info reports the live
// agent, and the first prompt delivers the boundary host-side.
const empty = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
data: { sessionId: seededSessionId, question: '' },
})
expect(empty.ok(), `sidechat.start (empty): ${empty.status()} ${await empty.text()}`).toBe(true)
const emptyBody = (await empty.json()) as { ok: boolean; value?: { childId?: string } }
const emptyChildId = emptyBody.value?.childId
expect(emptyChildId, 'immediate create must return a child session id').toMatch(/^session-/)
const info = await api.post(`${BASE_URL}/sidebar/api/sidechat.info`, {
data: { childId: emptyChildId },
})
expect(info.ok(), `sidechat.info: ${info.status()} ${await info.text()}`).toBe(true)
const infoBody = (await info.json()) as { ok: boolean; value?: { live?: boolean; preset?: string } }
expect(infoBody.ok, `sidechat.info envelope: ${JSON.stringify(infoBody)}`).toBe(true)
expect(infoBody.value?.live, 'the fresh thread must have a live agent').toBe(true)
for (const method of ['sidechat.prompt', 'sidechat.dispose']) {
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
data: method === 'sidechat.prompt' ? { childId: emptyChildId, text: 'first message' } : { childId: emptyChildId },
})
expect(response.ok(), `${method} (immediate thread): ${response.status()} ${await response.text()}`).toBe(true)
}
await assertNoCrash()
// The editor chunk (client-editor.js) only loads when a files-window tab
// renders. Exercise the file-open path explicitly through the Files window's
// own tree: the seeded home tab ("Files") is already open with its tree
+365
View File
@@ -0,0 +1,365 @@
/**
* Unit tests for the pure Side Chat core (src/sidechat-core.ts): the seed
* construction (full-log inheritance with honest synthetic closing of an
* in-progress parent turn), the structured in-progress snapshot (with the
* dangling-tool-call fallback), thread-row derivation, save eligibility, and
* the preset resolution.
*/
import { describe, expect, it } from 'vitest'
import type { SidebarHistoryEntry, SidebarSessionEvent, SidebarSessionSummary } from '../src/context-types.ts'
import {
boundaryDelivered,
buildOpenTurnSnapshot,
buildSidechatInheritance,
hasDanglingToolCall,
isContextInjectionMessage,
resolvePresetId,
sideLabel,
sideThreadRows,
threadHasCompletedTurn,
threadTrailingPending,
SIDE_BOUNDARY_PROMPT,
SIDE_INJECTION_PLUGIN,
SIDE_LABEL_PREFIX,
type SeedEvent,
type SidechatLogEvent,
} from '../src/sidechat-core.ts'
/** One log event fixture (structural, seq === index like the live contract).
* Surface-eligible events carry their required `surfaceOp: 'append'`
* marker, exactly like the live pipeline appends them. */
function ev(type: string, seq: number, data: Record<string, unknown> = {}): SidebarSessionEvent {
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
}
return event
}
/** A completed user→assistant turn block (turn T, step 1, optional tools). */
function completedTurn(seq: number, turn: number, over: {
text?: string
tools?: Array<{ callId: string; name?: string; args?: string; result?: string }>
} = {}): SidebarSessionEvent[] {
const events: SidebarSessionEvent[] = [
ev('user/message', seq, { content: [{ type: 'text', text: `q${turn}` }], source: { kind: 'user' } }),
ev('turn/start', seq + 1, { turn }),
ev('step/start', seq + 2, { turn, step: 1 }),
]
let next = seq + 3
for (const tool of over.tools ?? []) {
events.push(ev('tool/call', next++, { turn, step: 1, callId: tool.callId, name: tool.name ?? 'bash', arguments: tool.args ?? '{}' }))
events.push(ev('tool/result', next++, {
turn,
step: 1,
message: {
source: { kind: 'tool', callId: tool.callId },
content: [{ type: 'tool-result', toolCallId: tool.callId, isError: false, content: [{ type: 'text', text: tool.result ?? 'ok' }] }],
},
}))
}
if (over.text !== undefined) {
events.push(ev('assistant/message', next++, {
turn,
step: 1,
message: { content: [{ type: 'text', text: over.text }] },
}))
}
events.push(ev('step/end', next++, { turn, step: 1 }))
events.push(ev('turn/end', next, { turn, reason: { kind: 'completed' } }))
return events
}
/** Assert the seed is contiguous from seq 0. */
function expectContiguous(seed: readonly SeedEvent[]): void {
seed.forEach((event, index) => expect(event.seq).toBe(index))
}
describe('buildSidechatInheritance', () => {
it('returns an empty seed for an empty log', () => {
const { seed, snapshot } = buildSidechatInheritance([])
expect(seed).toEqual([])
expect(snapshot).toBeNull()
})
it('copies a log that ends outside any turn verbatim (pending user message)', () => {
const events = [
...completedTurn(0, 1, { text: 'a' }),
ev('user/message', 6, { content: [{ type: 'text', text: 'pending?' }], source: { kind: 'user' } }),
]
const { seed, snapshot } = buildSidechatInheritance(events)
expectContiguous(seed)
expect(seed).toHaveLength(events.length)
expect(seed.at(-1)?.type).toBe('user/message')
expect(seed.some(event => event.type === 'turn/end')).toBe(true)
expect(snapshot).toBeNull()
})
it('preserves the surface envelope of message events in the seed', () => {
// Regression: the seed validator rejects surface-eligible events
// (user/message, assistant/message, tool/result) whose `surfaceOp`
// marker was stripped — the copy must keep the FULL event envelope.
const events = [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{}' }),
ev('tool/result', 4, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }],
},
}),
ev('assistant/message', 5, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'a' }] } }),
ev('step/end', 6, { turn: 1, step: 1 }),
ev('turn/end', 7, { turn: 1, reason: { kind: 'completed' } }),
]
const { seed } = buildSidechatInheritance(events)
expect(seed.filter(event => event.type === 'user/message').every(event => event.surfaceOp === 'append')).toBe(true)
expect(seed.filter(event => event.type === 'assistant/message').every(event => event.surfaceOp === 'append')).toBe(true)
expect(seed.filter(event => event.type === 'tool/result').every(event => event.surfaceOp === 'append')).toBe(true)
// Non-surface events never gain a marker.
expect(seed.filter(event => event.type === 'turn/end').every(event => event.surfaceOp === undefined)).toBe(true)
})
it('copies a log with no turns at all', () => {
const events = [ev('user/message', 0, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })]
const { seed } = buildSidechatInheritance(events)
expectContiguous(seed)
expect(seed).toHaveLength(1)
})
it('closes an open streaming turn with synthetic step/end + interrupted turn/end', () => {
const events = [
...completedTurn(0, 1, { text: 'done' }),
ev('user/message', 6, { content: [{ type: 'text', text: 'next?' }], source: { kind: 'user' } }),
ev('turn/start', 7, { turn: 2 }),
ev('step/start', 8, { turn: 2, step: 1 }),
ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'partial answer' } }),
]
const { seed, snapshot } = buildSidechatInheritance(events)
expectContiguous(seed)
expect(seed).toHaveLength(events.length + 2)
const stepEnd = seed.at(-2)!
const turnEnd = seed.at(-1)!
expect(stepEnd).toMatchObject({ type: 'step/end', data: { turn: 2, step: 1 } })
expect(turnEnd).toMatchObject({ type: 'turn/end', data: { turn: 2, reason: { kind: 'interrupted' } } })
// The pending user message and the partial chunk survive as REAL events.
expect(seed.some(event => event.type === 'user/message'
&& (event.data.content as Array<{ text: string }>)[0]?.text === 'next?')).toBe(true)
expect(seed.some(event => event.type === 'assistant/chunk')).toBe(true)
expect(snapshot).toBeNull()
})
it('closes an open turn whose tool calls already have results', () => {
const events = [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'grep', arguments: '{"q":"x"}' }),
ev('tool/result', 4, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'hit' }] }],
},
}),
ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'found ' } }),
]
const { seed, snapshot } = buildSidechatInheritance(events)
expectContiguous(seed)
expect(seed.at(-1)?.type).toBe('turn/end')
expect(hasDanglingToolCall(events, 1)).toBe(false)
expect(snapshot).toBeNull()
})
it('falls back to a snapshot cut when a tool call is still executing', () => {
const events = [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'running ' } }),
ev('tool/call', 4, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep 9"}' }),
]
expect(hasDanglingToolCall(events, 1)).toBe(true)
const { seed, snapshot } = buildSidechatInheritance(events)
expectContiguous(seed)
// The seed stops BEFORE the open turn; the pending user message stays.
expect(seed.at(-1)?.type).toBe('user/message')
expect(seed.some(event => event.type === 'turn/start')).toBe(false)
expect(snapshot).not.toBeNull()
expect(snapshot).toContain('running ')
expect(snapshot).toContain('`bash` (executing)')
expect(snapshot).toContain('sleep 9')
})
it('closes only the current step when the open turn has completed steps', () => {
const events = [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
ev('step/end', 4, { turn: 1, step: 1 }),
ev('step/start', 5, { turn: 1, step: 2 }),
ev('assistant/chunk', 6, { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'second' } }),
]
const { seed } = buildSidechatInheritance(events)
expectContiguous(seed)
expect(seed.at(-2)).toMatchObject({ type: 'step/end', data: { turn: 1, step: 2 } })
expect(seed.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 1 } })
})
})
describe('buildOpenTurnSnapshot', () => {
it('returns null without an open turn', () => {
expect(buildOpenTurnSnapshot([ev('user/message', 0, { content: [], source: { kind: 'user' } })])).toBeNull()
expect(buildOpenTurnSnapshot(completedTurn(0, 1, { text: 'x' }))).toBeNull()
})
it('preserves streamed text and tool detail verbatim', () => {
const events = [
ev('turn/start', 0, { turn: 1 }),
ev('step/start', 1, { turn: 1, step: 1 }),
ev('assistant/chunk', 2, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'think' } }),
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '```js\ncode' } }),
ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '\n```' } }),
ev('tool/call', 5, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a.txt"}' }),
ev('tool/result', 6, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'file body' }] }],
},
}),
ev('tool/call', 7, { turn: 1, step: 1, callId: 'c2', name: 'bash', arguments: '{"cmd":"long"}' }),
]
const snapshot = buildOpenTurnSnapshot(events)
expect(snapshot).not.toBeNull()
expect(snapshot).toContain('```js\ncode\n```')
expect(snapshot).toContain('Reasoning so far')
expect(snapshot).toContain('`read` — arguments: `{"path":"a.txt"}`')
expect(snapshot).toContain('Result: file body')
expect(snapshot).toContain('`bash` (executing)')
})
})
describe('sideLabel', () => {
it('prefixes and truncates', () => {
expect(sideLabel('hello')).toBe('Side: hello')
expect(sideLabel(' a b ')).toBe('Side: a b')
const long = 'x'.repeat(100)
const label = sideLabel(long)
expect(label.startsWith(SIDE_LABEL_PREFIX)).toBe(true)
expect(label.length).toBeLessThanOrEqual(48)
expect(label.endsWith('…')).toBe(true)
})
})
describe('sideThreadRows', () => {
const summary = (over: Partial<SidebarSessionSummary>): SidebarSessionSummary => ({
id: 's1',
displayTitle: 'x',
...over,
})
it('keeps only subagent-origin children of the session with the label prefix', () => {
const byId: Record<string, SidebarSessionSummary> = {
main: summary({ id: 'main', displayTitle: 'Main' }),
thread: summary({ id: 'thread', origin: 'subagent', parentId: 'main', displayTitle: 'Side: q', running: true }),
plainSubagent: summary({ id: 'plain', origin: 'subagent', parentId: 'main', displayTitle: 'Read files' }),
foreignThread: summary({ id: 'foreign', origin: 'subagent', parentId: 'other', displayTitle: 'Side: other' }),
}
const rows = sideThreadRows(byId, 'main')
expect(rows).toEqual([{ id: 'thread', title: 'Side: q', running: true }])
})
})
describe('save eligibility', () => {
const entry = (event: SidebarSessionEvent): SidebarHistoryEntry => ({ event })
it('requires a completed turn after the seed boundary', () => {
const seed = [ev('session/end-seed', 0)]
expect(threadHasCompletedTurn([entry(seed[0]!)])).toBe(false)
expect(threadHasCompletedTurn([entry(seed[0]!), entry(ev('user/message', 1, { content: [], source: { kind: 'user' } }))])).toBe(false)
const withTurn = [...completedTurn(2, 1, { text: 'a' })]
const entries = [entry(seed[0]!), ...withTurn.map(entry)]
expect(threadHasCompletedTurn(entries)).toBe(true)
})
it('detects a trailing unanswered follow-up', () => {
const entries = [
...completedTurn(2, 1, { text: 'a' }).map(entry),
entry(ev('user/message', 9, { content: [], source: { kind: 'user' } })),
]
expect(threadTrailingPending(entries)).toBe(true)
expect(threadTrailingPending(completedTurn(2, 1, { text: 'a' }).map(entry))).toBe(false)
})
})
describe('resolvePresetId', () => {
it('prefers the newest selection event over the header', () => {
const events = [ev('agent-preset/selected', 0, { agentPreset: 'later' })]
expect(resolvePresetId({ agentPreset: 'early' }, events)).toBe('later')
expect(resolvePresetId({ agentPreset: 'early' }, [])).toBe('early')
expect(resolvePresetId({}, [])).toBeUndefined()
})
})
describe('SIDE_BOUNDARY_PROMPT contract', () => {
it('opens with the boundary prefix the transcript drops', () => {
expect(SIDE_BOUNDARY_PROMPT.startsWith('Side conversation boundary')).toBe(true)
})
})
describe('boundaryDelivered', () => {
it('detects the boundary message and ignores everything else', () => {
expect(boundaryDelivered([])).toBe(false)
expect(boundaryDelivered([ev('user/message', 0, {
content: [{ type: 'text', text: 'ordinary question' }],
})])).toBe(false)
// The inherited seed never contains a boundary: parent messages are
// ordinary user rows.
expect(boundaryDelivered([
ev('user/message', 0, { content: [{ type: 'text', text: 'parent q' }] }),
ev('user/message', 1, {
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nfirst` }],
}),
])).toBe(true)
// Bare-string content is tolerated too.
expect(boundaryDelivered([ev('user/message', 0, {
content: `${SIDE_BOUNDARY_PROMPT}\n\nfirst`,
})])).toBe(true)
})
})
describe('isContextInjectionMessage', () => {
it('recognizes plugin-stamped sources structurally', () => {
expect(isContextInjectionMessage({
content: [{ type: 'text', text: 'runtime context' }],
source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN },
})).toBe(true)
expect(isContextInjectionMessage({
content: [{ type: 'text', text: 'q' }],
source: { kind: 'user' },
})).toBe(false)
})
it('falls back to the boundary prefix for pre-split logs and sourceless rows', () => {
expect(isContextInjectionMessage({
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nlegacy question` }],
source: { kind: 'user' },
})).toBe(true)
expect(isContextInjectionMessage({
content: `${SIDE_BOUNDARY_PROMPT}\n\nlegacy`,
})).toBe(true)
expect(isContextInjectionMessage({
content: [{ type: 'text', text: 'ordinary question' }],
source: { kind: 'user' },
})).toBe(false)
expect(isContextInjectionMessage({ content: [{ type: 'text', text: 'no source row' }] })).toBe(false)
})
})
+361
View File
@@ -0,0 +1,361 @@
/**
* Host route tests for the Side Chat API ('sidechat.start' / 'sidechat.prompt'
* / 'sidechat.cancel' / 'sidechat.dispose'): the custom-seed thread creation
* (with the in-progress-turn synthetic close and the dangling-tool-call
* snapshot fallback), the boundary+question first prompt, follow-ups on live
* and cold (resumed) agents, cancel, and dispose.
*/
import { describe, expect, it, vi } from 'vitest'
import { buildSidechatApi } from '../src/sidechat-routes.ts'
import { SidebarError } from '../src/wire.ts'
import { SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
import type { Context } from '../src/context-types.ts'
/** A fake live agent (inject/followup/cancel spied). */
function agent(id: string, over: { events?: unknown[]; header?: Record<string, unknown>; provider?: string; model?: string } = {}) {
return {
id,
status: 'idle' as const,
options: { provider: over.provider ?? 'test', model: over.model ?? 'model-x' },
session: {
id,
header: { cwd: '/p', delegationDepth: 0, agentPreset: 'preset-a', ...over.header },
events: over.events ?? [],
},
inject: vi.fn(),
followup: vi.fn(),
cancel: vi.fn(),
}
}
type AgentLike = ReturnType<typeof agent>
type Handle = { agent: AgentLike; dispose: () => Promise<void> }
/** The standard services the happy paths need. */
function happyServices(parent: AgentLike | undefined, child: AgentLike) {
const create = vi.fn(async (_options: unknown): Promise<Handle> => ({
agent: child,
dispose: vi.fn(async () => {}),
}))
const resume = vi.fn(async (_options: unknown): Promise<Handle> => ({
agent: child,
dispose: vi.fn(async () => {}),
}))
const get = vi.fn((id: unknown) => (id === child.id ? child : parent))
const rename = vi.fn((_session: unknown, _title: string) => ({ title: 'x', eventSeq: 1 }))
const resolve = vi.fn(async (_id?: string) => ({ id: 'preset-a' }))
const mount = vi.fn(async () => {})
const inspect = vi.fn(async () => ({ meta: { agentPreset: 'preset-a' }, events: [] }))
return {
agents: { get, create, resume },
agentPresets: { resolve, mount },
sessionTitle: { rename },
sessionPersistence: { inspect },
create,
resume,
get,
rename,
mount,
inspect,
}
}
/** A context serving the optional services the routes read via ctx.get. */
function ctxWith(services: {
agents?: unknown
agentPresets?: unknown
sessionTitle?: unknown
sessionPersistence?: unknown
}): Context {
const table: Record<string, unknown> = {
...(services.agents === undefined ? {} : { agents: services.agents }),
...(services.agentPresets === undefined ? {} : { agentPresets: services.agentPresets }),
...(services.sessionTitle === undefined ? {} : { sessionTitle: services.sessionTitle }),
...(services.sessionPersistence === undefined ? {} : { sessionPersistence: services.sessionPersistence }),
}
return {
get: (key: string) => table[key],
} as unknown as Context
}
function ev(type: string, seq: number, data: Record<string, unknown> = {}): { type: string; seq: number; time: number; data: Record<string, unknown>; surfaceOp?: string } {
const event = { type, seq, time: seq * 1000, data }
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
return { ...event, surfaceOp: 'append' }
}
return event
}
describe('sidechat.start', () => {
it('creates a seeded subagent-origin child and admits the boundary + question', async () => {
const parent = agent('parent', {
events: [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'a' }] } }),
ev('step/end', 4, { turn: 1, step: 1 }),
ev('turn/end', 5, { turn: 1, reason: { kind: 'completed' } }),
],
})
const child = agent('child')
const services = happyServices(parent, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.start']({ sessionId: 'parent', question: 'explain the event flow' })
expect(result.childId).toMatch(/^session-/)
expect(services.create).toHaveBeenCalledTimes(1)
const options = services.create.mock.calls[0]![0] as {
sessionId: string
meta: Record<string, unknown>
seed: readonly { type: string; data: Record<string, unknown> }[]
agentOptions: { provider: string; model: string }
setup: unknown
}
expect(options.sessionId).toBe(result.childId)
expect(options.meta).toMatchObject({
parentSession: 'parent',
origin: 'subagent',
seedLength: 7,
delegationDepth: 1,
agentPreset: 'preset-a',
cwd: '/p',
})
expect(options.agentOptions).toEqual({ provider: 'test', model: 'model-x' })
// The child carries the parent's completed turns as a verbatim seed,
// closed by the durable subagent descriptor (honest catalog citizenship:
// without it the host's subagents.list renders the cold thread as a
// 'corrupt' diagnostic row).
expect(options.seed.map(event => event.type)).toEqual([
'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end',
'subagent/descriptor',
])
expect(options.seed.at(-1)?.data).toMatchObject({
version: 2,
mode: 'continuable',
provider: 'sidechat',
label: sideLabel('explain the event flow'),
agentProvider: 'test',
agentModel: 'model-x',
})
// First contact is SPLIT: the boundary rides agent.inject (plugin-stamped
// context, no wake), the question is the follow-up that wakes the driver.
expect(child.inject).toHaveBeenCalledTimes(1)
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string; plugin: string } }
expect(injection.source).toEqual({ kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN })
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
expect(injection.content[0]!.text).not.toContain('explain the event flow')
expect(child.followup).toHaveBeenCalledTimes(1)
const message = child.followup.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string } }
expect(message.source).toEqual({ kind: 'user' })
expect(message.content[0]!.text).toBe('explain the event flow')
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
})
it('synthetically closes an in-progress parent turn inside the seed', async () => {
const parent = agent('parent', {
events: [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'streaming' } }),
],
})
const child = agent('child')
const services = happyServices(parent, child)
const api = buildSidechatApi(ctxWith(services))
await api['sidechat.start']({ sessionId: 'parent', question: 'what now?' })
const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string; data: Record<string, unknown> }> }
expect(options.seed.map(event => event.type)).toEqual([
'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end',
'subagent/descriptor',
])
expect(options.seed.at(-2)?.data).toEqual({ turn: 1, reason: { kind: 'interrupted' } })
})
it('falls back to the snapshot when a tool call is still executing', async () => {
const parent = agent('parent', {
events: [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
],
})
const child = agent('child')
const services = happyServices(parent, child)
const api = buildSidechatApi(ctxWith(services))
await api['sidechat.start']({ sessionId: 'parent', question: 'what now?' })
const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string }> }
// Cut BEFORE the open turn: only the pending user message is inherited.
expect(options.seed.map(event => event.type)).toEqual(['user/message', 'subagent/descriptor'])
// The snapshot rides the injection, not the follow-up question.
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }> }
expect(injection.content[0]!.text).toContain('`bash` (executing)')
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
expect(message.content[0]!.text).toBe('what now?')
})
it('creates an EMPTY thread on a blank question (Codex-style immediate create)', async () => {
const parent = agent('parent', {
events: [
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
],
})
const child = agent('child')
const services = happyServices(parent, child)
const api = buildSidechatApi(ctxWith(services))
const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: ' ' })
// No prompt yet: the composer owns the first message; the placeholder
// label is pinned and the in-progress snapshot is parked for it.
expect(child.inject).not.toHaveBeenCalled()
expect(child.followup).not.toHaveBeenCalled()
expect(services.rename).toHaveBeenCalledWith(child.session, SIDE_NEW_THREAD_TITLE)
// The first prompt injects the boundary + the parked snapshot (split
// from the question) and earns the thread its real label.
services.agents.get = vi.fn((id: unknown) => (id === childId ? child : parent))
await api['sidechat.prompt']({ childId, text: 'explain the event flow' })
expect(child.inject).toHaveBeenCalledTimes(1)
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }>; source: { kind: string } }
expect(injection.source.kind).toBe('plugin')
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
expect(injection.content[0]!.text).toContain('`bash` (executing)')
expect(child.followup).toHaveBeenCalledTimes(1)
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
expect(message.content[0]!.text).toBe('explain the event flow')
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
})
it('rejects a non-running parent', async () => {
const parent = agent('parent', { events: [] })
const child = agent('child')
const services = happyServices(parent, child)
const idle = ctxWith({
...services,
agents: { ...services.agents, get: vi.fn((_id: unknown) => undefined) },
})
const idleApi = buildSidechatApi(idle)
await expect(idleApi['sidechat.start']({ sessionId: 'parent', question: 'q' })).rejects.toThrow(/not running/)
})
it('degrades when the agents service is absent', async () => {
const api = buildSidechatApi(ctxWith({}))
await expect(api['sidechat.start']({ sessionId: 'parent', question: 'q' })).rejects.toThrow(SidebarError)
})
})
describe('sidechat.prompt', () => {
it('delivers follow-ups to a live agent (boundary already delivered)', async () => {
const child = agent('child', {
events: [
ev('user/message', 0, {
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nfirst question` }],
source: { kind: 'user' },
}),
],
})
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.prompt']({ childId: 'child', text: 'tell me more' })
expect(result).toEqual({ accepted: true })
expect(child.inject).not.toHaveBeenCalled()
expect(child.followup).toHaveBeenCalledTimes(1)
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
expect(message.content[0]!.text).toBe('tell me more')
})
it('cold-resumes the thread when the agent is gone', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
services.agents.get = vi.fn((_id: unknown) => undefined)
const api = buildSidechatApi(ctxWith(services))
await api['sidechat.prompt']({ childId: 'child', text: 'after restart' })
expect(services.resume).toHaveBeenCalledTimes(1)
const resumeOptions = services.resume.mock.calls[0]![0] as { resumeSessionId: string; setup: unknown }
expect(resumeOptions.resumeSessionId).toBe('child')
expect(typeof resumeOptions.setup).toBe('function')
expect(services.inspect).toHaveBeenCalledWith('child')
expect(child.followup).toHaveBeenCalledTimes(1)
})
it('rejects blank text', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
await expect(api['sidechat.prompt']({ childId: 'child', text: '' })).rejects.toThrow(SidebarError)
})
})
describe('sidechat.cancel and sidechat.dispose', () => {
it('cancels the running turn with the user authority', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
await api['sidechat.cancel']({ childId: 'child' })
expect(child.cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
})
it('disposes the created thread agent', async () => {
const parent = agent('parent', { events: [] })
const dispose = vi.fn(async () => {})
const create = vi.fn(async (_options: unknown): Promise<Handle> => ({
agent: agent('child'),
dispose,
}))
const services = happyServices(parent, agent('child'))
services.agents.create = create
const api = buildSidechatApi(ctxWith(services))
const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: 'q' })
await api['sidechat.dispose']({ childId })
expect(dispose).toHaveBeenCalledTimes(1)
// A second dispose is a harmless no-op.
await api['sidechat.dispose']({ childId })
expect(dispose).toHaveBeenCalledTimes(1)
})
})
describe('sidechat.info', () => {
it('reports the live agent state and identity', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const info = await api['sidechat.info']({ childId: 'child' })
expect(info).toEqual({
live: true,
status: 'idle',
provider: 'test',
model: 'model-x',
preset: 'preset-a',
})
})
it('reports the persisted preset of a cold thread', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
services.agents.get = vi.fn((_id: unknown) => undefined)
const api = buildSidechatApi(ctxWith(services))
const info = await api['sidechat.info']({ childId: 'child' })
expect(info).toEqual({ live: false, preset: 'preset-a' })
expect(services.inspect).toHaveBeenCalledWith('child')
})
it('degrades to a bare cold info when the session is gone', async () => {
const services = happyServices(undefined, agent('child'))
services.agents.get = vi.fn((_id: unknown) => undefined)
services.inspect = vi.fn(async () => { throw new Error('unknown session') })
services.sessionPersistence = { inspect: services.inspect }
const api = buildSidechatApi(ctxWith(services))
await expect(api['sidechat.info']({ childId: 'ghost' })).resolves.toEqual({ live: false })
})
})
+122
View File
@@ -0,0 +1,122 @@
/**
* Seed-validator integration test: the seed built by {@link buildSidechatInheritance}
* must be accepted by the REAL dsh-session validator (`Session.create`).
*
* Regression: the seed copy originally stripped the event envelope's
* `surfaceOp` marker, and the real validator rejects surface-eligible events
* (user/message, assistant/message, tool/result) without it — the live host
* surfaced it as "invalid seed event at index N: ... requires a surfaceOp
* marker" during real thread creation. This test runs the ACTUAL validator,
* not a mock, so the class of bug cannot silently return.
*/
import { describe, expect, it } from 'vitest'
import { Session } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { SidebarSessionEvent } from '../src/context-types.ts'
import { buildSidechatInheritance } from '../src/sidechat-core.ts'
/** One live-style event with the surface marker message events carry, and
* the REAL message shapes the validator demands (id/role/source/content;
* tool/result messages carry role 'user' + one tool-result block). */
function ev(type: string, seq: number, data: Record<string, unknown>): SidebarSessionEvent {
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
}
return event
}
function userMessage(text: string): Record<string, unknown> {
return { id: `m-${text}`, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }
}
function assistantMessage(text: string): Record<string, unknown> {
return {
id: `m-${text}`,
role: 'assistant',
content: [{ type: 'text', text }],
source: { kind: 'model', provider: 'test', model: 'model-x' },
}
}
function toolResultMessage(callId: string, text: string): Record<string, unknown> {
return {
id: `m-${callId}`,
role: 'user',
content: [{ type: 'tool-result', toolCallId: callId, content: [{ type: 'text', text }] }],
source: { kind: 'tool', callId },
}
}
/** A parent log with a completed turn, a pending question, and an open
* in-progress turn (the exact shape a mid-stream thread creation sees). */
function parentLog(): SidebarSessionEvent[] {
return [
ev('user/message', 0, userMessage('first question')),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/message', 3, { turn: 1, step: 1, message: assistantMessage('first answer') }),
ev('step/end', 4, { turn: 1, step: 1 }),
ev('turn/end', 5, { turn: 1, reason: { kind: 'completed' } }),
ev('user/message', 6, userMessage('pending question')),
ev('turn/start', 7, { turn: 2 }),
ev('step/start', 8, { turn: 2, step: 1 }),
ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'in-progress' } }),
]
}
describe('sidechat seed against the real dsh-session validator', () => {
it('accepts a seed with a completed turn + pending user message + synthetically closed open turn', () => {
const { seed, snapshot } = buildSidechatInheritance(parentLog())
expect(snapshot).toBeNull()
// The REAL validator: this throws when an envelope field was stripped
// (the reported regression) or the turn balance is off.
const child = Session.create('session-validator-test' as SessionId, seed as never)
const types = child.events.map(event => event.type)
expect(types).toEqual([
'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end',
'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end',
'session/end-seed',
])
// The synthetic close is honest: the frozen turn ends interrupted.
const turnEnd = child.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.data).toEqual({ turn: 2, reason: { kind: 'interrupted' } })
})
it('accepts a dangling-tool-call fallback seed (cut before the open turn)', () => {
const log = [
...parentLog().slice(0, 7),
ev('turn/start', 7, { turn: 2 }),
ev('step/start', 8, { turn: 2, step: 1 }),
ev('tool/call', 9, { turn: 2, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
]
const { seed, snapshot } = buildSidechatInheritance(log)
expect(snapshot).not.toBeNull()
const child = Session.create('session-validator-fallback' as SessionId, seed as never)
expect(child.events.map(event => event.type).at(-1)).toBe('session/end-seed')
})
it('accepts the durable subagent descriptor the routes append to the seed', () => {
// Regression guard for the catalog-corrupt fix: a cold thread WITHOUT a
// descriptor renders as a 'corrupt' diagnostic in the host subagents.list.
// The descriptor is a log-only event appended INSIDE the seed (before the
// end-seed marker); the real validator must accept the combined seed.
const { seed } = buildSidechatInheritance(parentLog())
const descriptor = snapshotSubagentDescriptor({
mode: 'continuable',
provider: 'sidechat',
label: 'Side: test',
agentProvider: 'test',
agentModel: 'model-x',
})
const withDescriptor = [
...seed,
{ type: 'subagent/descriptor', seq: seed.length, time: Date.now(), data: descriptor },
]
const child = Session.create('session-validator-descriptor' as SessionId, withDescriptor as never)
const types = child.events.map(event => event.type)
expect(types.at(-2)).toBe('subagent/descriptor')
expect(types.at(-1)).toBe('session/end-seed')
})
})
+248
View File
@@ -0,0 +1,248 @@
/**
* Unit tests for the Side Chat transcript mapping (src/client/sidechat-
* transcript.ts): the seed cut at session/end-seed, context-injection rows
* (plugin-stamped sources and the legacy boundary-prefix blob), chunk
* streaming accumulation superseded by assembled messages, tool call/result
* pairing, and orphan failed results.
*/
import { describe, expect, it } from 'vitest'
import type { SidebarHistoryEntry, SidebarSessionEvent } from '../src/context-types.ts'
import { SIDE_BOUNDARY_PREFIX, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN } from '../src/sidechat-core.ts'
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
/** One history entry (event + optional view). */
function entry(event: SidebarSessionEvent): SidebarHistoryEntry {
return { event }
}
/** One log event fixture. Surface-eligible events carry their required
* `surfaceOp: 'append'` marker, exactly like the live pipeline appends them. */
function ev(type: string, seq: number, data: Record<string, unknown> = {}): SidebarSessionEvent {
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
}
return event
}
function textBlocks(...texts: string[]): unknown[] {
return texts.map(text => ({ type: 'text', text }))
}
describe('transcriptRows', () => {
it('cuts the inherited seed at the last end-seed and renders the boundary as an injection row', () => {
const entries = [
entry(ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } })),
entry(ev('session/end-seed', 1)),
entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN } })),
entry(ev('user/message', 3, { content: textBlocks('the side question'), source: { kind: 'user' } })),
]
const rows = transcriptRows(entries)
expect(rows).toEqual([
{ kind: 'injection', seq: 2, text: `${SIDE_BOUNDARY_PREFIX}\n\nmode` },
{ kind: 'user', seq: 3, text: 'the side question' },
])
})
it('splits the LEGACY wrapped first message (boundary + question in one user row) at the boundary prompt', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, {
content: textBlocks(`${SIDE_BOUNDARY_PROMPT}\n\nthe first question`),
source: { kind: 'user' },
})),
entry(ev('user/message', 2, { content: textBlocks('follow-up'), source: { kind: 'user' } })),
]
const rows = transcriptRows(entries)
expect(rows).toEqual([
{ kind: 'injection', seq: 1, text: SIDE_BOUNDARY_PROMPT },
{ kind: 'user', seq: 1, text: 'the first question' },
{ kind: 'user', seq: 2, text: 'follow-up' },
])
})
it('renders any plugin-sourced context message as an injection row, boundary prefix or not', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, { content: textBlocks('runtime context'), source: { kind: 'plugin', plugin: 'other-plugin' } })),
entry(ev('user/message', 2, { content: textBlocks('q'), source: { kind: 'user' } })),
]
const rows = transcriptRows(entries)
expect(rows).toEqual([
{ kind: 'injection', seq: 1, text: 'runtime context' },
{ kind: 'user', seq: 2, text: 'q' },
])
})
it('accumulates chunk deltas per block and supersedes them on settle', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
entry(ev('turn/start', 2, { turn: 1 })),
entry(ev('step/start', 3, { turn: 1, step: 1 })),
entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'Hel' } })),
entry(ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'lo' } })),
entry(ev('assistant/chunk', 6, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' } })),
]
const rows = transcriptRows(entries)
const assistant = rows.find(row => row.kind === 'assistant') as Extract<SidechatTranscriptRow, { kind: 'assistant' }>
expect(assistant.text).toBe('Hello')
expect(assistant.settled).toBe(false)
const reasoning = rows.find(row => row.kind === 'reasoning') as Extract<SidechatTranscriptRow, { kind: 'reasoning' }>
expect(reasoning.text).toBe('think')
})
it('replaces streaming rows with the settled assistant message', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
entry(ev('turn/start', 2, { turn: 1 })),
entry(ev('step/start', 3, { turn: 1, step: 1 })),
entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'par' } })),
entry(ev('assistant/message', 5, { turn: 1, step: 1, message: { content: textBlocks('final answer') } })),
]
const rows = transcriptRows(entries)
const assistants = rows.filter(row => row.kind === 'assistant')
expect(assistants).toHaveLength(1)
expect(assistants[0]).toMatchObject({ kind: 'assistant', text: 'final answer', settled: true })
})
it('pairs tool calls with results and marks failures', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('turn/start', 1, { turn: 1 })),
entry(ev('step/start', 2, { turn: 1, step: 1 })),
entry(ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a"}' })),
entry(ev('tool/result', 4, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', isError: true, content: [{ type: 'text', text: 'denied' }] }],
},
error: { name: 'EACCES', code: 'EACCES' },
})),
]
const rows = transcriptRows(entries)
expect(rows).toHaveLength(1)
const tool = rows[0]
expect(tool).toMatchObject({
kind: 'tool',
name: 'read',
args: '{"path":"a"}',
resultText: 'denied',
failed: true,
executing: false,
})
})
it('keeps a call executing until its result lands and surfaces orphan failures', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('turn/start', 1, { turn: 1 })),
entry(ev('step/start', 2, { turn: 1, step: 1 })),
entry(ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{}' })),
]
let rows = transcriptRows(entries)
expect(rows[0]).toMatchObject({ kind: 'tool', executing: true })
// Orphan failed result outside the fetched window still surfaces.
const orphan = [
entry(ev('session/end-seed', 0)),
entry(ev('tool/result', 1, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'gone' },
content: [{ type: 'tool-result', toolCallId: 'gone', isError: true, content: [{ type: 'text', text: 'boom' }] }],
},
error: { name: 'X', code: 'X' },
})),
]
rows = transcriptRows(orphan)
expect(rows[0]).toMatchObject({ kind: 'tool', failed: true, resultText: 'boom' })
})
})
describe('collectOwnEvents', () => {
/** A paginated fake log: fetchPage(beforeSeq) returns the ≤pageSize events
* ENDING before beforeSeq (exclusive); the tail page when omitted. */
function pagedLog(events: SidebarSessionEvent[], pageSize: number) {
const calls: Array<number | undefined> = []
const fetchPage = async (beforeSeq?: number): Promise<SidebarHistoryEntry[]> => {
calls.push(beforeSeq)
const window = beforeSeq === undefined ? events : events.filter(e => e.seq < beforeSeq)
return window.slice(-pageSize).map(entry)
}
return { fetchPage, calls }
}
it('finds the seed boundary on the tail page in one fetch', async () => {
const { fetchPage, calls } = pagedLog([
ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } }),
ev('session/end-seed', 1),
ev('user/message', 2, { content: textBlocks('own'), source: { kind: 'user' } }),
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
], 200)
const result = await collectOwnEvents(fetchPage)
expect(calls).toEqual([undefined])
expect(result.seedBoundary).toBe(1)
expect(result.entries.map(e => e.event.seq)).toEqual([2, 3])
})
it('walks back across pages until the boundary surfaces', async () => {
// A dense chunk tail (cold reads re-expand chunk-rows): the boundary is
// several pages back and earlier tool/call events must NOT fall out of
// the loaded window (the re-entry tool-row regression).
const events = [
ev('session/end-seed', 0),
ev('tool/call', 1, { turn: 1, step: 1, callId: 'c1', name: 'bash' }),
...Array.from({ length: 12 }, (_, i) => ev('assistant/chunk', 2 + i, { turn: 1, step: 1 })),
]
const { fetchPage, calls } = pagedLog(events, 5)
const result = await collectOwnEvents(fetchPage)
expect(calls.length).toBeGreaterThan(1)
expect(result.seedBoundary).toBe(0)
// Everything after the marker, oldest-first, deduped.
expect(result.entries.map(e => e.event.seq)).toEqual(events.slice(1).map(e => e.seq))
})
it('stops at the log start when no marker exists and reports boundary 0', async () => {
const { fetchPage } = pagedLog([
ev('user/message', 0, { content: textBlocks('legacy'), source: { kind: 'user' } }),
ev('assistant/message', 1, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
], 200)
const result = await collectOwnEvents(fetchPage)
expect(result.seedBoundary).toBe(0)
expect(result.entries.map(e => e.event.seq)).toEqual([0, 1])
})
it('honours the page cap instead of re-walking forever', async () => {
const events = Array.from({ length: 30 }, (_, i) => ev('assistant/chunk', i, { turn: 1, step: 1 }))
const { fetchPage, calls } = pagedLog(events, 5)
const result = await collectOwnEvents(fetchPage, 2)
expect(calls).toEqual([undefined, 25])
expect(result.seedBoundary).toBe(0)
expect(result.entries.map(e => e.event.seq)).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
})
})
describe('toolArgsSummary', () => {
it('picks the most identifying string field', () => {
expect(toolArgsSummary('{"command":"ls -la","timeout":1000}')).toBe('ls -la')
expect(toolArgsSummary('{"file_path":"/a/b.ts","old_string":"x"}')).toBe('/a/b.ts')
expect(toolArgsSummary('{"pattern":"foo","path":"/repo"}')).toBe('/repo')
})
it('flattens and truncates raw text when no known key parses', () => {
expect(toolArgsSummary('{"custom":"v"}')).toBe('{"custom":"v"}')
expect(toolArgsSummary('not json at all')).toBe('not json at all')
expect(toolArgsSummary(`{"command":"${'x'.repeat(200)}"`)).toHaveLength(80)
expect(toolArgsSummary(`{"command":"${'x'.repeat(200)}"`)).toMatch(/…$/)
})
it('reads empty for missing or blank input', () => {
expect(toolArgsSummary(undefined)).toBe('')
expect(toolArgsSummary('{"command":" "}')).toBe('{"command":" "}')
})
})
+44 -6
View File
@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import { contentText, lastActivity } from '../src/client/subagent-activity.ts'
import type { SidebarHistoryEntry } from '../src/context-types.ts'
import { contentText, lastActivity } from '../src/subagent-activity.ts'
import type { SidebarSessionEvent } from '../src/context-types.ts'
describe('subagent activity summary parser', () => {
/** One history entry from raw event fields. */
const entry = (type: string, data: Record<string, unknown>): SidebarHistoryEntry => ({
event: { type, seq: 0, time: 0, data },
/** One raw session event. */
const entry = (type: string, data: Record<string, unknown>): SidebarSessionEvent => ({
type, seq: 0, time: 0, data,
})
it('extracts text blocks and skips non-text content', () => {
@@ -37,7 +37,7 @@ describe('subagent activity summary parser', () => {
})
})
it('lastActivity keeps only the fields the tail actually has', () => {
it('lastActivity keeps only the fields the log actually has', () => {
expect(lastActivity([
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }),
])).toEqual({ tool: { name: 'bash', args: '{"command":"ls"}' } })
@@ -64,4 +64,42 @@ describe('subagent activity summary parser', () => {
])
expect(live.tool).toEqual({ name: 'web', args: '' })
})
it('lastActivity window excludes activity older than the last maxMessages messages', () => {
// One stale tool call before the window: with maxMessages=1 only the
// latest message (and the events around it) are considered.
expect(lastActivity([
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }),
], 1)).toEqual({ text: 'new text' })
// The same log with a wider window surfaces the tool call again.
expect(lastActivity([
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }),
], 2)).toEqual({
text: 'new text',
tool: { name: 'bash', args: '{"command":"old"}' },
})
// Non-message events (tool calls) do not consume quota, but once the
// window holds its messages, older events are dropped even without an
// older message to stop at: the second message pushes the tool call out.
expect(lastActivity([
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
entry('user/message', { content: [{ type: 'text', text: '继续' }] }),
], 1)).toEqual({})
// A tool call between the two in-window messages stays visible.
expect(lastActivity([
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
entry('tool/call', { callId: 'fresh', name: 'read', arguments: '{"path":"a.ts"}' }),
entry('user/message', { content: [{ type: 'text', text: '继续' }] }),
], 2)).toEqual({
text: 'first',
tool: { name: 'read', args: '{"path":"a.ts"}' },
})
})
})
+50
View File
@@ -75,6 +75,56 @@ describe('subagent detection over the sessions list feed', () => {
expect(countSubagentDescendants(cyclic, 'a')).toEqual({ count: 2, runningCount: 0 })
})
it('excludes Side Chat threads (subagent origin, "Side: " label) from counts', () => {
// Side threads ride the subagent origin for list hiding + the RPC fence
// but they are tab-strip conversations, never topology: they must not
// fire the auto-open trigger nor inflate the Subagent page totals.
const byId: SidebarSessionList['byId'] = {
p1: { id: 'p1', displayTitle: 'P1' },
c1: { id: 'c1', displayTitle: 'C1', origin: 'subagent', parentId: 'p1' },
s1: { id: 's1', displayTitle: 'Side: refactor plan', origin: 'subagent', parentId: 'p1', running: true },
}
expect(directSubagentCount(byId, 'p1')).toBe(1)
expect(countSubagentDescendants(byId, 'p1')).toEqual({ count: 1, runningCount: 0 })
// A side thread appearing under an empty session never trips 0 → N.
const before: SidebarSessionList = { current: 'p2', byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
const after: SidebarSessionList = {
current: 'p2',
byId: {
p2: { id: 'p2', displayTitle: 'P2' },
s2: { id: 's2', displayTitle: 'Side: New thread', origin: 'subagent', parentId: 'p2' },
},
}
expect(detectNewDirectSubagent(before, after, 'p2')).toBe(false)
})
it('documents the title-frame race the Sidebar auto-open debounce absorbs', () => {
// The host delivers a new child's origin and title in SEPARATE frames:
// a Side Chat thread's FIRST visible frame still shows a fallback title
// (the cwd basename — no 'Side: ' prefix), so an immediate 0→N check
// misreads it as a genuine subagent. Sidebar.tsx therefore debounces
// AUTO_OPEN_DEBOUNCE_MS and re-evaluates the ORIGINAL baseline against
// the live snapshot; once the title frame has landed the same baseline
// yields no trigger. These two assertions pin exactly that dependency.
const baseline: SidebarSessionList = { current: 'p2', byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
const firstFrame: SidebarSessionList = {
current: 'p2',
byId: {
p2: { id: 'p2', displayTitle: 'P2' },
s2: { id: 's2', displayTitle: 'DSH-better-sidebar', origin: 'subagent', parentId: 'p2' },
},
}
expect(detectNewDirectSubagent(baseline, firstFrame, 'p2')).toBe(true) // the race
const settled: SidebarSessionList = {
current: 'p2',
byId: {
p2: { id: 'p2', displayTitle: 'P2' },
s2: { id: 's2', displayTitle: 'Side: New thread', origin: 'subagent', parentId: 'p2' },
},
}
expect(detectNewDirectSubagent(baseline, settled, 'p2')).toBe(false) // after the debounce
})
it('resolves the main-agent root of the current session tree', () => {
const byId: SidebarSessionList['byId'] = {
main: { id: 'main', displayTitle: 'Main' },
+7 -4
View File
@@ -96,21 +96,24 @@ beforeEach(() => {
killCalls.length = 0
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop()
const body = JSON.parse(String(init?.body)) as { sessionId: string; id: string }
const body = JSON.parse(String(init?.body)) as { sessionId?: string; id?: string; rootSessionId?: string }
if (method === 'subagents.live') {
return jsonResponse({ ok: true, value: { live: {} } })
}
if (method === 'jobs.output') {
outputCalls.push({ sessionId: body.sessionId, id: body.id })
outputCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
// bash-9 stands for a job the model never read (read:false).
return jsonResponse({
ok: true,
value: {
text: body.id === 'bash-9' ? '' : `output-of-${body.id}`,
text: body.id === 'bash-9' ? '' : `output-of-${body.id ?? ''}`,
truncated: false,
read: body.id !== 'bash-9',
},
})
}
if (method === 'jobs.kill') {
killCalls.push({ sessionId: body.sessionId, id: body.id })
killCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } })
}
throw new Error(`unexpected fetch ${String(url)}`)
+282
View File
@@ -0,0 +1,282 @@
/**
* Component-level regression for the Subagent page live preview polling:
* one shared `subagents.live` request per refresh, never a per-child
* `subagents.history` call, and never a second in-flight request while a
* slow host is still answering.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { SubagentView } from '../src/client/SubagentView.tsx'
import type { Context, SidebarSessionList } from '../src/context-types.ts'
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
function makeStore(initial: SidebarSessionList) {
let snapshot = initial
const listeners = new Set<() => void>()
return {
getSnapshot: () => snapshot,
subscribe: (fn: () => void) => {
listeners.add(fn)
return () => { listeners.delete(fn) }
},
set(next: SidebarSessionList) {
snapshot = next
for (const fn of [...listeners]) fn()
},
}
}
type Store = ReturnType<typeof makeStore>
/** The client context face SubagentView touches; history is spied for the
* regression assertion (the new page must never call it). */
function makeCtx(store: Store, historySpy: ReturnType<typeof vi.fn>): Context {
return {
sessions: {
list: store,
setSubagentCatalogOpen: () => {},
openSubagent: () => {},
open: () => {},
refreshSubagents: async () => {},
},
connection: {
api: {
sessions: { history: historySpy },
subagents: { history: historySpy },
},
},
} as unknown as Context
}
/** Render `node` into a detached body container under React's act(). */
function mount(node: ReactNode): {
container: HTMLDivElement
rerender: (next: ReactNode) => void
unmount: () => void
} {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const rerender = (next: ReactNode): void => { act(() => { root.render(next) }) }
act(() => { root.render(node) })
const unmount = (): void => {
act(() => { root.unmount() })
container.remove()
}
return { container, rerender, unmount }
}
function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
/** A topology snapshot with two running direct subagents and a ready catalog. */
function runningSnapshot(): SidebarSessionList {
return {
current: 'root',
byId: {
root: { id: 'root', displayTitle: '主会话' },
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'root', running: true },
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'root', running: true },
},
subagentsByParent: {
root: {
entries: [
{ kind: 'child', id: 'a', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'A' },
{ kind: 'child', id: 'b', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'B' },
],
parentAvailable: true,
state: 'ready',
error: null,
},
},
jobsBySession: {},
}
}
/** The same tree re-rooted under a new ancestor ('grand' becomes the root). */
function reRootedSnapshot(): SidebarSessionList {
return {
current: 'root',
byId: {
grand: { id: 'grand', displayTitle: '主会话' },
root: { id: 'root', displayTitle: 'R', origin: 'subagent', parentId: 'grand', running: true },
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'root', running: true },
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'root', running: true },
},
subagentsByParent: {
grand: {
entries: [
{ kind: 'child', id: 'root', activity: 'running', hasChildren: true, mode: 'continuable', label: 'R' },
],
parentAvailable: true,
state: 'ready',
error: null,
},
root: {
entries: [
{ kind: 'child', id: 'a', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'A' },
{ kind: 'child', id: 'b', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'B' },
],
parentAvailable: true,
state: 'ready',
error: null,
},
},
jobsBySession: {},
}
}
beforeEach(() => {
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
for (const el of document.querySelectorAll('body > div')) el.remove()
})
describe('SubagentView live polling', () => {
it('sends one subagents.live per poll and never calls per-child history', async () => {
vi.useFakeTimers()
const historySpy = vi.fn()
const liveCalls: string[] = []
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop()
if (method === 'subagents.live') {
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
liveCalls.push(body.rootSessionId ?? '')
return jsonResponse({ ok: true, value: { live: {} } })
}
throw new Error(`unexpected fetch ${String(url)}`)
})
const store = makeStore(runningSnapshot())
const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
)
// Initial load plus the first scheduled tick.
await act(async () => { await Promise.resolve() })
expect(liveCalls).toEqual(['root'])
expect(historySpy).not.toHaveBeenCalled()
expect(container.textContent).toContain('思考中…')
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
expect(liveCalls).toEqual(['root', 'root'])
expect(historySpy).not.toHaveBeenCalled()
unmount()
})
it('never starts a second request while one is in flight', async () => {
vi.useFakeTimers()
const historySpy = vi.fn()
const liveCalls: string[] = []
let resolveFirst: ((response: Response) => void) | undefined
vi.stubGlobal('fetch', (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop()
if (method === 'subagents.live') {
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
liveCalls.push(body.rootSessionId ?? '')
return new Promise<Response>((resolve) => { resolveFirst = resolve })
}
throw new Error(`unexpected fetch ${String(url)}`)
})
const store = makeStore(runningSnapshot())
const { unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
)
await act(async () => { await Promise.resolve() })
expect(liveCalls).toEqual(['root'])
expect(resolveFirst).toBeTypeOf('function')
// While the first request is pending, no timer can fire a second one.
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(liveCalls).toEqual(['root'])
// Settle the first request; only then does the next 3s tick fire.
await act(async () => {
resolveFirst?.(jsonResponse({ ok: true, value: { live: {} } }))
await Promise.resolve()
})
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
expect(liveCalls).toEqual(['root', 'root'])
expect(historySpy).not.toHaveBeenCalled()
unmount()
})
it('stops polling and drops the in-flight request when the page hides', async () => {
vi.useFakeTimers()
const historySpy = vi.fn()
const liveCalls: string[] = []
let resolveFirst: ((response: Response) => void) | undefined
vi.stubGlobal('fetch', (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop()
if (method === 'subagents.live') {
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
liveCalls.push(body.rootSessionId ?? '')
return new Promise<Response>((resolve) => { resolveFirst = resolve })
}
throw new Error(`unexpected fetch ${String(url)}`)
})
const store = makeStore(runningSnapshot())
const { rerender, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
)
await act(async () => { await Promise.resolve() })
expect(liveCalls).toEqual(['root'])
// Hide the page: the effect cleanup aborts the in-flight request and no
// timer may fire afterwards.
rerender(createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store, historySpy) }))
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(liveCalls).toEqual(['root'])
// Settling the stale response must neither render nor schedule a poll.
await act(async () => {
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { text: 'stale' } } } }))
await Promise.resolve()
})
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(liveCalls).toEqual(['root'])
expect(historySpy).not.toHaveBeenCalled()
unmount()
})
it('clears the live map and re-polls when the topology root changes', async () => {
vi.useFakeTimers()
const historySpy = vi.fn()
const liveCalls: string[] = []
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop()
if (method === 'subagents.live') {
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
liveCalls.push(body.rootSessionId ?? '')
const live = body.rootSessionId === 'root' ? { a: { text: 'hello' } } : {}
return jsonResponse({ ok: true, value: { live } })
}
throw new Error(`unexpected fetch ${String(url)}`)
})
const store = makeStore(runningSnapshot())
const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
)
await act(async () => { await Promise.resolve() })
expect(liveCalls).toEqual(['root'])
expect(container.textContent).toContain('hello')
// The tree is re-rooted under a new ancestor: old rows must not leak.
store.set(reRootedSnapshot())
await act(async () => { await Promise.resolve() })
expect(liveCalls).toEqual(['root', 'grand'])
expect(container.textContent).not.toContain('hello')
expect(historySpy).not.toHaveBeenCalled()
unmount()
})
})
+168
View File
@@ -0,0 +1,168 @@
/**
* Host route tests for the Subagent live-preview batch API ('subagents.live').
* The route must enumerate the tree ONCE, keep only catalog-running
* non-Side-Chat children, fold only non-empty activity from their session
* logs, and degrade to a 503 when the host subagent runtime is absent.
*/
import { describe, expect, it, vi } from 'vitest'
import { buildSubagentLiveApi } from '../src/subagent-live-route.ts'
import { SidebarError } from '../src/wire.ts'
import type {
Context,
SidebarSessionEvent,
SidebarSubagentDescendantEntry,
SidebarSubagentsService,
} from '../src/context-types.ts'
/** One child descendant row. */
function child(
id: string,
over: Partial<Extract<SidebarSubagentDescendantEntry, { kind: 'child' }>> = {},
): SidebarSubagentDescendantEntry {
return {
kind: 'child',
id,
activity: 'running',
hasChildren: false,
mode: 'one-shot',
parentId: 'root',
depth: 1,
...over,
}
}
/** One diagnostic descendant row. */
function diagnostic(id: string): SidebarSubagentDescendantEntry {
return { kind: 'diagnostic', id, reason: 'corrupt', parentId: 'root', depth: 1 }
}
/** A session with the given raw events. */
function session(events: SidebarSessionEvent[]): { header: { cwd: string }; events: SidebarSessionEvent[] } {
return { header: { cwd: '/p' }, events }
}
/** A context whose `get` serves only the subagents face, with a session store. */
function ctxWith(subagents: unknown, sessions: unknown): Context {
return {
sessions,
get: (key: string) => (key === 'subagents' ? subagents : undefined),
} as unknown as Context
}
describe('subagents.live route', () => {
it('returns non-empty activity for running children only', async () => {
const subagents: SidebarSubagentsService = {
listDescendants: vi.fn(async () => [
child('running-a', { label: 'A' }),
child('running-b', { label: 'B' }),
child('inactive', { activity: 'inactive', label: 'C' }),
child('side-chat', { label: 'Side: chat' }),
diagnostic('corrupt-row'),
]),
}
const sessions = {
get: (id: string) => {
if (id === 'running-a') {
return session([
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'hello' }] } } },
])
}
if (id === 'running-b') {
return session([
{ type: 'tool/call', seq: 0, time: 0, data: { name: 'bash', arguments: '{"command":"ls"}' } },
])
}
return session([])
},
}
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
live: {
'running-a': { text: 'hello' },
'running-b': { tool: { name: 'bash', args: '{"command":"ls"}' } },
},
})
expect(subagents.listDescendants).toHaveBeenCalledWith('root')
})
it('omits children with no text/tool yet', async () => {
const subagents: SidebarSubagentsService = {
listDescendants: vi.fn(async () => [child('empty', { label: 'Empty' })]),
}
const sessions = { get: () => session([]) }
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({ live: {} })
})
it('folds only activity inside the recent 12-message window', async () => {
// One stale tool call sits before 13 user messages; the recent window
// (the tail's last 12 messages) must not surface it.
const staleTool: SidebarSessionEvent = {
type: 'tool/call', seq: 0, time: 0,
data: { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' },
}
const oldMessages: SidebarSessionEvent[] = Array.from({ length: 13 }, (_, i) => ({
type: 'user/message', seq: i + 1, time: 0,
data: { content: [{ type: 'text', text: `m${i}` }] },
}))
const recentText: SidebarSessionEvent = {
type: 'assistant/message', seq: 99, time: 0,
data: { message: { content: [{ type: 'text', text: 'recent' }] } },
}
const subagents: SidebarSubagentsService = {
listDescendants: vi.fn(async () => [child('windowed', { label: 'W' })]),
}
const sessions = { get: () => session([staleTool, ...oldMessages, recentText]) }
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
live: { windowed: { text: 'recent' } },
})
})
it('skips a child whose session log is unavailable without failing the batch', async () => {
const subagents: SidebarSubagentsService = {
listDescendants: vi.fn(async () => [
child('good', { label: 'Good' }),
child('bad', { label: 'Bad' }),
]),
}
const sessions = {
get: (id: string) => {
if (id === 'good') {
return session([
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'ok' }] } } },
])
}
throw new Error('missing')
},
}
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
live: { good: { text: 'ok' } },
})
})
it('degrades to a 503 when the subagent runtime is absent', async () => {
const api = buildSubagentLiveApi(ctxWith(undefined, { get: () => undefined }))
await expect(api.live({ rootSessionId: 'root' })).rejects.toThrowError(
expect.objectContaining<Partial<SidebarError>>({ code: 'subagents-unavailable', status: 503 }),
)
})
it('degrades to a 503 when listDescendants fails', async () => {
const subagents: SidebarSubagentsService = {
listDescendants: vi.fn(async () => { throw new Error('projection unavailable') }),
}
const api = buildSubagentLiveApi(ctxWith(subagents, { get: () => undefined }))
await expect(api.live({ rootSessionId: 'root' })).rejects.toThrowError(
expect.objectContaining<Partial<SidebarError>>({ code: 'subagents-unavailable', status: 503 }),
)
})
it('rejects a missing rootSessionId as bad-request', async () => {
const api = buildSubagentLiveApi(ctxWith(undefined, { get: () => undefined }))
await expect(api.live({})).rejects.toThrowError(
expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }),
)
})
})