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. 服务定位 ## 1. 服务定位
- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`) - **服务名**:`betterSidebar`(即 `ctx.betterSidebar`)
@@ -364,6 +372,9 @@ interface BetterSidebarService {
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子, * url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子,
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。 * 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。
* 被设置禁用的类型是 no-op(console.warn 提示)。 * 被设置禁用的类型是 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 openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
/** 关闭一个 tab */ /** 关闭一个 tab */
@@ -396,6 +407,7 @@ interface BetterSidebarService {
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 | | **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 | | **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) | | **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 # 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)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径 - **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ HTML(沙箱 iframe 预览,相对资源可加载)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿 - **📝 编辑与预览**: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_*` 工具 - **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取 - **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
- **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开 - **🧩 任务管理页**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开;**同页显示后台命令**(当前树全部任务,bash/pwsh 类型徽标 + 退出码,点击查看实时输出——自动跟随底部、非消费 peek,不干扰模型的 `task_output`;两击确认可强制终止)
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠 - **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅 - **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开 - **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开)
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑 - **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md)) - **🔌 服务化**:暴露 `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),首次加载较慢 - `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制);Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
- 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈 - 浏览器沙箱无登录态/第三方 Cookie 受限,部分站点登录需走弹窗;被 `X-Frame-Options`/`frame-ancestors` 拒绝嵌入的站点(如 arxiv.org)显示原因面板(含「在浏览器中打开」);iframe 内部跳转不进后退栈
- HTML 预览渲染的是已保存文件(不反映未保存草稿) - 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", "id": "dsh-external/dsh-better-sidebar",
"version": "0.6.0", "version": "0.8.1",
"main": "./lib/index.js", "main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": { "engines": {
+7 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dsh-better-sidebar", "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.", "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, "private": true,
"type": "module", "type": "module",
@@ -47,6 +47,12 @@
"lib/index.js", "lib/index.js",
"lib/invariant.js", "lib/invariant.js",
"lib/client.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", "lib/types/**/*.d.ts",
"src" "src"
], ],
+12
View File
@@ -29,6 +29,18 @@ const files = [
'lib/invariant.js', 'lib/invariant.js',
'lib/client-registry.js', 'lib/client-registry.js',
'lib/client-registry.js.map', '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', 'README.md',
'LICENSE', '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 / * content to the views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer). Tabs live in one tree only — * terminal; editors open from the explorer). Tabs live in one tree only —
* they never cross panels; only the panel sizes drag against each other. * 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 { useSyncExternalStore } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts' import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
import { 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, reconcileAgentTerminals,
resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel, resizeSplitIn, setBottomHeight, setWidth, toggleBottomPanel, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts' } from './state.ts'
import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx' import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
import { Workbench, type WorkbenchActions } from './split-pane.tsx' import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import { useNarrowViewport } from './breakpoints.ts'
import type { NewTabOption } from './TabBar.tsx' import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx' import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts' import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx' import { OrphanedTab } from './OrphanedTab.tsx'
import { detectNewDirectSubagent } from './subagent-detect.ts' import { detectNewDirectSubagent } from './subagent-detect.ts'
import { detectNewTask } from './subagent-tasks.ts'
import { t } from './locales.ts' import { t } from './locales.ts'
import { api, type SessionScope } from './api.ts' import { api, type SessionScope } from './api.ts'
import css from './sidebar.module.css' 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 }) { export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { ctx, store } = props 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). // Current conversation (the sessions list feed).
const sessionList = useSyncExternalStore( const sessionList = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]), 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 sessionId = snapshot.sessionId
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd 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), // 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 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 // 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') }) ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx]) }, [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 * Topology jump-back: clicking a subagent node on the Subagent page calls
* the official `openSubagent`, which switches the sidebar to that child * 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) const bottomWasOpenRef = useRef<boolean | undefined>(undefined)
useEffect(() => { 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 if (state === undefined) return
const wasOpen = bottomWasOpenRef.current const wasOpen = bottomWasOpenRef.current
bottomWasOpenRef.current = state.bottomOpen 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. // atomically so later expansions never repeat the auto-open.
store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true })) store.reduce(s => ({ ...s, activePane: firstLeaf(s.bottomSplits).id, bottomOpenedOnce: true }))
ctx.betterSidebar?.openTab({ type: 'terminal' }) 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 drags: the right panel's width (left edge strip), the bottom
// panel's height (top edge strip), and the shared corner (both at once). // 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 // 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 // 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. // 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(() => { useEffect(() => {
const width = snapshot.state?.panelOpen === true const width = !narrow && snapshot.state?.panelOpen === true
? Math.min(snapshot.state.width, window.innerWidth) ? Math.min(snapshot.state.width, window.innerWidth)
: 0 : 0
const height = snapshot.state?.bottomOpen === true const height = !narrow && snapshot.state?.bottomOpen === true
? Math.min(snapshot.state.bottomHeight, window.innerHeight) ? Math.min(snapshot.state.bottomHeight, window.innerHeight)
: 0 : 0
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`) document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}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(() => { useEffect(() => {
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '') if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
else document.body.removeAttribute('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) { if (state === undefined || sessionId === undefined) {
return ( return (
<div className={css.toggleCluster}> <div className={css.toggleCluster}>
<Tooltip label={t('noSession')} side="bottom" delayMs={500}> {!narrow && (
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}> <Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<IconPanelBottomOutline16 /> <button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
</button> <IconPanelBottomOutline16 />
</Tooltip> </button>
</Tooltip>
)}
<Tooltip label={t('noSession')} side="bottom" delayMs={500}> <Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}> <button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelRightOutline16 /> <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 * ctx and the conversation input service at click time; a missing service
* or scope degrades to a logged no-op, never a crash. * 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 * 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 * 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. so the tabs genuinely yield space to it.
*/} */}
<div className={css.toggleCluster}> <div className={css.toggleCluster}>
<Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}> {/*
<button Narrow viewports merge the two workbenches into the one drawer —
type="button" there is no bottom panel, so its toggle button is not offered.
className={css.toggleButton} */}
aria-label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} {!narrow && (
onClick={() => { store.reduce(toggleBottomPanel) }} <Tooltip label={state.bottomOpen ? t('collapseBottomPanel') : t('expandBottomPanel')} side="bottom" delayMs={500}>
> <button
<IconPanelBottomOutline16 /> type="button"
</button> className={css.toggleButton}
</Tooltip> 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}> <Tooltip label={state.panelOpen ? t('collapse') : t('expand')} side="bottom" delayMs={500}>
<button <button
type="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 the slide in/out can animate; visibility hides it after the slide
settles. Its bottom edge follows the bottom panel's height (0 while settles. Its bottom edge follows the bottom panel's height (0 while
the bottom panel is closed) — the VSCode-style "sidebar above panel". 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 <div
ref={panelRef} ref={panelRef}
className={clsx(css.panel, !state.panelOpen && css.panelHidden)} 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} data-dragging={anyDragging || undefined}
> >
<div {!narrow && (
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)} <div
onPointerDown={(event) => { className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
event.preventDefault() onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId) event.preventDefault()
widthDrag.current = { startX: event.clientX, startWidth: state.width } event.currentTarget.setPointerCapture(event.pointerId)
setDraggingWidth(true) widthDrag.current = { startX: event.clientX, startWidth: state.width }
}} setDraggingWidth(true)
onPointerMove={(event) => { }}
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return onPointerMove={(event) => {
const { startX, startWidth } = widthDrag.current if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const width = clampWidth(startWidth + (startX - event.clientX)) const { startX, startWidth } = widthDrag.current
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0 const width = clampWidth(startWidth + (startX - event.clientX))
scheduleDrag(width, height) const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
}} scheduleDrag(width, height)
onPointerUp={(event) => { }}
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return onPointerUp={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId) if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current event.currentTarget.releasePointerCapture(event.pointerId)
stopDragScheduling() const { startX, startWidth } = widthDrag.current
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX))) stopDragScheduling()
setDraggingWidth(false) store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
}} setDraggingWidth(false)
/> }}
/>
)}
<div className={css.panelBody}> <div className={css.panelBody}>
<Workbench <Workbench
state={state} state={state}
@@ -576,11 +656,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
actions={actions} actions={actions}
onNewTab={onNewTab} onNewTab={onNewTab}
renderTab={renderTab} renderTab={renderTab}
getTabIcon={(tab) => { getTabIcon={tabIconOf}
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}}
/> />
</div> </div>
</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 — 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 neither sidebar gives up any position (the right panel keeps its
full height). Its resize strip is the top edge; hidden by sliding 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 <div
ref={bottomRef} ref={bottomRef}
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)} className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
@@ -655,21 +733,19 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
actions={actions} actions={actions}
onNewTab={onNewTab} onNewTab={onNewTab}
renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)} renderTab={(tab, active, paneId) => renderTab(tab, active, paneId, true)}
getTabIcon={(tab) => { getTabIcon={tabIconOf}
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}}
/> />
</div> </div>
</div> </div>
)}
{/* {/*
The shared corner (only while BOTH panels are open): the intersection 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. 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 Horizontal drags resize the right panel's width, vertical drags the
bottom panel's height — the two panels drag against each other. 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 <div
ref={cornerRef} ref={cornerRef}
className={css.cornerHandle} className={css.cornerHandle}
+272
View File
@@ -273,3 +273,275 @@
background: var(--dsw-alias-interactive-bg-hover); background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary); 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' } from '@deepseek-ai/dsh-client-ui-primitives'
import type { import type {
Context, Context,
SidebarSessionList,
SidebarSessionSummary, SidebarSessionSummary,
SidebarSubagentAddress, SidebarSubagentAddress,
SidebarSubagentCatalog, SidebarSubagentCatalog,
SidebarSubagentChildEntry, SidebarSubagentChildEntry,
SidebarSubagentDiagnosticEntry, SidebarSubagentDiagnosticEntry,
SidebarTaskView,
} from '../context-types.ts' } from '../context-types.ts'
import { import {
collectBranchIds, collectBranchIds,
@@ -39,6 +41,17 @@ import {
rootAncestor, rootAncestor,
} from './subagent-detect.ts' } from './subagent-detect.ts'
import { lastActivity } from './subagent-activity.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 { t } from './locales.ts'
import css from './SubagentView.module.css' import css from './SubagentView.module.css'
@@ -46,6 +59,10 @@ import css from './SubagentView.module.css'
const POLL_MS = 3000 const POLL_MS = 3000
/** Preview cap of one tool-call argument line. */ /** Preview cap of one tool-call argument line. */
const ARGS_PREVIEW = 60 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). */ /** The direct subagent children of one parent (durable `origin` rows). */
function directChildren( 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. * The sidebar's Subagent topology page.
* @param props - current session id, whether the page is actually visible * @param props - current session id, whether the page is actually visible
@@ -515,69 +788,78 @@ export function SubagentView(props: {
<div <div
ref={bodyRef} ref={bodyRef}
className={css.subagentBody} className={css.subagentBody}
role="tree"
aria-label={t('subagent')}
aria-busy={summaryBackedLoading || undefined}
onKeyDown={onTreeKeyDown} onKeyDown={onTreeKeyDown}
> >
{rootId !== undefined && rootSummary !== undefined && ( <div
<div role="tree"
role="treeitem" aria-label={t('subagent')}
tabIndex={0} aria-busy={summaryBackedLoading || undefined}
aria-level={0} >
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`} {rootId !== undefined && rootSummary !== undefined && (
aria-current={rootId === sessionId ? 'true' : undefined} <div
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)} role="treeitem"
onClick={openMain} tabIndex={0}
onKeyDown={(event) => { aria-level={0}
if (event.key === 'Enter' || event.key === ' ') { aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
event.preventDefault() aria-current={rootId === sessionId ? 'true' : undefined}
event.stopPropagation() className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
openMain() onClick={openMain}
} onKeyDown={(event) => {
}} if (event.key === 'Enter' || event.key === ' ') {
> event.preventDefault()
<StateDot event.stopPropagation()
state={rootSummary.running === true ? 'ongoing' : 'done'} openMain()
className={css.subagentDot} }
/> }}
<span className={css.subagentContent}> >
<span className={css.subagentLabel}> <StateDot
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} state={rootSummary.running === true ? 'ongoing' : 'done'}
</span> className={css.subagentDot}
<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}
/> />
)} <span className={css.subagentContent}>
</div> <span className={css.subagentLabel}>
)} {rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
{readyEmpty && ( </span>
<div className={css.subagentEmpty}> <span className={css.subagentSecondary}>
<div>{t('subagentEmpty')}</div> {`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div> </span>
</div> </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>
</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. */ * `head` carries the first bytes (base64) for viewer detect sniffing. */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string } 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> { async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response let response: Response
try { try {
@@ -145,6 +160,19 @@ export const api = {
/** Release an agent terminal by uuid (tab closed while WS was down). */ /** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) => agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }), 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). */ /** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () => settingsGet: () =>
call<{ value?: unknown; revision?: number }>('settings.get', {}), 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 { openSidebarFile } from '../intercept.tsx'
import { ExplorerView } from '../ExplorerView.tsx' import { ExplorerView } from '../ExplorerView.tsx'
import { EditorHost } from '../EditorHost.tsx' import { EditorHost } from '../EditorHost.tsx'
import { TerminalView } from '../TerminalView.tsx' import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx' import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx' import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx' import { SubagentView } from '../SubagentView.tsx'
import { BrowserView } from '../BrowserView.tsx' import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.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' 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). */ /** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
export const TERMINAL_LIMIT = 3 export const TERMINAL_LIMIT = 3
@@ -82,13 +109,17 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
icon: (size: number) => <IconThinkOutline16 size={size} />, icon: (size: number) => <IconThinkOutline16 size={size} />,
order: 30, order: 30,
single: true, single: true,
// Declarative settings: the auto-open switch renders under this row in // Declarative settings: the auto-open switches render under this row in
// the Side card settings page (the Subagent page's own related setting). // the Side card settings page (the Tasks page's own related settings).
settings: { settings: {
toggles: [{ toggles: [{
key: 'autoOpenSubagent', key: 'autoOpenSubagent',
title: () => t('settingsSubagentTitle'), title: () => t('settingsSubagentTitle'),
desc: () => t('settingsSubagentDesc'), desc: () => t('settingsSubagentDesc'),
}, {
key: 'autoOpenTasks',
title: () => t('settingsTasksTitle'),
desc: () => t('settingsTasksDesc'),
}], }],
}, },
component: ({ ctx, scope, visible, onSubagentJump }) => ( component: ({ ctx, scope, visible, onSubagentJump }) => (
@@ -133,9 +164,7 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
patch: { nextTerminal: state.nextTerminal + 1 }, patch: { nextTerminal: state.nextTerminal + 1 },
} }
}, },
component: ({ scope, tab, store }) => ( component: ({ tab, scope, store }) => <LazyTerminal scope={scope} store={store} tabId={tab.id} />,
<TerminalView scope={scope} tabId={tab.id} store={store} />
),
}, },
{ {
id: 'browser', id: 'browser',
+28 -16
View File
@@ -6,15 +6,21 @@
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all * binaries and serves doc/xls/ppt by extension; `code` is the catch-all
* (`exts: []`, lowest priority) that claims any file no other viewer did. * (`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` * Every viewer carries the declarative settings-surface fields — `title`
* and `icon` — so the Side card settings page can render the enable/disable * and `icon` — so the Side card settings page can render the enable/disable
* inventory without hardcoding (eating our own dogfood). * inventory without hardcoding (eating our own dogfood).
*/ */
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' 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 { PdfView } from '../PdfView.tsx'
import { PptxView } from '../PptxView.tsx'
import { TextEditor } from '../TextEditor.tsx'
import { BinaryDownload } from '../binary-download.tsx' import { BinaryDownload } from '../binary-download.tsx'
import { import {
IconImageOutline16, IconImageOutline16,
@@ -25,10 +31,22 @@ import {
IconPptxOutline16, IconPptxOutline16,
IconHtmlOutline16, IconHtmlOutline16,
} from '../icons.tsx' } 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 { t } from '../locales.ts'
import css from '../sidebar.module.css' 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. */ /** The 9 built-in file viewer descriptors. */
export function builtinViewers(): readonly FileViewerDescriptor[] { export function builtinViewers(): readonly FileViewerDescriptor[] {
return [ return [
@@ -60,9 +78,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconDocxOutline16 size={size} />, icon: (size: number) => <IconDocxOutline16 size={size} />,
exts: ['docx'], exts: ['docx'],
fetchStrategy: 'mediaUrl', fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => ( component: (props) => <LazyDocx {...props} />,
<DocxView scope={scope} path={path} title={title} />
),
}, },
{ {
id: 'xlsx', id: 'xlsx',
@@ -70,9 +86,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconXlsxOutline16 size={size} />, icon: (size: number) => <IconXlsxOutline16 size={size} />,
exts: ['xlsx'], exts: ['xlsx'],
fetchStrategy: 'mediaUrl', fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => ( component: (props) => <LazyXlsx {...props} />,
<XlsxView scope={scope} path={path} title={title} />
),
}, },
{ {
id: 'pptx', id: 'pptx',
@@ -80,9 +94,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconPptxOutline16 size={size} />, icon: (size: number) => <IconPptxOutline16 size={size} />,
exts: ['pptx'], exts: ['pptx'],
fetchStrategy: 'mediaUrl', fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => ( component: (props) => <LazyPptx {...props} />,
<PptxView scope={scope} path={path} title={title} />
),
}, },
{ {
id: 'markdown', id: 'markdown',
@@ -90,7 +102,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
icon: (size: number) => <IconMarkdownOutline16 size={size} />, icon: (size: number) => <IconMarkdownOutline16 size={size} />,
exts: ['md', 'markdown'], exts: ['md', 'markdown'],
fetchStrategy: 'fsRead', fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />, component: (props) => <LazyTextEditor {...props} />,
}, },
{ {
id: 'html', id: 'html',
@@ -112,7 +124,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
desc: () => t('settingsHtmlDefaultUnsafeDesc'), desc: () => t('settingsHtmlDefaultUnsafeDesc'),
}], }],
}, },
component: (props) => <TextEditor {...props} />, component: (props) => <LazyTextEditor {...props} />,
}, },
{ {
id: 'code', id: 'code',
@@ -121,7 +133,7 @@ export function builtinViewers(): readonly FileViewerDescriptor[] {
exts: [], exts: [],
priority: -100, priority: -100,
fetchStrategy: 'fsRead', fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />, component: (props) => <LazyTextEditor {...props} />,
}, },
{ {
id: 'binary-download', 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> </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) ──────────────── // ── File-viewer inventory glyphs (Side card settings page) ────────────────
/** Image viewer glyph: a picture frame with a sun and a mountain. */ /** 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 type { Context } from '../context-types.ts'
import { createSidebarStore } from './state.ts' import { createSidebarStore } from './state.ts'
import { createBetterSidebarService } from './service.ts' import { createBetterSidebarService } from './service.ts'
import { resetChunks } from './chunk-loader.ts'
import { registerBuiltins } from './builtins/index.ts' import { registerBuiltins } from './builtins/index.ts'
import { Sidebar } from './Sidebar.tsx' import { Sidebar } from './Sidebar.tsx'
import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx' import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx'
@@ -103,6 +104,10 @@ export function apply(ctx: Context): void {
} }
} }
try { 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(() => { ctx.effect(() => {
let disposed = false let disposed = false
let root: Root | undefined 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: '下一页', nextSlide: '下一页',
zoom: '缩放', zoom: '缩放',
zoomHint: 'Alt + 滚轮', zoomHint: 'Alt + 滚轮',
settingsSubagentTitle: '检测到子代理时自动展开子代理页面', settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开', settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
settingsTasksTitle: '有新后台任务时自动展开任务管理页',
settingsTasksDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开任务管理页(每个新任务都会触发);关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具', settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)', settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsBottomTerminalTitle: '底部面板首次展开自动开终端', settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
@@ -165,8 +167,8 @@ const zh = {
browserEmbedBlocked: '{host} 拒绝了嵌入请求', browserEmbedBlocked: '{host} 拒绝了嵌入请求',
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开', browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
browserEmbedAnyway: '仍然加载', browserEmbedAnyway: '仍然加载',
subagent: '子代理', subagent: '任务管理',
openSubagent: '子代理', openSubagent: '任务管理',
subagentMainAgent: '主代理', subagentMainAgent: '主代理',
subagentEmpty: '暂无子代理', subagentEmpty: '暂无子代理',
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里', subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
@@ -180,6 +182,26 @@ const zh = {
subagentDiagUnsupported: '不支持的条目', subagentDiagUnsupported: '不支持的条目',
subagentDiagUnavailable: '不可用', subagentDiagUnavailable: '不可用',
subagentThinking: '思考中…', 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> = { const en: Record<keyof typeof zh, string> = {
@@ -296,8 +318,10 @@ const en: Record<keyof typeof zh, string> = {
nextSlide: 'Next', nextSlide: 'Next',
zoom: 'Zoom', zoom: 'Zoom',
zoomHint: 'Alt + wheel', zoomHint: 'Alt + wheel',
settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears', settingsSubagentTitle: 'Auto-open the Tasks 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', 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', 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)', 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', 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', 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.', 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', browserEmbedAnyway: 'Load anyway',
subagent: 'Subagents', subagent: 'Tasks',
openSubagent: 'Subagents', openSubagent: 'Tasks',
subagentMainAgent: 'Main agent', subagentMainAgent: 'Main agent',
subagentEmpty: 'No subagents', subagentEmpty: 'No subagents',
subagentEmptyDesc: 'Subagents spawned under the main agent will appear here', subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
@@ -357,6 +381,26 @@ const en: Record<keyof typeof zh, string> = {
subagentDiagUnsupported: 'Unsupported', subagentDiagUnsupported: 'Unsupported',
subagentDiagUnavailable: 'Unavailable', subagentDiagUnavailable: 'Unavailable',
subagentThinking: 'Thinking…', 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). */ /** 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 * Compat re-export shim for the historical `src/client/office-view.tsx`
* (preserved styles/images/tables), {@link XlsxView} renders .xlsx via the * path. The office preview views were split into docx-view.tsx /
* Univer sheets preset (data + formulas + formatting). Both load the file * xlsx-view.tsx (+ office-shared.tsx) so the bundler can emit each library
* bytes through the existing /sidebar/file media route and own their library * as its own lazy chunk (see docs/plans/2026-08-12-lazy-chunks-design.md).
* lifecycle (dispose on unmount so canvases/workers don't leak — mirrors the * This module keeps deep imports of this path resolvable through the
* TerminalView dispose discipline). * `./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
* Errors degrade to the same download-button affordance the binary * drag both libraries into that bundle).
* 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).
*/ */
import { useEffect, useRef, useState } from 'react' export { DocxView } from './docx-view.tsx'
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts' export { XlsxView } from './xlsx-view.tsx'
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>
)
}
+3
View File
@@ -40,6 +40,9 @@ export function parsePrefs(value: unknown): SidebarPrefs {
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean' autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
? record.autoOpenSubagent ? record.autoOpenSubagent
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent, : SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
autoOpenTasks: typeof record.autoOpenTasks === 'boolean'
? record.autoOpenTasks
: SIDEBAR_PREFS_DEFAULTS.autoOpenTasks,
agentTerminalTools: typeof record.agentTerminalTools === 'boolean' agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools ? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools, : SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
+39 -3
View File
@@ -22,9 +22,10 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts' import type { Context } from '../context-types.ts'
import { import {
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, togglePanel, treeOf,
type SidebarState, type SidebarStore, type SidebarTab, type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts' } from './state.ts'
import { isNarrowWidth } from './breakpoints.ts'
import type { SessionScope } from './api.ts' import type { SessionScope } from './api.ts'
/** One declarative boolean setting of a tab/viewer, rendered as a nested /** 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 * 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 * usually pairs it with a hostname `title`). A disabled tab type is a
* no-op. * 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 openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
/** Close a tab by id. */ /** 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 // 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 // on mount). An explicit seed.title also wins over a createTab-minted
// default title (e.g. the sidebar-browser's hostname title). // default title (e.g. the sidebar-browser's hostname title).
let landed: SidebarState
if (seed.url !== undefined) { if (seed.url !== undefined) {
return patchTab(next, tab.id, { landed = patchTab(next, tab.id, {
path: seed.url, path: seed.url,
...(seed.title !== undefined ? { title: seed.title } : {}), ...(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; 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. * 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 { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
import { isNarrowWidth } from './breakpoints.ts'
/** /**
* Tab type identifier. Builtins register their ids (explorer / git / editor * 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]!) 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. */ /** Find the leaf containing a tab id, if any. */
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined { export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
if (node.kind === 'leaf') { 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 // 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 // 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 // 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 viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
const width = viewport === undefined const width = viewport === undefined
? PANEL_DEFAULT ? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent) : 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), openByDefault: z.boolean().default(true),
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT), defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
autoOpenSubagent: z.boolean().default(true), autoOpenSubagent: z.boolean().default(true),
autoOpenTasks: z.boolean().default(true),
agentTerminalTools: z.boolean().default(false), agentTerminalTools: z.boolean().default(false),
bottomPanelAutoTerminal: z.boolean().default(true), bottomPanelAutoTerminal: z.boolean().default(true),
interceptOpenPath: 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). */ /** The host session store face (`ctx.sessions.get(id)` returns the live session). */
export interface SidebarSessionStore { 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). */ /** One loader entry's options slice (the connection row's resolved config). */
@@ -150,6 +158,42 @@ export interface SidebarHistoryEntry {
view?: unknown 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). */ /** RPC result slot mirror (`RpcResult<T>` on the wire). */
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } } 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> byId: Record<string, SidebarSessionSummary>
/** Direct durable catalogs keyed by their selected parent address. */ /** Direct durable catalogs keyed by their selected parent address. */
subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>> 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). */ /** The client sessions service face (only the list feed is needed). */
@@ -322,12 +372,31 @@ declare module 'cordis' {
settings: SidebarSettingsService settings: SidebarSettingsService
invariants: SidebarInvariantsService invariants: SidebarInvariantsService
tools: SidebarToolsService 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 * The client-side sidebar registry: external plugins register tab types
* and file previewers here. Provided by the client half (see * and file previewers here. Provided by the client half (see
* {@link ./client/index.tsx}); undefined on the host side. * {@link ./client/index.tsx}); undefined on the host side.
*/ */
betterSidebar: BetterSidebarService 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 * Register a lifecycle callback (DSH-vendored cordis): runs at plugin
* activation; its returned cleanup runs at disposal. * 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 * dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
* read/write, git), the /sidebar/file media route (images), and the terminal * read/write, git), the /sidebar/file media route (images), the /sidebar/html
* WebSocket upgrade. Every route passes the same browser-trust fence as the * preview route, the /sidebar/bundle lazy-chunk route (client code splits),
* /api gateway — Host-header loopback or the connection row's `trustedHosts` * and the terminal WebSocket upgrade. Every route passes the same
* (the `dsh web` launcher derives LAN IP literals per boot) — with the * browser-trust fence as the /api gateway — Host-header loopback or the
* trustedHosts read live from the connection loader row so the fence never * connection row's `trustedHosts` (the `dsh web` launcher derives LAN IP
* drifts from the deployment's. * 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 * All operations are conversation-scoped: requests carry a sessionId, the
* session's authoritative cwd comes from the session store, and terminal * 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 { decodeHtmlUrl } from './html-route.ts'
import { extractFrameAncestors } from './browser-probe.ts' import { extractFrameAncestors } from './browser-probe.ts'
import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts' import { isTrustedApiRequest, isLoopbackHostname } from './trust-fence.ts'
import { registerBundleRoute } from './bundle-route.ts'
import * as git from './git.ts' import * as git from './git.ts'
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts' import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts' import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
import { registerTools } from './tools.ts' import { registerTools } from './tools.ts'
import { buildTasksApi, type SidebarTasksRoutes } from './tasks-routes.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
export { Config } export { Config }
@@ -196,6 +199,12 @@ function buildApi(
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) } 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 { return {
'session.cwd': (payload) => { 'session.cwd': (payload) => {
const { sessionId, cwd } = cwdOf(payload) const { sessionId, cwd } = cwdOf(payload)
@@ -325,6 +334,14 @@ function buildApi(
agentPtyRegistry.close(uuid) agentPtyRegistry.close(uuid)
return { ok: true } 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 // The side card preferences. The settings service is optional in the
// composition; while absent the routes report undefined and the client // composition; while absent the routes report undefined and the client
// keeps the schema defaults. Writes are revision-guarded: a stale editor // 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') }), '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) ───────────────────────────────── // ── Media route (images for the editor) ─────────────────────────────────
ctx.effect(() => ctx.httpServer.register({ ctx.effect(() => ctx.httpServer.register({
kind: 'prefix', kind: 'prefix',
+7
View File
@@ -20,6 +20,12 @@ export interface SidebarPrefs {
* Subagent page when the current conversation spawns a new subagent. * Subagent page when the current conversation spawns a new subagent.
*/ */
autoOpenSubagent: boolean 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 / * Whether the model-facing agent terminal tools (terminal_create / list /
* send / read / wait_for / resize / signal / close) are injected into the * send / read / wait_for / resize / signal / close) are injected into the
@@ -103,6 +109,7 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true, openByDefault: true,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT, defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
interceptOpenPath: 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' | 'fs-error'
| 'git-error' | 'git-error'
| 'pty-error' | 'pty-error'
| 'task-error'
| 'settings-rejected' | 'settings-rejected'
| 'settings-conflict' | 'settings-conflict'
| 'internal' | '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> => ({ const elementStub = (): Record<string, unknown> => ({
style: {}, style: {},
dataset: {},
textContent: '',
classList: { add: () => {}, remove: () => {}, contains: () => false }, classList: { add: () => {}, remove: () => {}, contains: () => false },
setAttribute: () => {}, setAttribute: () => {},
getAttribute: () => null, getAttribute: () => null,
@@ -32,9 +34,12 @@ if (g.document === undefined) {
removeEventListener: () => {}, removeEventListener: () => {},
defaultView: globalThis, defaultView: globalThis,
body: elementStub(), 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(), documentElement: elementStub(),
head: elementStub(), head: elementStub(),
querySelector: () => null,
querySelectorAll: () => [],
styleSheets: [], 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 { service } = setup()
const toggles = service.getTab('subagent')?.settings?.toggles ?? [] 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', () => { 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]! 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)', () => { describe('registry manifest consistency (dsh.plugin.json)', () => {
it('id is a valid two-segment registry id (native form)', () => { it('id is a valid two-segment registry id (native form)', () => {
expect(manifest.id).toMatch(ID_PATTERN) expect(manifest.id).toMatch(ID_PATTERN)
@@ -86,13 +97,20 @@ describe('registry manifest consistency (dsh.plugin.json)', () => {
expect(registryId).not.toBe(pkg.name) 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', () => { 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') const source = readFileSync(resolve(ROOT, file), 'utf8')
// Exclude method calls such as `freeModule.require("util")`; only the // Exclude method calls such as `freeModule.require("util")`; only the
// loader factory's lexical require() is constrained by the module table. // loader factory's lexical require() is constrained by the module table.
const required = [...source.matchAll(/(^|[^.$\w])require\("([^"]+)"\)/gm)].map(match => match[2]!) 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.openByDefault).toBe(true)
expect(resolved.defaultWidthPercent).toBe(30) expect(resolved.defaultWidthPercent).toBe(30)
expect(resolved.autoOpenSubagent).toBe(true) 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 // The terminal tools default OFF (the feature is dormant until the user
// enables it in the side card settings). // enables it in the side card settings).
expect(resolved.agentTerminalTools).toBe(false) expect(resolved.agentTerminalTools).toBe(false)
@@ -57,6 +59,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as { const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown> (input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 }) })({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, 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) 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 /** The settings service never appears in the smoke context: the inject
* callback must never run (mirror of cordis' service-less inject). */ * callback must never run (mirror of cordis' service-less inject). */
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void 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', () => { describe('host plugin smoke', () => {
@@ -56,9 +58,11 @@ describe('host plugin smoke', () => {
// No settings service in the smoke context: the registration callback // No settings service in the smoke context: the registration callback
// never runs (cordis' service-less inject behaves the same). // never runs (cordis' service-less inject behaves the same).
inject: () => () => {}, inject: () => () => {},
// No tasks/agents services: the tasks routes degrade to a 503.
get: () => undefined,
} }
apply(ctx as never) 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']) expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
// Teardown runs without throwing (pty manager has nothing open). // Teardown runs without throwing (pty manager has nothing open).
for (const cleanup of effects) cleanup() for (const cleanup of effects) cleanup()
@@ -315,6 +319,8 @@ describe('session cwd resolution over the API route', () => {
effect: (fn: () => void | (() => void)) => { fn() }, effect: (fn: () => void | (() => void)) => { fn() },
// No settings service: the namespace registration never runs. // No settings service: the namespace registration never runs.
inject: () => () => {}, inject: () => () => {},
// No tasks/agents services in the smoke context: the routes degrade.
get: () => undefined,
} }
apply(ctx as never) apply(ctx as never)
return routes.find(route => route.path === '/sidebar/api')! 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 }) if (deps.includes('settings') && settings !== undefined) callback({ settings })
return () => {} return () => {}
}, },
// No tasks/agents services: the tasks routes degrade to a 503.
get: () => undefined,
} }
apply(ctx as never) apply(ctx as never)
return routes.find(route => route.path === '/sidebar/api')! return routes.find(route => route.path === '/sidebar/api')!
@@ -506,6 +514,7 @@ describe('side card settings routes', () => {
openByDefault: true, openByDefault: true,
defaultWidthPercent: 30, defaultWidthPercent: 30,
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
interceptOpenPath: true, interceptOpenPath: true,
@@ -647,6 +656,8 @@ describe('agent terminal tool gating', () => {
if (deps.includes('settings')) callback({ settings }) if (deps.includes('settings')) callback({ settings })
return () => {} return () => {}
}, },
// No tasks/agents services: the tasks routes degrade to a 503.
get: () => undefined,
} }
apply(ctx as never) apply(ctx as never)
// Default off: no tools are registered even though the settings service is mounted. // 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 { parseUnifiedDiff } from '../src/client/DiffView.tsx'
import { import {
activateTab, allLeaves, BOTTOM_DEFAULT, BOTTOM_MIN, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState, 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, type SidebarState, type SidebarTab, type SplitNode,
} from '../src/client/state.ts' } from '../src/client/state.ts'
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.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', () => { it('openTabInActivePane lands in the bottom tree when the active pane lives there', () => {
let s = state() let s = state()
s = toggleBottomPanel(s) s = toggleBottomPanel(s)
@@ -1090,6 +1150,7 @@ describe('side card preferences', () => {
openByDefault: false, openByDefault: false,
defaultWidthPercent: 60, defaultWidthPercent: 60,
autoOpenSubagent: false, autoOpenSubagent: false,
autoOpenTasks: true,
agentTerminalTools: true, agentTerminalTools: true,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
interceptOpenPath: true, interceptOpenPath: true,
@@ -1108,6 +1169,7 @@ describe('side card preferences', () => {
openByDefault: true, openByDefault: true,
defaultWidthPercent: 33, defaultWidthPercent: 33,
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
interceptOpenPath: true, interceptOpenPath: true,
@@ -1126,6 +1188,7 @@ describe('side card preferences', () => {
openByDefault: false, openByDefault: false,
defaultWidthPercent: 40, defaultWidthPercent: 40,
autoOpenSubagent: true, autoOpenSubagent: true,
autoOpenTasks: true,
agentTerminalTools: false, agentTerminalTools: false,
bottomPanelAutoTerminal: true, bottomPanelAutoTerminal: true,
interceptOpenPath: true, interceptOpenPath: true,
@@ -1145,6 +1208,11 @@ describe('side card preferences', () => {
.toBe(false) .toBe(false)
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools) expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
.toBe(true) .toBe(true)
// The 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 () => { 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() const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT, // Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference. // while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, 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') 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() const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session') expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false) expect(snapshot.state?.panelOpen).toBe(false)
@@ -1187,9 +1255,32 @@ describe('side card preferences', () => {
expect(openStore.getSnapshot().state?.panelOpen).toBe(true) 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', () => { it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore() 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') store.setSession('no-explorer')
const state = store.getSnapshot().state! const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs) const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -1197,7 +1288,7 @@ describe('side card preferences', () => {
expect(state.splits.kind).toBe('leaf') expect(state.splits.kind).toBe('leaf')
// Re-enabling seeds the explorer tab again. // Re-enabling seeds the explorer tab again.
const openStore = createSidebarStore() 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') openStore.setSession('with-explorer')
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs) const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['explorer']) 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, * - each artifact registers itself via window.__ModuleLoader__.load({id,
* factory}) with the (require) => exports CJS closure shape. * 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. * Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown.
*/ */
import { readFile } from 'node:fs/promises' 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. // External wins for module-table entries; every other dependency inlines.
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true), noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
plugins: [{ plugins: [purityGatePlugin(), makeCssPlugin(pluginId)],
// 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')
},
}],
outputOptions: { outputOptions: {
entryFileNames: entryFile, entryFileNames: entryFile,
sourcemapPathTransform: browserSourcePath, sourcemapPathTransform: browserSourcePath,
@@ -228,14 +175,144 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
intro: 'var module = { exports: {} }; var exports = module.exports;', intro: 'var module = { exports: {} }; var exports = module.exports;',
// The CJS wrapper factory's `require` only resolves module-table entries // The CJS wrapper factory's `require` only resolves module-table entries
// (react, cordis, ...); it cannot load relative chunk URLs in the browser. // (react, cordis, ...); it cannot load relative chunk URLs in the browser.
// Disable code splitting so dynamic import() inlines into the single // Disable code splitting so every artifact is one script (the lazy chunk
// factory chunk (Office preview libs — docx-preview, Univer — pull in // files themselves are separate bundles — see chunkBundle below).
// several MB but only when first opened, the trade-off for wrapper safety).
codeSplitting: false, 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 [ export default [
{ {
entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' }, entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' },
@@ -254,4 +331,7 @@ export default [
clientBundle('dsh-better-sidebar', 'client.js'), clientBundle('dsh-better-sidebar', 'client.js'),
// Plugin-registry channel: bundle id = manifest id (dsh.plugin.json `id`). // Plugin-registry channel: bundle id = manifest id (dsh.plugin.json `id`).
clientBundle('dsh-external/dsh-better-sidebar', 'client-registry.js'), 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[] ] satisfies UserConfig[]