Compare commits

...
11 Commits
Author SHA1 Message Date
Menghuan1918 983d2b2d71 chore: v0.8.1 — 版本升级(任务输出回放活事件镜像) 2026-08-12 23:04:30 +08:00
Menghuan1918 6e4474cd2e fix: 任务输出回放合并活事件镜像,修复重启后 store 会话滞后导致新读取不可见
- host 半挂 ctx.on('session/event')(api-proxy 同款 API),镜像 task_output
  的 tool/call + tool/result 活事件(每会话 200 条有界环,无关结果不缓存)
- tasks.output 回放合并「store 事件日志 + 活事件镜像」,按 seq 去重排序配对
- 零 DSH 写入:会话 store 在宿主重启后可能冻结在再水合边界(持久化文件
  停在重启时刻),活事件只走 append 推送链;镜像绕开该滞后
- 测试:新增镜像用例(live 事件、跨会话隔离、种子+镜像合并不双计),
  全量 373 通过
2026-08-12 23:04:30 +08:00
Menghuan1918 88f116338a feat: 任务管理页二级开关「有新后台任务时自动展开」(autoOpenTasks)
- 新 pref autoOpenTasks(默认开):当前会话出现新后台任务(任意新任务 id,
  非仅首个)时自动展开侧边栏并打开任务管理页,与 autoOpenSubagent 并列
- 注册为 subagent tab 的声明式二级开关(设置页齿轮弹窗,key 在 PrefsSchema)
- Sidebar 新增独立基线 effect(镜像子代理自动展开;刷新不触发、开关关闭/类型
  禁用不触发);detectNewTask 纯函数(settling/同 id/他会话不触发)
- 测试:detectNewTask 用例、prefs 解析断言、builtins 开关清单、schema 默认值
  断言;全量 371 通过
2026-08-12 23:04:30 +08:00
Menghuan1918 b90251cce5 fix: 遵守仓库硬约束(禁止修改 DSH 源码)——tasks.output 改为会话事件回放
- 回退对 harness peek 缝隙的依赖:输出查看不再调用注册表,改为从属主
  会话事件日志回放 task_output 的 tool/call+tool/result 对(模型已读内容)
- read:false 时面板提示「模型尚未读取」;'(no new output)' 增量与错误
  结果过滤;截断上限不变;kill 走注册表原生 API(缺席时 503)
- AGENTS.md 新增 §0 硬约束:禁止修改 DSH 源码、挂载只走 cordis.patch.yml
  + profile 机制;设计文档同步实施偏差
- 测试:tasks-routes 重写为回放语义(拼接/过滤/read 标志/截断/503),
  视图新增 read:false 提示用例;全量 369 通过
