Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cde107ce9e | ||
|
|
0c254a2197 | ||
|
|
c5fda3716d | ||
|
|
37d91befdb | ||
|
|
d83490c93e | ||
|
|
96064c31a9 | ||
|
|
047f48337d | ||
|
|
efb2e2bd58 | ||
|
|
97e52b650e | ||
|
|
3cbf43854b | ||
|
|
e128c303bc | ||
|
|
8bf6d1d7ba | ||
|
|
0f9af851ae | ||
|
|
3d88752eb1 | ||
|
|
1b7c8ba497 | ||
|
|
5a93f610d8 | ||
|
|
9fdb44d9f9 | ||
|
|
e515d74f77 | ||
|
|
0a07cff6a3 | ||
|
|
a85d1ab004 | ||
|
|
b9f8eea56d | ||
|
|
72ba010ab7 | ||
|
|
1b7014b2d4 | ||
|
|
64c6aa477e | ||
|
|
5f6d4c97d5 | ||
|
|
58d6167b69 | ||
|
|
23b1b564d7 | ||
|
|
b31a9a601f | ||
|
|
2e9db44a71 | ||
|
|
1bfabbbfc2 | ||
|
|
ce052faee8 | ||
|
|
a2bb04bce0 | ||
|
|
422d5e3c10 | ||
|
|
9c5293c94b | ||
|
|
983d2b2d71 | ||
|
|
6e4474cd2e | ||
|
|
88f116338a | ||
|
|
b90251cce5 | ||
|
|
8c521be26e | ||
|
|
27bc49830e |
@@ -146,3 +146,6 @@ lib/
|
||||
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
|
||||
registry/
|
||||
.npmrc
|
||||
|
||||
# stray npm cache scratch dir
|
||||
.npm-cache-tmp/
|
||||
|
||||
@@ -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 的 `jobs.output` 事件回放:读会话事件日志而非动注册表);如果确实做不到,先向用户说明取舍,而不是直接改 DSH。
|
||||
|
||||
---
|
||||
|
||||
## 1. 服务定位
|
||||
|
||||
- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`)
|
||||
@@ -119,15 +127,27 @@ interface TabDescriptor {
|
||||
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
|
||||
/**
|
||||
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关,
|
||||
* 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。
|
||||
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套设置行,
|
||||
* 绑定 SidebarPrefs 字段。嵌套设置仅父级启用时显示(v0.11.0 起行控件不限于
|
||||
* 布尔开关:`type: 'switch' | 'text' | 'number'`,缺省 'switch';text/number
|
||||
* 行 blur/Enter 提交,number 行按 min/max 钳制,unit 渲染单位后缀)。
|
||||
*/
|
||||
settings?: {
|
||||
toggles?: readonly {
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
|
||||
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools' / 'terminalFontFamily' / 'htmlViewerNoSandbox' / 'htmlViewerDefaultUnsafe' / 'browserNoSandbox' / 'browserInterceptLinks') */
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
/** 行控件类型;缺省 'switch'(向后兼容)。 */
|
||||
type?: 'switch' | 'text' | 'number'
|
||||
/** number 行的提交钳制下限。 */
|
||||
min?: number
|
||||
/** number 行的提交钳制上限。 */
|
||||
max?: number
|
||||
/** text 行的输入占位符。 */
|
||||
placeholder?: string
|
||||
/** 输入框后的单位后缀(如 'px')。 */
|
||||
unit?: string
|
||||
}[]
|
||||
}
|
||||
/** 渲染函数 */
|
||||
@@ -376,7 +396,7 @@ interface BetterSidebarService {
|
||||
}
|
||||
```
|
||||
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+)——父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks`),外部插件的自定义键会被 settings seam 丢弃。
|
||||
|
||||
---
|
||||
|
||||
@@ -399,6 +419,7 @@ interface BetterSidebarService {
|
||||
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
|
||||
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
|
||||
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
|
||||
| **i18n 跟随** | 侧边栏界面文案跟随 DSH 的 `ctx.locale`(`@deepseek-ai/dsh-client-locale`):词典注册在 `betterSidebar` 命名空间,语言偏好(Host-backed `locale.preference`)与浏览器语言不一致时以 DSH 为准并实时切换;locale 服务缺失时回退浏览器语言。插件自身的 `t()`(`src/client/locales.ts`)由 `apply()` 挂接服务;消费插件**不要**依赖此内部函数——标题等字段传字符串或 `() => string` 即可(i18n 友好)。⚠️ 渲染 DSH 的 `MarkdownText` 时必须传 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`——该组件 cordis-free,漏传则代码块复制按钮回退硬编码中文 |
|
||||
| **懒加载 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` 纯渲染函数)不变 |
|
||||
|
||||
---
|
||||
@@ -475,7 +496,7 @@ function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /
|
||||
function parseCsv(text: string): string[][] { /* ... */ }
|
||||
```
|
||||
|
||||
**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,重启 `dsh web` + 浏览器硬刷新。
|
||||
**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,浏览器硬刷新即可(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
|
||||
|
||||
---
|
||||
|
||||
@@ -485,7 +506,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"
|
||||
|
||||
- **`src/client/builtins/`**:7 个内置 tab(explorer/git/subagent/terminal/browser/editor/diff)+ 9 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/html/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
|
||||
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
|
||||
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套设置行:switch/text/number + 持久化)
|
||||
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 9 viewer + 声明式元数据)
|
||||
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
|
||||
|
||||
@@ -1,24 +1,194 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
> DeepSeek Harness Web GUI 的 **VSCode 风格侧边栏**插件:文件管理、编辑预览、内嵌浏览器、真实终端、Git 面板、Subagent 页面——**右侧栏 + 底部面板双工作台**,一个插件全部搞定。
|
||||
<!-- 头部介绍区(HTML 排版) -->
|
||||
<div align="center">
|
||||
<b style="font-size: 1.15em;">一个插件,一套完整工作台</b><br /><br />
|
||||
<code>文件管理</code> <code>编辑预览</code> <code>内嵌浏览器</code> <code>真实终端</code> <code>Git 面板</code> <code>后台任务页</code><br /><br />
|
||||
<b>右侧栏 + 底部面板双工作台</b>,一个插件全部搞定。<br />
|
||||
<small>支持 Tab 窗口随意拖拽,支持三方拓展注册新 Tab 页面和文件预览</small>
|
||||
</div>
|
||||
|
||||
<img width="4632" height="2720" alt="image" src="https://github.com/user-attachments/assets/39d86636-7654-412f-86ea-c60a2d5f20f0" />
|
||||
<div align="center">
|
||||
🌏 <a href="./README.md"><b>中文</b></a> · <a href="./README_EN.md">English</a>
|
||||
</div>
|
||||
|
||||
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" />
|
||||
|
||||
## 🆕 最近更新
|
||||
|
||||
- **终端自定义字体**:设置页终端卡片新增字体族 + 9–32px 字号设置,实时生效
|
||||
- **侧边卡片设置页重构**:分组容器卡片、计数徽标、自定义开关、加宽设置弹窗
|
||||
- **修复**:WKWebView 下底部面板展开后终端空白(xterm 零尺寸容器初始化崩溃,[#25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25))
|
||||
- **文档**:client 改动硬刷新即可生效,无需重启 DSH
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径
|
||||
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ HTML(沙箱 iframe 预览,相对资源可加载)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿
|
||||
- **⚡ 客户端懒加载**:Office / 终端 / 代码编辑器等重依赖按需分块加载——启动只拉 ~325KB 核心,打开 .xlsx 才拉 Univer(~20MB)、打开 .docx 只拉 docx 预览器、打开终端才拉 xterm;首次打开短暂 loading 后即用(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板)
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
|
||||
- **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开
|
||||
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
|
||||
- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区
|
||||
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
|
||||
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开)
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
|
||||
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md))
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd),点击打开、`@文件` 引用、右键复制路径
|
||||
- **📝 编辑与预览**:CodeMirror 6 编辑(Ctrl/Cmd+S 原子保存,切 Tab 不丢草稿);图片 / Markdown / HTML / PDF / Word / Excel / PPT 内联预览(HTML 走沙箱 iframe)
|
||||
- **⚡ 客户端懒加载**:启动只拉 ~325KB 核心,Office / 终端 / 编辑器等重依赖用到才按需拉取(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 浏览器**:多开内嵌网页 tab,后退/前进/刷新;内容运行在沙箱 iframe(无法访问界面数据与本地文件,拒绝本机地址),可临时解锁(红色警示);被站点拒绝嵌入时显示原因;外链默认在侧边栏打开
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell、断线重连回放;可选为模型注入 `terminal_*` 工具;支持自定义字体(字体族 + 9–32px 字号,实时生效)
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存/提交/还原等
|
||||
- **🧩 后台任务页**:主会话 agent 拓扑、点击直达执行记录;同页显示后台任务(类型徽标 + 退出码、实时输出 peek、可强制终止)
|
||||
- **🪟 底部面板**:第二个独立工作台,只挤占中间输出区;首次展开自动开终端(可关)
|
||||
- **📱 移动端**:窄屏(<768px)自动合并为全宽抽屉,底部面板标签页并入右侧栏;点文件/外链自动展开
|
||||
- **🔧 分栏工作台**:拖 Tab 拆分/合并(可跨面板)、分隔线调比例;右上角按钮一键折叠/展开面板
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板状态按会话持久化,陈旧状态自动净化;「产出文件」在侧边栏打开
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」按注册表渲染开关网格,逐项独立开/关;二级设置(自动展开、终端工具、沙箱等)经齿轮弹窗编辑
|
||||
- **🔌 服务化**:暴露 `ctx.betterSidebar`,其他插件可注册 tab 与文件预览器(内置 7 tab + 9 viewer 同走一服务,见 [AGENTS.md](./AGENTS.md))
|
||||
- **🌏 多语言**:界面文案跟随 DSH 语言(zh/en)实时切换,无需刷新
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
**前置**:已装好 DSH(`dsh web` 能正常运行),Node.js ≥ 20、pnpm ≥ 10。
|
||||
|
||||
**macOS / Linux**(Windows 装了 Git Bash 或 WSL 也可):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows(PowerShell 5.1+ / pwsh)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
装完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可看到侧边栏(DSH 对 client 改动热加载,无需重启;仅 host 半更新时需要重启)。
|
||||
|
||||
<details>
|
||||
<summary><b>指定版本 / 装完自动重启(可选)</b></summary>
|
||||
|
||||
```sh
|
||||
# macOS / Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.10.3 --restart
|
||||
|
||||
# Windows PowerShell
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.3 -Restart
|
||||
```
|
||||
|
||||
不确定的话,可先加 `--dry-run`(PowerShell 用 `-DryRun`)预览步骤再执行。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>手动安装(逐步命令,想看清每一步)</b></summary>
|
||||
|
||||
与一键脚本等价。**第 ③ 步可重复执行;①② 只需做一次。**
|
||||
|
||||
**macOS / Linux(bash)**:
|
||||
|
||||
```sh
|
||||
cd ~/.dsh/profiles/web
|
||||
|
||||
# ① 放行 node-pty / protobufjs 的构建脚本(pnpm 11 默认拦截;pnpm 10 可跳过)
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② 放行「发布不足 24h」的新版本(装老版本可跳过;若已有该键,把下面那行并入其下即可)
|
||||
cat >> pnpm-workspace.yaml <<'EOF'
|
||||
minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar
|
||||
EOF
|
||||
|
||||
# ③ 安装并自动挂载(不带 @版本 = npm 的 latest;固定版本写 dsh-better-sidebar@0.10.3)
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
**Windows(PowerShell)**:
|
||||
|
||||
```powershell
|
||||
cd ~\.dsh\profiles\web
|
||||
|
||||
# ① 放行构建脚本
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② 放行新版本(一次性;若已有该键,把 - dsh-better-sidebar 并入其下即可)
|
||||
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
|
||||
|
||||
# ③ 安装并自动挂载
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>脚本内部做了什么(技术细节)</b></summary>
|
||||
|
||||
一键脚本自动完成 4 件事(全部幂等,可安全重复执行):
|
||||
|
||||
1. 预写 `allowBuilds`(node-pty / protobufjs),规避 pnpm 11 的构建脚本拦截;
|
||||
2. 预写 `minimumReleaseAgeExclude`,放行「发布不足 24 小时」的新版本;
|
||||
3. 执行 `dsh plugin --profile web add dsh-better-sidebar`:登记依赖 → 识别包内 `dsh.bundle.patch` → 自动注册进 `dsh.profile.bundles` 挂载;
|
||||
4. 清理旧版残留的手动挂载行,避免「双挂载」(页面出现两个侧边栏)。
|
||||
|
||||
`curl | bash` / `irm | iex` 会执行远程代码——脚本已随仓库开源(`scripts/install.sh` / `scripts/install.ps1`),可先下载审阅。插件以 npm 包 `dsh-better-sidebar@0.10.3` 发布,通过 `dsh.bundle.patch`(随包的 `cordis.patch.yml`)由官方 CLI 自动挂载,**不修改 DSH 源码**。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>更新</b></summary>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
或重跑一次一键脚本;也可把 `~/.dsh/profiles/web/package.json` 里的版本号改高后 `pnpm install`。改完**硬刷新浏览器**(Cmd/Ctrl+Shift+R)即可(client 改动无需重启 DSH)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>常见问题</b></summary>
|
||||
|
||||
| 现象 | 原因与解决 |
|
||||
|---|---|
|
||||
| 报 `Ignored build scripts` | pnpm 11 拦截构建脚本。跑 `pnpm approve-builds --all`(一键脚本已自动处理)。 |
|
||||
| 报 `minimum release age` / 版本不足 24h | 装的版本发布不足 24 小时。等 24h 或重跑一次(pnpm 会自动补 `minimumReleaseAgeExclude`);一键脚本已自动处理。 |
|
||||
| 报「找不到 profile 目录」 | 先跑一次 `dsh web`,让它初始化 `~/.dsh/profiles/web`。 |
|
||||
| 页面出现**两个侧边栏** | 双挂载:`~/.dsh/profiles/web/cordis.patch.yml` 还留着旧的手动挂载行,删掉那段 `- insert: ... better-sidebar ...`(一键脚本会自动清)。 |
|
||||
| Windows 下终端无法使用 | `node-pty` 依赖预编译二进制;若当前 Node 版本没有对应产物,需装编译工具链(VS Build Tools)。主流 Node 版本一般已有预编译。 |
|
||||
| Windows 没有 bash / curl | 直接用 PowerShell 一键命令;或安装 Git Bash / WSL 再跑 bash 命令。 |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>从源码安装 / 开发(可选,替代 npm 方式)</b></summary>
|
||||
|
||||
调试本地改动或跟随开发分支时,把依赖指向本地克隆并自行构建:
|
||||
|
||||
```text
|
||||
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
2. ~/.dsh/profiles/web/package.json 的 dependencies 写 "dsh-better-sidebar": "link:<克隆目录绝对路径>"
|
||||
3. ~/.dsh/profiles/web/cordis.patch.yml 追加挂载行:
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
4. 在 ~/.dsh/profiles/web 执行 pnpm install
|
||||
5. 硬刷新浏览器(Cmd/Ctrl+Shift+R)即可看到效果(client 改动无需重启 DSH;host 半改动才需重启)
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → 硬刷新浏览器即可(client 改动热加载生效,无需重启 DSH;host 半改动才需重启)。切回 npm 通道时,把依赖改回 `"dsh-better-sidebar": "^0.10.3"` 再 `pnpm install`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>通过 plugin-registry 安装(可选,与上述二选一)</b></summary>
|
||||
|
||||
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
|
||||
|
||||
```sh
|
||||
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ 快捷键
|
||||
|
||||
@@ -31,51 +201,6 @@
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
|
||||
## 🚀 安装
|
||||
|
||||
前置:已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10。把下面提示词**整段**发给 DSH 即可自动完成克隆、构建、注册与安装:
|
||||
|
||||
```text
|
||||
请帮我把 dsh-better-sidebar 插件安装到我的 web profile(插件 = VSCode 风格右侧侧边栏,仓库 https://github.com/dsh-external/DSH-better-sidebar):
|
||||
|
||||
1. 克隆并构建:
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
(@deepseek-ai/* 从 npm 解析(0.0.1-rc.? 预发布版);若 401/404,用安装者自己的只读令牌配置 ~/.npmrc,或把 devDependencies 改回指向自己 ~/.dsh/source/current 的 link:,不要继续)
|
||||
2. 注册到 web profile:
|
||||
a. ~/.dsh/profiles/web/package.json 的 dependencies 加 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
|
||||
b. ~/.dsh/profiles/web/cordis.patch.yml 追加:
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
3. 在 ~/.dsh/profiles/web 执行 pnpm install
|
||||
4. 重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
|
||||
```
|
||||
|
||||
> 安装 = 依赖登记(等价 `dsh plugin --profile web add link:<路径>`)+ 一行挂载行。**DSH 以 npm 包启动(如 `npx -p @deepseek-ai/dsh@0.0.1-rc.1 dsh web`)同样可用**(v0.4.3 起实测验证)。
|
||||
|
||||
### 更新
|
||||
|
||||
```text
|
||||
1. cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build(401/404 处理同上)
|
||||
2. 核对注册仍有效(缺失才补):profile package.json 的 link: 依赖 + cordis.patch.yml 挂载行
|
||||
3. 仅 client(src/client/*)→ 硬刷新即可;含 host(src/index.ts、src/config.ts 等)→ 重启 DSH + 硬刷新
|
||||
```
|
||||
|
||||
### 通过 plugin-registry 安装(可选,与上述二选一)
|
||||
|
||||
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**同时启用两个通道会双挂载**(Node 半挂两次、页面两个侧边栏)。
|
||||
|
||||
```sh
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存(含清单 + 产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`。切换通道前先移除另一通道的挂载。
|
||||
|
||||
## 🔌 服务化:注册 tab 与文件预览器
|
||||
|
||||
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 7 tab + 9 viewer 也走同一服务,吃自己的狗粮):
|
||||
@@ -95,9 +220,7 @@ export function apply(ctx: Context) {
|
||||
## 🛠️ 开发与构建
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* 从 npm 解析(^0.0.1-rc.1),需安装者自己的只读令牌:
|
||||
# ~/.npmrc 配 //registry.npmjs.org/:_authToken=<自己的令牌>(勿写进仓库),
|
||||
# 或改 devDependencies 为 link: 指向自己的 ~/.dsh/source/current
|
||||
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.6),直接解析、无需令牌
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
|
||||
@@ -124,3 +247,7 @@ pnpm watch # tsdown --watch
|
||||
## 🖥️ 平台支持
|
||||
|
||||
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
|
||||
|
||||
## 🔗 友情链接
|
||||
|
||||
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui):DeepSeek Harness 交互式终端 UI 插件(渲染核心由自研 harness agent Tianshu-Tui 演进而来),在官方基础上增加 TDD 与证据门等工作流
|
||||
|
||||
+253
@@ -0,0 +1,253 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
<!-- Hero (HTML layout) -->
|
||||
<div align="center">
|
||||
<b style="font-size: 1.15em;">One plugin, one complete workbench</b><br /><br />
|
||||
<code>File management</code> <code>Edit & preview</code> <code>Embedded browser</code> <code>Real terminal</code> <code>Git panel</code> <code>Background tasks</code><br /><br />
|
||||
<b>Dual workbench: right sidebar + bottom panel</b>, all in one plugin.<br />
|
||||
<small>Tabs are freely draggable; third-party plugins can register new tab pages and file viewers</small>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
🌏 <a href="./README.md">中文</a> · <a href="./README_EN.md"><b>English</b></a>
|
||||
</div>
|
||||
|
||||
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" />
|
||||
|
||||
## 🆕 Recent Updates
|
||||
|
||||
- **Custom terminal fonts**: font-family + 9–32px size settings on the terminal card, applied live
|
||||
- **Side card settings redesign**: grouped container cards, count badges, custom toggles, wider settings dialog
|
||||
- **Fix**: blank terminal after expanding the bottom panel on WKWebView (xterm initialized in a zero-size container, [#25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25))
|
||||
- **Docs**: client changes take effect on a hard refresh — no DSH restart needed
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🗂️ File Explorer**: lazy-loading directory tree (root = session cwd), click to open, `@file` reference, right-click to copy path
|
||||
- **📝 Edit & Preview**: CodeMirror 6 editing (Ctrl/Cmd+S atomic save, drafts survive tab switches); inline preview for images / Markdown / HTML / PDF / Word / Excel / PPT (HTML in a sandboxed iframe)
|
||||
- **⚡ Client-side Lazy Loading**: only ~325KB core at startup; heavy deps (Office / terminal / editor) load on demand (see `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 Browser**: multiple embedded web tabs, back/forward/refresh; pages run in a sandboxed iframe (no access to UI data or local files, rejects local addresses), temporarily unlockable (red warning); sites refusing embedding show a reason panel; external links open in the sidebar by default
|
||||
- **💻 Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model; custom font (family + 9–32px size, applied live)
|
||||
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage/commit/revert etc.
|
||||
- **🧩 Background Tasks**: main-session agent topology with click-through to execution records; background tasks on the same page (type badges + exit codes, live-output peek, force-kill)
|
||||
- **🪟 Bottom Panel**: an independent second workbench that only squeezes the center output area; auto-opens a terminal on first expand (toggleable)
|
||||
- **📱 Mobile**: narrow viewports (<768px) merge into a full-width drawer with the bottom panel's tabs folded into the sidebar; files/links auto-expand the panel
|
||||
- **🔧 Split-pane Workbench**: drag tabs to split/merge panes (cross-panel supported), divider to adjust ratios; one-click collapse/expand both panels from the top-right buttons
|
||||
- **🔁 Session Isolation**: layout / tabs / panel states persisted per session, stale state auto-purged; "produced files" open in the sidebar
|
||||
- **⚙️ Declarative Settings**: the "Side Cards" settings section renders a registry-driven toggle grid, each item independently switchable; secondary settings (auto-expand, terminal tools, sandbox, etc.) edited in a native dialog via the gear button
|
||||
- **🔌 Service API**: exposes `ctx.betterSidebar` — other plugins can register tabs and file viewers (the 7 built-in tabs + 9 viewers share the same service, see [AGENTS.md](./AGENTS.md))
|
||||
- **🌏 i18n**: UI text follows DSH's language setting (zh/en) with live switching, no refresh needed
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
**Prerequisites**: DSH installed (`dsh web` boots), Node.js ≥ 20, pnpm ≥ 10.
|
||||
|
||||
**macOS / Linux** (also works in Git Bash / WSL on Windows):
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows (PowerShell 5.1+ / pwsh)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Then **hard-refresh the browser** (Cmd/Ctrl+Shift+R) to see the sidebar (DSH hot-reloads client changes; only host-half updates need a restart).
|
||||
|
||||
<details>
|
||||
<summary><b>Pin a version / auto-restart (optional)</b></summary>
|
||||
|
||||
```sh
|
||||
# macOS / Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.sh | bash -s 0.10.3 --restart
|
||||
|
||||
# Windows PowerShell
|
||||
& ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.3 -Restart
|
||||
```
|
||||
|
||||
Not sure? Add `--dry-run` (`-DryRun` in PowerShell) to preview before running.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Manual install (step by step)</b></summary>
|
||||
|
||||
Equivalent to the one-click script. **Step ③ is repeatable; ①② only need to run once.**
|
||||
|
||||
**macOS / Linux (bash)**:
|
||||
|
||||
```sh
|
||||
cd ~/.dsh/profiles/web
|
||||
|
||||
# ① Allow node-pty / protobufjs build scripts (pnpm 11 blocks them by default; skip on pnpm 10)
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② Allow versions published less than 24h ago (skip for older releases; if the key already exists, merge the line under it instead)
|
||||
cat >> pnpm-workspace.yaml <<'EOF'
|
||||
minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar
|
||||
EOF
|
||||
|
||||
# ③ Install and auto-mount (no @version = npm's latest; pin with dsh-better-sidebar@0.10.3)
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**:
|
||||
|
||||
```powershell
|
||||
cd ~\.dsh\profiles\web
|
||||
|
||||
# ① Allow build scripts
|
||||
pnpm approve-builds --all
|
||||
|
||||
# ② Allow fresh releases (once; if the key already exists, merge - dsh-better-sidebar under it instead)
|
||||
Add-Content -Path pnpm-workspace.yaml -Value "`nminimumReleaseAgeExclude:`n - dsh-better-sidebar"
|
||||
|
||||
# ③ Install and auto-mount
|
||||
npx -y --package @deepseek-ai/dsh dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>What the script does (technical details)</b></summary>
|
||||
|
||||
The one-click script does four things, all idempotent (safe to re-run):
|
||||
|
||||
1. Pre-writes `allowBuilds` (node-pty / protobufjs) to dodge pnpm 11's build-script block;
|
||||
2. Pre-writes `minimumReleaseAgeExclude` to allow versions younger than 24 hours;
|
||||
3. Runs `dsh plugin --profile web add dsh-better-sidebar`: registers the dependency → detects `dsh.bundle.patch` → auto-appends the plugin to `dsh.profile.bundles`;
|
||||
4. Removes any leftover hand-written mount line to avoid double-mounting (two sidebars on the page).
|
||||
|
||||
`curl | bash` / `irm | iex` executes remote code — the scripts are open source in the repo (`scripts/install.sh` / `scripts/install.ps1`); download and review them first if you prefer. The plugin ships as npm package `dsh-better-sidebar@0.10.3` and mounts via `dsh.bundle.patch` (the shipped `cordis.patch.yml`), so the DSH source is never modified.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Updating</b></summary>
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add dsh-better-sidebar
|
||||
```
|
||||
|
||||
or re-run the one-click script; or bump the version in `~/.dsh/profiles/web/package.json` (e.g. `"^0.10.3"`) and run `pnpm install`. Then hard-refresh the browser (Cmd/Ctrl+Shift+R) — client changes do not need a DSH restart.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Troubleshooting</b></summary>
|
||||
|
||||
| Symptom | Cause & fix |
|
||||
|---|---|
|
||||
| `Ignored build scripts` | pnpm 11 blocked build scripts. Run `pnpm approve-builds --all` (the one-click script handles it). |
|
||||
| `minimum release age` / version `< 24h` | The release is younger than 24 hours. Wait, or re-run once (pnpm auto-adds `minimumReleaseAgeExclude`); the one-click script handles it. |
|
||||
| "profile directory not found" | Run `dsh web` once so it initializes `~/.dsh/profiles/web`. |
|
||||
| Two sidebars on the page | Double-mount: `~/.dsh/profiles/web/cordis.patch.yml` still has the old hand-written `- insert: ... better-sidebar ...` line — delete it (the one-click script cleans it). |
|
||||
| Terminal fails on Windows | `node-pty` relies on prebuilt binaries; if none match your Node version, install a build toolchain (VS Build Tools). Mainstream Node versions are usually covered. |
|
||||
| No bash / curl on Windows | Use the PowerShell one-click command, or install Git Bash / WSL and run the bash commands. |
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Install from source / develop (optional — alternative to the npm flow)</b></summary>
|
||||
|
||||
To debug local changes or track the dev branch, point the dependency at a local clone and build it yourself:
|
||||
|
||||
```text
|
||||
1. git clone https://github.com/omdsh-dev/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
|
||||
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
|
||||
2. In ~/.dsh/profiles/web/package.json dependencies write "dsh-better-sidebar": "link:<absolute path of the clone>"
|
||||
3. Append this mount line to ~/.dsh/profiles/web/cordis.patch.yml:
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
4. Run pnpm install in ~/.dsh/profiles/web
|
||||
5. Restart DSH and hard-refresh
|
||||
```
|
||||
|
||||
Update: `git pull && pnpm install && pnpm build` → just hard-refresh the browser (client changes hot-reload; only host-half changes need a DSH restart). To switch back to the npm channel, restore `"dsh-better-sidebar": "^0.10.3"` and re-run `pnpm install`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Install via plugin-registry (optional — use either this or the main flow)</b></summary>
|
||||
|
||||
Prerequisite: DSH with [plugin-registry](https://github.com/dsh-external/plugin-registry) integrated (`dsh registry` available). **Enabling both channels double-mounts** (the Node half loads twice, the page gets two sidebars).
|
||||
|
||||
```sh
|
||||
git clone https://github.com/omdsh-dev/DSH-better-sidebar.git && cd DSH-better-sidebar
|
||||
pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs # assemble the registry/ staging (manifest + artifacts + README, not committed)
|
||||
dsh registry install ./registry # install (disabled by default)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
Update: `git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`. Remove the other channel's mount before switching.
|
||||
|
||||
</details>
|
||||
|
||||
## ⌨️ Keyboard Shortcuts
|
||||
|
||||
| Action | Keys |
|
||||
|---|---|
|
||||
| Save edits | `Ctrl/Cmd + S` |
|
||||
| Git commit | `Ctrl + Enter` |
|
||||
| Close tab | Middle mouse button |
|
||||
| Split / merge panes | Drag tab to pane edge / middle |
|
||||
| Reference file to input | Hover the `@file` button at end of line |
|
||||
| Copy file path | Right-click row → copy relative/absolute path |
|
||||
|
||||
## 🔌 Service: register tabs & file viewers
|
||||
|
||||
Since v0.4.0 the plugin exposes the `ctx.betterSidebar` service — other plugins can register sidebar pages and file viewers (the 7 built-in tabs + 9 viewers go through the same service, eating our own dog food):
|
||||
|
||||
```ts
|
||||
import type {} from 'dsh-better-sidebar' // triggers the ctx.betterSidebar type merge
|
||||
export const inject = ['betterSidebar']
|
||||
export function apply(ctx: Context) {
|
||||
ctx.effect(() => ctx.betterSidebar.registerTab({
|
||||
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
Full integration docs (`TabDescriptor` / `FileViewerDescriptor` full fields, matching algorithm, HMR pitfalls, declarative settings): see [`AGENTS.md`](./AGENTS.md).
|
||||
|
||||
## 🛠️ Development & Build
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.6, published) — no token needed
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest (includes manifest consistency guard; build first)
|
||||
pnpm watch # tsdown --watch
|
||||
```
|
||||
|
||||
**Architecture**: a single npm package with host/client halves — host (`src/index.ts`): `/sidebar/api/*` JSON API, `/sidebar/file` media route, `/sidebar/html` preview route, `/sidebar/ws/terminal` WebSocket (fs / git / pty / preview, all session-scoped with a trust fence); client (`src/client/index.tsx`): portal sidebar + views + interception; state persisted per session in localStorage. Organized per DSH official conventions (no default export, dual client bundles); no dependency on npm / checkout at runtime (`@deepseek-ai/*` provided by the web profile).
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- Routes protected by a Host-header trust fence (same as `/api`); `fs.write` is atomic; media/preview routes only serve files inside the session cwd; git only shells out to the CLI and never sets identity
|
||||
- HTML preview and browser tab content render in **opaque-origin sandboxed iframes** (no `allow-same-origin`/`allow-top-navigation`, `no-referrer`, all permission policies disabled); the `/sidebar/html` route carries a CSP `sandbox` + size/path bounds; the address bar rejects `javascript:`/`data:`/`file:` and local addresses like localhost
|
||||
- The UI shows the sandbox status live (red warning when off) and can temporarily unlock the current page; the settings page can disable the sandbox per feature (disabled by default, with a warning) — when off, content shares the origin with the UI; only recommended for fully trusted content
|
||||
|
||||
## ⚠️ Known Limitations
|
||||
|
||||
- Git has no push/pull/fetch; no file watcher (manual refresh); tool inline file-open buttons cannot be intercepted
|
||||
- Dragging a terminal tab to another pane remounts it (shell restarts)
|
||||
- `.xlsx` preview does not preserve cell styles (SheetJS community-edition limitation); Office/PPTX preview is inlined into the client bundle (~23MB), slower on first load
|
||||
- Browser sandbox has no login state / third-party cookies are restricted; some sites need popup login; sites that refuse embedding via `X-Frame-Options`/`frame-ancestors` (e.g. arxiv.org) show a reason panel (with "Open in browser"); in-iframe navigation does not enter the back stack
|
||||
- HTML preview renders the saved file (not unsaved drafts)
|
||||
- No bottom panel on mobile (<768px): on narrow screens its tabs merge into the right sidebar once (after migrating back to desktop they stay in the right sidebar); the desktop bottom panel is only available on wide viewports; auto-open terminal on first bottom-panel expand does not trigger on mobile
|
||||
|
||||
## 🖥️ Platform Support
|
||||
|
||||
Windows / Linux / macOS (macOS validated daily; the rest covered by unit tests); `node-pty` prefers prebuilt binaries, otherwise a build toolchain is required (Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT).
|
||||
|
||||
## 🔗 Friends
|
||||
|
||||
- [dsh-tianshu-tui](https://github.com/huiliyi37/dsh-tianshu-tui): an interactive terminal UI plugin for DeepSeek Harness (its rendering core evolved from the self-developed harness agent Tianshu-Tui), adding TDD and evidence-gate workflows on top of the official harness
|
||||
@@ -0,0 +1,19 @@
|
||||
# dsh-better-sidebar bundle patch
|
||||
#
|
||||
# This file is the `dsh.bundle.patch` layer of the published npm package: when
|
||||
# the plugin is installed through the official CLI —
|
||||
#
|
||||
# dsh plugin --profile <name> add dsh-better-sidebar@<version>
|
||||
#
|
||||
# — the command reconciles `dsh.profile.bundles` against installed packages
|
||||
# and, seeing this declaration, appends `dsh-better-sidebar` to the bundle
|
||||
# stack. The profile boot then merges THIS patch (a single `insert` of the
|
||||
# plugin row) exactly like the manual cordis.patch.yml mount line used
|
||||
# before. No profile file edits needed — one command installs and mounts.
|
||||
#
|
||||
# If the profile still carries the old manual mount line in its own
|
||||
# cordis.patch.yml, remove it before switching to the bundle channel to
|
||||
# avoid double-mounting (two Node halves, two sidebars).
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
@@ -598,7 +598,7 @@ function parseCsv(text: string): string[][] { /* ... */ }
|
||||
1. `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`;
|
||||
2. `~/.dsh/profiles/web/cordis.patch.yml` 追加挂载行(`- insert: - id: my-plugin / name: 'my-plugin'`);
|
||||
3. 在 profile 目录 `pnpm install`;
|
||||
4. 重启 `dsh web` + 浏览器硬刷新(Cmd/Ctrl+Shift+R)。
|
||||
4. 浏览器硬刷新(Cmd/Ctrl+Shift+R)即可看到效果(DSH 对 client 改动热加载,无需重启 `dsh web`;仅 host 半改动需要重启)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# 跟随 DSH 的 i18n(ctx.locale)设计
|
||||
|
||||
> 2026-08-12 · 随 v0.8.2 发布
|
||||
|
||||
## 背景
|
||||
|
||||
better-sidebar 的界面文案(约 260 处 `t()` 调用、28 个文件)此前自带 zh/en 词典并直接读
|
||||
`navigator.language`(`src/client/locales.ts` 头注释曾明言"renders outside the slot
|
||||
system's locale seat, so it reads the browser language directly")。问题:
|
||||
|
||||
- DSH 的语言偏好是 Host-backed(`locale.preference`,存于 `$DSH_HOME/settings.yaml`),
|
||||
用户设置与浏览器语言不一致时侧边栏不跟随(如偏好 zh、浏览器 en 时侧边栏仍为英文)。
|
||||
- 词典游离在 DSH 的 i18n 注册表之外,无法参与公共命名空间回退等机制。
|
||||
|
||||
DSH 的 i18n 系统(`@deepseek-ai/dsh-client-locale`):
|
||||
|
||||
- `ctx.locale` 服务(client-only):`getSnapshot()/subscribe()` 是 LocaleFace
|
||||
(uSES 安全,revision 递增),`locale/change` 事件在切换时触发;
|
||||
- `ctx.locale.register(ns, {zh, en})` 命名空间词典注册(typed 变体要求合并
|
||||
`LocaleNamespaceMap`);查询链 ns → common → zh → key;
|
||||
- 所有 DSH 客户端包以 `inject = [..., 'locale']` 消费。
|
||||
|
||||
## 设计决策
|
||||
|
||||
1. **结构镜像服务**(仓库惯例,`src/context-types.ts` 头注释):本插件解析在 DSH
|
||||
monorepo 单一 cordis 实例之外,上游 `declare module 'cordis'` augmentation 不达此
|
||||
Context。新增 `SidebarLocaleService` 结构脸(只镜像用到的切片:
|
||||
`getSnapshot` / `subscribe` / `register`),merge `Context.locale`。不引入
|
||||
`@deepseek-ai/dsh-client-locale` 依赖、不做 value import(client bundle 纯度门)。
|
||||
2. **保留模块级 `t()`**:`t()` 被 28 个文件使用,穿 props 不可行。改为模块级 holder:
|
||||
`apply()` 调 `attachLocale(ctx.locale)`,`t()`/`isZh()` 从 holder 读活跃 locale;
|
||||
无 holder 时回退既有 `navigator.language` 检测(行为与改动前完全一致,也是 jsdom
|
||||
测试与非标准组合的降级路径)。
|
||||
3. **根级重渲染触发**:侧边栏自行 `createRoot` 挂在 slot 系统之外,拿不到 `t` seat。
|
||||
在 `Sidebar.tsx` 根组件加 `useSyncExternalStore(ctx.locale.getSnapshot/subscribe)`
|
||||
(该文件已用此 API 订阅 sessions/store);树内无 `React.memo`,根重渲染即整树
|
||||
重渲染,所有渲染期 `t()` 求值读到新语言。事件期调用(intercept、api、subagent-tasks)
|
||||
在调用时刻读当前语言,天然正确。SideCardSection 由 settings shell 渲染,shell 自身
|
||||
订阅 locale revision(ui-settings-general 的 section rows 缓存含 locale revision),
|
||||
自动跟随。
|
||||
4. **命名空间 `'betterSidebar'`**:`'sidebar'` 已被 DSH 的 ui-sidebar 占用。注册走
|
||||
非类型化单语言重载 `register(ns, locale, dict)`(DSH 内 ui-permission 同款先例);
|
||||
键型安全由本地 `const en: Record<keyof typeof zh, string>` + 测试键集相等断言保证。
|
||||
5. **词典注册进 DSH 注册表**:`ctx.effect` 内注册 zh/en 并返回合并 disposer
|
||||
(fiber 卸载时撤销,HMR 重激活不抛 "already registered")。
|
||||
6. **xlsx 预览的 Univer locale pack**:`xlsx-view.tsx` 原内联 navigator 检测改走
|
||||
`isZh()`(跟随 DSH 语言;Univer 实例在挂载时创建,语言切换不重建实例——可接受)。
|
||||
|
||||
## 实施偏差记录
|
||||
|
||||
- 无。计划与实施一致。
|
||||
- 版本号随 v0.8.2 发版提交(`package.json` + `dsh.plugin.json`)。
|
||||
- v0.8.2 后补遗:md 预览(`TextEditor.tsx`)的代码块复制按钮文案——DSH 的
|
||||
`MarkdownText`/`CodeBlock` 是 cordis-free 组件,未传 `codeLabels` 时回退到硬编码中文
|
||||
「复制/复制成功」。修复为渲染时传入 `codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}`
|
||||
(对齐 DSH 聊天区 `AssistantMarkdown` 的做法),新增 `tests/markdown-copy-labels.spec.tsx`
|
||||
钉住 zh/en 跟随。
|
||||
|
||||
## 验收
|
||||
|
||||
- `pnpm typecheck` / `pnpm test` / `pnpm build`(纯度门)全绿;`tests/locales.spec.ts`
|
||||
覆盖:无服务回退、attach 后跟随活跃 locale 实时切换、detach 恢复回退、占位符插值、
|
||||
relativeTime 链路、命名空间名、zh/en 键集相等。
|
||||
- 手动:设置页切换语言 → 侧边栏全部文案实时切换;`locale.preference: zh` 且浏览器 en
|
||||
→ 侧边栏中文。
|
||||
@@ -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)`。
|
||||
@@ -0,0 +1,111 @@
|
||||
# 终端自定义字体(设置页二级设置)设计
|
||||
|
||||
**日期**:2026-08-14
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.11.0
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在 DSH 设置页「侧边卡片」分区中,终端 tab 卡片的二级设置弹窗(齿轮按钮,README 所称"终端卡片二级设置")新增两行:
|
||||
|
||||
1. **终端字体**(text 输入):自定义 CSS font-family stack,如 `"JetBrains Mono", monospace`;留空 = 跟随主题等宽字体(`--ds-font-family-code` token)。
|
||||
2. **终端字号**(number 输入):9–32px,默认 13,带 `px` 后缀。
|
||||
|
||||
配置持久化到 `SidebarPrefs`(`dsh-better-sidebar` 命名空间,host 侧 PrefsSchema 校验),对**所有已打开终端实时生效**(UI tab 与 agent tab 共用 TerminalView):设置页提交 → `store.setPrefs` 通知 → xterm `options.fontFamily/fontSize` 更新 + 重排网格。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不在终端视图内加菜单/按钮(用户明确选择仅设置页齿轮弹窗)。
|
||||
- 不做 per-tab / per-terminal 隔离(全局偏好,与 SidebarPrefs 现有语义一致)。
|
||||
- 不自动给字体族加引号、不做字体合法性嗅探(用户负责合法 CSS font-family;placeholder 示范格式)。
|
||||
- 不改 pty / WS / 终端连接生命周期:字体变化不重连、不重启 shell。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- 终端描述符(`builtins/tabs.tsx`)的 `settings.toggles` 只支持**布尔开关**(`SidebarSettingToggle` 无类型字段),`FeatureSettingsRows`(`SideCardSection.tsx`)只渲染 Switch 行。
|
||||
- `SidebarPrefs` / `PrefsSchema` / `parsePrefs` 三处同步维护(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)。
|
||||
- `TerminalView` 硬编码 `fontSize: 13` + `fontFamily: tokenValue('--ds-font-family-code') || 内置栈`,且不响应偏好变化。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
|
||||
|
||||
```ts
|
||||
interface SidebarPrefs {
|
||||
// …
|
||||
terminalFontFamily: string // '' = 跟随主题等宽字体
|
||||
terminalFontSize: number // 9–32,默认 13
|
||||
}
|
||||
|
||||
// prefs-shared.ts
|
||||
export const TERMINAL_FONT_SIZE_MIN = 9
|
||||
export const TERMINAL_FONT_SIZE_MAX = 32
|
||||
export const TERMINAL_FONT_SIZE_DEFAULT = 13
|
||||
export function clampTerminalFontSize(value: number): number // round + clamp(仿 clampWidthPercent)
|
||||
```
|
||||
|
||||
- `PrefsSchema`:`terminalFontFamily: z.string().default('')`、`terminalFontSize: z.number().step(1).min(9).max(32).default(13)` —— 旧文档解析自动补默认,无迁移。
|
||||
- `parsePrefs`:family 需 string 否则默认;size 需有限数并 `clampTerminalFontSize` 否则默认。
|
||||
|
||||
### 4.2 声明式 API 扩展(`client/service.ts`,向后兼容)
|
||||
|
||||
```ts
|
||||
type SidebarSettingToggleType = 'switch' | 'text' | 'number'
|
||||
|
||||
interface SidebarSettingToggle {
|
||||
key: string
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
type?: SidebarSettingToggleType // 缺省 'switch',外部插件现有用法不变
|
||||
min?: number // number 行的下限(提交时钳制)
|
||||
max?: number // number 行的上限
|
||||
placeholder?: string // text 行的占位符
|
||||
unit?: string // 输入框后的单位后缀(如 'px')
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 终端描述符声明(`builtins/tabs.tsx`)
|
||||
|
||||
`settings.toggles` 追加两行:`{ key: 'terminalFontFamily', type: 'text', placeholder: … }` 与 `{ key: 'terminalFontSize', type: 'number', min: 9, max: 32, unit: 'px' }`(标题/描述走 locales)。
|
||||
|
||||
### 4.4 设置行渲染(`SideCardSection.tsx` + CSS)
|
||||
|
||||
- `FeatureSettingsRows` 按 `toggle.type` 分支:`switch` 走现有 Switch 行;`text`/`number` 走新 `TypedRow`(局部 draft state,`Input` 原语,blur/Enter 提交,number 行带 unit 后缀)。
|
||||
- `TypedRow` 的 `key` 含**已提交值**:提交失败 → prefs 回滚 → key 变化 → 行重挂载为存储值(输入中 draft 不因 key 变化被打断,因为 key 只随已提交值变)。
|
||||
- 父级 `onCommitSetting(toggle, raw): string`:number 解析+钳制(非法/空 → 走 `Number('')=0` 钳到下限,与宽度行 `commitWidth` 先例一致);text 原样;乐观 `setPrefs` → `commit` → 失败 `applyOutcome` 回滚(复用既有 `applyPref` 管道,原 `togglePref` 更名)。
|
||||
|
||||
### 4.5 终端消费(`client/terminal-font.ts` 新模块 + `TerminalView.tsx`)
|
||||
|
||||
```ts
|
||||
// terminal-font.ts(纯函数,可单测)
|
||||
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
|
||||
export function resolveTerminalFont(prefs, themeFontFamily):
|
||||
{ fontFamily: 非空用户值 ?? themeFontFamily ?? 内置栈, fontSize: clampTerminalFontSize(prefs.terminalFontSize) }
|
||||
```
|
||||
|
||||
- `TerminalView` 构造 Terminal 时用解析值;主 effect 内 `store.subscribe` 回调 diff `term.options.fontFamily/fontSize`,变化则更新 options + `fit.fit()` + `sendResize()`(网格尺寸随字体变化);cleanup 退订。懒加载 chunk 无新增依赖(store 为运行时 prop)。
|
||||
|
||||
### 4.6 i18n(`locales.ts`)
|
||||
|
||||
zh/en 各新增:`settingsFontFamilyTitle/Desc/Placeholder`、`settingsFontSizeTitle/Desc/Suffix`(px)。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
- `tests/unit.spec.ts`:`parsePrefs`/`loadPrefs` 新字段(合法 string 直通、越界钳制 9/32、非 number 回退、缺省默认);全等字面量补字段。
|
||||
- `tests/plugin-shape.spec.ts`:PrefsSchema 默认值 + overridden 全等。
|
||||
- `tests/builtins.spec.ts`:终端 toggles keys = 4 个,后两个 type/min/max 断言。
|
||||
- `tests/smoke.spec.ts`:settings.get 全默认字面量补字段。
|
||||
- `tests/side-card-section.spec.tsx`(SSR):text/number 行渲染(value/min/max/placeholder/unit,非 checkbox)。
|
||||
- `tests/side-card-section-rows.spec.tsx`(新,jsdom + createRoot):blur 提交回调、number 钳制、空输入钳到下限、提交失败回滚草稿(key 重挂载)。
|
||||
- `tests/terminal-font.spec.ts`(新):`resolveTerminalFont` 回退链 + `clampTerminalFontSize`。
|
||||
|
||||
## 6. 限制与取舍
|
||||
|
||||
- 外部插件的自定义 toggle key 照旧被 host PrefsSchema 校验丢弃(现有 seam 行为);`type: 'text'/'number'` 是公开 API 扩展,外部插件可用但 key 仍须是宿主 schema 字段。
|
||||
- number 输入的"非法值"在浏览器里只会以空串出现(number input 拒绝非数字字符),因此 `Number('')=0 → 钳到下限` 是唯一可达路径,与宽度行先例一致。
|
||||
- 字号实时生效依赖 xterm 的 options setter + re-fit;若未来 xterm 版本行为变化,回退方案是重建 Terminal(不推荐,会丢滚动缓冲)。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
- 计划中的"非法输入回退当前存储值"用例在实现中发现不可达(浏览器 number input 的值清洗),改为断言"空输入钳到下限"(与宽度行一致)。
|
||||
- `togglePref` 更名为 `applyPref`(它本就接受任意 patch,不只是布尔翻转)。
|
||||
@@ -0,0 +1,90 @@
|
||||
# 终端延迟初始化(零尺寸容器 xterm open 崩溃修复)设计
|
||||
|
||||
**日期**:2026-08-14
|
||||
**状态**:已实施(本文档含实施偏差记录)
|
||||
**目标版本**:v0.11.x(issue #25 修复)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
修复 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25):WKWebView 下点击「展开底部面板」后底部面板内容空白、无终端,控制台报 `Cannot read properties of undefined (reading 'dimensions')` 与 `(reading 'handleResize')`。
|
||||
|
||||
根因:`TerminalView` 的挂载 effect 在容器可能为零尺寸时**同步**执行 `term.open(host); fit.fit()`。xterm 5.3.0 在零尺寸容器里 open 时渲染器创建失败,`RenderService._renderer.value` 保持 undefined,随后 Viewport 的 `_innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)崩溃。Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现。
|
||||
|
||||
修复:把 `term.open(host)` + `fit.fit()` 推迟到容器有实际尺寸之后再执行(xterm 在隐藏/零尺寸容器初始化的标准修法),open 恰好执行一次。
|
||||
|
||||
## 2. 非目标
|
||||
|
||||
- 不换 xterm 渲染器(打包产物只有 DomRenderer 是现状,不是本次根因;`rendererType` 保持默认)。
|
||||
- 不改 pty / WS / 终端连接生命周期:推迟 open 不推迟连接、不重启 shell、不丢 transcript。
|
||||
- 不改 host 半与 chunk 打包结构(新模块随 terminal chunk 打入 `lib/client-terminal.js`)。
|
||||
- 不 bump 版本号(release 流程负责)。
|
||||
|
||||
## 3. 现状回顾
|
||||
|
||||
- `src/client/TerminalView.tsx` 第 99–101 行同步 `term.open(host); fit.fit()`。
|
||||
- 零尺寸挂载路径真实存在且不止一条:
|
||||
- 底部面板首次展开自动开终端(`Sidebar.tsx` 的 `bottomOpenedOnce` effect)在面板 slide-in transition 的同一次 commit 挂载 TerminalView,WKWebView 下此刻 host 高度为 0;
|
||||
- 所有 tab 保持挂载(`split-pane.tsx`:非激活 tab 用 `.paneTabHidden { display: none }` 隐藏),面板折叠时保持挂载(`.panelHidden`)——任何 `display:none` / 零尺寸祖先都会踩同一坑。
|
||||
- 前提已验证(xterm 5.3.0 与 @xterm/addon-fit 0.10.0 源码):
|
||||
- `FitAddon.fit()` 在 open 之前是安全 no-op(`proposeDimensions` 先查 `this._terminal.element`,undefined 即返回)。
|
||||
- `WriteBuffer.write()` 不依赖 open 状态:数据解析进 buffer,open 后渲染——推迟 open 不丢初始 transcript。
|
||||
- `TerminalView` 已有的 ResizeObserver / 字体订阅里的 `fit.fit()` 在 open 前 no-op、open 后照常 re-fit,无需改动。
|
||||
|
||||
## 4. 设计
|
||||
|
||||
### 4.1 新模块 `src/client/open-when-sized.ts`(纯逻辑,可单测)
|
||||
|
||||
```ts
|
||||
export function openWhenSized(
|
||||
host: HTMLElement,
|
||||
open: () => void,
|
||||
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
|
||||
caf: (id: number) => void = cancelAnimationFrame,
|
||||
): () => void
|
||||
```
|
||||
|
||||
- 单条代码路径:`raf(step)` → step 检查 `host.isConnected`(false 则停止,防卸载后空转)→ `clientWidth > 0 && clientHeight > 0` 则调 `open()` 并停止;否则再排一帧。
|
||||
- 返回 cancel:`caf(frame)` 并置空(幂等),cleanup 调用。
|
||||
- helper 不 try/catch(保持纯净),异常由调用方处理。
|
||||
|
||||
### 4.2 消费点 `src/client/TerminalView.tsx`
|
||||
|
||||
- 移除 effect 顶部的 `term.open(host); fit.fit()`(保留 `term.loadAddon(fit)`)。
|
||||
- 在 effect 尾部(`sendResize` / `connect` 定义之后、`connect()` 调用之前)注册:
|
||||
|
||||
```ts
|
||||
const cancelOpen = openWhenSized(host, () => {
|
||||
try {
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
sendResize()
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] xterm open failed:', error)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
- 回调里补 `sendResize()`:推迟路径下 socket 可能已先连上(`connect()` 在 effect 尾部同步调用),open+fit 后必须把真实 cols/rows 发出去,否则 pty 停留在默认 80x24 直到下次 resize;已尺寸快速路径下 socket 尚未 open,`sendResize` 内部守卫(`readyState === OPEN`)自然 no-op,无行为变化。
|
||||
- cleanup 里在 `term.dispose()` 前加 `cancelOpen()`。
|
||||
- 其余(connect / observer / fontSub / schemeSub / onData / 主题)不动。
|
||||
|
||||
## 5. 测试
|
||||
|
||||
`tests/open-when-sized.spec.ts`(jsdom + 注入手动步进的假 raf/caf;`Object.defineProperty` 桩 `clientWidth/clientHeight`;`appendChild/remove` 控制 `isConnected`):
|
||||
|
||||
1. 已尺寸 → tick 一次后 open 恰好一次,之后不再排帧;
|
||||
2. 零尺寸 → 多次 tick 不 open,设尺寸后下一次 tick open 一次,后续 tick 不重复;
|
||||
3. 只有一维缺失 → 补齐后 open;
|
||||
4. cancel 后永不 open、无残留帧、cancel 幂等;
|
||||
5. host 脱离文档 → 轮询停止、不 open;
|
||||
6. open 抛错 → 异常向调用方传播(调用方负责 try/catch),不重复调用。
|
||||
|
||||
## 6. 限制与取舍
|
||||
|
||||
- rAF 轮询在 host 长期隐藏(`display:none` 祖先)期间持续排帧,每帧只读两个属性,开销可忽略;open 后立即停止。用 ResizeObserver 触发也可行,但轮询是 issue 中建议的标准修法,且不依赖 observer 对 transition 中间态的回调时序。
|
||||
- 已尺寸快速路径比原行为晚一帧(rAF 回调时机),无感知差异。
|
||||
- 若容器永远零尺寸(异常布局),终端永不 open、不崩溃——对不可见内容是可接受的降级。
|
||||
|
||||
## 7. 实施偏差记录
|
||||
|
||||
无(按设计实施)。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.7.0",
|
||||
"version": "0.11.0",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
+54
-31
@@ -1,9 +1,15 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.7.0",
|
||||
"version": "0.11.0",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/omdsh-dev/DSH-better-sidebar"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
@@ -34,9 +40,13 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
@@ -54,10 +64,17 @@
|
||||
"lib/client-terminal.js",
|
||||
"lib/client-editor.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"src"
|
||||
"src",
|
||||
"scripts/install.sh",
|
||||
"scripts/install.ps1",
|
||||
"cordis.patch.yml",
|
||||
"README.md",
|
||||
"README_EN.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
|
||||
"prepublishOnly": "pnpm build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch",
|
||||
@@ -66,24 +83,29 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/cordis": "^4.0.1",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"cordis": { "optional": true }
|
||||
},
|
||||
"dependencies": {
|
||||
"@aiden0z/pptx-renderer": "^1.2.4",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
@@ -120,20 +142,21 @@
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
||||
"@deepseek-ai/dsh-agent": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-session": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.1",
|
||||
|
||||
Generated
+1334
-1109
File diff suppressed because it is too large
Load Diff
@@ -65,3 +65,66 @@ minimumReleaseAgeExclude:
|
||||
- '@deepseek-ai/dsh-user-interaction@0.0.1-rc.1'
|
||||
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
|
||||
- '@deepseek-ai/schemastery@3.18.1-rc.1'
|
||||
- '@deepseek-ai/cordis-plugin-include@1.0.6'
|
||||
- '@deepseek-ai/cordis-plugin-loader@1.0.2'
|
||||
- '@deepseek-ai/cordis@4.0.1'
|
||||
- '@deepseek-ai/cosmokit@1.8.2'
|
||||
- '@deepseek-ai/dsh-agent-default-model@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-agent-presets@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-agent@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-api-gateway@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-api-remotes@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-atomic-write@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-attachment@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-brand@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-connection@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-locale@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-runtime@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-schema-form@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-attachment@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-conversation@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-input-trigger@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-primitives@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-settings@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-ui-slots@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-client-web-react@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-code-runtime@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-commands@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-compaction@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-cordis-host-runner@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-credentials@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-goal@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-home-paths@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-apiproxy@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-directory-picker@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-plugin-inventory@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-host-webserver@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-invariants@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-jobs@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-llm-retry@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-llm@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-message-feedback@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-native-command@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-scope@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-persistence@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-projection-cache@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-projection@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-query@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-stats@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session-title@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-session@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-settings@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-skill@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-storage-domain@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-storage@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-subagent@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-system-prompt@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-timeout@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-token-meter@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-tools@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-typert-protocol@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-typert-registry@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-user-approval@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-user-questions@0.1.0-rc.6'
|
||||
- '@deepseek-ai/dsh-workspace@0.1.0-rc.6'
|
||||
- '@deepseek-ai/schemastery@3.18.1'
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
# =============================================================================
|
||||
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,Windows PowerShell 5.1+ / pwsh)
|
||||
#
|
||||
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
|
||||
# dsh plugin --profile web add dsh-better-sidebar@<version>
|
||||
#
|
||||
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
|
||||
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
|
||||
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
|
||||
# 独立包被 profile 引用。
|
||||
#
|
||||
# 用法(任选其一):
|
||||
# # 默认最新版
|
||||
# irm https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1 | iex
|
||||
# # 指定版本 / 装完重启
|
||||
# & ([scriptblock]::Create((irm 'https://raw.githubusercontent.com/omdsh-dev/DSH-better-sidebar/main/scripts/install.ps1'))) -Version 0.10.2 -Restart
|
||||
# # 本地保存后运行
|
||||
# powershell -ExecutionPolicy Bypass -File install.ps1 -Version 0.10.2 -DryRun
|
||||
#
|
||||
# 参数:
|
||||
# -Version npm 版本号/范围,缺省 latest(自动解析为最新)。
|
||||
# -Restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。会断开当前页面会话。
|
||||
# -DryRun 只打印将要执行的操作,不写任何文件。
|
||||
#
|
||||
# 环境变量(均可省略):
|
||||
# DSH_HOME 默认 %USERPROFILE%\.dsh
|
||||
# REGISTRY 默认 https://registry.npmjs.org
|
||||
# DSH_CMD 默认优先 PATH 上的 dsh,缺省回退 npx -y --package @deepseek-ai/dsh
|
||||
#
|
||||
# 说明:
|
||||
# - pnpm 11 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
|
||||
# `dsh plugin add` 非零退出。脚本会先把这两个构建许可写进 profile 的
|
||||
# pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
|
||||
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
|
||||
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
|
||||
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载。
|
||||
# 脚本会幂等移除 better-sidebar 挂载行。
|
||||
# =============================================================================
|
||||
param(
|
||||
[string]$Version = '',
|
||||
[switch]$Restart,
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
$PKG = 'dsh-better-sidebar'
|
||||
$REGISTRY = if ($env:REGISTRY) { $env:REGISTRY } else { 'https://registry.npmjs.org' }
|
||||
|
||||
# DSH_HOME:DSH_HOME 环境变量 > %USERPROFILE% > $HOME
|
||||
if ($env:DSH_HOME) {
|
||||
$DSH_HOME = $env:DSH_HOME
|
||||
} elseif ($env:USERPROFILE) {
|
||||
$DSH_HOME = Join-Path $env:USERPROFILE '.dsh'
|
||||
} else {
|
||||
$DSH_HOME = Join-Path $HOME '.dsh'
|
||||
}
|
||||
$PROFILE_DIR = Join-Path $DSH_HOME 'profiles\web'
|
||||
$WS_YML = Join-Path $PROFILE_DIR 'pnpm-workspace.yaml'
|
||||
$PATCH_YML = Join-Path $PROFILE_DIR 'cordis.patch.yml'
|
||||
|
||||
function Say([string]$m) { Write-Host "[install] $m" -ForegroundColor Green }
|
||||
function Warn([string]$m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
|
||||
function Die([string]$m) { Write-Host "[error] $m" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# 解析版本 -> npm spec("x.y.z" / "^x.y.z" / latest)
|
||||
function Resolve-Spec {
|
||||
param([string]$Given)
|
||||
if ([string]::IsNullOrWhiteSpace($Given) -or $Given -eq 'latest') {
|
||||
$v = $null
|
||||
foreach ($tool in @('npm', 'pnpm')) {
|
||||
if (Get-Command $tool -ErrorAction SilentlyContinue) {
|
||||
$v = (& $tool view $PKG version "--registry=$REGISTRY" 2>$null | Select-Object -Last 1)
|
||||
if ($v) { break }
|
||||
}
|
||||
}
|
||||
if ($v) { return ([string]$v).Trim() }
|
||||
Warn '无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。'
|
||||
Warn '若已知版本号,可显式传入:-Version 0.10.2'
|
||||
return 'latest'
|
||||
}
|
||||
return $Given
|
||||
}
|
||||
|
||||
# 组装 dsh CLI:优先 PATH 上的 dsh,缺省 npx 拉官方包
|
||||
function Get-DshCli {
|
||||
if ($env:DSH_CMD) { return $env:DSH_CMD }
|
||||
if (Get-Command dsh -ErrorAction SilentlyContinue) { return 'dsh' }
|
||||
if (Get-Command npx -ErrorAction SilentlyContinue) { return 'npx' }
|
||||
return $null
|
||||
}
|
||||
|
||||
# 前置校验
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
Die '未找到 node(DSH 运行需要 Node.js >= 20),请先安装 Node.js 并加入 PATH。'
|
||||
}
|
||||
if (-not (Test-Path $PROFILE_DIR)) {
|
||||
Die "找不到 profile 目录:$PROFILE_DIR(请先安装并运行过一次 dsh web)"
|
||||
}
|
||||
if (-not (Test-Path $WS_YML)) {
|
||||
Die "找不到 $WS_YML(请先初始化 web profile)"
|
||||
}
|
||||
|
||||
$SPEC = Resolve-Spec $Version
|
||||
$CLI = Get-DshCli
|
||||
if (-not $CLI) {
|
||||
Die '未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定。'
|
||||
}
|
||||
Say "目标:$CLI plugin --profile web add $PKG@$SPEC(profile: $PROFILE_DIR)"
|
||||
|
||||
if ($DryRun) {
|
||||
Say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude($PKG)"
|
||||
Say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@$SPEC(安装 + bundle 自动注册)"
|
||||
Say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
|
||||
Say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
|
||||
if ($Restart) { Say '[dry-run] 步骤 5:pm2 restart dsh-web' } else { Say '[dry-run] 步骤 5:提示用户手动重启 DSH' }
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、放行本插件新版本
|
||||
$wsScript = @'
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
let t = fs.readFileSync(p, "utf8");
|
||||
const before = t;
|
||||
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
|
||||
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
|
||||
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
|
||||
} else {
|
||||
for (const k of ["node-pty", "protobufjs"]) {
|
||||
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
|
||||
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
|
||||
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
|
||||
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
|
||||
} else {
|
||||
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
|
||||
}
|
||||
}
|
||||
if (t !== before) fs.writeFileSync(p, t);
|
||||
console.log(t === before ? "unchanged" : "updated");
|
||||
'@
|
||||
$wsOut = node -e $wsScript "$WS_YML" 2>&1
|
||||
$wsCode = $LASTEXITCODE
|
||||
$wsResult = (($wsOut | Out-String)).Trim()
|
||||
if ($wsCode -ne 0) { Die "处理 $WS_YML 失败(node 退出码 $wsCode):$wsResult" }
|
||||
if ($wsResult -eq 'updated') {
|
||||
Say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude($PKG)"
|
||||
} else {
|
||||
Say 'workspace 设置已就绪,跳过'
|
||||
}
|
||||
|
||||
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
|
||||
if ($CLI -eq 'dsh') {
|
||||
$cliArgs = @('plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
|
||||
} else {
|
||||
$cliArgs = @('-y', '--package', '@deepseek-ai/dsh', 'dsh', 'plugin', '--profile', 'web', 'add', "$PKG@$SPEC")
|
||||
}
|
||||
Say "执行 $CLI plugin --profile web add $PKG@$SPEC ..."
|
||||
$addOut = & $CLI @cliArgs 2>&1
|
||||
$addCode = $LASTEXITCODE
|
||||
$addOut | ForEach-Object { $_ }
|
||||
if ($addCode -ne 0) {
|
||||
Warn 'dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:'
|
||||
Warn ' - 网络/登录问题:npm registry 不可达或需要登录。'
|
||||
Warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR; pnpm install。"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 步骤 3:校验 bundle 已注册(挂载生效的判据)
|
||||
$pkgJson = Get-Content -Raw (Join-Path $PROFILE_DIR 'package.json') | ConvertFrom-Json
|
||||
$bundles = $pkgJson.dsh.profile.bundles
|
||||
if ($bundles -notcontains $PKG) {
|
||||
Warn 'dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。'
|
||||
Warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
|
||||
exit 1
|
||||
}
|
||||
Say "bundle 已注册:dsh.profile.bundles 包含 $PKG(下次启动自动挂载)"
|
||||
|
||||
# 步骤 4:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
|
||||
$mountScript = @'
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
const lines = fs.readFileSync(p, "utf8").split("\n");
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let removed = false;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^[ \t]*- insert:\s*$/.test(line)) {
|
||||
const block = [line];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
|
||||
block.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
|
||||
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
|
||||
i = j;
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
i++;
|
||||
}
|
||||
if (!removed) {
|
||||
console.log("none");
|
||||
} else {
|
||||
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
fs.writeFileSync(p, t);
|
||||
console.log("removed");
|
||||
}
|
||||
'@
|
||||
$mountOut = node -e $mountScript "$PATCH_YML" 2>&1
|
||||
$mountCode = $LASTEXITCODE
|
||||
$mountResult = (($mountOut | Out-String)).Trim()
|
||||
if ($mountCode -ne 0) { Die "处理 $PATCH_YML 失败(node 退出码 $mountCode):$mountResult" }
|
||||
if ($mountResult -eq 'removed') {
|
||||
Say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)"
|
||||
} else {
|
||||
Say '无旧手动挂载行,跳过'
|
||||
}
|
||||
|
||||
Say "安装完成:$PKG@$SPEC"
|
||||
|
||||
# 步骤 5:重启提示
|
||||
if ($Restart) {
|
||||
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
|
||||
Say '重启 dsh-web(pm2)...'
|
||||
pm2 restart dsh-web
|
||||
if ($LASTEXITCODE -ne 0) { Warn 'pm2 restart 失败,请手动重启 DSH' }
|
||||
} else {
|
||||
Warn '未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)'
|
||||
}
|
||||
} else {
|
||||
Say '下一步:重启 DSH 并硬刷新(Ctrl+Shift+R / Cmd+Shift+R)使新副本生效。'
|
||||
if (Get-Command pm2 -ErrorAction SilentlyContinue) {
|
||||
Say '本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)'
|
||||
}
|
||||
}
|
||||
Executable
+246
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# dsh-better-sidebar 一键安装脚本(官方 CLI 方式,macOS / Linux / Windows Git Bash)
|
||||
#
|
||||
# 通过 DSH 官方插件命令安装 npm 包并自动挂载:
|
||||
# dsh plugin --profile web add dsh-better-sidebar@<version>
|
||||
#
|
||||
# 包内声明了 dsh.bundle.patch(cordis.patch.yml):CLI 的 bundle 协调会把它
|
||||
# 自动加进 profile 的 dsh.profile.bundles,下次启动即挂载——无需手动写
|
||||
# cordis.patch.yml 挂载行。符合仓库硬约束:不修改 DSH 源码,插件永远作为
|
||||
# 独立包被 profile 引用。
|
||||
#
|
||||
# 用法:
|
||||
# bash scripts/install.sh [版本] [--restart] [--dry-run]
|
||||
#
|
||||
# 版本 npm 版本号/范围,缺省为 latest(自动解析为 ^<最新>)。
|
||||
# 示例:0.10.2、^0.10.2、~0.10.2、latest
|
||||
# --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅打印提示)。
|
||||
# 注意:重启会断开当前 DSH 页面会话,默认不自动重启。
|
||||
# --dry-run 只打印将要执行的操作,不写任何文件。
|
||||
# -h/--help 打印本帮助。
|
||||
#
|
||||
# 环境(均可省略,脚本会自动探测):
|
||||
# DSH_HOME 默认 ~/.dsh(Windows Git Bash 下回退 $USERPROFILE/.dsh)
|
||||
# REGISTRY 默认 https://registry.npmjs.org(发布源;装依赖仍走 pnpm 配置)
|
||||
# DSH_CMD 默认优先用 PATH 上的 `dsh`,缺省回退 npx -y --package @deepseek-ai/dsh
|
||||
#
|
||||
# 说明:
|
||||
# - pnpm 11 的 strict-dep-builds 会拦截 node-pty/protobufjs 的构建脚本并使
|
||||
# `dsh plugin add` 非零退出(bundle 协调因此被跳过)。脚本会先把这两个
|
||||
# 构建许可写进 profile 的 pnpm-workspace.yaml(幂等),保证 CLI 一步成功。
|
||||
# - pnpm 11 的 minimumReleaseAge 会拒绝发布 <24h 的新版本。脚本会预写
|
||||
# minimumReleaseAgeExclude(幂等),放行本插件,避免"重跑一次才成功"。
|
||||
# - 老版本(<0.10.2)用手动挂载行,bundle 通道激活后需移除,否则双挂载
|
||||
# (Node 半挂两次、页面两个侧边栏)。脚本会幂等移除 better-sidebar 挂载行。
|
||||
# - 回滚:dsh plugin --profile web remove dsh-better-sidebar,或把 profile 依赖
|
||||
# 改回 "dsh-better-sidebar": "link:<路径>" 再 pnpm install。
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# 帮助请求优先处理
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then
|
||||
cat <<'EOF'
|
||||
dsh-better-sidebar 一键安装脚本
|
||||
|
||||
用法:bash scripts/install.sh [版本] [--restart] [--dry-run]
|
||||
|
||||
版本 npm 版本号/范围,缺省 latest(自动解析为最新)。示例:0.10.2、^0.10.2、latest
|
||||
--restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)
|
||||
--dry-run 只打印将要执行的操作,不写任何文件
|
||||
|
||||
环境变量(可省略):DSH_HOME(默认 ~/.dsh)、REGISTRY(npm 源)、DSH_CMD(dsh 命令)
|
||||
EOF
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}"
|
||||
PROFILE_DIR="$DSH_HOME/profiles/web"
|
||||
WS_YML="$PROFILE_DIR/pnpm-workspace.yaml"
|
||||
PATCH_YML="$PROFILE_DIR/cordis.patch.yml"
|
||||
REGISTRY="${REGISTRY:-https://registry.npmjs.org}"
|
||||
PKG="dsh-better-sidebar"
|
||||
DSH_CMD="${DSH_CMD:-dsh}"
|
||||
|
||||
RESTART=false
|
||||
DRY_RUN=false
|
||||
VERSION_SPEC=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--restart) RESTART=true ;;
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
-h|--help) : ;; # 已在上面处理
|
||||
-*) echo "未知参数: ${arg}(用 -h 查看用法)" >&2; exit 2 ;;
|
||||
*) VERSION_SPEC="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf '\033[32m[install]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
|
||||
die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# 解析用户给的版本 -> CLI 要用的 npm spec("x.y.z" / "^x.y.z" / latest)
|
||||
resolve_spec() {
|
||||
local given="${1:-latest}"
|
||||
case "$given" in
|
||||
latest)
|
||||
local v=""
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
v="$(npm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
|
||||
fi
|
||||
if [ -z "$v" ] && command -v pnpm >/dev/null 2>&1; then
|
||||
v="$(pnpm view "$PKG" version --registry="$REGISTRY" 2>/dev/null)" || v=""
|
||||
fi
|
||||
if [ -n "$v" ]; then
|
||||
printf '%s' "$v"
|
||||
else
|
||||
warn "无法联网解析最新版本(npm/pnpm 查询失败),回退为 latest,由 pnpm 直接解析。"
|
||||
warn "若已知版本号,可显式传入:bash scripts/install.sh 0.10.2"
|
||||
printf 'latest'
|
||||
fi
|
||||
;;
|
||||
*) printf '%s' "$given" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 组装 dsh CLI 调用:优先 PATH 上的 dsh,缺省 npx 拉官方包
|
||||
dsh_cli() {
|
||||
if command -v "$DSH_CMD" >/dev/null 2>&1; then
|
||||
printf '%s' "$DSH_CMD"
|
||||
elif command -v npx >/dev/null 2>&1; then
|
||||
printf 'npx -y --package @deepseek-ai/dsh dsh'
|
||||
else
|
||||
die "未找到 dsh 或 npx。请先安装 DSH(并确保 Node/npm 可用),或用 DSH_CMD 指定 dsh 路径。"
|
||||
fi
|
||||
}
|
||||
|
||||
# 前置校验
|
||||
command -v node >/dev/null 2>&1 || die "未找到 node(DSH 运行需要 Node.js ≥ 20),请先安装 Node.js 并加入 PATH。"
|
||||
|
||||
[ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先安装并运行过一次 dsh web)"
|
||||
[ -f "$WS_YML" ] || die "找不到 ${WS_YML}(请先初始化 web profile)"
|
||||
|
||||
SPEC="$(resolve_spec "$VERSION_SPEC")"
|
||||
CLI="$(dsh_cli)"
|
||||
say "目标:$CLI plugin --profile web add $PKG@${SPEC}(profile: ${PROFILE_DIR})"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
say "[dry-run] 步骤 1:确保 $WS_YML 含 allowBuilds(node-pty/protobufjs: true)与 minimumReleaseAgeExclude(${PKG})"
|
||||
say "[dry-run] 步骤 2:执行 $CLI plugin --profile web add $PKG@${SPEC}(安装 + bundle 自动注册)"
|
||||
say "[dry-run] 步骤 3:校验 dsh.profile.bundles 含 $PKG"
|
||||
say "[dry-run] 步骤 4:幂等移除 $PATCH_YML 里旧的 better-sidebar 手动挂载行(避免双挂载)"
|
||||
if [ "$RESTART" = true ]; then say "[dry-run] 步骤 5:pm2 restart dsh-web"; else say "[dry-run] 步骤 5:提示用户手动重启 DSH"; fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 步骤 1:预写 workspace 设置(幂等),保证 pnpm 不拦截构建、不放行旧版本策略
|
||||
WS_RESULT="$(node -e '
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
let t = fs.readFileSync(p, "utf8");
|
||||
const before = t;
|
||||
// allowBuilds:把 node-pty/protobufjs 归位为 true
|
||||
t = t.replace(/^(\s*)(node-pty|protobufjs):.*$/gm, "$1$2: true");
|
||||
if (!/^\s*allowBuilds:\s*$/m.test(t)) {
|
||||
t += "\nallowBuilds:\n node-pty: true\n protobufjs: true\n";
|
||||
} else {
|
||||
for (const k of ["node-pty", "protobufjs"]) {
|
||||
if (!new RegExp("^\\s*" + k + ":\\s*true\\s*$", "m").test(t)) {
|
||||
t = t.replace(/^(\s*allowBuilds:\s*)$/m, "$1\n " + k + ": true");
|
||||
}
|
||||
}
|
||||
}
|
||||
// minimumReleaseAgeExclude:放行本插件(版本无关),避免 <24h 新版本被拒
|
||||
if (!/^\s*-\s+dsh-better-sidebar\s*$/m.test(t)) {
|
||||
if (/^\s*minimumReleaseAgeExclude:\s*$/m.test(t)) {
|
||||
t = t.replace(/^(\s*minimumReleaseAgeExclude:\s*)$/m, "$1\n - dsh-better-sidebar");
|
||||
} else {
|
||||
t += "\nminimumReleaseAgeExclude:\n - dsh-better-sidebar\n";
|
||||
}
|
||||
}
|
||||
if (t !== before) fs.writeFileSync(p, t);
|
||||
console.log(t === before ? "unchanged" : "updated");
|
||||
' "$WS_YML")"
|
||||
[ "$WS_RESULT" = "updated" ] \
|
||||
&& say "已确保 $WS_YML:allowBuilds(node-pty/protobufjs: true)+ minimumReleaseAgeExclude(${PKG})" \
|
||||
|| say "workspace 设置已就绪,跳过"
|
||||
|
||||
# 步骤 2:官方 CLI 安装 + bundle 自动注册(含挂载)
|
||||
say "执行 $CLI plugin --profile web add $PKG@$SPEC ..."
|
||||
if ! $CLI plugin --profile web add "$PKG@$SPEC" 2>&1 | tail -n +1; then
|
||||
warn "dsh plugin add 失败。已预写 allowBuilds 与 minimumReleaseAgeExclude,仍失败的可能原因:"
|
||||
warn " - 网络/登录问题:npm registry 不可达或需要登录。"
|
||||
warn " - 依赖安装冲突:可手动重试 cd $PROFILE_DIR && pnpm install。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 校验 bundle 已注册(挂载生效的判据)
|
||||
if ! node -e '
|
||||
const fs = require("fs");
|
||||
const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const bundles = p.dsh?.profile?.bundles ?? [];
|
||||
process.exit(bundles.includes(process.argv[2]) ? 0 : 1);
|
||||
' "$PROFILE_DIR/package.json" "$PKG"; then
|
||||
warn "dsh-better-sidebar 未出现在 dsh.profile.bundles 中——挂载未注册。"
|
||||
warn "若上面的 pnpm 输出提示 ignored build scripts,请确认 $WS_YML 的 allowBuilds 后重跑本脚本。"
|
||||
exit 1
|
||||
fi
|
||||
say "bundle 已注册:dsh.profile.bundles 包含 ${PKG}(下次启动自动挂载)"
|
||||
|
||||
# 步骤 3:幂等移除旧的 manual 挂载行(避免与 bundle 双挂载)
|
||||
MOUNT_RESULT="$(node -e '
|
||||
const fs = require("fs");
|
||||
const p = process.argv[1];
|
||||
const lines = fs.readFileSync(p, "utf8").split("\n");
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let removed = false;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (/^[ \t]*- insert:\s*$/.test(line)) {
|
||||
const block = [line];
|
||||
let j = i + 1;
|
||||
while (j < lines.length && lines[j].trim() !== "" && !/^-\s/.test(lines[j])) {
|
||||
block.push(lines[j]);
|
||||
j++;
|
||||
}
|
||||
if (block.some((l) => /id:\s*better-sidebar\b/.test(l))) {
|
||||
while (out.length && /^[ \t]*#/.test(out[out.length - 1])) out.pop();
|
||||
i = j;
|
||||
removed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
i++;
|
||||
}
|
||||
if (!removed) {
|
||||
console.log("none");
|
||||
} else {
|
||||
const t = out.join("\n").replace(/\n{3,}/g, "\n\n");
|
||||
fs.writeFileSync(p, t);
|
||||
console.log("removed");
|
||||
}
|
||||
' "$PATCH_YML")"
|
||||
[ "$MOUNT_RESULT" = "removed" ] \
|
||||
&& say "已从 $PATCH_YML 移除旧的 better-sidebar 手动挂载行(bundle 通道接管挂载)" \
|
||||
|| say "无旧手动挂载行,跳过"
|
||||
|
||||
say "安装完成:$PKG@$SPEC"
|
||||
|
||||
# 步骤 4:重启提示
|
||||
if [ "$RESTART" = true ]; then
|
||||
if command -v pm2 >/dev/null 2>&1; then
|
||||
say "重启 dsh-web(pm2)..."
|
||||
pm2 restart dsh-web || warn "pm2 restart 失败,请手动重启 DSH"
|
||||
else
|
||||
warn "未找到 pm2,请手动重启 DSH(如:pm2 restart dsh-web 或 dsh web)"
|
||||
fi
|
||||
else
|
||||
say "下一步:重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)使新副本生效。"
|
||||
if command -v pm2 >/dev/null 2>&1; then
|
||||
say "本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)"
|
||||
fi
|
||||
fi
|
||||
+1
-1
@@ -122,7 +122,7 @@ export function createBundleRouteHandler(
|
||||
|
||||
/** Register the /sidebar/bundle route (disposed with the fiber). */
|
||||
export function registerBundleRoute(ctx: Context, fence: (req: IncomingMessage) => boolean): () => void {
|
||||
return ctx.httpServer.register({
|
||||
return ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/bundle',
|
||||
handler: createBundleRouteHandler(fence),
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
/**
|
||||
* Side card settings section chrome (DSH-native tokens throughout):
|
||||
* - the section is a column of GROUP CONTAINER cards (the PluginCard recipe:
|
||||
* l2 hairline, 16px radius, layer-3 fill) with a heading + inventory count
|
||||
* badge (the settings catalogHeading recipe), opening with a one-line
|
||||
* intro (the DSH section heading+intro recipe);
|
||||
* - 常规 holds full-width settings rows (the DSH settings-row recipe:
|
||||
* title/desc left + control right, hairline separators) whose toggles are
|
||||
* custom switches — a real checkbox (native semantics and focus) driving
|
||||
* a styled track/thumb;
|
||||
* - the feature inventories are responsive GRIDS of small toggle cards
|
||||
* (auto-fill columns — several cards per row, wrapping);
|
||||
* - a card's on/off state IS its visual state: enabled = brand border +
|
||||
* tinted fill + a check badge pinned to the far right of the top row,
|
||||
* disabled = neutral hairline + dimmed text;
|
||||
* (auto-fill columns — several cards per row, wrapping): icon chip +
|
||||
* title + type id, the card's on/off state IS its visual state
|
||||
* (enabled = brand border + tinted fill + circular check badge,
|
||||
* disabled = neutral hairline + dimmed);
|
||||
* - features that declare related settings carry a gear corner button
|
||||
* opening a native Modal whose rows are the DSH settings-row recipe
|
||||
* (title/desc left + native accent checkbox, hairline separators);
|
||||
* opening a native Modal (wider than the primitive default) whose rows
|
||||
* follow the same row recipe with switch controls and a Done footer;
|
||||
* - the width input stays a native settings row (a value, not a switch).
|
||||
*/
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
/* Tall inventories (7 tab cards + 9 viewer cards + general rows) must
|
||||
never be clipped by the settings shell: fill the shell's options area
|
||||
@@ -24,15 +32,55 @@
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
border-bottom: none;
|
||||
/* One-line section intro (the DSH section heading+intro recipe). */
|
||||
.intro {
|
||||
margin: 0;
|
||||
padding: 0 2px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Group container: the DSH card recipe (PluginCard) — l2 hairline, r16,
|
||||
layer-3 fill; holds its own heading + stacked rows / card grid. */
|
||||
.group {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 18px 20px 20px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
/* Group heading: 13/600 + the inventory count badge (the settings
|
||||
catalogHeading recipe: count in tabular-nums, tertiary). */
|
||||
.groupHeading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
padding: 0 2px 6px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 400;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Responsive inventory: as many small cards per row as the width allows. */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
|
||||
@@ -40,7 +88,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
@@ -52,6 +100,7 @@
|
||||
/* Hover affordance on disabled cards only (enabled cards are already lit). */
|
||||
.card:not(.cardOn):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* Enabled = highlighted: brand border + tinted fill. */
|
||||
@@ -64,9 +113,9 @@
|
||||
.cardMain {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
padding: 12px;
|
||||
border: 0;
|
||||
border-radius: inherit;
|
||||
background: transparent;
|
||||
@@ -85,18 +134,28 @@
|
||||
.cardTop {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cardIcon {
|
||||
/* The icon sits in a rounded chip: brand-tinted while the card is on,
|
||||
neutral (layer-2 fill + hairline) while off. */
|
||||
.cardIconChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.cardOn .cardIcon {
|
||||
.cardOn .cardIconChip {
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 12%, transparent);
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
@@ -105,6 +164,7 @@
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -115,12 +175,18 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */
|
||||
/* The check badge: a circular brand-filled pin at the card's far right
|
||||
(enabled only), the icon in the inverted surface token (the .save recipe). */
|
||||
.cardCheck {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
color: var(--dsw-alias-button-primary-fill);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-button-primary-fill);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
@@ -136,24 +202,28 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The gear corner button: bottom-right of cards that declare related
|
||||
settings; the desc gets right padding so the ellipsis never runs under it. */
|
||||
/* Cards with a settings gear keep the desc clear of it: the gear sits in
|
||||
the desc row, sharing the check badge's right edge (a vertical column of
|
||||
controls at the card's right side). */
|
||||
.cardWithGear .cardDesc {
|
||||
padding-right: 18px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
/* The gear corner button: directly UNDER the check badge on the card's
|
||||
right edge — the two controls are vertically aligned (same right inset,
|
||||
one row apart) and the SAME 16px size, not side by side. */
|
||||
.cardGear {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
right: 12px;
|
||||
top: 46px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
@@ -164,26 +234,21 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */
|
||||
.sectionHeading {
|
||||
padding: 14px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Native settings row recipe (the width input row + popup rows). */
|
||||
/* Settings-row recipe (the DSH General section rows): title/desc left,
|
||||
control right, hairline separators (stripped on the last row). */
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 0;
|
||||
padding: 12px 2px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -199,45 +264,66 @@
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Native checkbox: 16px, brand accent (the settings-row recipe). */
|
||||
.toggle {
|
||||
/* Custom switch: a real checkbox (native semantics + focus, visually
|
||||
hidden) driving a styled 36x20 track with a sliding thumb. */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switchInput {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Secondary settings popup body: stacked native rows, trailing separator
|
||||
stripped by the trailing-child rule. */
|
||||
.popupRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popupRow {
|
||||
display: flex;
|
||||
.switchTrack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.popupRows > :last-child {
|
||||
border-bottom: none;
|
||||
.switchThumb {
|
||||
display: block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-secondary);
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.switch:hover .switchTrack {
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.switchInput:checked + .switchTrack {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
background: var(--dsw-alias-button-primary-fill);
|
||||
}
|
||||
|
||||
.switchInput:checked + .switchTrack .switchThumb {
|
||||
transform: translateX(16px);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.switchInput:focus-visible + .switchTrack {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.control {
|
||||
@@ -251,12 +337,74 @@
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
/* Text/number rows in the settings popup: the free-form input (e.g. the
|
||||
terminal font family) gets a generous width; numeric rows (font size)
|
||||
keep the compact width of the general number inputs. */
|
||||
.typedInput {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.typedInputNumber {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Secondary settings popup: a wider dialog than the primitive default (the
|
||||
dialog portals to document.body; the doubled class keeps the width rule
|
||||
above the primitive's .dialog regardless of stylesheet order). */
|
||||
.popupDialog.popupDialog {
|
||||
width: min(460px, 100%);
|
||||
}
|
||||
|
||||
/* Popup rows: the same settings-row recipe with switch controls. */
|
||||
.popupRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.popupRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 2px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.popupRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.popupRows > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* The popup's Done button: the settings primary-button recipe
|
||||
(PluginCard .save: label-primary fill, inverted surface text). */
|
||||
.done {
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 5px 14px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.done:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 0 2px;
|
||||
font-size: 12px;
|
||||
@@ -265,7 +413,9 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card {
|
||||
.card,
|
||||
.switchTrack,
|
||||
.switchThumb {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+269
-114
@@ -4,21 +4,30 @@
|
||||
*
|
||||
* The section is DECLARATIVE — it renders the enable/disable inventory from
|
||||
* the sidebar service's registries instead of hardcoding rows:
|
||||
* - 常规: new conversations open the panel by default (a toggle card), and
|
||||
* the default panel width as a percent of the window (number input row).
|
||||
* - 常规: new conversations open the panel by default (a toggle row), the
|
||||
* default panel width as a percent of the window (number input row), and
|
||||
* the open-path interception toggle — the DSH settings-row recipe
|
||||
* (title/desc left + control right, hairline separators).
|
||||
* - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
|
||||
* external plugins alike), laid out in a responsive grid that wraps
|
||||
* several cards per row — icon + title + type id, clicked to toggle the
|
||||
* switch persisted in `prefs.tabsEnabled[id]`.
|
||||
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title +
|
||||
* the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
|
||||
* several cards per row — icon chip + title + type id, clicked to toggle
|
||||
* the switch persisted in `prefs.tabsEnabled[id]`.
|
||||
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon chip + title
|
||||
* + the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
|
||||
*
|
||||
* Every group lives in a container card (the DSH PluginCard recipe: l2
|
||||
* hairline, 16px radius, layer-3 fill) with a heading and an inventory count
|
||||
* badge (the settings catalogHeading recipe); the section opens with a
|
||||
* one-line intro (the DSH section heading+intro recipe).
|
||||
*
|
||||
* A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
|
||||
* border + tinted fill + a check badge pinned to the card's far right),
|
||||
* disabled = neutral and dimmed. Features that declare `settings.toggles`
|
||||
* carry a gear corner button that opens a native Modal with the related
|
||||
* settings as native checkbox rows (e.g. the Subagent page's "auto-open
|
||||
* when a subagent appears", the Terminal page's model terminal tools).
|
||||
* border + tinted fill + a circular check badge pinned to the card's far
|
||||
* right), disabled = neutral and dimmed. Features that declare
|
||||
* `settings.toggles` carry a gear corner button that opens a native Modal
|
||||
* (wider than the primitive default) with the related settings as
|
||||
* title/desc + custom-switch rows and a Done footer. The toggles
|
||||
* themselves are custom switches: a real checkbox (native semantics and
|
||||
* focus) driving a styled track/thumb.
|
||||
*
|
||||
* Writes ride the plugin's own fenced settings route (the host calls the
|
||||
* settings seam in-process — the DSH settings RPC domain does not serve
|
||||
@@ -32,8 +41,6 @@
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16,
|
||||
IconCodeOutline16,
|
||||
IconPanelLeftOutline16,
|
||||
IconSettingsOutline16,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -106,40 +113,133 @@ function prefBool(prefs: SidebarPrefs, key: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of a feature's secondary settings popup: one native checkbox row
|
||||
* per declared toggle. Extracted so the rows are testable without opening
|
||||
* the Modal (the Modal portal renders only while open).
|
||||
* The custom switch: a real checkbox (hidden, native semantics and focus)
|
||||
* driving a styled track/thumb. Used by the general toggle rows and the
|
||||
* secondary settings popup rows.
|
||||
*/
|
||||
function Switch(props: {
|
||||
checked: boolean
|
||||
onChange: (next: boolean) => void
|
||||
label: string
|
||||
}) {
|
||||
const { checked, onChange, label } = props
|
||||
return (
|
||||
<label className={css.switch}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.switchInput}
|
||||
checked={checked}
|
||||
aria-label={label}
|
||||
onChange={event => { onChange(event.currentTarget.checked) }}
|
||||
/>
|
||||
<span className={css.switchTrack} aria-hidden="true">
|
||||
<span className={css.switchThumb} />
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The body of a feature's secondary settings popup: one row (title/desc +
|
||||
* control) per declared setting. Switches render the custom switch; text and
|
||||
* number rows render a free-form / numeric input committed on blur/Enter
|
||||
* (clamped to the declared min/max). Extracted so the rows are testable
|
||||
* without opening the Modal (the Modal portal renders only while open).
|
||||
*/
|
||||
export function FeatureSettingsRows(props: {
|
||||
toggles: readonly SidebarSettingToggle[]
|
||||
prefs: SidebarPrefs
|
||||
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
|
||||
/** Commit one text/number row; returns the canonical value the row should
|
||||
* display (clamped for numbers, the current pref when the input is
|
||||
* invalid). Optional: rows with no handler keep their draft. */
|
||||
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
|
||||
}) {
|
||||
const { toggles, prefs, onToggle } = props
|
||||
const { toggles, prefs, onToggle, onCommit } = props
|
||||
return (
|
||||
<div className={css.popupRows}>
|
||||
{toggles.map(toggle => {
|
||||
const title = textOf(toggle.title)
|
||||
if ((toggle.type ?? 'switch') === 'switch') {
|
||||
return (
|
||||
<div key={toggle.key} className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<Switch
|
||||
label={title}
|
||||
checked={prefBool(prefs, toggle.key)}
|
||||
onChange={(next) => { onToggle(toggle, next) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const value = String((prefs as unknown as Record<string, unknown>)[toggle.key] ?? '')
|
||||
// Keyed by the committed value: a failed commit reverts prefs, the
|
||||
// key changes, and the row remounts with the stored value (typing
|
||||
// never changes the key, so mid-edit drafts survive re-renders).
|
||||
return (
|
||||
<label key={toggle.key} className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefBool(prefs, toggle.key)}
|
||||
aria-label={title}
|
||||
onChange={event => { onToggle(toggle, event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
<TypedRow
|
||||
key={`${toggle.key}:${value}`}
|
||||
toggle={toggle}
|
||||
title={title}
|
||||
value={value}
|
||||
onCommit={onCommit}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One text/number row: a controlled input whose draft is local state,
|
||||
* committed on blur/Enter through the parent's onCommit. The parent's
|
||||
* canonical return is adopted (clamped numbers, stored value for invalid
|
||||
* input); a `unit` suffix renders after the input (e.g. 'px').
|
||||
*/
|
||||
function TypedRow(props: {
|
||||
toggle: SidebarSettingToggle
|
||||
title: string
|
||||
value: string
|
||||
onCommit?: (toggle: SidebarSettingToggle, raw: string) => string
|
||||
}) {
|
||||
const { toggle, title, value, onCommit } = props
|
||||
const [draft, setDraft] = useState(value)
|
||||
const commit = (): void => {
|
||||
const canonical = onCommit?.(toggle, draft) ?? draft
|
||||
setDraft(canonical)
|
||||
}
|
||||
const number = toggle.type === 'number'
|
||||
return (
|
||||
<div className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type={number ? 'number' : 'text'}
|
||||
className={number ? css.typedInputNumber : css.typedInput}
|
||||
value={draft}
|
||||
min={toggle.min}
|
||||
max={toggle.max}
|
||||
step={1}
|
||||
placeholder={toggle.placeholder}
|
||||
aria-label={title}
|
||||
onChange={event => { setDraft(event.currentTarget.value) }}
|
||||
onBlur={commit}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
{toggle.unit !== undefined && <span className={css.suffix}>{toggle.unit}</span>}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Side card preferences section.
|
||||
* @param props - composed slot props (runtime share + injected store/service).
|
||||
@@ -219,8 +319,8 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
setWidthDraft(String(settled.defaultWidthPercent))
|
||||
}
|
||||
|
||||
/** Optimistically flip one boolean pref, then commit (revert on failure). */
|
||||
const togglePref = (patch: Record<string, unknown>): void => {
|
||||
/** Optimistically apply one pref patch, then commit (revert on failure). */
|
||||
const applyPref = (patch: Record<string, unknown>): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, ...patch } as SidebarPrefs)
|
||||
setError(null)
|
||||
@@ -228,22 +328,44 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
togglePref({ openByDefault: next })
|
||||
applyPref({ openByDefault: next })
|
||||
}
|
||||
|
||||
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
|
||||
const onToggleTab = (id: string, next: boolean): void => {
|
||||
togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
|
||||
applyPref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
|
||||
const onToggleViewer = (id: string, next: boolean): void => {
|
||||
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
|
||||
applyPref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
|
||||
}
|
||||
|
||||
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
|
||||
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
|
||||
togglePref({ [toggle.key]: next })
|
||||
applyPref({ [toggle.key]: next })
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit one declaratively-declared text/number row. Numbers are parsed
|
||||
* and clamped to the toggle's declared min/max (an unparsable input falls
|
||||
* back to the CURRENT stored value, mirroring the width row); text rows
|
||||
* persist as-is (empty is meaningful, e.g. the theme-default font).
|
||||
* Returns the canonical value the row should display.
|
||||
*/
|
||||
const onCommitSetting = (toggle: SidebarSettingToggle, raw: string): string => {
|
||||
if (toggle.type === 'number') {
|
||||
const parsed = Number(raw)
|
||||
const fallback = String((prefs as unknown as Record<string, unknown>)[toggle.key] ?? '')
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
let clamped = Math.round(parsed)
|
||||
if (toggle.min !== undefined) clamped = Math.max(toggle.min, clamped)
|
||||
if (toggle.max !== undefined) clamped = Math.min(toggle.max, clamped)
|
||||
applyPref({ [toggle.key]: clamped })
|
||||
return String(clamped)
|
||||
}
|
||||
applyPref({ [toggle.key]: raw })
|
||||
return raw
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
@@ -262,9 +384,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
|
||||
/**
|
||||
* One SMALL toggle card for the responsive inventory grid: the card's main
|
||||
* area is the switch (click to flips, visual state IS the state), the
|
||||
* check badge sits at the far right, and a feature that declares related
|
||||
* settings carries a gear corner button opening its settings popup.
|
||||
* area is the switch (click to flips, visual state IS the state), the icon
|
||||
* sits in a rounded chip, the check badge pins to the far right, and a
|
||||
* feature that declares related settings carries a gear corner button
|
||||
* opening its settings popup.
|
||||
*/
|
||||
const renderCard = (props: {
|
||||
title: string
|
||||
@@ -289,12 +412,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
>
|
||||
<span className={css.cardTop}>
|
||||
{props.icon !== null && props.icon !== undefined && (
|
||||
<span className={css.cardIcon}>{props.icon}</span>
|
||||
<span className={css.cardIconChip}>{props.icon}</span>
|
||||
)}
|
||||
<span className={css.cardTitle}>{props.title}</span>
|
||||
{props.enabled && (
|
||||
<span className={css.cardCheck}>
|
||||
<IconCheckOutline16 size={14} />
|
||||
<IconCheckOutline16 size={12} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -308,7 +431,7 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
title={t('settingsPopup')}
|
||||
onClick={props.onOpenSettings}
|
||||
>
|
||||
<IconSettingsOutline16 size={14} />
|
||||
<IconSettingsOutline16 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -317,101 +440,133 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<div className={css.sectionHeading}>{t('settingsGeneralTitle')}</div>
|
||||
{renderCard({
|
||||
title: t('settingsOpenTitle'),
|
||||
desc: t('settingsOpenDesc'),
|
||||
icon: <IconPanelLeftOutline16 size={16} />,
|
||||
enabled: prefs.openByDefault,
|
||||
onToggle,
|
||||
})}
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
<p className={css.intro}>{t('settingsIntro')}</p>
|
||||
|
||||
{/* 常规: the DSH settings-row recipe — title/desc left, control right. */}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>{t('settingsGeneralTitle')}</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenDesc')}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t('settingsOpenTitle')}
|
||||
checked={prefs.openByDefault}
|
||||
onChange={onToggle}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenPathTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenPathDesc')}</span>
|
||||
</span>
|
||||
<Switch
|
||||
label={t('settingsOpenPathTitle')}
|
||||
checked={prefs.interceptOpenPath}
|
||||
onChange={(next) => { applyPref({ interceptOpenPath: next }) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{renderCard({
|
||||
title: t('settingsOpenPathTitle'),
|
||||
desc: t('settingsOpenPathDesc'),
|
||||
icon: <IconCodeOutline16 size={16} />,
|
||||
enabled: prefs.interceptOpenPath,
|
||||
onToggle: (next) => { togglePref({ interceptOpenPath: next }) },
|
||||
})}
|
||||
|
||||
{/* 侧边栏内容: one small card per registered tab type in a responsive
|
||||
grid; features declaring `settings.toggles` open their settings in
|
||||
the popup (gear corner button) instead of nested inline rows. */}
|
||||
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
|
||||
<div className={css.grid}>
|
||||
{tabs.map(tab => (
|
||||
<Fragment key={tab.id}>
|
||||
{renderCard({
|
||||
title: textOf(tab.title),
|
||||
desc: tab.id,
|
||||
icon: iconOf(tab.icon, 16),
|
||||
enabled: prefs.tabsEnabled[tab.id] !== false,
|
||||
onToggle: (next) => { onToggleTab(tab.id, next) },
|
||||
// The settings gear only while the feature is enabled: its
|
||||
// related settings are dormant while the feature is off.
|
||||
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
|
||||
&& (tab.settings?.toggles?.length ?? 0) > 0
|
||||
? () => { setSettingsFor(tab) }
|
||||
: undefined,
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>
|
||||
<span>{t('settingsTabsTitle')}</span>
|
||||
<span className={css.count}>{tabs.length}</span>
|
||||
</div>
|
||||
<div className={css.grid}>
|
||||
{tabs.map(tab => (
|
||||
<Fragment key={tab.id}>
|
||||
{renderCard({
|
||||
title: textOf(tab.title),
|
||||
desc: tab.id,
|
||||
icon: iconOf(tab.icon, 16),
|
||||
enabled: prefs.tabsEnabled[tab.id] !== false,
|
||||
onToggle: (next) => { onToggleTab(tab.id, next) },
|
||||
// The settings gear only while the feature is enabled: its
|
||||
// related settings are dormant while the feature is off.
|
||||
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
|
||||
&& (tab.settings?.toggles?.length ?? 0) > 0
|
||||
? () => { setSettingsFor(tab) }
|
||||
: undefined,
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件预览: one small card per registered file viewer. */}
|
||||
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
|
||||
<div className={css.grid}>
|
||||
{viewers.map(viewer => (
|
||||
<Fragment key={viewer.id}>
|
||||
{renderCard({
|
||||
title: textOf(viewer.title) || viewer.id,
|
||||
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
|
||||
icon: iconOf(viewer.icon, 16),
|
||||
enabled: prefs.viewersEnabled[viewer.id] !== false,
|
||||
onToggle: (next) => { onToggleViewer(viewer.id, next) },
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
<div className={css.group}>
|
||||
<div className={css.groupHeading}>
|
||||
<span>{t('settingsViewersTitle')}</span>
|
||||
<span className={css.count}>{viewers.length}</span>
|
||||
</div>
|
||||
<div className={css.grid}>
|
||||
{viewers.map(viewer => (
|
||||
<Fragment key={viewer.id}>
|
||||
{renderCard({
|
||||
title: textOf(viewer.title) || viewer.id,
|
||||
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
|
||||
icon: iconOf(viewer.icon, 16),
|
||||
enabled: prefs.viewersEnabled[viewer.id] !== false,
|
||||
onToggle: (next) => { onToggleViewer(viewer.id, next) },
|
||||
})}
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The secondary settings popup: a feature's declared related settings
|
||||
as native checkbox rows (Modal chrome is the app's own). Mounted
|
||||
only while a feature is open — the Modal primitive runs hooks
|
||||
unconditionally, so a closed-but-mounted Modal would break SSR
|
||||
(and the renderToString spec) under the test dual-react split. */}
|
||||
as title/desc + switch rows in a wider-than-default Modal with a
|
||||
Done footer (Modal chrome is the app's own). Mounted only while a
|
||||
feature is open — the Modal primitive runs hooks unconditionally,
|
||||
so a closed-but-mounted Modal would break SSR (and the
|
||||
renderToString spec) under the test dual-react split. */}
|
||||
{settingsFor !== null && (
|
||||
<Modal
|
||||
open
|
||||
onClose={() => { setSettingsFor(null) }}
|
||||
title={textOf(settingsFor.title)}
|
||||
description={t('settingsPopupDesc', { feature: textOf(settingsFor.title) })}
|
||||
closeLabel={t('close')}
|
||||
className={css.popupDialog}
|
||||
footer={(
|
||||
<button type="button" className={css.done} onClick={() => { setSettingsFor(null) }}>
|
||||
{t('settingsDone')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<FeatureSettingsRows
|
||||
toggles={settingsFor.settings?.toggles ?? []}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
+142
-41
@@ -29,7 +29,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
|
||||
import type { Context, SidebarSessionList } from '../context-types.ts'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import {
|
||||
BOTTOM_MIN, PANEL_MIN, agentUuidOf, closeTab, firstLeaf, isAgentTabId, leafWithTab, mapLeaf, migrateBottomTabs, moveTab, moveTabToEdge, openDiffTab,
|
||||
reconcileAgentTerminals,
|
||||
@@ -44,6 +45,7 @@ import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { OrphanedTab } from './OrphanedTab.tsx'
|
||||
import { detectNewDirectSubagent } from './subagent-detect.ts'
|
||||
import { detectNewJob } from './subagent-jobs.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { api, type SessionScope } from './api.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -103,6 +105,15 @@ function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionSco
|
||||
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const { ctx, store } = props
|
||||
|
||||
// Copy freshness: re-render the whole tree when the DSH locale switches.
|
||||
// The module-level t() reads the active locale at call time, so a root
|
||||
// re-render alone re-localizes every panel (no memo barriers below).
|
||||
const localeRevision = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => ctx.locale.subscribe(callback), [ctx]),
|
||||
useCallback(() => ctx.locale.getSnapshot().active, [ctx]),
|
||||
)
|
||||
void localeRevision
|
||||
|
||||
// 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
|
||||
@@ -131,6 +142,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const sessionId = snapshot.sessionId
|
||||
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
|
||||
|
||||
// The collapsed toggle cluster reclaims the top-right corner, so the DSH
|
||||
// session header's right-aligned utilities (the "Session log" download
|
||||
// capsule) must yield. layout.css keys off this body attribute to push the
|
||||
// header's right padding out past the cluster. Only the CLOSED panel needs
|
||||
// it — an open panel already squeezes `#root` left, moving the header clear.
|
||||
const collapsed = state === undefined || !state.panelOpen
|
||||
useEffect(() => {
|
||||
if (collapsed) document.body.setAttribute('data-dsh-sidebar-collapsed', '')
|
||||
else document.body.removeAttribute('data-dsh-sidebar-collapsed')
|
||||
return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
|
||||
}, [collapsed])
|
||||
|
||||
/**
|
||||
* Bottom-panel merge on narrow viewports: whenever a session is current
|
||||
* while narrow (mount, session switch, or a desktop→narrow transition),
|
||||
@@ -241,6 +264,28 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* Job auto-activation: the moment a NEW background job appears for the
|
||||
* current conversation (a job id the previous snapshot lacked), the
|
||||
* auto-open pref is on, and the Jobs tab type is enabled, open the panel
|
||||
* (if collapsed) and focus the Jobs page. Unlike the subagent trigger
|
||||
* (0 → N only), ANY new job id triggers: the agent may start several
|
||||
* jobs in one session, and each should surface. A fresh page load never
|
||||
* triggers — its baseline starts at the current snapshot.
|
||||
*/
|
||||
const jobBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const prev = jobBaselineRef.current
|
||||
jobBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewJob(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenJobs) return
|
||||
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
|
||||
store.reduce(s => s.panelOpen ? s : togglePanel(s))
|
||||
store.reduce(s => ({ ...s, activePane: firstLeaf(s.splits).id }))
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionList, sessionId, store, ctx])
|
||||
|
||||
/**
|
||||
* Topology jump-back: clicking a subagent node on the Subagent page calls
|
||||
* the official `openSubagent`, which switches the sidebar to that child
|
||||
@@ -262,29 +307,81 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
|
||||
}, [sessionId, store, ctx])
|
||||
|
||||
// The app shell's own columns: the bottom panel spans ONLY the center
|
||||
// column ("squeezes the agent output area") — it starts at the app
|
||||
// sidebar's right edge and ends at the details column's left edge (the
|
||||
// details column sits between the center and the right panel). Measured
|
||||
// from the AppFrame's DOM (the plugin already couples to #root in
|
||||
// layout.css); a missing frame degrades to 0 (full width).
|
||||
const [appSidebarWidth, setAppSidebarWidth] = useState(0)
|
||||
const [detailsWidth, setDetailsWidth] = useState(0)
|
||||
useEffect(() => {
|
||||
const frame = document.querySelector('#root > div')
|
||||
const sidebarCol = frame?.children[0] as HTMLElement | undefined
|
||||
const detailsCol = frame?.children[2] as HTMLElement | undefined
|
||||
if (sidebarCol === undefined || detailsCol === undefined) return
|
||||
const measure = (): void => {
|
||||
setAppSidebarWidth(sidebarCol.getBoundingClientRect().width)
|
||||
setDetailsWidth(detailsCol.getBoundingClientRect().width)
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(sidebarCol)
|
||||
observer.observe(detailsCol)
|
||||
return () => { observer.disconnect() }
|
||||
// The app shell's center column: the bottom panel spans ONLY that column
|
||||
// ("squeezes the agent output area") — it starts at the app sidebar's
|
||||
// right edge and ends at the details column's left edge (the details
|
||||
// column sits between the center and the right panel). Measured directly
|
||||
// from the AppFrame's center column DOM (the parent of the
|
||||
// [data-slot="conversation"] wrapper — layout.css's center column) so the
|
||||
// bottom panel tracks the column's real
|
||||
// horizontal edges — including the animated margin-right push while the
|
||||
// right panel opens/closes; a frame that never appears keeps the initial
|
||||
// zero-size fallback (the panel renders at 0 width until measured).
|
||||
const [centerRect, setCenterRect] = useState({ left: 0, right: 0 })
|
||||
// Refs keep the measure step stable across renders and let it skip work
|
||||
// mid-drag: during a width/corner drag the layout push resizes the center
|
||||
// column every frame, and reacting (setCenterRect → re-render) would
|
||||
// re-introduce the drag lag this shell deliberately avoids. applyDrag
|
||||
// writes the bottom panel's edges directly, so measurement pauses then.
|
||||
const centerColRef = useRef<HTMLElement | null>(null)
|
||||
const draggingRef = useRef(false)
|
||||
const measureCenter = useCallback((): void => {
|
||||
if (draggingRef.current) return
|
||||
const col = centerColRef.current
|
||||
if (col === null) return
|
||||
const rect = col.getBoundingClientRect()
|
||||
// The bottom panel only cares about the horizontal edges: a pure height
|
||||
// change (the bottom panel itself opening/closing) must not re-render,
|
||||
// so keep the previous object when left/right are unchanged.
|
||||
setCenterRect(prev =>
|
||||
prev.left === rect.left && prev.right === rect.right
|
||||
? prev
|
||||
: { left: rect.left, right: rect.right })
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
let observer: ResizeObserver | undefined
|
||||
// Locate the AppFrame's center column. DSH 0.1.x wraps slot hosts in
|
||||
// [data-slot] containers: the conversation slot wrapper
|
||||
// ([data-slot="conversation"]) sits directly inside the center column,
|
||||
// so its parent IS that column — no hashed-class or positional
|
||||
// dependency (layout.css uses the same anchor). The shell swaps the
|
||||
// boot page for the AppFrame only AFTER boot settles, so the first
|
||||
// query may miss it. Never give up: watch #root's children (the swap
|
||||
// mutates them) and re-run this locator — querying once and bailing
|
||||
// would strand the panel at the zero-size fallback forever (observed:
|
||||
// a 1px sliver at the viewport's left edge).
|
||||
const locate = (): void => {
|
||||
if (disposed) return
|
||||
const col = document.querySelector('#root [data-slot="conversation"]')
|
||||
?.parentElement as HTMLElement | undefined
|
||||
if (col === undefined) {
|
||||
if (centerColRef.current !== null) {
|
||||
centerColRef.current = null
|
||||
observer?.disconnect()
|
||||
observer = undefined
|
||||
}
|
||||
return
|
||||
}
|
||||
if (centerColRef.current !== col) {
|
||||
centerColRef.current = col
|
||||
observer?.disconnect()
|
||||
observer = new ResizeObserver(measureCenter)
|
||||
observer.observe(col)
|
||||
}
|
||||
measureCenter()
|
||||
}
|
||||
locate()
|
||||
const watcher = new MutationObserver(locate)
|
||||
const root = document.getElementById('root')
|
||||
if (root !== null) watcher.observe(root, { childList: true })
|
||||
return () => {
|
||||
disposed = true
|
||||
observer?.disconnect()
|
||||
watcher.disconnect()
|
||||
centerColRef.current = null
|
||||
}
|
||||
}, [measureCenter])
|
||||
|
||||
/**
|
||||
* Bottom-panel first-expansion auto terminal: the FIRST time the user
|
||||
@@ -334,6 +431,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const [draggingCorner, setDraggingCorner] = useState(false)
|
||||
const anyDragging = draggingWidth || draggingBottom || draggingCorner
|
||||
|
||||
// Pause center-column measurement while dragging, and re-measure once the
|
||||
// drag settles at its committed size. The store commit lands on release and
|
||||
// the final width equals the last drag width, so no ResizeObserver event
|
||||
// fires to refresh centerRect — this explicit re-measure covers that gap.
|
||||
useEffect(() => {
|
||||
draggingRef.current = anyDragging
|
||||
if (!anyDragging) measureCenter()
|
||||
}, [anyDragging, measureCenter])
|
||||
|
||||
// Clamp mirrors of setWidth/setBottomHeight for mid-drag values (the store
|
||||
// re-clamps on commit; these keep the panels from overshooting mid-drag).
|
||||
const clampWidth = (width: number): number =>
|
||||
@@ -348,7 +454,11 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
const applyDrag = (width: number, height: number): void => {
|
||||
panelRef.current?.style.setProperty('width', `${width}px`)
|
||||
bottomRef.current?.style.setProperty('height', `${height}px`)
|
||||
bottomRef.current?.style.setProperty('right', `${width + detailsWidth}px`)
|
||||
// centerRect.right is the center column's right edge at the committed
|
||||
// width (innerWidth - state.width - detailsWidth), so this equals
|
||||
// `width + detailsWidth` — derived from the measured column, keeping the
|
||||
// drag write-only (no React re-render mid-drag).
|
||||
bottomRef.current?.style.setProperty('right', `${(window.innerWidth - centerRect.right) + (width - (state?.width ?? 0))}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
|
||||
document.documentElement.style.setProperty('--dsh-sidebar-height', `${height}px`)
|
||||
if (cornerRef.current !== null) {
|
||||
@@ -470,18 +580,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
*/
|
||||
const referenceInChat = useCallback((path: string): void => {
|
||||
if (sessionId === undefined) return
|
||||
try {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) return
|
||||
const conversation = ctx.get('conversation') as SidebarConversation | undefined
|
||||
if (conversation === undefined) return
|
||||
const input = conversation.input.for(actx)
|
||||
const mention = `@${relativeTo(cwd ?? '', path)}`
|
||||
const draft = input.state.getSnapshot().draft
|
||||
input.setDraft(draft.trim() === '' ? mention : `${draft} ${mention}`)
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] reference insert failed:', error)
|
||||
}
|
||||
appendToDraft(ctx, sessionId, `@${relativeTo(cwd ?? '', path)}`)
|
||||
}, [ctx, sessionId, cwd])
|
||||
|
||||
if (state === undefined || sessionId === undefined) {
|
||||
@@ -652,11 +751,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
|
||||
style={{
|
||||
height: Math.min(state.bottomHeight, window.innerHeight),
|
||||
left: appSidebarWidth,
|
||||
// Ends at the details column's left edge (the details column sits
|
||||
// between the center and the right panel) plus the right panel's
|
||||
// own width — the bottom panel squeezes ONLY the center column.
|
||||
right: detailsWidth + (state.panelOpen ? Math.min(state.width, window.innerWidth) : 0),
|
||||
left: centerRect.left,
|
||||
// Direct from the center column's measured right edge: the bottom
|
||||
// panel spans ONLY the center column, ending exactly at the
|
||||
// details column's left edge (the details column sits between the
|
||||
// center and the right panel, and the right panel's margin-right
|
||||
// push is already baked into centerRect.right).
|
||||
right: window.innerWidth - centerRect.right,
|
||||
// The seam against the open right panel needs its own hairline
|
||||
// (the right panel's border-left alone is covered by this panel's
|
||||
// fill — without it the corner looks cut off).
|
||||
|
||||
@@ -273,3 +273,275 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* ── Background-job section (below the topology tree) ────────────────── */
|
||||
|
||||
.jobs {
|
||||
margin-top: 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.jobsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 26px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.jobsTitle {
|
||||
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;
|
||||
}
|
||||
|
||||
.jobsCount {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* One job row; the whole row lights up on hover, the expandable target
|
||||
stays transparent (the kill button rides on the row's right edge). */
|
||||
.jobsRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.jobsRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.jobsRowSettled {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* The row whose output is shown in the bottom dock. */
|
||||
.jobsRowSelected,
|
||||
.jobsRowSelected:hover {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.jobsRowMain {
|
||||
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;
|
||||
}
|
||||
|
||||
.jobsRowMain:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.jobsDot {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.jobsContent {
|
||||
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. */
|
||||
.jobsLabelLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jobsKind {
|
||||
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);
|
||||
}
|
||||
|
||||
.jobsLabel {
|
||||
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);
|
||||
}
|
||||
|
||||
.jobsSecondary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsKill {
|
||||
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;
|
||||
}
|
||||
|
||||
.jobsKill: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. */
|
||||
.jobsKillArmed,
|
||||
.jobsKillArmed: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;
|
||||
}
|
||||
|
||||
.jobsKill:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.jobsKillError {
|
||||
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 job list scrolls above it; a solid background keeps the
|
||||
tree from showing through. */
|
||||
.jobsPane {
|
||||
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;
|
||||
}
|
||||
|
||||
.jobsPaneHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 4px 0 10px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.jobsPaneDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.jobsPaneLabel {
|
||||
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);
|
||||
}
|
||||
|
||||
.jobsPaneStatus {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsPaneClose {
|
||||
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;
|
||||
}
|
||||
|
||||
.jobsPaneClose:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.jobsPanePre {
|
||||
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;
|
||||
}
|
||||
|
||||
.jobsPaneHint {
|
||||
padding: 8px 10px;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.jobsPaneError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
+342
-60
@@ -27,11 +27,13 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
Context,
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentAddress,
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentChildEntry,
|
||||
SidebarSubagentDiagnosticEntry,
|
||||
SidebarJobView,
|
||||
} from '../context-types.ts'
|
||||
import {
|
||||
collectBranchIds,
|
||||
@@ -39,6 +41,17 @@ import {
|
||||
rootAncestor,
|
||||
} from './subagent-detect.ts'
|
||||
import { lastActivity } from './subagent-activity.ts'
|
||||
import {
|
||||
collectTreeJobs,
|
||||
formatJobDuration,
|
||||
isJobLive,
|
||||
orderJobs,
|
||||
jobDotState,
|
||||
jobStatusLabel,
|
||||
type TreeJob,
|
||||
} from './subagent-jobs.ts'
|
||||
import { api, type JobOutputResult } from './api.ts'
|
||||
import { IconStopOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SubagentView.module.css'
|
||||
|
||||
@@ -46,6 +59,10 @@ import css from './SubagentView.module.css'
|
||||
const POLL_MS = 3000
|
||||
/** Preview cap of one tool-call argument line. */
|
||||
const ARGS_PREVIEW = 60
|
||||
/** Refresh cadence of an expanded job-output panel while its job runs. */
|
||||
const JOB_POLL_MS = 2000
|
||||
/** How long the kill button stays armed before it needs re-confirming. */
|
||||
const JOB_KILL_ARM_MS = 3000
|
||||
|
||||
/** The direct subagent children of one parent (durable `origin` rows). */
|
||||
function directChildren(
|
||||
@@ -340,6 +357,262 @@ function CatalogRows({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared output dock of the jobs section: ONE pane at the bottom of the
|
||||
* sidebar body (sticky, terminal-like) shows the SELECTED job's output as
|
||||
* the MODEL has read it so far (replayed from the owner session's event
|
||||
* log), refreshed every {@link JOB_POLL_MS} while the job runs and the
|
||||
* page is visible. The model's `job_output` cursor is never touched — the
|
||||
* pane can never steal the agent's bytes, and it stays empty until the
|
||||
* agent reads the job. A single dock — not a panel per row — keeps the
|
||||
* job list compact and stable when many jobs are running.
|
||||
*/
|
||||
function JobOutputPane(props: {
|
||||
ownerSessionId: string
|
||||
job: SidebarJobView
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { ownerSessionId, job, active, onClose } = props
|
||||
const [state, setState] = useState<'loading' | JobOutputResult | '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.jobOutput({ sessionId: ownerSessionId }, job.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, job.id])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
if (!active || !isJobLive(job)) return
|
||||
const timer = window.setInterval(() => { void load() }, JOB_POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [load, active, job.status])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
// Terminal-tail behavior: while the job runs, each refresh pins the view
|
||||
// to the newest output; a settled dock leaves scrolling to the reader.
|
||||
useEffect(() => {
|
||||
if (!isJobLive(job) || typeof state !== 'object' || state.text.length === 0) return
|
||||
const pre = preRef.current
|
||||
if (pre !== null) pre.scrollTop = pre.scrollHeight
|
||||
}, [state, job.status])
|
||||
|
||||
return (
|
||||
<div className={css.jobsPane} role="region" aria-label={`${job.label} ${t('jobs')}`}>
|
||||
<div className={css.jobsPaneHeader}>
|
||||
<StateDot state={jobDotState(job.status)} className={css.jobsPaneDot} />
|
||||
<span className={css.jobsPaneLabel} title={job.label}>{job.label}</span>
|
||||
<span className={css.jobsPaneStatus}>
|
||||
{jobStatusLabel(job.status, t)}
|
||||
{job.detail !== undefined && job.detail !== '' ? ` · ${job.detail}` : ''}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.jobsPaneClose}
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconStopOutline16 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{state === 'loading' && <div className={css.jobsPaneHint}>{t('loading')}</div>}
|
||||
{state === 'error' && (
|
||||
<div className={`${css.jobsPaneHint} ${css.jobsPaneError}`}>{t('jobOutputError')}</div>
|
||||
)}
|
||||
{typeof state === 'object' && (
|
||||
<>
|
||||
{state.text.length > 0
|
||||
? <pre ref={preRef} className={css.jobsPanePre}>{state.text}</pre>
|
||||
: state.read
|
||||
? <div className={css.jobsPaneHint}>{t('jobNoOutput')}</div>
|
||||
: <div className={css.jobsPaneHint}>{t('jobNotReadYet')}</div>}
|
||||
{state.truncated && <div className={css.jobsPaneHint}>{t('jobOutputTruncated')}</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The background-job section of the Subagent page: every job of the whole
|
||||
* current tree (main agent + subagents, owner-labeled), fed by the harness
|
||||
* `session/jobs` 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 jobs.
|
||||
*/
|
||||
function JobsSection(props: {
|
||||
byId: SidebarSessionList['byId']
|
||||
jobsBySession: SidebarSessionList['jobsBySession']
|
||||
rootId: string | undefined
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
}) {
|
||||
const { byId, jobsBySession, rootId, active } = props
|
||||
const rows = useMemo(
|
||||
() => orderJobs(collectTreeJobs(byId, jobsBySession, rootId)),
|
||||
[byId, jobsBySession, 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.job.id === selectedId)),
|
||||
[rows, selectedId],
|
||||
)
|
||||
|
||||
const liveCount = useMemo(
|
||||
() => rows.reduce((count, row) => count + (isJobLive(row.job) ? 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) }, JOB_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 job: when the selected job 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: TreeJob): Promise<void> => {
|
||||
setKillingId(row.job.id)
|
||||
setKillErrorId(undefined)
|
||||
try {
|
||||
await api.jobKill({ sessionId: row.ownerSessionId }, row.job.id)
|
||||
} catch {
|
||||
setKillErrorId(row.job.id)
|
||||
} finally {
|
||||
setKillingId(undefined)
|
||||
setArmedId(undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (rows.length === 0) return null
|
||||
|
||||
const countLabel = liveCount > 0
|
||||
? t('jobsCountRunning', { count: rows.length, running: liveCount })
|
||||
: t('jobsCount', { count: rows.length })
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className={css.jobs} aria-label={t('jobs')}>
|
||||
<div className={css.jobsHeader}>
|
||||
<span className={css.jobsTitle}>{t('jobs')}</span>
|
||||
<span className={css.jobsCount}>{countLabel}</span>
|
||||
</div>
|
||||
<ul className={css.jobsList} aria-label={t('jobs')}>
|
||||
{rows.map((row) => {
|
||||
const { job } = row
|
||||
const live = isJobLive(job)
|
||||
const selected = selectedId === job.id
|
||||
const armed = armedId === job.id
|
||||
const killing = killingId === job.id
|
||||
const killFailed = killErrorId === job.id
|
||||
const elapsed = live
|
||||
? now - job.startedAt
|
||||
: (job.finishedAt ?? job.startedAt) - job.startedAt
|
||||
const secondary = [
|
||||
...(multiOwner ? [row.ownerTitle] : []),
|
||||
jobStatusLabel(job.status, t),
|
||||
...(job.detail !== undefined && job.detail !== '' ? [job.detail] : []),
|
||||
formatJobDuration(elapsed, t),
|
||||
].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<li
|
||||
key={job.id}
|
||||
className={clsx(
|
||||
css.jobsRow,
|
||||
!live && css.jobsRowSettled,
|
||||
selected && css.jobsRowSelected,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={css.jobsRowMain}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${job.label} ${secondary}`}
|
||||
onClick={() => { setSelectedId(selected ? undefined : job.id) }}
|
||||
>
|
||||
<StateDot state={jobDotState(job.status)} className={css.jobsDot} />
|
||||
<span className={css.jobsContent}>
|
||||
<span className={css.jobsLabelLine}>
|
||||
<span className={css.jobsKind}>{job.kind}</span>
|
||||
<span className={css.jobsLabel} title={job.label}>{job.label}</span>
|
||||
</span>
|
||||
<span className={css.jobsSecondary}>{secondary}</span>
|
||||
</span>
|
||||
</button>
|
||||
{job.status === 'running' && (
|
||||
<button
|
||||
type="button"
|
||||
className={armed ? `${css.jobsKill} ${css.jobsKillArmed}` : css.jobsKill}
|
||||
aria-label={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
title={armed ? t('jobKillConfirm') : t('jobKill')}
|
||||
disabled={killing}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
if (armed) void kill(row)
|
||||
else setArmedId(job.id)
|
||||
}}
|
||||
>
|
||||
{armed ? t('jobKillConfirm') : <IconStopOutline16 size={12} />}
|
||||
</button>
|
||||
)}
|
||||
{killFailed && <span className={css.jobsKillError}>{t('jobKillError')}</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
{selectedRow !== undefined && (
|
||||
<JobOutputPane
|
||||
ownerSessionId={selectedRow.ownerSessionId}
|
||||
job={selectedRow.job}
|
||||
active={active}
|
||||
onClose={() => { setSelectedId(undefined) }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar's Subagent topology page.
|
||||
* @param props - current session id, whether the page is actually visible
|
||||
@@ -515,69 +788,78 @@ export function SubagentView(props: {
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className={css.subagentBody}
|
||||
role="tree"
|
||||
aria-label={t('subagent')}
|
||||
aria-busy={summaryBackedLoading || undefined}
|
||||
onKeyDown={onTreeKeyDown}
|
||||
>
|
||||
{rootId !== undefined && rootSummary !== undefined && (
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={0}
|
||||
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
|
||||
aria-current={rootId === sessionId ? 'true' : undefined}
|
||||
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
|
||||
onClick={openMain}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openMain()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={rootSummary.running === true ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>
|
||||
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
|
||||
</span>
|
||||
<span className={css.subagentSecondary}>
|
||||
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rootId !== undefined && (
|
||||
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
|
||||
{summaryBackedLoading && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
catalog={rootCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
<div
|
||||
role="tree"
|
||||
aria-label={t('subagent')}
|
||||
aria-busy={summaryBackedLoading || undefined}
|
||||
>
|
||||
{rootId !== undefined && rootSummary !== undefined && (
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={0}
|
||||
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
|
||||
aria-current={rootId === sessionId ? 'true' : undefined}
|
||||
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
|
||||
onClick={openMain}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openMain()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={rootSummary.running === true ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
<div className={css.subagentEmpty}>
|
||||
<div>{t('subagentEmpty')}</div>
|
||||
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>
|
||||
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
|
||||
</span>
|
||||
<span className={css.subagentSecondary}>
|
||||
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rootId !== undefined && (
|
||||
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
|
||||
{summaryBackedLoading && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
catalog={rootCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
<div className={css.subagentEmpty}>
|
||||
<div>{t('subagentEmpty')}</div>
|
||||
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<JobsSection
|
||||
byId={byId}
|
||||
jobsBySession={list.jobsBySession}
|
||||
rootId={rootId}
|
||||
active={active}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -24,9 +24,11 @@ import { Terminal, type ITheme } from 'xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
import { t } from './locales.ts'
|
||||
import { openWhenSized } from './open-when-sized.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
|
||||
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
|
||||
import { resolveTerminalFont } from './terminal-font.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** How many consecutive unreasoned failures before showing the error banner. */
|
||||
@@ -82,10 +84,13 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
useEffect(() => {
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
// The custom font prefs (side card settings, terminal card) resolve at
|
||||
// mount; store changes re-apply them live below.
|
||||
const font = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
||||
fontSize: font.fontSize,
|
||||
fontFamily: font.fontFamily,
|
||||
allowTransparency: true,
|
||||
convertEol: false,
|
||||
scrollback: 4000,
|
||||
@@ -93,8 +98,6 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
})
|
||||
const fit = new FitAddon()
|
||||
term.loadAddon(fit)
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
// Re-theme in place when the app's scheme flips (tokens + palette).
|
||||
const applyTheme = (): void => {
|
||||
term.options.theme = xtermTheme()
|
||||
@@ -185,11 +188,52 @@ export function TerminalView(props: { scope: SessionScope; tabId: string; store:
|
||||
})
|
||||
observer.observe(host)
|
||||
|
||||
// Custom font prefs (the terminal card's secondary settings) apply LIVE:
|
||||
// on any store change re-resolve and diff the two options, re-fitting
|
||||
// when they moved (the grid dimensions may change with the font). The
|
||||
// subscribe fires on every store change (tabs, panels…), so the diff is
|
||||
// what keeps this cheap.
|
||||
const fontSub = store.subscribe(() => {
|
||||
const next = resolveTerminalFont(store.getPrefs(), tokenValue('--ds-font-family-code'))
|
||||
if (next.fontFamily !== term.options.fontFamily || next.fontSize !== term.options.fontSize) {
|
||||
term.options.fontFamily = next.fontFamily
|
||||
term.options.fontSize = next.fontSize
|
||||
try {
|
||||
fit.fit()
|
||||
sendResize()
|
||||
} catch {
|
||||
// The terminal may be mid-dispose; ignore.
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// The terminal must not be opened in a zero-size container: xterm's
|
||||
// renderer creation fails there and the next Viewport refresh crashes
|
||||
// reading `.dimensions` off the undefined renderer (blank terminal on
|
||||
// WKWebView when the bottom panel's expand slide leaves the host at
|
||||
// height 0; any display:none-hidden ancestor does the same). Defer
|
||||
// open+fit until the host has a real size — writes arriving meanwhile
|
||||
// are buffered by xterm's WriteBuffer and render once open, and
|
||||
// FitAddon.fit() is a safe no-op before open. sendResize() here covers
|
||||
// the deferred path where the socket may already be open with the
|
||||
// default 80x24 dims.
|
||||
const cancelOpen = openWhenSized(host, () => {
|
||||
try {
|
||||
term.open(host)
|
||||
fit.fit()
|
||||
sendResize()
|
||||
} catch (error) {
|
||||
console.error('[dsh-better-sidebar] xterm open failed:', error)
|
||||
}
|
||||
})
|
||||
|
||||
connect()
|
||||
return () => {
|
||||
closed = true
|
||||
cancelOpen()
|
||||
window.clearTimeout(retry)
|
||||
observer.disconnect()
|
||||
fontSub()
|
||||
schemeSub()
|
||||
inputSub.dispose()
|
||||
// The close frame tells the host the owning tab is GONE (immediate
|
||||
|
||||
+150
-4
@@ -10,6 +10,7 @@
|
||||
* row below the host's title bar, VSCode-style.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
@@ -20,6 +21,8 @@ import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { FileViewerProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -27,6 +30,13 @@ import css from './sidebar.module.css'
|
||||
/** Previewable files (rendered output vs source editing). */
|
||||
type ViewMode = 'preview' | 'edit'
|
||||
|
||||
/** The floating "add to conversation" action: payload + viewport anchor. */
|
||||
interface SelectionPopup {
|
||||
insert: string
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
|
||||
* preview must stay in an opaque origin — with the route's own origin it
|
||||
@@ -37,7 +47,7 @@ type ViewMode = 'preview' | 'edit'
|
||||
export const HTML_IFRAME_SANDBOX = 'allow-scripts allow-popups allow-downloads allow-modals'
|
||||
|
||||
export function TextEditor(props: FileViewerProps) {
|
||||
const { scope, path, viewerId, content, truncated } = props
|
||||
const { ctx, scope, path, viewerId, content, truncated } = props
|
||||
const [mode, setMode] = useState<ViewMode>('preview')
|
||||
/** The editor's current text (null while clean); preview renders this. */
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
@@ -50,6 +60,36 @@ export function TextEditor(props: FileViewerProps) {
|
||||
const themeCompRef = useRef<CmThemeCompartment | null>(null)
|
||||
/** The app's resolved color scheme; the editor re-themes in place on flips. */
|
||||
const [dark, setDark] = useState(() => isDarkScheme())
|
||||
/** The floating "add to conversation" popup (viewport-anchored; null = hidden). */
|
||||
const [popup, setPopup] = useState<SelectionPopup | null>(null)
|
||||
/** Live mirror of the popup state for click-time reads (no re-render race). */
|
||||
const popupRef = useRef<SelectionPopup | null>(null)
|
||||
/** The markdown preview container (selection-containment + line lookup). */
|
||||
const mdRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const hidePopup = (): void => {
|
||||
popupRef.current = null
|
||||
setPopup(null)
|
||||
}
|
||||
|
||||
/** Anchor the popup above the selection center; clamp inside the viewport. */
|
||||
const showPopup = (insert: string, left: number, top: number): void => {
|
||||
const next: SelectionPopup = {
|
||||
insert,
|
||||
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
|
||||
top,
|
||||
}
|
||||
popupRef.current = next
|
||||
setPopup(next)
|
||||
}
|
||||
|
||||
/** The popup button's click: insert the stored payload into the draft. */
|
||||
const commitPopup = (): void => {
|
||||
const current = popupRef.current
|
||||
if (current === null) return
|
||||
appendToDraft(ctx, scope.sessionId, current.insert)
|
||||
hidePopup()
|
||||
}
|
||||
|
||||
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
||||
|
||||
@@ -59,6 +99,7 @@ export function TextEditor(props: FileViewerProps) {
|
||||
setDraft(null)
|
||||
setDirty(false)
|
||||
setSaveState('idle')
|
||||
hidePopup()
|
||||
}, [content])
|
||||
|
||||
// Create the CodeMirror editor once the content is loaded. The view owns
|
||||
@@ -100,6 +141,49 @@ export function TextEditor(props: FileViewerProps) {
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
]),
|
||||
// Selection popup (the catch-all code viewer only): a non-empty
|
||||
// selection anchors the floating "add to conversation" button above
|
||||
// its head. Scrolling (geometry/viewport change) or losing focus
|
||||
// hides it; typing collapses the selection and hides it too.
|
||||
...(viewerId === 'code' ? [
|
||||
CodeMirrorView.updateListener.of((update) => {
|
||||
if (update.geometryChanged || update.viewportChanged) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
if (!update.view.hasFocus) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
if (!(update.selectionSet || update.docChanged || update.focusChanged)) return
|
||||
const sel = update.state.selection.main
|
||||
if (sel.empty) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const text = update.state.sliceDoc(sel.from, sel.to)
|
||||
if (text.trim() === '') {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
// Page coordinates (the document root may scroll); the popup is
|
||||
// position:fixed, so convert to viewport coordinates.
|
||||
const rect = update.view.coordsAtPos(sel.head)
|
||||
if (rect === null) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const doc = update.state.doc
|
||||
showPopup(
|
||||
buildSelectionInsert(path, scope.cwd, {
|
||||
start: doc.lineAt(sel.from).number,
|
||||
end: doc.lineAt(sel.to).number,
|
||||
}, text),
|
||||
rect.left - window.scrollX + (rect.right - rect.left) / 2,
|
||||
rect.top - window.scrollY,
|
||||
)
|
||||
}),
|
||||
] : []),
|
||||
],
|
||||
})
|
||||
const view = new CodeMirrorView({ state, parent: host })
|
||||
@@ -124,8 +208,10 @@ export function TextEditor(props: FileViewerProps) {
|
||||
}, [dark])
|
||||
|
||||
// The editor may have been display:none while previewing; re-measure when
|
||||
// it becomes visible again (CodeMirror sizes itself on reveal).
|
||||
// it becomes visible again (CodeMirror sizes itself on reveal). A mode
|
||||
// flip also invalidates any anchored selection popup.
|
||||
useEffect(() => {
|
||||
hidePopup()
|
||||
if (mode === 'edit') viewRef.current?.requestMeasure()
|
||||
}, [mode])
|
||||
|
||||
@@ -147,6 +233,39 @@ export function TextEditor(props: FileViewerProps) {
|
||||
|
||||
const markdown = viewerId === 'markdown'
|
||||
const html = viewerId === 'html'
|
||||
|
||||
/**
|
||||
* Selection popup for the markdown preview: a mouse-up inside the preview
|
||||
* container anchors the floating "add to conversation" button above the
|
||||
* selection. Line numbers come from a best-effort reverse-search of the
|
||||
* selected text in the source ({@link linesOfSelection} — an ambiguous or
|
||||
* missing hit omits them). The button's own mousedown preventDefaults so
|
||||
* the selection survives until the click commits.
|
||||
*/
|
||||
const handlePreviewMouseUp = (): void => {
|
||||
const sel = window.getSelection()
|
||||
if (sel === null || sel.isCollapsed || sel.anchorNode === null || sel.focusNode === null) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const host = mdRef.current
|
||||
if (host === null || !host.contains(sel.anchorNode) || !host.contains(sel.focusNode)) {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const text = sel.toString()
|
||||
if (text.trim() === '') {
|
||||
hidePopup()
|
||||
return
|
||||
}
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect()
|
||||
const lines = linesOfSelection(draft ?? content ?? '', text)
|
||||
showPopup(
|
||||
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
|
||||
rect.left + rect.width / 2,
|
||||
rect.top,
|
||||
)
|
||||
}
|
||||
const editable = content !== undefined
|
||||
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
|
||||
// Per-feature sandbox escape hatch: the global side card setting (warned)
|
||||
@@ -204,8 +323,21 @@ export function TextEditor(props: FileViewerProps) {
|
||||
</>
|
||||
)}
|
||||
{markdown && mode === 'preview' && (
|
||||
<div className={css.editorMd}>
|
||||
<MarkdownText text={draft ?? content ?? ''} />
|
||||
<div
|
||||
className={css.editorMd}
|
||||
ref={mdRef}
|
||||
onMouseUp={handlePreviewMouseUp}
|
||||
onScroll={hidePopup}
|
||||
>
|
||||
{/* The fence copy-button labels must come from this plugin's own
|
||||
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
|
||||
fall back to hardcoded Chinese otherwise (same pattern as the
|
||||
chat's AssistantMarkdown). Render-time t() keeps them following
|
||||
the active locale on live switches. */}
|
||||
<MarkdownText
|
||||
text={draft ?? content ?? ''}
|
||||
codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{html && mode === 'preview' && (
|
||||
@@ -231,6 +363,20 @@ export function TextEditor(props: FileViewerProps) {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{popup !== null && createPortal(
|
||||
<button
|
||||
type="button"
|
||||
className={css.selectionPopup}
|
||||
style={{ left: popup.left, top: popup.top }}
|
||||
// Keep the selection (and CodeMirror focus) alive until the click
|
||||
// commits — without this the popup unmounts before click lands.
|
||||
onMouseDown={(event) => { event.preventDefault() }}
|
||||
onClick={commitPopup}
|
||||
>
|
||||
{t('addToConversation')}
|
||||
</button>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,6 +60,21 @@ export interface FsTextResult { kind: 'text'; content: string; truncated: boolea
|
||||
* `head` carries the first bytes (base64) for viewer detect sniffing. */
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
|
||||
|
||||
/**
|
||||
* One jobs.output response: the output the MODEL has read so far for the
|
||||
* job (replayed from the owner session's event log — the model's
|
||||
* job_output cursor is never touched, so the pane can never steal the
|
||||
* agent's bytes). `read` is false until the model actually called
|
||||
* job_output for the job.
|
||||
*/
|
||||
export interface JobOutputResult {
|
||||
text: string
|
||||
/** True when the host capped the text at its output limit. */
|
||||
truncated: boolean
|
||||
/** Whether the model has read the job at least once. */
|
||||
read: boolean
|
||||
}
|
||||
|
||||
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
@@ -145,6 +160,19 @@ export const api = {
|
||||
/** Release an agent terminal by uuid (tab closed while WS was down). */
|
||||
agentPtyClose: (uuid: string) =>
|
||||
call<{ ok: true }>('agent-pty.close', { uuid }),
|
||||
/**
|
||||
* The output the model has read so far for one background job (replayed
|
||||
* from the owner session's event log — never the model's job_output
|
||||
* cursor). The scope MUST be the job's OWNER session.
|
||||
*/
|
||||
jobOutput: (scope: SessionScope, id: string, signal?: AbortSignal) =>
|
||||
call<JobOutputResult>('jobs.output', scopePayload(scope, { id }), signal),
|
||||
/** Request cancellation of one background job (live jobs flip to stopping). */
|
||||
jobKill: (scope: SessionScope, id: string, reason?: string) =>
|
||||
call<{ ok: true; outcome: 'requested' | 'already-finished' }>('jobs.kill', scopePayload(scope, {
|
||||
id,
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
})),
|
||||
/** Read the side card preferences (plugin-global, no session scope). */
|
||||
settingsGet: () =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.get', {}),
|
||||
|
||||
@@ -19,6 +19,7 @@ import { DiffTab } from '../DiffTab.tsx'
|
||||
import { SubagentView } from '../SubagentView.tsx'
|
||||
import { BrowserView } from '../BrowserView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
|
||||
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { SessionScope } from '../api.ts'
|
||||
import type { SidebarStore } from '../state.ts'
|
||||
@@ -109,13 +110,17 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
icon: (size: number) => <IconThinkOutline16 size={size} />,
|
||||
order: 30,
|
||||
single: true,
|
||||
// Declarative settings: the auto-open switch renders under this row in
|
||||
// the Side card settings page (the Subagent page's own related setting).
|
||||
// Declarative settings: the auto-open switches render under this row in
|
||||
// the Side card settings page (the Jobs page's own related settings).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'autoOpenSubagent',
|
||||
title: () => t('settingsSubagentTitle'),
|
||||
desc: () => t('settingsSubagentDesc'),
|
||||
}, {
|
||||
key: 'autoOpenJobs',
|
||||
title: () => t('settingsJobsTitle'),
|
||||
desc: () => t('settingsJobsDesc'),
|
||||
}],
|
||||
},
|
||||
component: ({ ctx, scope, visible, onSubagentJump }) => (
|
||||
@@ -133,10 +138,11 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
icon: (size: number) => <IconTerminalOutline16 size={size} />,
|
||||
order: 40,
|
||||
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
|
||||
// Declarative settings: the model-facing terminal tools switch and the
|
||||
// bottom-panel first-expansion auto-terminal switch render under this
|
||||
// row in the Side card settings page (the Terminal page's own related
|
||||
// settings; the host gates the toolset on the tools one independently).
|
||||
// Declarative settings: the model-facing terminal tools switch, the
|
||||
// bottom-panel first-expansion auto-terminal switch, and the custom
|
||||
// font family/size rows render under this card in the Side card
|
||||
// settings page (the host gates the toolset on the tools one
|
||||
// independently; the font rows apply live to every terminal).
|
||||
settings: {
|
||||
toggles: [{
|
||||
key: 'agentTerminalTools',
|
||||
@@ -146,6 +152,20 @@ export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
|
||||
key: 'bottomPanelAutoTerminal',
|
||||
title: () => t('settingsBottomTerminalTitle'),
|
||||
desc: () => t('settingsBottomTerminalDesc'),
|
||||
}, {
|
||||
key: 'terminalFontFamily',
|
||||
type: 'text',
|
||||
title: () => t('settingsFontFamilyTitle'),
|
||||
desc: () => t('settingsFontFamilyDesc'),
|
||||
placeholder: t('settingsFontFamilyPlaceholder'),
|
||||
}, {
|
||||
key: 'terminalFontSize',
|
||||
type: 'number',
|
||||
title: () => t('settingsFontSizeTitle'),
|
||||
desc: () => t('settingsFontSizeDesc'),
|
||||
min: TERMINAL_FONT_SIZE_MIN,
|
||||
max: TERMINAL_FONT_SIZE_MAX,
|
||||
unit: 'px',
|
||||
}],
|
||||
},
|
||||
createTab: (state) => {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Append text to the current session's composer draft through the
|
||||
* conversation service — the shared path behind the explorer's @-reference
|
||||
* button and the viewer selection popup. The service is resolved lazily
|
||||
* through `ctx.get` (the inject-free read the app's own plugins use); a
|
||||
* missing service or scope degrades to a logged no-op, never a crash.
|
||||
*/
|
||||
import type { Context, SidebarConversation } from '../context-types.ts'
|
||||
|
||||
/**
|
||||
* Append `text` to the session's composer draft (space-separated, like the
|
||||
* @-mentions). Returns false — and logs — when the conversation service or
|
||||
* the session scope is unavailable.
|
||||
*/
|
||||
export function appendToDraft(ctx: Context, sessionId: string, text: string): boolean {
|
||||
try {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) return false
|
||||
const conversation = ctx.get('conversation') as SidebarConversation | undefined
|
||||
if (conversation === undefined) return false
|
||||
const input = conversation.input.for(actx)
|
||||
const draft = input.state.getSnapshot().draft
|
||||
input.setDraft(draft.trim() === '' ? text : `${draft} ${text}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] draft insert failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,16 @@ export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/**
|
||||
* Stop glyph for the background-job kill button: a filled square in the
|
||||
* app's outline scale (16), the universal "halt this work" mark.
|
||||
*/
|
||||
export const IconStopOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
|
||||
|
||||
/** Image viewer glyph: a picture frame with a sun and a mountain. */
|
||||
|
||||
+15
-3
@@ -22,12 +22,13 @@ import { registerImeGuard } from './ime-guard.ts'
|
||||
import { loadPrefs } from './prefs.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { LOCALE_NS, attachLocale, t, zh, en } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
import './layout.css'
|
||||
|
||||
/** Services required before mounting (provided by the client runtime). */
|
||||
export const inject = ['slots', 'sessions', 'connection', 'workspaces']
|
||||
/** Services required before mounting (provided by the client runtime; the
|
||||
* locale service backs the sidebar's copy — see locales.ts). */
|
||||
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale']
|
||||
|
||||
/**
|
||||
* Error boundary over the sidebar tree: a render error must never blank the
|
||||
@@ -69,6 +70,17 @@ class SidebarBoundary extends Component<{ children: ReactNode }, { error: string
|
||||
* @param ctx - the client cordis context (slots, sessions).
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// The sidebar follows the DSH i18n system: attach the locale service so
|
||||
// the module-level t()/isZh() resolve the Host-backed language preference
|
||||
// (and switch live — the Sidebar root subscribes to it), and register the
|
||||
// plugin's dictionaries into the shared locale registry. The disposers
|
||||
// run on fiber disposal, so re-activation (HMR) re-registers cleanly.
|
||||
attachLocale(ctx.locale)
|
||||
ctx.effect(() => {
|
||||
const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh)
|
||||
const offEn = ctx.locale.register(LOCALE_NS, 'en', en)
|
||||
return () => { offZh(); offEn() }
|
||||
}, 'dsh-better-sidebar: dictionaries')
|
||||
// One store instance per activation: production code creates it only here,
|
||||
// then hands it to the mounted panel and closes over it in the slot
|
||||
// registrations (the official createXXXStore() factory rule — no
|
||||
|
||||
@@ -57,9 +57,22 @@ export function SidebarProducedFiles(props: {
|
||||
)
|
||||
}
|
||||
|
||||
/** Register the turn-tail interception (returns the disposer). */
|
||||
/**
|
||||
* Register the turn-tail interception (returns the disposer).
|
||||
*
|
||||
* The slot is a CHILD slot the host's ui-conversation declares in its
|
||||
* `conversation.chat.node` children table (kind: chain, scope: session).
|
||||
* Registering it directly races the declaration — the ui-slots core's
|
||||
* load-time validation throws "not declared (a parent entry's children
|
||||
* table must declare it)" when the parent entry is not on the ledger yet.
|
||||
* slots.inject waits for the declaration: the callback runs synchronously
|
||||
* when the slot is already declared, otherwise it runs inside the declaring
|
||||
* register() call once the declaration commits; declaration collapse
|
||||
* disposes the entry and a later declaration re-registers it. This mirrors
|
||||
* @deepseek-ai/dsh-client-ui-deliverables' registration of the same slot.
|
||||
*/
|
||||
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
|
||||
return ctx.slots.register({
|
||||
return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
|
||||
name: 'conversation.chat.turnTail',
|
||||
// Decline the takeover while the editor tab type is disabled in the side
|
||||
// card settings: the produced-files row falls back to the default
|
||||
@@ -73,7 +86,7 @@ export function registerTurnTailInterception(ctx: Context, store: SidebarStore):
|
||||
inject: (sessionId: string) => ({
|
||||
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
}),
|
||||
}, SidebarProducedFiles)
|
||||
}, SidebarProducedFiles))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+26
-11
@@ -6,11 +6,13 @@
|
||||
* conversation output and the input bar, like a VSCode sidebar.
|
||||
*
|
||||
* The bottom panel squeezes ONLY the center column — it must not cover the
|
||||
* app's own left sidebar or the right panel. The AppFrame's grid items are
|
||||
* (#root > div > div:nth-child(2)): sidebarCol, centerCol, detailsCol, so
|
||||
* the vertical push lands on the center column alone. The fixed panels
|
||||
* cover the vacated strips, which looks seamless because #root's background
|
||||
* is the theme base.
|
||||
* app's own left sidebar or the right panel. DSH 0.1.x wraps slot hosts in
|
||||
* [data-slot] containers, so the AppFrame grid lives one level deeper:
|
||||
* #root > div[data-slot="root"] > div (the frame). Its grid items are
|
||||
* (frame > div:nth-child(1..3)): sidebarCol, centerCol, detailsCol, so the
|
||||
* vertical push lands on the center column alone. The fixed panels cover
|
||||
* the vacated strips, which looks seamless because #root's background is
|
||||
* the theme base.
|
||||
*
|
||||
* The sizes ride CSS variables updated by the Sidebar shell (0 while
|
||||
* collapsed); expand/collapse animates both the margins and the panel
|
||||
@@ -23,22 +25,35 @@
|
||||
}
|
||||
|
||||
/* The AppFrame's grid items are sidebarCol, centerCol, detailsCol (children
|
||||
1-3 of #root > div) — nth-child(2) is the center column. A stretched grid
|
||||
item shrinks by its margins, so the conversation content (output + input
|
||||
bar) lifts without touching the sidebars. */
|
||||
#root > div > div:nth-child(2) {
|
||||
1-3 of #root > div[data-slot="root"] > div) — nth-child(2) is the center
|
||||
column. A stretched grid item shrinks by its margins, so the conversation
|
||||
content (output + input bar) lifts without touching the sidebars. */
|
||||
#root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
margin-bottom: var(--dsh-sidebar-height, 0px);
|
||||
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* When the sidebar is collapsed, the toggle cluster reclaims the top-right
|
||||
corner. Push the DSH session header's right padding out so its right-aligned
|
||||
utilities (the "Session log" download capsule) yield the corner instead of
|
||||
hiding under the cluster. The header default right-pads 28px; the 2-button
|
||||
cluster spans right 10→70px, so 78px clears it with an 8px gap. Anchor on
|
||||
the header's slot host wrapper ([data-slot="conversation.session.header"])
|
||||
rather than a positional path: DSH 0.1.x nests the header several levels
|
||||
under the center column. The Sidebar shell toggles the body attribute with
|
||||
the panel open state. */
|
||||
body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > header {
|
||||
padding-right: 78px;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root,
|
||||
body[data-dsh-sidebar-dragging] #root > div > div:nth-child(2) {
|
||||
body[data-dsh-sidebar-dragging] #root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root,
|
||||
#root > div > div:nth-child(2) {
|
||||
#root > div[data-slot="root"] > div > div:nth-child(2) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+118
-19
@@ -1,11 +1,16 @@
|
||||
/**
|
||||
* Minimal zh/en copy for the sidebar. The plugin renders outside the slot
|
||||
* system's locale seat, so it reads the browser language directly (the DSH
|
||||
* locale preference and the browser language agree in practice; both default
|
||||
* from navigator.language anyway).
|
||||
* Minimal zh/en copy for the sidebar. The copy follows the DSH i18n system:
|
||||
* the client apply attaches the locale service (`ctx.locale`, provided by
|
||||
* `@deepseek-ai/dsh-client-locale`) through {@link attachLocale}, and
|
||||
* `t()`/`isZh()` resolve the active locale from it — the Host-backed
|
||||
* `locale.preference` wins over the raw browser language and switches live.
|
||||
* Without an attached service (standalone/test compositions) the browser
|
||||
* language is used, matching the previous behavior. The dictionaries are
|
||||
* also registered into the DSH locale registry under {@link LOCALE_NS}.
|
||||
*/
|
||||
|
||||
const zh = {
|
||||
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
|
||||
export const zh = {
|
||||
explorer: '资源管理器',
|
||||
git: '源代码管理',
|
||||
terminal: '终端',
|
||||
@@ -96,10 +101,14 @@ const zh = {
|
||||
openEditor: '打开编辑器',
|
||||
gitDetail: '查看变更详情',
|
||||
referenceFile: '@文件',
|
||||
addToConversation: '添加到对话',
|
||||
copyRelative: '复制相对地址',
|
||||
copyAbsolute: '复制绝对地址',
|
||||
download: '下载',
|
||||
settingsNav: '侧边卡片',
|
||||
settingsIntro: '管理侧边卡片的显示内容与默认行为',
|
||||
settingsPopupDesc: '为「{feature}」配置相关选项',
|
||||
settingsDone: '完成',
|
||||
settingsOpenTitle: '新会话默认打开',
|
||||
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
|
||||
settingsWidthTitle: '默认宽度占比',
|
||||
@@ -119,12 +128,20 @@ const zh = {
|
||||
nextSlide: '下一页',
|
||||
zoom: '缩放',
|
||||
zoomHint: 'Alt + 滚轮',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开任务管理页',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开任务管理页;关闭后需手动打开',
|
||||
settingsJobsTitle: '有新后台任务时自动展开后台任务页',
|
||||
settingsJobsDesc: '当前会话出现新的后台任务时,自动展开侧边栏并打开后台任务页(每个新任务都会触发);关闭后需手动打开',
|
||||
settingsToolsTitle: '为模型注入终端工具',
|
||||
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
|
||||
settingsBottomTerminalTitle: '底部面板首次展开自动开终端',
|
||||
settingsBottomTerminalDesc: '每次会话中第一次展开底部面板时,尝试在底部面板自动打开一个新终端标签(终端数量上限仍会限制;默认开启)',
|
||||
settingsFontFamilyTitle: '终端字体',
|
||||
settingsFontFamilyDesc: '自定义终端字体族(CSS font-family,如 "JetBrains Mono", monospace;留空跟随主题等宽字体)',
|
||||
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
|
||||
settingsFontSizeTitle: '终端字号',
|
||||
settingsFontSizeDesc: '终端字号(9–32,默认 13)',
|
||||
settingsFontSizeSuffix: 'px',
|
||||
settingsTabsTitle: '侧边栏内容',
|
||||
settingsViewersTitle: '文件预览',
|
||||
settingsGeneralTitle: '常规',
|
||||
@@ -165,8 +182,8 @@ const zh = {
|
||||
browserEmbedBlocked: '{host} 拒绝了嵌入请求',
|
||||
browserEmbedBlockedDesc: '该站点通过 X-Frame-Options / frame-ancestors 禁止在其它页面中显示,无法在侧边栏内加载。可在浏览器中直接打开',
|
||||
browserEmbedAnyway: '仍然加载',
|
||||
subagent: '子代理',
|
||||
openSubagent: '子代理',
|
||||
subagent: '任务管理',
|
||||
openSubagent: '任务管理',
|
||||
subagentMainAgent: '主代理',
|
||||
subagentEmpty: '暂无子代理',
|
||||
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
|
||||
@@ -180,9 +197,30 @@ const zh = {
|
||||
subagentDiagUnsupported: '不支持的条目',
|
||||
subagentDiagUnavailable: '不可用',
|
||||
subagentThinking: '思考中…',
|
||||
jobs: '后台任务',
|
||||
jobsCount: '{count} 个后台任务',
|
||||
jobsCountRunning: '{count} 个后台任务 · {running} 运行中',
|
||||
jobStatusRunning: '运行中',
|
||||
jobStatusStopping: '终止中',
|
||||
jobStatusCompleted: '已完成',
|
||||
jobStatusKilled: '已终止',
|
||||
jobStatusFailed: '失败',
|
||||
jobDurationSeconds: '{seconds} 秒',
|
||||
jobDurationMinutes: '{minutes} 分 {seconds} 秒',
|
||||
jobDurationHours: '{hours} 小时 {minutes} 分',
|
||||
jobViewOutput: '查看输出',
|
||||
jobHideOutput: '收起输出',
|
||||
jobNoOutput: '暂无输出',
|
||||
jobNotReadYet: '等待模型读取该任务的输出(模型执行 job_output 后,输出会显示在这里)',
|
||||
jobOutputTruncated: '输出过长,已截断显示',
|
||||
jobOutputError: '输出读取失败',
|
||||
jobKill: '终止',
|
||||
jobKillConfirm: '再次点击确认终止',
|
||||
jobKillError: '终止失败',
|
||||
}
|
||||
|
||||
const en: Record<keyof typeof zh, string> = {
|
||||
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
|
||||
export const en: Record<keyof typeof zh, string> = {
|
||||
explorer: 'Explorer',
|
||||
git: 'Source Control',
|
||||
terminal: 'Terminal',
|
||||
@@ -273,10 +311,14 @@ const en: Record<keyof typeof zh, string> = {
|
||||
openEditor: 'Open editor',
|
||||
gitDetail: 'View change details',
|
||||
referenceFile: '@file',
|
||||
addToConversation: 'Add to conversation',
|
||||
copyRelative: 'Copy relative path',
|
||||
copyAbsolute: 'Copy absolute path',
|
||||
download: 'Download',
|
||||
settingsNav: 'Side card',
|
||||
settingsIntro: 'Manage what the side card shows and how it behaves',
|
||||
settingsPopupDesc: 'Configure related options for {feature}',
|
||||
settingsDone: 'Done',
|
||||
settingsOpenTitle: 'Open by default for new conversations',
|
||||
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
|
||||
settingsWidthTitle: 'Default width share',
|
||||
@@ -296,12 +338,20 @@ const en: Record<keyof typeof zh, string> = {
|
||||
nextSlide: 'Next',
|
||||
zoom: 'Zoom',
|
||||
zoomHint: 'Alt + wheel',
|
||||
settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears',
|
||||
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsSubagentTitle: 'Auto-open the Tasks page when a subagent appears',
|
||||
settingsSubagentDesc: 'Expand the side card and open the Tasks page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
settingsJobsTitle: 'Auto-open the Jobs page on a new background job',
|
||||
settingsJobsDesc: 'Expand the side card and open the Jobs page whenever a new background job appears for the current conversation (every new job triggers); turn off to open it manually',
|
||||
settingsToolsTitle: 'Inject terminal tools for the model',
|
||||
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
|
||||
settingsBottomTerminalTitle: 'Auto-open a terminal on the bottom panel\'s first expansion',
|
||||
settingsBottomTerminalDesc: 'When the bottom panel is expanded for the first time in a session, try to open a fresh terminal tab there (the terminal quota still applies; on by default)',
|
||||
settingsFontFamilyTitle: 'Terminal font family',
|
||||
settingsFontFamilyDesc: 'Custom terminal font family (a CSS font-family stack like "JetBrains Mono", monospace; leave empty to follow the theme\'s monospace font)',
|
||||
settingsFontFamilyPlaceholder: '"JetBrains Mono", monospace',
|
||||
settingsFontSizeTitle: 'Terminal font size',
|
||||
settingsFontSizeDesc: 'Terminal font size in px (9–32, default 13)',
|
||||
settingsFontSizeSuffix: 'px',
|
||||
settingsTabsTitle: 'Sidebar content',
|
||||
settingsViewersTitle: 'File viewers',
|
||||
settingsGeneralTitle: 'General',
|
||||
@@ -342,8 +392,8 @@ const en: Record<keyof typeof zh, string> = {
|
||||
browserEmbedBlocked: '{host} refused to be embedded',
|
||||
browserEmbedBlockedDesc: 'The site forbids being displayed inside other pages (X-Frame-Options / frame-ancestors), so it cannot load in the sidebar. Open it directly in your browser instead.',
|
||||
browserEmbedAnyway: 'Load anyway',
|
||||
subagent: 'Subagents',
|
||||
openSubagent: 'Subagents',
|
||||
subagent: 'Tasks',
|
||||
openSubagent: 'Tasks',
|
||||
subagentMainAgent: 'Main agent',
|
||||
subagentEmpty: 'No subagents',
|
||||
subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
|
||||
@@ -357,15 +407,64 @@ const en: Record<keyof typeof zh, string> = {
|
||||
subagentDiagUnsupported: 'Unsupported',
|
||||
subagentDiagUnavailable: 'Unavailable',
|
||||
subagentThinking: 'Thinking…',
|
||||
jobs: 'Background jobs',
|
||||
jobsCount: '{count} background jobs',
|
||||
jobsCountRunning: '{count} background jobs · {running} running',
|
||||
jobStatusRunning: 'Running',
|
||||
jobStatusStopping: 'Stopping',
|
||||
jobStatusCompleted: 'Completed',
|
||||
jobStatusKilled: 'Killed',
|
||||
jobStatusFailed: 'Failed',
|
||||
jobDurationSeconds: '{seconds}s',
|
||||
jobDurationMinutes: '{minutes}m {seconds}s',
|
||||
jobDurationHours: '{hours}h {minutes}m',
|
||||
jobViewOutput: 'View output',
|
||||
jobHideOutput: 'Hide output',
|
||||
jobNoOutput: 'No output yet',
|
||||
jobNotReadYet: 'Waiting for the model to read this job; its output appears here once the model runs job_output',
|
||||
jobOutputTruncated: 'Output truncated',
|
||||
jobOutputError: 'Failed to read output',
|
||||
jobKill: 'Kill',
|
||||
jobKillConfirm: 'Click again to confirm kill',
|
||||
jobKillError: 'Kill failed',
|
||||
}
|
||||
|
||||
/** Translate a copy key in the browser's language (zh → zh, else en). */
|
||||
/**
|
||||
* The dictionary namespace this plugin owns in the DSH locale registry
|
||||
* (`'sidebar'` is taken by DSH's own ui-sidebar, hence this distinct name).
|
||||
*/
|
||||
export const LOCALE_NS = 'betterSidebar'
|
||||
|
||||
/** The DSH locale service attached by the client apply (absent → browser detection). */
|
||||
let localeService: { getSnapshot(): { active: string } } | undefined
|
||||
|
||||
/**
|
||||
* Attach (or detach, with undefined) the DSH locale service. The sidebar
|
||||
* mounts its own React root outside the slot system's locale seat, so the
|
||||
* service rides this module-level holder: components keep calling the plain
|
||||
* `t()` function, and the Sidebar root's locale subscription re-renders the
|
||||
* whole tree on switches.
|
||||
*/
|
||||
export function attachLocale(service: { getSnapshot(): { active: string } } | undefined): void {
|
||||
localeService = service
|
||||
}
|
||||
|
||||
/**
|
||||
* The active locale id ('zh' | 'en'): the DSH locale service's snapshot when
|
||||
* attached, else the browser language.
|
||||
*/
|
||||
function activeLocale(): string {
|
||||
return localeService?.getSnapshot().active
|
||||
?? (typeof navigator !== 'undefined' ? navigator.language : '')
|
||||
?? 'en'
|
||||
}
|
||||
|
||||
/** Translate a copy key in the active locale (zh → zh, else en). */
|
||||
export type CopyKey = keyof typeof zh
|
||||
|
||||
/** Translate a copy key; `{name}` placeholders interpolate from `params`. */
|
||||
export function t(key: CopyKey, params?: Record<string, string | number>): string {
|
||||
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en'
|
||||
const dict = lang.toLowerCase().startsWith('zh') ? zh : en
|
||||
const dict = activeLocale().toLowerCase().startsWith('zh') ? zh : en
|
||||
let text = dict[key]
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
@@ -375,9 +474,9 @@ export function t(key: CopyKey, params?: Record<string, string | number>): strin
|
||||
return text
|
||||
}
|
||||
|
||||
/** Whether the browser language is Chinese (used for selectors). */
|
||||
/** Whether the active locale is Chinese (used for selectors). */
|
||||
export function isZh(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
|
||||
return activeLocale().toLowerCase().startsWith('zh')
|
||||
}
|
||||
|
||||
/** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Deferred one-shot open for hosts that may not have a real size yet.
|
||||
*
|
||||
* xterm's `Terminal.open()` must not run in a zero-size container: the
|
||||
* renderer creation fails there (the DomRenderer is built from the host's
|
||||
* dimensions), leaving the render service's renderer `undefined`, and the
|
||||
* next Viewport refresh crashes reading `.dimensions` off it. WebKit-based
|
||||
* hosts (WKWebView) reliably report zero while the bottom panel's expand
|
||||
* slide is in flight; any `display:none`-hidden ancestor does the same.
|
||||
*
|
||||
* The caller's `open` callback (open + fit + resize) is invoked exactly
|
||||
* once, on the first frame where the host reports a real size. While the
|
||||
* host stays zero-sized the polling continues every frame; it stops when
|
||||
* the host leaves the document (`isConnected`), so a pending open never
|
||||
* fires after unmount. The returned cancel function drops a pending frame
|
||||
* immediately (idempotent).
|
||||
*
|
||||
* `raf`/`caf` are injectable so tests can drive the polling deterministically.
|
||||
*/
|
||||
export function openWhenSized(
|
||||
host: HTMLElement,
|
||||
open: () => void,
|
||||
raf: (cb: FrameRequestCallback) => number = requestAnimationFrame,
|
||||
caf: (id: number) => void = cancelAnimationFrame,
|
||||
): () => void {
|
||||
let frame: number | null = null
|
||||
const step = (): void => {
|
||||
frame = null
|
||||
if (!host.isConnected) return
|
||||
if (host.clientWidth > 0 && host.clientHeight > 0) {
|
||||
open()
|
||||
return
|
||||
}
|
||||
frame = raf(step)
|
||||
}
|
||||
frame = raf(step)
|
||||
return () => {
|
||||
if (frame !== null) {
|
||||
caf(frame)
|
||||
frame = null
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -10,12 +10,13 @@
|
||||
*/
|
||||
import type { api } from './api.ts'
|
||||
import {
|
||||
clampTerminalFontSize,
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent }
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampWidthPercent }
|
||||
export type { SidebarPrefs }
|
||||
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
@@ -40,12 +41,21 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
|
||||
? record.autoOpenSubagent
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
|
||||
autoOpenJobs: typeof record.autoOpenJobs === 'boolean'
|
||||
? record.autoOpenJobs
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenJobs,
|
||||
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
|
||||
? record.agentTerminalTools
|
||||
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
|
||||
bottomPanelAutoTerminal: typeof record.bottomPanelAutoTerminal === 'boolean'
|
||||
? record.bottomPanelAutoTerminal
|
||||
: SIDEBAR_PREFS_DEFAULTS.bottomPanelAutoTerminal,
|
||||
terminalFontFamily: typeof record.terminalFontFamily === 'string'
|
||||
? record.terminalFontFamily
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalFontFamily,
|
||||
terminalFontSize: typeof record.terminalFontSize === 'number' && Number.isFinite(record.terminalFontSize)
|
||||
? clampTerminalFontSize(record.terminalFontSize)
|
||||
: SIDEBAR_PREFS_DEFAULTS.terminalFontSize,
|
||||
interceptOpenPath: typeof record.interceptOpenPath === 'boolean'
|
||||
? record.interceptOpenPath
|
||||
: SIDEBAR_PREFS_DEFAULTS.interceptOpenPath,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Pure payload builders for the "add selection to conversation" popup in the
|
||||
* text viewers (markdown preview + the catch-all code viewer). Everything
|
||||
* here is string math — no React, no ctx — so the unit tests cover it
|
||||
* directly.
|
||||
*
|
||||
* Insert shape (agreed with the product owner):
|
||||
* - Selection ≤ SELECTION_LIMIT characters: a fenced code block whose info
|
||||
* line is `相对路径:起止行` and whose body is the selected text.
|
||||
* - Selection over the limit: a single plain-text line `相对路径:起止行`
|
||||
* (no fence, no content).
|
||||
* - The path is relative to the session cwd (the same projection the
|
||||
* explorer's @ button uses); an unknown cwd falls back to the absolute
|
||||
* path.
|
||||
* - Line numbers: single-line selections write `path:12`, multi-line write
|
||||
* `path:12-15`. The markdown preview cannot map rendered DOM back to
|
||||
* source lines directly, so it reverse-searches the selected text in the
|
||||
* source and only reports lines on an unambiguous hit (see
|
||||
* {@link linesOfSelection}).
|
||||
*/
|
||||
import { relativeTo } from './paths.ts'
|
||||
|
||||
/** Max inserted selection length (UTF-16 code units, i.e. JS `.length`). */
|
||||
export const SELECTION_LIMIT = 500
|
||||
|
||||
/** The source line span a selection maps to (1-based, inclusive). */
|
||||
export interface SelectionLines {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The fence info line: `rel[:start[-end]]` — lines are omitted entirely
|
||||
* when unknown (the preview reverse-search missed).
|
||||
*/
|
||||
export function headerOf(path: string, cwd: string | undefined, lines?: SelectionLines): string {
|
||||
const rel = cwd !== undefined ? relativeTo(cwd, path) : path
|
||||
if (lines === undefined) return rel
|
||||
if (lines.end > lines.start) return `${rel}:${lines.start}-${lines.end}`
|
||||
return `${rel}:${lines.start}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The full text appended to the composer draft for one selection.
|
||||
* Over the limit the content is dropped: the plain path line is the whole
|
||||
* payload (an empty fenced block would just occupy the draft).
|
||||
*/
|
||||
export function buildSelectionInsert(
|
||||
path: string,
|
||||
cwd: string | undefined,
|
||||
lines: SelectionLines | undefined,
|
||||
selected: string,
|
||||
): string {
|
||||
const header = headerOf(path, cwd, lines)
|
||||
if (selected.length > SELECTION_LIMIT) return header
|
||||
return `\`\`\`${header}\n${selected}\n\`\`\``
|
||||
}
|
||||
|
||||
/** 1-based line number of a character index in a text. */
|
||||
function lineAt(source: string, index: number): number {
|
||||
let line = 1
|
||||
for (let i = 0; i < index && i < source.length; i++) {
|
||||
if (source[i] === '\n') line++
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-map a rendered-DOM selection back to source line numbers. The
|
||||
* preview selection is plain text (block boundaries come out as `\n`), so
|
||||
* this is a best-effort substring search: a single trailing newline is
|
||||
* stripped first (DOM block selections tend to carry one), and only an
|
||||
* EXACTLY-ONE occurrence yields lines — an ambiguous or missing match
|
||||
* returns null (the header then carries the path without line numbers).
|
||||
*/
|
||||
export function linesOfSelection(source: string, selected: string): SelectionLines | null {
|
||||
const text = selected.endsWith('\n') ? selected.slice(0, -1) : selected
|
||||
if (text === '') return null
|
||||
const at = source.indexOf(text)
|
||||
if (at === -1) return null
|
||||
if (source.indexOf(text, at + 1) !== -1) return null
|
||||
return {
|
||||
start: lineAt(source, at),
|
||||
end: lineAt(source, at + Math.max(text.length - 1, 0)),
|
||||
}
|
||||
}
|
||||
+22
-5
@@ -28,9 +28,15 @@ import {
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
|
||||
/** One declarative boolean setting of a tab/viewer, rendered as a nested
|
||||
* switch row in the Side card settings page (e.g. the Subagent page's
|
||||
* "auto-open when a subagent appears"). */
|
||||
/** The row control a declarative setting renders as in the settings popup. */
|
||||
export type SidebarSettingToggleType = 'switch' | 'text' | 'number'
|
||||
|
||||
/** One declarative setting of a tab/viewer, rendered as a nested row in the
|
||||
* Side card settings page (e.g. the Subagent page's "auto-open when a
|
||||
* subagent appears" switch, or the terminal's custom font rows). `type`
|
||||
* selects the control: 'switch' (default) renders the custom switch,
|
||||
* 'text' a free-form input committed on blur/Enter, 'number' a numeric
|
||||
* input clamped to `min`/`max`. */
|
||||
export interface SidebarSettingToggle {
|
||||
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
|
||||
key: string
|
||||
@@ -38,15 +44,26 @@ export interface SidebarSettingToggle {
|
||||
title: string | (() => string)
|
||||
/** Row description (i18n friendly). */
|
||||
desc?: string | (() => string)
|
||||
/** Row control type; defaults to 'switch' (backward compatible). */
|
||||
type?: SidebarSettingToggleType
|
||||
/** Lower bound for `type: 'number'` rows (clamped on commit). */
|
||||
min?: number
|
||||
/** Upper bound for `type: 'number'` rows (clamped on commit). */
|
||||
max?: number
|
||||
/** Input placeholder for `type: 'text'` rows. */
|
||||
placeholder?: string
|
||||
/** Unit suffix rendered after the input (e.g. 'px' for a size row). */
|
||||
unit?: string
|
||||
}
|
||||
|
||||
/** Declarative settings of one registered tab or file viewer. */
|
||||
export interface SidebarSettingsDeclaration {
|
||||
/**
|
||||
* Extra boolean toggles rendered under the feature's own row in the
|
||||
* Extra settings rows rendered under the feature's own row in the
|
||||
* settings page (only while the feature is enabled). Keys must be fields
|
||||
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
|
||||
* 'agentTerminalTools'); unknown keys are dropped by the settings seam.
|
||||
* 'agentTerminalTools', 'terminalFontFamily'); unknown keys are dropped
|
||||
* by the settings seam.
|
||||
*/
|
||||
toggles?: readonly SidebarSettingToggle[]
|
||||
}
|
||||
|
||||
@@ -1067,6 +1067,32 @@
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* The floating "add to conversation" button anchored above a text
|
||||
selection in the preview/code viewers. Portalled to document.body and
|
||||
position:fixed, so the editor's overflow clips cannot crop it; flat
|
||||
chrome like the rest of the sidebar (hairline border, layer-2 fill,
|
||||
hover fill — no shadow). */
|
||||
.selectionPopup {
|
||||
position: fixed;
|
||||
z-index: 60;
|
||||
transform: translate(-50%, calc(-100% - 8px));
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selectionPopup:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Word preview: the document scrolls independently while the zoom control
|
||||
stays pinned to the bottom edge. */
|
||||
.editorDocx {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Pure derivations for the Subagent page's background-job section. Kept
|
||||
* framework-free so the node test environment can unit-test them: the job
|
||||
* rows arrive through the harness `session/jobs` push mirror
|
||||
* (`jobsBySession` in the sessions list feed) — nothing here issues
|
||||
* requests, and the row ordering / status mapping mirror the official
|
||||
* ui-jobs header list.
|
||||
*/
|
||||
import type {
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarJobStatus,
|
||||
SidebarJobView,
|
||||
} from '../context-types.ts'
|
||||
import type { CopyKey } from './locales.ts'
|
||||
|
||||
/** One row of the jobs section: the job plus its owning session's title. */
|
||||
export interface TreeJob {
|
||||
ownerSessionId: string
|
||||
ownerTitle: string
|
||||
job: SidebarJobView
|
||||
}
|
||||
|
||||
/** Whether the registry still holds the job open (its duration ticks). */
|
||||
export function isJobLive(job: SidebarJobView): boolean {
|
||||
return job.status === 'running' || job.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
|
||||
* jobs 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 job appeared for one session between two
|
||||
* consecutive list snapshots (a job id the previous snapshot lacked).
|
||||
* Unlike the subagent auto-open (0 → N only), ANY new job id triggers: the
|
||||
* agent may start several jobs over a session, and each new one should
|
||||
* surface the Jobs page (a fresh page load never triggers — its baseline
|
||||
* starts at the current snapshot).
|
||||
*/
|
||||
export function detectNewJob(
|
||||
prev: SidebarSessionList,
|
||||
next: SidebarSessionList,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
const prevIds = new Set((prev.jobsBySession?.[sessionId] ?? []).map(job => job.id))
|
||||
return (next.jobsBySession?.[sessionId] ?? []).some(job => !prevIds.has(job.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the background jobs of the whole current tree, owner-labeled.
|
||||
* Sessions without a mirror entry contribute nothing; an absent mirror
|
||||
* (runtime older than the jobs feed) yields an empty list.
|
||||
*/
|
||||
export function collectTreeJobs(
|
||||
byId: SidebarSessionList['byId'],
|
||||
jobsBySession: Readonly<Record<string, readonly SidebarJobView[]>> | undefined,
|
||||
rootId: string | undefined,
|
||||
): TreeJob[] {
|
||||
const rows: TreeJob[] = []
|
||||
if (jobsBySession === undefined) return rows
|
||||
for (const sessionId of treeSessionIds(byId, rootId)) {
|
||||
const jobs = jobsBySession[sessionId]
|
||||
if (jobs === undefined || jobs.length === 0) continue
|
||||
const ownerTitle = byId[sessionId]?.displayTitle ?? sessionId
|
||||
for (const job of jobs) rows.push({ ownerSessionId: sessionId, ownerTitle, job })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Live rows first in start order, then settled rows newest-first (mirror of
|
||||
* the official ui-jobs ordering); a tie falls back to start order so the
|
||||
* sort never depends on the host's map iteration.
|
||||
*/
|
||||
export function orderJobs(rows: readonly TreeJob[]): TreeJob[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
const liveLeft = isJobLive(left.job)
|
||||
if (liveLeft !== isJobLive(right.job)) return liveLeft ? -1 : 1
|
||||
if (liveLeft) return left.job.startedAt - right.job.startedAt
|
||||
const finished = (right.job.finishedAt ?? right.job.startedAt) - (left.job.finishedAt ?? left.job.startedAt)
|
||||
return finished !== 0 ? finished : left.job.startedAt - right.job.startedAt
|
||||
})
|
||||
}
|
||||
|
||||
/** The sidebar's StateDot states for the five wire statuses. */
|
||||
export type JobDotState = '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 jobDotState(status: SidebarJobStatus): JobDotState {
|
||||
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 jobStatusLabel(
|
||||
status: SidebarJobStatus,
|
||||
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
switch (status) {
|
||||
case 'running': return t('jobStatusRunning')
|
||||
case 'stopping': return t('jobStatusStopping')
|
||||
case 'completed': return t('jobStatusCompleted')
|
||||
case 'killed': return t('jobStatusKilled')
|
||||
case 'failed': return t('jobStatusFailed')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elapsed time in at most two adjacent units (mirror of the official
|
||||
* ui-jobs duration wording). A background job that outlives an hour is
|
||||
* already exceptional, so hours is the widest unit.
|
||||
*/
|
||||
export function formatJobDuration(
|
||||
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('jobDurationHours', { hours, minutes })
|
||||
if (minutes > 0) return t('jobDurationMinutes', { minutes, seconds })
|
||||
return t('jobDurationSeconds', { seconds })
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Terminal font resolution: the user's custom font prefs (SidebarPrefs,
|
||||
* configured under the terminal card's secondary settings) turned into the
|
||||
* xterm options. Kept as a pure module (no DOM, no xterm) so the fallback
|
||||
* chain and clamping are unit-testable without mounting a terminal.
|
||||
*/
|
||||
import { clampTerminalFontSize, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
/** The built-in fallback stack when neither the user nor the theme sets one. */
|
||||
export const DEFAULT_TERMINAL_FONT_FAMILY = '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
|
||||
|
||||
/**
|
||||
* Resolve the xterm font options for the given prefs.
|
||||
* @param prefs - the current side card preferences.
|
||||
* @param themeFontFamily - the app's theme code font (`--ds-font-family-code`
|
||||
* token value, read live by the caller); undefined when the token is absent.
|
||||
* @returns the `fontFamily` / `fontSize` xterm options.
|
||||
*/
|
||||
export function resolveTerminalFont(
|
||||
prefs: SidebarPrefs,
|
||||
themeFontFamily: string | undefined,
|
||||
): { fontFamily: string; fontSize: number } {
|
||||
const custom = prefs.terminalFontFamily.trim()
|
||||
return {
|
||||
fontFamily: custom !== '' ? custom : (themeFontFamily || DEFAULT_TERMINAL_FONT_FAMILY),
|
||||
fontSize: clampTerminalFontSize(prefs.terminalFontSize),
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { mediaUrl } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { isZh, 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'
|
||||
@@ -54,14 +54,14 @@ export function XlsxView(props: OfficeViewProps): JSX.Element {
|
||||
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
|
||||
// Locales follow the DSH i18n system (the active locale); falls
|
||||
// back to the browser language, then en-US.
|
||||
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 locale = isZh() ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
@@ -9,6 +9,9 @@ import z from 'schemastery'
|
||||
import {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
TERMINAL_FONT_SIZE_DEFAULT,
|
||||
TERMINAL_FONT_SIZE_MAX,
|
||||
TERMINAL_FONT_SIZE_MIN,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
@@ -18,6 +21,9 @@ import {
|
||||
export {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
TERMINAL_FONT_SIZE_DEFAULT,
|
||||
TERMINAL_FONT_SIZE_MAX,
|
||||
TERMINAL_FONT_SIZE_MIN,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
@@ -79,8 +85,11 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
openByDefault: z.boolean().default(true),
|
||||
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
|
||||
autoOpenSubagent: z.boolean().default(true),
|
||||
autoOpenJobs: z.boolean().default(true),
|
||||
agentTerminalTools: z.boolean().default(false),
|
||||
bottomPanelAutoTerminal: z.boolean().default(true),
|
||||
terminalFontFamily: z.string().default(''),
|
||||
terminalFontSize: z.number().step(1).min(TERMINAL_FONT_SIZE_MIN).max(TERMINAL_FONT_SIZE_MAX).default(TERMINAL_FONT_SIZE_DEFAULT),
|
||||
interceptOpenPath: z.boolean().default(true),
|
||||
htmlViewerNoSandbox: z.boolean().default(false),
|
||||
htmlViewerDefaultUnsafe: z.boolean().default(false),
|
||||
|
||||
+116
-10
@@ -6,14 +6,14 @@
|
||||
* the npm cordis package does not declare the DSH-vendored runtime members
|
||||
* (`ctx.effect`, service properties). The members below mirror the actual
|
||||
* runtime shapes this plugin touches:
|
||||
* - httpServer: @deepseek-ai/dsh-host-webserver
|
||||
* - webServer: @deepseek-ai/dsh-host-webserver (the WebServer)
|
||||
* - sessions: host side @deepseek-ai/dsh-session (SessionStore), client
|
||||
* side the runtime ISessions list feed
|
||||
* - conversation: client side ui-conversation's IConversation (composer
|
||||
* draft), read lazily through `ctx.get` — cross-plugin service reads need
|
||||
* an inject declaration, so the direct property is never typed here
|
||||
* - loader: @cordisjs/plugin-loader (entry options)
|
||||
* - slots: the client runtime SlotsService
|
||||
* - slots: the client runtime SlotRegistry
|
||||
* - effect: the DSH-vendored cordis lifecycle helper
|
||||
* Drift from upstream is contained to this file.
|
||||
*/
|
||||
@@ -35,8 +35,8 @@ export interface SidebarWebUpgradeRoute {
|
||||
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
|
||||
}
|
||||
|
||||
/** The httpServer service face this plugin uses. */
|
||||
export interface SidebarHttpServer {
|
||||
/** The webServer service face this plugin uses. */
|
||||
export interface SidebarWebServer {
|
||||
register(route: SidebarWebRoute): () => void
|
||||
registerUpgrade(route: SidebarWebUpgradeRoute): () => void
|
||||
}
|
||||
@@ -48,7 +48,15 @@ export interface SidebarSessionHeader {
|
||||
|
||||
/** The host session store face (`ctx.sessions.get(id)` returns the live session). */
|
||||
export interface SidebarSessionStore {
|
||||
get(id: string): { header: SidebarSessionHeader } | undefined
|
||||
get(id: string): {
|
||||
header: SidebarSessionHeader
|
||||
/**
|
||||
* The live session's append-only event log (immutable snapshot; absent
|
||||
* on sessions the runtime has not hydrated). Read-only access — the
|
||||
* jobs.output route replays `job_output` tool/result rows from it.
|
||||
*/
|
||||
events?: readonly SidebarSessionEvent[]
|
||||
} | undefined
|
||||
}
|
||||
|
||||
/** One loader entry's options slice (the connection row's resolved config). */
|
||||
@@ -83,7 +91,7 @@ export interface SidebarSlotsService {
|
||||
register(options: SidebarSlotRegisterOptions, component: unknown): () => void
|
||||
/**
|
||||
* Run a callback for each declaration lifetime of a slot (the runtime
|
||||
* SlotsService.inject): a no-op while the slot is undeclared, so the
|
||||
* SlotRegistry.inject): a no-op while the slot is undeclared, so the
|
||||
* settings section registration waits for the settings shell.
|
||||
*/
|
||||
inject(key: string, callback: () => () => void): () => void
|
||||
@@ -150,6 +158,42 @@ export interface SidebarHistoryEntry {
|
||||
view?: unknown
|
||||
}
|
||||
|
||||
/** Lifecycle status set of one background job (closed wire union). */
|
||||
export type SidebarJobStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
|
||||
/**
|
||||
* One background job as the client mirror sees it (wire `JobView` shape:
|
||||
* id/kind/label/status/detail?/startedAt/finishedAt?).
|
||||
*/
|
||||
export interface SidebarJobView {
|
||||
/** Registry-issued `<kind>-N` identity, stable for the job'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: SidebarJobStatus
|
||||
/** Kind-specific status detail ('exit code: 3'), present once supplied. */
|
||||
detail?: string
|
||||
/** Epoch ms when the job was registered. */
|
||||
startedAt: number
|
||||
/** Epoch ms when the job settled; absent while live. */
|
||||
finishedAt?: number
|
||||
}
|
||||
|
||||
/** The host jobs registry face the sidebar routes touch (structural mirror of `JobRegistry`). */
|
||||
export interface SidebarJobsService {
|
||||
/** Request cancellation; throws for an unknown or foreign job. */
|
||||
kill(id: string, caller?: SidebarAgent, reason?: string): 'requested' | 'already-finished'
|
||||
}
|
||||
|
||||
/** The host agent registry face (structural mirror of the runtime `ctx.agents`). */
|
||||
export interface SidebarAgentsService {
|
||||
/** The live agent registered under a session id, or undefined when not live. */
|
||||
get(id: string): SidebarAgent | undefined
|
||||
}
|
||||
|
||||
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
|
||||
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
@@ -177,6 +221,12 @@ export interface SidebarSessionList {
|
||||
byId: Record<string, SidebarSessionSummary>
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>
|
||||
/**
|
||||
* Background jobs per session, last-wins from the harness's `session/jobs`
|
||||
* push (a missing key is an empty set). Absent on runtime snapshots older
|
||||
* than the jobs mirror — the sidebar simply shows no job rows.
|
||||
*/
|
||||
jobsBySession?: Readonly<Record<string, readonly SidebarJobView[]>>
|
||||
}
|
||||
|
||||
/** The client sessions service face (only the list feed is needed). */
|
||||
@@ -215,6 +265,23 @@ export interface SidebarSessionsService {
|
||||
refreshSubagents?(parentSessionId: string): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* The client locale service face (mirror of @deepseek-ai/dsh-client-locale's
|
||||
* LocaleRuntime — only the slices the sidebar touches). The sidebar follows
|
||||
* the DSH i18n system: the active locale is the Host-backed preference
|
||||
* (`locale.preference` in settings.yaml) rather than the raw browser
|
||||
* language, and the sidebar's zh/en dictionaries register into the service's
|
||||
* namespace registry under `betterSidebar`.
|
||||
*/
|
||||
export interface SidebarLocaleService {
|
||||
/** Current immutable locale snapshot (uSES-safe; `active` is 'zh' | 'en' today). */
|
||||
getSnapshot(): { active: string }
|
||||
/** Subscribe to snapshot changes (locale switch or dictionary registration). */
|
||||
subscribe(fn: () => void): () => void
|
||||
/** Register one locale's dictionary for a namespace; returns the disposer. */
|
||||
register(ns: string, locale: string, dict: Record<string, string>): () => void
|
||||
}
|
||||
|
||||
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
|
||||
export interface SidebarSessionInput {
|
||||
/** The live input store (draft read for append). */
|
||||
@@ -245,7 +312,7 @@ export interface SidebarWorkspacesService {
|
||||
|
||||
/**
|
||||
* The invariant service face (mirror of @deepseek-ai/dsh-invariants'
|
||||
* InvariantService). The upstream augmentation does not reach this Context
|
||||
* InvariantRegistry). The upstream augmentation does not reach this Context
|
||||
* (dual-cordis-instance resolution), so the register signature is restated
|
||||
* structurally, exactly like the other service faces above.
|
||||
*/
|
||||
@@ -257,7 +324,7 @@ export interface SidebarInvariantsService {
|
||||
): () => void
|
||||
}
|
||||
|
||||
/** The settings service face (mirror of @deepseek-ai/dsh-settings' Settings). */
|
||||
/** The settings service face (mirror of @deepseek-ai/dsh-settings' SettingsProvider). */
|
||||
export interface SidebarSettingsService {
|
||||
/**
|
||||
* Register one namespace schema (the resolved value layers schema defaults,
|
||||
@@ -287,7 +354,7 @@ export interface SidebarSettingsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRegistry).
|
||||
* The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRuntime).
|
||||
* The host half registers model-facing tools here; the registry attaches the
|
||||
* returned disposer to the contributing fiber so unloading unregisters them.
|
||||
*/
|
||||
@@ -313,7 +380,7 @@ export interface SidebarAgent {
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
httpServer: SidebarHttpServer
|
||||
webServer: SidebarWebServer
|
||||
sessions: SidebarSessionStore & SidebarSessionsService
|
||||
connection: SidebarConnectionHandle
|
||||
loader: SidebarLoader
|
||||
@@ -322,12 +389,37 @@ declare module 'cordis' {
|
||||
settings: SidebarSettingsService
|
||||
invariants: SidebarInvariantsService
|
||||
tools: SidebarToolsService
|
||||
/**
|
||||
* The client locale service (`@deepseek-ai/dsh-client-locale`): the
|
||||
* sidebar's copy follows its active locale and registers its
|
||||
* dictionaries under the `betterSidebar` namespace. Client side only.
|
||||
*/
|
||||
locale: SidebarLocaleService
|
||||
/**
|
||||
* The host background-job registry (`ctx.get('jobs')`; optional — the
|
||||
* sidebar routes degrade to a 503 when the deployment lacks it).
|
||||
*/
|
||||
jobs: SidebarJobsService
|
||||
/**
|
||||
* The host live-agent registry (`ctx.get('agents')`; optional — used to
|
||||
* resolve the caller the jobs fence compares against).
|
||||
*/
|
||||
agents: SidebarAgentsService
|
||||
/**
|
||||
* The client-side sidebar registry: external plugins register tab types
|
||||
* and file previewers here. Provided by the client half (see
|
||||
* {@link ./client/index.tsx}); undefined on the host side.
|
||||
*/
|
||||
betterSidebar: BetterSidebarService
|
||||
/**
|
||||
* Subscribe to the session append feed (mirror of the cordis event API):
|
||||
* the listener receives every appended session event with the LIVE
|
||||
* Session instance that appended it. The api-proxy pushes the same feed
|
||||
* to browsers; the sidebar uses it to mirror job_output events the
|
||||
* session store's own log can lag behind (restart divergence). Returns
|
||||
* the disposer.
|
||||
*/
|
||||
on(event: string, listener: (session: unknown, event: SidebarSessionEvent) => void): () => void
|
||||
/**
|
||||
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
|
||||
* activation; its returned cleanup runs at disposal.
|
||||
@@ -336,4 +428,18 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual cordis-scope augmentation: DSH's runtime (and the public packages)
|
||||
* resolve against the vendored `@deepseek-ai/cordis` scope, which DSH's own
|
||||
* packages already augment with `effect`/`on`/service members. This side only
|
||||
* adds the sidebar service member so consumers importing `Context` from
|
||||
* `@deepseek-ai/cordis` (instead of the public `cordis` above) still see
|
||||
* `ctx.betterSidebar`.
|
||||
*/
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
betterSidebar: BetterSidebarService
|
||||
}
|
||||
}
|
||||
|
||||
export type { Context }
|
||||
|
||||
+22
-7
@@ -36,6 +36,7 @@ import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
||||
import { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
|
||||
import { registerTools } from './tools.ts'
|
||||
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
|
||||
export { Config }
|
||||
@@ -58,7 +59,7 @@ export type {
|
||||
export const name = 'dsh-better-sidebar'
|
||||
|
||||
/** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */
|
||||
export const inject = ['httpServer', 'sessions', 'loader', 'tools']
|
||||
export const inject = ['webServer', 'sessions', 'loader', 'tools']
|
||||
|
||||
/** Content types for the media route, by extension. */
|
||||
const MEDIA_TYPES: Record<string, string> = {
|
||||
@@ -198,6 +199,12 @@ function buildApi(
|
||||
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
|
||||
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
|
||||
}
|
||||
// Background jobs: the LIST rides the harness's `session/jobs` 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
|
||||
// job_output cursor is never consumed) and kill (the registry's stock
|
||||
// API). A deployment without the jobs registry downgrades kill to a 503.
|
||||
const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit)
|
||||
return {
|
||||
'session.cwd': (payload) => {
|
||||
const { sessionId, cwd } = cwdOf(payload)
|
||||
@@ -327,6 +334,14 @@ function buildApi(
|
||||
agentPtyRegistry.close(uuid)
|
||||
return { ok: true }
|
||||
},
|
||||
// Background jobs: read one job's output (a REPLAY of what the model
|
||||
// has read so far, from the owner session's event log — the model's
|
||||
// job_output cursor is never touched, so the human pane can never steal
|
||||
// the agent's bytes), and kill one job. The job LIST itself arrives
|
||||
// through the harness's session/jobs push mirror, so no list route
|
||||
// exists. Kill is fenced to the owning session by the jobs registry.
|
||||
'jobs.output': (payload) => jobsApi.output(payload),
|
||||
'jobs.kill': (payload) => jobsApi.kill(payload),
|
||||
// The side card preferences. The settings service is optional in the
|
||||
// composition; while absent the routes report undefined and the client
|
||||
// keeps the schema defaults. Writes are revision-guarded: a stale editor
|
||||
@@ -410,7 +425,7 @@ function buildApi(
|
||||
|
||||
/**
|
||||
* Plugin body: mount the fenced routes and the pty lifecycle.
|
||||
* @param ctx - host plugin context (httpServer, sessions, loader).
|
||||
* @param ctx - host plugin context (webServer, sessions, loader).
|
||||
* @param config - deployment-provided limits; the Loader validates against
|
||||
* {@link Config} and fills defaults, direct callers get them from
|
||||
* {@link resolveSidebarConfig}.
|
||||
@@ -488,7 +503,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
|
||||
// ── JSON API ────────────────────────────────────────────────────────────
|
||||
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/api',
|
||||
handler: async (req, res) => {
|
||||
@@ -526,7 +541,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
ctx.effect(() => registerBundleRoute(ctx, fence), 'dsh-better-sidebar: /sidebar/bundle chunk route')
|
||||
|
||||
// ── Media route (images for the editor) ─────────────────────────────────
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/file',
|
||||
handler: async (req, res) => {
|
||||
@@ -584,7 +599,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// boundary, this header is defense-in-depth so even a top-level load of
|
||||
// the URL (e.g. a popup opened by a previewed page) stays in an opaque
|
||||
// origin with no same-origin access to the GUI.
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/html',
|
||||
handler: async (req, res) => {
|
||||
@@ -647,7 +662,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// terminals, scheduled-0 for UI tabs which keep the same reconnect grace
|
||||
// contract the host has always had).
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
ctx.effect(() => ctx.webServer.registerUpgrade({
|
||||
path: '/sidebar/ws/terminal',
|
||||
handler: (req, socket, head) => {
|
||||
if (!fence(req)) {
|
||||
@@ -669,7 +684,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
// sends `{type:'close'}` on the terminal WS, which kills the pty, which
|
||||
// fires a change here, which converges the view).
|
||||
const agentListWss = new WebSocketServer({ noServer: true })
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
ctx.effect(() => ctx.webServer.registerUpgrade({
|
||||
path: '/sidebar/ws/agent-terminals',
|
||||
handler: (req, socket, head) => {
|
||||
if (!fence(req)) {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the sidebar owns no service state or event protocol
|
||||
* of its own — every route is mounted under the host's httpServer fence, the
|
||||
* of its own — every route is mounted under the host's webServer fence, the
|
||||
* pty lifecycle is exercised by the smoke spec, and the panel's store is a
|
||||
* plain snapshot registry asserted by the behavior specs. The route fence,
|
||||
* pty quota, and store semantics are each observed through their seams.
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Background-job routes of the /sidebar JSON API ('jobs.output' /
|
||||
* 'jobs.kill'). The job LIST needs no route: it arrives through the
|
||||
* harness's `session/jobs` push mirror (`jobsBySession` in the sessions
|
||||
* list feed). The routes:
|
||||
*
|
||||
* - 'jobs.output' — REPLAYS the output the MODEL has read so far for one
|
||||
* job. The source is the owner session's own event log: `tool/call` rows
|
||||
* of `job_output` name the job via `arguments.job_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 job_output events from
|
||||
* the live `session/event` feed and merges both sources (deduped by seq).
|
||||
* This touches NO DSH source: the model's `job_output` cursor is never
|
||||
* consumed, and the pane stays empty until the agent reads the job.
|
||||
* - 'jobs.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-job routes of the sidebar API. */
|
||||
export interface SidebarJobsRoutes {
|
||||
/** The output the model has read so far for one job (event replay, capped). */
|
||||
output(payload: unknown): { text: string; truncated: boolean; read: boolean }
|
||||
/** Request cancellation of one job (live jobs 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 job_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 job_output trace (a tool/call or its paired tool/result). */
|
||||
interface JobOutputTrace {
|
||||
seq: number
|
||||
kind: 'call' | 'result'
|
||||
/** The tool call identity pairing the two rows. */
|
||||
callId: string
|
||||
/** tool/call: the job id parsed from the model arguments. */
|
||||
jobId?: 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 job_output trace of one raw session event (undefined = unrelated). */
|
||||
function traceOf(event: SidebarSessionEvent): JobOutputTrace | undefined {
|
||||
if (event.type === 'tool/call') {
|
||||
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
|
||||
if (data.name !== 'job_output' || typeof data.callId !== 'string') return undefined
|
||||
let jobId: string | undefined
|
||||
try {
|
||||
const args = JSON.parse(typeof data.arguments === 'string' ? data.arguments : '') as { job_id?: unknown }
|
||||
if (typeof args.job_id === 'string') jobId = args.job_id
|
||||
} catch {
|
||||
// Malformed model arguments: not a job_output pair.
|
||||
}
|
||||
if (jobId === undefined) return undefined
|
||||
return { seq: event.seq, kind: 'call', callId: data.callId, jobId }
|
||||
}
|
||||
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 job_output mirror: subscribes to the session append feed and
|
||||
* caches the job_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 createJobOutputMirror(ctx: Context): { entries(sessionId: string): readonly JobOutputTrace[] } {
|
||||
const perSession = new Map<string, JobOutputTrace[]>()
|
||||
// tool/call identities per session, so unrelated tool/result rows are
|
||||
// never cached (only job_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: job-output event mirror')
|
||||
|
||||
const push = (sessionId: string, trace: JobOutputTrace): 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 jobs routes bound to the plugin context. `output` merges the
|
||||
* owner session's own event log with the live job_output mirror; `kill`
|
||||
* reads the jobs/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 buildJobsApi(ctx: Context, outputLimit: number): SidebarJobsRoutes {
|
||||
const jobs = ctx.get('jobs')
|
||||
const agents = ctx.get('agents')
|
||||
const mirror = createJobOutputMirror(ctx)
|
||||
/** The live caller whose session id the registry fence compares against. */
|
||||
const callerOf = (sessionId: string) => agents?.get(sessionId)
|
||||
/** Registry refusals become a 404 job-error; unknown and foreign ids are indistinguishable. */
|
||||
const registryError = (error: unknown): SidebarError =>
|
||||
new SidebarError('job-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, JobOutputTrace>()
|
||||
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 jobOf = 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.jobId !== undefined) jobOf.set(trace.callId, trace.jobId)
|
||||
} else if (jobOf.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 (jobs === undefined) {
|
||||
throw new SidebarError('job-error', 'the background-job 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: jobs.kill(id, callerOf(sessionId), reason) }
|
||||
} catch (error) {
|
||||
throw registryError(error)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ export interface SidebarPrefs {
|
||||
* Subagent page when the current conversation spawns a new subagent.
|
||||
*/
|
||||
autoOpenSubagent: boolean
|
||||
/**
|
||||
* Whether the sidebar auto-activates (opens the panel) and expands the
|
||||
* Jobs page when a NEW background job appears for the current
|
||||
* conversation (any new job id, not just the first one).
|
||||
*/
|
||||
autoOpenJobs: boolean
|
||||
/**
|
||||
* Whether the model-facing agent terminal tools (terminal_create / list /
|
||||
* send / read / wait_for / resize / signal / close) are injected into the
|
||||
@@ -27,6 +33,18 @@ export interface SidebarPrefs {
|
||||
* user explicitly enables it in the side card settings.
|
||||
*/
|
||||
agentTerminalTools: boolean
|
||||
/**
|
||||
* Custom terminal font-family stack (a CSS font-family value, e.g.
|
||||
* `'JetBrains Mono', monospace`). Empty string follows the app's theme
|
||||
* monospace font (`--ds-font-family-code`). Applied live to every
|
||||
* terminal tab; configured under the terminal card's secondary settings.
|
||||
*/
|
||||
terminalFontFamily: string
|
||||
/**
|
||||
* Custom terminal font size in px (9–32). Applied live to every terminal
|
||||
* tab; configured under the terminal card's secondary settings.
|
||||
*/
|
||||
terminalFontSize: number
|
||||
/**
|
||||
* Whether expanding the bottom panel for the FIRST time in a session tries
|
||||
* to open a fresh terminal tab there (the terminal quota/type still gates
|
||||
@@ -98,13 +116,21 @@ export const WIDTH_PERCENT_MIN = 20
|
||||
export const WIDTH_PERCENT_MAX = 60
|
||||
export const WIDTH_PERCENT_DEFAULT = 30
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.terminalFontSize}. */
|
||||
export const TERMINAL_FONT_SIZE_MIN = 9
|
||||
export const TERMINAL_FONT_SIZE_MAX = 32
|
||||
export const TERMINAL_FONT_SIZE_DEFAULT = 13
|
||||
|
||||
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
||||
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: TERMINAL_FONT_SIZE_DEFAULT,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
@@ -118,3 +144,8 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
export function clampWidthPercent(value: number): number {
|
||||
return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
/** Clamp one terminal font size into the contract range (shared by schema and client reads). */
|
||||
export function clampTerminalFontSize(value: number): number {
|
||||
return Math.min(TERMINAL_FONT_SIZE_MAX, Math.max(TERMINAL_FONT_SIZE_MIN, Math.round(value)))
|
||||
}
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ const TRANSCRIPT_LIMIT = 1 << 20
|
||||
* Restore the executable bit pnpm strips from node-pty's prebuilt
|
||||
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
|
||||
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
|
||||
* @deepseek-ai/dsh-pty-local's ensure-spawn-helper postinstall, run at
|
||||
* @deepseek-ai/dsh-terminal-bash's ensure-spawn-helper postinstall, run at
|
||||
* plugin activation so link-installed deployments get the fix too.
|
||||
*/
|
||||
export function ensureSpawnHelper(): void {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SidebarErrorCode =
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'job-error'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
| 'internal'
|
||||
|
||||
+16
-4
@@ -41,16 +41,28 @@ describe('built-in tab registrations', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('the subagent tab declares its auto-open related setting', () => {
|
||||
it('the subagent tab declares its auto-open related settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent'])
|
||||
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent', 'autoOpenJobs'])
|
||||
})
|
||||
|
||||
it('the terminal tab declares the model terminal-tools related setting', () => {
|
||||
it('the terminal tab declares the model terminal-tools, auto-terminal and custom-font settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal'])
|
||||
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools', 'bottomPanelAutoTerminal', 'terminalFontFamily', 'terminalFontSize'])
|
||||
// The font rows are text/number inputs (not switches), with the size
|
||||
// row bounded by the shared 9–32 contract.
|
||||
expect(toggles[2]?.type).toBe('text')
|
||||
expect(toggles[2]?.title).toBeDefined()
|
||||
expect(toggles[2]?.placeholder).toBeDefined()
|
||||
expect(toggles[3]?.type).toBe('number')
|
||||
expect(toggles[3]?.min).toBe(9)
|
||||
expect(toggles[3]?.max).toBe(32)
|
||||
expect(toggles[3]?.unit).toBe('px')
|
||||
// The first two rows stay plain boolean switches.
|
||||
expect(toggles[0]?.type ?? 'switch').toBe('switch')
|
||||
expect(toggles[1]?.type ?? 'switch').toBe('switch')
|
||||
})
|
||||
|
||||
it('the browser tab declares its sandbox and link-takeover related settings', () => {
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Host route tests for the background-job API ('jobs.output' / 'jobs.kill').
|
||||
* 'jobs.output' REPLAYS the output the model has read so far from the owner
|
||||
* session's event log (tool/call + tool/result pairs of job_output calls) —
|
||||
* the model's cursor is never touched, so nothing is stolen and unread jobs
|
||||
* report `read: false`. 'jobs.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 { buildJobsApi } from '../src/jobs-routes.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import type { Context, SidebarSessionEvent } from '../src/context-types.ts'
|
||||
|
||||
/** A context whose `get` serves only the jobs/agents faces, with a session store. */
|
||||
function ctxWith(sessions: unknown, jobs: unknown, agents: unknown): Context {
|
||||
return {
|
||||
sessions,
|
||||
get: (key: string) => (key === 'jobs' ? jobs : 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 job_output tool/call event. */
|
||||
function jobOutputCall(seq: number, callId: string, jobId: string): SidebarSessionEvent {
|
||||
return { type: 'tool/call', seq, time: seq, data: { name: 'job_output', callId, arguments: JSON.stringify({ job_id: jobId }) } }
|
||||
}
|
||||
|
||||
/** One tool/result event carrying the finalized text the model received. */
|
||||
function jobOutputResult(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('jobs.output route (event replay)', () => {
|
||||
it('concatenates the job_output results the model read for the job, oldest first', () => {
|
||||
const events = [
|
||||
jobOutputCall(0, 'c1', 'bash-1'),
|
||||
jobOutputResult(1, 'c1', 'line1\nline2\n[status: running]'),
|
||||
jobOutputCall(2, 'c2', 'bash-1'),
|
||||
jobOutputResult(3, 'c2', 'line3\n[status: completed, exit code: 0]'),
|
||||
// Another job's reads are ignored.
|
||||
jobOutputCall(4, 'c3', 'bash-2'),
|
||||
jobOutputResult(5, 'c3', 'other job output'),
|
||||
]
|
||||
const api = buildJobsApi(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 = [
|
||||
jobOutputCall(0, 'c1', 'bash-1'),
|
||||
jobOutputResult(1, 'c1', 'real output\n[status: running]'),
|
||||
jobOutputCall(2, 'c2', 'bash-1'),
|
||||
jobOutputResult(3, 'c2', '(no new output)\n[status: running]'),
|
||||
jobOutputCall(4, 'c3', 'bash-1'),
|
||||
jobOutputResult(5, 'c3', 'boom', { isError: true }),
|
||||
]
|
||||
const api = buildJobsApi(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 job (no registry call at all)', () => {
|
||||
const events = [jobOutputCall(0, 'c1', 'bash-2')]
|
||||
const jobs = { kill: vi.fn() }
|
||||
const api = buildJobsApi(ctxWith({ get: () => session(events) }, jobs, 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(jobs.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mirrors live job_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 = buildJobsApi(ctx, 512 * 1024)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
||||
|
||||
// The live feed delivers the job_output call and its result.
|
||||
emit({ id: 's1' }, jobOutputCall(100, 'c-live', 'bash-1'))
|
||||
emit({ id: 's1' }, jobOutputResult(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 job_output call paired them),
|
||||
// and another session's feed does not leak into this one.
|
||||
emit({ id: 's1' }, jobOutputResult(102, 'c-other', 'unpaired line'))
|
||||
emit({ id: 's2' }, jobOutputCall(103, 'c-s2', 'bash-1'))
|
||||
emit({ id: 's2' }, jobOutputResult(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 = [jobOutputCall(5, 'c-seed', 'bash-1'), jobOutputResult(6, 'c-seed', 'seed line')]
|
||||
const { ctx, emit } = ctxWithFeed({ get: () => session(events) })
|
||||
const api = buildJobsApi(ctx, 512 * 1024)
|
||||
emit({ id: 's1' }, jobOutputCall(106, 'c-live', 'bash-1'))
|
||||
emit({ id: 's1' }, jobOutputResult(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 = [jobOutputCall(0, 'c1', 'bash-1'), jobOutputResult(1, 'c1', 'x'.repeat(10_000))]
|
||||
const api = buildJobsApi(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 = buildJobsApi(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('jobs.kill route', () => {
|
||||
it('kills with the forwarded reason and the live caller', () => {
|
||||
const jobs = { kill: vi.fn(() => 'requested' as const) }
|
||||
const agents = { get: vi.fn((id: string) => agent(id)) }
|
||||
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, agents), 100)
|
||||
expect(api.kill({ sessionId: 's1', id: 'bash-1', reason: 'user pressed stop' }))
|
||||
.toEqual({ ok: true, outcome: 'requested' })
|
||||
expect(jobs.kill).toHaveBeenCalledWith('bash-1', agent('s1'), 'user pressed stop')
|
||||
})
|
||||
|
||||
it('defaults the reason when none is supplied', () => {
|
||||
const jobs = { kill: vi.fn(() => 'already-finished' as const) }
|
||||
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, undefined), 100)
|
||||
expect(api.kill({ sessionId: 's1', id: 'bash-1' })).toEqual({ ok: true, outcome: 'already-finished' })
|
||||
expect(jobs.kill).toHaveBeenCalledWith('bash-1', undefined, 'user requested via sidebar')
|
||||
})
|
||||
|
||||
it('maps registry refusals to a 404 job-error', () => {
|
||||
const jobs = { kill: vi.fn(() => { throw new Error('unknown job bash-9') }) }
|
||||
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, undefined), 100)
|
||||
expect(() => api.kill({ sessionId: 's1', id: 'bash-9' })).toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 404 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades to a 503 when the jobs registry is absent (output keeps working)', () => {
|
||||
const api = buildJobsApi(ctxWith({ get: () => session([]) }, undefined, undefined), 100)
|
||||
expect(() => api.kill({ sessionId: 's1', id: 'bash-1' })).toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 503 }),
|
||||
)
|
||||
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Locale-following spec: the sidebar copy follows the DSH locale service
|
||||
* (`ctx.locale`, provided by @deepseek-ai/dsh-client-locale) when attached
|
||||
* through `attachLocale`, and falls back to the browser language otherwise.
|
||||
* Covers attach/detach, live switching, dictionary parity, and placeholder
|
||||
* interpolation.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { LOCALE_NS, attachLocale, en, isZh, relativeTime, t, zh } from '../src/client/locales.ts'
|
||||
|
||||
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
|
||||
class FakeLocale {
|
||||
active: string = 'en'
|
||||
getSnapshot(): { active: string } {
|
||||
return { active: this.active }
|
||||
}
|
||||
subscribe(_fn: () => void): () => void {
|
||||
return () => {}
|
||||
}
|
||||
register(_ns: string, _locale: string, _dict: Record<string, string>): () => void {
|
||||
return () => {}
|
||||
}
|
||||
switchTo(id: string): void {
|
||||
this.active = id
|
||||
}
|
||||
}
|
||||
|
||||
/** Point the browser-language fallback at a specific language (undefined = none). */
|
||||
function stubNavigatorLanguage(lang: string | undefined): void {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: lang === undefined ? undefined : { language: lang },
|
||||
configurable: true,
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
attachLocale(undefined)
|
||||
stubNavigatorLanguage(undefined)
|
||||
})
|
||||
|
||||
describe('locales (DSH i18n following)', () => {
|
||||
it('falls back to the browser language without an attached service', () => {
|
||||
stubNavigatorLanguage('en-US')
|
||||
expect(t('explorer')).toBe('Explorer')
|
||||
expect(isZh()).toBe(false)
|
||||
|
||||
stubNavigatorLanguage('zh-CN')
|
||||
expect(t('explorer')).toBe('资源管理器')
|
||||
expect(isZh()).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to English when no locale service and no browser language are available', () => {
|
||||
expect(t('explorer')).toBe('Explorer')
|
||||
expect(isZh()).toBe(false)
|
||||
})
|
||||
|
||||
it('follows the attached locale service instead of the browser language', () => {
|
||||
stubNavigatorLanguage('en-US')
|
||||
const locale = new FakeLocale()
|
||||
attachLocale(locale)
|
||||
|
||||
locale.switchTo('zh')
|
||||
expect(t('git')).toBe('源代码管理')
|
||||
expect(isZh()).toBe(true)
|
||||
|
||||
// Live switch: the service's active locale wins even though the
|
||||
// browser still asks for en-US.
|
||||
locale.switchTo('en')
|
||||
expect(t('git')).toBe('Source Control')
|
||||
expect(isZh()).toBe(false)
|
||||
})
|
||||
|
||||
it('detaches back to the browser-language fallback', () => {
|
||||
const locale = new FakeLocale()
|
||||
locale.switchTo('zh')
|
||||
attachLocale(locale)
|
||||
expect(t('terminal')).toBe('终端')
|
||||
|
||||
stubNavigatorLanguage('en-US')
|
||||
attachLocale(undefined)
|
||||
expect(t('terminal')).toBe('Terminal')
|
||||
})
|
||||
|
||||
it('interpolates {name} placeholders in the active locale', () => {
|
||||
const locale = new FakeLocale()
|
||||
attachLocale(locale)
|
||||
locale.switchTo('zh')
|
||||
expect(t('timeMinutesAgo', { n: 5 })).toBe('5 分钟前')
|
||||
locale.switchTo('en')
|
||||
expect(t('timeMinutesAgo', { n: 5 })).toBe('5 min ago')
|
||||
})
|
||||
|
||||
it('drives the relative-time chain through the active locale', () => {
|
||||
const locale = new FakeLocale()
|
||||
attachLocale(locale)
|
||||
locale.switchTo('zh')
|
||||
expect(relativeTime(new Date().toISOString())).toBe('刚刚')
|
||||
locale.switchTo('en')
|
||||
expect(relativeTime(new Date().toISOString())).toBe('just now')
|
||||
})
|
||||
|
||||
it('registers a namespace distinct from DSH ui-sidebar\'s own \'sidebar\'', () => {
|
||||
expect(LOCALE_NS).toBe('betterSidebar')
|
||||
})
|
||||
|
||||
it('keeps the zh and en dictionaries key-set-equal', () => {
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Markdown preview copy-label spec: the fence copy buttons ("复制" / "Copy")
|
||||
* must follow the DSH locale service through `codeLabels` — the DSH
|
||||
* MarkdownText/CodeBlock are cordis-free and fall back to HARDCODED Chinese
|
||||
* when the caller omits the labels, so TextEditor must pass its own
|
||||
* dictionary copy (`t('copy')` / `t('copied')`), re-evaluated per render.
|
||||
* Rendered with renderToString (the initial preview mode never mounts
|
||||
* CodeMirror, so no DOM/effects are needed).
|
||||
*/
|
||||
import { describe, expect, it, afterEach } from 'vitest'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
import './browser-globals.ts'
|
||||
import { TextEditor } from '../src/client/TextEditor.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { attachLocale } from '../src/client/locales.ts'
|
||||
import type { FileViewerProps } from '../src/client/service.ts'
|
||||
|
||||
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
|
||||
class FakeLocale {
|
||||
active: string = 'zh'
|
||||
getSnapshot(): { active: string } {
|
||||
return { active: this.active }
|
||||
}
|
||||
subscribe(_fn: () => void): () => void {
|
||||
return () => {}
|
||||
}
|
||||
register(_ns: string, _locale: string, _dict: Record<string, string>): () => void {
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
const CTX = {} as Parameters<typeof TextEditor>[0]['ctx']
|
||||
|
||||
/** A markdown source with one fenced code block (the copy-button surface). */
|
||||
const MD_WITH_FENCE = '```ts\nconst a = 1\n```'
|
||||
|
||||
function viewerProps(overrides: Partial<FileViewerProps> = {}): FileViewerProps {
|
||||
return {
|
||||
ctx: CTX,
|
||||
store: createSidebarStore(),
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
path: '/p/a/README.md',
|
||||
title: 'README.md',
|
||||
viewerId: 'markdown',
|
||||
content: MD_WITH_FENCE,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
attachLocale(undefined)
|
||||
})
|
||||
|
||||
describe('markdown preview code-block copy labels (DSH i18n following)', () => {
|
||||
it('renders the fence copy button with the zh dictionary label by default', () => {
|
||||
const locale = new FakeLocale()
|
||||
locale.active = 'zh'
|
||||
attachLocale(locale)
|
||||
const html = renderToString(createElement(TextEditor, viewerProps()))
|
||||
expect(html).toContain('复制')
|
||||
expect(html).not.toContain('Copy')
|
||||
})
|
||||
|
||||
it('follows the attached locale service live: en renders "Copy" instead of the zh label', () => {
|
||||
const locale = new FakeLocale()
|
||||
locale.active = 'en'
|
||||
attachLocale(locale)
|
||||
const html = renderToString(createElement(TextEditor, viewerProps()))
|
||||
expect(html).toContain('Copy')
|
||||
expect(html).not.toContain('复制')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* openWhenSized tests: the deferred one-shot open guard for hosts that may
|
||||
* not have a real size yet (xterm crashes when opened in a zero-size
|
||||
* container — the WKWebView bottom-panel blank-terminal bug, issue #25).
|
||||
* The polling must open exactly once, only once the host reports a real
|
||||
* size, stop when the host leaves the document, and cancel cleanly.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { openWhenSized } from '../src/client/open-when-sized.ts'
|
||||
|
||||
/** A manually-stepped raf/caf pair so the polling is deterministic. */
|
||||
function makeScheduler(): {
|
||||
raf: (cb: FrameRequestCallback) => number
|
||||
caf: (id: number) => void
|
||||
tick: () => void
|
||||
pending: () => number
|
||||
} {
|
||||
let nextId = 0
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
return {
|
||||
raf: (cb) => { const id = ++nextId; frames.set(id, cb); return id },
|
||||
caf: (id) => { frames.delete(id) },
|
||||
tick: () => {
|
||||
const cbs = [...frames.values()]
|
||||
frames.clear()
|
||||
for (const cb of cbs) cb(0)
|
||||
},
|
||||
pending: () => frames.size,
|
||||
}
|
||||
}
|
||||
|
||||
/** A host whose reported size can be changed between ticks. */
|
||||
function makeHost(width: number, height: number): {
|
||||
el: HTMLElement
|
||||
setSize: (w: number, h: number) => void
|
||||
} {
|
||||
const el = document.createElement('div')
|
||||
document.body.appendChild(el)
|
||||
const setSize = (w: number, h: number): void => {
|
||||
Object.defineProperty(el, 'clientWidth', { value: w, configurable: true })
|
||||
Object.defineProperty(el, 'clientHeight', { value: h, configurable: true })
|
||||
}
|
||||
setSize(width, height)
|
||||
return { el, setSize }
|
||||
}
|
||||
|
||||
describe('openWhenSized', () => {
|
||||
it('opens on the first tick when the host already has a size, then stops', () => {
|
||||
const { raf, caf, tick, pending } = makeScheduler()
|
||||
const host = makeHost(320, 200)
|
||||
let opened = 0
|
||||
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
|
||||
tick()
|
||||
expect(opened).toBe(1)
|
||||
expect(pending()).toBe(0)
|
||||
tick()
|
||||
expect(opened).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('defers while the host is zero-sized and opens exactly once once sized', () => {
|
||||
const { raf, caf, tick, pending } = makeScheduler()
|
||||
const host = makeHost(0, 0)
|
||||
let opened = 0
|
||||
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
|
||||
tick()
|
||||
tick()
|
||||
tick()
|
||||
expect(opened).toBe(0)
|
||||
expect(pending()).toBe(1)
|
||||
host.setSize(320, 200)
|
||||
tick()
|
||||
expect(opened).toBe(1)
|
||||
expect(pending()).toBe(0)
|
||||
tick()
|
||||
expect(opened).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('opens when only one dimension was missing', () => {
|
||||
const { raf, caf, tick } = makeScheduler()
|
||||
const host = makeHost(320, 0)
|
||||
let opened = 0
|
||||
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
|
||||
tick()
|
||||
expect(opened).toBe(0)
|
||||
host.setSize(320, 120)
|
||||
tick()
|
||||
expect(opened).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('cancels a pending open and leaves no scheduled frames', () => {
|
||||
const { raf, caf, tick, pending } = makeScheduler()
|
||||
const host = makeHost(0, 0)
|
||||
let opened = 0
|
||||
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
|
||||
tick()
|
||||
cancel()
|
||||
expect(pending()).toBe(0)
|
||||
host.setSize(320, 200)
|
||||
tick()
|
||||
expect(opened).toBe(0)
|
||||
// Idempotent: cancelling again is a no-op.
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('stops polling when the host leaves the document', () => {
|
||||
const { raf, caf, tick, pending } = makeScheduler()
|
||||
const host = makeHost(0, 0)
|
||||
let opened = 0
|
||||
const cancel = openWhenSized(host.el, () => { opened += 1 }, raf, caf)
|
||||
tick()
|
||||
host.el.remove()
|
||||
tick()
|
||||
expect(opened).toBe(0)
|
||||
expect(pending()).toBe(0)
|
||||
// Even if the host somehow got a size after detaching, nothing opens.
|
||||
host.setSize(320, 200)
|
||||
tick()
|
||||
expect(opened).toBe(0)
|
||||
cancel()
|
||||
})
|
||||
|
||||
it('does not swallow exceptions from open (the caller owns error handling)', () => {
|
||||
const { raf, caf, tick } = makeScheduler()
|
||||
const host = makeHost(320, 200)
|
||||
let calls = 0
|
||||
const cancel = openWhenSized(host.el, () => {
|
||||
calls += 1
|
||||
throw new Error('boom')
|
||||
}, raf, caf)
|
||||
expect(() => tick()).toThrow('boom')
|
||||
expect(calls).toBe(1)
|
||||
cancel()
|
||||
})
|
||||
})
|
||||
@@ -17,7 +17,7 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(sidebar)
|
||||
expect(unwrapped.name).toBe('dsh-better-sidebar')
|
||||
expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader', 'tools'])
|
||||
expect(unwrapped.inject).toEqual(['webServer', 'sessions', 'loader', 'tools'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
@@ -45,9 +45,15 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
expect(resolved.openByDefault).toBe(true)
|
||||
expect(resolved.defaultWidthPercent).toBe(30)
|
||||
expect(resolved.autoOpenSubagent).toBe(true)
|
||||
// A new background job auto-opens the Jobs page too.
|
||||
expect(resolved.autoOpenJobs).toBe(true)
|
||||
// The terminal tools default OFF (the feature is dormant until the user
|
||||
// enables it in the side card settings).
|
||||
expect(resolved.agentTerminalTools).toBe(false)
|
||||
// The terminal font customizations default to the theme (empty family)
|
||||
// and 13px.
|
||||
expect(resolved.terminalFontFamily).toBe('')
|
||||
expect(resolved.terminalFontSize).toBe(13)
|
||||
// The enable-switch maps resolve to {} (everything on) for old documents.
|
||||
expect(resolved.tabsEnabled).toEqual({})
|
||||
expect(resolved.viewersEnabled).toEqual({})
|
||||
@@ -57,6 +63,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Pure payload logic for the viewer selection popup ("add to
|
||||
* conversation"): the fence header (relative path + line span), the
|
||||
* over-limit plain-line fallback, and the best-effort reverse-search that
|
||||
* maps a preview selection back to source lines.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SELECTION_LIMIT,
|
||||
buildSelectionInsert,
|
||||
headerOf,
|
||||
linesOfSelection,
|
||||
} from '../src/client/selection-payload.ts'
|
||||
|
||||
describe('headerOf', () => {
|
||||
it('projects the path relative to the session cwd', () => {
|
||||
expect(headerOf('/p/docs/plan.md', '/p', { start: 12, end: 12 })).toBe('docs/plan.md:12')
|
||||
})
|
||||
|
||||
it('falls back to the absolute path when the cwd is unknown', () => {
|
||||
expect(headerOf('/p/docs/plan.md', undefined, { start: 3, end: 3 })).toBe('/p/docs/plan.md:3')
|
||||
})
|
||||
|
||||
it('keeps the absolute path for files outside the cwd', () => {
|
||||
expect(headerOf('/other/x.md', '/p', { start: 1, end: 1 })).toBe('/other/x.md:1')
|
||||
})
|
||||
|
||||
it('writes a single line number for single-line selections', () => {
|
||||
expect(headerOf('/p/a.md', '/p', { start: 5, end: 5 })).toBe('a.md:5')
|
||||
})
|
||||
|
||||
it('writes the start-end range for multi-line selections', () => {
|
||||
expect(headerOf('/p/a.md', '/p', { start: 5, end: 9 })).toBe('a.md:5-9')
|
||||
})
|
||||
|
||||
it('omits the line suffix entirely when the lines are unknown', () => {
|
||||
expect(headerOf('/p/a.md', '/p', undefined)).toBe('a.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSelectionInsert', () => {
|
||||
it('wraps a small selection in a fenced block with the path:line info line', () => {
|
||||
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 2, end: 4 }, 'const x = 1')).toBe(
|
||||
'```a.ts:2-4\nconst x = 1\n```',
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a selection exactly at the limit (fenced)', () => {
|
||||
const text = 'x'.repeat(SELECTION_LIMIT)
|
||||
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 1, end: 1 }, text)).toBe(
|
||||
`\`\`\`a.ts:1\n${text}\n\`\`\``,
|
||||
)
|
||||
})
|
||||
|
||||
it('drops the content past the limit: one plain path line, no fence', () => {
|
||||
const text = 'x'.repeat(SELECTION_LIMIT + 1)
|
||||
expect(buildSelectionInsert('/p/a.ts', '/p', { start: 7, end: 7 }, text)).toBe('a.ts:7')
|
||||
})
|
||||
|
||||
it('keeps the plain-line form when past the limit without line numbers', () => {
|
||||
const text = 'x'.repeat(SELECTION_LIMIT + 1)
|
||||
expect(buildSelectionInsert('/p/a.ts', '/p', undefined, text)).toBe('a.ts')
|
||||
})
|
||||
})
|
||||
|
||||
describe('linesOfSelection', () => {
|
||||
const source = 'alpha\nbeta\ngamma alpha\ndelta\n'
|
||||
|
||||
it('maps a unique hit to its source line span', () => {
|
||||
expect(linesOfSelection(source, 'beta')).toEqual({ start: 2, end: 2 })
|
||||
expect(linesOfSelection(source, 'gamma alpha')).toEqual({ start: 3, end: 3 })
|
||||
})
|
||||
|
||||
it('spans the end line when the selection crosses line breaks', () => {
|
||||
expect(linesOfSelection(source, 'beta\ngamma')).toEqual({ start: 2, end: 3 })
|
||||
})
|
||||
|
||||
it('returns null when the text is missing from the source', () => {
|
||||
expect(linesOfSelection(source, 'nope')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null on an ambiguous (multi-hit) match', () => {
|
||||
expect(linesOfSelection(source, 'alpha')).toBeNull()
|
||||
})
|
||||
|
||||
it('strips a single trailing newline before searching (DOM block selections)', () => {
|
||||
expect(linesOfSelection(source, 'delta\n')).toEqual({ start: 4, end: 4 })
|
||||
})
|
||||
|
||||
it('returns null for an empty selection', () => {
|
||||
expect(linesOfSelection(source, '')).toBeNull()
|
||||
expect(linesOfSelection(source, '\n')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Interactive tests for the settings popup's text/number rows: the draft is
|
||||
* local state committed on blur through the parent's onCommit; the parent's
|
||||
* canonical return is adopted (clamped numbers, stored value for invalid
|
||||
* input), and the row remounts when the committed pref value changes (a
|
||||
* failed commit reverts prefs → the stored value reappears).
|
||||
*
|
||||
* Rendered with createRoot + act() in jsdom (the SSR specs stay in
|
||||
* side-card-section.spec.tsx; this file exercises the event paths).
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
import type { SidebarSettingToggle } from '../src/client/service.ts'
|
||||
import { FeatureSettingsRows } from '../src/client/SideCardSection.tsx'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
|
||||
/** Render the rows into a detached container under React's act(). */
|
||||
function mount(node: ReactNode): { container: HTMLDivElement; rerender: (node: ReactNode) => void; unmount: () => void } {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
act(() => { root.render(node) })
|
||||
return {
|
||||
container,
|
||||
rerender: (next) => { act(() => { root.render(next) }) },
|
||||
unmount: () => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Type into an input and commit it via blur (React 18: input event +
|
||||
* focusout). The native setter bypasses React's value tracker so the
|
||||
* change is actually seen. */
|
||||
function typeAndBlur(input: HTMLInputElement, value: string): void {
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!
|
||||
act(() => {
|
||||
setter.call(input, value)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new FocusEvent('focusout', { bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
const prefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
|
||||
describe('FeatureSettingsRows typed rows (interactive)', () => {
|
||||
it('commits the raw text on blur and adopts the canonical return', () => {
|
||||
const commits: Array<[string, string]> = []
|
||||
const toggle: SidebarSettingToggle = {
|
||||
key: 'terminalFontFamily',
|
||||
type: 'text',
|
||||
title: () => 'Font family',
|
||||
}
|
||||
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs,
|
||||
onToggle: () => {},
|
||||
onCommit: (t, raw) => {
|
||||
commits.push([t.key, raw])
|
||||
return raw
|
||||
},
|
||||
}))
|
||||
const input = container.querySelector('input')!
|
||||
typeAndBlur(input, 'Monaco')
|
||||
expect(commits).toEqual([['terminalFontFamily', 'Monaco']])
|
||||
// The canonical return is adopted into the draft.
|
||||
expect(input.value).toBe('Monaco')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('clamps numbers into the declared bounds on commit', () => {
|
||||
const commits: Array<[string, number]> = []
|
||||
const toggle: SidebarSettingToggle = {
|
||||
key: 'terminalFontSize',
|
||||
type: 'number',
|
||||
title: () => 'Font size',
|
||||
min: 9,
|
||||
max: 32,
|
||||
}
|
||||
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs: { ...prefs, terminalFontSize: 13 },
|
||||
onToggle: () => {},
|
||||
onCommit: (t, raw) => {
|
||||
const parsed = Number(raw)
|
||||
const clamped = Math.min(32, Math.max(9, Math.round(parsed)))
|
||||
commits.push([t.key, clamped])
|
||||
return String(clamped)
|
||||
},
|
||||
}))
|
||||
const input = container.querySelector('input')!
|
||||
typeAndBlur(input, '40')
|
||||
expect(commits).toEqual([['terminalFontSize', 32]])
|
||||
expect(input.value).toBe('32')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('clamps an emptied number input to the lower bound on commit (width-row precedent)', () => {
|
||||
const commits: Array<[string, number]> = []
|
||||
const toggle: SidebarSettingToggle = {
|
||||
key: 'terminalFontSize',
|
||||
type: 'number',
|
||||
title: () => 'Font size',
|
||||
min: 9,
|
||||
max: 32,
|
||||
}
|
||||
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs: { ...prefs, terminalFontSize: 13 },
|
||||
onToggle: () => {},
|
||||
// The parent mirrors the real handler: an emptied number parses to 0
|
||||
// and clamps into the bounds (a browser number input never holds a
|
||||
// non-numeric string — the draft can only be empty or numeric).
|
||||
onCommit: (t, raw) => {
|
||||
const clamped = Math.min(32, Math.max(9, Math.round(Number(raw))))
|
||||
commits.push([t.key, clamped])
|
||||
return String(clamped)
|
||||
},
|
||||
}))
|
||||
const input = container.querySelector('input')!
|
||||
typeAndBlur(input, '')
|
||||
expect(commits).toEqual([['terminalFontSize', 9]])
|
||||
expect(input.value).toBe('9')
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('reverts the draft to the committed value after a failed commit reverts prefs', () => {
|
||||
const toggle: SidebarSettingToggle = {
|
||||
key: 'terminalFontFamily',
|
||||
type: 'text',
|
||||
title: () => 'Font family',
|
||||
}
|
||||
// The parent mirrors the real handler: the optimistic commit adopts the
|
||||
// typed value, and a FAILED write reverts prefs to the stored one.
|
||||
let prefsNow = { ...prefs, terminalFontFamily: 'Old' }
|
||||
const { container, rerender, unmount } = mount(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs: prefsNow,
|
||||
onToggle: () => {},
|
||||
onCommit: (_t, raw) => raw,
|
||||
}))
|
||||
const input = container.querySelector('input')!
|
||||
typeAndBlur(input, 'New')
|
||||
// Optimistic commit: the draft adopts the typed value.
|
||||
expect(input.value).toBe('New')
|
||||
// The optimistic pref lands (parent state): the key changes, the row
|
||||
// remounts with the same value — no visible reset while editing.
|
||||
prefsNow = { ...prefsNow, terminalFontFamily: 'New' }
|
||||
rerender(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs: prefsNow,
|
||||
onToggle: () => {},
|
||||
onCommit: (_t, raw) => raw,
|
||||
}))
|
||||
expect(container.querySelector('input')!.value).toBe('New')
|
||||
// The write fails: prefs revert to the stored value and the row
|
||||
// remounts with it (the stale draft is gone).
|
||||
prefsNow = { ...prefsNow, terminalFontFamily: 'Old' }
|
||||
rerender(createElement(FeatureSettingsRows, {
|
||||
toggles: [toggle],
|
||||
prefs: prefsNow,
|
||||
onToggle: () => {},
|
||||
onCommit: (_t, raw) => raw,
|
||||
}))
|
||||
expect(container.querySelector('input')!.value).toBe('Old')
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -5,9 +5,11 @@
|
||||
* copy. The toggles are CARDS in a responsive grid: the card's main area is
|
||||
* the switch, the visual state IS the state (highlighted = enabled),
|
||||
* announced via `aria-pressed`, and the check badge sits at the far right.
|
||||
* Features that declare related settings carry a gear corner button whose
|
||||
* popup rows (native checkboxes) are tested through the extracted
|
||||
* FeatureSettingsRows component (the Modal portal renders only while open).
|
||||
* The general rows follow the DSH settings-row recipe with custom SWITCHES
|
||||
* (real checkboxes driving a styled track). Features that declare related
|
||||
* settings carry a gear corner button whose popup rows (switch controls)
|
||||
* are tested through the extracted FeatureSettingsRows component (the Modal
|
||||
* portal renders only while open).
|
||||
*
|
||||
* Rendered with renderToString (mount effects — the settings RPC sync — do
|
||||
* not run in SSR; the initial store prefs are the render input).
|
||||
@@ -78,14 +80,27 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>explorer<')
|
||||
expect(html).toContain('data-icon="subagent"')
|
||||
expect(html).toContain('>Subagents<')
|
||||
// Default prefs: openByDefault + interceptOpenPath + both tabs + the
|
||||
// image viewer are all enabled → 5 cards pressed, none pressed=false.
|
||||
// Default prefs: openByDefault + interceptOpenPath switches checked, and
|
||||
// both tabs + the image viewer cards pressed (3 aria-pressed cards).
|
||||
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
|
||||
expect(pressedCount(html, 'true')).toBe(5)
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
expect(pressedCount(html, 'false')).toBe(0)
|
||||
// The general toggles are custom switches (real checkboxes, checked).
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
expect(html).not.toContain('Auto-open Subagents')
|
||||
})
|
||||
|
||||
it('renders the section intro and group headings with inventory counts', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
expect(html).toContain('Manage what the side card shows and how it behaves')
|
||||
// Group headings carry the inventory count badge (2 tabs, 1 viewer).
|
||||
expect(html).toContain('>Sidebar content</span><span')
|
||||
expect(html).toContain('>File viewers</span><span')
|
||||
expect(html).toContain('>2</span>')
|
||||
expect(html).toContain('>1</span>')
|
||||
})
|
||||
|
||||
it('renders one small card per registered viewer: icon + title + exts', () => {
|
||||
const { store, service } = mount()
|
||||
const html = renderSection(store, service)
|
||||
@@ -111,8 +126,9 @@ describe('SideCardSection declarative inventory', () => {
|
||||
expect(html).toContain('>Subagents<')
|
||||
expect(html).toContain('>Image<')
|
||||
expect(pressedCount(html, 'false')).toBe(2)
|
||||
// openByDefault + interceptOpenPath + the explorer stay pressed.
|
||||
expect(pressedCount(html, 'true')).toBe(3)
|
||||
// The explorer card stays pressed; the general switches stay checked.
|
||||
expect(pressedCount(html, 'true')).toBe(1)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
})
|
||||
|
||||
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
|
||||
@@ -134,7 +150,7 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
desc: () => 'Expand on new subagent',
|
||||
}]
|
||||
|
||||
it('renders one native checkbox row per declared toggle with its current value', () => {
|
||||
it('renders one switch row per declared toggle with its current value', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles,
|
||||
prefs,
|
||||
@@ -142,7 +158,9 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
}))
|
||||
expect(html).toContain('Auto-open Subagents')
|
||||
expect(html).toContain('Expand on new subagent')
|
||||
// The checkbox reflects the prefs value (false here → unchecked).
|
||||
// The row's switch is a real checkbox (aria-label = the toggle title)
|
||||
// reflecting the prefs value (false here → unchecked).
|
||||
expect(html).toContain('aria-label="Auto-open Subagents"')
|
||||
expect(html).not.toContain('checked=""')
|
||||
})
|
||||
|
||||
@@ -154,4 +172,47 @@ describe('FeatureSettingsRows (the secondary settings popup body)', () => {
|
||||
}))
|
||||
expect(html).toContain('checked=""')
|
||||
})
|
||||
|
||||
it('renders a text row as an input seeded with the pref value (empty = theme default)', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles: [{
|
||||
key: 'terminalFontFamily',
|
||||
type: 'text',
|
||||
title: () => 'Font family',
|
||||
desc: () => 'CSS stack',
|
||||
placeholder: '"JetBrains Mono", monospace',
|
||||
}],
|
||||
prefs: { ...prefs, terminalFontFamily: '"JetBrains Mono", monospace' },
|
||||
onToggle: () => {},
|
||||
onCommit: () => '',
|
||||
}))
|
||||
expect(html).toContain('Font family')
|
||||
expect(html).toContain('placeholder=""JetBrains Mono", monospace"')
|
||||
// The input carries the pref value (no switch for text rows).
|
||||
expect(html).toContain('value=""JetBrains Mono", monospace"')
|
||||
expect(html).not.toContain('type="checkbox"')
|
||||
})
|
||||
|
||||
it('renders a number row with the pref value, the declared bounds and a unit suffix', () => {
|
||||
const html = renderToString(createElement(FeatureSettingsRows, {
|
||||
toggles: [{
|
||||
key: 'terminalFontSize',
|
||||
type: 'number',
|
||||
title: () => 'Font size',
|
||||
min: 9,
|
||||
max: 32,
|
||||
unit: 'px',
|
||||
}],
|
||||
prefs: { ...prefs, terminalFontSize: 18 },
|
||||
onToggle: () => {},
|
||||
onCommit: () => '18',
|
||||
}))
|
||||
expect(html).toContain('Font size')
|
||||
expect(html).toContain('type="number"')
|
||||
expect(html).toContain('value="18"')
|
||||
expect(html).toContain('min="9"')
|
||||
expect(html).toContain('max="32"')
|
||||
expect(html).toContain('px')
|
||||
expect(html).not.toContain('type="checkbox"')
|
||||
})
|
||||
})
|
||||
|
||||
+18
-5
@@ -17,7 +17,7 @@ import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-typ
|
||||
|
||||
interface FakeContext {
|
||||
loader: { entries: () => never[] }
|
||||
httpServer: {
|
||||
webServer: {
|
||||
register: (route: SidebarWebRoute) => () => void
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
|
||||
}
|
||||
@@ -27,6 +27,8 @@ interface FakeContext {
|
||||
/** The settings service never appears in the smoke context: the inject
|
||||
* callback must never run (mirror of cordis' service-less inject). */
|
||||
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void
|
||||
/** Optional services (jobs/agents) are read lazily; absent → undefined. */
|
||||
get: (key: string) => undefined
|
||||
}
|
||||
|
||||
describe('host plugin smoke', () => {
|
||||
@@ -41,7 +43,7 @@ describe('host plugin smoke', () => {
|
||||
const effects: Array<() => void | (() => void)> = []
|
||||
const ctx: FakeContext = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
webServer: {
|
||||
register: (route) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route) => { upgrades.push(route); return () => {} },
|
||||
},
|
||||
@@ -56,6 +58,8 @@ describe('host plugin smoke', () => {
|
||||
// No settings service in the smoke context: the registration callback
|
||||
// never runs (cordis' service-less inject behaves the same).
|
||||
inject: () => () => {},
|
||||
// No jobs/agents services: the jobs routes degrade to a 503.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/bundle', '/sidebar/file', '/sidebar/html'])
|
||||
@@ -305,7 +309,7 @@ describe('session cwd resolution over the API route', () => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
const ctx = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
webServer: {
|
||||
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
@@ -315,6 +319,8 @@ describe('session cwd resolution over the API route', () => {
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
// No settings service: the namespace registration never runs.
|
||||
inject: () => () => {},
|
||||
// No jobs/agents services in the smoke context: the routes degrade.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
@@ -453,7 +459,7 @@ describe('side card settings routes', () => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
const ctx = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
webServer: {
|
||||
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
@@ -464,6 +470,8 @@ describe('side card settings routes', () => {
|
||||
if (deps.includes('settings') && settings !== undefined) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
// No jobs/agents services: the jobs routes degrade to a 503.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
@@ -506,8 +514,11 @@ describe('side card settings routes', () => {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: 30,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
@@ -636,7 +647,7 @@ describe('agent terminal tool gating', () => {
|
||||
}
|
||||
const ctx = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
webServer: {
|
||||
register: (route: SidebarWebRoute) => { void route; return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
@@ -647,6 +658,8 @@ describe('agent terminal tool gating', () => {
|
||||
if (deps.includes('settings')) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
// No jobs/agents services: the jobs routes degrade to a 503.
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
// Default off: no tools are registered even though the settings service is mounted.
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Subagent page tests for the background-job section: rows render from the
|
||||
* `jobsBySession` mirror, clicking a row peeks its output through
|
||||
* `jobs.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: {},
|
||||
jobsBySession: {
|
||||
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 === 'jobs.output') {
|
||||
outputCalls.push({ sessionId: body.sessionId, id: body.id })
|
||||
// bash-9 stands for a job the model never read (read:false).
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
value: {
|
||||
text: body.id === 'bash-9' ? '' : `output-of-${body.id}`,
|
||||
truncated: false,
|
||||
read: body.id !== 'bash-9',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (method === 'jobs.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 jobs', () => {
|
||||
it('renders the tree jobs 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 job-related when the mirror is empty', () => {
|
||||
const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, jobsBySession: {} })
|
||||
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 jobs 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(), jobsBySession: {} }) })
|
||||
expect(container.textContent).not.toContain('后台任务')
|
||||
// And returning jobs 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 job 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 job (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 job yet', async () => {
|
||||
const snapshot = baseSnapshot()
|
||||
snapshot.jobsBySession = {
|
||||
root: [
|
||||
...(snapshot.jobsBySession?.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 job_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 jobs', 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: {},
|
||||
jobsBySession: { 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 job 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Pure-helper tests for the Subagent page's background-job section:
|
||||
* tree-membership collection, ordering, and status presentation mapping.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectTreeJobs,
|
||||
detectNewJob,
|
||||
formatJobDuration,
|
||||
isJobLive,
|
||||
orderJobs,
|
||||
jobDotState,
|
||||
jobStatusLabel,
|
||||
treeSessionIds,
|
||||
} from '../src/client/subagent-jobs.ts'
|
||||
import type { SidebarSessionList, SidebarSessionSummary, SidebarJobStatus, SidebarJobView } from '../src/context-types.ts'
|
||||
|
||||
/** The translator stub: renders duration templates like the real locale copy. */
|
||||
const templates: Record<string, string> = {
|
||||
jobDurationSeconds: '{seconds}秒',
|
||||
jobDurationMinutes: '{minutes}分{seconds}秒',
|
||||
jobDurationHours: '{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 job(id: string, over: Partial<SidebarJobView> = {}): SidebarJobView {
|
||||
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('collectTreeJobs', () => {
|
||||
it('collects jobs of the whole tree with owner titles, ignoring outside sessions', () => {
|
||||
const byId = {
|
||||
root: summary('root'),
|
||||
child: summary('child', { origin: 'subagent', parentId: 'root' }),
|
||||
}
|
||||
const jobsBySession = {
|
||||
root: [job('bash-1')],
|
||||
child: [job('bash-2', { status: 'completed', finishedAt: 2_000 })],
|
||||
stranger: [job('bash-9')],
|
||||
}
|
||||
const rows = collectTreeJobs(byId, jobsBySession, 'root')
|
||||
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.job.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(collectTreeJobs(byId, undefined, 'root')).toEqual([])
|
||||
expect(collectTreeJobs(byId, {}, 'root')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('orderJobs', () => {
|
||||
it('puts live rows first in start order, then settled rows newest-first', () => {
|
||||
const row = (id: string, status: SidebarJobStatus, startedAt: number, finishedAt?: number) => ({
|
||||
ownerSessionId: 'root',
|
||||
ownerTitle: 'root',
|
||||
job: job(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(orderJobs(rows).map(r => r.job.id)).toEqual(['live-1', 'live-2', 'old-settled', 'new-settled'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('status presentation helpers', () => {
|
||||
it('treats running and stopping as live', () => {
|
||||
expect(isJobLive(job('a', { status: 'running' }))).toBe(true)
|
||||
expect(isJobLive(job('b', { status: 'stopping' }))).toBe(true)
|
||||
expect(isJobLive(job('c', { status: 'completed' }))).toBe(false)
|
||||
expect(isJobLive(job('d', { status: 'killed' }))).toBe(false)
|
||||
expect(isJobLive(job('e', { status: 'failed' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('maps the five wire statuses to dot states and localized labels', () => {
|
||||
expect(jobDotState('running')).toBe('ongoing')
|
||||
expect(jobDotState('stopping')).toBe('warning')
|
||||
expect(jobDotState('completed')).toBe('done')
|
||||
expect(jobDotState('killed')).toBe('warning')
|
||||
expect(jobDotState('failed')).toBe('error')
|
||||
expect(jobStatusLabel('running', t)).toBe('jobStatusRunning')
|
||||
expect(jobStatusLabel('stopping', t)).toBe('jobStatusStopping')
|
||||
expect(jobStatusLabel('completed', t)).toBe('jobStatusCompleted')
|
||||
expect(jobStatusLabel('killed', t)).toBe('jobStatusKilled')
|
||||
expect(jobStatusLabel('failed', t)).toBe('jobStatusFailed')
|
||||
})
|
||||
|
||||
it('formats durations in at most two adjacent units', () => {
|
||||
expect(formatJobDuration(0, t)).toBe('0秒')
|
||||
expect(formatJobDuration(45_000, t)).toBe('45秒')
|
||||
expect(formatJobDuration(90_000, t)).toBe('1分30秒')
|
||||
expect(formatJobDuration(3_661_000, t)).toBe('1小时1分')
|
||||
// Negative or fractional input clamps to zero seconds.
|
||||
expect(formatJobDuration(-5, t)).toBe('0秒')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectNewJob', () => {
|
||||
const list = (jobsBySession: Record<string, SidebarJobView[]>): SidebarSessionList => ({
|
||||
current: 'root',
|
||||
byId: { root: { id: 'root', displayTitle: 'root' } },
|
||||
subagentsByParent: {},
|
||||
jobsBySession: jobsBySession,
|
||||
})
|
||||
|
||||
it('fires on EVERY new job id for the session (not just the first)', () => {
|
||||
expect(detectNewJob(list({}), list({ root: [job('bash-1')] }), 'root')).toBe(true)
|
||||
expect(detectNewJob(
|
||||
list({ root: [job('bash-1')] }),
|
||||
list({ root: [job('bash-1'), job('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(detectNewJob(
|
||||
list({ root: [job('bash-1')] }),
|
||||
list({ root: [job('bash-1', { status: 'completed', finishedAt: 2_000 })] }),
|
||||
'root',
|
||||
)).toBe(false)
|
||||
expect(detectNewJob(list({ root: [job('bash-1')] }), list({ root: [job('bash-1')] }), 'root')).toBe(false)
|
||||
// Jobs owned by another session do not trigger the current one.
|
||||
expect(detectNewJob(list({}), list({ child: [job('bash-1')] }), 'root')).toBe(false)
|
||||
expect(detectNewJob(list({}), list({}), 'root')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Terminal font resolution tests: the custom font prefs (side card settings,
|
||||
* terminal card) resolve into xterm options with a strict fallback chain —
|
||||
* user family > theme code font > the built-in monospace stack, and the size
|
||||
* clamped into the 9–32 contract.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../src/prefs-shared.ts'
|
||||
import { DEFAULT_TERMINAL_FONT_FAMILY, resolveTerminalFont } from '../src/client/terminal-font.ts'
|
||||
|
||||
describe('clampTerminalFontSize', () => {
|
||||
it('rounds and clamps into the 9–32 contract', () => {
|
||||
expect(clampTerminalFontSize(13)).toBe(13)
|
||||
expect(clampTerminalFontSize(15.6)).toBe(16)
|
||||
expect(clampTerminalFontSize(5)).toBe(TERMINAL_FONT_SIZE_MIN)
|
||||
expect(clampTerminalFontSize(40)).toBe(TERMINAL_FONT_SIZE_MAX)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTerminalFont', () => {
|
||||
it('falls back to the theme code font when the family pref is empty', () => {
|
||||
const { fontFamily, fontSize } = resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, 'var-theme-font')
|
||||
expect(fontFamily).toBe('var-theme-font')
|
||||
expect(fontSize).toBe(13)
|
||||
})
|
||||
|
||||
it('falls back to the built-in monospace stack when neither the pref nor the theme provides one', () => {
|
||||
expect(resolveTerminalFont(SIDEBAR_PREFS_DEFAULTS, undefined).fontFamily).toBe(DEFAULT_TERMINAL_FONT_FAMILY)
|
||||
// A whitespace-only pref counts as empty (theme default).
|
||||
expect(resolveTerminalFont({ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: ' ' }, undefined).fontFamily)
|
||||
.toBe(DEFAULT_TERMINAL_FONT_FAMILY)
|
||||
})
|
||||
|
||||
it('prefers the custom family verbatim and clamps the size', () => {
|
||||
const { fontFamily, fontSize } = resolveTerminalFont(
|
||||
{ ...SIDEBAR_PREFS_DEFAULTS, terminalFontFamily: '"JetBrains Mono", monospace', terminalFontSize: 40 },
|
||||
'var-theme-font',
|
||||
)
|
||||
expect(fontFamily).toBe('"JetBrains Mono", monospace')
|
||||
expect(fontSize).toBe(TERMINAL_FONT_SIZE_MAX)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Turn-tail interception registration spec (issue #15): `registerTurnTailInterception`
|
||||
* must go through `ctx.slots.inject` — the slot is a CHILD slot the host's
|
||||
* ui-conversation declares in its `conversation.chat.node` children table, so a
|
||||
* direct `slots.register` races the declaration and the ui-slots core throws
|
||||
* "not declared (a parent entry's children table must declare it)".
|
||||
*
|
||||
* The fake `slots` mirrors SlotRegistry.inject's semantics: run the callback
|
||||
* synchronously when the slot is already declared; otherwise wait and run it
|
||||
* when the declaration commits; the returned disposer cancels a pending wait
|
||||
* and disposes any active registration; the register disposer is idempotent.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { registerTurnTailInterception } from '../src/client/intercept.tsx'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
|
||||
interface RegisteredSlot {
|
||||
options: Record<string, unknown>
|
||||
component: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* A structural fake of the client slots service. `declared` selects the
|
||||
* timing: already-on-ledger (callback runs synchronously) vs. pending
|
||||
* (callback runs on `declare()`, unless the controller was disposed first).
|
||||
*/
|
||||
const fakeSlots = (declared: boolean) => {
|
||||
const registered: RegisteredSlot[] = []
|
||||
const disposals: number[] = []
|
||||
const pendings: Array<() => void> = []
|
||||
return {
|
||||
registered,
|
||||
disposals,
|
||||
pendings,
|
||||
slots: {
|
||||
register: (options: Record<string, unknown>, component: unknown) => {
|
||||
registered.push({ options, component })
|
||||
return () => { disposals.push(1) }
|
||||
},
|
||||
inject: (key: string, callback: () => () => void) => {
|
||||
if (key !== 'conversation.chat.turnTail') {
|
||||
throw new Error(`unexpected injected key "${key}"`)
|
||||
}
|
||||
let active: (() => void) | undefined
|
||||
let stopped = false
|
||||
const run = (): void => {
|
||||
if (stopped || active !== undefined) return
|
||||
active = callback()
|
||||
}
|
||||
if (declared) run()
|
||||
else pendings.push(run)
|
||||
return () => {
|
||||
stopped = true
|
||||
active?.()
|
||||
active = undefined
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A produced-files owner currency: one closing assistant seq + its nodes. */
|
||||
const producedOwner = (paths: string[]): unknown => ({
|
||||
nodes: [
|
||||
{ kind: 'assistant', seq: 1, turn: 1 },
|
||||
...paths.map(path => ({
|
||||
kind: 'tool-result', isError: false, callView: { card: 'diff', locations: [{ path }] },
|
||||
})),
|
||||
{ kind: 'assistant', seq: 2, turn: 1 },
|
||||
],
|
||||
seq: 2,
|
||||
})
|
||||
|
||||
const emptyOwner = (): unknown => ({ nodes: [{ kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })
|
||||
|
||||
/** The minimal client-context fake the registration (and its seats) touch. */
|
||||
const clientCtx = (slots: unknown): Context => ({
|
||||
slots,
|
||||
sessions: {
|
||||
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { id: 's1', cwd: '/w', displayTitle: 's1' } } }) },
|
||||
},
|
||||
betterSidebar: { openTab: vi.fn() },
|
||||
} as unknown as Context)
|
||||
|
||||
describe('turn-tail interception registration (issue #15)', () => {
|
||||
it('registers through slots.inject and lands once the slot is already declared', () => {
|
||||
const fake = fakeSlots(true)
|
||||
const store = createSidebarStore()
|
||||
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
|
||||
|
||||
// Exactly one registration, with the takeover descriptor.
|
||||
expect(fake.registered).toHaveLength(1)
|
||||
const { options, component } = fake.registered[0]!
|
||||
expect(options.name).toBe('conversation.chat.turnTail')
|
||||
expect(options.priority).toBe(-1)
|
||||
expect(options.registrant).toBe('dsh-better-sidebar')
|
||||
expect(options.select).toBeTypeOf('function')
|
||||
expect(options.inject).toBeTypeOf('function')
|
||||
expect(component).toBeTypeOf('function')
|
||||
|
||||
// Disposal removes the registration; the disposer is idempotent.
|
||||
restore()
|
||||
expect(fake.disposals).toHaveLength(1)
|
||||
restore()
|
||||
expect(fake.disposals).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('waits for the host declaration instead of throwing (the pre-fix race)', () => {
|
||||
const fake = fakeSlots(false)
|
||||
const store = createSidebarStore()
|
||||
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
|
||||
|
||||
// The slot is undeclared: nothing registered yet, no error.
|
||||
expect(fake.registered).toHaveLength(0)
|
||||
|
||||
// The host's ui-conversation commits the declaration → the entry lands.
|
||||
expect(fake.pendings).toHaveLength(1)
|
||||
fake.pendings[0]!()
|
||||
expect(fake.registered).toHaveLength(1)
|
||||
|
||||
// A later re-declaration does not double-register while the entry is live.
|
||||
fake.pendings[0]!()
|
||||
expect(fake.registered).toHaveLength(1)
|
||||
|
||||
restore()
|
||||
expect(fake.disposals).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a disposal before the declaration cancels the wait permanently', () => {
|
||||
const fake = fakeSlots(false)
|
||||
const store = createSidebarStore()
|
||||
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
|
||||
restore()
|
||||
|
||||
// The declaration arrives after the controller was disposed: ignored.
|
||||
fake.pendings[0]!()
|
||||
expect(fake.registered).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('declines the takeover while the editor tab is disabled in the settings', () => {
|
||||
const fake = fakeSlots(true)
|
||||
const store = createSidebarStore()
|
||||
const restore = registerTurnTailInterception(clientCtx(fake.slots), store)
|
||||
const select = fake.registered[0]!.options.select as (owner: unknown) => unknown
|
||||
|
||||
// Enabled (default): a produced turn claims the chain; an empty one declines.
|
||||
expect(select(producedOwner(['a.ts', 'b.ts']))).toEqual(['a.ts', 'b.ts'])
|
||||
expect(select(emptyOwner())).toBeNull()
|
||||
|
||||
// Editor tab disabled: even a produced turn falls back to the default
|
||||
// deliverables row (chips that cannot open must not be offered).
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { editor: false } })
|
||||
expect(select(producedOwner(['a.ts']))).toBeNull()
|
||||
|
||||
restore()
|
||||
})
|
||||
|
||||
it('wires the openInSidebar seat to the sidebar editor tab', () => {
|
||||
const fake = fakeSlots(true)
|
||||
const ctx = clientCtx(fake.slots)
|
||||
const store = createSidebarStore()
|
||||
const restore = registerTurnTailInterception(ctx, store)
|
||||
const inject = fake.registered[0]!.options.inject as (sessionId: string) => {
|
||||
openInSidebar: (path: string) => void
|
||||
}
|
||||
|
||||
// The seat hands the session-scoped opener to the chips row.
|
||||
const seat = inject('s1')
|
||||
expect(seat.openInSidebar).toBeTypeOf('function')
|
||||
seat.openInSidebar('/w/src/a.ts')
|
||||
expect(ctx.betterSidebar.openTab).toHaveBeenCalledWith({
|
||||
type: 'editor',
|
||||
title: 'a.ts',
|
||||
path: '/w/src/a.ts',
|
||||
id: 'editor:/w/src/a.ts',
|
||||
})
|
||||
|
||||
restore()
|
||||
})
|
||||
})
|
||||
+34
-4
@@ -1150,8 +1150,11 @@ describe('side card preferences', () => {
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 60,
|
||||
autoOpenSubagent: false,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: true,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
@@ -1168,8 +1171,11 @@ describe('side card preferences', () => {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: 33,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
@@ -1186,8 +1192,11 @@ describe('side card preferences', () => {
|
||||
openByDefault: false,
|
||||
defaultWidthPercent: 40,
|
||||
autoOpenSubagent: true,
|
||||
autoOpenJobs: true,
|
||||
agentTerminalTools: false,
|
||||
bottomPanelAutoTerminal: true,
|
||||
terminalFontFamily: '',
|
||||
terminalFontSize: 13,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
@@ -1205,6 +1214,11 @@ describe('side card preferences', () => {
|
||||
.toBe(false)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, agentTerminalTools: true }))).agentTerminalTools)
|
||||
.toBe(true)
|
||||
// The job auto-open is ON by default; only an explicit false turns it off.
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: 1 }))).autoOpenJobs)
|
||||
.toBe(true)
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenJobs: false }))).autoOpenJobs)
|
||||
.toBe(false)
|
||||
})
|
||||
|
||||
it('defaults interceptOpenPath to true; only an explicit false turns the takeover off', async () => {
|
||||
@@ -1217,6 +1231,22 @@ describe('side card preferences', () => {
|
||||
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves the terminal font prefs (family passthrough, size clamp)', async () => {
|
||||
// Absent → theme default (empty family) + default size.
|
||||
expect((await loadPrefs(wire({}))).terminalFontFamily).toBe('')
|
||||
expect((await loadPrefs(wire({}))).terminalFontSize).toBe(13)
|
||||
// A custom family survives verbatim; a malformed one falls back.
|
||||
expect((await loadPrefs(wire({ terminalFontFamily: '"JetBrains Mono", monospace' }))).terminalFontFamily)
|
||||
.toBe('"JetBrains Mono", monospace')
|
||||
expect((await loadPrefs(wire({ terminalFontFamily: 42 }))).terminalFontFamily).toBe('')
|
||||
// The size clamps into 9–32 (rounded); non-numbers fall back.
|
||||
expect((await loadPrefs(wire({ terminalFontSize: 5 }))).terminalFontSize).toBe(9)
|
||||
expect((await loadPrefs(wire({ terminalFontSize: 40 }))).terminalFontSize).toBe(32)
|
||||
expect((await loadPrefs(wire({ terminalFontSize: 15.6 }))).terminalFontSize).toBe(16)
|
||||
expect((await loadPrefs(wire({ terminalFontSize: 'big' }))).terminalFontSize).toBe(13)
|
||||
expect((await loadPrefs(wire({ terminalFontSize: 18 }))).terminalFontSize).toBe(18)
|
||||
})
|
||||
|
||||
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
|
||||
// A non-object map falls back to {} (everything enabled).
|
||||
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
|
||||
@@ -1234,9 +1264,9 @@ describe('side card preferences', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
@@ -1272,7 +1302,7 @@ describe('side card preferences', () => {
|
||||
|
||||
it('skips the default explorer tab when the explorer type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
|
||||
store.setSession('no-explorer')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
@@ -1280,7 +1310,7 @@ describe('side card preferences', () => {
|
||||
expect(state.splits.kind).toBe('leaf')
|
||||
// Re-enabling seeds the explorer tab again.
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
openStore.setSession('with-explorer')
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
|
||||
|
||||
Reference in New Issue
Block a user