2026-08-12 23:04:30 +08:00
Menghuan1918 8c521be26e chore: v0.8.0 — 任务管理页(子代理拓扑 + 后台命令底部输出 Dock、两击确认终止)
- tab 更名为任务管理:同页显示当前树全部后台命令(bash/pwsh 徽标、状态、时长、属主、退出码)
- 点击任务行在侧边栏底部共享 Dock 显示输出(sticky 终端式、自动跟随、单一轮询实例)
- 运行中任务两击确认强制终止;大量任务时列表保持紧凑、排序变动不跳动
- 新增 /sidebar/api tasks.output(非消费 peek,不偷模型 task_output 字节)与 tasks.kill 路由
- 修复任务镜像清空时的 React #300 hook 顺序崩溃(useCallback 移到提前 return 之前)+ 回归测试
2026-08-12 23:04:30 +08:00
Atlas Zeng 27bc49830e Update README.md 2026-08-12 19:06:18 +08:00
Menghuan1918 96b83ae3b8 chore: v0.7.0 — 版本升级(客户端懒加载、移动端体验、内容打开自动展开) 2026-08-12 18:39:19 +08:00
Menghuan1918 cb9a7cf751 feat: 内容型打开自动展开承载面板(宽视口)
宽视口下 openTab 带 path/url seed 时,承载落点 pane 的面板折叠则自动展开:
落点在底部树展开底部面板,否则展开右侧面板;去重聚焦既有 tab 同样展开
(打开必须落在视野内)。窄视口抽屉逻辑不变;类型型打开(+ 菜单、agent
终端自动补 tab)不展开。同步 AGENTS.md / README 文档与测试。
2026-08-12 18:39:17 +08:00
Menghuan1918 ffc2270d77 fix: 移动端合并改为底部标签直接并入右侧栏(迁移式,删除双工作台堆叠方案)+ 窄屏断点收紧至 768px(不对齐宿主 1024) 2026-08-12 18:27:28 +08:00
Menghuan1918 b391ebffed feat: 移动端侧边栏体验 — 窄视口(<1024px)双工作台合并为单面板抽屉(可拖分隔条、高度与底部面板互通)、不提供底部面板按钮、文件/外链打开自动展开、新会话默认收起 2026-08-12 18:20:34 +08:00
Menghuan1918 bab659f8bd feat: 客户端懒加载分块 — 重依赖(Univer/xterm/CodeMirror/Office)拆独立 bundle 按需下发(/sidebar/bundle 路由 + __dshChunks__ 注册表),核心启动仅 ~325KB 2026-08-12 18:19:44 +08:00
57 changed files with 4307 additions and 494 deletions
+12
View File
@@ -6,6 +6,14 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
---
## 0. 仓库硬约束(所有本仓库插件必须遵守)
- **禁止修改 DeepSeek Harness (DSH) 源码**:对官方源码 checkout(`~/.dsh/source/current`)零写入——不得改 harness 包、不得把 harness 改动提交到它的分支。
- **挂载只走 `cordis.patch.yml` + profile 机制**(`~/.dsh/profiles/<profile>/`),插件永远作为独立包被 profile 引用,不反向侵入 DSH。
- 需要 harness 没有的能力时,用 DSH **现成的只读/公开 API** 或插件自有路由实现(参考 §7 的 `tasks.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
---
## 1. 服务定位
- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`)
@@ -364,6 +372,9 @@ interface BetterSidebarService {
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子,
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。
* 被设置禁用的类型是 no-op(console.warn 提示)。
* 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板
* 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口
* 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。
*/
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
/** 关闭一个 tab */
@@ -396,6 +407,7 @@ interface BetterSidebarService {
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
| **懒加载 chunk** | 内置重依赖(Univer/xterm/CodeMirror/pptx/docx)在独立 bundle(`lib/client-<name>.js`)中,经 `/sidebar/bundle` 路由按需下发;每个脚本把 factory 赋到插件自有全局注册表 `globalThis.__dshChunks__[<name>]`,由 `src/client/chunk-loader.ts` 用自定义 require(externals 经 `__DSH_MODULES__` seed 分支解析)物化——**不经过** `__ModuleLoader__` 注册;**核心 bundle 禁止静态 import `src/client/chunks/*`**(会把库拖回启动路径);对消费插件透明——懒加载只作用于内置 descriptor,`component` 契约(`(props) => ReactNode` 纯渲染函数)不变 |
---
+14 -5
View File
@@ -1,20 +1,28 @@
# dsh-better-sidebar
> DeepSeek Harness Web GUI 的 **VSCode 风格侧边栏**插件:文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、Subagent 页面——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。
> 一个插件,一套完整工作台:
>
> 文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、任务管理页——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。
>
> 支持Tab窗口随意拖拽,支持三方拓展注册新Tab页面和文件预览
<img width="4632" height="2720" alt="image" src="https://github.com/user-attachments/assets/39d86636-7654-412f-86ea-c60a2d5f20f0" />
https://github.com/user-attachments/assets/23187822-047e-45cc-b480-fe997bd55b86
<img width="2630" height="1794" alt="6c4293e1bec2e935031bf0e986d6ec65" src="https://github.com/user-attachments/assets/dfdb875e-a1a8-4d4b-8340-353736b1708f" />
## ✨ 功能一览
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ HTML(沙箱 iframe 预览,相对资源可加载)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开
- **⚡ 客户端懒加载**:Office / 终端 / 代码编辑器等重依赖按需分块加载——启动只拉 ~325KB 核心,打开 .xlsx 才拉 Univer(~20MB)、打开 .docx 只拉 docx 预览器、打开终端才拉 xterm;首次打开短暂 loading 后即用(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板)
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
- **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开
- **🧩 任务管理页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台命令**(当前树全部任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `task_output`;两击确认可强制终止)
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开)
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md))
@@ -117,6 +125,7 @@ pnpm watch # tsdown --watch
- `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制);Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
- HTML 预览渲染的是已保存文件(不反映未保存草稿)
- 移动端(<768px)无底部面板:进入窄屏时其标签页一次性并入右侧栏(迁移后回桌面仍保留在右侧栏),桌面端的底部面板只在宽视口下可用;移动端底部首展自动开终端不触发
## 🖥️ 平台支持
@@ -0,0 +1,88 @@
# 客户端懒加载分块(lazy chunks)设计
> 日期:2026-08-12
> 状态:已实现
> 范围:`dsh-better-sidebar` 插件,客户端 bundle 拆分
## 1. 背景与问题
改造前客户端是单文件 bundle(`lib/client.js`,约 24.6MB),页面启动时全部**下载 + 解析 + 执行**。实测构成(按 sourcemap 源字节):
| 依赖 | 体积 | 用途 |
|---|---|---|
| `@univerjs/*` 全家桶 + echarts/zrender 等 | ~18MB | .xlsx 预览 |
| `@codemirror/*` + `@lezer/*` | ~1.9MB | 文本编辑器高亮 |
| `xlsx`(SheetJS) | ~0.6MB | .xlsx 解析 |
| `@aiden0z/pptx-renderer` | ~0.5MB | .pptx 预览 |
| `xterm` | ~0.3MB | 终端 |
| `docx-preview` + `jszip` | ~0.2MB | .docx 预览 |
| 插件自身代码 | ~0.35MB | — |
虽然 `office-view.tsx` 用了动态 `import()`,但 `codeSplitting: false` 只保证**延迟执行**(`Promise.resolve().then(() => (init_docx_preview(), ...))`),下载与解析成本仍在启动时;且 `xlsx-to-univer.ts` 对 `@univerjs/presets` 的静态 value-import 让 Univer 核心实际在启动时执行。
**目标**:重依赖拆成独立脚本,首次打开对应功能才下载/解析/执行;启动只拉核心(~325KB)。
## 2. 机制
chunk 脚本**不经过** `window.__ModuleLoader__` / `ClientModuleSystem.import()` 的物化路径——模块系统的 import() 只解析 seed word / shell-own 模块 / 已注册 factory / boot graph row,chunk id 四者皆非,解析行为依赖 DSH 版本(实测某些版本在 factory 已注册时仍抛 "cannot resolve")。因此改为插件自有的全局 factory 注册表:
1. **构建期**:每个 chunk 是独立 tsdown 浏览器 bundle(`lib/client-<name>.js`),脚本首行为:
```js
globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
globalThis.__dshChunks__["terminal"] = (require) => { /* CJS 闭包体 */ };
```
与主 bundle 相同的 externals(react 等走 module table)、纯度门、CSS 内联管线;`codeSplitting: false` 保持单文件。
2. **运行期** `loadChunk(name)`:
- 注入 `<script src="/sidebar/bundle/<name>.js">`(经典同源脚本;官方 `/plugins/<id>/client.js` 路由的路径白名单无法服务任意文件名,故走插件自有路由);
- 从 `globalThis.__dshChunks__[name]` 读取 factory(脚本执行即赋值,幂等——重复执行覆盖槽位,无 "duplicate factory registration" 错误类);
- 用**自定义 require** 物化:externals(CLIENT_EXTERNALS 清单)经 `__DSH_MODULES__.import(spec)` 的 **seed 分支**逐一解析(页面级记忆化;解析失败的 spec 标记未解析,仅当 chunk 实际 require 时才报错)——seed 分支是模块系统最稳定、跨版本一致的部分。
## 3. 分块清单
| chunk 名 | 入口 | 内容 | 构建产物 | 触发 |
|---|---|---|---|---|
| `xlsx` | `src/client/chunks/xlsx.tsx` | `XlsxView` + Univer 全家桶 + xlsx + Univer CSS | lib/client-xlsx.js(~20MB) | 打开 .xlsx |
| `docx` | `src/client/chunks/docx.tsx` | `DocxView` + docx-preview + jszip | lib/client-docx.js(~330KB) | 打开 .docx |
| `pptx` | `src/client/chunks/pptx.tsx` | `PptxView` + pptx-renderer | lib/client-pptx.js(~2.4MB) | 打开 .pptx |
| `terminal` | `src/client/chunks/terminal.tsx` | `TerminalView` + xterm + addon-fit + xterm.css | lib/client-terminal.js(~450KB) | 打开终端 tab |
| `editor` | `src/client/chunks/editor.tsx` | `TextEditor` + @codemirror/* + lang.ts + cm-themes | lib/client-editor.js(~1.6MB) | 打开 code/md/html 文件 |
| 核心 | `src/client/index.tsx` | 其余全部 | lib/client.js(~325KB) | 启动 |
关键拆分:**`office-view.tsx` 拆为 `docx-view.tsx` + `xlsx-view.tsx` + `office-shared.tsx`**(共享 `LoadState`/`BinaryFallback`)。若不拆,docx chunk 会经共享文件静态拖入 `xlsx-to-univer` → Univer 14.5MB。原路径 `office-view.tsx` 保留为 re-export 垫片,兼容 `./src/*` 深导入。
## 4. 缓存契约(三层,各带失败路径)
| 层 | 机制 | 失败路径 |
|---|---|---|
| 内存 | `loadChunk` 每 chunk 一个 in-flight promise,并发去重,常驻到 `resetChunks` | 失败删除缓存项 → 下次调用重试 |
| 执行 | 脚本执行即覆盖全局注册表槽位(赋值幂等) | 物化失败清缓存 → 重试重新注入 + 重新执行脚本 |
| HTTP | `/sidebar/bundle` 路由 `cache-control: no-cache` + ETag(内容哈希,按 mtime/size 记忆化),`If-None-Match` 命中返回 304 | 页面刷新/HMR 重激活时 304 免重下载 17MB;文件变化 ETag 轮转返回 200 |
**HMR**:`apply()` 每次激活调用 `resetChunks()`(清内存缓存 + externals 记忆),热更新后重新拉取新代码。chunk 源码单独改动需手动刷新页面(HMR 轮询只盯 client.js)。
## 5. 对外 API 兼容性(硬约束)
- `ctx.betterSidebar` 服务方法签名与行为零变化;懒加载只作用于**内置** descriptor,外部插件注册的原样存储/渲染。
- `component` 契约 `(props) => ReactNode` 保持:懒包装是**纯渲染函数**(函数体无 hooks,hooks 在内部 `LazyChunkView`),兼容 Sidebar 的函数式调用与 EditorHost 的 `createElement` 渲染两种风格。
- 内置 viewer/tab 的 id、exts、priority、detect、icon、title、settings 全部不变 → `matchFileViewer` 语义、外部插件同扩展名高优先级覆盖、设置页清单不变。
- 公开类型面(`./client/service`、`./client/api`)不变;无新增 peerDeps。
- 契约测试:`tests/lazy-chunk.spec.tsx` 钉住"component 可直接函数调用不抛错"。
## 6. 边界与失败模式
- chunk 加载/执行/物化失败 → 视图级错误 + 重试 UI(`css.editorError` + `css.terminalRetry`),面板其余部分不受影响;
- host 旧版本无 `/sidebar/bundle` 路由(404) → 同样的错误 + 重试,升级 host 后重试即可;
- `window.__DSH_MODULES__` 缺失(异常环境) → 明确报错,不触网;
- chunk 注册 id 恒定 `dsh-better-sidebar/<name>`,两渠道共用(每页只活一个渠道——双渠道并存本就双挂载,属既有破损组合);
- 跨 chunk 复用:`t()`/`api.ts`/`SandboxStatusBar`/`clsx`/`sidebar.module.css` 等无状态小模块允许在各 chunk 内联(几十 KB 级重复),避免 chunk→主 bundle require 的脆弱耦合;
- CSS:`sidebar.module.css` 的 tagId 与官方渠道核心一致 → 幂等跳过注入;registry 渠道会多注入一份同内容样式(数据标签差异,无功能影响);xterm.css/Univer.css 随各自 chunk 注入。
## 7. 实施偏差记录
- 原设想 React.lazy + Suspense 被否决:错误处理需 error boundary,重试语义差;改用有状态包装组件(loading/error/retry 三态),与本库"每个视图自带 loading/error/retry"风格一致。
- 原设想 chunk 注册 id 按渠道区分(`__DSH_PLUGIN_ID__` define)被否决:chunk 文件两渠道共用,恒定槽位即可;避免重复构建 6 份产物。
- `resetChunks` 同时清空测试注册表(测试隔离需要)。
- `lazy-chunk.tsx` 内部渲染用 `ComponentType<any>` 状态成员规避 React 18 createElement 泛型重载限制(仅内部一处显式 any)。
- **v2(运行时修复)**:最初实现经 `window.__ModuleLoader__.load({id})` 注册 chunk factory、再 `__DSH_MODULES__.import(id)` 物化——真实环境报 `client-modules: cannot resolve "dsh-better-sidebar/terminal"`(某 DSH 版本的 import() 不解析已注册的非 boot 图 id)。改为全局 factory 注册表(`globalThis.__dshChunks__`) + 自定义 externals require(seed 分支),彻底不依赖模块系统的 factory 解析行为;`resetChunks` 不再需要 invalidate。
- **v3(运行时修复)**:terminal 懒包装把整个 `TabComponentProps` 原样透传,但 `TerminalView` 的 props 是 `{ scope, tabId, store }` 而 `TabComponentProps` 只有 `tab` 对象、没有 `tabId` → `tabId` 为 undefined → `isAgentTabId` 里 `undefined.startsWith` 崩溃(`dsh-better-sidebar: Cannot read properties of undefined (reading 'startsWith')`)。修复:descriptor 显式映射 `tabId={tab.id}`,懒包装的 props 泛型 = TerminalView 真实签名(`{ scope, tabId, store }`);回归测试钉住映射(lazy-chunk.spec.tsx)。**教训:懒包装的 props 泛型必须等于目标组件真实 props,而非描述符 props 全集**。
- **v2 附加**:bundled 产物经真实 `ClientModuleSystem`(带真实 react seed)端到端验证——5 个 chunk 均能物化出真实 React 组件;产物契约由 `tests/chunk-artifact.spec.ts` 长期钉住(脚本执行赋值槽位 + factory 可被 externals require 物化)。
@@ -0,0 +1,77 @@
# 移动端(窄视口 <768px)侧边栏布局设计
**日期**:2026-08-12
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.7.0
## 1. 目标
按**视口宽度**(<768px——"真正的移动端宽度",刻意不对齐宿主 1024 断点)切换移动端侧边栏体验:
1. 移动端**不提供底部面板按钮**(右上角按钮簇只剩一枚右侧面板开关)。
2. **合并显示 = 只显示右侧栏**:进入窄屏时底部面板的标签页**直接并入右侧栏**(一次性状态迁移),右侧栏成为全宽抽屉,底部面板浮层整体不存在。
3. 附带移动端优化:新会话默认收起抽屉、文件/外链打开自动展开、宽度拖动条禁用、布局不挤压(抽屉悬浮)、窄屏标签收窄。
## 2. 非目标
- 不改持久化 schema:`bottomOpen` / `bottomHeight` / `bottomSplits` 字段原样保留。
- 不改桌面行为:≥768px 时双面板布局与之前完全一致(含布局挤压、拐角、底部首展自动终端)。
- 不改公开服务 API、PrefsSchema、host 半。
- 不处理 DSH 详情栏(宿主让步链在 <~996px 自动关闭详情栏,与本改动无交互)。
## 3. 现状回顾
- `Sidebar.tsx` 渲染两个独立浮层:右侧面板(全高、宽度可拖、布局挤压 `--dsh-sidebar-width`)与底部面板(只挤中间列、高度可拖、右上角按钮簇双按钮、共享拐角)。
- 底部面板状态字段:`bottomOpen` / `bottomHeight`(钳制 `[BOTTOM_MIN, innerHeight-PANEL_MIN]`)/ `bottomOpenedOnce` / `bottomSplits`(独立 split 树,pane/tab id 全局唯一)。
- 首次展开底部面板自动开终端(`bottomPanelAutoTerminal` pref)。
## 4. 设计
### 4.1 断点模块(新 `src/client/breakpoints.ts`)
- `NARROW_MAX_WIDTH = 768`;`isNarrowWidth(width)` 纯函数;`useNarrowViewport()` hook(初始读 `window.innerWidth`,resize + rAF 节流,`typeof window` 守卫;不用 matchMedia——jsdom 未实现)。
- CSS 侧配对 `@media (max-width: 767px)`(767 ≡ <768),两端注释互指。
- 刻意**不对齐**宿主 `SIDEBAR_AUTO_COLLAPSE = 1024`:1024px 窗口(小笔记本、分屏)保留桌面双面板;只有手机/竖屏平板才进移动布局。
### 4.2 合并 = 底部标签并入右侧栏(状态迁移)
- **新 reducer `migrateBottomTabs(state)`**(`state.ts`):
- 底部树所有标签按深度优先序**追加到右侧树第一个 leaf**(`firstLeaf(splits)`)的标签条;
- 底部树清空(结构保留——桌面端回显欢迎卡片)、`bottomOpen=false`、`activePane` 指向右侧树第一个 leaf(保证迁移后新开的标签落在可见面板);
- 幂等:底部无标签且面板已关且 activePane 不在底部树时返回同一引用。
- **Sidebar 触发 effect**:`narrow && sessionId 已定义` 时执行(覆盖:窄屏挂载、会话切换、桌面→窄屏过渡);幂等性保证其自然收敛,不循环。
- 迁移是**永久**的(符合"直接丢到右侧栏中"):回桌面后标签仍留在右侧树,底部面板为空(欢迎卡片);用户可在桌面重新往底部面板开标签,再进窄屏会再次迁移。
- 迁移后右侧栏用**现有 `Workbench` 原样渲染**——零视图层特判、零 action 路由修补(标签真实住在右侧树,close/activate/drag 全部走原路径)。
### 4.3 窄屏渲染(`Sidebar.tsx`)
- 面板宽度 `100vw`(全宽抽屉);`--dsh-sidebar-*` 布局变量恒 0(抽屉悬浮,不挤压 AppFrame);宽度拖动条、底部浮层、`bottomClose`、拐角、底部面板按钮全部不渲染。
- `visible` 语义回到原始:底部树只在桌面底部面板场景(`bottomOpen && active`)——窄屏下底部树为空且不渲染。
- 底部首展自动终端 effect 加 `narrow` 守卫。
### 4.4 行为修正
- `state.ts` `loadState`:窄屏新会话默认 `panelOpen=false`(首开才生效;用户手动展开后照常持久化)。
- `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。
### 4.5 样式(`sidebar.module.css`)
- `@media (max-width: 767px)`:开放面板标签条预留宽度 72px → 40px(按钮簇只剩一枚);`.tab` min/max 宽收窄(48/128px)。
### 4.6 测试
- `tests/breakpoints.spec.ts`:断点边界(767 窄 / 768 宽;1024 明确为宽)。
- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起(stub `window.innerWidth=390`)。
- `tests/service.spec.ts`:窄屏 auto-expand 5 例(390 < 768 仍窄)。
## 5. 边界情况
- 迁移触发:窄屏挂载 / 会话切换 / 桌面→窄屏过渡,均由同一 effect 覆盖;幂等保证收敛。
- 迁移后新开标签:`activePane` 已重指右侧树第一 leaf,`openTabInActivePane` 必落在可见面板(含"空底部树但 activePane 指向它"的陈旧指针场景)。
- 跨断点往返:迁移永久;桌面底部面板空显欢迎卡片;`bottomHeight` 不受影响。
- jsdom 兼容:hook 不用 matchMedia;既有测试 stub `innerWidth=1024` 自动走桌面路径,零行为变化。
## 6. 实施偏差记录
- **v1(已提交 b391ebf,用户反馈推翻)**:合并 = 单面板内上下堆叠两个工作台(`MobileWorkbench` + 可拖分隔条,高度复用 `bottomHeight`),断点 1024(对齐宿主)。用户明确:合并 = 只显示右侧栏、底部标签直接丢进右侧栏;窄屏 = 真窄屏、不对齐 1024。
- **v2(本文档,最终形态)**:删除 `MobileWorkbench`(组件 + CSS + spec),改为 `migrateBottomTabs` 状态迁移 + 现有 `Workbench` 渲染;断点 768。`bottomHeight` 不再参与移动端布局(仅桌面底部面板使用)。
@@ -0,0 +1,43 @@
# 任务管理页后台命令区块(查看输出 + 强制终止)设计
> 2026-08-12 · v0.8.0
## 目标
任务管理页(当前树的主代理拓扑,原「子代理」页)在拓扑树下方同页显示**当前树全部会话**(主代理 + 子代理)的后台任务:
- 点击任务行,输出显示在**侧边栏底部共享 Dock**(sticky 终端式,滚动时保持可见;运行中每 2s 轮询,页面不可见时暂停;自动跟随底部);
- 运行中的任务行带两击确认的终止按钮;
- **不破坏模型契约、不修改 DSH 源码**(本仓库规则:对官方源码 checkout 零写入,挂载只走 cordis.patch.yml + profile 机制):输出查看是**模型已读输出的回放**,绝不推进 `task_output` 的读取游标。
## 数据流
```
bash/tool run_in_background → ctx.tasks.start(owner = 执行 agent)
api-proxy session/tasks 帧(TaskView[],无输出)→ 客户端 tasksBySession 镜像
SubagentView 读镜像 → collectTreeTasks(树内会话) → 行渲染(单 Dock,选中行)
点行 → POST /sidebar/api/tasks.output(事件回放)→ 扫描属主会话 events 里
task_output 的 tool/call + tool/result 对 → 模型已读文本
终止 → POST /sidebar/api/tasks.kill → ctx.tasks.kill(DSH 现成 API)
```
## 关键决策
1. **输出 = 会话事件回放,零 DSH 写入**:`TaskService.read()` 是单一消费游标——UI 直接读会偷走模型 `task_output` 的字节(或读到空)。而 DSH 源码禁止修改,所以 `tasks.output` **不碰注册表**:扫描属主会话的 append-only 事件日志,取 `task_output` 调用的 `tool/call`(`arguments.task_id` 配对)与其 `tool/result`(finalize 后的文本,即模型实际收到的内容)按序拼接;`"(no new output)"` 增量与错误结果跳过;`read: false` 表示模型尚未读过。模型游标天然不被触碰。
- 语义代价(用户已确认接受):面板只在模型读过之后才有内容——没有实时流。
2. **列表不新增路由**:任务清单由 harness 的 `session/tasks` 推送镜像提供;只加 `tasks.output`(回放)与 `tasks.kill`(注册表原生 `kill`)两个路由(信任围栏与其余路由一致)。注册表缺席时 kill 降级 503(output 不需要注册表)。
3. **终止的访问围栏**:caller 由请求的 `sessionId` 现场解析 `ctx.agents.get(sessionId)`,注册表按 owner session id 拒绝 foreign 任务;未知/foreign 统一映射 404 `task-error`。
4. **展示范围 = 整棵树**:与页面作用域一致(拓扑即当前树);树跨多会话时行尾显示属主标题。
5. **单共享 Dock(大量任务的形态)**:行内展开会让列表随任务数膨胀、排序变化时面板跳动;改为一个底部 sticky Dock + 单一轮询实例,行保持紧凑、排序变动不惊吓,选中行高亮。
6. **两击确认终止**:首次点击进入「再次点击确认」态(3s 自动解除),防止误杀长任务。
7. **输出上限**:路由按 `readLimit`(默认 512KB)截断并置 `truncated` 标记。
## 实施偏差
- 曾实现 harness 侧非消费 peek 缝隙(`BashProcess.peekOutput` / `TaskService.peek`),因「禁止修改 DSH 源码」规则**整体回退**(harness 提交已 reset,checkout 恢复纯净快照),改用事件回放。
- 宿主不新增 `@deepseek-ai/*` 依赖(`ctx.tasks`/`ctx.agents` 走结构性镜像 + 运行时 `ctx.get`,可选服务降级 503);`ctx.sessions.get(id).events` 为只读访问。
## 验证
- 插件单测:tasks-routes(回放拼接/过滤/read 标志/截断/404/503 降级);subagent-tasks 纯函数;jsdom 视图(行渲染、单 Dock 切换、关闭、两击终止、read:false 提示、大量任务、隐藏不轮询、镜像清空 hook 顺序回归 #300)。
- 集成(真实 dsh web):启动 `run_in_background` bash → 模型 `task_output` 读取 → `/sidebar/api/tasks.output` 返回模型已读内容 → `/sidebar/api/tasks.kill` 后进程退出、任务结算 `killed (signal: SIGTERM)`。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.6.0",
"version": "0.8.1",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
+7 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dsh-better-sidebar",
"version": "0.6.0",
"version": "0.8.1",
"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.",
"private": true,
"type": "module",
@@ -47,6 +47,12 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/client-registry.js",
"lib/client-docx.js",
"lib/client-xlsx.js",
"lib/client-pptx.js",
"lib/client-terminal.js",
"lib/client-editor.js",
"lib/types/**/*.d.ts",
"src"
],
+12
View File
@@ -29,6 +29,18 @@ const files = [
'lib/invariant.js',
'lib/client-registry.js',
'lib/client-registry.js.map',
// Lazy chunks: the client fetches these from the host's /sidebar/bundle
// route on first use (see src/client/chunk-loader.ts / src/bundle-route.ts).
'lib/client-docx.js',
'lib/client-docx.js.map',
'lib/client-xlsx.js',
'lib/client-xlsx.js.map',
'lib/client-pptx.js',
'lib/client-pptx.js.map',
'lib/client-terminal.js',
'lib/client-terminal.js.map',
'lib/client-editor.js',
'lib/client-editor.js.map',
'README.md',
'LICENSE',
]
+130
View File
@@ -0,0 +1,130 @@
/**
* Lazy chunk route: serves the client bundle's chunk scripts
* (/sidebar/bundle/<name>.js). The official /plugins/<id>/client.js route
* cannot serve arbitrary file names, so the plugin serves its own split
* bundles (lib/client-<name>.js) here; the client injects the script on
* first use of the feature that needs it (see src/client/chunk-loader.ts).
*
* Caching contract: every response carries `cache-control: no-cache` plus an
* ETag (content hash, memoized per file by mtime/size) and honors
* If-None-Match — the browser revalidates each fetch, but a 304 avoids
* re-downloading multi-MB chunks that did not change (page refresh, HMR
* re-activation). Same browser-trust fence as every other /sidebar route;
* only allowlisted chunk names are servable (no path traversal).
*/
import { createHash } from 'node:crypto'
import { stat, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Context } from './context-types.ts'
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
export const CHUNK_NAMES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'] as const
export type ChunkName = (typeof CHUNK_NAMES)[number]
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
const LIB_DIR = dirname(fileURLToPath(import.meta.url))
/** sha1 content hash shortened to 12 hex chars (same shape as the client-modules rev). */
function shortHash(input: string | Buffer): string {
return createHash('sha1').update(input).digest('hex').slice(0, 12)
}
interface ChunkEtag {
mtimeMs: number
size: number
etag: string
}
/** ETag memo: recompute the content hash only when the file's stat changed. */
const etags = new Map<string, ChunkEtag>()
/**
* The chunk file's ETag (quoted hash), or undefined when the file is
* missing. Hash is recomputed only when mtime/size changed (hashing a
* multi-MB chunk per request is wasteful).
*/
async function etagOf(name: ChunkName, chunkDir: string): Promise<string | undefined> {
const path = join(chunkDir, `client-${name}.js`)
const key = `${chunkDir}:${name}`
try {
const info = await stat(path)
const memo = etags.get(key)
if (memo !== undefined && memo.mtimeMs === info.mtimeMs && memo.size === info.size) {
return memo.etag
}
const etag = `"${shortHash(await readFile(path))}"`
etags.set(key, { mtimeMs: info.mtimeMs, size: info.size, etag })
return etag
} catch {
return undefined
}
}
/**
* Build the /sidebar/bundle route handler. `fence` is the shared browser-
* trust check every /sidebar route applies; `chunkDir` is the directory the
* chunk scripts live in (overridable for tests).
*/
export function createBundleRouteHandler(
fence: (req: IncomingMessage) => boolean,
chunkDir: string = LIB_DIR,
): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
return async (req, res): Promise<void> => {
if (!fence(req)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
res.end()
return
}
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
const match = /^\/sidebar\/bundle\/([a-z0-9-]+)\.js$/.exec(pathname)
const name = match?.[1] as ChunkName | undefined
if (name === undefined || !(CHUNK_NAMES as readonly string[]).includes(name)) {
res.writeHead(404)
res.end('not found')
return
}
const etag = await etagOf(name, chunkDir)
if (etag === undefined) {
// Registered name but unreadable (bundle not built yet): loud 404.
res.writeHead(404)
res.end('not found')
return
}
if (req.headers['if-none-match'] === etag) {
// Revalidation hit: unchanged chunk, no body — avoids re-downloading
// multi-MB scripts on page refresh / HMR re-activation.
res.writeHead(304, { 'cache-control': 'no-cache', etag })
res.end()
return
}
try {
const body = await readFile(join(chunkDir, `client-${name}.js`))
res.writeHead(200, {
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
etag,
})
res.end(body)
} catch {
// Read raced a delete/rebuild between the stat and the read.
res.writeHead(404)
res.end('not found')
}
}
}
/** Register the /sidebar/bundle route (disposed with the fiber). */
export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void {
return ctx.httpServer.register({
kind: 'prefix',
path: '/sidebar/bundle',
handler: createBundleRouteHandler(fence),
})
}
+134 -58
View File
@@ -16,25 +16,35 @@
* content to the views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer). Tabs live in one tree only —
* they never cross panels; only the panel sizes drag against each other.
*
* Narrow (mobile, <768px) viewports show ONLY the right sidebar: entering
* narrow migrates the bottom panel's tabs INTO the right tree
* (migrateBottomTabs) — one workbench, the bottom tabs thrown into its
* strips. The right panel becomes a full-width drawer, the bottom panel
* and its toggle button disappear, and the layout push is disabled (the
* drawer floats). Widening does not migrate back: the tabs keep living in
* the right tree.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
import {
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals,
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { useNarrowViewport } from './breakpoints.ts'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx'
import { detectNewDirectSubagent } from './subagent-detect.ts'
import { detectNewTask } from './subagent-tasks.ts'
import { t } from './locales.ts'
import { api, type SessionScope } from './api.ts'
import css from './sidebar.module.css'
@@ -94,6 +104,16 @@ function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionSco
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { ctx, store } = props
// Narrow (mobile) viewports collapse the two panels into one: the right
// panel becomes a full-width drawer holding BOTH workbenches, the bottom
// panel (and its toggle button) disappears, and the layout push is
// disabled (the drawer floats over the app shell). Entering narrow
// MIGRATES the bottom tree's tabs into the right tree (migrateBottomTabs)
// — the merged display is the right sidebar alone, the bottom tabs thrown
// into its strips. Widening never rewrites the migrated state: the tabs
// keep living in the right tree.
const narrow = useNarrowViewport()
// Current conversation (the sessions list feed).
const sessionList = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
@@ -112,6 +132,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const sessionId = snapshot.sessionId
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
/**
* Bottom-panel merge on narrow viewports: whenever a session is current
* while narrow (mount, session switch, or a desktop→narrow transition),
* throw the bottom tree's tabs into the right tree. Idempotent — after
* the first migration the bottom tree is empty and the reducer returns
* the same reference, so this effect settles immediately.
*/
useEffect(() => {
if (!narrow || sessionId === undefined) return
store.reduce(migrateBottomTabs)
}, [narrow, sessionId, store])
// While the session's header is still hydrating (or the session is blank),
// the list summary may carry no cwd; ask the host once (it falls back to
// the process cwd) so the explorer root and terminal cwd are real from
@@ -210,6 +242,28 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx])
/**
* Task auto-activation: the moment a NEW background task appears for the
* current conversation (a task id the previous snapshot lacked), the
* auto-open pref is on, and the Tasks tab type is enabled, open the panel
* (if collapsed) and focus the Tasks page. Unlike the subagent trigger
* (0 → N only), ANY new task id triggers: the agent may start several
* tasks in one session, and each should surface. A fresh page load never
* triggers — its baseline starts at the current snapshot.
*/
const taskBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
useEffect(() => {
const prev = taskBaselineRef.current
taskBaselineRef.current = sessionList
if (sessionId === undefined || prev === undefined) return
if (!detectNewTask(prev, sessionList, sessionId)) return
if (!store.getPrefs().autoOpenTasks) return
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx])
/**
* Topology jump-back: clicking a subagent node on the Subagent page calls
* the official `openSubagent`, which switches the sidebar to that child
@@ -268,6 +322,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
*/
const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
useEffect(() => {
// The bottom panel does not exist on narrow viewports (the two
// workbenches merge into one panel), so the first-expansion auto
// terminal is a desktop-only behavior.
if (narrow) return
if (state === undefined) return
const wasOpen = bottomWasOpenRef.current
bottomWasOpenRef.current = state.bottomOpen
@@ -279,7 +337,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// atomically so later expansions never repeat the auto-open.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
ctx.betterSidebar?.openTab({ type: 'terminal' })
}, [state, store, ctx])
}, [state, store, ctx, narrow])
// Panel drags: the right panel's width (left edge strip), the bottom
// panel's height (top edge strip), and the shared corner (both at once).
@@ -356,16 +414,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
// are squeezed instead of covered. The margins are capped at the viewport
// so a stale persisted size (e.g. fullscreen on a bigger window) can never
// crush the app shell to zero. Dragging disables the layout transition.
// On NARROW viewports the drawer FLOATS over the app shell — no push, the
// conversation keeps the full width behind the drawer.
useEffect(() => {
const width = snapshot.state?.panelOpen === true
const width = !narrow && snapshot.state?.panelOpen === true
? Math.min(snapshot.state.width, window.innerWidth)
: 0
const height = snapshot.state?.bottomOpen === true
const height = !narrow && snapshot.state?.bottomOpen === true
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
: 0
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
}, [snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
useEffect(() => {
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
else document.body.removeAttribute('data-dsh-sidebar-dragging')
@@ -450,11 +510,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
if (state === undefined || sessionId === undefined) {
return (
<div className={css.toggleCluster}>
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
{!narrow && (
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
)}
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelRightOutline16 />
@@ -478,6 +540,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
* ctx and the conversation input service at click time; a missing service
* or scope degrades to a logged no-op, never a crash.
*/
/** The tab icon from the tab-type registry (shared by every workbench). */
const tabIconOf = (tab: SidebarTab): ReactNode => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}
/**
* Render one tab's content. `active` (from the workbench) tells whether
* this tab is the active one in its pane; combined with the panel's
@@ -512,16 +581,22 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
so the tabs genuinely yield space to it.
*/}
<div className={css.toggleCluster}>
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
{/*
Narrow viewports merge the two workbenches into the one drawer —
there is no bottom panel, so its toggle button is not offered.
*/}
{!narrow && (
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')}
onClick={() => { store.reduce(toggleBottomPanel) }}
>
<IconPanelBottomOutline16 />
</button>
</Tooltip>
)}
<Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
<button
type="button"
@@ -538,37 +613,42 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
the slide in/out can animate; visibility hides it after the slide
settles. Its bottom edge follows the bottom panel's height (0 while
the bottom panel is closed) — the VSCode-style "sidebar above panel".
On NARROW viewports it is a full-width drawer holding both
workbenches (see MobileWorkbench); the width drag strip is not
offered there — a full-screen sheet has nothing to drag.
*/}
<div
ref={panelRef}
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: Math.min(state.width, window.innerWidth) }}
style={{ width: narrow ? '100vw' : Math.min(state.width, window.innerWidth) }}
data-dragging={anyDragging || undefined}
>
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
widthDrag.current = { startX: event.clientX, startWidth: state.width }
setDraggingWidth(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
stopDragScheduling()
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
setDraggingWidth(false)
}}
/>
{!narrow && (
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
widthDrag.current = { startX: event.clientX, startWidth: state.width }
setDraggingWidth(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current
const width = clampWidth(startWidth + (startX - event.clientX))
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
scheduleDrag(width, height)
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
const { startX, startWidth } = widthDrag.current
stopDragScheduling()
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
setDraggingWidth(false)
}}
/>
)}
<div className={css.panelBody}>
<Workbench
state={state}
@@ -576,11 +656,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={(tab) => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}}
getTabIcon={tabIconOf}
/>
</div>
</div>
@@ -590,8 +666,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
shell's own left sidebar and ends at the right panel's left edge —
neither sidebar gives up any position (the right panel keeps its
full height). Its resize strip is the top edge; hidden by sliding
down like the right panel.
down like the right panel. On NARROW viewports it does not exist —
the bottom workbench lives inside the drawer (MobileWorkbench).
*/}
{!narrow && (
<div
ref={bottomRef}
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
@@ -655,21 +733,19 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
actions={actions}
onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
getTabIcon={(tab) => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}}
getTabIcon={tabIconOf}
/>
</div>
</div>
)}
{/*
The shared corner (only while BOTH panels are open): the intersection
of the right panel's left edge and the bottom panel's top edge.
Horizontal drags resize the right panel's width, vertical drags the
bottom panel's height — the two panels drag against each other.
(Never on narrow viewports: the bottom panel does not exist there.)
*/}
{state.panelOpen && state.bottomOpen && (
{!narrow && state.panelOpen && state.bottomOpen && (
<div
ref={cornerRef}
className={css.cornerHandle}
+272
View File
@@ -273,3 +273,275 @@
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* ── Background-task section (below the topology tree) ────────────────── */
.tasks {
margin-top: 10px;
border-top: 1px solid var(--dsw-alias-border-l2);
padding-top: 8px;
}
.tasksHeader {
display: flex;
align-items: center;
gap: 8px;
height: 26px;
padding: 0 2px;
}
.tasksTitle {
flex: 1;
min-width: 0;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tasksCount {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.tasksList {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
/* One task row; the whole row lights up on hover, the expandable target
stays transparent (the kill button rides on the row's right edge). */
.tasksRow {
display: flex;
align-items: center;
gap: 4px;
border-radius: 8px;
}
.tasksRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.tasksRowSettled {
opacity: 0.8;
}
/* The row whose output is shown in the bottom dock. */
.tasksRowSelected,
.tasksRowSelected:hover {
background: var(--dsw-alias-interactive-bg-active);
}
.tasksRowMain {
flex: 1;
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
padding: 6px 8px 6px 11px;
border: none;
border-radius: 8px;
background: transparent;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
text-align: left;
cursor: pointer;
outline: none;
}
.tasksRowMain:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
}
.tasksDot {
margin-top: 5px;
}
.tasksContent {
display: flex;
flex-direction: column;
min-width: 0;
gap: 1px;
}
/* The command line: a kind badge followed by the command itself in the code
face, like the terminal's live lines. */
.tasksLabelLine {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.tasksKind {
flex: none;
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 4px;
font: var(--dsw-font-xxxs-strong-11);
line-height: 14px;
color: var(--dsw-alias-label-tertiary);
}
.tasksLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: var(--dsw-font-xxxs-11-line-height);
color: var(--dsw-alias-label-primary);
}
.tasksSecondary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.tasksKill {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin-right: 4px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.tasksKill:hover {
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
}
/* The armed (first-click) state: a filled confirm chip instead of the icon. */
.tasksKillArmed,
.tasksKillArmed:hover {
width: auto;
height: 20px;
padding: 0 8px;
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
font: var(--dsw-font-xxxs-strong-11);
white-space: nowrap;
}
.tasksKill:disabled {
opacity: 0.5;
cursor: default;
}
.tasksKillError {
flex: none;
margin-right: 4px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-state-error-primary);
}
/* ── The shared output dock (bottom of the page, sticky like a terminal) ── */
/* Direct child of the scrolling body, so it docks at the scrollport's bottom
edge while the task list scrolls above it; a solid background keeps the
tree from showing through. */
.tasksPane {
position: sticky;
bottom: 0;
z-index: 1;
margin-top: 4px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 8px;
background: var(--dsw-alias-bg-base);
box-shadow: 0 -6px 12px -8px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.tasksPaneHeader {
display: flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 4px 0 10px;
border-bottom: 1px solid var(--dsw-alias-border-l1);
}
.tasksPaneDot {
flex: none;
}
.tasksPaneLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: var(--dsw-font-xxxs-11-line-height);
color: var(--dsw-alias-label-primary);
}
.tasksPaneStatus {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.tasksPaneClose {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.tasksPaneClose:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.tasksPanePre {
margin: 0;
max-height: 200px;
overflow: auto;
padding: 6px 10px;
font-family: var(--ds-font-family-code);
font-size: var(--dsw-font-xxxs-11-font-size);
line-height: 1.5;
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
word-break: break-word;
}
.tasksPaneHint {
padding: 8px 10px;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.tasksPaneError {
color: var(--dsw-alias-state-error-primary);
}
+342 -60
View File
@@ -27,11 +27,13 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import type {
Context,
SidebarSessionList,
SidebarSessionSummary,
SidebarSubagentAddress,
SidebarSubagentCatalog,
SidebarSubagentChildEntry,
SidebarSubagentDiagnosticEntry,
SidebarTaskView,
} from '../context-types.ts'
import {
collectBranchIds,
@@ -39,6 +41,17 @@ import {
rootAncestor,
} from './subagent-detect.ts'
import { lastActivity } from './subagent-activity.ts'
import {
collectTreeTasks,
formatTaskDuration,
isTaskLive,
orderTasks,
taskDotState,
taskStatusLabel,
type TreeTask,
} from './subagent-tasks.ts'
import { api, type TaskOutputResult } from './api.ts'
import { IconStopOutline16 } from './icons.tsx'
import { t } from './locales.ts'
import css from './SubagentView.module.css'
@@ -46,6 +59,10 @@ import css from './SubagentView.module.css'
const POLL_MS = 3000
/** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 60
/** Refresh cadence of an expanded task-output panel while its task runs. */
const TASK_POLL_MS = 2000
/** How long the kill button stays armed before it needs re-confirming. */
const TASK_KILL_ARM_MS = 3000
/** The direct subagent children of one parent (durable `origin` rows). */
function directChildren(
@@ -340,6 +357,262 @@ function CatalogRows({
)
}
/**
* The shared output dock of the tasks section: ONE pane at the bottom of the
* sidebar body (sticky, terminal-like) shows the SELECTED task's output as
* the MODEL has read it so far (replayed from the owner session's event
* log), refreshed every {@link TASK_POLL_MS} while the task runs and the
* page is visible. The model's `task_output` cursor is never touched — the
* pane can never steal the agent's bytes, and it stays empty until the
* agent reads the task. A single dock — not a panel per row — keeps the
* task list compact and stable when many tasks are running.
*/
function TaskOutputPane(props: {
ownerSessionId: string
task: SidebarTaskView
/** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean
onClose: () => void
}) {
const { ownerSessionId, task, active, onClose } = props
const [state, setState] = useState<'loading' | TaskOutputResult | 'error'>('loading')
const controllerRef = useRef<AbortController | undefined>(undefined)
const preRef = useRef<HTMLPreElement>(null)
const load = useCallback(async (): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
try {
const result = await api.taskOutput({ sessionId: ownerSessionId }, task.id, controller.signal)
setState(result)
} catch {
// A newer pull aborted this one, or the wire failed: keep the last
// known output; only a dock that never loaded anything shows an error.
setState(current => (current === 'loading' ? 'error' : current))
}
}, [ownerSessionId, task.id])
useEffect(() => {
void load()
if (!active || !isTaskLive(task)) return
const timer = window.setInterval(() => { void load() }, TASK_POLL_MS)
return () => { window.clearInterval(timer) }
}, [load, active, task.status])
useEffect(() => () => { controllerRef.current?.abort() }, [])
// Terminal-tail behavior: while the task runs, each refresh pins the view
// to the newest output; a settled dock leaves scrolling to the reader.
useEffect(() => {
if (!isTaskLive(task) || typeof state !== 'object' || state.text.length === 0) return
const pre = preRef.current
if (pre !== null) pre.scrollTop = pre.scrollHeight
}, [state, task.status])
return (
<div className={css.tasksPane} role="region" aria-label={`${task.label} ${t('tasks')}`}>
<div className={css.tasksPaneHeader}>
<StateDot state={taskDotState(task.status)} className={css.tasksPaneDot} />
<span className={css.tasksPaneLabel} title={task.label}>{task.label}</span>
<span className={css.tasksPaneStatus}>
{taskStatusLabel(task.status, t)}
{task.detail !== undefined && task.detail !== '' ? ` · ${task.detail}` : ''}
</span>
<button
type="button"
className={css.tasksPaneClose}
aria-label={t('close')}
title={t('close')}
onClick={onClose}
>
<IconStopOutline16 size={10} />
</button>
</div>
{state === 'loading' && <div className={css.tasksPaneHint}>{t('loading')}</div>}
{state === 'error' && (
<div className={`${css.tasksPaneHint} ${css.tasksPaneError}`}>{t('taskOutputError')}</div>
)}
{typeof state === 'object' && (
<>
{state.text.length > 0
? <pre ref={preRef} className={css.tasksPanePre}>{state.text}</pre>
: state.read
? <div className={css.tasksPaneHint}>{t('taskNoOutput')}</div>
: <div className={css.tasksPaneHint}>{t('taskNotReadYet')}</div>}
{state.truncated && <div className={css.tasksPaneHint}>{t('taskOutputTruncated')}</div>}
</>
)}
</div>
)
}
/**
* The background-task section of the Subagent page: every task of the whole
* current tree (main agent + subagents, owner-labeled), fed by the harness
* `session/tasks` push mirror. Clicking a row feeds its model-read output to
* the shared bottom dock (event replay — never the model's cursor); live
* rows carry a two-click-confirm kill button. Renders nothing while the
* tree has no tasks.
*/
function TasksSection(props: {
byId: SidebarSessionList['byId']
tasksBySession: SidebarSessionList['tasksBySession']
rootId: string | undefined
/** The page is visible (active tab + open panel): skip polling otherwise. */
active: boolean
}) {
const { byId, tasksBySession, rootId, active } = props
const rows = useMemo(
() => orderTasks(collectTreeTasks(byId, tasksBySession, rootId)),
[byId, tasksBySession, rootId],
)
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
const [armedId, setArmedId] = useState<string | undefined>(undefined)
const [killingId, setKillingId] = useState<string | undefined>(undefined)
const [killErrorId, setKillErrorId] = useState<string | undefined>(undefined)
// The duration clock only runs while a live row is on screen.
const [now, setNow] = useState(() => Date.now())
const selectedRow = useMemo(
() => (selectedId === undefined ? undefined : rows.find(row => row.task.id === selectedId)),
[rows, selectedId],
)
const liveCount = useMemo(
() => rows.reduce((count, row) => count + (isTaskLive(row.task) ? 1 : 0), 0),
[rows],
)
const multiOwner = useMemo(
() => new Set(rows.map(row => row.ownerSessionId)).size > 1,
[rows],
)
// The kill button stays armed only briefly; a stray click must never kill.
useEffect(() => {
if (armedId === undefined) return
const timer = window.setTimeout(() => { setArmedId(undefined) }, TASK_KILL_ARM_MS)
return () => { window.clearTimeout(timer) }
}, [armedId])
useEffect(() => {
if (liveCount === 0) return
setNow(Date.now())
const timer = window.setInterval(() => { setNow(Date.now()) }, 1_000)
return () => { window.clearInterval(timer) }
}, [liveCount])
// The docked output pane follows its task: when the selected task leaves
// the mirror (settled and dropped, or the tree switched), close the dock.
useEffect(() => {
if (selectedId !== undefined && selectedRow === undefined) setSelectedId(undefined)
}, [selectedId, selectedRow])
// NOTE: every hook must live ABOVE the empty-state return — a hook below it
// would flip this component's hook count when the mirror empties and crash
// React with "Rendered fewer hooks than expected" (the #300 regression).
const kill = useCallback(async (row: TreeTask): Promise<void> => {
setKillingId(row.task.id)
setKillErrorId(undefined)
try {
await api.taskKill({ sessionId: row.ownerSessionId }, row.task.id)
} catch {
setKillErrorId(row.task.id)
} finally {
setKillingId(undefined)
setArmedId(undefined)
}
}, [])
if (rows.length === 0) return null
const countLabel = liveCount > 0
? t('tasksCountRunning', { count: rows.length, running: liveCount })
: t('tasksCount', { count: rows.length })
return (
<>
<section className={css.tasks} aria-label={t('tasks')}>
<div className={css.tasksHeader}>
<span className={css.tasksTitle}>{t('tasks')}</span>
<span className={css.tasksCount}>{countLabel}</span>
</div>
<ul className={css.tasksList} aria-label={t('tasks')}>
{rows.map((row) => {
const { task } = row
const live = isTaskLive(task)
const selected = selectedId === task.id
const armed = armedId === task.id
const killing = killingId === task.id
const killFailed = killErrorId === task.id
const elapsed = live
? now - task.startedAt
: (task.finishedAt ?? task.startedAt) - task.startedAt
const secondary = [
...(multiOwner ? [row.ownerTitle] : []),
taskStatusLabel(task.status, t),
...(task.detail !== undefined && task.detail !== '' ? [task.detail] : []),
formatTaskDuration(elapsed, t),
].filter(Boolean).join(' · ')
return (
<li
key={task.id}
className={clsx(
css.tasksRow,
!live && css.tasksRowSettled,
selected && css.tasksRowSelected,
)}
>
<button
type="button"
className={css.tasksRowMain}
aria-pressed={selected}
aria-label={`${task.label} ${secondary}`}
onClick={() => { setSelectedId(selected ? undefined : task.id) }}
>
<StateDot state={taskDotState(task.status)} className={css.tasksDot} />
<span className={css.tasksContent}>
<span className={css.tasksLabelLine}>
<span className={css.tasksKind}>{task.kind}</span>
<span className={css.tasksLabel} title={task.label}>{task.label}</span>
</span>
<span className={css.tasksSecondary}>{secondary}</span>
</span>
</button>
{task.status === 'running' && (
<button
type="button"
className={armed ? `${css.tasksKill} ${css.tasksKillArmed}` : css.tasksKill}
aria-label={armed ? t('taskKillConfirm') : t('taskKill')}
title={armed ? t('taskKillConfirm') : t('taskKill')}
disabled={killing}
onClick={(event) => {
event.stopPropagation()
if (armed) void kill(row)
else setArmedId(task.id)
}}
>
{armed ? t('taskKillConfirm') : <IconStopOutline16 size={12} />}
</button>
)}
{killFailed && <span className={css.tasksKillError}>{t('taskKillError')}</span>}
</li>
)
})}
</ul>
</section>
{selectedRow !== undefined && (
<TaskOutputPane
ownerSessionId={selectedRow.ownerSessionId}
task={selectedRow.task}
active={active}
onClose={() => { setSelectedId(undefined) }}
/>
)}
</>
)
}
/**
* The sidebar's Subagent topology page.
* @param props - current session id, whether the page is actually visible
@@ -515,69 +788,78 @@ export function SubagentView(props: {
<div
ref={bodyRef}
className={css.subagentBody}
role="tree"
aria-label={t('subagent')}
aria-busy={summaryBackedLoading || undefined}
onKeyDown={onTreeKeyDown}
>
{rootId !== undefined && rootSummary !== undefined && (
<div
role="treeitem"
tabIndex={0}
aria-level={0}
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
aria-current={rootId === sessionId ? 'true' : undefined}
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
onClick={openMain}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openMain()
}
}}
>
<StateDot
state={rootSummary.running === true ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
<span className={css.subagentContent}>
<span className={css.subagentLabel}>
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
</span>
<span className={css.subagentSecondary}>
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
</span>
</span>
</div>
)}
{rootId !== undefined && (
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
{summaryBackedLoading && (
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
)}
{!summaryBackedLoading && (
<CatalogRows
parentSessionId={rootId}
catalog={rootCatalog}
catalogs={catalogs}
byId={byId}
level={1}
currentSessionId={sessionId}
active={active}
ctx={ctx}
openChild={openChild}
refresh={refresh}
<div
role="tree"
aria-label={t('subagent')}
aria-busy={summaryBackedLoading || undefined}
>
{rootId !== undefined && rootSummary !== undefined && (
<div
role="treeitem"
tabIndex={0}
aria-level={0}
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
aria-current={rootId === sessionId ? 'true' : undefined}
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
onClick={openMain}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
event.stopPropagation()
openMain()
}
}}
>
<StateDot
state={rootSummary.running === true ? 'ongoing' : 'done'}
className={css.subagentDot}
/>
)}
</div>
)}
{readyEmpty && (
<div className={css.subagentEmpty}>
<div>{t('subagentEmpty')}</div>
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
</div>
)}
<span className={css.subagentContent}>
<span className={css.subagentLabel}>
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
</span>
<span className={css.subagentSecondary}>
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
</span>
</span>
</div>
)}
{rootId !== undefined && (
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
{summaryBackedLoading && (
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
)}
{!summaryBackedLoading && (
<CatalogRows
parentSessionId={rootId}
catalog={rootCatalog}
catalogs={catalogs}
byId={byId}
level={1}
currentSessionId={sessionId}
active={active}
ctx={ctx}
openChild={openChild}
refresh={refresh}
/>
)}
</div>
)}
{readyEmpty && (
<div className={css.subagentEmpty}>
<div>{t('subagentEmpty')}</div>
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
</div>
)}
</div>
<TasksSection
byId={byId}
tasksBySession={list.tasksBySession}
rootId={rootId}
active={active}
/>
</div>
</div>
)
+28
View File
@@ -60,6 +60,21 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
* `head` carries the first bytes (base64) for viewer detect sniffing. */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
/**
* One tasks.output response: the output the MODEL has read so far for the
* task (replayed from the owner session's event log — the model's
* task_output cursor is never touched, so the pane can never steal the
* agent's bytes). `read` is false until the model actually called
* task_output for the task.
*/
export interface TaskOutputResult {
text: string
/** True when the host capped the text at its output limit. */
truncated: boolean
/** Whether the model has read the task at least once. */
read: boolean
}
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response
try {
@@ -145,6 +160,19 @@ export const api = {
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/**
* The output the model has read so far for one background task (replayed
* from the owner session's event log — never the model's task_output
* cursor). The scope MUST be the task's OWNER session.
*/
taskOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
call<TaskOutputResult>('tasks.output', scopePayload(scope, { id }), signal),
/** Request cancellation of one background task (live tasks flip to stopping). */
taskKill: (scope: SessionScope, id: string, reason?: string) =>
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('tasks.kill', scopePayload(scope, {
id,
...(reason !== undefined ? { reason } : {}),
})),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number }>('settings.get', {}),
+51
View File
@@ -0,0 +1,51 @@
/**
* Narrow-viewport ("mobile") breakpoint for the sidebar. Width-based, shared
* by the layout logic (JS) and the style gates (CSS). The CSS side pairs
* with this file via `@media (max-width: 767px)` rules (sidebar.module.css)
* — 767px ≡ widths below NARROW_MAX_WIDTH, documented at both ends.
*
* "Real narrow" on purpose: the mobile layout (one full-screen drawer, the
* bottom panel's tabs merged into the right sidebar) is a phone / portrait
* tablet experience. The value is deliberately NOT aligned to the DSH app
* shell's own 1024px breakpoint — 1024px windows (small laptops, split
* panes) keep the desktop two-panel layout.
*/
import { useEffect, useState } from 'react'
/** Viewport widths strictly below this are "mobile" (paired CSS: max-width: 767px). */
export const NARROW_MAX_WIDTH = 768
/** Whether a viewport width is narrow (mobile). */
export function isNarrowWidth(width: number): boolean {
return width < NARROW_MAX_WIDTH
}
/**
* Live narrow-viewport flag for components. Reads `window.innerWidth` and
* re-measures on resize (rAF-throttled, the repo's existing drag pattern).
* Deliberately avoids `matchMedia` (jsdom does not implement it) — the
* resize listener is equally exact for a breakpoint that never changes
* while the page is open.
*/
export function useNarrowViewport(): boolean {
const [narrow, setNarrow] = useState(
() => typeof window !== 'undefined' && isNarrowWidth(window.innerWidth),
)
useEffect(() => {
if (typeof window === 'undefined') return
let frame: number | null = null
const measure = (): void => {
frame = null
setNarrow(isNarrowWidth(window.innerWidth))
}
const onResize = (): void => {
if (frame === null) frame = requestAnimationFrame(measure)
}
window.addEventListener('resize', onResize)
return () => {
window.removeEventListener('resize', onResize)
if (frame !== null) cancelAnimationFrame(frame)
}
}, [])
return narrow
}
+35 -6
View File
@@ -13,14 +13,41 @@ import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
import { ExplorerView } from '../ExplorerView.tsx'
import { EditorHost } from '../EditorHost.tsx'
import { TerminalView } from '../TerminalView.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
import type { SidebarStore } from '../state.ts'
import type { TabDescriptor } from '../service.ts'
/**
* Lazy wrapper over the terminal view: xterm (and its stylesheet) is fetched
* only when a terminal tab is first opened (see chunk-loader.ts). The
* wrapper keeps the descriptor contract `(props) => ReactNode` — Sidebar
* calls it as a plain function.
*
* TerminalView's props are { scope, tabId, store } — `tabId` is NOT part of
* TabComponentProps (it carries `tab: SidebarTab` instead), so the
* descriptor maps it explicitly; a bare pass-through would leave tabId
* undefined and TerminalView's isAgentTabId(tabId) would crash on
* `undefined.startsWith` (regression-pinned in tests/lazy-chunk.spec.tsx).
*/
const LazyTerminal = lazyChunkComponent<TerminalViewProps>(
'terminal',
(mod) => mod.TerminalView as ComponentType<TerminalViewProps> | undefined,
)
/** The terminal view's props (mirror of TerminalView's own signature). */
interface TerminalViewProps {
scope: SessionScope
tabId: string
store: SidebarStore
}
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
export const TERMINAL_LIMIT = 3
@@ -82,13 +109,17 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
icon: (size: number) => <IconThinkOutline16 size={size} />,
order: 30,
single: true,
// Declarative settings: the auto-open switch renders under this row in
// the Side card settings page (the Subagent page's own related setting).
// Declarative settings: the auto-open switches render under this row in
// the Side card settings page (the Tasks page's own related settings).
settings: {
toggles: [{
key: 'autoOpenSubagent',
title: () => t('settingsSubagentTitle'),
desc: () => t('settingsSubagentDesc'),
}, {
key: 'autoOpenTasks',
title: () => t('settingsTasksTitle'),
desc: () => t('settingsTasksDesc'),
}],
},
component: ({ ctx, scope, visible, onSubagentJump }) => (
@@ -133,9 +164,7 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
patch: { nextTerminal: state.nextTerminal + 1 },
}
},
component: ({ scope, tab, store }) => (
<TerminalView scope={scope} tabId={tab.id} store={store} />
),
component: ({ tab, scope, store }) => <LazyTerminal scope={scope} store={store} tabId={tab.id} />,
},
{
id: 'browser',
+28 -16
View File
@@ -6,15 +6,21 @@
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
* (`exts: []`, lowest priority) that claims any file no other viewer did.
*
* The heavy viewers (docx/xlsx/pptx and the CodeMirror-backed
* markdown/html/code) render through {@link lazyChunkComponent} wrappers —
* their libraries are fetched only when such a file is first opened (see
* chunk-loader.ts). The descriptor metadata (id/exts/priority/detect) is
* identical either way, so matching semantics and external-plugin
* overrides are unaffected; the `component` wrapper keeps the descriptor
* contract `(props) => ReactNode`.
*
* Every viewer carries the declarative settings-surface fields — `title`
* and `icon` — so the Side card settings page can render the enable/disable
* inventory without hardcoding (eating our own dogfood).
*/
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DocxView, XlsxView } from '../office-view.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { PdfView } from '../PdfView.tsx'
import { PptxView } from '../PptxView.tsx'
import { TextEditor } from '../TextEditor.tsx'
import { BinaryDownload } from '../binary-download.tsx'
import {
IconImageOutline16,
@@ -25,10 +31,22 @@ import {
IconPptxOutline16,
IconHtmlOutline16,
} from '../icons.tsx'
import type { FileViewerDescriptor } from '../service.ts'
import type { ComponentType } from 'react'
import type { FileViewerDescriptor, FileViewerProps } from '../service.ts'
import { t } from '../locales.ts'
import css from '../sidebar.module.css'
/**
* Lazy wrappers over the chunk-resident viewer components. The `pick`
* functions are module-level (stable identity — the wrapper effect depends
* on them); the cast bridges the chunk exports record to the descriptor
* prop shape (the views read only their own subset of FileViewerProps).
*/
const LazyDocx = lazyChunkComponent<FileViewerProps>('docx', (mod) => mod.DocxView as ComponentType<FileViewerProps> | undefined)
const LazyXlsx = lazyChunkComponent<FileViewerProps>('xlsx', (mod) => mod.XlsxView as ComponentType<FileViewerProps> | undefined)
const LazyPptx = lazyChunkComponent<FileViewerProps>('pptx', (mod) => mod.PptxView as ComponentType<FileViewerProps> | undefined)
const LazyTextEditor = lazyChunkComponent<FileViewerProps>('editor', (mod) => mod.TextEditor as ComponentType<FileViewerProps> | undefined)
/** The 9 built-in file viewer descriptors. */
export function builtinViewers(): readonly FileViewerDescriptor[] {
return [
@@ -60,9 +78,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconDocxOutline16 size={size} />,
exts: ['docx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<DocxView scope={scope} path={path} title={title} />
),
component: (props) => <LazyDocx {...props} />,
},
{
id: 'xlsx',
@@ -70,9 +86,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconXlsxOutline16 size={size} />,
exts: ['xlsx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<XlsxView scope={scope} path={path} title={title} />
),
component: (props) => <LazyXlsx {...props} />,
},
{
id: 'pptx',
@@ -80,9 +94,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconPptxOutline16 size={size} />,
exts: ['pptx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<PptxView scope={scope} path={path} title={title} />
),
component: (props) => <LazyPptx {...props} />,
},
{
id: 'markdown',
@@ -90,7 +102,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconMarkdownOutline16 size={size} />,
exts: ['md', 'markdown'],
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
component: (props) => <LazyTextEditor {...props} />,
},
{
id: 'html',
@@ -112,7 +124,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
desc: () => t('settingsHtmlDefaultUnsafeDesc'),
}],
},
component: (props) => <TextEditor {...props} />,
component: (props) => <LazyTextEditor {...props} />,
},
{
id: 'code',
@@ -121,7 +133,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
exts: [],
priority: -100,
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
component: (props) => <LazyTextEditor {...props} />,
},
{
id: 'binary-download',
+196
View File
@@ -0,0 +1,196 @@
/**
* Lazy chunk loader for the client bundle. The heavy preview/terminal
* libraries (Univer, docx-preview, pptx-renderer, CodeMirror, xterm — the
* office/terminal/editor stacks, tens of MB) live in separate build-time
* bundles (`lib/client-<name>.js`) fetched only on first use of the feature
* that needs them, so startup downloads/parses only the ~1MB core bundle.
*
* How a chunk script works (see tsdown.config.ts chunkBundle):
*
* globalThis.__dshChunks__ = globalThis.__dshChunks__ || {};
* globalThis.__dshChunks__["terminal"] = (require) => { ...exports };
*
* The script registers its factory on a plugin-owned global registry (NOT
* through window.__ModuleLoader__.load — the module loader's import() only
* resolves seed words, shell-own modules, registered factories, and boot
* graph rows; a chunk id is none of those, so resolution would be version-
* dependent). Materialization is plugin-owned:
*
* 1. inject <script src="/sidebar/bundle/<name>.js"> (classic same-origin
* script; the official /plugins/<id>/client.js route cannot serve
* arbitrary file names, so the plugin's own host route serves the chunks),
* 2. read the factory from the global registry,
* 3. call it with a require that resolves the platform externals through
* `__DSH_MODULES__.import(spec)` — the seed-word branch, the one part of
* the module system that is stable across versions.
*
* Caching contract (three layers, each with a failure path):
* - In-memory: one in-flight promise per chunk, memoized until
* {@link resetChunks}; a failed load removes its entry so the next call
* retries from scratch.
* - Script execution: each re-execution overwrites the global registry slot
* (assignment, never registration) — no "duplicate factory registration"
* class of errors; a failed materialization clears the cache so the retry
* re-injects and re-executes.
* - HTTP: the bundle route revalidates every request (`cache-control:
* no-cache` + ETag, 304 when unchanged), so page refreshes and HMR
* re-activations never re-download a multi-MB chunk that did not change.
*
* HMR: each plugin activation calls {@link resetChunks}, which drops the
* in-memory cache (and any test registry), so a hot-reloaded core bundle
* re-fetches and re-executes the current chunk scripts on the next lazy
* open. Chunk-only source edits still need a manual page refresh (the HMR
* poll watches only client.js).
*/
export type ChunkName = 'docx' | 'xlsx' | 'pptx' | 'terminal' | 'editor'
/** The module exports a chunk factory provides (namespace-ish record). */
export type ChunkExports = Record<string, unknown>
/** A chunk factory: (require) => exports (the chunk's CJS closure shape). */
type ChunkFactory = (require: (spec: string) => unknown) => ChunkExports
/**
* The platform externals a chunk bundle may require (mirror of
* CLIENT_EXTERNALS in tsdown.config.ts — the chunk builds keep these
* external and the loader resolves them here). A superset is safe: the
* require only answers what the chunk actually asks for.
*/
export const CHUNK_EXTERNALS: readonly string[] = [
'react',
'react/jsx-runtime',
'react-dom',
'react-dom/client',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-schema-form',
'@deepseek-ai/dsh-client-runtime/client',
]
/** Chunk script endpoint served by the plugin host half (src/bundle-route.ts). */
const CHUNK_URL = (name: ChunkName): string => `/sidebar/bundle/${name}.js`
/** The client module system surface this loader needs (window.__DSH_MODULES__). */
interface ChunkModuleSystem {
import(specifier: string): Promise<unknown>
}
/** Resolve the shell-installed module system (set before any plugin activates). */
function moduleSystem(): ChunkModuleSystem | undefined {
return (globalThis as { __DSH_MODULES__?: ChunkModuleSystem }).__DSH_MODULES__
}
/** The plugin-owned chunk factory registry the chunk scripts populate. */
interface ChunkRegistry {
[name: string]: ChunkFactory | undefined
}
function chunkRegistry(): ChunkRegistry {
const g = globalThis as { __dshChunks__?: ChunkRegistry }
return g.__dshChunks__ ??= {}
}
/** Script-load hook; tests replace it with a stub (the default needs a real DOM + network). */
export type ChunkScriptLoader = (src: string) => Promise<void>
const defaultScriptLoader: ChunkScriptLoader = (src) => new Promise((resolve, reject) => {
const el = document.createElement('script')
el.async = true
el.src = src
el.addEventListener('load', () => {
el.remove()
resolve()
}, { once: true })
el.addEventListener('error', () => {
el.remove()
reject(new Error(`[dsh-better-sidebar] chunk script ${src} failed to load`))
}, { once: true })
document.head.append(el)
})
let scriptLoader: ChunkScriptLoader = defaultScriptLoader
/** Test hook: replace the chunk-script loader (pass null to restore the default). */
export function setChunkScriptLoaderForTests(loader: ChunkScriptLoader | null): void {
scriptLoader = loader ?? defaultScriptLoader
}
/** Test/dev hook: resolve a chunk without fetching a script (e.g. vitest). */
const testLoaders = new Map<ChunkName, () => Promise<ChunkExports>>()
export function registerChunkForTests(name: ChunkName, loader: () => Promise<ChunkExports>): void {
testLoaders.set(name, loader)
}
/** Memoized externals require, resolved once per page from the seed table. */
let externalsRequire: ((spec: string) => unknown) | undefined
async function buildExternalsRequire(modules: ChunkModuleSystem): Promise<(spec: string) => unknown> {
if (externalsRequire !== undefined) return externalsRequire
// Per-spec tolerance: a spec the running DSH version cannot resolve (e.g.
// the runtime/client exemption row) stays unresolved until a chunk
// actually requires it — only then it is a loud error.
const entries = await Promise.all(CHUNK_EXTERNALS.map(async (spec) => {
try {
return [spec, await modules.import(spec)] as const
} catch {
return [spec, undefined] as const
}
}))
const table = new Map<string, unknown>(entries)
externalsRequire = (spec: string): unknown => {
if (!table.has(spec)) {
// Single quotes: the bundle-consistency scan regexes for require("...")
// lexical calls — a double-quoted literal here would trip it.
throw new Error(`[dsh-better-sidebar] chunk require('${spec}') missed the module table`)
}
return table.get(spec)
}
return externalsRequire
}
/** In-flight/memoized chunk loads; a failure removes its entry so a retry re-fetches. */
const cache = new Map<ChunkName, Promise<ChunkExports>>()
/**
* Load (once) and materialize a lazy chunk, returning its module exports.
* Concurrent callers share one in-flight load; a failure clears the cache
* entry so the next call retries (the script re-executes and overwrites its
* global registry slot — assignments are idempotent).
* @param name - the chunk to load.
*/
export function loadChunk(name: ChunkName): Promise<ChunkExports> {
const cached = cache.get(name)
if (cached !== undefined) return cached
const task = (async (): Promise<ChunkExports> => {
const test = testLoaders.get(name)
if (test !== undefined) return test()
const modules = moduleSystem()
if (modules === undefined) {
throw new Error(`[dsh-better-sidebar] chunk "${name}": client module system unavailable`)
}
await scriptLoader(CHUNK_URL(name))
const factory = chunkRegistry()[name]
if (typeof factory !== 'function') {
throw new Error(`[dsh-better-sidebar] chunk "${name}" script did not register its factory`)
}
const require = await buildExternalsRequire(modules)
return factory(require)
})()
cache.set(name, task)
void task.catch(() => { cache.delete(name) })
return task
}
/**
* Drop all chunk state for a fresh plugin activation (HMR-safe): clear the
* in-memory cache and any test-registry entries, so the next lazy open
* re-fetches and re-executes the current chunk scripts (the registry slots
* are overwritten by the re-execution — no cleanup needed).
*/
export function resetChunks(): void {
cache.clear()
testLoaders.clear()
externalsRequire = undefined
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Lazy chunk entry: the .docx preview (docx-preview + jszip). Built as
* `lib/client-docx.js` and registered under `dsh-better-sidebar/docx` —
* fetched only when a .docx is first opened (see chunk-loader.ts and
* docs/plans/2026-08-12-lazy-chunks-design.md). Never import this module
* from the core bundle: it pulls the office library into the startup path.
*/
export { DocxView } from '../docx-view.tsx'
+9
View File
@@ -0,0 +1,9 @@
/**
* Lazy chunk entry: the code/markdown/html text editor (CodeMirror 6 + the
* language packages). Built as `lib/client-editor.js` and registered under
* `dsh-better-sidebar/editor` — fetched only when a text file is first
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
* Never import this module from the core bundle: it pulls CodeMirror into
* the startup path.
*/
export { TextEditor } from '../TextEditor.tsx'
+8
View File
@@ -0,0 +1,8 @@
/**
* Lazy chunk entry: the .pptx preview (pptx-renderer). Built as
* `lib/client-pptx.js` and registered under `dsh-better-sidebar/pptx` —
* fetched only when a .pptx is first opened (see chunk-loader.ts and
* docs/plans/2026-08-12-lazy-chunks-design.md). Never import this module
* from the core bundle: it pulls the renderer into the startup path.
*/
export { PptxView } from '../PptxView.tsx'
+9
View File
@@ -0,0 +1,9 @@
/**
* Lazy chunk entry: the interactive terminal (xterm + fit addon). Built as
* `lib/client-terminal.js` and registered under
* `dsh-better-sidebar/terminal` — fetched only when a terminal tab is first
* opened (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
* Never import this module from the core bundle: it pulls xterm (and its
* stylesheet) into the startup path.
*/
export { TerminalView } from '../TerminalView.tsx'
+9
View File
@@ -0,0 +1,9 @@
/**
* Lazy chunk entry: the .xlsx preview (Univer family + SheetJS, several MB).
* Built as `lib/client-xlsx.js` and registered under
* `dsh-better-sidebar/xlsx` — fetched only when a .xlsx is first opened
* (see chunk-loader.ts and docs/plans/2026-08-12-lazy-chunks-design.md).
* Never import this module from the core bundle: it pulls Univer into the
* startup path.
*/
export { XlsxView } from '../xlsx-view.tsx'
+113
View File
@@ -0,0 +1,113 @@
/**
* The .docx preview component: renders via docx-preview (preserved
* styles/images/tables). Lives in its own module (split out of the former
* office-view.tsx) so it builds as a standalone lazy chunk
* (`lib/client-docx.js`) — the library is loaded only when a .docx is first
* opened, and never pulls in the Univer/xlsx stack.
*
* Errors degrade to the shared download-button affordance, so a corrupted /
* encrypted / oversized file always leaves the user with a way to get it.
*/
import { useEffect, useRef, useState } from 'react'
import { mediaUrl } from './api.ts'
import { t } from './locales.ts'
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
import css from './sidebar.module.css'
/**
* Render a .docx file via docx-preview. The library renders into a container
* div (no canvas); images and styles are inlined. Unmounting clears the
* container's innerHTML — docx-preview has no dispose API, but tearing down
* the DOM is enough.
*/
export function DocxView(props: OfficeViewProps): JSX.Element {
const { scope, path, title } = props
const viewportRef = useRef<HTMLDivElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
const [zoom, setZoom] = useState(100)
useEffect(() => {
let cancelled = false
const container = viewportRef.current
const wrap = wrapRef.current
if (container === null || wrap === null) return
setZoom(100)
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path))
if (cancelled) return
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const buf = await response.arrayBuffer()
if (cancelled) return
// docx-preview ships its own CSS through the className option; the
// wrapper div scopes its render output.
const { renderAsync } = await import('docx-preview')
await renderAsync(buf, wrap, undefined, {
className: 'docx',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
breakPages: true,
experimental: false,
})
if (!cancelled) setLoad({ status: 'ready' })
} catch (error) {
if (!cancelled) {
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
}
})()
return () => {
cancelled = true
// Tear down the rendered DOM so a reopen starts clean.
if (wrap !== null) wrap.innerHTML = ''
}
}, [scope.sessionId, scope.cwd, path])
useEffect(() => {
const viewport = viewportRef.current
if (viewport === null) return
const onWheel = (event: WheelEvent): void => {
if (!event.altKey) return
event.preventDefault()
const delta = event.deltaY < 0 ? 10 : -10
setZoom(current => Math.max(50, Math.min(200, current + delta)))
}
viewport.addEventListener('wheel', onWheel, { passive: false })
return () => { viewport.removeEventListener('wheel', onWheel) }
}, [])
return (
<div className={css.editorDocx}>
<div className={css.editorDocxViewport} ref={viewportRef}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
{load.status !== 'error' && (
<div
className={css.editorDocxWrap}
ref={wrapRef}
aria-label={title}
style={{ zoom: zoom / 100 }}
/>
)}
</div>
<div className={css.editorDocxZoom}>
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
<input
className={css.editorDocxZoomRange}
type="range"
min={50}
max={200}
step={10}
value={zoom}
aria-label={t('zoom')}
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
/>
<span className={css.editorDocxZoomValue}>{zoom}%</span>
</div>
</div>
)
}
+10
View File
@@ -51,6 +51,16 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/**
* Stop glyph for the background-task kill button: a filled square in the
* app's outline scale (16), the universal "halt this work" mark.
*/
export const IconStopOutline16 = ({ 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">
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" stroke="none" />
</svg>
)
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
/** Image viewer glyph: a picture frame with a sun and a mountain. */
+5
View File
@@ -13,6 +13,7 @@ import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { createSidebarStore } from './state.ts'
import { createBetterSidebarService } from './service.ts'
import { resetChunks } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
@@ -103,6 +104,10 @@ export function apply(ctx: Context): void {
}
}
try {
// Fresh chunk state for this activation: invalidate any chunk factories
// registered by a previous fiber (HMR) and drop the in-memory load cache
// so the next lazy open re-fetches the current chunk scripts.
resetChunks()
ctx.effect(() => {
let disposed = false
let root: Root | undefined
+89
View File
@@ -0,0 +1,89 @@
/**
* Lazy chunk view wrapper: mounts a component that lives in a lazy chunk,
* showing a loading placeholder while the chunk script loads and an error +
* retry affordance on failure. Used by the built-in tab/viewer descriptors.
*
* Contract note: {@link lazyChunkComponent} returns a plain render-prop
* function — the descriptor contract is `component: (props) => ReactNode`,
* and the repo renders descriptors BOTH ways: Sidebar calls
* `descriptor.component(props)` directly, EditorHost renders it via
* `createElement`. The wrapper function body therefore contains no hooks;
* all state lives in the inner {@link LazyChunkView} component.
*/
import { createElement, useEffect, useState, type ComponentType, type ReactNode } from 'react'
import { loadChunk, type ChunkExports, type ChunkName } from './chunk-loader.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
interface LazyChunkViewProps<P> {
chunk: ChunkName
/** Module-level-stable selector (an inline lambda would re-trigger the effect). */
pick: (mod: ChunkExports) => ComponentType<P> | undefined
props: P
}
function LazyChunkView<P>({ chunk, pick, props }: LazyChunkViewProps<P>): ReactNode {
const [attempt, setAttempt] = useState(0)
const [state, setState] = useState<
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; Comp: ComponentType<any> }
>({ status: 'loading' })
useEffect(() => {
let cancelled = false
setState({ status: 'loading' })
loadChunk(chunk).then((mod) => {
if (cancelled) return
const Comp = pick(mod)
if (Comp === undefined) {
setState({ status: 'error', message: `[dsh-better-sidebar] chunk "${chunk}" is missing its component` })
return
}
setState({ status: 'ready', Comp })
}).catch((error: unknown) => {
if (cancelled) return
setState({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return () => { cancelled = true }
}, [chunk, pick, attempt])
if (state.status === 'loading') {
return <div className={css.editorPlaceholder}>{t('loading')}</div>
}
if (state.status === 'error') {
return (
<div className={css.editorError}>
<span>{state.message}</span>
<button
type="button"
className={css.terminalRetry}
onClick={() => { setAttempt(current => current + 1) }}
>
{t('terminalRetry')}
</button>
</div>
)
}
return createElement(state.Comp, props)
}
/**
* Build a descriptor-compatible lazy wrapper for a chunk-resident component.
* The returned function is the descriptor `component` itself: it returns an
* element and never calls hooks, so both invocation styles (plain function
* call and createElement/JSX render) work. `pick` must be a module-level
* function (stable identity) — an inline lambda would re-trigger the load
* effect on every render.
* @param chunk - the chunk name (see chunk-loader.ts).
* @param pick - select the component from the chunk's exports.
*/
export function lazyChunkComponent<P extends object>(
chunk: ChunkName,
pick: (mod: ChunkExports) => ComponentType<P> | undefined,
): (props: P) => ReactNode {
return (props: P) => createElement(
LazyChunkView as ComponentType<LazyChunkViewProps<P>>,
{ chunk, pick, props },
)
}
+52 -8
View File
@@ -119,8 +119,10 @@ const zh = {
nextSlide: '下一页',
zoom: '缩放',
zoomHint: 'Alt + 滚轮',
settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
settingsTasksTitle: '有新后台任务时自动展开任务管理页',
settingsTasksDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开任务管理页(每个新任务都会触发);关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
@@ -165,8 +167,8 @@ const zh = {
browserEmbedBlocked: '{host} 拒绝了嵌入请求',
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
browserEmbedAnyway: '仍然加载',
subagent: '子代理',
openSubagent: '子代理',
subagent: '任务管理',
openSubagent: '任务管理',
subagentMainAgent: '主代理',
subagentEmpty: '暂无子代理',
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
@@ -180,6 +182,26 @@ const zh = {
subagentDiagUnsupported: '不支持的条目',
subagentDiagUnavailable: '不可用',
subagentThinking: '思考中…',
tasks: '后台命令',
tasksCount: '{count} 个后台命令',
tasksCountRunning: '{count} 个后台命令 · {running} 运行中',
taskStatusRunning: '运行中',
taskStatusStopping: '终止中',
taskStatusCompleted: '已完成',
taskStatusKilled: '已终止',
taskStatusFailed: '失败',
taskDurationSeconds: '{seconds} 秒',
taskDurationMinutes: '{minutes} 分 {seconds} 秒',
taskDurationHours: '{hours} 小时 {minutes} 分',
taskViewOutput: '查看输出',
taskHideOutput: '收起输出',
taskNoOutput: '暂无输出',
taskNotReadYet: '等待模型读取该任务的输出(模型执行 task_output 后,输出会显示在这里)',
taskOutputTruncated: '输出过长,已截断显示',
taskOutputError: '输出读取失败',
taskKill: '终止',
taskKillConfirm: '再次点击确认终止',
taskKillError: '终止失败',
}
const en: Record<keyof typeof zh, string> = {
@@ -296,8 +318,10 @@ const en: Record<keyof typeof zh, string> = {
nextSlide: 'Next',
zoom: 'Zoom',
zoomHint: 'Alt + wheel',
settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears',
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
settingsTasksTitle: 'Auto-open the Tasks page on a new background task',
settingsTasksDesc: 'Expand the side card and open the Tasks page whenever a new background task appears for the current conversation (every new task triggers); turn off to open it manually',
settingsToolsTitle: 'Inject terminal tools for the model',
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
@@ -342,8 +366,8 @@ const en: Record<keyof typeof zh, string> = {
browserEmbedBlocked: '{host} refused to be embedded',
browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.',
browserEmbedAnyway: 'Load anyway',
subagent: 'Subagents',
openSubagent: 'Subagents',
subagent: 'Tasks',
openSubagent: 'Tasks',
subagentMainAgent: 'Main agent',
subagentEmpty: 'No subagents',
subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
@@ -357,6 +381,26 @@ const en: Record<keyof typeof zh, string> = {
subagentDiagUnsupported: 'Unsupported',
subagentDiagUnavailable: 'Unavailable',
subagentThinking: 'Thinking…',
tasks: 'Background tasks',
tasksCount: '{count} background tasks',
tasksCountRunning: '{count} background tasks · {running} running',
taskStatusRunning: 'Running',
taskStatusStopping: 'Stopping',
taskStatusCompleted: 'Completed',
taskStatusKilled: 'Killed',
taskStatusFailed: 'Failed',
taskDurationSeconds: '{seconds}s',
taskDurationMinutes: '{minutes}m {seconds}s',
taskDurationHours: '{hours}h {minutes}m',
taskViewOutput: 'View output',
taskHideOutput: 'Hide output',
taskNoOutput: 'No output yet',
taskNotReadYet: 'Waiting for the model to read this task; its output appears here once the model runs task_output',
taskOutputTruncated: 'Output truncated',
taskOutputError: 'Failed to read output',
taskKill: 'Kill',
taskKillConfirm: 'Click again to confirm kill',
taskKillError: 'Kill failed',
}
/** Translate a copy key in the browser's language (zh → zh, else en). */
+40
View File
@@ -0,0 +1,40 @@
/**
* Shared pieces of the Office preview views (docx / xlsx): the load-state
* union, the shared props, and the download fallback. The two views live in
* their own modules (docx-view.tsx / xlsx-view.tsx) so the bundler can emit
* them as separate lazy chunks — docx-preview and the Univer family (several
* MB) must never share a script (see docs/plans/2026-08-12-lazy-chunks-design.md).
*/
import { downloadUrl, type SessionScope } from './api.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** Loading / ready / error state shared by both views. */
export type LoadState =
| { status: 'loading' }
| { status: 'ready' }
| { status: 'error'; message: string }
/** Shared props. */
export interface OfficeViewProps {
scope: SessionScope
path: string
title: string
}
/**
* The shared error / fallback affordance: the failure reason plus a download
* link, so the user always has a path to the file. Used by both DocxView and
* XlsxView, and matches the binary placeholder download link.
*/
export function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
const { scope, path, message } = props
return (
<div className={css.editorBinary}>
<span className={css.editorBinaryNotice}>{message}</span>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
)
}
+10 -247
View File
@@ -1,249 +1,12 @@
/**
* Office preview components: {@link DocxView} renders .docx via docx-preview
* (preserved styles/images/tables), {@link XlsxView} renders .xlsx via the
* Univer sheets preset (data + formulas + formatting). Both load the file
* bytes through the existing /sidebar/file media route and own their library
* lifecycle (dispose on unmount so canvases/workers don't leak — mirrors the
* TerminalView dispose discipline).
*
* Errors degrade to the same download-button affordance the binary
* placeholder uses, so a corrupted / encrypted / oversized file always
* leaves the user with a way to get the file.
*
* Both libraries pull in several MB; tsdown's `codeSplitting: false` inlines
* them into the single client bundle (the CJS __ModuleLoader__ wrapper's
* `require` cannot load relative chunk URLs in the browser, so dynamic
* import() collapses to a synchronous inline — see tsdown.config.ts).
* Compat re-export shim for the historical `src/client/office-view.tsx`
* path. The office preview views were split into docx-view.tsx /
* xlsx-view.tsx (+ office-shared.tsx) so the bundler can emit each library
* as its own lazy chunk (see docs/plans/2026-08-12-lazy-chunks-design.md).
* This module keeps deep imports of this path resolvable through the
* `./src/*` exports subpath — it deliberately re-exports BOTH views, so it
* must never be imported from the core bundle or a chunk entry (it would
* drag both libraries into that bundle).
*/
import { useEffect, useRef, useState } from 'react'
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
import { t } from './locales.ts'
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
import css from './sidebar.module.css'
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
// are present before the first .xlsx opens.
import '@univerjs/preset-sheets-core/lib/index.css'
/** Loading / ready / error state shared by both views. */
type LoadState =
| { status: 'loading' }
| { status: 'ready' }
| { status: 'error'; message: string }
/** Shared props. */
interface OfficeViewProps {
scope: SessionScope
path: string
title: string
}
/**
* Render a .docx file via docx-preview. The library renders into a container
* div (no canvas); images and styles are inlined. Unmounting clears the
* container's innerHTML — docx-preview has no dispose API, but tearing down
* the DOM is enough.
*/
export function DocxView(props: OfficeViewProps): JSX.Element {
const { scope, path, title } = props
const viewportRef = useRef<HTMLDivElement>(null)
const wrapRef = useRef<HTMLDivElement>(null)
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
const [zoom, setZoom] = useState(100)
useEffect(() => {
let cancelled = false
const container = viewportRef.current
const wrap = wrapRef.current
if (container === null || wrap === null) return
setZoom(100)
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path))
if (cancelled) return
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const buf = await response.arrayBuffer()
if (cancelled) return
// docx-preview ships its own CSS through the className option; the
// wrapper div scopes its render output.
const { renderAsync } = await import('docx-preview')
await renderAsync(buf, wrap, undefined, {
className: 'docx',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
breakPages: true,
experimental: false,
})
if (!cancelled) setLoad({ status: 'ready' })
} catch (error) {
if (!cancelled) {
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
}
})()
return () => {
cancelled = true
// Tear down the rendered DOM so a reopen starts clean.
if (wrap !== null) wrap.innerHTML = ''
}
}, [scope.sessionId, scope.cwd, path])
useEffect(() => {
const viewport = viewportRef.current
if (viewport === null) return
const onWheel = (event: WheelEvent): void => {
if (!event.altKey) return
event.preventDefault()
const delta = event.deltaY < 0 ? 10 : -10
setZoom(current => Math.max(50, Math.min(200, current + delta)))
}
viewport.addEventListener('wheel', onWheel, { passive: false })
return () => { viewport.removeEventListener('wheel', onWheel) }
}, [])
return (
<div className={css.editorDocx}>
<div className={css.editorDocxViewport} ref={viewportRef}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
{load.status !== 'error' && (
<div
className={css.editorDocxWrap}
ref={wrapRef}
aria-label={title}
style={{ zoom: zoom / 100 }}
/>
)}
</div>
<div className={css.editorDocxZoom}>
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
<input
className={css.editorDocxZoomRange}
type="range"
min={50}
max={200}
step={10}
value={zoom}
aria-label={t('zoom')}
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
/>
<span className={css.editorDocxZoomValue}>{zoom}%</span>
</div>
</div>
)
}
/**
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
* — without it the canvas, workers, and DOM listeners leak (mirrors the
* xterm dispose discipline in TerminalView).
*/
export function XlsxView(props: OfficeViewProps): JSX.Element {
const { scope, path, title } = props
const hostRef = useRef<HTMLDivElement>(null)
const univerRef = useRef<{ dispose: () => void } | null>(null)
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
useEffect(() => {
let cancelled = false
const host = hostRef.current
if (host === null) return
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path))
if (cancelled) return
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const buf = await response.arrayBuffer()
if (cancelled) return
// Dynamic imports — collapsed into the bundle by codeSplitting:false.
// The dynamic form keeps the source readable: each lib is only pulled
// in when an .xlsx is actually opened (semantically; the bundle still
// contains all of them).
const XLSX = await import('xlsx')
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
// Locales pick the browser language; falls back to en-US.
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
const localePack = await (isZh
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
const wb = XLSX.read(buf, { type: 'array' })
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
if (cancelled) return
const { univer, univerAPI } = createUniver({
locale,
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
presets: [UniverSheetsCorePreset({ container: host })],
})
univerRef.current = univer
univerAPI.createWorkbook(workbookData)
if (!cancelled) setLoad({ status: 'ready' })
} catch (error) {
if (!cancelled) {
try {
univerRef.current?.dispose()
} catch {
// Partially initialized instance — ignore disposal errors.
}
univerRef.current = null
host.innerHTML = ''
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
}
})()
return () => {
cancelled = true
// Critical: dispose the Univer instance (canvas + workers + listeners).
try {
univerRef.current?.dispose()
} catch {
// Already torn down — ignore.
}
univerRef.current = null
// Clear the host in case dispose left DOM behind.
if (host !== null) host.innerHTML = ''
}
}, [scope.sessionId, scope.cwd, path])
return (
<div className={css.editorXlsx} aria-label={title}>
{/* Univer exclusively owns this element's descendants. React only owns
the sibling overlay, so Univer cannot remove a React child. */}
<div className={css.editorUniverHost} ref={hostRef} />
{load.status !== 'ready' && (
<div className={css.editorOfficeOverlay}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
</div>
)}
</div>
)
}
/**
* The shared error / fallback affordance: the failure reason plus a download
* link, so the user always has a path to the file. Used by both DocxView and
* XlsxView, and matches the binary placeholder download link.
*/
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
const { scope, path, message } = props
return (
<div className={css.editorBinary}>
<span className={css.editorBinaryNotice}>{message}</span>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
)
}
export { DocxView } from './docx-view.tsx'
export { XlsxView } from './xlsx-view.tsx'
+3
View File
@@ -40,6 +40,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
? record.autoOpenSubagent
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
autoOpenTasks: typeof record.autoOpenTasks === 'boolean'
? record.autoOpenTasks
: SIDEBAR_PREFS_DEFAULTS.autoOpenTasks,
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
+39 -3
View File
@@ -22,9 +22,10 @@
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab,
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, togglePanel, treeOf,
type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts'
import { isNarrowWidth } from './breakpoints.ts'
import type { SessionScope } from './api.ts'
/** One declarative boolean setting of a tab/viewer, rendered as a nested
@@ -198,6 +199,13 @@ export interface BetterSidebarService {
* pre-set to the URL (the browser tab's navigation seed; the caller
* usually pairs it with a hostname `title`). A disabled tab type is a
* no-op.
*
* A CONTENT open (a `path` or `url` seed) must land in sight: when the
* panel hosting the landing pane is collapsed, it is expanded
* automatically (the right panel by default, the bottom panel when the
* active pane lives there; on narrow viewports the merged drawer opens).
* Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
* the panel behavior is their caller's business.
*/
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
/** Close a tab by id. */
@@ -335,13 +343,41 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
// A URL seed pre-fills the tab's path (the browser tab navigates to it
// on mount). An explicit seed.title also wins over a createTab-minted
// default title (e.g. the sidebar-browser's hostname title).
let landed: SidebarState
if (seed.url !== undefined) {
return patchTab(next, tab.id, {
landed = patchTab(next, tab.id, {
path: seed.url,
...(seed.title !== undefined ? { title: seed.title } : {}),
})
} else {
landed = next
}
return next
// A CONTENT open (file / browser) must land in sight: when the panel
// hosting the landing pane is collapsed, expand it. On narrow
// viewports the two workbenches merge into one drawer, so the drawer
// (panelOpen) is the only lever; on wide viewports the landing pane's
// own panel opens — the bottom panel when the active pane lives in the
// bottom tree, else the right panel. Type-only opens (+ menu,
// agent-terminal auto-tabs) never expand (the panel behavior is their
// caller's business). The check runs on the post-dedupe state, so a
// content open that merely FOCUSES an existing tab expands the panel
// too — the open must never land out of sight.
if (
typeof window !== 'undefined'
&& (seed.path !== undefined || seed.url !== undefined)
) {
if (isNarrowWidth(window.innerWidth)) {
if (!landed.panelOpen) return togglePanel(landed)
} else {
const hostKey = treeOf(landed, landed.activePane ?? '')
if (hostKey === 'bottomSplits') {
if (!landed.bottomOpen) return { ...landed, bottomOpen: true }
} else if (!landed.panelOpen) {
return togglePanel(landed)
}
}
}
return landed
})
}
+25
View File
@@ -1864,3 +1864,28 @@
animation: none;
}
}
/* ── Narrow (mobile) viewport ──────────────────────────────────────────── */
/* Pairs with NARROW_MAX_WIDTH = 768 in src/client/breakpoints.ts: widths
strictly below 768px (i.e. max-width: 767px) switch the sidebar to the
mobile layout — one full-screen drawer (the bottom panel's tabs were
migrated into the right tree), one toggle button. The structural
switches (which panels render, the migration) are JS-driven via
useNarrowViewport; this block only adapts the pure style details. */
@media (max-width: 767px) {
/* The toggle cluster holds a single button on narrow viewports (the
bottom panel button is not rendered), so the open panel's tab strip
reserves 40px instead of the desktop 72px — the tabs and + menu still
yield space to the one remaining control. */
.panel:not(.panelHidden) .tabBar {
padding-right: 40px;
}
/* Narrower tabs: a phone-width drawer fits more open tabs before the
strip scrolls. */
.tab {
min-width: 48px;
max-width: 128px;
}
}
+47 -2
View File
@@ -10,6 +10,7 @@
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
*/
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
import { isNarrowWidth } from './breakpoints.ts'
/**
* Tab type identifier. Builtins register their ids (explorer / git / editor
@@ -205,6 +206,44 @@ export function firstLeaf(node: SplitNode): SidebarLeaf {
return firstLeaf(node.children[0]!)
}
/** Empty every leaf of a tree (the bottom tree after its tabs migrate out). */
function clearAllTabs(node: SplitNode): SplitNode {
if (node.kind === 'leaf') return { ...node, tabs: [], active: null }
return { ...node, children: node.children.map(clearAllTabs) }
}
/**
* Narrow-viewport migration: the bottom panel's tabs are thrown INTO the
* right sidebar — the "merged display" on mobile is the right panel alone,
* whose tab strips now carry the bottom tree's tabs (depth-first order,
* appended to the right tree's FIRST leaf). The bottom tree is emptied (its
* structure stays — the desktop bottom panel re-renders its welcome cards)
* and the panel closes. The active pane moves to the right tree's first
* leaf so every new tab lands in the visible panel.
*
* Idempotent: a bottom tree with no tabs and a closed panel returns the
* same reference. Runs when the viewport enters narrow (see the Sidebar
* shell); migrating is permanent for the session — the tabs now live in the
* right tree, exactly like the user "threw them in".
*/
export function migrateBottomTabs(state: SidebarState): SidebarState {
const bottomTabs = allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
const activeInBottom = state.activePane !== null && treeHasId(state.bottomSplits, state.activePane)
if (bottomTabs.length === 0 && !state.bottomOpen && !activeInBottom) return state
const target = firstLeaf(state.splits)
return {
...state,
activePane: target.id,
bottomOpen: false,
splits: bottomTabs.length > 0
? mapLeaf(state.splits, target.id, leaf => {
leaf.tabs = [...leaf.tabs, ...bottomTabs]
})
: state.splits,
bottomSplits: bottomTabs.length > 0 ? clearAllTabs(state.bottomSplits) : state.bottomSplits,
}
}
/** Find the leaf containing a tab id, if any. */
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
if (node.kind === 'leaf') {
@@ -721,12 +760,18 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
// chosen percent of the window (clamped to the panel floor and the
// viewport so a huge percent can never crush the app shell), the panel
// starts open only when the preference says so, and the default explorer
// tab is skipped when the user disabled the explorer tab type.
// tab is skipped when the user disabled the explorer tab type. On a
// NARROW viewport a brand-new session starts collapsed instead — the
// panel is a full-screen drawer there, and auto-opening it on first
// paint would cover the conversation before the user asked. Only the
// first seeding is affected: once the user expands the drawer,
// `panelOpen: true` persists like any other state.
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
const width = viewport === undefined
? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
return makeDefaultState(width, prefs.openByDefault, prefs.tabsEnabled['explorer'] !== false)
const openByDefault = prefs.openByDefault && (viewport === undefined || !isNarrowWidth(viewport))
return makeDefaultState(width, openByDefault, prefs.tabsEnabled['explorer'] !== false)
}
/**
+161
View File
@@ -0,0 +1,161 @@
/**
* Pure derivations for the Subagent page's background-task section. Kept
* framework-free so the node test environment can unit-test them: the task
* rows arrive through the harness `session/tasks` push mirror
* (`tasksBySession` in the sessions list feed) — nothing here issues
* requests, and the row ordering / status mapping mirror the official
* ui-task header list.
*/
import type {
SidebarSessionList,
SidebarSessionSummary,
SidebarTaskStatus,
SidebarTaskView,
} from '../context-types.ts'
import type { CopyKey } from './locales.ts'
/** One row of the tasks section: the task plus its owning session's title. */
export interface TreeTask {
ownerSessionId: string
ownerTitle: string
task: SidebarTaskView
}
/** Whether the registry still holds the task open (its duration ticks). */
export function isTaskLive(task: SidebarTaskView): boolean {
return task.status === 'running' || task.status === 'stopping'
}
/**
* Every session id of the topology tree rooted at `rootId` (the root plus
* each session whose uninterrupted subagent-origin chain reaches it — same
* lineage semantics as {@link countSubagentDescendants}; cycles fail soft).
* Sessions outside the tree (orphans, other trees) are excluded, so the
* tasks section never shows foreign work.
*/
export function treeSessionIds(
byId: SidebarSessionList['byId'],
rootId: string | undefined,
): Set<string> {
const ids = new Set<string>()
if (rootId === undefined) return ids
for (const summary of Object.values(byId)) {
const seen = new Set<string>()
let current: SidebarSessionSummary | undefined = summary
let reachesRoot = false
while (current !== undefined && !seen.has(current.id)) {
seen.add(current.id)
if (current.id === rootId) {
reachesRoot = true
break
}
if (current.origin !== 'subagent' || current.parentId === undefined) break
current = byId[current.parentId]
}
if (reachesRoot) ids.add(summary.id)
}
return ids
}
/**
* Whether a NEW background task appeared for one session between two
* consecutive list snapshots (a task id the previous snapshot lacked).
* Unlike the subagent auto-open (0 → N only), ANY new task id triggers: the
* agent may start several tasks over a session, and each new one should
* surface the Tasks page (a fresh page load never triggers — its baseline
* starts at the current snapshot).
*/
export function detectNewTask(
prev: SidebarSessionList,
next: SidebarSessionList,
sessionId: string,
): boolean {
const prevIds = new Set((prev.tasksBySession?.[sessionId] ?? []).map(task => task.id))
return (next.tasksBySession?.[sessionId] ?? []).some(task => !prevIds.has(task.id))
}
/**
* Collect the background tasks of the whole current tree, owner-labeled.
* Sessions without a mirror entry contribute nothing; an absent mirror
* (runtime older than the tasks feed) yields an empty list.
*/
export function collectTreeTasks(
byId: SidebarSessionList['byId'],
tasksBySession: Readonly<Record<string, readonly SidebarTaskView[]>> | undefined,
rootId: string | undefined,
): TreeTask[] {
const rows: TreeTask[] = []
if (tasksBySession === undefined) return rows
for (const sessionId of treeSessionIds(byId, rootId)) {
const tasks = tasksBySession[sessionId]
if (tasks === undefined || tasks.length === 0) continue
const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId
for (const task of tasks) rows.push({ ownerSessionId: sessionId, ownerTitle, task })
}
return rows
}
/**
* Live rows first in start order, then settled rows newest-first (mirror of
* the official ui-task ordering); a tie falls back to start order so the
* sort never depends on the host's map iteration.
*/
export function orderTasks(rows: readonly TreeTask[]): TreeTask[] {
return [...rows].sort((left, right) => {
const liveLeft = isTaskLive(left.task)
if (liveLeft !== isTaskLive(right.task)) return liveLeft ? -1 : 1
if (liveLeft) return left.task.startedAt - right.task.startedAt
const finished = (right.task.finishedAt ?? right.task.startedAt) - (left.task.finishedAt ?? left.task.startedAt)
return finished !== 0 ? finished : left.task.startedAt - right.task.startedAt
})
}
/** The sidebar's StateDot states for the five wire statuses. */
export type TaskDotState = 'ongoing' | 'warning' | 'done' | 'error'
/**
* Status marker semantics. `stopping` and `killed` share the attention
* color: both mean the work ended (or is ending) on request rather than on
* its own.
*/
export function taskDotState(status: SidebarTaskStatus): TaskDotState {
switch (status) {
case 'running': return 'ongoing'
case 'stopping': return 'warning'
case 'completed': return 'done'
case 'killed': return 'warning'
case 'failed': return 'error'
}
}
/** Human status word of one wire status (localized through the passed translator). */
export function taskStatusLabel(
status: SidebarTaskStatus,
t: (key: CopyKey, params?: Record<string, string | number>) => string,
): string {
switch (status) {
case 'running': return t('taskStatusRunning')
case 'stopping': return t('taskStatusStopping')
case 'completed': return t('taskStatusCompleted')
case 'killed': return t('taskStatusKilled')
case 'failed': return t('taskStatusFailed')
}
}
/**
* Elapsed time in at most two adjacent units (mirror of the official
* ui-task duration wording). A background task that outlives an hour is
* already exceptional, so hours is the widest unit.
*/
export function formatTaskDuration(
elapsedMs: number,
t: (key: CopyKey, params?: Record<string, string | number>) => string,
): string {
const total = Math.max(0, Math.floor(elapsedMs / 1_000))
const seconds = total % 60
const minutes = Math.floor(total / 60) % 60
const hours = Math.floor(total / 3_600)
if (hours > 0) return t('taskDurationHours', { hours, minutes })
if (minutes > 0) return t('taskDurationMinutes', { minutes, seconds })
return t('taskDurationSeconds', { seconds })
}
+116
View File
@@ -0,0 +1,116 @@
/**
* The .xlsx preview component: renders via the Univer sheets preset (data +
* formulas + formatting). Lives in its own module (split out of the former
* office-view.tsx) so it builds as a standalone lazy chunk
* (`lib/client-xlsx.js`) — the Univer family and SheetJS pull in several MB
* but are loaded only when a .xlsx is first opened. The docx stack never
* shares this script.
*
* Errors degrade to the shared download-button affordance, so a corrupted /
* encrypted / oversized file always leaves the user with a way to get it.
*/
import { useEffect, useRef, useState } from 'react'
import { mediaUrl } from './api.ts'
import { t } from './locales.ts'
import { BinaryFallback, type LoadState, type OfficeViewProps } from './office-shared.tsx'
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
import css from './sidebar.module.css'
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
// are present before the first .xlsx opens.
import '@univerjs/preset-sheets-core/lib/index.css'
/**
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
* — without it the canvas, workers, and DOM listeners leak (mirrors the
* xterm dispose discipline in TerminalView).
*/
export function XlsxView(props: OfficeViewProps): JSX.Element {
const { scope, path, title } = props
const hostRef = useRef<HTMLDivElement>(null)
const univerRef = useRef<{ dispose: () => void } | null>(null)
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
useEffect(() => {
let cancelled = false
const host = hostRef.current
if (host === null) return
void (async () => {
try {
const response = await fetch(mediaUrl(scope, path))
if (cancelled) return
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const buf = await response.arrayBuffer()
if (cancelled) return
// Dynamic imports — collapsed into the chunk by codeSplitting:false.
// The dynamic form keeps the source readable: each lib is only pulled
// in when an .xlsx is actually opened (semantically; the chunk still
// contains all of them — the chunk itself only loads on first .xlsx).
const XLSX = await import('xlsx')
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
// Locales pick the browser language; falls back to en-US.
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
const localePack = await (isZh
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
const wb = XLSX.read(buf, { type: 'array' })
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
if (cancelled) return
const { univer, univerAPI } = createUniver({
locale,
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
presets: [UniverSheetsCorePreset({ container: host })],
})
univerRef.current = univer
univerAPI.createWorkbook(workbookData)
if (!cancelled) setLoad({ status: 'ready' })
} catch (error) {
if (!cancelled) {
try {
univerRef.current?.dispose()
} catch {
// Partially initialized instance — ignore disposal errors.
}
univerRef.current = null
host.innerHTML = ''
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
}
})()
return () => {
cancelled = true
// Critical: dispose the Univer instance (canvas + workers + listeners).
try {
univerRef.current?.dispose()
} catch {
// Already torn down — ignore.
}
univerRef.current = null
// Clear the host in case dispose left DOM behind.
if (host !== null) host.innerHTML = ''
}
}, [scope.sessionId, scope.cwd, path])
return (
<div className={css.editorXlsx} aria-label={title}>
{/* Univer exclusively owns this element's descendants. React only owns
the sibling overlay, so Univer cannot remove a React child. */}
<div className={css.editorUniverHost} ref={hostRef} />
{load.status !== 'ready' && (
<div className={css.editorOfficeOverlay}>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
</div>
)}
</div>
)
}
+1
View File
@@ -79,6 +79,7 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
openByDefault: z.boolean().default(true),
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true),
autoOpenTasks: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false),
bottomPanelAutoTerminal: z.boolean().default(true),
interceptOpenPath: z.boolean().default(true),
+70 -1
View File
@@ -48,7 +48,15 @@ export interface SidebarSessionHeader {
/** The host session store face (`ctx.sessions.get(id)` returns the live session). */
export interface SidebarSessionStore {
get(id: string): { header: SidebarSessionHeader } | undefined
get(id: string): {
header: SidebarSessionHeader
/**
* The live session's append-only event log (immutable snapshot; absent
* on sessions the runtime has not hydrated). Read-only access — the
* tasks.output route replays `task_output` tool/result rows from it.
*/
events?: readonly SidebarSessionEvent[]
} | undefined
}
/** One loader entry's options slice (the connection row's resolved config). */
@@ -150,6 +158,42 @@ export interface SidebarHistoryEntry {
view?: unknown
}
/** Lifecycle status set of one background task (closed wire union). */
export type SidebarTaskStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
/**
* One background task as the client mirror sees it (wire `TaskView` shape:
* id/kind/label/status/detail?/startedAt/finishedAt?).
*/
export interface SidebarTaskView {
/** Registry-issued `<kind>-N` identity, stable for the task's whole life. */
id: string
/** Producer kind (`bash`, `pwsh`, `subagent`, …; open string by design). */
kind: string
/** Producer-supplied one-line label: the command, or the delegation description. */
label: string
/** Current lifecycle state. */
status: SidebarTaskStatus
/** Kind-specific status detail ('exit code: 3'), present once supplied. */
detail?: string
/** Epoch ms when the task was registered. */
startedAt: number
/** Epoch ms when the task settled; absent while live. */
finishedAt?: number
}
/** The host tasks registry face the sidebar routes touch (structural mirror of `TaskService`). */
export interface SidebarTasksService {
/** Request cancellation; throws for an unknown or foreign task. */
kill(id: string, caller?: SidebarAgent, reason?: string): 'requested' | 'already-finished'
}
/** The host agent registry face (structural mirror of the runtime `ctx.agents`). */
export interface SidebarAgentsService {
/** The live agent registered under a session id, or undefined when not live. */
get(id: string): SidebarAgent | undefined
}
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
@@ -177,6 +221,12 @@ export interface SidebarSessionList {
byId: Record<string, SidebarSessionSummary>
/** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>
/**
* Background tasks per session, last-wins from the harness's `session/tasks`
* push (a missing key is an empty set). Absent on runtime snapshots older
* than the tasks mirror — the sidebar simply shows no task rows.
*/
tasksBySession?: Readonly<Record<string, readonly SidebarTaskView[]>>
}
/** The client sessions service face (only the list feed is needed). */
@@ -322,12 +372,31 @@ declare module 'cordis' {
settings: SidebarSettingsService
invariants: SidebarInvariantsService
tools: SidebarToolsService
/**
* The host background-task registry (`ctx.get('tasks')`; optional — the
* sidebar routes degrade to a 503 when the deployment lacks it).
*/
tasks: SidebarTasksService
/**
* The host live-agent registry (`ctx.get('agents')`; optional — used to
* resolve the caller the tasks fence compares against).
*/
agents: SidebarAgentsService
/**
* The client-side sidebar registry: external plugins register tab types
* and file previewers here. Provided by the client half (see
* {@link ./client/index.tsx}); undefined on the host side.
*/
betterSidebar: BetterSidebarService
/**
* Subscribe to the session append feed (mirror of the cordis event API):
* the listener receives every appended session event with the LIVE
* Session instance that appended it. The api-proxy pushes the same feed
* to browsers; the sidebar uses it to mirror task_output events the
* session store's own log can lag behind (restart divergence). Returns
* the disposer.
*/
on(event: string, listener: (session: unknown, event: SidebarSessionEvent) => void): () => void
/**
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
* activation; its returned cleanup runs at disposal.
+29 -6
View File
@@ -1,11 +1,12 @@
/**
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
* read/write, git), the /sidebar/file media route (images), and the terminal
* WebSocket upgrade. Every route passes the same browser-trust fence as the
* /api gateway — Host-header loopback or the connection row's `trustedHosts`
* (the `dsh web` launcher derives LAN IP literals per boot) — with the
* trustedHosts read live from the connection loader row so the fence never
* drifts from the deployment's.
* read/write, git), the /sidebar/file media route (images), the /sidebar/html
* preview route, the /sidebar/bundle lazy-chunk route (client code splits),
* and the terminal WebSocket upgrade. Every route passes the same
* browser-trust fence as the /api gateway — Host-header loopback or the
* connection row's `trustedHosts` (the `dsh web` launcher derives LAN IP
* literals per boot) — with the trustedHosts read live from the connection
* loader row so the fence never drifts from the deployment's.
*
* All operations are conversation-scoped: requests carry a sessionId, the
* session's authoritative cwd comes from the session store, and terminal
@@ -29,11 +30,13 @@ import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from '.
import { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts'
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
import { registerBundleRoute } from './bundle-route.ts'
import * as git from './git.ts'
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
import { registerTools } from './tools.ts'
import { buildTasksApi, type SidebarTasksRoutes } from './tasks-routes.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
export { Config }
@@ -196,6 +199,12 @@ function buildApi(
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
}
// Background tasks: the LIST rides the harness's `session/tasks` push
// mirror, so these routes only replay output the model has read (from the
// session's own event log — no DSH source is touched, the model's
// task_output cursor is never consumed) and kill (the registry's stock
// API). A deployment without the tasks registry downgrades kill to a 503.
const tasksApi: SidebarTasksRoutes = buildTasksApi(ctx, resolved.readLimit)
return {
'session.cwd': (payload) => {
const { sessionId, cwd } = cwdOf(payload)
@@ -325,6 +334,14 @@ function buildApi(
agentPtyRegistry.close(uuid)
return { ok: true }
},
// Background tasks: read one task's output (a REPLAY of what the model
// has read so far, from the owner session's event log — the model's
// task_output cursor is never touched, so the human pane can never steal
// the agent's bytes), and kill one task. The task LIST itself arrives
// through the harness's session/tasks push mirror, so no list route
// exists. Kill is fenced to the owning session by the tasks registry.
'tasks.output': (payload) => tasksApi.output(payload),
'tasks.kill': (payload) => tasksApi.kill(payload),
// The side card preferences. The settings service is optional in the
// composition; while absent the routes report undefined and the client
// keeps the schema defaults. Writes are revision-guarded: a stale editor
@@ -517,6 +534,12 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
},
}), 'dsh-better-sidebar: /sidebar/api routes')
// ── Lazy chunk route (client bundle splits) ─────────────────────────────
// Serves the client half's split bundles (lib/client-<name>.js) so the
// heavy preview/terminal libraries load on first use, not at page start
// (see bundle-route.ts / src/client/chunk-loader.ts).
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
// ── Media route (images for the editor) ─────────────────────────────────
ctx.effect(() => ctx.httpServer.register({
kind: 'prefix',
+7
View File
@@ -20,6 +20,12 @@ export interface SidebarPrefs {
* Subagent page when the current conversation spawns a new subagent.
*/
autoOpenSubagent: boolean
/**
* Whether the sidebar auto-activates (opens the panel) and expands the
* Tasks page when a NEW background task appears for the current
* conversation (any new task id, not just the first one).
*/
autoOpenTasks: boolean
/**
* Whether the model-facing agent terminal tools (terminal_create / list /
* send / read / wait_for / resize / signal / close) are injected into the
@@ -103,6 +109,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
+257
View File
@@ -0,0 +1,257 @@
/**
* Background-task routes of the /sidebar JSON API ('tasks.output' /
* 'tasks.kill'). The task LIST needs no route: it arrives through the
* harness's `session/tasks` push mirror (`tasksBySession` in the sessions
* list feed). The routes:
*
* - 'tasks.output' — REPLAYS the output the MODEL has read so far for one
* task. The source is the owner session's own event log: `tool/call` rows
* of `task_output` name the task via `arguments.task_id`, and the paired
* `tool/result` rows carry the finalized content the model received.
* Because the session store's in-memory log can lag the live append feed
* after a host restart (the store session stays frozen at its
* rehydration boundary), the plugin ALSO mirrors task_output events from
* the live `session/event` feed and merges both sources (deduped by seq).
* This touches NO DSH source: the model's `task_output` cursor is never
* consumed, and the pane stays empty until the agent reads the task.
* - 'tasks.kill' — the registry's stock `kill` (a pristine DSH API),
* fenced by the owning session via the live agent caller. Absent registry
* → 503, mirroring the settings routes' optional-service downgrade.
*/
import type { Context, SidebarSessionEvent } from './context-types.ts'
import { requireString, SidebarError } from './wire.ts'
/** The two background-task routes of the sidebar API. */
export interface SidebarTasksRoutes {
/** The output the model has read so far for one task (event replay, capped). */
output(payload: unknown): { text: string; truncated: boolean; read: boolean }
/** Request cancellation of one task (live tasks flip to stopping). */
kill(payload: unknown): { ok: true; outcome: 'requested' | 'already-finished' }
}
/** The 'tool/result' message envelope inside a session event's data. */
interface ToolResultMessageLike {
source?: { kind?: unknown; callId?: unknown }
content?: unknown
}
/** One 'tool-result' content block (the inner blocks carry the text). */
interface ToolResultBlockLike {
type?: unknown
content?: unknown
isError?: unknown
}
/**
* Extract the plain text of a finalized tool result: the text blocks inside
* the 'tool-result' block, joined with newlines. Error results and
* non-text blocks contribute nothing.
*/
function resultText(message: ToolResultMessageLike): string | undefined {
if (!Array.isArray(message.content)) return undefined
const parts: string[] = []
for (const block of message.content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as ToolResultBlockLike
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.length > 0 ? parts.join('\n') : undefined
}
/** Whether a tool/result is an error result (the inner block's isError flag). */
function resultIsError(message: ToolResultMessageLike): boolean {
if (!Array.isArray(message.content)) return false
return message.content.some((block) => {
if (block === null || typeof block !== 'object') return false
return (block as ToolResultBlockLike).type === 'tool-result'
&& (block as ToolResultBlockLike).isError === true
})
}
/** Whether a task_output result carries no new output — the controller's
* model-facing "(no new output)" body, noise for the human pane. */
function isNoNewOutput(text: string): boolean {
return text.startsWith('(no new output)')
}
/** One compact task_output trace (a tool/call or its paired tool/result). */
interface TaskOutputTrace {
seq: number
kind: 'call' | 'result'
/** The tool call identity pairing the two rows. */
callId: string
/** tool/call: the task id parsed from the model arguments. */
taskId?: string
/** tool/result: the finalized text the model received. */
text?: string
/** tool/result: whether the result was an error (read counts, text skipped). */
isError?: boolean
}
/** Extract the task_output trace of one raw session event (undefined = unrelated). */
function traceOf(event: SidebarSessionEvent): TaskOutputTrace | undefined {
if (event.type === 'tool/call') {
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
if (data.name !== 'task_output' || typeof data.callId !== 'string') return undefined
let taskId: string | undefined
try {
const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { task_id?: unknown }
if (typeof args.task_id === 'string') taskId = args.task_id
} catch {
// Malformed model arguments: not a task_output pair.
}
if (taskId === undefined) return undefined
return { seq: event.seq, kind: 'call', callId: data.callId, taskId }
}
if (event.type === 'tool/result') {
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
if (message === undefined) return undefined
const callId = message.source?.callId
if (typeof callId !== 'string') return undefined
return {
seq: event.seq,
kind: 'result',
callId,
text: resultText(message),
isError: resultIsError(message),
}
}
return undefined
}
/** Per-session cap of mirrored live traces (a bounded, lossy ring). */
const MIRROR_MAX_ENTRIES = 200
/**
* The live task_output mirror: subscribes to the session append feed and
* caches the task_output traces the session store's own log can lag behind
* (after a host restart the store session stays frozen at its rehydration
* boundary, so `session.events` misses everything appended since — the very
* reads the pane exists to show). Zero DSH writes: the api-proxy pushes the
* same feed to browsers.
*/
function createTaskOutputMirror(ctx: Context): { entries(sessionId: string): readonly TaskOutputTrace[] } {
const perSession = new Map<string, TaskOutputTrace[]>()
// tool/call identities per session, so unrelated tool/result rows are
// never cached (only task_output results pair with a cached call).
const callIds = new Map<string, Set<string>>()
if (typeof ctx.on !== 'function') {
// Test doubles without the event API degrade to seed-only replay.
return { entries: () => [] }
}
const dispose = ctx.on('session/event', (session, event) => {
const sessionId = (session as { id?: unknown } | null)?.id
if (typeof sessionId !== 'string') return
if (event.type === 'tool/call') {
const trace = traceOf(event)
if (trace?.kind !== 'call') return
let ids = callIds.get(sessionId)
if (ids === undefined) callIds.set(sessionId, ids = new Set())
ids.add(trace.callId)
push(sessionId, trace)
} else if (event.type === 'tool/result') {
const trace = traceOf(event)
if (trace?.kind !== 'result') return
if (!callIds.get(sessionId)?.has(trace.callId)) return
push(sessionId, trace)
}
})
ctx.effect(() => dispose, 'dsh-better-sidebar: task-output event mirror')
const push = (sessionId: string, trace: TaskOutputTrace): void => {
let list = perSession.get(sessionId)
if (list === undefined) perSession.set(sessionId, list = [])
list.push(trace)
if (list.length > MIRROR_MAX_ENTRIES) {
const removed = list.splice(0, list.length - MIRROR_MAX_ENTRIES)
const ids = callIds.get(sessionId)
if (ids !== undefined) {
for (const entry of removed) {
if (entry.kind === 'call') ids.delete(entry.callId)
}
if (ids.size === 0) callIds.delete(sessionId)
}
}
}
return { entries: (sessionId) => perSession.get(sessionId) ?? [] }
}
/**
* Build the tasks routes bound to the plugin context. `output` merges the
* owner session's own event log with the live task_output mirror; `kill`
* reads the tasks/agents services lazily and degrades to a 503 when the
* deployment lacks the registry.
* @param ctx - host plugin context.
* @param outputLimit - response cap for one output replay in bytes; longer
* texts are sliced and flagged `truncated` (mirrors the fs.read cap).
*/
export function buildTasksApi(ctx: Context, outputLimit: number): SidebarTasksRoutes {
const tasks = ctx.get('tasks')
const agents = ctx.get('agents')
const mirror = createTaskOutputMirror(ctx)
/** The live caller whose session id the registry fence compares against. */
const callerOf = (sessionId: string) => agents?.get(sessionId)
/** Registry refusals become a 404 task-error; unknown and foreign ids are indistinguishable. */
const registryError = (error: unknown): SidebarError =>
new SidebarError('task-error', error instanceof Error ? error.message : String(error), 404)
return {
output(payload) {
const sessionId = requireString(payload, 'sessionId')
const id = requireString(payload, 'id')
// Merge the store's event log (durable seed + whatever it received)
// with the live mirror, deduped by seq — a trace never double-counts.
const bySeq = new Map<number, TaskOutputTrace>()
for (const event of ctx.sessions.get(sessionId)?.events ?? []) {
const trace = traceOf(event)
if (trace !== undefined) bySeq.set(trace.seq, trace)
}
for (const trace of mirror.entries(sessionId)) bySeq.set(trace.seq, trace)
// Pair calls with results in seq order: the model's reads, oldest first.
const taskOf = new Map<string, string>()
const parts: string[] = []
let read = false
for (const trace of [...bySeq.values()].sort((left, right) => left.seq - right.seq)) {
if (trace.kind === 'call') {
if (trace.taskId !== undefined) taskOf.set(trace.callId, trace.taskId)
} else if (taskOf.get(trace.callId) === id) {
read = true
if (trace.isError !== true && trace.text !== undefined && !isNoNewOutput(trace.text)) {
parts.push(trace.text)
}
}
}
const text = parts.join('\n')
return {
text: text.length > outputLimit ? text.slice(0, outputLimit) : text,
truncated: text.length > outputLimit,
read,
}
},
kill(payload) {
if (tasks === undefined) {
throw new SidebarError('task-error', 'the background-task registry is not mounted in this deployment', 503)
}
const sessionId = requireString(payload, 'sessionId')
const id = requireString(payload, 'id')
const record = payload as { reason?: unknown } | null
const reason = typeof record?.reason === 'string' && record.reason !== ''
? record.reason
: 'user requested via sidebar'
try {
return { ok: true, outcome: tasks.kill(id, callerOf(sessionId), reason) }
} catch (error) {
throw registryError(error)
}
},
}
}
+1
View File
@@ -15,6 +15,7 @@ export type SidebarErrorCode =
| 'fs-error'
| 'git-error'
| 'pty-error'
| 'task-error'
| 'settings-rejected'
| 'settings-conflict'
| 'internal'
+26
View File
@@ -0,0 +1,26 @@
/**
* Narrow-viewport breakpoint tests: the boundary the mobile sidebar layout
* keys off (paired with the CSS @media (max-width: 767px) gates in
* sidebar.module.css — 767px ≡ widths strictly below NARROW_MAX_WIDTH).
* Deliberately NOT aligned to the DSH app shell's 1024px breakpoint: the
* mobile layout is for real narrow screens (phones / portrait tablets).
*/
import { describe, expect, it } from 'vitest'
import { isNarrowWidth, NARROW_MAX_WIDTH } from '../src/client/breakpoints.ts'
describe('narrow-viewport breakpoint', () => {
it('treats widths below NARROW_MAX_WIDTH as narrow (mobile)', () => {
expect(NARROW_MAX_WIDTH).toBe(768)
expect(isNarrowWidth(320)).toBe(true)
expect(isNarrowWidth(390)).toBe(true)
expect(isNarrowWidth(600)).toBe(true)
expect(isNarrowWidth(767)).toBe(true)
})
it('treats NARROW_MAX_WIDTH and above as wide (desktop)', () => {
expect(isNarrowWidth(768)).toBe(false)
expect(isNarrowWidth(1024)).toBe(false)
expect(isNarrowWidth(1280)).toBe(false)
expect(isNarrowWidth(1920)).toBe(false)
})
})
+6 -1
View File
@@ -12,6 +12,8 @@ if (g.self === undefined) g.self = globalThis
const elementStub = (): Record<string, unknown> => ({
style: {},
dataset: {},
textContent: '',
classList: { add: () => {}, remove: () => {}, contains: () => false },
setAttribute: () => {},
getAttribute: () => null,
@@ -32,9 +34,12 @@ if (g.document === undefined) {
removeEventListener: () => {},
defaultView: globalThis,
body: elementStub(),
// CodeMirror's view module probes the UA and injects styles at load.
// CodeMirror's view module probes the UA and injects styles at load;
// the plugin's css-inline prologue probes for existing style tags.
documentElement: elementStub(),
head: elementStub(),
querySelector: () => null,
querySelectorAll: () => [],
styleSheets: [],
}
}
+2 -2
View File
@@ -41,10 +41,10 @@ describe('built-in tab registrations', () => {
}
})
it('the subagent tab declares its auto-open related setting', () => {
it('the subagent tab declares its auto-open related settings', () => {
const { service } = setup()
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent'])
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenTasks'])
})
it('the terminal tab declares the model terminal-tools related setting', () => {
+148
View File
@@ -0,0 +1,148 @@
/**
* Lazy chunk route tests (src/bundle-route.ts): the /sidebar/bundle handler
* that serves the client's split bundles. Pins the trust fence, the name
* allowlist (no traversal), method gating, and the caching contract — ETag
* + If-None-Match 304 so multi-MB chunks revalidate cheaply instead of
* re-downloading.
*/
import { describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { CHUNK_NAMES, createBundleRouteHandler } from '../src/bundle-route.ts'
interface FakeRes {
status: number
headers: Record<string, string>
body: string
writeHead(status: number, headers?: Record<string, string>): void
end(body?: string | Buffer): void
}
function fakeRes(): FakeRes {
return {
status: 0,
headers: {},
body: '',
writeHead(status, headers = {}) {
this.status = status
this.headers = headers
},
end(body) {
if (body !== undefined) this.body = body.toString()
},
} as FakeRes
}
function req(method: string, url: string, headers: Record<string, string> = {}): IncomingMessage {
return { method, url, headers } as unknown as IncomingMessage
}
/** One handler instance over a scratch dir with a fake chunk file. */
function setup(): { handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>; dir: string; cleanup: () => void } {
const dir = mkdtempSync(join(tmpdir(), 'bundle-route-'))
writeFileSync(join(dir, 'client-xlsx.js'), 'window.__ModuleLoader__ && 0;')
const handler = createBundleRouteHandler(() => true, dir)
return { handler, dir, cleanup: () => { rmSync(dir, { recursive: true, force: true }) } }
}
describe('/sidebar/bundle route', () => {
it('serves an allowlisted chunk with the JS content type and an ETag', async () => {
const { handler, cleanup } = setup()
try {
const res = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
expect(res.status).toBe(200)
expect(res.headers['content-type']).toBe('text/javascript; charset=utf-8')
expect(res.headers['cache-control']).toBe('no-cache')
expect(res.headers.etag).toMatch(/^"[0-9a-f]{12}"$/)
expect(res.body).toContain('window.__ModuleLoader__')
} finally {
cleanup()
}
})
it('revalidates with a 304 when If-None-Match matches (no re-download)', async () => {
const { handler, cleanup } = setup()
try {
const first = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js'), first as unknown as ServerResponse)
const etag = first.headers.etag!
const second = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js', { 'if-none-match': etag }), second as unknown as ServerResponse)
expect(second.status).toBe(304)
expect(second.body).toBe('')
expect(second.headers.etag).toBe(etag)
} finally {
cleanup()
}
})
it('serves the new body after the file changed (ETag rotates)', async () => {
const { handler, dir, cleanup } = setup()
try {
const first = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js'), first as unknown as ServerResponse)
writeFileSync(join(dir, 'client-xlsx.js'), 'window.__ModuleLoader__ && 1;')
const second = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js', { 'if-none-match': first.headers.etag! }), second as unknown as ServerResponse)
expect(second.status).toBe(200)
expect(second.headers.etag).not.toBe(first.headers.etag)
expect(second.body).toContain('&& 1')
} finally {
cleanup()
}
})
it('rejects unknown chunk names (including traversal attempts) with 404', async () => {
const { handler, cleanup } = setup()
try {
for (const url of ['/sidebar/bundle/evil.js', '/sidebar/bundle/xlsx', '/sidebar/bundle/../secret.js', '/sidebar/bundle/xlsx.js/extra']) {
const res = fakeRes()
await handler(req('GET', url), res as unknown as ServerResponse)
expect(res.status, url).toBe(404)
}
} finally {
cleanup()
}
})
it('404s an allowlisted name whose built file is missing', async () => {
const { handler, cleanup } = setup()
try {
const res = fakeRes()
await handler(req('GET', '/sidebar/bundle/editor.js'), res as unknown as ServerResponse)
expect(res.status).toBe(404)
} finally {
cleanup()
}
})
it('gates non-GET/HEAD methods with 405', async () => {
const { handler, cleanup } = setup()
try {
const res = fakeRes()
await handler(req('POST', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
expect(res.status).toBe(405)
} finally {
cleanup()
}
})
it('enforces the browser-trust fence with 403 before any lookup', async () => {
const dir = mkdtempSync(join(tmpdir(), 'bundle-route-fence-'))
try {
const handler = createBundleRouteHandler(() => false, dir)
const res = fakeRes()
await handler(req('GET', '/sidebar/bundle/xlsx.js'), res as unknown as ServerResponse)
expect(res.status).toBe(403)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
expect([...CHUNK_NAMES]).toEqual(['docx', 'xlsx', 'pptx', 'terminal', 'editor'])
})
})
+43
View File
@@ -0,0 +1,43 @@
/**
* Built chunk artifact contract: each lib/client-<name>.js must, when
* executed as a classic script, assign its factory to the plugin-owned
* global registry (globalThis.__dshChunks__[<name>]), and the factory must
* be callable with a require that resolves the platform externals — the
* exact shape the loader (src/client/chunk-loader.ts) depends on. Reads the
* built lib/ output, so run `pnpm build` first (like manifest-consistency).
*/
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
// Browser globals first: chunk bodies probe `self`/`document` at evaluation
// (xterm's UMD wrapper, CodeMirror's UA probe).
import './browser-globals.ts'
import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
const g = globalThis as Record<string, unknown>
const CHUNKS = ['docx', 'xlsx', 'pptx', 'terminal', 'editor']
describe('built chunk artifacts', () => {
it('each chunk assigns its global registry slot when executed as a script', () => {
g.window = g // classic-script globals
for (const name of CHUNKS) {
const code = readFileSync(`lib/client-${name}.js`, 'utf8')
// eslint-disable-next-line no-new-func
expect(() => new Function(code)(), name).not.toThrow()
const registry = g.__dshChunks__ as Record<string, unknown>
expect(typeof registry[name], name).toBe('function')
}
})
it('each chunk factory materializes through a require over the platform externals', () => {
const registry = g.__dshChunks__ as Record<string, unknown>
const table = new Map<string, unknown>(CHUNK_EXTERNALS.map(spec => [spec, { spec }]))
for (const name of CHUNKS) {
const factory = registry[name] as (require: (spec: string) => unknown) => Record<string, unknown>
expect(() => factory((spec) => {
if (!table.has(spec)) throw new Error(`require("${spec}") missed the module table`)
return table.get(spec)
}), name).not.toThrow()
}
})
})
+210
View File
@@ -0,0 +1,210 @@
/**
* Chunk loader tests: the lazy chunk machinery (src/client/chunk-loader.ts).
* Pins the caching contract that makes the lazy mechanism correct:
* - one in-flight promise per chunk (concurrent opens share it),
* - a failed load clears the cache so the next call retries (the script
* re-executes and overwrites its global registry slot — assignments are
* idempotent, no duplicate-registration class of errors),
* - externals resolve through the module system's seed branch (the stable,
* version-independent part), once per page,
* - resetChunks drops the cache and the externals memo (HMR).
* The production path runs against a fake `window.__DSH_MODULES__` and a
* stub script loader that simulates the executed chunk script by assigning
* the plugin-owned global factory registry.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import './browser-globals.ts'
import {
CHUNK_EXTERNALS,
loadChunk,
registerChunkForTests,
resetChunks,
setChunkScriptLoaderForTests,
} from '../src/client/chunk-loader.ts'
import type { ChunkExports } from '../src/client/chunk-loader.ts'
interface FakeModuleSystem {
import: ReturnType<typeof vi.fn>
}
function installModuleSystem(): FakeModuleSystem {
const fake: FakeModuleSystem = {
import: vi.fn(async (specifier: string) => ({ seed: specifier })),
}
;(globalThis as Record<string, unknown>).__DSH_MODULES__ = fake
return fake
}
function removeModuleSystem(): void {
delete (globalThis as Record<string, unknown>).__DSH_MODULES__
}
/** The global registry the chunk scripts populate (mirror of chunk-loader). */
function registry(): Record<string, unknown> {
return (globalThis as { __dshChunks__?: Record<string, unknown> }).__dshChunks__ ?? {}
}
/** Simulate a chunk script executing: it assigns its factory to the registry. */
function simulateScript(name: string, factory: (require: (spec: string) => unknown) => ChunkExports): void {
const g = globalThis as { __dshChunks__?: Record<string, unknown> }
g.__dshChunks__ = g.__dshChunks__ ?? {}
g.__dshChunks__[name] = factory
}
beforeEach(() => {
removeModuleSystem()
delete (globalThis as Record<string, unknown>).__dshChunks__
resetChunks()
setChunkScriptLoaderForTests(null)
})
describe('test-registry path (vitest / jsdom-less environments)', () => {
it('returns the registered chunk exports, loading once even for concurrent callers', async () => {
let calls = 0
registerChunkForTests('docx', async () => {
calls += 1
return { DocxView: 'docx-view' }
})
const [a, b, c] = await Promise.all([loadChunk('docx'), loadChunk('docx'), loadChunk('docx')])
expect(a).toEqual({ DocxView: 'docx-view' })
expect(b).toBe(a)
expect(c).toBe(a)
expect(calls).toBe(1)
// Memoized after resolution too.
expect(await loadChunk('docx')).toBe(a)
expect(calls).toBe(1)
})
it('a failed load clears the cache so the next call retries', async () => {
let calls = 0
registerChunkForTests('pptx', async () => {
calls += 1
if (calls === 1) throw new Error('boom')
return { PptxView: 'pptx-view' }
})
await expect(loadChunk('pptx')).rejects.toThrow('boom')
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
expect(calls).toBe(2)
})
})
describe('production path (script injection + global registry + externals require)', () => {
it('injects the chunk script, then materializes the factory with externals from the module table', async () => {
const modules = installModuleSystem()
const loaded: string[] = []
setChunkScriptLoaderForTests(async (src) => {
loaded.push(src)
simulateScript('xlsx', (require) => ({ XlsxView: `view:${String(require('react'))}` }))
})
const exports = await loadChunk('xlsx')
expect(loaded).toEqual(['/sidebar/bundle/xlsx.js'])
expect(exports).toEqual({ XlsxView: 'view:[object Object]' })
// Externals resolved through the module system's seed branch, once.
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
// Memoized: no second script injection, no second externals resolution.
await loadChunk('xlsx')
expect(loaded).toHaveLength(1)
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
})
it('resolves the externals table once across chunks', async () => {
const modules = installModuleSystem()
const seen: string[] = []
setChunkScriptLoaderForTests(async (src) => {
seen.push(src)
simulateScript(src.endsWith('editor.js') ? 'editor' : 'terminal', () => ({}))
})
await loadChunk('terminal')
await loadChunk('editor')
expect(seen).toEqual(['/sidebar/bundle/terminal.js', '/sidebar/bundle/editor.js'])
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
})
it('a chunk requiring an unresolvable externals spec fails loudly', async () => {
installModuleSystem()
setChunkScriptLoaderForTests(async () => {
simulateScript('docx', (require) => { require('not-in-the-table'); return {} })
})
await expect(loadChunk('docx')).rejects.toThrow('missed the module table')
})
it('a script load failure rejects without materializing', async () => {
const modules = installModuleSystem()
setChunkScriptLoaderForTests(async () => { throw new Error('script 404') })
await expect(loadChunk('terminal')).rejects.toThrow('script 404')
expect(modules.import).not.toHaveBeenCalled()
// Cache cleared: the retry re-attempts the script load.
setChunkScriptLoaderForTests(async () => { simulateScript('terminal', () => ({ TerminalView: 'ok' })) })
await expect(loadChunk('terminal')).resolves.toEqual({ TerminalView: 'ok' })
})
it('a script that ran but registered no factory fails with a clear error', async () => {
installModuleSystem()
setChunkScriptLoaderForTests(async () => { /* executes but assigns nothing */ })
await expect(loadChunk('editor')).rejects.toThrow('did not register its factory')
})
it('a materialization failure clears the cache; the retry re-executes cleanly (idempotent slot assignment)', async () => {
const modules = installModuleSystem()
let calls = 0
setChunkScriptLoaderForTests(async () => {
calls += 1
simulateScript('pptx', () => {
if (calls === 1) throw new Error('materialize boom')
return { PptxView: 'pptx-view' }
})
})
await expect(loadChunk('pptx')).rejects.toThrow('materialize boom')
// Retry: script re-injected, slot overwritten, materialization succeeds.
await expect(loadChunk('pptx')).resolves.toEqual({ PptxView: 'pptx-view' })
expect(calls).toBe(2)
expect(modules.import).toHaveBeenCalled()
})
it('fails loudly when no module system is installed (before touching the network)', async () => {
const loaded: string[] = []
setChunkScriptLoaderForTests(async (src) => { loaded.push(src) })
await expect(loadChunk('docx')).rejects.toThrow('client module system unavailable')
expect(loaded).toEqual([])
})
it('resetChunks drops the cache and the externals memo (HMR re-activation)', async () => {
const modules = installModuleSystem()
let calls = 0
setChunkScriptLoaderForTests(async () => {
calls += 1
simulateScript('editor', () => ({ TextEditor: 'editor-view' }))
})
await loadChunk('editor')
expect(calls).toBe(1)
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length)
resetChunks()
// Cache dropped: the next open re-fetches and re-executes; the externals
// memo is rebuilt (fresh module-table resolution).
await loadChunk('editor')
expect(calls).toBe(2)
expect(modules.import).toHaveBeenCalledTimes(CHUNK_EXTERNALS.length * 2)
})
it('resetChunks is a safe no-op without a module system', () => {
resetChunks()
expect(() => resetChunks()).not.toThrow()
})
})
describe('externals contract', () => {
it('the loader resolves exactly the platform externals the chunk builds keep external', () => {
expect(CHUNK_EXTERNALS).toEqual([
'react',
'react/jsx-runtime',
'react-dom',
'react-dom/client',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-schema-form',
'@deepseek-ai/dsh-client-runtime/client',
])
})
})
+194
View File
@@ -0,0 +1,194 @@
/**
* Lazy wrapper tests (src/client/lazy-chunk.tsx): the wrapper that mounts
* chunk-resident components from the built-in descriptors. Pins the two
* contracts that matter for the descriptor API:
* - the wrapper is a plain render-prop function — `component(props)` can be
* called directly (Sidebar's style) without a chunk registered and
* without throwing; hooks live in the inner component only,
* - loading → placeholder, failure → error + retry that recovers, success →
* the chunk component rendered.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createElement, type ComponentType, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { builtinTabs } from '../src/client/builtins/tabs.tsx'
import { builtinViewers } from '../src/client/builtins/viewers.tsx'
import { registerChunkForTests, resetChunks } from '../src/client/chunk-loader.ts'
import { lazyChunkComponent } from '../src/client/lazy-chunk.tsx'
import type { Context } from '../src/context-types.ts'
import type { FileViewerProps, TabComponentProps } from '../src/client/service.ts'
import css from '../src/client/sidebar.module.css'
/** Render `node` into a detached body container under React's act(). */
function mount(node: ReactNode): { container: HTMLDivElement; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
// act() flushes the (concurrent) initial commit — the placeholder must be
// in the DOM before the async load flushes below.
act(() => { root.render(node) })
const unmount = (): void => {
act(() => { root.unmount() })
container.remove()
}
return { container, unmount }
}
const Marker = (): ReactNode => createElement('div', { 'data-testid': 'chunk-rendered' }, 'loaded')
beforeEach(() => {
resetChunks()
})
afterEach(() => {
// Defensive: drop any containers left by failed assertions.
for (const el of document.querySelectorAll('body > div')) el.remove()
})
describe('lazyChunkComponent', () => {
it('renders the loading placeholder first, then the chunk component', async () => {
let calls = 0
registerChunkForTests('editor', async () => {
calls += 1
return { TextEditor: Marker }
})
const Wrapper = lazyChunkComponent<{ label: string }>('editor', (mod) => mod.TextEditor as ComponentType<{ label: string }> | undefined)
const { container, unmount } = mount(createElement(Wrapper, { label: 'x' }))
// Initial paint: the loading placeholder (no chunk loaded yet).
expect(container.querySelector(`.${css.editorPlaceholder}`)).not.toBeNull()
await act(async () => {})
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
expect(container.textContent).toContain('loaded')
expect(calls).toBe(1)
unmount()
})
it('shows the failure reason with a retry that recovers', async () => {
let fail = true
registerChunkForTests('docx', async () => {
if (fail) throw new Error('boom')
return { DocxView: Marker }
})
const Wrapper = lazyChunkComponent<Record<string, never>>('docx', (mod) => mod.DocxView as ComponentType<Record<string, never>> | undefined)
const { container, unmount } = mount(createElement(Wrapper, {}))
await act(async () => {})
expect(container.textContent).toContain('boom')
// The failed load cleared the loader cache; retry now succeeds.
fail = false
const button = container.querySelector('button')
expect(button).not.toBeNull()
await act(async () => { button!.click() })
await act(async () => {})
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
unmount()
})
it('props flow through to the chunk component', async () => {
const Recorder = (props: { label: string }): ReactNode => createElement('div', { 'data-testid': 'rec', 'data-label': props.label })
registerChunkForTests('pptx', async () => ({ PptxView: Recorder }))
const Wrapper = lazyChunkComponent<{ label: string }>('pptx', (mod) => mod.PptxView as ComponentType<{ label: string }> | undefined)
const { container, unmount } = mount(createElement(Wrapper, { label: 'hello' }))
await act(async () => {})
expect(container.querySelector('[data-testid="rec"]')?.getAttribute('data-label')).toBe('hello')
unmount()
})
})
describe('built-in descriptor contract (render-prop functions)', () => {
it('every heavy built-in viewer component is callable as a plain function without a chunk (returns an element)', () => {
const viewers = builtinViewers()
for (const id of ['docx', 'xlsx', 'pptx', 'markdown', 'html', 'code']) {
const descriptor = viewers.find(viewer => viewer.id === id)
expect(descriptor, id).toBeDefined()
// No chunk registered: calling must not throw — it returns the lazy
// element (the loading placeholder renders once mounted).
expect(() => descriptor!.component({} as FileViewerProps), id).not.toThrow()
}
})
it('the terminal tab component keeps the same contract', () => {
const tabs = builtinTabs({} as Context)
const terminal = tabs.find(tab => tab.id === 'terminal')
expect(terminal).toBeDefined()
// The descriptor reads tab.id (the tabId mapping); a real tab is part of
// the contract — Sidebar always provides one.
const props = { tab: { id: 'terminal:1', type: 'terminal', title: '终端 1' } } as unknown as TabComponentProps
expect(() => terminal!.component(props)).not.toThrow()
})
it('a built-in viewer mounted without a chunk available degrades to the error + retry affordance (no crash)', async () => {
// No test chunk registered and jsdom has no client module system: the
// load fails and the wrapper must degrade gracefully, never throw.
const viewers = builtinViewers()
const code = viewers.find(viewer => viewer.id === 'code')!
const { container, unmount } = mount(createElement(code.component, {} as FileViewerProps))
await act(async () => {})
expect(container.querySelector(`.${css.editorError}`)).not.toBeNull()
expect(container.querySelector('button')).not.toBeNull()
unmount()
})
it('a registered chunk makes the built-in viewer render through the real descriptor path', async () => {
registerChunkForTests('editor', async () => ({ TextEditor: Marker }))
const viewers = builtinViewers()
const markdown = viewers.find(viewer => viewer.id === 'markdown')!
// EditorHost renders viewer components via createElement(component, props).
const { container, unmount } = mount(createElement(markdown.component, {
ctx: {},
store: undefined,
scope: { sessionId: 's1', cwd: '/p' },
path: '/p/a.md',
title: 'a.md',
viewerId: 'markdown',
} as unknown as FileViewerProps))
await act(async () => {})
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
unmount()
})
it('the terminal descriptor maps tab.id → tabId (TerminalView props are not TabComponentProps)', async () => {
let received: unknown
registerChunkForTests('terminal', async () => ({
TerminalView: ((props: { tabId: string }) => {
received = props.tabId
return createElement('div', { 'data-testid': 'terminal-tabid' }, props.tabId)
}) as unknown as ComponentType<Record<string, never>>,
}))
const tabs = builtinTabs({} as Context)
const terminal = tabs.find(tab => tab.id === 'terminal')!
const props = {
ctx: {},
store: undefined,
scope: { sessionId: 's1', cwd: '/p' },
tab: { id: 'terminal:2', type: 'terminal', title: '终端 2' },
visible: true,
} as unknown as TabComponentProps
const { container, unmount } = mount(createElement(terminal.component, props))
await act(async () => {})
expect(container.querySelector('[data-testid="terminal-tabid"]')?.textContent).toBe('terminal:2')
expect(received).toBe('terminal:2')
unmount()
})
it('the loader cache survives across descriptor mounts (chunk fetched once)', async () => {
let calls = 0
registerChunkForTests('terminal', async () => {
calls += 1
return { TerminalView: Marker }
})
const tabs = builtinTabs({} as Context)
const terminal = tabs.find(tab => tab.id === 'terminal')!
const props = { tab: { id: 'terminal:1', type: 'terminal', title: '终端 1' } } as unknown as TabComponentProps
const { container: first, unmount: unmountFirst } = mount(createElement(terminal.component, props))
await act(async () => {})
expect(first.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
unmountFirst()
const { container: second, unmount: unmountSecond } = mount(createElement(terminal.component, props))
await act(async () => {})
expect(second.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
unmountSecond()
expect(calls).toBe(1)
})
})
+20 -2
View File
@@ -64,6 +64,17 @@ function bundleId(file: string): string {
return match[1]!
}
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
const CHUNK_FILES = ['docx', 'xlsx', 'pptx', 'terminal', 'editor'].map(name => `lib/client-${name}.js`)
/** The global registry slot a built chunk script assigns (its factory key). */
function chunkSlot(file: string): string {
const source = readFileSync(resolve(ROOT, file), 'utf8')
const match = /globalThis\.__dshChunks__\["([a-z0-9-]+)"\]/.exec(source)
if (match === null) throw new Error(`${file} assigns no globalThis.__dshChunks__ slot — run \`pnpm build\` first`)
return match[1]!
}
describe('registry manifest consistency (dsh.plugin.json)', () => {
it('id is a valid two-segment registry id (native form)', () => {
expect(manifest.id).toMatch(ID_PATTERN)
@@ -86,13 +97,20 @@ describe('registry manifest consistency (dsh.plugin.json)', () => {
expect(registryId).not.toBe(pkg.name)
})
it('the lazy chunk bundles exist and assign their global registry slots (served by /sidebar/bundle)', () => {
for (const file of CHUNK_FILES) {
expect(existsSync(resolve(ROOT, file)), file).toBe(true)
expect(chunkSlot(file), file).toBe(file.slice('lib/client-'.length, -'.js'.length))
}
})
it('client bundles require only frozen module-table entries', () => {
for (const file of ['lib/client.js', manifest.client!.main!]) {
for (const file of ['lib/client.js', manifest.client!.main!, ...CHUNK_FILES]) {
const source = readFileSync(resolve(ROOT, file), 'utf8')
// Exclude method calls such as `freeModule.require("util")`; only the
// loader factory's lexical require() is constrained by the module table.
const required = [...source.matchAll(/(^|[^.$\w])require\("([^"]+)"\)/gm)].map(match => match[2]!)
expect([...new Set(required)].filter(id => !CLIENT_REQUIRE_ALLOWED.has(id))).toEqual([])
expect([...new Set(required)].filter(id => !CLIENT_REQUIRE_ALLOWED.has(id)), file).toEqual([])
}
})
+3 -1
View File
@@ -45,6 +45,8 @@ describe('dsh-better-sidebar plugin export shape', () => {
expect(resolved.openByDefault).toBe(true)
expect(resolved.defaultWidthPercent).toBe(30)
expect(resolved.autoOpenSubagent).toBe(true)
// A new background task auto-opens the Tasks page too.
expect(resolved.autoOpenTasks).toBe(true)
// The terminal tools default OFF (the feature is dormant until the user
// enables it in the side card settings).
expect(resolved.agentTerminalTools).toBe(false)
@@ -57,6 +59,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
})
})
+132
View File
@@ -465,3 +465,135 @@ describe('service.openTab across the two panels', () => {
expect(allLeaves(after.bottomSplits).flatMap(l => l.tabs).some(t => t.id === gitTab.id)).toBe(false)
})
})
describe('service.openTab auto-expand for content opens', () => {
/** The window stub is a plain object (see the file header), so the width is writable. */
const setWidth = (width: number): void => {
;(g.window as { innerWidth: number }).innerWidth = width
}
/** Collapse the right panel (the store defaults it open). */
const collapseRightPanel = (store: ReturnType<typeof createSidebarStore>): void => {
store.reduce(s => ({ ...s, panelOpen: false }))
}
it('expands the collapsed drawer for a path (file) open on a narrow viewport', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
expect(store.getSnapshot().state?.panelOpen).toBe(true)
} finally {
setWidth(1024)
}
})
it('expands the collapsed drawer for a URL (browser) open on a narrow viewport', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'browser', url: 'https://example.com', title: 'example.com' })
expect(store.getSnapshot().state?.panelOpen).toBe(true)
} finally {
setWidth(1024)
}
})
it('keeps a collapsed drawer for a type-only open on a narrow viewport', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'explorer', title: 'Explorer' })
expect(store.getSnapshot().state?.panelOpen).toBe(false)
} finally {
setWidth(1024)
}
})
it('expands the collapsed right panel for a path (file) open on a wide viewport', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
collapseRightPanel(store)
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state!
expect(state.panelOpen).toBe(true)
expect(allLeaves(state.splits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
})
it('expands the collapsed right panel for a URL (browser) open on a wide viewport', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
store.setSession('s1')
collapseRightPanel(store)
service.openTab({ type: 'browser', url: 'https://example.com', title: 'example.com' })
expect(store.getSnapshot().state!.panelOpen).toBe(true)
})
it('a wide-viewport path open landing in the bottom tree expands the bottom panel instead', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
// The last-touched pane lives in the bottom tree and BOTH panels are
// collapsed: the open must surface the bottom panel, not the right one.
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id, panelOpen: false, bottomOpen: false }))
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state!
expect(state.bottomOpen).toBe(true)
expect(state.panelOpen).toBe(false)
expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
})
it('keeps a collapsed panel for a type-only open on a wide viewport', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
store.setSession('s1')
collapseRightPanel(store)
service.openTab({ type: 'explorer', title: 'Explorer' })
expect(store.getSnapshot().state?.panelOpen).toBe(false)
})
it('expands on a narrow viewport even when the open focuses an existing tab (id dedupe)', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
expect(store.getSnapshot().state?.panelOpen).toBe(true)
} finally {
setWidth(1024)
}
})
it('expands on a wide viewport even when the open focuses an existing tab (id dedupe)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
collapseRightPanel(store)
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state!
expect(state.panelOpen).toBe(true)
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')).toHaveLength(1)
})
})
+12 -1
View File
@@ -27,6 +27,8 @@ interface FakeContext {
/** The settings service never appears in the smoke context: the inject
* callback must never run (mirror of cordis' service-less inject). */
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void
/** Optional services (tasks/agents) are read lazily; absent → undefined. */
get: (key: string) => undefined
}
describe('host plugin smoke', () => {
@@ -56,9 +58,11 @@ describe('host plugin smoke', () => {
// No settings service in the smoke context: the registration callback
// never runs (cordis' service-less inject behaves the same).
inject: () => () => {},
// No tasks/agents services: the tasks routes degrade to a 503.
get: () => undefined,
}
apply(ctx as never)
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file', '/sidebar/html'])
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/bundle', '/sidebar/file', '/sidebar/html'])
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
// Teardown runs without throwing (pty manager has nothing open).
for (const cleanup of effects) cleanup()
@@ -315,6 +319,8 @@ describe('session cwd resolution over the API route', () => {
effect: (fn: () => void | (() => void)) => { fn() },
// No settings service: the namespace registration never runs.
inject: () => () => {},
// No tasks/agents services in the smoke context: the routes degrade.
get: () => undefined,
}
apply(ctx as never)
return routes.find(route => route.path === '/sidebar/api')!
@@ -464,6 +470,8 @@ describe('side card settings routes', () => {
if (deps.includes('settings') && settings !== undefined) callback({ settings })
return () => {}
},
// No tasks/agents services: the tasks routes degrade to a 503.
get: () => undefined,
}
apply(ctx as never)
return routes.find(route => route.path === '/sidebar/api')!
@@ -506,6 +514,7 @@ describe('side card settings routes', () => {
openByDefault: true,
defaultWidthPercent: 30,
autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
@@ -647,6 +656,8 @@ describe('agent terminal tool gating', () => {
if (deps.includes('settings')) callback({ settings })
return () => {}
},
// No tasks/agents services: the tasks routes degrade to a 503.
get: () => undefined,
}
apply(ctx as never)
// Default off: no tools are registered even though the settings service is mounted.
+295
View File
@@ -0,0 +1,295 @@
/**
* Subagent page tests for the background-task section: rows render from the
* `tasksBySession` mirror, clicking a row peeks its output through
* `tasks.output` with the OWNER session scope, the kill button needs a
* two-click confirm, settled rows offer no kill, and the output panel never
* polls while the page is hidden.
*/
// @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 stub; everything else inert). */
function makeCtx(store: Store): Context {
return {
sessions: {
list: store,
setSubagentCatalogOpen: () => {},
openSubagent: () => {},
open: () => {},
refreshSubagents: async () => {},
},
connection: {
api: {
subagents: {
history: async () => ({ result: { ok: true, value: { events: [], hasMore: false } } }),
},
},
},
} as unknown as Context
}
/** Render `node` into a detached body container under React's act(). */
function mount(node: ReactNode): { container: HTMLDivElement; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
act(() => { root.render(node) })
const unmount = (): void => {
act(() => { root.unmount() })
container.remove()
}
return { container, unmount }
}
const outputCalls: Array<{ sessionId: string; id: string }> = []
const killCalls: Array<{ sessionId: string; id: string }> = []
function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
function baseSnapshot(): SidebarSessionList {
return {
current: 'root',
byId: {
root: { id: 'root', displayTitle: '主会话', running: true },
child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true },
},
subagentsByParent: {},
tasksBySession: {
root: [
{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 },
],
child: [
{ id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 },
],
},
}
}
beforeEach(() => {
outputCalls.length = 0
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 }
if (method === 'tasks.output') {
outputCalls.push({ sessionId: body.sessionId, id: body.id })
// bash-9 stands for a task the model never read (read:false).
return jsonResponse({
ok: true,
value: {
text: body.id === 'bash-9' ? '' : `output-of-${body.id}`,
truncated: false,
read: body.id !== 'bash-9',
},
})
}
if (method === 'tasks.kill') {
killCalls.push({ sessionId: body.sessionId, id: body.id })
return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } })
}
throw new Error(`unexpected fetch ${String(url)}`)
})
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
})
afterEach(() => {
vi.unstubAllGlobals()
for (const el of document.querySelectorAll('body > div')) el.remove()
})
describe('SubagentView background tasks', () => {
it('renders the tree tasks with status, durations, and owner labels', () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const text = container.textContent ?? ''
expect(text).toContain('后台命令')
expect(text).toContain('2 个后台命令 · 1 运行中')
// Both rows of the whole tree, owner-labeled (the tree spans two sessions).
expect(text).toContain('sleep 300')
expect(text).toContain('echo hi')
expect(text).toContain('主会话')
expect(text).toContain('子代理')
// Only the running row offers a kill button.
expect(container.querySelectorAll('button[aria-label="终止"]')).toHaveLength(1)
unmount()
})
it('renders nothing task-related when the mirror is empty', () => {
const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, tasksBySession: {} })
const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
expect(container.textContent).not.toContain('后台命令')
unmount()
})
it('survives the mirror emptying while mounted (hook-order regression #300)', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
expect(container.textContent).toContain('sleep 300')
// The mirror empties (all tasks settled and dropped): the section must
// vanish WITHOUT reordering hooks — a hook below the empty-state return
// would crash React with "Rendered fewer hooks than expected" (the
// minified #300 the sidebar boundary surfaces with a retry button).
await act(async () => { store.set({ ...baseSnapshot(), tasksBySession: {} }) })
expect(container.textContent).not.toContain('后台命令')
// And returning tasks must work too, with the same hook order.
await act(async () => { store.set(baseSnapshot()) })
expect(container.textContent).toContain('sleep 300')
unmount()
})
it('shows the selected task output in the bottom dock, closeable', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() })
// The peek request carries the OWNER session (the fence compares it).
expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }])
expect(container.textContent).toContain('output-of-bash-1')
// Exactly one dock region exists (never one per row).
expect(container.querySelectorAll('[role="region"]')).toHaveLength(1)
// The selected row is marked, and the close button dismisses the dock.
expect(row.getAttribute('aria-pressed')).toBe('true')
const close = container.querySelector('button[aria-label="关闭"]') as HTMLButtonElement
await act(async () => { close.click() })
expect(container.textContent).not.toContain('output-of-bash-1')
expect(container.querySelectorAll('[role="region"]')).toHaveLength(0)
unmount()
})
it('switches the single dock between selected rows', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { first.click() })
expect(container.textContent).toContain('output-of-bash-1')
const second = container.querySelector('button[aria-label*="echo hi"]') as HTMLButtonElement
await act(async () => { second.click() })
// One dock, now fed by the second task (its owner session scopes the replay).
expect(outputCalls).toEqual([
{ sessionId: 'root', id: 'bash-1' },
{ sessionId: 'child', id: 'bash-2' },
])
expect(container.querySelectorAll('[role="region"]')).toHaveLength(1)
expect(container.textContent).not.toContain('output-of-bash-1')
expect(container.textContent).toContain('output-of-bash-2')
unmount()
})
it('explains when the model has not read the task yet', async () => {
const snapshot = baseSnapshot()
snapshot.tasksBySession = {
root: [
...(snapshot.tasksBySession?.root ?? []),
{ id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 },
],
}
const store = makeStore(snapshot)
const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
await act(async () => { row.click() })
// read:false → the pane explains the output awaits the model's task_output
// (never the model's cursor, so there is nothing to steal yet).
expect(container.textContent).toContain('等待模型读取该任务的输出')
unmount()
})
it('stays compact and functional with many tasks', async () => {
const many = Array.from({ length: 60 }, (_, index) => ({
id: `bash-${index + 10}`,
kind: 'bash' as const,
label: `bulk cmd ${index}`,
status: index % 2 === 0 ? ('running' as const) : ('completed' as const),
startedAt: 1_000 + index,
...(index % 2 === 0 ? {} : { finishedAt: 2_000 + index, detail: 'exit code: 1' }),
}))
const store = makeStore({
current: 'root',
byId: { root: { id: 'root', displayTitle: '主会话' } },
subagentsByParent: {},
tasksBySession: { root: many },
})
const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
expect(container.textContent).toContain('60 个后台命令 · 30 运行中')
expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60)
// Clicking a row anywhere in the long list still feeds the single dock.
const row = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement
await act(async () => { row.click() })
expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-69' }])
expect(container.textContent).toContain('output-of-bash-69')
unmount()
})
it('kills a live task only after the two-click confirm', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const kill = container.querySelector('button[aria-label="终止"]') as HTMLButtonElement
await act(async () => { kill.click() })
// First click only arms the confirm — no request leaves the page.
expect(killCalls).toEqual([])
const confirm = container.querySelector('button[aria-label="再次点击确认终止"]')
expect(confirm).not.toBeNull()
await act(async () => { (confirm as HTMLButtonElement).click() })
expect(killCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }])
unmount()
})
it('does not poll the output while the page is hidden', async () => {
vi.useFakeTimers()
try {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() })
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
// One peek for the initial load; the 2s poll never ran while inactive.
expect(outputCalls).toHaveLength(1)
unmount()
} finally {
vi.useRealTimers()
}
})
})
+165
View File
@@ -0,0 +1,165 @@
/**
* Pure-helper tests for the Subagent page's background-task section:
* tree-membership collection, ordering, and status presentation mapping.
*/
import { describe, expect, it } from 'vitest'
import {
collectTreeTasks,
detectNewTask,
formatTaskDuration,
isTaskLive,
orderTasks,
taskDotState,
taskStatusLabel,
treeSessionIds,
} from '../src/client/subagent-tasks.ts'
import type { SidebarSessionList, SidebarSessionSummary, SidebarTaskStatus, SidebarTaskView } from '../src/context-types.ts'
/** The translator stub: renders duration templates like the real locale copy. */
const templates: Record<string, string> = {
taskDurationSeconds: '{seconds}秒',
taskDurationMinutes: '{minutes}分{seconds}秒',
taskDurationHours: '{hours}小时{minutes}分',
}
const t = (key: string, params?: Record<string, string | number>): string => {
let text = templates[key] ?? key
if (params !== undefined) {
for (const [name, value] of Object.entries(params)) text = text.replaceAll(`{${name}}`, String(value))
}
return text
}
function summary(id: string, over: Partial<SidebarSessionSummary> = {}): SidebarSessionSummary {
return { id, displayTitle: `title-${id}`, ...over }
}
function task(id: string, over: Partial<SidebarTaskView> = {}): SidebarTaskView {
return { id, kind: 'bash', label: `cmd ${id}`, status: 'running', startedAt: 1_000, ...over }
}
describe('treeSessionIds', () => {
it('includes the root and every subagent-origin session whose chain reaches it', () => {
const byId = {
root: summary('root'),
child: summary('child', { origin: 'subagent', parentId: 'root' }),
grand: summary('grand', { origin: 'subagent', parentId: 'child' }),
orphan: summary('orphan', { origin: 'subagent', parentId: 'gone' }),
other: summary('other', { origin: 'subagent', parentId: 'other-root' }),
}
const ids = treeSessionIds(byId, 'root')
expect([...ids].sort()).toEqual(['child', 'grand', 'root'])
})
it('fails soft on parent cycles and yields nothing without a root', () => {
const byId = {
root: summary('root'),
a: summary('a', { origin: 'subagent', parentId: 'b' }),
b: summary('b', { origin: 'subagent', parentId: 'a' }),
}
expect(treeSessionIds(byId, 'root').size).toBe(1)
expect(treeSessionIds(byId, undefined).size).toBe(0)
})
})
describe('collectTreeTasks', () => {
it('collects tasks of the whole tree with owner titles, ignoring outside sessions', () => {
const byId = {
root: summary('root'),
child: summary('child', { origin: 'subagent', parentId: 'root' }),
}
const tasksBySession = {
root: [task('bash-1')],
child: [task('bash-2', { status: 'completed', finishedAt: 2_000 })],
stranger: [task('bash-9')],
}
const rows = collectTreeTasks(byId, tasksBySession, 'root')
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.task.id]))
.toEqual([['root', 'title-root', 'bash-1'], ['child', 'title-child', 'bash-2']])
})
it('returns an empty list for an absent mirror or empty sets', () => {
const byId = { root: summary('root') }
expect(collectTreeTasks(byId, undefined, 'root')).toEqual([])
expect(collectTreeTasks(byId, {}, 'root')).toEqual([])
})
})
describe('orderTasks', () => {
it('puts live rows first in start order, then settled rows newest-first', () => {
const row = (id: string, status: SidebarTaskStatus, startedAt: number, finishedAt?: number) => ({
ownerSessionId: 'root',
ownerTitle: 'root',
task: task(id, { status, startedAt, ...(finishedAt !== undefined ? { finishedAt } : {}) }),
})
const rows = [
row('old-settled', 'completed', 1_000, 2_000),
row('live-2', 'running', 4_000),
row('new-settled', 'killed', 1_500, 1_800),
row('live-1', 'stopping', 3_000),
]
expect(orderTasks(rows).map(r => r.task.id)).toEqual(['live-1', 'live-2', 'old-settled', 'new-settled'])
})
})
describe('status presentation helpers', () => {
it('treats running and stopping as live', () => {
expect(isTaskLive(task('a', { status: 'running' }))).toBe(true)
expect(isTaskLive(task('b', { status: 'stopping' }))).toBe(true)
expect(isTaskLive(task('c', { status: 'completed' }))).toBe(false)
expect(isTaskLive(task('d', { status: 'killed' }))).toBe(false)
expect(isTaskLive(task('e', { status: 'failed' }))).toBe(false)
})
it('maps the five wire statuses to dot states and localized labels', () => {
expect(taskDotState('running')).toBe('ongoing')
expect(taskDotState('stopping')).toBe('warning')
expect(taskDotState('completed')).toBe('done')
expect(taskDotState('killed')).toBe('warning')
expect(taskDotState('failed')).toBe('error')
expect(taskStatusLabel('running', t)).toBe('taskStatusRunning')
expect(taskStatusLabel('stopping', t)).toBe('taskStatusStopping')
expect(taskStatusLabel('completed', t)).toBe('taskStatusCompleted')
expect(taskStatusLabel('killed', t)).toBe('taskStatusKilled')
expect(taskStatusLabel('failed', t)).toBe('taskStatusFailed')
})
it('formats durations in at most two adjacent units', () => {
expect(formatTaskDuration(0, t)).toBe('0秒')
expect(formatTaskDuration(45_000, t)).toBe('45秒')
expect(formatTaskDuration(90_000, t)).toBe('1分30秒')
expect(formatTaskDuration(3_661_000, t)).toBe('1小时1分')
// Negative or fractional input clamps to zero seconds.
expect(formatTaskDuration(-5, t)).toBe('0秒')
})
})
describe('detectNewTask', () => {
const list = (tasksBySession: Record<string, SidebarTaskView[]>): SidebarSessionList => ({
current: 'root',
byId: { root: { id: 'root', displayTitle: 'root' } },
subagentsByParent: {},
tasksBySession: tasksBySession,
})
it('fires on EVERY new task id for the session (not just the first)', () => {
expect(detectNewTask(list({}), list({ root: [task('bash-1')] }), 'root')).toBe(true)
expect(detectNewTask(
list({ root: [task('bash-1')] }),
list({ root: [task('bash-1'), task('bash-2')] }),
'root',
)).toBe(true)
})
it('stays quiet on settling, same ids, other sessions, or an absent mirror', () => {
// Settling only mutates status, never adds ids.
expect(detectNewTask(
list({ root: [task('bash-1')] }),
list({ root: [task('bash-1', { status: 'completed', finishedAt: 2_000 })] }),
'root',
)).toBe(false)
expect(detectNewTask(list({ root: [task('bash-1')] }), list({ root: [task('bash-1')] }), 'root')).toBe(false)
// Tasks owned by another session do not trigger the current one.
expect(detectNewTask(list({}), list({ child: [task('bash-1')] }), 'root')).toBe(false)
expect(detectNewTask(list({}), list({}), 'root')).toBe(false)
})
})
+219
View File
@@ -0,0 +1,219 @@
/**
* Host route tests for the background-task API ('tasks.output' / 'tasks.kill').
* 'tasks.output' REPLAYS the output the model has read so far from the owner
* session's event log (tool/call + tool/result pairs of task_output calls) —
* the model's cursor is never touched, so nothing is stolen and unread tasks
* report `read: false`. 'tasks.kill' uses the registry's stock kill, fenced
* by the owning session, with a 503 when the registry is absent.
*/
import { describe, expect, it, vi } from 'vitest'
import { buildTasksApi } from '../src/tasks-routes.ts'
import { SidebarError } from '../src/wire.ts'
import type { Context, SidebarSessionEvent } from '../src/context-types.ts'
/** A context whose `get` serves only the tasks/agents faces, with a session store. */
function ctxWith(sessions: unknown, tasks: unknown, agents: unknown): Context {
return {
sessions,
get: (key: string) => (key === 'tasks' ? tasks : key === 'agents' ? agents : undefined),
} as unknown as Context
}
/** A context that additionally captures the session/event listener (live mirror). */
function ctxWithFeed(sessions: unknown): {
ctx: Context
emit: (session: unknown, event: SidebarSessionEvent) => void
} {
let listener: ((session: unknown, event: SidebarSessionEvent) => void) | undefined
const base = ctxWith(sessions, undefined, undefined) as unknown as {
on: (event: string, fn: (session: unknown, event: SidebarSessionEvent) => void) => () => void
effect: (fn: () => void | (() => void)) => void
}
base.on = (_event: string, fn) => {
listener = fn
return () => { if (listener === fn) listener = undefined }
}
// The vendored cordis runs the registration effect immediately.
base.effect = (fn) => { fn() }
return {
ctx: base as unknown as Context,
emit: (session, event) => { listener?.(session, event) },
}
}
/** A stub live agent (the fence compares `id` only). */
const agent = (id: string) => ({ id, session: { header: { cwd: '/p' } } })
/** One task_output tool/call event. */
function taskOutputCall(seq: number, callId: string, taskId: string): SidebarSessionEvent {
return { type: 'tool/call', seq, time: seq, data: { name: 'task_output', callId, arguments: JSON.stringify({ task_id: taskId }) } }
}
/** One tool/result event carrying the finalized text the model received. */
function taskOutputResult(seq: number, callId: string, text: string, over: { isError?: boolean } = {}): SidebarSessionEvent {
return {
type: 'tool/result',
seq,
time: seq,
data: {
message: {
source: { kind: 'tool', callId },
content: [{
type: 'tool-result',
toolCallId: callId,
isError: over.isError === true,
content: [{ type: 'text', text }],
}],
},
},
}
}
/** The owner session with the given event log. */
function session(events: SidebarSessionEvent[]): unknown {
return { header: { cwd: '/p' }, events }
}
describe('tasks.output route (event replay)', () => {
it('concatenates the task_output results the model read for the task, oldest first', () => {
const events = [
taskOutputCall(0, 'c1', 'bash-1'),
taskOutputResult(1, 'c1', 'line1\nline2\n[status: running]'),
taskOutputCall(2, 'c2', 'bash-1'),
taskOutputResult(3, 'c2', 'line3\n[status: completed, exit code: 0]'),
// Another task's reads are ignored.
taskOutputCall(4, 'c3', 'bash-2'),
taskOutputResult(5, 'c3', 'other task output'),
]
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'line1\nline2\n[status: running]\nline3\n[status: completed, exit code: 0]',
truncated: false,
read: true,
})
})
it('skips the controller\'s "(no new output)" deltas and error results', () => {
const events = [
taskOutputCall(0, 'c1', 'bash-1'),
taskOutputResult(1, 'c1', 'real output\n[status: running]'),
taskOutputCall(2, 'c2', 'bash-1'),
taskOutputResult(3, 'c2', '(no new output)\n[status: running]'),
taskOutputCall(4, 'c3', 'bash-1'),
taskOutputResult(5, 'c3', 'boom', { isError: true }),
]
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'real output\n[status: running]',
truncated: false,
read: true,
})
})
it('reports read:false until the model reads the task (no registry call at all)', () => {
const events = [taskOutputCall(0, 'c1', 'bash-2')]
const tasks = { kill: vi.fn() }
const api = buildTasksApi(ctxWith({ get: () => session(events) }, tasks, undefined), 100)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
// The replay never touches the registry — the model's cursor is safe by construction.
expect(tasks.kill).not.toHaveBeenCalled()
})
it('mirrors live task_output events the store log misses (restart divergence)', () => {
// The store session is frozen at its rehydration boundary (no events);
// the read exists only on the live session/event feed.
const { ctx, emit } = ctxWithFeed({ get: () => session([]) })
const api = buildTasksApi(ctx, 512 * 1024)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
// The live feed delivers the task_output call and its result.
emit({ id: 's1' }, taskOutputCall(100, 'c-live', 'bash-1'))
emit({ id: 's1' }, taskOutputResult(101, 'c-live', 'live line\n[status: completed, exit code: 0]'))
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'live line\n[status: completed, exit code: 0]',
truncated: false,
read: true,
})
// Unrelated results are never cached (no task_output call paired them),
// and another session's feed does not leak into this one.
emit({ id: 's1' }, taskOutputResult(102, 'c-other', 'unpaired line'))
emit({ id: 's2' }, taskOutputCall(103, 'c-s2', 'bash-1'))
emit({ id: 's2' }, taskOutputResult(104, 'c-s2', 'other session line'))
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'live line\n[status: completed, exit code: 0]',
truncated: false,
read: true,
})
expect(api.output({ sessionId: 's2', id: 'bash-1' })).toEqual({
text: 'other session line',
truncated: false,
read: true,
})
})
it('merges store-log traces with live mirror traces without double-counting', () => {
// A seed read (seq 5) in the store log plus a live read (seq 106) that
// ALSO reached the store log would double-count — the seq dedupe keeps
// exactly one copy of each.
const events = [taskOutputCall(5, 'c-seed', 'bash-1'), taskOutputResult(6, 'c-seed', 'seed line')]
const { ctx, emit } = ctxWithFeed({ get: () => session(events) })
const api = buildTasksApi(ctx, 512 * 1024)
emit({ id: 's1' }, taskOutputCall(106, 'c-live', 'bash-1'))
emit({ id: 's1' }, taskOutputResult(107, 'c-live', 'live line'))
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'seed line\nlive line',
truncated: false,
read: true,
})
})
it('caps oversized replays with the truncated flag', () => {
const events = [taskOutputCall(0, 'c1', 'bash-1'), taskOutputResult(1, 'c1', 'x'.repeat(10_000))]
const api = buildTasksApi(ctxWith({ get: () => session(events) }, undefined, undefined), 100)
const value = api.output({ sessionId: 's1', id: 'bash-1' })
expect(value.text).toBe('x'.repeat(100))
expect(value.truncated).toBe(true)
expect(value.read).toBe(true)
})
it('rejects a missing sessionId or id as bad-request', () => {
const api = buildTasksApi(ctxWith({ get: () => undefined }, undefined, undefined), 100)
expect(() => api.output({ id: 'bash-1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
})
})
describe('tasks.kill route', () => {
it('kills with the forwarded reason and the live caller', () => {
const tasks = { kill: vi.fn(() => 'requested' as const) }
const agents = { get: vi.fn((id: string) => agent(id)) }
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, agents), 100)
expect(api.kill({ sessionId: 's1', id: 'bash-1', reason: 'user pressed stop' }))
.toEqual({ ok: true, outcome: 'requested' })
expect(tasks.kill).toHaveBeenCalledWith('bash-1', agent('s1'), 'user pressed stop')
})
it('defaults the reason when none is supplied', () => {
const tasks = { kill: vi.fn(() => 'already-finished' as const) }
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, undefined), 100)
expect(api.kill({ sessionId: 's1', id: 'bash-1' })).toEqual({ ok: true, outcome: 'already-finished' })
expect(tasks.kill).toHaveBeenCalledWith('bash-1', undefined, 'user requested via sidebar')
})
it('maps registry refusals to a 404 task-error', () => {
const tasks = { kill: vi.fn(() => { throw new Error('unknown task bash-9') }) }
const api = buildTasksApi(ctxWith({ get: () => undefined }, tasks, undefined), 100)
expect(() => api.kill({ sessionId: 's1', id: 'bash-9' })).toThrowError(
expect.objectContaining<Partial<SidebarError>>({ code: 'task-error', status: 404 }),
)
})
it('degrades to a 503 when the tasks registry is absent (output keeps working)', () => {
const api = buildTasksApi(ctxWith({ get: () => session([]) }, undefined, undefined), 100)
expect(() => api.kill({ sessionId: 's1', id: 'bash-1' })).toThrowError(
expect.objectContaining<Partial<SidebarError>>({ code: 'task-error', status: 503 }),
)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
})
})
+96 -5
View File
@@ -4,7 +4,7 @@ import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import {
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, resizeSplitIn, sanitizeState, setBottomHeight, splitPane, tabOpenIn, toggleBottomPanel, toggleExpanded, togglePanel,
type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts'
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
@@ -550,6 +550,66 @@ describe('sidebar state', () => {
}
})
// ── Narrow-viewport merge (bottom tabs thrown into the right sidebar) ──
it('migrateBottomTabs throws the bottom tree tabs into the right tree’s FIRST leaf', () => {
let s = state()
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
// Two bottom tabs in their own pane; the right pane holds explorer.
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:1', type: 'terminal', title: 'T1' })
s = openTabInActivePane(s, { id: 'terminal:2', type: 'terminal', title: 'T2' })
const migrated = migrateBottomTabs(s)
// All tabs now live in the right tree's first leaf, bottom tabs appended.
expect((migrated.splits as { tabs: SidebarTab[] }).tabs.map(t => t.id))
.toEqual([expect.stringMatching(/^tab:/), 'terminal:1', 'terminal:2'])
// The bottom tree is emptied (structure stays), the panel closes, and
// new tabs land in the right tree.
expect((migrated.bottomSplits as { tabs: SidebarTab[] }).tabs).toHaveLength(0)
expect(migrated.bottomOpen).toBe(false)
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
// The migrated tabs are fully functional: closing one works through the
// right tree.
expect(tabOpenIn(migrated, 'terminal:1')).toBe(true)
expect(tabOpenIn(closeTab(migrated, migrated.activePane!, 'terminal:1'), 'terminal:1')).toBe(false)
})
it('migrateBottomTabs appends into the FIRST leaf when the right tree is a split', () => {
let s = state()
s = splitPane(s, 'row') // splits the active pane into two leaves
s = toggleBottomPanel(s)
const bottomPane = (s.bottomSplits as { id: string }).id
s = openTabInActivePane({ ...s, activePane: bottomPane }, { id: 'terminal:9', type: 'terminal', title: 'T9' })
const migrated = migrateBottomTabs(s)
// The first (leftmost) leaf carries the bottom tab; the second leaf
// keeps its own tabs untouched.
const leaves = allLeaves(migrated.splits)
expect(leaves[0]!.tabs.map(t => t.id)).toContain('terminal:9')
expect(allLeaves(migrated.bottomSplits).flatMap(l => l.tabs)).toHaveLength(0)
})
it('migrateBottomTabs is idempotent (same reference) once the bottom tree is empty and closed', () => {
const s = state()
expect(migrateBottomTabs(s)).toBe(s)
// With the panel open but no tabs, the migration only closes the panel.
const open = toggleBottomPanel(s)
const migrated = migrateBottomTabs(open)
expect(migrated).not.toBe(open)
expect(migrated.bottomOpen).toBe(false)
expect(migrateBottomTabs(migrated)).toBe(migrated)
})
it('migrateBottomTabs repoints an active pane that lives in the bottom tree', () => {
let s = state()
const bottomPane = (s.bottomSplits as { id: string }).id
s = { ...s, activePane: bottomPane } // empty bottom pane, panel closed
const migrated = migrateBottomTabs(s)
expect(migrated.activePane).toBe((migrated.splits as { id: string }).id)
// A tab opened after the migration lands in the VISIBLE right tree.
const landed = openTabInActivePane(migrated, { id: 'git', type: 'git' as const, title: 'Git' })
expect((landed.splits as { tabs: SidebarTab[] }).tabs.map(t => t.type)).toContain('git')
})
it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => {
let s = state()
s = toggleBottomPanel(s)
@@ -1090,6 +1150,7 @@ describe('side card preferences', () => {
openByDefault: false,
defaultWidthPercent: 60,
autoOpenSubagent: false,
autoOpenTasks: true,
agentTerminalTools: true,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
@@ -1108,6 +1169,7 @@ describe('side card preferences', () => {
openByDefault: true,
defaultWidthPercent: 33,
autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
@@ -1126,6 +1188,7 @@ describe('side card preferences', () => {
openByDefault: false,
defaultWidthPercent: 40,
autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false,
bottomPanelAutoTerminal: true,
interceptOpenPath: true,
@@ -1145,6 +1208,11 @@ describe('side card preferences', () => {
.toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
.toBe(true)
// The task auto-open is ON by default; only an explicit false turns it off.
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenTasks: 1 }))).autoOpenTasks)
.toBe(true)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenTasks: false }))).autoOpenTasks)
.toBe(false)
})
it('defaults interceptOpenPath to true; only an explicit false turns the takeover off', async () => {
@@ -1174,9 +1242,9 @@ describe('side card preferences', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -1187,9 +1255,32 @@ describe('side card preferences', () => {
expect(openStore.getSnapshot().state?.panelOpen).toBe(true)
})
it('seeds a brand-new session COLLAPSED on narrow viewports (the panel is a full-screen drawer there)', () => {
// Stub a narrow window (the file otherwise runs without one): only
// innerWidth is read while seeding a fresh session.
const original = (globalThis as Record<string, unknown>).window
;(globalThis as Record<string, unknown>).window = {
innerWidth: 390,
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
}
try {
const store = createSidebarStore()
// Default prefs say openByDefault: true — the narrow viewport overrides
// it for the FIRST seeding only (a later user expansion persists).
store.setSession('narrow-fresh')
expect(store.getSnapshot().state?.panelOpen).toBe(false)
// The width seeding still follows the window (clamped to the floor).
expect(store.getSnapshot().state?.width).toBe(280)
} finally {
if (original === undefined) delete (globalThis as Record<string, unknown>).window
else (globalThis as Record<string, unknown>).window = original
}
})
it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
store.setSession('no-explorer')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -1197,7 +1288,7 @@ describe('side card preferences', () => {
expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the explorer tab again.
const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenTasks: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
+148 -68
View File
@@ -27,6 +27,17 @@
* - each artifact registers itself via window.__ModuleLoader__.load({id,
* factory}) with the (require) => exports CJS closure shape.
*
* Lazy chunks (lib/client-<name>.js): the heavy preview/terminal libraries
* (Univer, docx-preview, pptx-renderer, CodeMirror, xterm — tens of MB)
* build as five standalone chunk bundles (src/client/chunks/<name>.tsx),
* shared by both channels. Each script assigns its factory to the
* plugin-owned global registry (globalThis.__dshChunks__) and is fetched by
* the client on first use from the plugin's own /sidebar/bundle route —
* chunks deliberately do NOT go through the module loader (see
* src/client/chunk-loader.ts). `codeSplitting: false` keeps every chunk a
* single script; the core client.js must never statically import a chunks/
* entry.
*
* Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown.
*/
import { readFile } from 'node:fs/promises'
@@ -155,71 +166,7 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
},
// External wins for module-table entries; every other dependency inlines.
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
plugins: [{
// Bundle purity gate (mirror of the official preset): platform seed
// entries stay external, inline-safe wire layers inline, and every
// other @deepseek-ai value import is a build error — a cross-plugin
// value import either inlines a duplicate runtime instance or requires
// a specifier the frozen module table cannot answer. Cross-plugin
// collaboration goes through cordis services instead. Type-only
// imports are erased and never reach this gate.
name: 'dsh-client-bundle-purity',
resolveId(source: string) {
if (NODE_BUILTINS.has(source)) {
throw new Error(
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
+ 'select the dependency browser export or add an explicit browser implementation',
)
}
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
throw new Error(
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
)
},
}, {
name: 'dsh-css-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.css')) return null
// Relative/absolute paths resolve against the importer; bare
// specifiers (e.g. 'xterm/css/xterm.css') resolve from the package.
let abs: string
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
} else {
abs = require.resolve(source)
}
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
// CSS Modules (x.module.css) become hashed class maps; plain css
// (xterm's stylesheet) is inlined verbatim.
if (fileId.endsWith('.module.css')) {
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: `[hash]_[local]` },
minify: true,
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
return [
injectTag(pluginId, fileId, code.toString()),
`export default ${JSON.stringify(classMap)};`,
].join('\n')
}
return [
injectTag(pluginId, fileId, source.toString('utf8')),
'export default "";',
].join('\n')
},
}],
plugins: [purityGatePlugin(), makeCssPlugin(pluginId)],
outputOptions: {
entryFileNames: entryFile,
sourcemapPathTransform: browserSourcePath,
@@ -228,14 +175,144 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
intro: 'var module = { exports: {} }; var exports = module.exports;',
// The CJS wrapper factory's `require` only resolves module-table entries
// (react, cordis, ...); it cannot load relative chunk URLs in the browser.
// Disable code splitting so dynamic import() inlines into the single
// factory chunk (Office preview libs — docx-preview, Univer — pull in
// several MB but only when first opened, the trade-off for wrapper safety).
// Disable code splitting so every artifact is one script (the lazy chunk
// files themselves are separate bundles — see chunkBundle below).
codeSplitting: false,
},
}
}
/**
* One lazy chunk bundle: a heavy feature slice of the client built as a
* standalone single script (lib/client-<name>.js), fetched by the client on
* first use through the plugin's /sidebar/bundle route. The core bundle must
* never statically import the chunk entry.
*
* Chunks do NOT register with window.__ModuleLoader__: the module loader's
* import() resolves seed words / shell-own modules / registered factories /
* boot graph rows, and a chunk id is none of those — resolution would be
* version-dependent. Instead each script assigns its CJS factory to the
* plugin-owned global registry `globalThis.__dshChunks__[<name>]`, and the
* loader (src/client/chunk-loader.ts) materializes it with a require built
* from the module table's seed words.
*
* Chunk css tags use the constant plugin id `dsh-better-sidebar` (matching
* the official channel; the registry channel re-injects an identical copy
* of the shared module css — same content, no functional impact).
* @param name - chunk name; entry src/client/chunks/<name>.tsx, output
* lib/client-<name>.js. Keep in sync with CHUNK_NAMES in src/bundle-route.ts.
*/
function chunkBundle(name: string): UserConfig {
return {
entry: { [name]: `src/client/chunks/${name}.tsx` },
outDir: 'lib',
format: 'cjs',
platform: 'browser',
dts: false,
sourcemap: true,
clean: false,
external: [...CLIENT_EXTERNALS],
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
'import.meta.resolve': 'undefined',
},
alias: {
jszip: JSZIP_BROWSER_ENTRY,
xlsx: XLSX_BROWSER_ENTRY,
},
inputOptions: {
resolve: {
conditionNames: ['browser', 'import', 'require', 'default'],
},
},
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
plugins: [purityGatePlugin(), makeCssPlugin('dsh-better-sidebar')],
outputOptions: {
entryFileNames: `client-${name}.js`,
sourcemapPathTransform: browserSourcePath,
banner: `globalThis.__dshChunks__ = globalThis.__dshChunks__ || {}; globalThis.__dshChunks__[${JSON.stringify(name)}] = (require) => {`,
footer: 'return module.exports; };',
intro: 'var module = { exports: {} }; var exports = module.exports;',
codeSplitting: false,
},
}
}
/** A rolldown plugin as tsdown's config accepts it (contextual `this` for load/resolveId). */
type BuildPlugin = NonNullable<UserConfig['plugins']>
/** The shared client-bundle purity gate (see the clientBundle doc). */
function purityGatePlugin(): BuildPlugin {
return {
name: 'dsh-client-bundle-purity',
resolveId(source: string) {
if (NODE_BUILTINS.has(source)) {
throw new Error(
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
+ 'select the dependency browser export or add an explicit browser implementation',
)
}
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
throw new Error(
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
)
},
}
}
/** The shared CSS-inline virtual-module plugin (one <style data-plugin> per file). */
function makeCssPlugin(pluginId: string): BuildPlugin {
return {
name: 'dsh-css-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.css')) return null
// Relative/absolute paths resolve against the importer; bare
// specifiers (e.g. 'xterm/css/xterm.css') resolve from the package.
let abs: string
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
} else {
abs = require.resolve(source)
}
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
// CSS Modules (x.module.css) become hashed class maps; plain css
// (xterm's stylesheet) is inlined verbatim.
if (fileId.endsWith('.module.css')) {
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: `[hash]_[local]` },
minify: true,
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
return [
injectTag(pluginId, fileId, code.toString()),
`export default ${JSON.stringify(classMap)};`,
].join('\n')
}
return [
injectTag(pluginId, fileId, source.toString('utf8')),
'export default "";',
].join('\n')
},
}
}
/** The lazy chunk names (keep in sync with src/bundle-route.ts CHUNK_NAMES). */
const CHUNKS = ['docx', 'xlsx', 'pptx', 'terminal', 'editor']
export default [
{
entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' },
@@ -254,4 +331,7 @@ export default [
clientBundle('dsh-better-sidebar', 'client.js'),
// Plugin-registry channel: bundle id = manifest id (dsh.plugin.json `id`).
clientBundle('dsh-external/dsh-better-sidebar', 'client-registry.js'),
// Lazy chunks: shared by both channels, fetched on first use through the
// plugin's /sidebar/bundle route (see src/client/chunk-loader.ts).
...CHUNKS.map(chunkBundle),
] satisfies UserConfig[]