Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0965e1d61 | ||
|
|
f268d35f8b | ||
|
|
ea70dc97eb | ||
|
|
b8e0840f3f | ||
|
|
3004604a2d | ||
|
|
2d2b42722a | ||
|
|
c62ba4a565 | ||
|
|
0fdf63b081 | ||
|
|
6cea4bdd6f | ||
|
|
a9d3451415 | ||
|
|
0da2812dd3 | ||
|
|
7f4f84c4cf | ||
|
|
50a888845f | ||
|
|
4e52923970 | ||
|
|
bb2456bea9 | ||
|
|
5cc1a4b52e | ||
|
|
e8d04980b4 |
@@ -37,9 +37,9 @@ body:
|
||||
attributes:
|
||||
label: DSH Web 版本 + 浏览器 / DSH Web version + Browser
|
||||
description: |
|
||||
例:DSH 0.1.0-rc.8 + Chrome 151(macOS)。
|
||||
Example: DSH 0.1.0-rc.8 + Chrome 151 (macOS).
|
||||
placeholder: "DSH 版本 / version + 浏览器 / browser(如 0.1.0-rc.8 + Chrome 151)"
|
||||
例:DSH 0.1.1-rc.1 + Chrome 151(macOS)。
|
||||
Example: DSH 0.1.1-rc.1 + Chrome 151 (macOS).
|
||||
placeholder: "DSH 版本 / version + 浏览器 / browser(如 0.1.1-rc.1 + Chrome 151)"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
# (dsh-base / dsh-web-app) resolves from this installation. Keep in
|
||||
# step with the plugin's @deepseek-ai/* peer range when bumping.
|
||||
- name: Install dsh CLI (pinned)
|
||||
run: npm install -g @deepseek-ai/dsh@0.1.0-rc.8
|
||||
run: npm install -g @deepseek-ai/dsh@0.1.1-rc.2
|
||||
|
||||
# lib/ is gitignored; the tarball is produced from this build.
|
||||
- name: Build and pack the npm tarball
|
||||
|
||||
@@ -21,7 +21,7 @@ better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context
|
||||
2. `scripts/e2e-mount.sh` 用官方 CLI 把它装进一个**全新 scratch profile**(`dsh plugin --profile web add file:<tarball>`,触发 `dsh.profile.bundles` 协调),然后启动真实 `dsh web`(keyless,`--port 0`)。
|
||||
3. `tests/e2e/mount.e2e.ts`(Playwright Chromium)加载页面,断言外壳与 `[data-dsh-better-sidebar]` 挂载、无 `dsh-better-sidebar:` 错误条、无 pageerror/插件 console 错误,显式展开面板(`openByDefault` 默认关)后通过「+ 菜单」逐个打开内置 tab(含终端懒加载 chunk)深扫,再经 Files 文件窗口的树打开 seed 文件强制加载 editor 懒加载 chunk(`client-editor.js`,独立模式:每个文件新开 tab,seed 的 home tab 保持资源管理器)——缺失的内置 tab 或 chunk 都会使门禁变红。
|
||||
|
||||
本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.0-rc.8`(与插件 peer 范围同步)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。
|
||||
本地跑:`pnpm build && pnpm pack && pnpm exec playwright install chromium && pnpm test:mount`(需 PATH 上有 `dsh` 或可经 npx 拉取)。DSH CLI 版本在 CI 钉住 `@deepseek-ai/dsh@0.1.1-rc.2`(挂载冒烟验证基线;peer 下限保持 `^0.1.0-rc.8`,rc.8 与 0.1.1-rc.x 双向兼容)。`tests/e2e` 的 spec 命名 `*.e2e.ts` + vitest `exclude` 双保险与 vitest 隔离;**改动 vitest `exclude` 时必须保留默认排除项**(`exclude` 会整体替换默认值)。
|
||||
|
||||
### npm 发版(GitHub Release → npm publish)
|
||||
|
||||
@@ -353,6 +353,7 @@ ctx.effect(() => {
|
||||
| `editor` | 10 | 否(按 path 去重) | 否 | 唯一的「文件窗口」(文件编辑/预览 + 文件资源管理):文件 tab(有 path)在两种 `editorExplorer` 模式下 chrome 恒为合并形态——头部路径输入框 + 文本编辑器预览/编辑/保存控件 + 可开关的内嵌文件树面板(含全局文件名搜索,走 host `fs.search` 路由;左缘拖拽调宽),状态存 `tab.meta.treeOpen` / `tab.meta.treeWidth`;pref 控制**打开行为与无路径窗口形态**:关(默认,独立)= 走 `openSidebarFile` 按 path 新开,**无路径窗口即独立资源管理器——只渲染文件树面板**(搜索 + FileTree 撑满全窗,无编辑器 chrome);开(合并)= 树点击/输入框 Enter 原地切换当前 tab(`updateTab` 重写 path/title,id 与 meta 不变),无路径窗口 = 带 chrome 的空文件窗口(树默认展开)。树右键菜单提供「在新 Tab 中打开」「在侧边打开」(后者在当前 pane 右侧 split 出新 editor tab)。新会话在两种模式下都默认 seed 空文件窗口(`title: 'Files'`,无 path,树面板展开);持久化的旧 `explorer` tab 经 `sanitizeState` 迁移为该 home tab |
|
||||
| `git` | 20 | 是 | 否 | Git 面板 |
|
||||
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
|
||||
| `sidechat` | 35 | 否(createTab 铸造 `sidechat:<uuid>`/按 `meta.threadId` 去重) | 否 | 侧边对话(Codex 风格,**每个对话一个独立 Tab**):打开 Tab 即自动创建空线程(composer 拥有首条消息,host 侧包裹边界提示 + 创建时停泊的进行中快照,首条消息赢得真实标签并同步 Tab 标题);线程 = 插件自建子会话(自定义种子继承父会话完整上下文,进行中回合以 `interrupted` 冻结诚实闭合;种子尾部带合法 `subagent/descriptor`——否则 cold 线程在宿主 subagents.list 里是 corrupt 诊断行——SubagentView/subagent-detect 按 `Side: ` 前缀过滤保持拓扑零噪音),`origin:'subagent'` 隐藏于主列表;生命周期走自有 `/sidebar/api/sidechat.*` 路由(`ctx.agents.create/resume` + `agent.followup/cancel` + `sidechat.info` 读 Agent 身份/状态);头部菜单可切换/重开既有线程(`parkSidechatReopen` + `sidechat:<threadId>` 确定性 id),关闭 Tab 释放 live agent(历史保留);重开 Tab 经 `collectOwnEvents` 大页回源到种子边界(cold 读会展开 chunk 压缩行,小窗口会丢早期 tool/call 行);「保存为新会话」= `session.fork` 提升顶层会话(必须方法调用形态,`this` 敏感)。UI 对齐主对话区(用户气泡 `--dsw-specific-bubble`、assistant 通栏 markdown、胶囊 composer + 圆形发送/停止钮、运行扫光状态行)。见 [设计文档](docs/plans/2026-08-20-sidechat-tab-design.md) |
|
||||
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
|
||||
| `browser` | 50 | 否(createTab 铸造 browser:`<n>`,nextBrowser 自增) | 否 | 内嵌网页浏览器(沙箱 iframe;可设置关闭沙箱) |
|
||||
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
|
||||
@@ -568,7 +569,7 @@ interface OpenTabSeed {
|
||||
}
|
||||
```
|
||||
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时齿轮隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——齿轮弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有齿轮)。
|
||||
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,启用卡片最右端渲染紧凑品牌开关滑块,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`、终端的 `terminalFontFamily`/`terminalFontSize`)通过卡片底部的「功能设置」设置条(齿轮图标 + 文字标签,hairline 分隔)在**原生弹窗**中编辑——`type: 'switch'` 行是复选框,`type: 'text'`/`'number'` 行是输入框(v0.11.0+),`type: 'select'` 行是下拉选择(options 带 icon 时为大图标选项卡,`multi: true` 多选存 value 数组)——父级卡片关闭时设置条隐藏;`settings.toggles` 的 **key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools` / `terminalFontFamily` / `terminalFontSize` / `editorExplorer` / `htmlViewerNoSandbox` / `htmlViewerDefaultUnsafe` / `browserNoSandbox` / `browserInterceptLinks` / `browserInterceptHttp` / `browserInterceptHttps`)。**v0.12.0 起设置 seam 已开放**:外部插件用 `settings.pluginToggles`(同款行控件,key 插件局部)或 `settings.render`(自定义面板)声明自己的设置,值持久化在 prefs 文档的 `pluginSettings[<descriptor id>]`(开放 map,宿主 schema 已有字段,无需注册)——设置弹窗对 tab 与 viewer 都可用(viewer 卡片 v0.12.0 起也有设置条)。
|
||||
|
||||
---
|
||||
|
||||
@@ -606,8 +607,15 @@ interface OpenTabSeed {
|
||||
- **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`(通用卡片表面)。**绝不消费 `--dsw-specific-sidebar-fill`**——那是宿主左侧导航列专属令牌,皮肤系统按左导航语义覆盖它(dsh-web-ui 皮肤把它做成半透明玻璃或主题色,Aqua 设成 `transparent`),面板消费它会失去填充或与标签令牌冲突。皮肤要整体换面板表面:覆写 `--dsw-alias-bg-layer-1` 即可(dsh-web-ui 10 款皮肤都已覆盖,无需任何额外工作)。
|
||||
- **终端/编辑器表面**:经 `effectiveTokenValue` 读取 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明玻璃值(dsh-web-ui 皮肤用 rgba 0.16–0.7)一律回退不透明底色,文字永不叠在皮肤背景画上滚动(issue #90);≥ 0.9 的近不透明值(如皮肤作用域内 0.96 的瓷器玻璃)放行,皮肤仍能控制终端表面。
|
||||
- **根锚点**:宿主 div 带 `data-dsh-better-sidebar` 属性(append 到 `document.body`)。其内是**统一面板宿主层** `[data-dsh-panel-host]`(`position:fixed; inset:0; z-index:40; pointer-events:none`,v0.13.1+):面板/开关簇在其内 **absolute** 定位(层 inset:0 即视口坐标),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;页面级 transform(罕见)触发 `data-dsh-panel-host-degraded` 降级同步。皮肤若要做作用域覆盖(deep-whale 的做法),限定在 `[data-dsh-better-sidebar]` 内即可,避免全局改写影响宿主。
|
||||
- **布局变量**(写在 `<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`(面板几何;拖拽期间逐帧更新)。宿主推挤 = `#root` 的 `margin-right/width: calc(100% - var)`(v0.13.1+ 防桌面壳加性溢出)与 centerCol 的 `margin-bottom`;推挤锚点是 **`#root [data-dsh-frame] > [data-pane="conversation"]`**(稳定 data 属性,**禁止**退回 `nth-child` 位置锚定)。
|
||||
- **桌面信号**(v0.13.1+):DSH Desktop 壳在 URL 注入 `dsh-desktop-mode` / `dsh-desktop-platform`(preload 另暴露 `__DSH_DESKTOP_FILE_PATH__`);win32 `advanced` 壳右上角有 32px 窗口控制 overlay,插件自动启用标题栏兼容(`body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip: 32px`),手动 pref 可覆盖;`compatibility` 模式与无信号环境不避让。
|
||||
- **布局变量**(写在 `<html>` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`(面板几何;拖拽期间逐帧更新)。宿主推挤 = `#root` 的 `margin-right/width: calc(100% - var)`(v0.13.1+ 防桌面壳加性溢出)与 centerCol 的 `margin-bottom`;推挤锚点是**复合选择器**(同一元素双保险,禁止退回 `nth-child` 位置锚定):`#root [data-dsh-frame] > [data-pane="conversation"]` **与** `#root :has(> [data-slot="conversation"])`(0.1.x 命名 / rc.8 时代命名,live 页面实测同元素;`drag-layout.e2e.ts` 断言两者同元素)。
|
||||
- **桌面信号与标题栏兼容**(v0.14.1+ 四选项模型,取代旧"win32 advanced 自动 32px"硬编码):
|
||||
- 壳信号(只读报告,不自动触发任何修改):URL `dsh-desktop-mode` / `dsh-desktop-platform`(DSH Desktop 壳注入;preload 另暴露 `__DSH_DESKTOP_FILE_PATH__`);可选契约参数 `dsh-desktop-titlebar-inset`(壳声明自己预留的顶栏像素,0–120,clamp;`parseDesktopEnv().titlebarInset`)。
|
||||
- **strip 取值链**(`src/client/titlebar-strip.ts` 唯一决策点,纯函数):⓪ `web` 方案强制 0(显式 DSH官方Web,连 WCO 也不适用);① `navigator.windowControlsOverlay` 标准 API 真实几何(`src/client/wco.ts`,反应式订阅 `geometrychange`,最大化/还原实时更新——**即使为 0 也权威**,`visible=false` 的幽灵 API 视为缺失,覆盖 #257 的 32/36px 差异);② URL `dsh-desktop-titlebar-inset`;③ 壳预设 `stripFor`(仅 `preset` 方案);④ 手动 `titleBarStripPx`(仅 `custom` 方案);⑤ 0(网页零修改)。结果驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`(语义与旧布尔一致)。
|
||||
- **四选项设置**(`SidebarPrefs.titleBarScheme`,设置页主行**下拉**:`auto` 默认 / `web` / `preset` / `custom`):`auto` = 只信标准信号(WCO),壳 stamp 不触发任何修改——**"为某壳做的兼容在另一个壳会再坏",核心不做壳专属分支**;`web` = 显式「DSH官方Web」,任何情况强制 0;`preset` = 数据驱动的内置壳预设(`src/client/shell-presets.ts`,准入规则:issue/PR 中被提及且 GitHub ⭐>100;v1 仅 anywhere-labs DSH Desktop:darwin advanced 20px、win32 无 WCO 时 32px 兜底;下拉选项命中环境时带「已检测」后缀,仅提示、绝不自动启用);`custom` = 用户自定义 CSS + 手动 strip px(下拉选中后出现齿轮按钮,弹窗含下移距离与 CSS 输入)。
|
||||
- **旧文档迁移**:schema 中 `titleBarScheme` 无默认值(缺失才可迁移);旧文档**已有值**(`titleBarCompat === true` 或 `titleBarStripPx` 非默认 40)→ 迁移到 `custom` 方案保留用户数值;干净文档 → `auto`。
|
||||
- **用户空间 CSS**:预设 css(`<style data-dsh-preset-css>`)与自定义 css(`<style data-dsh-custom-css>`)经 `Sidebar.tsx` effect 注入到 `document.head` 末尾(后写胜出;覆盖 JS 内联变量需 `!important`),fiber 卸载/变更即移除。稳定寻址面(皮肤/预设/自定义 CSS 共用):`[data-dsh-toggle-cluster]`、`[data-dsh-panel]`(右面板)、`[data-dsh-bottom-panel]`(底面板)。
|
||||
- **拖拽区退出**:插件交互 chrome(`.toggleCluster` / `.toggleButton` / `.tabBar`)统一 `-webkit-app-region: no-drag`——无边框壳的顶部拖拽带会吞点击(#103/#111),该属性在普通浏览器与无拖拽区壳中惰性无害。
|
||||
- `compatibility` 模式与无信号环境不避让。
|
||||
- **z-index**:面板宿主层 40、折叠按钮簇 45(角手柄在面板内层叠,z-index 2 仅面板内有效)——全部低于 DSH 浮层栈(100/1000+),任何浮层天然盖住侧边栏。
|
||||
|
||||
### 8.2 注意事项
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
|
||||
- **🧩 后台任务页**:subagent 拓扑 + 后台任务(退出码 / 实时输出 / 强制终止)
|
||||
- **💬 侧边对话(beta)**:Codex 风格的侧边线程——继承主会话完整上下文(含进行中的回合与工具调用)独立运行,不进入主会话;线程内可持续追问,一键「保存为新会话」提升为顶层会话
|
||||
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
|
||||
@@ -42,13 +43,33 @@
|
||||
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="33%" alt="添加插件截图" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
|
||||
</div>
|
||||
|
||||
### v0.14.0
|
||||
### v0.15.0
|
||||
|
||||
> ⚠️ **本版适配 DSH 0.1.0-rc.8**:全部 `@deepseek-ai/*` peer / devDependencies 升至 `^0.1.0-rc.8`(含传递依赖,lockfile 零 rc.7 残留),`cordis` 同步 `^4.0.0-rc.8`,CI 挂载冒烟钉版 `@deepseek-ai/dsh@0.1.0-rc.8`。**rc.7 及更早的 DSH 环境将无法解析本版依赖,请先升级 DSH**。自 v0.13.1 以来的全部更改:
|
||||
> ⚠️ **本版验证基线为 DSH 0.1.1-rc.2(@next)**:devDependencies / lockfile / CI 挂载冒烟基线升至 `0.1.1-rc.2`(lockfile 零 rc.8 残留);**peer 下限保持 `^0.1.0-rc.8`**——0.1.1-rc.x 为常规 rc 递进、与 rc.8 双向兼容,**rc.8 用户无需升级 DSH**。`cordis` 保持 `^4.0.0-rc.8`。自 v0.14.0 以来的全部更改:
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 🖼️ **统一面板宿主注入重构**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):面板/开关簇迁入 `[data-dsh-panel-host]` 固定含块层(`fixed inset-0 z-40`),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;挂载自检(页面级 transform → `data-dsh-panel-host-degraded` 降级同步,按未修正几何判定、祖先变换消失才退出);推挤锚点改 `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc 宽度防桌面壳加性溢出;chunk 激活重验证(HEAD+ETag 保留未变 chunk,5s 超时兜底 fail-open);桌面信号自动探测(win32 advanced 标题栏兼容 32px 避让,手动 pref 可覆盖);`visualViewport` 键盘 inset + `env(safe-area-inset-*)` 移动端适配
|
||||
- 💬 **侧边对话(beta) Tab**([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)):Codex 风格的侧边线程,**每个对话一个独立 Tab**——子会话继承主会话完整上下文(已完成回合 + 未回答消息 + 进行中回合的 assistant 输出与工具调用,以「interrupted」冻结标记诚实继承);同组合创建(同 preset / provider / model)复用前缀输入缓存;线程对主会话列表不可见、零子代理目录噪音;线程内可持续追问(重启后自动冷恢复);一键「保存为新会话」提升为顶层会话([设计文档](docs/plans/2026-08-20-sidechat-tab-design.md))
|
||||
- 📤 **文件窗口上传**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):头部「上传文件 / 上传文件夹」按钮 + 拖放上传(拖到树区 = 工作区根,目录行 = 进该目录,文件行 = 进其所在目录,对齐 VSCode);上传时全屏模糊进度弹层(文件级进度 + 取消 / Esc);上传中按钮禁用、成功后文件树自动刷新
|
||||
- 🧩 **桌面兼容四选项**([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)):位置兼容模式改为**主行下拉**——**自动检测**(默认,保守:仅使用标准的 Window Controls Overlay 几何,32/36px 等各壳差异自动跟随、最大化/还原实时更新,网页环境零修改)/ **DSH官方Web**(显式零适配)/ **壳兼容方案**(内置预设,手动启用;只收录 issue/PR 中出现过且 100+ star 的壳,命中环境带「已检测」提示)/ **自定义方案**(自定义 CSS + 下移距离)。旧版本已有兼容配置的用户自动落到自定义方案;交互控件统一退出桌面拖拽区(`no-drag`);底栏推挤锚点复合选择器双保险(`[data-pane]` 与 `:has(> [data-slot])`)
|
||||
- 🎛️ **设置页 UI/UX 现代化**([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)):侧边卡片二级设置入口改为卡片底部「功能设置」设置条(替代右下角隐形齿轮,可发现性提升);协调双色启用态(brand 激活强调 + success 绿勾选徽标);全部颜色仍为 `--dsw-alias-*` 令牌派生,皮肤体系自动跟随
|
||||
- ➕ **推荐插件目录新增**:`dsh-docs-panel` 全局文档面板([#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230))、`dsh-flowglass`([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261))、`dsh-git-forge` 与 `dsh-ssh-tunnel`([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204))、`dsh-turn-review`([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
|
||||
|
||||
**🐛 修复**
|
||||
|
||||
- ⚡ **子代理页实时预览批量接口**([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)):旧实现每个 running 子代理独立轮询 `subagents.history`,host 侧每次触发全量子代理枚举形成 O(N²) 放大、多子代理并发时页面卡顿——改为单个批量接口 `subagents.live`(一次枚举整棵子代理树)+ 客户端单轮询、单在途请求;展示逻辑与文案不变
|
||||
- 🖱️ **拖拽中断 / 快速释放不再回滚**([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249),关闭 [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)):中断 / 快速释放提交最后已知位置;HMR 后中心列重定位兜底(修复热更新后底栏空白)
|
||||
- 📐 **推挤变量挂载期持续有效**([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259),修复 [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)):拖拽松手后底边栏不再闪全宽
|
||||
- 🔧 **适配 DSH 0.1.1-rc.1 / rc.2(@next)**([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)):devDependencies / lockfile / CI 验证基线平移;逐包比对所有既有契约(DOM 槽位、CSS 令牌、boot 协议、MarkdownText `codeLabels`、`webServer` 路由注册)零破坏,**无代码逻辑改动**;peer 下限保持 `^0.1.0-rc.8`
|
||||
- 🔒 **上传链路安全加固**([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)):`relativePath` 空段 / 绝对路径显式拒绝;临时文件唯一命名(并发上传互不干扰、崩溃不阻塞);写流错误监听(磁盘失败不崩溃进程);客户端错误码与服务端统一、413 本地化
|
||||
|
||||
### v0.14.0
|
||||
|
||||
> ⚠️ **本版适配 DSH 0.1.0-rc.8**:全部 `@deepseek-ai/*` peer / devDependencies 升至 `^0.1.0-rc.8`(含传递依赖,lockfile 零 rc.7 残留),`cordis` 同步 `^4.0.0-rc.8`,CI 挂载冒烟钉版 `@deepseek-ai/dsh@0.1.0-rc.8`。**rc.7 及更早的 DSH 环境将无法解析本版依赖,请先升级 DSH。** 自 v0.13.1 以来的全部更改:
|
||||
|
||||
**✨ 新功能**
|
||||
|
||||
- 🖼️ **统一面板宿主注入重构**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):面板/开关簇迁入 `[data-dsh-panel-host]` 固定含块层(`fixed inset-0 z-40`),免疫桌面套壳中间层 transform 对 fixed 含块的劫持;挂载自检(页面级 transform → `data-dsh-panel-host-degraded` 降级同步,按未修正几何判定、祖先变换消失才退出);推挤锚点改 `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc 宽度防桌面壳加性溢出;chunk 激活重验证(HEAD+ETag 保留未变 chunk,5s 超时兜底 fail-open);`visualViewport` 键盘 inset + `env(safe-area-inset-*)` 移动端适配
|
||||
- 📂 **文件打开方式默认独立**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):`editorExplorer` 默认从「合并」改为「独立」——新会话树点击 / 打开文件按路径**新开**文件 tab,无路径窗口即纯资源管理器;合并模式保留为可选手动开启
|
||||
- 🖥️ **终端 shell / shellArgs 设置页可配**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):终端卡齿轮二级页面新增「Shell 路径」「Shell 参数」两行配置(此前只能通过 `cordis.patch.yml` 配置)——设置页写入后对**之后打开的** UI 终端与模型终端(`terminal_create`)即时生效;留空保持 yaml → `$SHELL` / 登录 shell / `powershell.exe` 的既有解析顺序
|
||||
- 🏷️ **设置页版本徽标**([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)):侧边卡片设置页顶部新增 `DSH-better-sidebar v0.14.0` 身份徽标(版本与服务实例同步,由测试守护)
|
||||
@@ -218,7 +239,7 @@ v0.12.1 补齐基座能力(完整类型导出、能力探测、状态订阅、
|
||||
## 🛠️ 开发与构建
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* 已发布到 npm(^0.1.0-rc.8),直接解析、无需令牌
|
||||
pnpm install # @deepseek-ai/* devDependencies 已发布 0.1.1-rc.1,直接解析、无需令牌
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
|
||||
|
||||
+26
-5
@@ -26,6 +26,7 @@
|
||||
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
|
||||
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
|
||||
- **🧩 Background Tasks**: agent topology + background tasks (exit codes / live output / force-kill)
|
||||
- **💬 Side Chat (beta)**: Codex-style side threads — the child inherits the parent's FULL context (completed turns + the pending question + the in-progress turn's assistant output and tool activity, honestly frozen as "interrupted") and runs independently without entering the main conversation; threads support continuous follow-ups (auto-resumed after a DSH restart) and one-click "Save as new session" promotion to a top-level session
|
||||
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
|
||||
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
|
||||
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
|
||||
@@ -41,13 +42,33 @@
|
||||
<a href="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e"><img width="45%" alt="Add Plugins screenshot" src="https://github.com/user-attachments/assets/d4385b7e-aab4-425d-a5c4-2da5da81a34e" /></a>
|
||||
</div>
|
||||
|
||||
### v0.14.0
|
||||
### v0.15.0
|
||||
|
||||
> ⚠️ **This release adapts to DSH 0.1.0-rc.8**: every `@deepseek-ai/*` peer/devDependency is raised to `^0.1.0-rc.8` (transitives included — zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`, and the CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`. **DSH environments on rc.7 or earlier can no longer resolve this release's dependencies — upgrade DSH first.** All changes since v0.13.1:
|
||||
> ⚠️ **This release is verified against DSH 0.1.1-rc.2 (@next)**: devDependencies / lockfile / CI mount-smoke baseline raised to `0.1.1-rc.2` (zero rc.8 leftovers in the lockfile); **the peer floor stays at `^0.1.0-rc.8`** — 0.1.1-rc.x is a routine rc progression, mutually compatible with rc.8, so **rc.8 users don't need to upgrade DSH**. `cordis` stays at `^4.0.0-rc.8`. All changes since v0.14.0:
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 🖼️ **Unified panel-host injection refactor** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): panels/toggle clusters moved into a `[data-dsh-panel-host]` fixed containing block (`fixed inset-0 z-40`), immune to desktop-shell intermediate transforms hijacking `fixed`; mount self-check (page-level transform → `data-dsh-panel-host-degraded` degraded sync, judged on uncorrected geometry, exits only when the ancestor transform is gone); push anchor switched to `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc width against desktop-shell additive overflow; chunk revalidation on activation (HEAD+ETag keeps unchanged chunks, 5s timeout fails open); desktop signal auto-detection (win32 advanced title-bar compat 32px avoidance, manual pref overrides); `visualViewport` keyboard inset + `env(safe-area-inset-*)` mobile adaptation
|
||||
- 💬 **Side Chat (beta) tab** ([#286](https://github.com/omdsh-dev/DSH-better-sidebar/pull/286)): Codex-style side threads, **one independent tab per conversation** — the child inherits the parent's full context (completed turns + pending messages + the in-progress turn's assistant output and tool activity, honestly frozen with an "interrupted" marker); created with an identical composition (same preset / provider / model) so the first request reuses the parent's input prefix cache; threads stay invisible in the main session list with zero subagent-catalog noise; follow-ups survive DSH restarts (auto cold-resume); one-click "Save as new session" promotes the thread to a top-level session ([design](docs/plans/2026-08-20-sidechat-tab-design.md))
|
||||
- 📤 **Upload into the files window** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): header "upload file / upload folder" buttons plus drag-drop (drop on the tree body = workspace root, on a directory row = that directory, on a file row = its parent directory, VSCode semantics); full-window blurred progress overlay while uploading (per-file progress + cancel / Esc); buttons disabled while busy, tree refreshes after the upload settles
|
||||
- 🧩 **Desktop compatibility in four options** ([#284](https://github.com/omdsh-dev/DSH-better-sidebar/pull/284)): "Position compatibility mode" is now a main-row dropdown — **Auto-detect** (default, conservative: only the standard Window Controls Overlay geometry contributes; real 32/36px caption-overlay heights per shell, live on maximize/restore; zero modification on plain web) / **DSH official web** (explicitly no adaptation) / **Shell preset** (built-in, opt-in; only shells that appeared in this repo's issues/PRs with 100+ stars, "detected" badge when the environment matches) / **Custom** (free-form CSS + shift distance). Documents that already carried compatibility values migrate to the custom scheme; interactive chrome opts out of desktop drag regions (`no-drag`); the bottom-push anchor is a composite selector (`[data-pane]` and `:has(> [data-slot])`)
|
||||
- 🎛️ **Settings page UI/UX modernization** ([#300](https://github.com/omdsh-dev/DSH-better-sidebar/pull/300)): the side-card secondary-settings entry is now a full-width "Feature settings" strip at the card bottom (replacing the invisible corner gear — much easier to discover); coordinated two-tone enabled state (brand activation accent + success-green check badge); every color is still `--dsw-alias-*` token-derived so skins follow automatically
|
||||
- ➕ **New entries in the recommended-plugin catalog**: `dsh-docs-panel` (global docs, [#230](https://github.com/omdsh-dev/DSH-better-sidebar/pull/230)), `dsh-flowglass` ([#261](https://github.com/omdsh-dev/DSH-better-sidebar/pull/261)), `dsh-git-forge` and `dsh-ssh-tunnel` ([#204](https://github.com/omdsh-dev/DSH-better-sidebar/pull/204)), `dsh-turn-review` ([#102](https://github.com/omdsh-dev/DSH-better-sidebar/pull/102))
|
||||
|
||||
**🐛 Fixes**
|
||||
|
||||
- ⚡ **Batched live preview for the subagent page** ([#298](https://github.com/omdsh-dev/DSH-better-sidebar/pull/298)): the old implementation polled `subagents.history` per running subagent, each poll triggering a full host-side enumeration — an O(N²) amplification that stalled the page with many concurrent subagents; now a single batch route `subagents.live` (one enumeration of the whole tree) plus one client poller with a single in-flight request; display logic and copy unchanged
|
||||
- 🖱️ **Interrupted / fast-release drags no longer roll back** ([#249](https://github.com/omdsh-dev/DSH-better-sidebar/pull/249), closes [#247](https://github.com/omdsh-dev/DSH-better-sidebar/issues/247) [#248](https://github.com/omdsh-dev/DSH-better-sidebar/issues/248)): interrupted or fast-released drags commit the last known position; HMR re-activation re-locates the center column (fixes the blank bottom panel after a hot reload)
|
||||
- 📐 **Push variables stay effective while mounted** ([#259](https://github.com/omdsh-dev/DSH-better-sidebar/pull/259), fixes [#258](https://github.com/omdsh-dev/DSH-better-sidebar/issues/258)): the bottom panel no longer flashes full-width after a drag is released
|
||||
- 🔧 **Adapted to DSH 0.1.1-rc.1 / rc.2 (@next)** ([#297](https://github.com/omdsh-dev/DSH-better-sidebar/pull/297) [#305](https://github.com/omdsh-dev/DSH-better-sidebar/pull/305)): devDependencies / lockfile / CI verification baseline shifted; every consumed contract (DOM slot, CSS tokens, boot protocol, MarkdownText `codeLabels`, `webServer` route registration) compared package by package with zero breakage — **no code changes needed**; peer floor stays at `^0.1.0-rc.8`
|
||||
- 🔒 **Upload-chain hardening** ([#239](https://github.com/omdsh-dev/DSH-better-sidebar/pull/239)): empty and absolute `relativePath` segments are refused outright; uniquely named temp files (concurrent uploads stay independent, crashed processes never block later uploads); write-stream error listeners (a failing disk can no longer crash the host); client error codes unified with the wire (`too-large`), 413s localized
|
||||
|
||||
### v0.14.0
|
||||
|
||||
> ⚠️ **This release adapts to DSH 0.1.0-rc.8**: all `@deepseek-ai/*` peer/devDependencies raised to `^0.1.0-rc.8` (transitives included; zero rc.7 leftovers in the lockfile), `cordis` to `^4.0.0-rc.8`, and the CI mount lane pins `@deepseek-ai/dsh@0.1.0-rc.8`. **DSH rc.7 and earlier cannot resolve this release's dependencies — upgrade DSH first.** All changes since v0.13.1:
|
||||
|
||||
**✨ New features**
|
||||
|
||||
- 🖼️ **Unified panel-host injection refactor** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): panels/toggle clusters moved into a `[data-dsh-panel-host]` fixed containing block (`fixed inset-0 z-40`), immune to desktop-shell intermediate transforms hijacking `fixed`; mount self-check (page-level transform → `data-dsh-panel-host-degraded` degraded sync, judged on uncorrected geometry, exits only when the ancestor transform is gone); push anchor switched to `#root [data-dsh-frame] > [data-pane="conversation"]` + `#root` calc width against desktop-shell additive overflow; chunk revalidation on activation (HEAD+ETag keeps unchanged chunks, 5s timeout fails open); `visualViewport` keyboard inset + `env(safe-area-inset-*)` mobile adaptation
|
||||
- 📂 **Separate file windows by default** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): `editorExplorer` now defaults to **separate** — tree clicks / file opens create a new tab per path and the path-less window is a pure file manager; merged mode stays available as an opt-in
|
||||
- 🖥️ **Terminal shell / shellArgs configurable from the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the terminal card's gear popup gains "Shell path" and "Shell arguments" rows (previously yaml-only via `cordis.patch.yml`) — saved values take effect immediately for terminals opened afterwards (UI terminals and model `terminal_create` alike); empty keeps the existing yaml → `$SHELL` / login shell / `powershell.exe` resolution order
|
||||
- 🏷️ **Version badge on the settings page** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): the side-card settings section now opens with a `DSH-better-sidebar v0.14.0` identity badge (version synced with the service instance, test-guarded)
|
||||
@@ -60,7 +81,7 @@
|
||||
- 🧩 **Chunk revalidation barrier hardening** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): HEAD revalidation gains a 5s timeout (fails open on a stuck route so the barrier can never wedge lazy loads); `resetChunks` clears a pending revalidation barrier
|
||||
- 🖱️ **Drag robustness** ([#232](https://github.com/omdsh-dev/DSH-better-sidebar/pull/232)): fast releases (browsers merge/lose pointermove bursts) commit the last known dragged position instead of rolling back; `pointercancel` / lost-capture interruptions keep the drag result too; the center column is re-measured right after commit (no mid-frame bottom-panel width jump); HMR re-activation re-locates the center column via an `<html>` style observer plus a retry when the bottom panel opens (fixes the blank bottom panel / shifted input bar after a hot reload)
|
||||
|
||||
### v0.13.1### v0.13.1
|
||||
### v0.13.1
|
||||
|
||||
**✨ New features**
|
||||
|
||||
@@ -249,7 +270,7 @@ The dashed cards at the end of the "Sidebar content" / "File viewers" grids in t
|
||||
## 🛠️ Development & Build
|
||||
|
||||
```sh
|
||||
pnpm install # @deepseek-ai/* resolved from npm (^0.1.0-rc.8, published) — no token needed
|
||||
pnpm install # @deepseek-ai/* devDependencies resolve to 0.1.1-rc.1 (published) — no token needed
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest (includes manifest consistency guard; build first)
|
||||
|
||||
@@ -146,3 +146,17 @@ isViewerEnabled(id: string): boolean
|
||||
- **声明**:`SidebarSettingsDeclaration` 增加 `pluginToggles`(声明式行,控件同 toggles 的 switch/text/number,值须 JSON 可序列化)与 `render?: (props: SidebarSettingsRenderProps) => ReactNode`(自定义面板:store/service/prefs/pluginSettings/updatePluginSetting/close;抛错被吞并显示内联错误)。
|
||||
- **渲染**:齿轮弹窗体抽为 `SettingsBody`(`render` 存在时优先,否则 toggles 行 + pluginToggles 行,后者把 pluginSettings 投影到 prefs 面复用 `FeatureSettingsRows`);`settingsFor` 状态从 `TabDescriptor | null` 扩为 `TabDescriptor | FileViewerDescriptor | null`——viewer 卡片 v0.12.0 起同样有齿轮设置弹窗(§4.6 原仅 tab)。
|
||||
- **向后兼容**:旧设置文档无 `pluginSettings` 字段 → schema default `{}`;`toggles` 行为与 v0.11.0 完全一致。
|
||||
|
||||
---
|
||||
|
||||
## 9. 设置页 UI/UX 现代化(feat/settings-sidecard-uiux)
|
||||
|
||||
纯视觉/交互入口改造,无 API 与持久化语义变化(`settings.toggles` / `pluginToggles` / `render` / Modal 条件挂载契约全部不变):
|
||||
|
||||
- **二级设置入口:齿轮角标 → 卡片底部设置条**。原 `.cardGear`(16×16px 三级色透明幽灵按钮,`absolute; top:46px` 像素钉位 + `.cardWithGear` padding 耦合)可发现性不足;改为卡片底缘的**全宽设置条** `.cardSettings`(hairline 分隔、齿轮图标 12px + 「功能设置」文字标签 11px/500,hover 换 `--dsw-alias-interactive-bg-hover-accent` 品牌淡底 + brand 字色)。可见性条件不变(仅父级启用且声明 settings);aria-label 模式 `"${title} ${t('settingsPopup')}"` 不变(测试契约)。常规行齿轮 `.rowGear` 升级为 22×22 带 border-l2 边框的芯片按钮(hover 同样 accent 引导)。
|
||||
- **启用态指示(后续迭代:勾选 → 紧凑开关滑块)**:启用卡片描边从全强度品牌色柔化为 `color-mix(button-primary-fill 45%)`。第一版曾把勾选徽标改为 `--dsw-alias-state-success-primary` 实心绿(绿 = 已开启确认,brand = 激活强调);按反馈再迭代为**紧凑品牌开关滑块** `.cardSwitch`(30×16 track + 10px thumb,开 = brand 填充 + thumb 反色,关 = 中性且不渲染滑块)——纯视觉指示,卡片本身仍是开关(`aria-pressed` 语义不变),不再使用 success 色。全部颜色仍为 `--dsw-alias-*` 令牌派生,零硬编码。
|
||||
- **卡片等高**:`.card` 统一 `min-height: 106px` + `.cardMain { flex: 1 }` + `.cardTop { min-height: 28px }`——带设置条的卡片(天然更高)与不带设置条的卡片、无图标卡片全部等高;`prefers-reduced-motion` 覆盖新增的 `.cardSwitchTrack/Thumb` 过渡。
|
||||
- **二级弹窗长列表**:`.popupRows` 增加 `max-height: min(56vh, 480px)` + 内部 `overflow-y: auto`(终端等声明 6 行设置的场景下弹窗壳体与 Done 按钮固定,仅行区滚动);行密度收紧(padding 12/14、gap 8)。
|
||||
- **节奏**:section gap 16 / group padding 20 / 网格 `minmax(180px,1fr)` gap 12;计数徽章改为 `accent-soft` 淡底 pill(versionBadgeTag 同款令牌配方);开关关态 thumb 降一档(label-tertiary)+ track 底 layer-2。
|
||||
- **动效纪律**:全部过渡 ≤160ms 且仅 background/border-color/color;`prefers-reduced-motion` 扩展覆盖 `.cardSettings` / `.rowGear` / `.popupRow`。
|
||||
- **清理**:合并 `.pluginEntries` 重复定义(原 L598 与 L795 两处,保留带 max-height/overflow 的一处)。
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# 桌面端兼容统一方案:四选项下拉(自动检测 / DSH官方Web / 壳预设 / 自定义 CSS)
|
||||
|
||||
> 2026-08-20 · 对应分支 `feat/desktop-scheme-settings` · 关联 issue #257、#208、#103/#111;外部 PR #82/#89/#111/#153/#226/#243 处置见文末。
|
||||
|
||||
## 背景与问题
|
||||
|
||||
better-sidebar 的桌面端适配历史上是"每壳一条补丁":右上角开关簇与原生标题栏按钮重叠(#87/#101)、点击被拖拽区吞掉(#103/#111)、推挤锚点随 DSH DOM 版本漂移(#208/#226/#262)、WCO 高度硬编码 32px 与实际不符(#257,Electron 版本间 32/36px 不一)。这些补丁全部针对**具体某个壳的 DOM/类名/数值**,换一个壳(Tauri、Pake、新壳)就失效或出错。
|
||||
|
||||
**全仓证据盘点**(121 issues + 159 PRs 全量 + 三个候选壳克隆取证):
|
||||
|
||||
| 壳 | ⭐ | issue/PR 提及 | 形态 | 需要什么 |
|
||||
|---|---|---|---|---|
|
||||
| anywhere-labs/deepseek-harness-desktop | 16.3K | ✅ 大量 | Electron;advanced: win32 WCO 32px / darwin caption 20px;compat 原生框 | win32 已被标准 WCO 覆盖;darwin 20px 让位 |
|
||||
| dataelement/dsh-desktop | 1.3K | ❌ 零提及 | Electron;win32 WCO 36px | 已被标准 WCO 覆盖 |
|
||||
| Deepseek-Harness-EAC | 1.0K | ❌ 零提及 | Electron Win-only;自绘 36px 玻璃栏(私有标记 `data-dsh-title-bar-height`) | 无 WCO,需 36px 让位(文档示例) |
|
||||
| hairyf Tauri 版 | 715 | ✅ pr#229 | Tauri2;44px 导航栏在 iframe **之上** | **无冲突**,无需适配 |
|
||||
|
||||
平台分布结论:兼容问题**不是 Windows 独有**(macOS 6 条、Web 3 条),但当前 main 上未闭环的 chrome 兼容基本就是 **Windows 顶栏**(WCO 几何 + 通用 no-drag 兜底);macOS 的坏 bug(#25/#208)已修复。
|
||||
|
||||
## 决策
|
||||
|
||||
1. **核心只留 Web 标准机制**:WCO(`navigator.windowControlsOverlay` + `geometrychange`)是唯一"不是适配、是标准"的信号;`-webkit-app-region: no-drag` 是标准属性(普通浏览器/无拖拽区壳惰性无害)。核心代码 grep 不到任何壳类名。
|
||||
2. **壳专属适配进用户空间**:「位置兼容模式」升级为**主行下拉**四选项:**自动检测(默认,保守,web 零修改)/ DSH官方Web(显式零适配)/ 壳兼容方案(内置预设,opt-in)/ 自定义方案(自由 CSS + 下移 px,保留齿轮弹窗)**。
|
||||
3. **预设准入规则**:issue/PR 中被提及(链接或明确名称)且 GitHub ⭐>100 的壳;v1 仅 anywhere-labs(唯一同时满足者)。预设只做**标题栏 strip 让位**,不做布局推挤/壳类名(尊重"不特意兼容 DSH Desktop 高级模式")。Tauri 无冲突只写文档;EAC 未提及但提供 custom CSS 示例。
|
||||
4. 不向 anywhere-labs 提跨仓库 PR;不修改 DSH 源码。
|
||||
|
||||
## 实施
|
||||
|
||||
### prefs 模型(`src/prefs-shared.ts` / `src/config.ts` / `src/client/prefs.ts`)
|
||||
|
||||
- 新增 `titleBarScheme: 'auto' | 'web' | 'preset' | 'custom'`(默认 `'auto'`)、`titleBarPresetId: string`、`customCss: string`。`web` = 显式「DSH官方Web」,取值链最优先强制 0(连 WCO 也不适用)。
|
||||
- schema 中三个新字段**无 default**(schemastery 验证:缺失字段不出现在解析结果里)→ `parsePrefs` 在字段缺失时由旧 `titleBarCompat` 派生:`true` → `custom`(保留 `titleBarStripPx`);`false`/缺失 → `auto`。写路径镜像写旧字段(`titleBarCompat = scheme !== 'auto'`)供降级。
|
||||
|
||||
### 标准件(`src/client/wco.ts` + `src/client/titlebar-strip.ts`)
|
||||
|
||||
- `wco.ts`:模块级反应式 store(subscribe/getSnapshot,`useSyncExternalStore` 消费),特性检测 `navigator.windowControlsOverlay`,订阅 `geometrychange` 重读矩形(最大化/还原实时更新);最后订阅者退订时解绑原生监听;`setWcoSourceForTests` 注入测试源。API 抛错视为 present+0(绝不崩布局)。
|
||||
- `titlebar-strip.ts`(唯一决策点,纯函数):**⓪ `web` 方案强制 0 → ① WCO 真实高度(权威,0 也权威;`visible=false` 幽灵 API 视为缺失)→ ② URL `dsh-desktop-titlebar-inset`(0–120 clamp)→ ③ 预设 `stripFor`(仅 preset 方案)→ ④ 手动 `titleBarStripPx`(仅 custom 方案)→ ⑤ 0**。结果驱动 `body[data-dsh-title-bar-compat]` + `--dsh-title-bar-strip`(CSS 契约不变)。
|
||||
- `desktop-env.ts`:只做环境报告(desktop/mode/platform/titlebarInset),SSR 安全;移除旧 `win32OverlayTop` 硬编码。
|
||||
|
||||
### 预设与用户 CSS(`src/client/shell-presets.ts` + `Sidebar.tsx` effect)
|
||||
|
||||
- `ShellPreset { id; title; desc; stripFor?; css?; detect? }`;v1 一条 `dsh-desktop`:darwin advanced 20px、win32 advanced 32px(无 WCO 兜底)、其余 undefined;`detect` 仅用于设置页「已检测」徽标。
|
||||
- 注入:预设 css → `<style data-dsh-preset-css="<id>">`;自定义 css → `<style data-dsh-custom-css="custom">`;追加到 `document.head` 末尾(后写胜出),fiber 卸载/变更即移除。
|
||||
- 稳定寻址面:`[data-dsh-toggle-cluster]`、`[data-dsh-panel]`、`[data-dsh-bottom-panel]` data 属性。
|
||||
|
||||
### no-drag 兜底(`sidebar.module.css`)
|
||||
|
||||
`.toggleCluster` / `.toggleButton` / `.tabBar` 统一 `-webkit-app-region: no-drag`(吸收 #111/#153,补 #103 插件侧缺口)。
|
||||
|
||||
### Web 推挤锚点加固(`layout.css`)
|
||||
|
||||
复合选择器(同元素双保险):`#root [data-dsh-frame] > [data-pane="conversation"]` 与 `#root :has(> [data-slot="conversation"])`;拖拽过渡/prefers-reduced-motion 同步;`drag-layout.e2e.ts` 新增两选择器同元素断言。
|
||||
|
||||
### 设置 UI(`SideCardSection.tsx` + locales)
|
||||
|
||||
「位置兼容模式」行改为**主行下拉**:自动检测(默认)/ DSH官方Web / 各壳预设(命中环境带「已检测」后缀,仅提示)/ 自定义方案(选中后行内出现齿轮,弹窗 = 下移距离 + CSS textarea(Cmd/Ctrl+Enter 提交))。
|
||||
|
||||
### 测试
|
||||
|
||||
- 单测:`wco.spec.ts`(7 例:检测/读取/取整/geometrychange 通知/抛错降级/换源)、`titlebar-strip.spec.ts`(取值链 5 例)、`shell-presets.spec.ts`(注册表完整性 + dsh-desktop strip 值)、`desktop-env.spec.ts`(重写:inset 参数 clamp + 移除 win32 硬编码)、`prefs.spec.ts`(新字段 + 旧字段迁移)、`plugin-shape.spec.ts`(schema 无默认值语义)、`smoke.spec.ts`(解析结果不含新字段)。
|
||||
- e2e(`mount.e2e.ts` 重写桌面场景):保守 auto(stamp 不触发修改 + data 属性 + no-drag 规则存在)、WCO mock 驱动 strip 且 geometrychange 反应、预设注入(settings.update 写入 scheme=preset → 32px + style 标签)、自定义 CSS 注入(marker 生效);`drag-layout.e2e.ts` 复合锚点。
|
||||
|
||||
## 验证
|
||||
|
||||
- `pnpm typecheck` / `pnpm test`(691 passed)/ `pnpm test:mount` 全绿。
|
||||
- 普通浏览器零行为变化(auto 无 WCO → 无 strip)。
|
||||
- Electron WCO win32(mock 实测):strip=真实高度(32/36),最大化/还原跟随。
|
||||
- anywhere-labs darwin advanced:启用预设后 20px 让位。
|
||||
- 核心代码 grep 无壳类名(`.dshDesktopFrame` 等仅存在于预设数据字符串与测试断言之外——v1 预设无 CSS,纯 strip 数据)。
|
||||
|
||||
## 实施偏差记录
|
||||
|
||||
- 无。计划与实施一致;仅补充:schema 新字段必须无 default 才能让旧文档迁移(否则 settings 服务会把 default 填进解析结果、吞掉迁移),`plugin-shape.spec.ts` 断言了这一点。
|
||||
|
||||
## 存量 PR/issue 处置(合并后执行,均含哲学声明)
|
||||
|
||||
- 实现后关闭:#257(WCO 标准件)。
|
||||
- 关闭:#111/#153(no-drag 吸收)、#226(复合锚点吸收,main 已修)、#243(布局推挤不采纳;标题栏部分进 dsh-desktop 预设)、#108(#232 已吸收 + WCO)、#82/#89/#138(不采纳,指向自定义 CSS/预设路径)。
|
||||
- 回复:#208(已修+回归)、#221(自定义 CSS 可微调)、#233(git.ts windowsHide 独立问题)、#167/#240(非 chrome 范畴/文档已有)。
|
||||
@@ -0,0 +1,97 @@
|
||||
# 侧边对话(Side Chat)Tab 设计
|
||||
|
||||
**日期**:2026-08-20
|
||||
**状态**:已批准,实施中
|
||||
**作者**:opencode + 用户
|
||||
**当前版本**:v0.14.1(分支 `feat/sidechat-tab`)
|
||||
**目标版本**:v0.14.1(不 bump)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
1. 对标 Codex 的 side 对话,为 better-sidebar 新增内置 Tab **sidechat**:在当前主会话下开一个**继承主会话完整上下文**的独立子会话线程,线程在 Tab 内可续聊、可查看完整时间线(user / assistant / reasoning / tool)。
|
||||
2. **继承必须是结构化的**:线程种子 = 父会话点击时刻的全部事件——已完成回合 + 未回答的 user 消息 + **进行中回合的 assistant 流式输出与工具调用**(原样复制,合成 `step/end`+`turn/end{reason:'interrupted'}` 诚实闭合,子会话看到的是「被切断的进行中回合」而非完整定稿)。
|
||||
3. **前缀缓存复用**:子会话与父会话同组合(同 preset / 同 provider/model、无 persona/toolFilter、非 continuable 无 report 增量)→ 子会话首个请求的 token 前缀与父会话当前请求一致(到未回答 user 消息为止),命中 provider 前缀缓存。
|
||||
4. **即用即销语义 + 保存为新会话**:线程以 `origin:'subagent'` 创建(主会话列表不可见、子代理目录零噪音);用户可一键「保存为新会话」——`session.fork` 提升为顶层会话(自动命名、挂工作区、打开)。
|
||||
5. 零 dsh-subagent 依赖;dsh-subagent 缺失时功能不受影响。
|
||||
|
||||
## 2. 非目标(Out of Scope)
|
||||
|
||||
- 不做 `/btw` 一次性侧问(只做持续可续聊线程)。
|
||||
- 不做合并回主会话 / 重命名线程。
|
||||
- **不改 DSH 源码**:不加 promote RPC、不改 fork 边界、不改 continuation 机制。
|
||||
- 工具执行中途(悬挂 tool/call)的事件级继承不可行(provider 拒绝悬挂 assistant 调用)→ 该窗口回退为「截断 + 结构化文本转储」(见 §4)。
|
||||
|
||||
## 3. 为什么自建机制(重实现)
|
||||
|
||||
用户明确拍板:不走 `ctx.subagents.startContinuable`。原因(已核实源码):
|
||||
|
||||
1. **种子粒度受限**:fork provider 的 `completedTurnPrefix` 固定切在最后一个 `turn/end`(`subagent-fork-in-process/src/index.ts`),`session.fork` RPC 显式拒绝 open-turn 锚点(`core/session` `OPEN_TURN`)——进行中回合永远进不了种子。
|
||||
2. **continuable 组合破坏前缀复用**:continuable 子会话的请求头部在继承历史之前插入 `report` 工具 + `tool:report` 提示段(`.agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md`,issue #2124)——前缀从请求头即分叉,重发整个转录却零 cache 收益。DSH 官方因此把 fork 绑定为 one-shot。
|
||||
3. **无法注入自定义种子**:`ContinuableStartSpec` 无 seed 字段,创建路径无注入点。
|
||||
|
||||
自建机制只复用 DSH 公开 host 能力:`ctx.agents.create/resume`(AgentRegistry 公开方法,api-proxy fork 与 subagent driver 同用)、`agent.followup/cancel`(公开方法)、`ctx.get('agentPresets')`、`ctx.get('sessionTitle')`、`ctx.get('sessionPersistence')`。客户端复用 `ctx.sessions.list / fork / binding / open` 与通用 `session.history` RPC。
|
||||
|
||||
## 4. 种子算法(`src/sidechat-core.ts`)
|
||||
|
||||
1. 复制父会话**全部事件**(点击时刻快照;live 日志 seq === 数组下标,保持连续)。
|
||||
2. 日志结束于 turn 外(最后 turn 边界是 turn/end 或无 turn):种子 = 全部复制事件(可能以未回答 user 消息结尾,合法)。
|
||||
3. 结束于 open turn 内:
|
||||
- **当前 step 存在无配对 tool/call(悬挂)** → 回退:切到该 turn 的 `turn/start` 之前(保留已完成回合 + 未回答 user 消息);进行中内容(含执行中的工具调用,标注 executing)走 `buildOpenTurnSnapshot` 结构化文本转储并入 boundary 消息。原因:validator 不拒悬挂调用(step/end 清空 pendingCalls),但 **provider 拒绝悬挂 assistant 调用**(`core/session/src/repair.ts:108`)。
|
||||
- 否则:追加合成 `step/end{turn,step}`(若 step 未闭合)+ `turn/end{turn, reason:{kind:'interrupted'}}`(编号取自已复制事件的 turn/start、step/start)→ 种子平衡合法(种子逐事件过同一不变式检查器,`core/session/src/index.ts:517-541`;`turn/end` schema 要求 reason,`interrupted` 是合法持久化变体,语义即「回合未完成被切断」——诚实标记,客户端对冻结局部渲染「已停止」)。
|
||||
|
||||
子会话首个请求(常见路径)= [已完成回合][未回答 user][冻结的进行中回合:chunk 文本 + 已完成工具调用/结果][boundary+question]。前缀复用不受影响:共享前缀到未回答 user 消息为止,冻结回合位于共享前缀之后。
|
||||
|
||||
## 5. 线程生命周期(host 路由,`/sidebar/api/sidechat.*`,既有信任 fence)
|
||||
|
||||
- **start** `{sessionId, question}`:父 Agent 必须 live;`buildSidechatInheritance` 出种子 + 转储;`ctx.agents.create({meta:{cwd,parentSession,seedLength,origin:'subagent',delegationDepth:父+1,agentPreset}, seed, agentOptions:父 provider/model, setup:preset 组合})`;`agent.followup(createUserMessage({content:[boundary+转储?+question], source:{kind:'user'}}))`;`sessionTitle.rename(session,'Side: '+截断(question,48))` 钉死线程标签;返回 `{childId}`。
|
||||
- **prompt** `{childId, text}`:live → `followup`;absent(重启/关闭后)→ `ctx.agents.resume`(`resolvePresetId` 从 persisted header/events 解析 preset,`agentPresets.resolve/mount` 组合)→ `followup`。
|
||||
- **cancel** `{childId}`:`agent.cancel({kind:'user'},{keepInbox:true})`。
|
||||
- **dispose** `{childId}`:释放创建/恢复时保存的 AgentHandle disposer(会话与历史保留)。
|
||||
|
||||
为什么全走自有路由:`origin:'subagent'` 的会话被通用 RPC 的 agent-lookup 所有权 fence 全面封锁(`api/remotes/agent-lookup.ts` `hasApiRemoteSubagentOwner`),`subagents.prompt` 对我们的线程也无法寻址——自有路由直接调 registry/agent 绕开 fence,且只依赖公开方法。~~线程没有 subagent/descriptor(无目录注册、零噪音:`list-children.ts` 投影为 undefined 即丢弃)~~ **修正(v3 起)**:该判断只对 live 线程成立——cold 线程无 descriptor 时 `resolveColdIdentity` 确定性产出 `corrupt` 诊断行(`list-children.ts:405`),用户实测在 Subagent 页看到「目录损坏」。修复 = 线程种子尾部追加合法的 `subagent/descriptor`(`snapshotSubagentDescriptor`,version 2 / continuable / provider `'sidechat'` / label 即 `Side: ` 标题),线程成为目录里的健康行;SubagentView 与 `subagent-detect.ts` 按 `Side: ` 标签/标题前缀过滤,拓扑 UI 与自动打开触发保持零噪音(模型侧 `list_agents` 也会列出带标签的健康行,而非 corrupt 垃圾)。
|
||||
|
||||
## 6. 客户端 Tab(`src/client/SideChatView.tsx`)
|
||||
|
||||
- 内置 descriptor:`id:'sidechat'`、`order:35`、`single:true`、图标 `IconNewChatOutline16`。
|
||||
- 两栏布局:左线程列表(`sideThreadRows`:`origin==='subagent' && parentId===当前 && displayTitle 前缀 'Side: '`;订阅 `ctx.sessions.list`);右详情(时间线 + 输入框 + 保存/停止/关闭按钮)。
|
||||
- 时间线:轮询 `connection.api.sessions.history`(通用 RPC,未 fence;`session/end-seed` 截断 + boundary 行丢弃 + chunk 流式累加 + 工具配对——`src/client/sidechat-transcript.ts` 纯映射);可见且运行中 ~2s,seq 去重缓存;`visible=false` 停轮询。
|
||||
- 保存为新会话:`ctx.sessions.fork({sessionId: threadId, increaseTitle:true})` → `binding(newId).session.rename(标题去前缀)` → `ctx.sessions.open(newId)`。无已完成回合 → 禁用 + 提示;末条追问未完成 → 提示不包含(fork 边界=最后 turn/end)。
|
||||
- 选中线程持久化:`tab.meta.threadId`(既有 per-session localStorage 通道)。
|
||||
|
||||
## 7. 边界情况与失败模式
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 父会话无 live agent | 409 `sidechat-error` 内联提示 |
|
||||
| 父会话进行中(流式/思考/工具已返回) | 完整事件入种子 + interrupted 合成闭合 |
|
||||
| 父会话工具执行中途点击新建 | 悬挂 tool/call → 截断 + 结构化转储(工具标注 executing) |
|
||||
| seam 缺失(agentPresets/sessionTitle/sessionPersistence) | 降级:默认组合 / 自动标题 / 无 preset 组合的冷恢复 |
|
||||
| DSH 重启后续聊 / 线程关闭后追问 | `ctx.agents.resume`(persisted preset 组合) |
|
||||
| 线程无已完成回合 | 保存禁用 + 提示 |
|
||||
| 线程末条追问未完成 | 保存前提示不包含 |
|
||||
| 与 dsh-sidechain 并存 | 各自独立;双方线程都有 descriptor(目录健康行),SubagentView 按 `Side: ` 前缀过滤均不可见;双方共享 `Side: ` 标签约定,线程互见 |
|
||||
|
||||
## 8. 测试与自验
|
||||
|
||||
- 单测:`sidechat-core.spec.ts`(种子全分支:turn 外 / 空 / 仅 pending user / open turn 无工具 / 成对工具 / 悬挂回退 / 多 step 闭合 / seq 连续性;转储保真;线程过滤;保存资格;preset 解析;boundaryDelivered)、`sidechat-seed-validation.spec.ts`(真实 `Session.create` 校验器接受种子产物)、`sidechat-transcript.spec.ts`(种子截断、boundary 丢弃、chunk 流式、工具配对、孤儿失败工具)、`sidechat-routes.spec.ts`(start 带问/空问立即创建、首条 prompt 包裹边界+快照并改名、prompt live+cold、cancel、dispose、info live/cold/未知)、`builtins.spec.ts` 7 tab + sidechat 每线程一 Tab 铸造/去重。
|
||||
- e2e 挂载冒烟:+ 菜单深扫含 Side Chat;**host 路由级自验**(真实 keyless dsh web 中 sidechat.start/prompt/cancel/dispose/info 全链路 + 空问题立即创建流程,种子会话为父)。
|
||||
- 真实环境手测(实施后勾选):新建线程 → 追问往返 → 流式渲染 → 工具行 → 主会话流式中新建(冻结继承)→ 工具执行中途新建(回退转储)→ 重启后续聊 → 保存为新会话 → 刷新持久。
|
||||
|
||||
## 9. 复用门记录
|
||||
|
||||
搜索面:DSH checkout `docs/` + `packages/`(关键词 side conversation / sidetrack / thread / promote)+ dsh-external hub catalog(`search-org.mjs`,323 repos)。命中:`dsh-external/dsh-sidechain`(Codex 风格侧会话,/side & /btw,临时 fork + 侧栏面板),但**无 better-sidebar Tab、无保存为新会话**;用户拍板独立实现(「我们做独立实现」),sidechain 保持不动。本文档与 PR 记录该结论。
|
||||
|
||||
## 10. 实施偏差记录
|
||||
|
||||
- 路由键命名:`buildSidechatApi` 返回对象的键必须是**完整 wire 方法名**(`'sidechat.start'` 等,/sidebar/api 分发器按 `api[method]` 查找),而非短名 `start/prompt/…`。首次实现用了短名,单测直接调对象方法未暴露,真实挂载冒烟以 404 抓出(该冒烟因此新增了路由级自验,见 §8)。
|
||||
- 种子函数入参类型:sidechat-core 使用宽松结构类型 `SidechatLogEvent {type, seq, time, data: unknown}`(同时接受 host 真实 `SessionEvent` 与 client 的 `SidebarSessionEvent` 镜像),避免两半的类型图互相污染。
|
||||
- keyless 挂载冒烟无法验证 boundary 消息落盘:`agent.followup` 的消息先入 inbox,被 turn claim 后才写入 session 日志——keyless(无模型路由)下 turn 不会 claim。挂载冒烟的深度断言因此改为 **session.list 成员校验**(证明子会话真实创建,provider 无关);boundary 落盘与流式/回答在真实 provider 环境手测(§8 清单)。
|
||||
- **UI 两轮返工(用户实测驱动)**:
|
||||
1. 种子事件信封字段丢失(用户实测报 `invalid seed event … requires a surfaceOp marker`):`copyEvents` 重建事件时只保留 `{type,seq,time,data}`,剥掉了 `surfaceOp`/`sourceEventSeqs`/`ignorable`,而种子校验器要求 surface-eligible 事件必带 `surfaceOp`。修复 = 信封字段原样透传 + 新增 `sidechat-seed-validation.spec.ts`(用真实 `@deepseek-ai/dsh-session` 的 `Session.create` 校验器跑种子产物,回归守护)。
|
||||
2. 交互模型从「单 Tab + 内嵌线程列表 + 首问表单」改为 **Codex app 对齐的「每线程一 Tab」**:`createTab` 铸造 `sidechat:new-<uuid>`(meta `autoCreate`,视图挂载即调 `sidechat.start` 空问题立即建线程)或消费 `parkSidechatReopen` 停泊的 `sidechat:<threadId>` 重连 Tab;`dedupeKey = meta.threadId` 保证同线程聚焦不重复;`onClose` 释放 live agent(历史保留,头部菜单可重开)。配套后端变化:`sidechat.start` 的 `question` 变为可选(空 = 仅建线程,快照停泊在 `pendingSnapshots`);`sidechat.prompt` 检测边界未送达时(`boundaryDelivered` 扫日志)自动包裹边界 + 停泊快照并用首条消息改名;新增 `sidechat.info` 路由(live 状态 + provider/model/preset 身份,供头部 Agent 徽标)。UI 对齐主对话区:用户右对齐气泡(`--dsw-specific-bubble`)、assistant 通栏 markdown、胶囊 composer(`--dsw-specific-input-major` + 圆形 accent 发送/停止钮 + 自动增高 textarea)、运行扫光状态行与工具行(`StateDot` + shimmer),动效全部 ≤200ms 交叉淡化并受 `prefers-reduced-motion` 收敛。
|
||||
- (预留)实施过程中如有与本文档的其他偏差,在此记录。
|
||||
- **第三轮实测修复(4 bug)**:
|
||||
1. 「保存为新会话」报 `Cannot read properties of undefined (reading 'list')`:`handleSave` 把 `ctx.sessions.fork` 解构成自由函数再调用,丢失 `this`——client-runtime 的 `service.fork` 内部读 `this.list.getSnapshot()`(标题递增)。修复 = 保持方法调用形态(`ctx.sessions.fork({...})`)。
|
||||
2. 图标语义:线程切换菜单改自绘 `IconHistoryOutline16`(逆时针时钟)、保存按钮改自绘 `IconSaveOutline16`(软盘),沿用 1.5px 描边规范(primitives 无现成 history/save 图标)。
|
||||
3. 重开线程 Tab 丢工具行:根因**不在宿主**(`session.history` cold 路径原样返回 `tool/call`,见 `api-proxy.ts:749` 与 `chunk-rows.ts` 的无损 chunk 压缩)而在首挂回源窗口——旧实现 8 事件/页 × 32 页 = 256 事件上限,而 cold 读会把 chunk-rows 展开回每个流式 delta 一条事件,一轮回答动辄数百事件,较早的 `tool/call` 掉出窗口(settled 文本靠最终 `assistant/message` 幸存)。修复 = 抽出 `collectOwnEvents`(200 事件/页 × 40 页帽、日志穷尽/无标记时落定 `seedBoundary:0` 停止每轮重走),单测覆盖四分支。
|
||||
4. Subagent 页「目录损坏」行:见 §5 修正——种子追加 descriptor + SubagentView/subagent-detect 按前缀过滤(`isSideThreadSummary` 同时修好 autoOpenSubagent 误触发与计数膨胀)。旧版创建的线程仍是 corrupt 诊断行,由 SubagentView 的标题前缀过滤兜底隐藏。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git / 浏览器,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
|
||||
"engines": {
|
||||
|
||||
+16
-14
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.14.0",
|
||||
"version": "0.15.0",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git / browser), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
@@ -96,6 +96,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-llm": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-session": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-subagent": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
|
||||
"cordis": "^4.0.0-rc.8",
|
||||
@@ -138,19 +139,20 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||
"@deepseek-ai/dsh-agent": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-locale": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-runtime": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-invariants": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-llm": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-session": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-settings": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-tools": "0.1.0-rc.8",
|
||||
"@deepseek-ai/dsh-agent": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-locale": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-runtime": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-invariants": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-llm": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-session": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-settings": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-subagent": "0.1.1-rc.1",
|
||||
"@deepseek-ai/dsh-tools": "0.1.1-rc.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
|
||||
Generated
+1009
-981
File diff suppressed because it is too large
Load Diff
+245
-9
@@ -11,19 +11,27 @@
|
||||
* right (appends `@<relative path>` to the composer draft), and right-click
|
||||
* opens a context menu: file rows offer the caller's open escapes
|
||||
* (new tab / to the side, only when the callbacks exist) and a download
|
||||
* action (the host serves raw bytes, binary-safe); every row can copy the
|
||||
* relative or absolute path (with a brief "copied" label replacing the
|
||||
* button after a successful write).
|
||||
* action (the host serves raw bytes, binary-safe); directory rows offer
|
||||
* "upload here"; every row can copy the relative or absolute path (with a
|
||||
* brief "copied" label replacing the button after a successful write).
|
||||
*
|
||||
* Uploads start here (drag-drop or the context menu picker) but run in the
|
||||
* caller: every request is reported through `onUploadRequest(dir, items)`
|
||||
* (VSCode semantics — a drop on a file row targets its parent directory),
|
||||
* and `busy` gates new drags while one upload is in flight.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
|
||||
IconLinkOutline16, Menu, writeClipboard,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, downloadUrl, type FsEntry } from './api.ts'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { uploadItemsFromDrop, uploadItemsFromFiles, type UploadItem } from './upload.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
interface LevelData {
|
||||
@@ -38,9 +46,52 @@ export function baseName(path: string): string {
|
||||
return at === -1 ? trimmed : trimmed.slice(at + 1)
|
||||
}
|
||||
|
||||
/** The containing directory of an absolute row path (never the root edge here). */
|
||||
function parentOf(path: string): string {
|
||||
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
|
||||
return at <= 0 ? path : path.slice(0, at)
|
||||
}
|
||||
|
||||
/** How long the row's "copied" label stays after a successful write. */
|
||||
const COPIED_MS = 1200
|
||||
|
||||
/**
|
||||
* The drop overlay's hero art: an arrow rising out of a notched tray
|
||||
* (upload zone — the same glyph family as the toolbar's upload icon) and a
|
||||
* tilted pair of photo cards (chat zone). Hand-drawn, colored in the
|
||||
* palette of DSH's own native drop illustration (#3964FE / #9CE5ED) so the
|
||||
* two zones read as one family; the drop overlay is this flow's one brand
|
||||
* moment, so it gets color the rest of the UI never does.
|
||||
*/
|
||||
const UploadDropIllustration = () => (
|
||||
<svg width="64" height="56" viewBox="0 0 64 56" fill="none" aria-hidden="true">
|
||||
<path d="M32 28V11" stroke="#3964FE" strokeWidth="5" strokeLinecap="round" />
|
||||
<path d="M23 20l9-9 9 9" stroke="#3964FE" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path
|
||||
d="M10 40a4 4 0 0 1 4-4h7l3.2 4.6a5 5 0 0 0 4.1 2.2h7.4a5 5 0 0 0 4.1-2.2L43 36h7a4 4 0 0 1 4 4v2a10 10 0 0 1-10 10H20A10 10 0 0 1 10 42v-2z"
|
||||
fill="#9CE5ED"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** The chat zone's art: two tilted photo cards, each with its own
|
||||
* sun-over-mountains motif (the back card carries detail too, so it never
|
||||
* reads as a bare blob). */
|
||||
const ChatDropIllustration = () => (
|
||||
<svg width="96" height="76" viewBox="0 0 96 76" fill="none" aria-hidden="true">
|
||||
<g transform="rotate(-12 24 34)">
|
||||
<rect x="6" y="16" width="36" height="36" rx="10" fill="#9CE5ED" />
|
||||
<circle cx="16" cy="27" r="3.5" fill="white" />
|
||||
<path d="M11 44l8-9 6 6 4-4 8 9" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
<g transform="rotate(8 61 35)">
|
||||
<rect x="40" y="12" width="42" height="46" rx="10" fill="#3964FE" />
|
||||
<circle cx="55" cy="27" r="5" fill="white" />
|
||||
<path d="M46 50l10-13 7 8 6-6 9 11" stroke="white" strokeWidth="3.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export function FileTree(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
@@ -55,14 +106,111 @@ export function FileTree(props: {
|
||||
onReferenceFile: (path: string) => void
|
||||
/** Bump to wipe the level cache and reload the visible set. */
|
||||
refreshTick: number
|
||||
/** Upload into `dir` (absolute, inside the workspace); runs in the caller. */
|
||||
onUploadRequest: (dir: string, items: UploadItem[]) => void
|
||||
/** True while an upload is in flight (drops are ignored). */
|
||||
busy: boolean
|
||||
}) {
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick } = props
|
||||
const { sessionId, cwd, expanded, onToggle, onOpenFile, onOpenFileNewTab, onOpenFileSide, onReferenceFile, refreshTick, onUploadRequest, busy } = props
|
||||
const [data, setData] = useState<Record<string, LevelData>>({})
|
||||
const dataRef = useRef(data)
|
||||
/** The row whose path was just copied ("copied" label replaces its button). */
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
||||
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
|
||||
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
|
||||
/** Whether a file drag hovers the tree (drives the portaled drop zone). */
|
||||
const [dropOver, setDropOver] = useState(false)
|
||||
/** The directory a drag is hovering right now (null = body, drop to root). */
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null)
|
||||
/**
|
||||
* Enter/leave depth under the tree body. dragenter/dragleave fire per
|
||||
* element along the drag path (and bubble), so a counter — DSH InputBar's
|
||||
* own pattern — is the flicker-free signal; relatedTarget is unreliable
|
||||
* across engines for drag events.
|
||||
*/
|
||||
const dropDepth = useRef(0)
|
||||
/** Explorer body element; its viewport rect anchors the portaled drop zone. */
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
/** The body's viewport rect captured at drag entry (null = not measured). */
|
||||
const [dropRect, setDropRect] = useState<{ top: number; left: number; width: number; height: number } | null>(null)
|
||||
/** Context-menu "upload here" target directory. */
|
||||
const pendingUploadDir = useRef<string | undefined>(undefined)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/** Reset all drag state (drop landed, the drag left, or a new drag begins). */
|
||||
const resetDrop = (): void => {
|
||||
dropDepth.current = 0
|
||||
setDropOver(false)
|
||||
setDropTarget(null)
|
||||
setDropRect(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop handlers: always swallow the event (a dropped file must never open
|
||||
* in the browser), then report the target directory to the caller. A drop
|
||||
* ends the drag without further leave events, so the depth resets here.
|
||||
* The payload collection is async (dropped folders are traversed through
|
||||
* their entry handles — captured synchronously inside uploadItemsFromDrop
|
||||
* while the dataTransfer is still live), so the request rides a then.
|
||||
*/
|
||||
const reportDrop = (dir: string, data: DataTransfer | undefined): void => {
|
||||
if (busy) return
|
||||
void uploadItemsFromDrop(data).then((items) => {
|
||||
if (items.length > 0) onUploadRequest(dir, items)
|
||||
})
|
||||
}
|
||||
const handleBodyDrop = (event: DragEvent): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDrop()
|
||||
if (cwd !== undefined) reportDrop(cwd, event.dataTransfer)
|
||||
}
|
||||
const handleDirDrop = (event: DragEvent, dir: string): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
resetDrop()
|
||||
reportDrop(dir, event.dataTransfer)
|
||||
}
|
||||
const handleFileDrop = (event: DragEvent, path: string): void => {
|
||||
// VSCode semantics: dropping onto a file uploads into its directory.
|
||||
handleDirDrop(event, parentOf(path))
|
||||
}
|
||||
const handleBodyDragEnter = (event: DragEvent): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dropDepth.current += 1
|
||||
if (busy) return
|
||||
// First entry: anchor the portaled drop zone to the body's rect.
|
||||
if (dropDepth.current === 1) {
|
||||
const rect = bodyRef.current?.getBoundingClientRect()
|
||||
setDropRect(rect === undefined ? null : { top: rect.top, left: rect.left, width: rect.width, height: rect.height })
|
||||
}
|
||||
setDropOver(true)
|
||||
}
|
||||
const handleBodyDragLeave = (): void => {
|
||||
dropDepth.current = Math.max(0, dropDepth.current - 1)
|
||||
if (dropDepth.current > 0) return
|
||||
setDropOver(false)
|
||||
setDropTarget(null)
|
||||
setDropRect(null)
|
||||
}
|
||||
const handleBodyDragOver = (event: DragEvent): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
|
||||
if (busy) return
|
||||
// Rows stop propagation, so this only fires over non-row regions: the
|
||||
// drag targets the workspace root. dragover fires continuously, making
|
||||
// it the authoritative (flicker-free) place to clear the row target.
|
||||
setDropTarget(null)
|
||||
}
|
||||
const handleRowDragOver = (event: DragEvent, dir: string): void => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.dataTransfer.dropEffect = busy ? 'none' : 'copy'
|
||||
if (busy) return
|
||||
setDropTarget(dir)
|
||||
}
|
||||
|
||||
const storeLevel = useCallback((path: string, level: LevelData) => {
|
||||
dataRef.current = { ...dataRef.current, [path]: level }
|
||||
@@ -170,7 +318,10 @@ export function FileTree(props: {
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
|
||||
className={clsx(
|
||||
css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden,
|
||||
dropTarget === entry.path && css.explorerRowDropTarget,
|
||||
)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
onClick={() => { onToggle(entry.path) }}
|
||||
onKeyDown={(event) => {
|
||||
@@ -179,6 +330,8 @@ export function FileTree(props: {
|
||||
onToggle(entry.path)
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => { handleRowDragOver(event, entry.path) }}
|
||||
onDrop={(event) => { handleDirDrop(event, entry.path) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
|
||||
>
|
||||
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
@@ -195,7 +348,10 @@ export function FileTree(props: {
|
||||
key={entry.path}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken)}
|
||||
className={clsx(
|
||||
css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken,
|
||||
dropTarget === parentOf(entry.path) && css.explorerRowDropTarget,
|
||||
)}
|
||||
style={{ paddingLeft: depth * 22 + 6 }}
|
||||
title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
|
||||
onClick={() => { onOpenFile(entry.path) }}
|
||||
@@ -205,6 +361,8 @@ export function FileTree(props: {
|
||||
onOpenFile(entry.path)
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => { handleRowDragOver(event, parentOf(entry.path)) }}
|
||||
onDrop={(event) => { handleFileDrop(event, entry.path) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
@@ -217,14 +375,23 @@ export function FileTree(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.explorerBody}>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className={css.explorerBody}
|
||||
onDragEnter={handleBodyDragEnter}
|
||||
onDragOver={handleBodyDragOver}
|
||||
onDragLeave={handleBodyDragLeave}
|
||||
onDrop={handleBodyDrop}
|
||||
>
|
||||
{root === undefined ? (
|
||||
<div className={css.explorerEmpty}>{t('noSession')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={css.explorerRow}
|
||||
className={clsx(css.explorerRow, dropTarget === root && css.explorerRowDropTarget)}
|
||||
style={{ paddingLeft: 6 }}
|
||||
onDragOver={(event) => { handleRowDragOver(event, root) }}
|
||||
onDrop={(event) => { handleDirDrop(event, root) }}
|
||||
onContextMenu={(event) => { openRowMenu(event, root, true) }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
@@ -249,10 +416,70 @@ export function FileTree(props: {
|
||||
{data[root] !== undefined && renderLevel(root, 1)}
|
||||
</>
|
||||
)}
|
||||
{dropOver && dropRect !== null && createPortal(
|
||||
/*
|
||||
* The sidebar's drop surface, portaled to document.body at z-1001 —
|
||||
* above DSH's own whole-page drop mask (z-1000, see InputBar's
|
||||
* document-level intake) so the two never compete. It dims the WHOLE
|
||||
* viewport (the giant box-shadow spread on the zone frame is the
|
||||
* mask; the zone rect itself stays clear), teaching the zone split:
|
||||
* the dimmed conversation column still takes drops into the chat
|
||||
* natively (this layer is pointer-inert), while the clear frame
|
||||
* marks the tree as the workspace-upload zone. Deliberate exception
|
||||
* to the "panel stays below the DSH float stack" rule: transient,
|
||||
* and the drop always lands on the element beneath. The hint pill
|
||||
* docks at the TOP edge of the zone — right under the search row,
|
||||
* the first thing the eye meets — keeping the rows aimable.
|
||||
*/
|
||||
<>
|
||||
<div
|
||||
className={css.uploadDropZone}
|
||||
style={{
|
||||
top: dropRect.top + 2,
|
||||
left: dropRect.left + 2,
|
||||
width: dropRect.width - 4,
|
||||
height: dropRect.height - 4,
|
||||
}}
|
||||
>
|
||||
<div className={css.uploadDropHero}>
|
||||
<UploadDropIllustration />
|
||||
<div className={css.uploadDropZonePill}>
|
||||
<IconUploadOutline16 size={14} />
|
||||
<span className={css.uploadDropZoneText}>
|
||||
{dropTarget !== null ? t('uploadTo', { dir: dropTarget }) : t('uploadDropHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* The left zone's invitation, centered in the space beside the
|
||||
tree; skipped when that space is too narrow to hold it. */}
|
||||
{dropRect.left >= 200 && (
|
||||
<div className={css.uploadDropChatHint} style={{ width: dropRect.left }}>
|
||||
<div className={css.uploadDropChatCard}>
|
||||
<ChatDropIllustration />
|
||||
<span>{t('uploadDropChat')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>,
|
||||
document.body,
|
||||
)}
|
||||
{/*
|
||||
The one shared context menu, positioned at the right-click cursor
|
||||
(portal so the tree's overflow clip cannot crop it).
|
||||
*/}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
const dir = pendingUploadDir.current ?? root
|
||||
pendingUploadDir.current = undefined
|
||||
if (dir !== undefined && !busy) onUploadRequest(dir, uploadItemsFromFiles(event.target.files ?? []))
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Menu
|
||||
open={rowMenu !== null}
|
||||
onClose={() => { setRowMenu(null) }}
|
||||
@@ -268,6 +495,10 @@ export function FileTree(props: {
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
]}
|
||||
@@ -287,6 +518,11 @@ export function FileTree(props: {
|
||||
downloadFile(target.path)
|
||||
return
|
||||
}
|
||||
if (id === 'upload-here') {
|
||||
pendingUploadDir.current = target.path
|
||||
fileInputRef.current?.click()
|
||||
return
|
||||
}
|
||||
copyPath(
|
||||
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
|
||||
target.path,
|
||||
|
||||
@@ -11,9 +11,13 @@
|
||||
* - the feature inventories are responsive GRIDS of small toggle cards
|
||||
* (auto-fill columns — several cards per row, wrapping): icon chip +
|
||||
* title + type id, the card's on/off state IS its visual state
|
||||
* (enabled = brand border + tinted fill + circular check badge,
|
||||
* disabled = neutral hairline + dimmed);
|
||||
* - features that declare related settings carry a gear corner button
|
||||
* (enabled = softened brand border + tinted fill + a compact brand
|
||||
* switch knob at the far right — the card itself is the toggle;
|
||||
* disabled = neutral hairline + dimmed, no knob); every card shares a
|
||||
* uniform min-height so rows stay equal whether or not they carry a
|
||||
* settings strip;
|
||||
* - features that declare related settings carry a labeled SETTINGS STRIP
|
||||
* across the card's bottom edge (gear icon + text, hairline-separated)
|
||||
* opening a native Modal (wider than the primitive default) whose rows
|
||||
* follow the same row recipe with switch controls and a Done footer;
|
||||
* - the width input stays a native settings row (a value, not a switch).
|
||||
@@ -22,7 +26,7 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 760px;
|
||||
/* The DSH settings shell's `.options` area owns vertical scrolling. Keep
|
||||
@@ -47,7 +51,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 18px 20px 20px;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 16px;
|
||||
@@ -67,26 +71,36 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The inventory count: a soft-accent pill (the versionBadgeTag token recipe)
|
||||
so the count reads as metadata, not as part of the heading text. */
|
||||
.count {
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
font-weight: 400;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-accent-soft, var(--dsw-alias-bg-layer-2));
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Responsive inventory: as many small cards per row as the width allows. */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
|
||||
/* One small toggle card (visual chrome; the interactive part is .cardMain).
|
||||
A uniform min-height keeps every inventory card the same height across
|
||||
rows whether or not it carries a settings strip (strip cards are
|
||||
naturally taller); overflow:hidden clips the strip to the card's radius. */
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 106px;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: transparent;
|
||||
@@ -102,15 +116,19 @@
|
||||
border-color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
/* Enabled = highlighted: brand border + tinted fill. */
|
||||
/* Enabled = highlighted: a SOFTENED brand border (color-mix, the icon-chip
|
||||
precedent) + tinted fill — full-strength brand reads harsh at card scale. */
|
||||
.cardOn {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
border-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 45%, transparent);
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
/* The card's switch: the whole main area (top row + desc) flips the feature. */
|
||||
/* The card's switch: the whole main area (top row + desc) flips the feature.
|
||||
flex:1 lets the main area absorb the extra height of the uniform card
|
||||
(cards without a settings strip stretch to match the strip cards). */
|
||||
.cardMain {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
@@ -125,7 +143,7 @@
|
||||
}
|
||||
|
||||
.cardMain:focus-visible,
|
||||
.cardGear:focus-visible,
|
||||
.cardSettings:focus-visible,
|
||||
.rowGear:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
@@ -136,6 +154,7 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
/* The icon sits in a rounded chip: brand-tinted while the card is on,
|
||||
@@ -175,18 +194,46 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The check badge: a circular brand-filled pin at the card's far right
|
||||
(enabled only), the icon in the inverted surface token (the .save recipe). */
|
||||
.cardCheck {
|
||||
/* The compact on/off switch knob at the card's far right (enabled only):
|
||||
the card itself is the toggle (aria-pressed on .cardMain), so this is a
|
||||
PURELY VISUAL switch — same track/thumb geometry as the row switches,
|
||||
one size down. Off cards show no knob (neutral card = off). */
|
||||
.cardSwitch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.cardSwitchTrack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 30px;
|
||||
height: 16px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.cardSwitchThumb {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-tertiary);
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.cardOn .cardSwitchTrack {
|
||||
border-color: var(--dsw-alias-button-primary-fill);
|
||||
background: var(--dsw-alias-button-primary-fill);
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.cardOn .cardSwitchThumb {
|
||||
transform: translateX(14px);
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
.cardDesc {
|
||||
@@ -234,41 +281,42 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Cards with a settings gear keep the desc clear of it: the gear sits in
|
||||
the desc row, sharing the check badge's right edge (a vertical column of
|
||||
controls at the card's right side). */
|
||||
.cardWithGear .cardDesc {
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
/* The gear corner button: directly UNDER the check badge on the card's
|
||||
right edge — the two controls are vertically aligned (same right inset,
|
||||
one row apart) and the SAME 16px size, not side by side. */
|
||||
.cardGear {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 46px;
|
||||
display: inline-flex;
|
||||
/* The settings strip: a labeled full-width footer across the card's bottom
|
||||
edge (gear icon + "功能设置" text), hairline-separated from the toggle
|
||||
area. This is the SECONDARY settings entry — it must be discoverable at
|
||||
rest (not a hover-only ghost), so it keeps a steady secondary-ink label
|
||||
and answers hover with the brand-accent tint + brand ink. */
|
||||
.cardSettings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
border-top: 1px solid var(--dsw-alias-border-l1);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.cardGear:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
.cardOn .cardSettings {
|
||||
border-top-color: color-mix(in srgb, var(--dsw-alias-button-primary-fill) 18%, transparent);
|
||||
}
|
||||
|
||||
.cardSettings:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
/* The gear icon button on a general settings ROW (the position-compat
|
||||
row's "customize the strip height" entry): the same 16px icon-button
|
||||
recipe as the card gear, sitting in the row's control area. */
|
||||
row's "customize the strip height" entry): a bordered chip (not a ghost)
|
||||
so it reads as a control at rest; hover answers with the accent tint. */
|
||||
.rowGear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -276,17 +324,19 @@
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
|
||||
.rowGear:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
color: var(--dsw-alias-brand-primary);
|
||||
}
|
||||
|
||||
/* Settings-row recipe (the DSH General section rows): title/desc left,
|
||||
@@ -349,7 +399,7 @@
|
||||
box-sizing: border-box;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
transition: background 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -358,7 +408,7 @@
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-label-secondary);
|
||||
background: var(--dsw-alias-label-tertiary);
|
||||
transition: transform 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
@@ -462,6 +512,28 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The multi-line custom-CSS input (scheme `custom`): monospace, generous
|
||||
height, resizable — same surface tokens as the typed inputs. */
|
||||
.cssTextArea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
min-height: 120px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-family: var(--ds-font-family-code, monospace);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.cssTextArea:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 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). */
|
||||
@@ -471,21 +543,49 @@
|
||||
|
||||
/* Popup rows: each setting gets the same outer chrome as a plugin-list
|
||||
PluginCard (l2 hairline, 12px radius, layer-3 fill). The row's title,
|
||||
description and control geometry stay unchanged inside that frame. */
|
||||
description and control geometry stay unchanged inside that frame.
|
||||
The body scrolls INTERNALLY once a feature declares many rows (e.g.
|
||||
Terminal's six) — the dialog shell and its Done footer stay put; a thin
|
||||
token-driven scrollbar makes the scrollable region discoverable.
|
||||
box-sizing:border-box keeps width:100% + the scrollbar gutter from
|
||||
overflowing horizontally (a content-box width would add the 4px pad and
|
||||
show a horizontal scrollbar); overflow-x:hidden is the belt-and-braces
|
||||
guarantee that rows always FILL the width and never slide sideways. */
|
||||
.popupRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-height: min(52vh, 440px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--dsw-alias-scrollbar-bg-l2, transparent) transparent;
|
||||
}
|
||||
|
||||
.popupRows::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.popupRows::-webkit-scrollbar-thumb {
|
||||
border-radius: 3px;
|
||||
background: var(--dsw-alias-scrollbar-bg-l2, var(--dsw-alias-border-l2));
|
||||
}
|
||||
|
||||
.popupRows::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--dsw-alias-scrollbar-hover-l2, var(--dsw-alias-label-dimmed));
|
||||
}
|
||||
|
||||
.popupRow {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
@@ -573,12 +673,6 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.pluginEntries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* One catalog entry: name + install button on the head row, description,
|
||||
then the install command in a mono block (the exact script that gets
|
||||
pre-typed into the terminal — review it here before installing). */
|
||||
@@ -708,6 +802,11 @@
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card,
|
||||
.cardSettings,
|
||||
.cardSwitchTrack,
|
||||
.cardSwitchThumb,
|
||||
.rowGear,
|
||||
.popupRow,
|
||||
.switchTrack,
|
||||
.switchThumb {
|
||||
transition: none;
|
||||
@@ -776,6 +875,7 @@
|
||||
gap: 10px;
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
|
||||
+225
-84
@@ -21,13 +21,15 @@
|
||||
* one-line intro (the DSH section heading+intro recipe).
|
||||
*
|
||||
* A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
|
||||
* border + tinted fill + a circular check badge pinned to the card's far
|
||||
* right), disabled = neutral and dimmed. Features that declare
|
||||
* `settings.toggles` carry a gear corner button that opens a native Modal
|
||||
* (wider than the primitive default) with the related settings as
|
||||
* title/desc + custom-switch rows and a Done footer. The toggles
|
||||
* themselves are custom switches: a real checkbox (native semantics and
|
||||
* focus) driving a styled track/thumb.
|
||||
* border + tinted fill + a compact switch knob at the card's far right),
|
||||
* disabled = neutral and dimmed. Features that declare
|
||||
* `settings.toggles` carry a labeled settings strip at the card's bottom
|
||||
* edge that opens a native Modal (wider than the primitive default) with
|
||||
* the related settings as title/desc + custom-switch rows and a Done
|
||||
* footer; the popup body scrolls internally when a feature declares many
|
||||
* rows (e.g. Terminal's six). The toggles themselves are custom
|
||||
* switches: a real checkbox (native semantics and focus) driving a styled
|
||||
* track/thumb.
|
||||
*
|
||||
* Writes ride the plugin's own fenced settings route (the host calls the
|
||||
* settings seam in-process — the DSH settings RPC domain does not serve
|
||||
@@ -38,9 +40,8 @@
|
||||
* shows the wire error inline — a broken settings surface never crashes the
|
||||
* shell.
|
||||
*/
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16,
|
||||
IconChevronDownOutline14,
|
||||
IconPlusOutline16,
|
||||
IconSettingsOutline16,
|
||||
@@ -59,11 +60,14 @@ import {
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
type TitleBarScheme,
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { AddPluginModal, type PluginKind } from './add-plugin-modal.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { parseDesktopEnv } from './desktop-env.ts'
|
||||
import { getShellPreset, getShellPresets } from './shell-presets.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
@@ -109,6 +113,17 @@ function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
|
||||
return (a.order ?? 100) - (b.order ?? 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scheme dropdown's current value: the plain scheme, or `preset:<id>`
|
||||
* while a preset is active. Falls back to `auto` when the stored preset id
|
||||
* is no longer registered (the strip resolves to 0 then anyway).
|
||||
*/
|
||||
function titleBarSchemeValue(prefs: SidebarPrefs): string {
|
||||
if (prefs.titleBarScheme !== 'preset') return prefs.titleBarScheme
|
||||
const preset = getShellPreset(prefs.titleBarPresetId)
|
||||
return preset !== undefined ? `preset:${preset.id}` : 'auto'
|
||||
}
|
||||
|
||||
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
|
||||
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
|
||||
return (b.priority ?? 0) - (a.priority ?? 0)
|
||||
@@ -318,24 +333,60 @@ function TypedRow(props: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/**
|
||||
* The multi-line custom-CSS input (scheme `custom`): a monospace textarea
|
||||
* whose draft is local state, committed on blur or Cmd/Ctrl+Enter through
|
||||
* the parent's handler. Keyed by the stored value so an external commit
|
||||
* remounts it with the canonical text (same pattern as TypedRow).
|
||||
*/
|
||||
function CssDraft(props: {
|
||||
value: string
|
||||
onCommit: (raw: string) => void
|
||||
label: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
const { value, onCommit, label, placeholder } = props
|
||||
const [draft, setDraft] = useState(value)
|
||||
return (
|
||||
<textarea
|
||||
className={css.cssTextArea}
|
||||
rows={6}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
aria-label={label}
|
||||
spellCheck={false}
|
||||
onChange={event => { setDraft(event.currentTarget.value) }}
|
||||
onBlur={() => { onCommit(draft) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One select row: a dropdown over the toggle's declared `options`. When any
|
||||
* option carries an icon, the dropdown renders big-icon option cards (icon +
|
||||
* title + desc) and the closed anchor shows the selected option's icon as
|
||||
* well; without icons both are a single line of text. Single-pick commits the
|
||||
* option's value and closes; `multi` toggles membership, commits the picked
|
||||
* values as an array (in options order), and stays open.
|
||||
* The reusable dropdown — the primitives Menu, NOT a native <select>: a
|
||||
* closed anchor button (picked option text + chevron) opening one Menu item
|
||||
* per option (big-icon cards when any option carries an icon). Single-pick
|
||||
* commits the option's value and closes; `multi` toggles membership and
|
||||
* commits the picked values as an array (in options order), staying open.
|
||||
* Shared by the declarative select rows (SelectRow) and the title-bar
|
||||
* scheme dropdown on the General row.
|
||||
*/
|
||||
function SelectRow(props: {
|
||||
toggle: SidebarSettingToggle
|
||||
title: string
|
||||
function SelectMenu(props: {
|
||||
label: string
|
||||
value: unknown
|
||||
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
options: readonly {
|
||||
value: string | number | boolean
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
}[]
|
||||
multi?: boolean
|
||||
onSelect: (next: unknown) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
const { toggle, title, value, onSelectValue } = props
|
||||
const options = toggle.options ?? []
|
||||
const multi = toggle.multi === true
|
||||
const { label, value, options, multi, onSelect, placeholder } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasIcons = options.some(option => option.icon !== undefined)
|
||||
const picked: readonly unknown[] = multi ? (Array.isArray(value) ? value : []) : [value]
|
||||
@@ -346,7 +397,7 @@ function SelectRow(props: {
|
||||
const option = options[index]
|
||||
if (option === undefined) return
|
||||
if (!multi) {
|
||||
onSelectValue?.(toggle, option.value)
|
||||
onSelect(option.value)
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
@@ -355,14 +406,14 @@ function SelectRow(props: {
|
||||
if (at >= 0) current.splice(at, 1)
|
||||
else current.push(option.value)
|
||||
// Stable wire order: follow the declared options order, not pick order.
|
||||
onSelectValue?.(toggle, options.filter(o => current.includes(o.value)).map(o => o.value))
|
||||
onSelect(options.filter(o => current.includes(o.value)).map(o => o.value))
|
||||
}
|
||||
|
||||
const anchor = (
|
||||
<button
|
||||
type="button"
|
||||
className={css.selectAnchor}
|
||||
aria-label={title}
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(now => !now) }}
|
||||
@@ -371,12 +422,55 @@ function SelectRow(props: {
|
||||
<span className={css.selectAnchorIcon}>{iconOf(selected[0].icon, 16)}</span>
|
||||
)}
|
||||
<span className={css.selectAnchorText}>
|
||||
{selected.length === 0 ? '—' : selected.map(option => textOf(option.title)).join(', ')}
|
||||
{selected.length === 0 ? (placeholder ?? '—') : selected.map(option => textOf(option.title)).join(', ')}
|
||||
</span>
|
||||
<IconChevronDownOutline14 size={12} />
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
items={options.map((option, index) => ({
|
||||
id: String(index),
|
||||
label: hasIcons
|
||||
? (
|
||||
<span className={css.selectOption}>
|
||||
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
|
||||
<span className={css.selectOptionText}>
|
||||
<span className={css.title}>{textOf(option.title)}</span>
|
||||
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
: textOf(option.title),
|
||||
}))}
|
||||
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
|
||||
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
|
||||
onSelect={(id) => { pick(Number(id)) }}
|
||||
onClose={() => { setOpen(false) }}
|
||||
portal
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One select row: a dropdown over the toggle's declared `options` (the
|
||||
* shared SelectMenu). When any option carries an icon, the dropdown renders
|
||||
* big-icon option cards (icon + title + desc) and the closed anchor shows
|
||||
* the selected option's icon as well; without icons both are a single line
|
||||
* of text. Single-pick commits the option's value and closes; `multi`
|
||||
* toggles membership, commits the picked values as an array (in options
|
||||
* order), and stays open.
|
||||
*/
|
||||
function SelectRow(props: {
|
||||
toggle: SidebarSettingToggle
|
||||
title: string
|
||||
value: unknown
|
||||
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
}) {
|
||||
const { toggle, title, value, onSelectValue } = props
|
||||
return (
|
||||
<div className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
@@ -384,28 +478,12 @@ function SelectRow(props: {
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Menu
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
items={options.map((option, index) => ({
|
||||
id: String(index),
|
||||
label: hasIcons
|
||||
? (
|
||||
<span className={css.selectOption}>
|
||||
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
|
||||
<span className={css.selectOptionText}>
|
||||
<span className={css.title}>{textOf(option.title)}</span>
|
||||
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
: textOf(option.title),
|
||||
}))}
|
||||
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
|
||||
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
|
||||
onSelect={(id) => { pick(Number(id)) }}
|
||||
onClose={() => { setOpen(false) }}
|
||||
portal
|
||||
<SelectMenu
|
||||
label={title}
|
||||
value={value}
|
||||
options={toggle.options ?? []}
|
||||
multi={toggle.multi === true}
|
||||
onSelect={(next) => { onSelectValue?.(toggle, next) }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -498,6 +576,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
|
||||
// Whether the position-compat strip popup (the gear on the 常规 row) is open.
|
||||
const [stripSettingsOpen, setStripSettingsOpen] = useState(false)
|
||||
// The parsed desktop environment (URL stamps — see desktop-env.ts). Used
|
||||
// ONLY to badge matching presets in the scheme dropdown ("已检测");
|
||||
// nothing is auto-applied.
|
||||
const detectedEnv = useMemo(() => parseDesktopEnv(), [])
|
||||
// Whether the "add plugin" modal (a dashed card at the end of the
|
||||
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point
|
||||
// (null = closed).
|
||||
@@ -635,6 +717,38 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the title-bar / shell compatibility scheme. Mirrors the legacy
|
||||
* `titleBarCompat` flag (true = anything but the conservative auto) so
|
||||
* documents stay readable by older plugin versions.
|
||||
*/
|
||||
/**
|
||||
* Pick the title-bar / shell compatibility scheme from the dropdown. The
|
||||
* option values are `auto` | `web` | `custom` | `preset:<id>`; selecting
|
||||
* a preset stores both the scheme and its id. Mirrors the legacy
|
||||
* `titleBarCompat` flag (true for preset/custom) so documents stay
|
||||
* readable by older plugin versions.
|
||||
*/
|
||||
const onSchemeSelect = (value: unknown): void => {
|
||||
if (typeof value !== 'string') return
|
||||
if (value === 'auto' || value === 'web' || value === 'custom') {
|
||||
applyPref({ titleBarScheme: value, titleBarCompat: value === 'custom' })
|
||||
return
|
||||
}
|
||||
if (value.startsWith('preset:') && getShellPreset(value.slice('preset:'.length)) !== undefined) {
|
||||
applyPref({
|
||||
titleBarScheme: 'preset',
|
||||
titleBarPresetId: value.slice('preset:'.length),
|
||||
titleBarCompat: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Commit the free-form custom CSS (scheme `custom`). */
|
||||
const commitCustomCss = (raw: string): void => {
|
||||
applyPref({ customCss: raw })
|
||||
}
|
||||
|
||||
/** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */
|
||||
const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => {
|
||||
applyPref({ pluginSettings: mergePluginSetting(optimisticRef.current.pluginSettings, descriptorId, key, value) })
|
||||
@@ -680,8 +794,9 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
* One SMALL toggle card for the responsive inventory grid: the card's main
|
||||
* area is the switch (click to flips, visual state IS the state), the icon
|
||||
* sits in a rounded chip, the check badge pins to the far right, and a
|
||||
* feature that declares related settings carries a gear corner button
|
||||
* opening its settings popup.
|
||||
* feature that declares related settings gets a labeled SETTINGS STRIP
|
||||
* across the card's bottom edge (gear icon + text) opening its settings
|
||||
* popup — discoverable at rest, not a hover-only ghost corner button.
|
||||
*/
|
||||
const renderCard = (props: {
|
||||
title: string
|
||||
@@ -689,13 +804,13 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
icon?: ReactNode
|
||||
enabled: boolean
|
||||
onToggle: (next: boolean) => void
|
||||
/** A feature with declared related settings shows the gear corner button. */
|
||||
/** A feature with declared related settings shows the settings strip. */
|
||||
onOpenSettings?: () => void
|
||||
}) => {
|
||||
const hasSettings = props.onOpenSettings !== undefined
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.card, props.enabled && css.cardOn, hasSettings && css.cardWithGear)}
|
||||
className={clsx(css.card, props.enabled && css.cardOn)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -710,8 +825,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
)}
|
||||
<span className={css.cardTitle}>{props.title}</span>
|
||||
{props.enabled && (
|
||||
<span className={css.cardCheck}>
|
||||
<IconCheckOutline16 size={12} />
|
||||
<span className={css.cardSwitch} aria-hidden="true">
|
||||
<span className={css.cardSwitchTrack}>
|
||||
<span className={css.cardSwitchThumb} />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -720,12 +837,12 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
{hasSettings && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardGear}
|
||||
className={css.cardSettings}
|
||||
aria-label={`${props.title} ${t('settingsPopup')}`}
|
||||
title={t('settingsPopup')}
|
||||
onClick={props.onOpenSettings}
|
||||
>
|
||||
<IconSettingsOutline16 size={12} />
|
||||
<span>{t('settingsPopup')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -800,12 +917,31 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
{/*
|
||||
The position-compat row's gear (same popup pattern as the
|
||||
feature cards): opens a Modal with the strip-height number row.
|
||||
Hidden while the mode is off — its related setting is dormant
|
||||
then (the feature-card convention).
|
||||
The scheme dropdown (the shared SelectMenu — NOT a native
|
||||
select): 自动检测 (default) / DSH官方Web / 各壳兼容方案 /
|
||||
自定义方案. Matching presets carry a 「已检测」 desc badge
|
||||
(suggestion only). The 自定义方案 row keeps its gear (the
|
||||
popup with the shift distance + custom CSS) — the other
|
||||
schemes need no further settings.
|
||||
*/}
|
||||
{prefs.titleBarCompat && (
|
||||
<SelectMenu
|
||||
label={t('settingsTitleBarTitle')}
|
||||
value={titleBarSchemeValue(prefs)}
|
||||
options={[
|
||||
{ value: 'auto', title: t('settingsSchemeAutoTitle'), desc: t('settingsSchemeAutoDesc') },
|
||||
{ value: 'web', title: t('settingsSchemeWebTitle'), desc: t('settingsSchemeWebDesc') },
|
||||
...getShellPresets().map(preset => ({
|
||||
value: `preset:${preset.id}`,
|
||||
title: preset.title,
|
||||
desc: preset.detect?.(detectedEnv) === true
|
||||
? `${preset.desc}(${t('settingsSchemeDetectedSuffix')})`
|
||||
: preset.desc,
|
||||
})),
|
||||
{ value: 'custom', title: t('settingsSchemeCustomTitle'), desc: t('settingsSchemeCustomDesc') },
|
||||
]}
|
||||
onSelect={onSchemeSelect}
|
||||
/>
|
||||
{prefs.titleBarScheme === 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.rowGear}
|
||||
@@ -816,11 +952,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
<IconSettingsOutline16 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<Switch
|
||||
label={t('settingsTitleBarTitle')}
|
||||
checked={prefs.titleBarCompat}
|
||||
onChange={(next) => { applyPref({ titleBarCompat: next }) }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -948,10 +1079,11 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* The position-compat strip popup (opened by the gear on the 常规
|
||||
row): one number row for the reserved strip height in px. Same
|
||||
Modal chrome and row machinery as the feature popups — mounted
|
||||
only while open (the Modal SSR rule above). */}
|
||||
{/* The custom-scheme popup (opened by the gear next to the scheme
|
||||
dropdown when 自定义方案 is active): the shift distance in px and
|
||||
the free-form custom CSS. The OTHER schemes (自动检测 / DSH官方Web /
|
||||
壳预设) need no further settings — the scheme itself is chosen on
|
||||
the 常规 row. Mounted only while open (the Modal SSR rule above). */}
|
||||
{stripSettingsOpen && (
|
||||
<Modal
|
||||
open
|
||||
@@ -966,20 +1098,29 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<FeatureSettingsRows
|
||||
toggles={[{
|
||||
key: 'titleBarStripPx',
|
||||
type: 'number',
|
||||
title: () => t('settingsTitleBarStripTitle'),
|
||||
desc: () => t('settingsTitleBarStripDesc'),
|
||||
min: TITLE_BAR_STRIP_MIN,
|
||||
max: TITLE_BAR_STRIP_MAX,
|
||||
unit: 'px',
|
||||
}]}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
/>
|
||||
<div className={css.popupRows}>
|
||||
<FeatureSettingsRows
|
||||
toggles={[{
|
||||
key: 'titleBarStripPx',
|
||||
type: 'number',
|
||||
title: () => t('settingsTitleBarStripTitle'),
|
||||
desc: () => t('settingsTitleBarStripDesc'),
|
||||
min: TITLE_BAR_STRIP_MIN,
|
||||
max: TITLE_BAR_STRIP_MAX,
|
||||
unit: 'px',
|
||||
}]}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
/>
|
||||
<CssDraft
|
||||
key={prefs.customCss}
|
||||
value={prefs.customCss}
|
||||
label={t('settingsCustomCssTitle')}
|
||||
placeholder={t('settingsCustomCssPlaceholder')}
|
||||
onCommit={commitCustomCss}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
/* Side Chat tab: ONE side conversation per tab (Codex-style). Visual
|
||||
* language mirrors the main conversation pane: right-aligned user bubbles
|
||||
* on --dsw-specific-bubble, full-width assistant markdown, a capsule
|
||||
* composer card on --dsw-specific-input-major with a circular accent
|
||||
* send/stop button, and shimmer reserved for the running status line and
|
||||
* executing tools. Motion is crossfade-only (≤200ms entrances, no
|
||||
* springs); reduced-motion disables every loop. All values consume DSW
|
||||
* tokens (fallbacks point at older alias tokens for rc-era hosts). */
|
||||
.sidechat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── thread header (status dot · agent badge · threads menu · save) ─── */
|
||||
.sidechatDetailHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 36px;
|
||||
padding: 4px 8px 4px 12px;
|
||||
border-bottom: 1px solid var(--dsw-alias-hairline);
|
||||
}
|
||||
|
||||
.sidechatHeaderDot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.sidechatHeaderSpacer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidechatAgentBadge {
|
||||
flex: none;
|
||||
max-width: 55%;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid var(--dsw-alias-hairline);
|
||||
border-radius: 999px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidechatIconBtn {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
transition: background-color 100ms ease-out, color 100ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatIconBtn:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.sidechatIconBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── hero (unbound tab: the one brand moment) ───────────────────────── */
|
||||
.sidechatHero {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 24px 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-align: center;
|
||||
animation: sidechatFadeIn 200ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatHeroTitle {
|
||||
font: var(--dsw-font-s-14);
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.sidechatHeroDesc {
|
||||
max-width: 300px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sidechatPrimaryBtn {
|
||||
flex: none;
|
||||
margin-top: 4px;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
|
||||
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
|
||||
font: var(--dsw-font-s-13);
|
||||
cursor: pointer;
|
||||
transition: opacity 100ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatPrimaryBtn:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.sidechatPrimaryBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sidechatHint {
|
||||
flex: none;
|
||||
padding: 4px 12px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.sidechatError {
|
||||
flex: none;
|
||||
padding: 4px 12px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-danger);
|
||||
}
|
||||
|
||||
/* ── transcript ─────────────────────────────────────────────────────── */
|
||||
.sidechatScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Rows crossfade in on append (keys are stable, so only NEW rows play). */
|
||||
.sidechatScroll > * {
|
||||
animation: sidechatRowIn 180ms ease-out;
|
||||
}
|
||||
|
||||
/* User message: right-aligned bubble, same surface as the main pane. */
|
||||
.sidechatUser {
|
||||
align-self: flex-end;
|
||||
max-width: 88%;
|
||||
padding: 8px 14px;
|
||||
border-radius: 18px;
|
||||
background: var(--dsw-specific-bubble, var(--dsw-alias-bg-base));
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Assistant message: full-width narration, no bubble — the main pane way. */
|
||||
.sidechatAssistant {
|
||||
align-self: stretch;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sidechatReasoning {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.sidechatReasoningSummary {
|
||||
display: inline-block;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidechatReasoningSummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidechatReasoningBody {
|
||||
margin-top: 2px;
|
||||
padding-left: 2px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sidechatTool {
|
||||
align-self: stretch;
|
||||
border: 1px solid var(--dsw-alias-hairline);
|
||||
border-radius: 10px;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidechatToolSummary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.sidechatToolSummary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidechatToolName {
|
||||
font-family: var(--dsw-font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Running tool: a light sweep across the summary row (shimmer = generating). */
|
||||
.sidechatToolExecuting {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--dsw-alias-interactive-bg-hover) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: sidechatSweep 2.6s linear infinite;
|
||||
}
|
||||
|
||||
.sidechatToolFailed {
|
||||
color: var(--dsw-alias-danger);
|
||||
}
|
||||
|
||||
.sidechatToolArgs,
|
||||
.sidechatToolResult {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
border-top: 1px solid var(--dsw-alias-hairline);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
font-family: var(--dsw-font-mono);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* ── running status line (above the composer, main-pane style) ──────── */
|
||||
.sidechatStatus {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 14px 6px;
|
||||
animation: sidechatFadeIn 160ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatStatusText {
|
||||
font: var(--dsw-font-xxs-12);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
var(--dsw-alias-label-tertiary) 0%,
|
||||
var(--dsw-alias-label-primary) 50%,
|
||||
var(--dsw-alias-label-tertiary) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
animation: sidechatSweep 2.6s linear infinite;
|
||||
}
|
||||
|
||||
/* ── composer (capsule card, InputBar-aligned) ──────────────────────── */
|
||||
.sidechatComposer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0 8px 8px;
|
||||
padding: 8px 8px 6px 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin, var(--dsw-alias-hairline));
|
||||
border-radius: 16px;
|
||||
background: var(--dsw-specific-input-major, var(--dsw-alias-bg-base));
|
||||
box-shadow: var(--dsw-shadow-lv2, none);
|
||||
}
|
||||
|
||||
.sidechatComposerInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 2px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-s-14);
|
||||
line-height: 22px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
max-height: 132px;
|
||||
}
|
||||
|
||||
.sidechatComposerInput::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.sidechatComposerBar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 28px;
|
||||
}
|
||||
|
||||
.sidechatComposerMeta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidechatSendBtn {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-button-info-fill, var(--dsw-alias-accent));
|
||||
color: var(--dsw-alias-button-info-label, var(--dsw-alias-accent-ink, #fff));
|
||||
cursor: pointer;
|
||||
/* send ⇄ stop swap: keyed remount plays this, hover only fades. */
|
||||
animation: sidechatBtnIn 120ms ease-out;
|
||||
transition: opacity 100ms ease-out;
|
||||
}
|
||||
|
||||
.sidechatSendBtn:hover:not(:disabled) {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.sidechatSendBtn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── motion vocabulary (crossfade only, ≤200ms; loops reduced-motion off) */
|
||||
@keyframes sidechatRowIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes sidechatFadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes sidechatBtnIn {
|
||||
from { opacity: 0; transform: scale(0.85); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes sidechatSweep {
|
||||
from { background-position: 200% 0; }
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sidechatScroll > *,
|
||||
.sidechatHero,
|
||||
.sidechatStatus,
|
||||
.sidechatSendBtn {
|
||||
animation: none;
|
||||
}
|
||||
.sidechatStatusText,
|
||||
.sidechatToolExecuting {
|
||||
animation: none;
|
||||
background-image: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* Side Chat page: Codex-style side conversations for the current session.
|
||||
*
|
||||
* EVERY side conversation is its own sidebar tab (侧边对话1/2/3 …): the
|
||||
* descriptor's createTab mints a fresh tab flagged `autoCreate` and this
|
||||
* view creates the EMPTY thread on mount (one click = one conversation,
|
||||
* exactly like the Codex app); the composer owns the first message (the
|
||||
* host wraps it with the side boundary + the in-progress snapshot parked
|
||||
* at creation, and the thread earns its real label — and the tab its
|
||||
* title — from that first message). Closing the tab releases the thread's
|
||||
* live agent (its history stays persisted); the header menu reopens any
|
||||
* existing thread into a tab (deduped by threadId).
|
||||
*
|
||||
* Each side thread is a child session the plugin created itself with a
|
||||
* custom seed (the parent's full log up to the click moment — see
|
||||
* sidechat-core.ts). Transport: thread creation/follow-up/cancel/dispose/
|
||||
* info go through the plugin's own /sidebar/api sidechat.* routes
|
||||
* (subagent-origin identities are fenced from the generic session RPCs);
|
||||
* the transcript is polled from the generic session.history RPC (seed-cut
|
||||
* at session/end-seed, boundary row dropped, chunk streaming accumulated)
|
||||
* — see sidechat-transcript.ts.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconNewChatOutline16,
|
||||
IconPlusOutline16,
|
||||
IconSendOutline16,
|
||||
IconStopFill16,
|
||||
MarkdownText,
|
||||
Menu,
|
||||
StateDot,
|
||||
type MenuEntry,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
|
||||
import type { Context, SidebarHistoryEntry } from '../context-types.ts'
|
||||
import {
|
||||
SIDE_LABEL_PREFIX,
|
||||
SIDE_NEW_THREAD_TITLE,
|
||||
sideThreadRows,
|
||||
threadHasCompletedTurn,
|
||||
threadTrailingPending,
|
||||
type SidechatThreadInfo,
|
||||
} from '../sidechat-core.ts'
|
||||
import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
|
||||
import { api } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SessionScope } from './api.ts'
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import css from './SideChatView.module.css'
|
||||
|
||||
/** Tail-page size for one transcript poll (events per page). Small on
|
||||
* purpose: streaming polls ride the tail and merge by seq. */
|
||||
const PAGE_MESSAGES = 8
|
||||
/** First-attach walk page size: cold reads re-expand chunk-rows into one
|
||||
* event per streamed delta, so a single answer can be hundreds of events —
|
||||
* the walk must page big or earlier tool/call rows fall out of the window. */
|
||||
const WALK_PAGE_EVENTS = 200
|
||||
/** Poll cadence while the selected thread is running and the tab visible. */
|
||||
const POLL_MS = 2000
|
||||
/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */
|
||||
const COMPOSER_MAX_HEIGHT = 132
|
||||
|
||||
/** The thread a tab is bound to (durable in tab.meta across refreshes). */
|
||||
export function sidechatThreadIdOf(tab: SidebarTab): string | undefined {
|
||||
const meta = tab.meta as { threadId?: unknown } | undefined
|
||||
return typeof meta?.threadId === 'string' ? meta.threadId : undefined
|
||||
}
|
||||
|
||||
/** The parked reopen target consumed by the descriptor's createTab (the
|
||||
* service's createTab receives no seed, so a thread-switch parks the id
|
||||
* here and openTab picks it up synchronously — exactly one consume per
|
||||
* park). */
|
||||
let parkedReopen: string | undefined
|
||||
|
||||
/** Park a thread id for the NEXT sidechat openTab to reattach. */
|
||||
export function parkSidechatReopen(threadId: string): void {
|
||||
parkedReopen = threadId
|
||||
}
|
||||
|
||||
/** Consume the parked reopen target (undefined = mint a fresh thread tab). */
|
||||
export function consumeSidechatSeed(): string | undefined {
|
||||
const value = parkedReopen
|
||||
parkedReopen = undefined
|
||||
return value
|
||||
}
|
||||
|
||||
/** In-flight thread creations keyed by tab id (double-mount guard: React
|
||||
* StrictMode / HMR must not mint two threads for one tab). */
|
||||
const inFlightStarts = new Set<string>()
|
||||
|
||||
/** Per-thread transcript cache: seed boundary + thread-own events merged by
|
||||
* seq (streaming polls never re-download the inherited seed). */
|
||||
interface ThreadCache {
|
||||
seedBoundary: number | null
|
||||
entries: SidebarHistoryEntry[]
|
||||
}
|
||||
|
||||
/** Row-render labels (locale-dependent, memoized once per mount). */
|
||||
interface RowLabels {
|
||||
copyLabel: string
|
||||
copiedLabel: string
|
||||
thinkLabel: string
|
||||
}
|
||||
|
||||
/** Merge history entries by event seq (newest wins), log order preserved. */
|
||||
function mergeBySeq(
|
||||
previous: readonly SidebarHistoryEntry[],
|
||||
incoming: readonly SidebarHistoryEntry[],
|
||||
): SidebarHistoryEntry[] {
|
||||
const bySeq = new Map<number, SidebarHistoryEntry>()
|
||||
for (const entry of previous) bySeq.set(entry.event.seq, entry)
|
||||
for (const entry of incoming) bySeq.set(entry.event.seq, entry)
|
||||
return [...bySeq.values()].sort((a, b) => a.event.seq - b.event.seq)
|
||||
}
|
||||
|
||||
/** The display title of a thread: the durable label minus the 'Side: '
|
||||
* prefix, with the fresh-thread placeholder localized. */
|
||||
function threadDisplayTitle(title: string): string {
|
||||
if (title === SIDE_NEW_THREAD_TITLE) return t('sideChatUntitled')
|
||||
return title.startsWith(SIDE_LABEL_PREFIX) ? title.slice(SIDE_LABEL_PREFIX.length) : title
|
||||
}
|
||||
|
||||
/** One row renderer (React keys ride the source event seq). */
|
||||
function renderRow(row: SidechatTranscriptRow, labels: RowLabels): React.ReactNode {
|
||||
switch (row.kind) {
|
||||
case 'user':
|
||||
return (
|
||||
<div key={row.seq} className={css.sidechatUser}>
|
||||
<MarkdownText text={row.text} codeLabels={labels} />
|
||||
</div>
|
||||
)
|
||||
case 'assistant':
|
||||
return (
|
||||
<div key={row.seq} className={css.sidechatAssistant}>
|
||||
<MarkdownText text={row.text} codeLabels={labels} />
|
||||
</div>
|
||||
)
|
||||
case 'reasoning':
|
||||
return (
|
||||
<details key={row.seq} className={css.sidechatReasoning}>
|
||||
<summary className={css.sidechatReasoningSummary}>{labels.thinkLabel}</summary>
|
||||
<div className={css.sidechatReasoningBody}>{row.text}</div>
|
||||
</details>
|
||||
)
|
||||
case 'tool':
|
||||
return (
|
||||
<details key={row.seq} className={css.sidechatTool}>
|
||||
<summary
|
||||
className={clsx(
|
||||
css.sidechatToolSummary,
|
||||
row.executing === true && css.sidechatToolExecuting,
|
||||
row.failed && css.sidechatToolFailed,
|
||||
)}
|
||||
>
|
||||
<StateDot
|
||||
state={row.executing === true ? 'ongoing' : row.failed ? 'error' : 'done'}
|
||||
size={8}
|
||||
/>
|
||||
<span className={css.sidechatToolName}>{row.name}</span>
|
||||
</summary>
|
||||
{row.args !== undefined && <pre className={css.sidechatToolArgs}>{row.args}</pre>}
|
||||
{row.resultText !== undefined && <pre className={css.sidechatToolResult}>{row.resultText}</pre>}
|
||||
</details>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** One side conversation tab (one thread per tab, Codex-style). */
|
||||
export function SideChatView(props: {
|
||||
ctx: Context
|
||||
scope: SessionScope
|
||||
tab: SidebarTab
|
||||
visible: boolean
|
||||
}): React.ReactNode {
|
||||
const { ctx, scope, tab, visible } = props
|
||||
const rowLabels = useMemo<RowLabels>(
|
||||
() => ({ copyLabel: t('copy'), copiedLabel: t('copied'), thinkLabel: t('sideChatThink') }),
|
||||
[],
|
||||
)
|
||||
|
||||
// The session list feed: thread rows (the header menu) + running states.
|
||||
const list = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
|
||||
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
|
||||
)
|
||||
const threads = useMemo(
|
||||
() => sideThreadRows(list.byId, scope.sessionId),
|
||||
[list, scope.sessionId],
|
||||
)
|
||||
|
||||
// The thread this tab is bound to rides tab.meta (refresh-restored).
|
||||
const threadId = sidechatThreadIdOf(tab)
|
||||
const autoCreate = (tab.meta as { autoCreate?: unknown } | undefined)?.autoCreate === true
|
||||
|
||||
const [composer, setComposer] = useState('')
|
||||
const [busy, setBusy] = useState<'starting' | 'sending' | 'saving' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [revision, setRevision] = useState(0)
|
||||
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const cacheRef = useRef<ThreadCache>({ seedBoundary: null, entries: [] })
|
||||
const controllerRef = useRef<AbortController | null>(null)
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const summary = threadId === undefined ? undefined : list.byId[threadId]
|
||||
const running = summary?.running === true
|
||||
|
||||
/** The agent-identity badge of the thread header (preset · model). */
|
||||
const agentBadge = useMemo(() => {
|
||||
if (info === null) return ''
|
||||
return [info.preset, info.model ?? info.provider].filter(Boolean).join(' · ')
|
||||
}, [info])
|
||||
|
||||
/** Create this tab's thread (immediate-create tabs and hero retries). */
|
||||
const startThread = useCallback(async (): Promise<void> => {
|
||||
if (inFlightStarts.has(tab.id)) return
|
||||
inFlightStarts.add(tab.id)
|
||||
setBusy('starting')
|
||||
setError(null)
|
||||
try {
|
||||
const { childId } = await api.sidechatStart(scope.sessionId)
|
||||
ctx.betterSidebar?.updateTab(tab.id, { meta: { threadId: childId } })
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
} finally {
|
||||
inFlightStarts.delete(tab.id)
|
||||
setBusy(null)
|
||||
}
|
||||
}, [ctx, scope.sessionId, tab.id])
|
||||
|
||||
// Codex-style immediate create: an autoCreate tab spawns its thread as
|
||||
// soon as it first renders.
|
||||
useEffect(() => {
|
||||
if (threadId !== undefined || !autoCreate || !visible) return
|
||||
void startThread()
|
||||
}, [threadId, autoCreate, visible, startThread])
|
||||
|
||||
// The tab title follows the thread's durable label (the first prompt
|
||||
// renames the thread; the strip picks it up here).
|
||||
useEffect(() => {
|
||||
const display = summary?.displayTitle
|
||||
if (display === undefined) return
|
||||
const title = threadDisplayTitle(display)
|
||||
if (title !== '' && title !== tab.title) {
|
||||
try {
|
||||
ctx.betterSidebar?.updateTab(tab.id, { title })
|
||||
} catch {
|
||||
// A stale title is cosmetic; the thread keeps working.
|
||||
}
|
||||
}
|
||||
}, [summary, tab.id, tab.title, ctx])
|
||||
|
||||
/** One transcript pull: the first read walks back to the seed boundary
|
||||
* (big pages — chunk deltas re-expand on cold reads), later reads fetch
|
||||
* one tail page and merge (seq-deduped). */
|
||||
const fetchThread = useCallback(async (childId: string): Promise<void> => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
const cache = cacheRef.current
|
||||
try {
|
||||
if (cache.seedBoundary === null) {
|
||||
const walk = await collectOwnEvents(async (beforeSeq) => {
|
||||
const response = await ctx.connection.api.sessions.history(
|
||||
{
|
||||
sessionId: childId,
|
||||
maxMessages: WALK_PAGE_EVENTS,
|
||||
...(beforeSeq === undefined ? {} : { beforeSeq }),
|
||||
},
|
||||
controller.signal,
|
||||
)
|
||||
if (!response.result.ok) throw new Error('history walk failed')
|
||||
return response.result.value.events
|
||||
})
|
||||
cache.seedBoundary = walk.seedBoundary
|
||||
cache.entries = mergeBySeq(cache.entries, walk.entries)
|
||||
} else {
|
||||
const response = await ctx.connection.api.sessions.history(
|
||||
{ sessionId: childId, maxMessages: PAGE_MESSAGES },
|
||||
controller.signal,
|
||||
)
|
||||
if (!response.result.ok) return
|
||||
cache.entries = mergeBySeq(cache.entries, response.result.value.events)
|
||||
}
|
||||
setRevision(value => value + 1)
|
||||
} catch {
|
||||
// Aborted by a newer pull or a wire failure: keep the last rows.
|
||||
}
|
||||
}, [ctx])
|
||||
|
||||
/** The thread header badge pull (live state + preset/model identity). */
|
||||
const fetchInfo = useCallback(async (childId: string): Promise<void> => {
|
||||
try {
|
||||
setInfo(await api.sidechatInfo(childId))
|
||||
} catch {
|
||||
// The badge is decorative; a wire failure keeps the last value.
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Reset the transcript cache whenever the binding changes, then focus
|
||||
// the composer — it owns the first message of a fresh thread.
|
||||
useEffect(() => {
|
||||
cacheRef.current = { seedBoundary: null, entries: [] }
|
||||
controllerRef.current?.abort()
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
setInfo(null)
|
||||
if (threadId !== undefined) {
|
||||
void fetchInfo(threadId)
|
||||
window.setTimeout(() => composerRef.current?.focus(), 0)
|
||||
}
|
||||
}, [threadId, fetchInfo])
|
||||
|
||||
// Poll while the tab is visible and the thread runs.
|
||||
useEffect(() => {
|
||||
if (!visible || threadId === undefined) return
|
||||
void fetchThread(threadId)
|
||||
if (!running) return
|
||||
const timer = window.setInterval(() => {
|
||||
void fetchThread(threadId)
|
||||
void fetchInfo(threadId)
|
||||
}, POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [visible, threadId, running, fetchThread, fetchInfo])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
const rows = useMemo(
|
||||
() => (threadId === undefined ? [] : transcriptRows(cacheRef.current.entries)),
|
||||
// The cache is a ref; revision bumps on every successful pull.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[threadId, revision],
|
||||
)
|
||||
const canSave = threadId !== undefined && threadHasCompletedTurn(cacheRef.current.entries)
|
||||
const trailingPending = threadId !== undefined && threadTrailingPending(cacheRef.current.entries)
|
||||
const freshThread = threadId !== undefined && rows.length === 0
|
||||
|
||||
// Follow the stream: stick to the bottom while the log grows.
|
||||
useEffect(() => {
|
||||
const scroller = scrollRef.current
|
||||
if (scroller === null) return
|
||||
scroller.scrollTop = scroller.scrollHeight
|
||||
}, [rows.length, threadId])
|
||||
|
||||
/** Open a NEW thread tab (createTab mints the autoCreate tab; its view
|
||||
* creates the thread on mount). */
|
||||
const openNewThread = (): void => {
|
||||
setMenuOpen(false)
|
||||
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
|
||||
}
|
||||
|
||||
/** Switch to an existing thread: parked for createTab, deduped to the
|
||||
* already-open tab when there is one. */
|
||||
const openExistingThread = (id: string): void => {
|
||||
setMenuOpen(false)
|
||||
if (id === threadId) return
|
||||
parkSidechatReopen(id)
|
||||
ctx.betterSidebar?.openTab({ type: 'sidechat' }, scope)
|
||||
}
|
||||
|
||||
const menuItems = useMemo<MenuEntry[]>(() => {
|
||||
const items: MenuEntry[] = [
|
||||
{ id: '$new', label: t('sideChatNew'), icon: <IconPlusOutline16 /> },
|
||||
]
|
||||
if (threads.length > 0) {
|
||||
items.push({ type: 'separator', id: '$sep' })
|
||||
for (const row of threads) {
|
||||
items.push({
|
||||
id: row.id,
|
||||
label: threadDisplayTitle(row.title),
|
||||
...(row.running ? { icon: <StateDot state="ongoing" size={8} /> } : {}),
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [threads])
|
||||
|
||||
const growComposer = (): void => {
|
||||
const field = composerRef.current
|
||||
if (field === null) return
|
||||
field.style.height = '0px'
|
||||
field.style.height = `${Math.min(field.scrollHeight, COMPOSER_MAX_HEIGHT)}px`
|
||||
}
|
||||
|
||||
const handleSend = async (): Promise<void> => {
|
||||
const text = composer.trim()
|
||||
if (text === '' || threadId === undefined || busy !== null) return
|
||||
setBusy('sending')
|
||||
setError(null)
|
||||
try {
|
||||
await api.sidechatPrompt(threadId, text)
|
||||
setComposer('')
|
||||
const field = composerRef.current
|
||||
if (field !== null) field.style.height = ''
|
||||
void fetchThread(threadId)
|
||||
void fetchInfo(threadId)
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = async (): Promise<void> => {
|
||||
if (threadId === undefined || busy !== null) return
|
||||
try {
|
||||
await api.sidechatCancel(threadId)
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (threadId === undefined || !canSave || busy !== null) return
|
||||
setBusy('saving')
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
try {
|
||||
// NOTE: fork must stay a METHOD call — `ctx.sessions.fork` is the
|
||||
// client-runtime sessions service, and an unbound reference loses
|
||||
// `this` (its fork reads this.list for the title bump).
|
||||
if (ctx.sessions.fork === undefined) throw new Error('session fork is unavailable')
|
||||
const newId = await ctx.sessions.fork({ sessionId: threadId, increaseTitle: true })
|
||||
const title = summary === undefined ? '' : threadDisplayTitle(summary.displayTitle).trim()
|
||||
const binding = ctx.sessions.binding?.(newId)
|
||||
if (binding !== undefined && title !== '') {
|
||||
await binding.session.rename(title)
|
||||
}
|
||||
ctx.sessions.open?.(newId)
|
||||
setSaved(true)
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
// ── unbound tab: the hero (fresh autoCreate tabs flash a creating state
|
||||
// until the thread lands; legacy persisted tabs offer a manual start) ──
|
||||
if (threadId === undefined) {
|
||||
return (
|
||||
<div className={css.sidechat}>
|
||||
<div className={css.sidechatHero}>
|
||||
<IconNewChatOutline16 />
|
||||
<div className={css.sidechatHeroTitle}>
|
||||
{busy === 'starting' ? t('sideChatCreating') : t('sideChatEmpty')}
|
||||
</div>
|
||||
<div className={css.sidechatHeroDesc}>{t('sideChatEmptyDesc')}</div>
|
||||
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
|
||||
{busy !== 'starting' && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.sidechatPrimaryBtn}
|
||||
onClick={() => void startThread()}
|
||||
>
|
||||
{error === null ? t('sideChatNew') : t('sideChatRetry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.sidechat}>
|
||||
<div className={css.sidechatDetailHeader}>
|
||||
{running && <StateDot state="ongoing" size={8} className={css.sidechatHeaderDot} />}
|
||||
{agentBadge !== '' && <span className={css.sidechatAgentBadge}>{agentBadge}</span>}
|
||||
<span className={css.sidechatHeaderSpacer} />
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.sidechatIconBtn}
|
||||
onClick={() => { setMenuOpen(value => !value) }}
|
||||
title={t('sideChatThreads')}
|
||||
>
|
||||
<IconHistoryOutline16 />
|
||||
</button>
|
||||
)}
|
||||
items={menuItems}
|
||||
selectedId={threadId}
|
||||
onSelect={(id) => { id === '$new' ? openNewThread() : openExistingThread(id) }}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
align="end"
|
||||
portal
|
||||
dense
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.sidechatIconBtn}
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || busy !== null}
|
||||
title={`${t('sideChatSave')} — ${t('sideChatSaveTitle')}`}
|
||||
>
|
||||
<IconSaveOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
{!canSave && !freshThread && <div className={css.sidechatHint}>{t('sideChatNoTurn')}</div>}
|
||||
{canSave && trailingPending
|
||||
&& <div className={css.sidechatHint}>{t('sideChatPendingDrop')}</div>}
|
||||
{saved && <div className={css.sidechatHint}>{t('sideChatSaved')}</div>}
|
||||
{error !== null && <div className={css.sidechatError}>{t('sideChatError', { message: error })}</div>}
|
||||
<div ref={scrollRef} className={css.sidechatScroll}>
|
||||
{rows.map(row => renderRow(row, rowLabels))}
|
||||
</div>
|
||||
{running && (
|
||||
<div className={css.sidechatStatus}>
|
||||
<StateDot state="ongoing" size={8} />
|
||||
<span className={css.sidechatStatusText}>{t('sideChatThinking')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={css.sidechatComposer}>
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
className={css.sidechatComposerInput}
|
||||
value={composer}
|
||||
placeholder={freshThread ? t('sideChatFirstPlaceholder') : t('sideChatComposerPlaceholder')}
|
||||
rows={1}
|
||||
onChange={event => {
|
||||
setComposer(event.target.value)
|
||||
growComposer()
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
|
||||
event.preventDefault()
|
||||
void handleSend()
|
||||
}}
|
||||
/>
|
||||
<div className={css.sidechatComposerBar}>
|
||||
<span className={css.sidechatComposerMeta}>
|
||||
{running ? '' : agentBadge}
|
||||
</span>
|
||||
{running ? (
|
||||
<button
|
||||
key="stop"
|
||||
type="button"
|
||||
className={css.sidechatSendBtn}
|
||||
onClick={() => void handleCancel()}
|
||||
disabled={busy !== null}
|
||||
title={t('sideChatCancelTitle')}
|
||||
>
|
||||
<IconStopFill16 />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
key="send"
|
||||
type="button"
|
||||
className={css.sidechatSendBtn}
|
||||
onClick={() => void handleSend()}
|
||||
disabled={composer.trim() === '' || busy !== null}
|
||||
title={t('sideChatSend')}
|
||||
>
|
||||
<IconSendOutline16 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+209
-75
@@ -28,7 +28,7 @@
|
||||
* drawer floats). Widening does not migrate back: the tabs keep living in
|
||||
* the right tree.
|
||||
*/
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { createElement, useCallback, useEffect, useMemo, useRef, useState, type DragEvent, type ReactNode } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCloseFill14, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -44,6 +44,9 @@ import { IconPanelBottomOutline16, IconPanelRightOutline16 } from './icons.tsx'
|
||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||
import { useNarrowViewport } from './breakpoints.ts'
|
||||
import { parseDesktopEnv } from './desktop-env.ts'
|
||||
import { getWcoSnapshot, subscribeWco } from './wco.ts'
|
||||
import { getShellPreset } from './shell-presets.ts'
|
||||
import { computeTitleBarStrip } from './titlebar-strip.ts'
|
||||
import type { NewTabOption } from './TabBar.tsx'
|
||||
import type { TabDragPayload } from './TabBar.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
@@ -59,6 +62,47 @@ import css from './sidebar.module.css'
|
||||
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
|
||||
const FAILURE_LIMIT = 3
|
||||
|
||||
/**
|
||||
* OS file drags over the sidebar belong to the sidebar, not to the chat:
|
||||
* DSH's composer (InputBar) listens for file drags on the DOCUMENT and
|
||||
* answers with a full-screen "drop image here" mask plus image intake on
|
||||
* drop. Both panel-host render sites swallow the whole event quartet —
|
||||
* enter/over/leave/drop — so the region is a black hole to that document
|
||||
* listener. All four must be stopped: InputBar keeps an enter/leave depth
|
||||
* counter, and a leave that escapes without its matching enter unbalances
|
||||
* the count (this was the full-screen mask flickering over the sidebar).
|
||||
* The conversation column keeps DSH's native overlay and intake untouched;
|
||||
* gated on the 'Files' type so in-app drags (tab reorder, split zones)
|
||||
* propagate exactly as before.
|
||||
*/
|
||||
const swallowOsFileDrag = (event: DragEvent): void => {
|
||||
if (!(event.dataTransfer?.types.includes('Files') ?? false)) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
/** The four drag events a file drag must never carry past the panel host. */
|
||||
const osFileDragShield = {
|
||||
onDragEnter: swallowOsFileDrag,
|
||||
onDragOver: swallowOsFileDrag,
|
||||
onDragLeave: swallowOsFileDrag,
|
||||
onDrop: swallowOsFileDrag,
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one user-space stylesheet (preset or custom CSS) as a tagged
|
||||
* `<style>` element. The tag attribute carries the source identity so the
|
||||
* running configuration is inspectable in DevTools; the returned tag is
|
||||
* removed by the caller's effect cleanup.
|
||||
*/
|
||||
function injectUserCss(attr: string, id: string, cssText: string): HTMLStyleElement {
|
||||
const tag = document.createElement('style')
|
||||
tag.setAttribute(attr, id)
|
||||
tag.textContent = cssText
|
||||
document.head.appendChild(tag)
|
||||
return tag
|
||||
}
|
||||
|
||||
/** Render the content of one tab (dispatched by type). */
|
||||
function TabContent(props: {
|
||||
tab: SidebarTab
|
||||
@@ -202,25 +246,31 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
return () => { document.body.removeAttribute('data-dsh-sidebar-collapsed') }
|
||||
}, [collapsed])
|
||||
|
||||
// Position compatibility mode (titleBarCompat pref): Windows frameless
|
||||
// windows draw the native title bar (minimize/maximize/close) at the
|
||||
// window's top-right corner, OVER the web content. When enabled, the body
|
||||
// attribute lets sidebar.module.css drop the toggle cluster below the
|
||||
// strip and push the right panel's content below it. Auto-enabled in the
|
||||
// win32 advanced desktop shell (the shell stamps the URL — see
|
||||
// desktop-env.ts — and reserves a 32px overlay for the window controls);
|
||||
// the manual pref and its strip height still win when the user sets them,
|
||||
// and the manual switch only ever ADDS compat. The strip height rides a
|
||||
// CSS variable so the rules stay declarative; the attribute rides the
|
||||
// snapshot's prefs, so flipping the setting re-renders and re-applies
|
||||
// immediately; the cleanup removes both on unmount/boundary swap so a
|
||||
// crashed sidebar never leaves them behind.
|
||||
// Title-bar / shell compatibility (the "位置兼容模式" scheme):
|
||||
// auto — CONSERVATIVE: only the standard Window Controls Overlay
|
||||
// geometry contributes (the real caption-overlay height,
|
||||
// reactive to maximize/restore). No URL stamp, no preset, no
|
||||
// guess — plain browsers see zero modification.
|
||||
// preset — an opt-in built-in shell preset (shell-presets.ts) adds its
|
||||
// per-shell strip as the no-WCO fallback.
|
||||
// custom — the user's own CSS (injected below) + the legacy manual
|
||||
// strip px.
|
||||
// The resolved strip drives the SAME body attribute + CSS variable as the
|
||||
// legacy boolean did, so the CSS contract is unchanged (layout.css /
|
||||
// sidebar.module.css); only the value source changed. The cleanup removes
|
||||
// both on unmount/boundary swap so a crashed sidebar never leaves them
|
||||
// behind.
|
||||
const desktopEnv = parseDesktopEnv()
|
||||
const autoTitleBarCompat = desktopEnv.win32OverlayTop > 0
|
||||
const titleBarCompat = snapshot.prefs.titleBarCompat || autoTitleBarCompat
|
||||
const titleBarStrip = snapshot.prefs.titleBarCompat
|
||||
? snapshot.prefs.titleBarStripPx
|
||||
: desktopEnv.win32OverlayTop
|
||||
const wco = useSyncExternalStore(
|
||||
useMemo(() => subscribeWco, []),
|
||||
getWcoSnapshot,
|
||||
)
|
||||
const scheme = snapshot.prefs.titleBarScheme
|
||||
const preset = scheme === 'preset' ? getShellPreset(snapshot.prefs.titleBarPresetId) : undefined
|
||||
const titleBarStrip = computeTitleBarStrip(
|
||||
desktopEnv, wco, scheme, preset, snapshot.prefs.titleBarStripPx,
|
||||
)
|
||||
const titleBarCompat = titleBarStrip > 0
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (titleBarCompat) {
|
||||
@@ -236,6 +286,22 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}
|
||||
}, [titleBarCompat, titleBarStrip])
|
||||
|
||||
// User-space CSS injection (the escape hatch): preset CSS (scheme
|
||||
// `preset`) and free-form custom CSS (scheme `custom`) are appended AFTER
|
||||
// the plugin's own styles — later in the cascade wins ties, and
|
||||
// `!important` can override the JS-written inline strip variable. Each
|
||||
// source gets its own tagged <style> so the running configuration stays
|
||||
// inspectable; tags are removed on change/unmount so a stale stylesheet
|
||||
// never outlives its fiber (HMR-safe).
|
||||
const presetCss = scheme === 'preset' ? preset?.css ?? '' : ''
|
||||
const customCss = scheme === 'custom' ? snapshot.prefs.customCss : ''
|
||||
useEffect(() => {
|
||||
const tags: HTMLStyleElement[] = []
|
||||
if (presetCss !== '') tags.push(injectUserCss('data-dsh-preset-css', preset?.id ?? '', presetCss))
|
||||
if (customCss !== '') tags.push(injectUserCss('data-dsh-custom-css', 'custom', customCss))
|
||||
return () => { for (const tag of tags) tag.remove() }
|
||||
}, [presetCss, customCss, preset?.id])
|
||||
|
||||
/**
|
||||
* Bottom-panel merge on narrow viewports: whenever a session is current
|
||||
* while narrow (mount, session switch, or a desktop→narrow transition),
|
||||
@@ -411,6 +477,14 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
if (draggingRef.current) return
|
||||
const col = centerColRef.current
|
||||
if (col === null) return
|
||||
if (!col.isConnected) {
|
||||
// The observed column was detached (HMR re-render swapped the node
|
||||
// in place): its rect is stale garbage. Drop the ref — the locate
|
||||
// chain re-runs on the next mutation/interval tick and picks up the
|
||||
// new column node (issue #248).
|
||||
centerColRef.current = 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,
|
||||
@@ -429,15 +503,15 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// 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).
|
||||
// query may miss it. Never give up: watch #root's subtree (the swap and
|
||||
// HMR re-renders mutate it) 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 (col === undefined || !col.isConnected) {
|
||||
if (centerColRef.current !== null) {
|
||||
centerColRef.current = null
|
||||
observer?.disconnect()
|
||||
@@ -446,17 +520,33 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
return
|
||||
}
|
||||
if (centerColRef.current !== col) {
|
||||
// A NEW column node (boot swap, HMR re-render, or a previous locate
|
||||
// that found nothing): attach the ResizeObserver to THIS node and
|
||||
// measure it once. Same-node size changes are the ResizeObserver's
|
||||
// job — no forced measurement here, because a forced
|
||||
// getBoundingClientRect per mutation would reflow the shell at
|
||||
// mutation cadence.
|
||||
centerColRef.current = col
|
||||
observer?.disconnect()
|
||||
observer = new ResizeObserver(measureCenter)
|
||||
observer.observe(col)
|
||||
measureCenter()
|
||||
}
|
||||
measureCenter()
|
||||
}
|
||||
locate()
|
||||
const watcher = new MutationObserver(locate)
|
||||
// rAF-debounce the mutation watchers: #root's subtree changes at chat
|
||||
// cadence (streaming turns), and locate() itself must stay cheap.
|
||||
let locateFrame: number | null = null
|
||||
const scheduleLocate = (): void => {
|
||||
if (locateFrame !== null) return
|
||||
locateFrame = requestAnimationFrame(() => {
|
||||
locateFrame = null
|
||||
locate()
|
||||
})
|
||||
}
|
||||
const watcher = new MutationObserver(scheduleLocate)
|
||||
const root = document.getElementById('root')
|
||||
if (root !== null) watcher.observe(root, { childList: true })
|
||||
if (root !== null) watcher.observe(root, { childList: true, subtree: true })
|
||||
// The layout push writes --dsh-sidebar-* on <html>. A HMR re-activation
|
||||
// clears those variables on teardown and re-writes them on setup — and
|
||||
// that is also the moment the shell may have re-created the center
|
||||
@@ -465,10 +555,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// Watching <html>'s style attribute catches that re-sync: the push
|
||||
// rewrite re-locates and re-measures, so the bottom panel recovers
|
||||
// instead of staying hidden on a stale {0,0} center rect.
|
||||
const htmlStyleWatcher = new MutationObserver(locate)
|
||||
const htmlStyleWatcher = new MutationObserver(scheduleLocate)
|
||||
htmlStyleWatcher.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
|
||||
// Last-resort safety net (issue #248): no watcher is guaranteed to fire
|
||||
// for every HMR teardown/setup interleaving (e.g. the style attribute
|
||||
// may end up byte-identical, and the col may be swapped before the
|
||||
// subtree watcher attaches). A slow unconditional re-locate makes the
|
||||
// panel converge on the real column within a couple of seconds no
|
||||
// matter what sequence the shell used. locate() is cheap when nothing
|
||||
// changed (one querySelector + an identity compare; no forced layout).
|
||||
const retry = window.setInterval(locate, 1500)
|
||||
return () => {
|
||||
disposed = true
|
||||
if (locateFrame !== null) cancelAnimationFrame(locateFrame)
|
||||
window.clearInterval(retry)
|
||||
observer?.disconnect()
|
||||
watcher.disconnect()
|
||||
htmlStyleWatcher.disconnect()
|
||||
@@ -556,12 +656,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
// (issue #106: skins that inset the panels must not fight JS coords).
|
||||
}
|
||||
|
||||
/** Last size a drag actually applied to the DOM (updated by applyDrag).
|
||||
* When a pointer stream dies without any position info (issue #247: an
|
||||
* ultra-fast flick whose release events carried no usable coordinates),
|
||||
* the abort path adopts this instead of rolling back to the pre-drag
|
||||
* value — the DOM's current size is the only truthful record left. */
|
||||
const lastDragSize = useRef<{ width: number; height: number } | null>(null)
|
||||
|
||||
/** Apply a drag size to the DOM without touching React state or the store.
|
||||
* The bottom panel's right edge tracks the right panel's left edge HERE
|
||||
* too — React state only updates on release, so the inline right must be
|
||||
* written directly or the bottom panel would lag the sidebar mid-drag.
|
||||
* The layout push rides the shared writer (writeGeometry). */
|
||||
const applyDrag = (width: number, height: number): void => {
|
||||
lastDragSize.current = { width, height }
|
||||
panelRef.current?.style.setProperty('width', `${width}px`)
|
||||
bottomRef.current?.style.setProperty('height', `${height}px`)
|
||||
// centerRect.right is the center column's right edge at the committed
|
||||
@@ -640,9 +748,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
* and an ultra-fast flick can cancel the stream before ANY move lands.
|
||||
* The commit order is therefore: the LAST KNOWN dragged size (the rAF
|
||||
* pending value) first, then the interrupting event's own pointer
|
||||
* position (pointercancel / lostpointercapture still carry coordinates),
|
||||
* and only a drag that produced neither (pure down+up at the same spot)
|
||||
* reverts to the store's committed sizes.
|
||||
* position (only pointercancel is trusted to carry coordinates —
|
||||
* lostpointercapture's coordinates are not guaranteed, so the handlers
|
||||
* pass the event only from pointercancel), and finally the size the drag
|
||||
* last APPLIED to the DOM (lastDragSize). A drag that produced none of
|
||||
* those (pure down+up at the same spot) commits the store's own sizes —
|
||||
* a no-op, never an explicit rollback (issue #247: v0.13.1 never reverted
|
||||
* an interrupted fast flick; the abort path added in the unified-host
|
||||
* refactor did, and that regression is what this ordering removes).
|
||||
*
|
||||
* Every commit path marks the drag committed, so the interrupt
|
||||
* double-fire (pointercancel → lostpointercapture) cannot commit once
|
||||
* and then roll the same drag back.
|
||||
*/
|
||||
const abortDrag = (reset: () => void, event?: { clientX: number; clientY: number }): void => {
|
||||
if (dragCommitted.current) return
|
||||
@@ -653,8 +770,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
width = pending.width
|
||||
height = pending.height
|
||||
} else if (event !== undefined) {
|
||||
// No move ever landed: the cancel/lost-capture position is all we
|
||||
// have — commit it (clamped) instead of rolling back the flick.
|
||||
// No move ever landed: the cancel position is all we have — commit it
|
||||
// (clamped) instead of rolling back the flick.
|
||||
if (draggingWidth) {
|
||||
width = clampWidth(widthDrag.current.startWidth + (widthDrag.current.startX - event.clientX))
|
||||
height = state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0
|
||||
@@ -667,6 +784,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}
|
||||
}
|
||||
if (width !== undefined && height !== undefined) {
|
||||
dragCommitted.current = true
|
||||
pendingDrag.current = null
|
||||
if (dragFrame.current !== null) {
|
||||
cancelAnimationFrame(dragFrame.current)
|
||||
@@ -677,11 +795,25 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
measureCenter()
|
||||
store.reduce(s => setBottomHeight(setWidth(s, width), height))
|
||||
} else {
|
||||
// No pending write and no usable event coordinates: keep the size the
|
||||
// drag last applied instead of rolling back to the pre-drag value
|
||||
// (the flick's moves may have been consumed by the rAF just before
|
||||
// the stream died — the DOM already shows the dragged size). Clamp to
|
||||
// the current geometry like the layout-push effect (closed/narrow
|
||||
// panels are written 0 by the push).
|
||||
dragCommitted.current = true
|
||||
stopDragScheduling()
|
||||
applyDrag(
|
||||
!narrow && state?.panelOpen === true ? Math.min(state?.width ?? 0, window.innerWidth) : 0,
|
||||
!narrow && state?.bottomOpen === true ? Math.min(state.bottomHeight, window.innerHeight) : 0,
|
||||
)
|
||||
const last = lastDragSize.current
|
||||
const adoptedWidth = !narrow && state?.panelOpen === true
|
||||
? Math.min(last?.width ?? state?.width ?? 0, window.innerWidth)
|
||||
: 0
|
||||
const adoptedHeight = !narrow && state?.bottomOpen === true
|
||||
? Math.min(last?.height ?? state?.bottomHeight ?? 0, window.innerHeight)
|
||||
: 0
|
||||
applyDrag(adoptedWidth, adoptedHeight)
|
||||
draggingRef.current = false
|
||||
measureCenter()
|
||||
store.reduce(s => setBottomHeight(setWidth(s, adoptedWidth), adoptedHeight))
|
||||
}
|
||||
reset()
|
||||
}
|
||||
@@ -701,18 +833,25 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
? Math.min(snapshot.state.bottomHeight, window.innerHeight)
|
||||
: 0
|
||||
writeGeometry(width, height)
|
||||
// Unmount must release the push (issue #31): when the boundary swaps the
|
||||
// whole sidebar after a render crash (or the plugin fiber is disposed /
|
||||
// HMR), the CSS variables would otherwise stay on <html> and layout.css
|
||||
// keeps squeezing #root with a stale margin — "the sidebar cannot be
|
||||
// hidden" until a full reload. removeProperty restores the CSS fallback
|
||||
// (var(--dsh-sidebar-width, 0px)); React re-runs cleanup+setup in the
|
||||
// same commit on state changes, so there is no visible flicker.
|
||||
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
|
||||
// Unmount must release the push (issue #31): when the boundary swaps the
|
||||
// whole sidebar after a render crash (or the plugin fiber is disposed /
|
||||
// HMR), the CSS variables would otherwise stay on <html> and layout.css
|
||||
// keeps squeezing #root with a stale margin — "the sidebar cannot be
|
||||
// hidden" until a full reload. This lives in an UNMOUNT-ONLY effect, NOT
|
||||
// in the push effect's cleanup: React can yield between a passive
|
||||
// effect's cleanup and setup phases, and removing the variables on a
|
||||
// dependency change used to paint the push-less layout for a frame (the
|
||||
// center column went full width) while the re-add restarted the margin
|
||||
// transition — the bottom panel flashed full width after every width
|
||||
// drag (issue #258). Keeping the variables continuously valid while
|
||||
// mounted makes the push invisible to mid-flush style recals.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.documentElement.style.removeProperty('--dsh-sidebar-width')
|
||||
document.documentElement.style.removeProperty('--dsh-sidebar-height')
|
||||
}
|
||||
}, [narrow, snapshot.state?.panelOpen, snapshot.state?.width, snapshot.state?.bottomOpen, snapshot.state?.bottomHeight])
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (anyDragging) document.body.setAttribute('data-dsh-sidebar-dragging', '')
|
||||
else document.body.removeAttribute('data-dsh-sidebar-dragging')
|
||||
@@ -790,8 +929,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
|
||||
if (state === undefined || sessionId === undefined) {
|
||||
return (
|
||||
<div data-dsh-panel-host>
|
||||
<div className={css.toggleCluster}>
|
||||
<div data-dsh-panel-host {...osFileDragShield}>
|
||||
<div className={css.toggleCluster} data-dsh-toggle-cluster>
|
||||
{!narrow && (
|
||||
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
|
||||
@@ -876,7 +1015,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-dsh-panel-host>
|
||||
<div data-dsh-panel-host {...osFileDragShield}>
|
||||
{/*
|
||||
The persistent toggle cluster at the top-right corner: the bottom
|
||||
panel's button (bottom glyph) LEFT of the right panel's (side glyph).
|
||||
@@ -885,7 +1024,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
right end it really squeezes (the strip reserves its width via CSS),
|
||||
so the tabs genuinely yield space to it.
|
||||
*/}
|
||||
<div className={css.toggleCluster}>
|
||||
<div className={css.toggleCluster} data-dsh-toggle-cluster>
|
||||
{/*
|
||||
Narrow viewports merge the two workbenches into the one drawer —
|
||||
there is no bottom panel, so its toggle button is not offered.
|
||||
@@ -925,6 +1064,7 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
|
||||
data-dsh-panel
|
||||
style={{
|
||||
width: narrow ? '100vw' : Math.min(state.width, window.innerWidth),
|
||||
// Narrow drawer: keep the bottom-anchored sheet above the on-screen
|
||||
@@ -955,22 +1095,20 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
if (dragCommitted.current) return
|
||||
dragCommitted.current = true
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startWidth } = widthDrag.current
|
||||
// A very fast release can merge/lose pointermove bursts:
|
||||
// prefer the LAST KNOWN dragged width (already clamped by
|
||||
// the move handler) over the up position, which may equal
|
||||
// the down position when no move landed.
|
||||
const pending = pendingDrag.current
|
||||
const width = pending !== null
|
||||
? pending.width
|
||||
: clampWidth(startWidth + (startX - event.clientX))
|
||||
// The up position is the pointer's FINAL position — a fast
|
||||
// flick's tail is coalesced into the up event, so the last
|
||||
// pointermove (the rAF pending value) can be stale. Commit
|
||||
// from the up position (v0.13.1 semantics; issue #247).
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = state.bottomOpen ? Math.min(state.bottomHeight, window.innerHeight) : 0
|
||||
commitDrag(width, height, s => setWidth(s, width))
|
||||
setDraggingWidth(false)
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag(() => setDraggingWidth(false)) }}
|
||||
onPointerCancel={(event) => { abortDrag(() => setDraggingWidth(false), event) }}
|
||||
onLostPointerCapture={() => { abortDrag(() => setDraggingWidth(false)) }}
|
||||
/>
|
||||
)}
|
||||
@@ -1021,22 +1159,18 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
if (dragCommitted.current) return
|
||||
dragCommitted.current = true
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startX, startY, startWidth, startHeight } = cornerDrag.current
|
||||
// Last known dragged size wins over the up position (fast
|
||||
// releases can merge/lose pointermove bursts).
|
||||
const pending = pendingDrag.current
|
||||
const width = pending !== null
|
||||
? pending.width
|
||||
: clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = pending !== null
|
||||
? pending.height
|
||||
: clampHeight(startHeight + (startY - event.clientY))
|
||||
// Up position wins over the rAF pending value (see the width
|
||||
// strip handler — issue #247).
|
||||
const width = clampWidth(startWidth + (startX - event.clientX))
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
commitDrag(width, height, s => setBottomHeight(setWidth(s, width), height))
|
||||
setDraggingCorner(false)
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag(() => setDraggingCorner(false)) }}
|
||||
onPointerCancel={(event) => { abortDrag(() => setDraggingCorner(false), event) }}
|
||||
onLostPointerCapture={() => { abortDrag(() => setDraggingCorner(false)) }}
|
||||
/>
|
||||
)}
|
||||
@@ -1061,6 +1195,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
<div
|
||||
ref={bottomRef}
|
||||
className={clsx(css.bottomPanel, !state.bottomOpen && css.bottomPanelHidden)}
|
||||
data-dsh-panel
|
||||
data-dsh-bottom-panel
|
||||
style={{
|
||||
height: Math.min(state.bottomHeight, window.innerHeight),
|
||||
left: centerRect.left,
|
||||
@@ -1102,19 +1238,17 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||
if (dragCommitted.current) return
|
||||
dragCommitted.current = true
|
||||
event.currentTarget.releasePointerCapture(event.pointerId)
|
||||
const { startY, startHeight } = bottomDrag.current
|
||||
// Last known dragged height wins over the up position (fast
|
||||
// releases can merge/lose pointermove bursts).
|
||||
const pending = pendingDrag.current
|
||||
const height = pending !== null
|
||||
? pending.height
|
||||
: clampHeight(startHeight + (startY - event.clientY))
|
||||
// Up position wins over the rAF pending value (see the width
|
||||
// strip handler — issue #247).
|
||||
const height = clampHeight(startHeight + (startY - event.clientY))
|
||||
commitDrag(Math.min(state.width, window.innerWidth), height, s => setBottomHeight(s, height))
|
||||
setDraggingBottom(false)
|
||||
}}
|
||||
onPointerCancel={() => { abortDrag(() => setDraggingBottom(false)) }}
|
||||
onPointerCancel={(event) => { abortDrag(() => setDraggingBottom(false), event) }}
|
||||
onLostPointerCapture={() => { abortDrag(() => setDraggingBottom(false)) }}
|
||||
/>
|
||||
{/*
|
||||
|
||||
+86
-69
@@ -38,9 +38,11 @@ import type {
|
||||
import {
|
||||
collectBranchIds,
|
||||
countSubagentDescendants,
|
||||
isSideThreadSummary,
|
||||
rootAncestor,
|
||||
} from './subagent-detect.ts'
|
||||
import { lastActivity } from './subagent-activity.ts'
|
||||
import { type LastActivity } from '../subagent-activity.ts'
|
||||
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
|
||||
import {
|
||||
collectTreeJobs,
|
||||
formatJobDuration,
|
||||
@@ -64,13 +66,16 @@ const JOB_POLL_MS = 2000
|
||||
/** How long the kill button stays armed before it needs re-confirming. */
|
||||
const JOB_KILL_ARM_MS = 3000
|
||||
|
||||
/** The direct subagent children of one parent (durable `origin` rows). */
|
||||
/** The direct subagent children of one parent (durable `origin` rows;
|
||||
* Side Chat threads ride the same origin but are tab-strip conversations,
|
||||
* never topology). */
|
||||
function directChildren(
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||
parentSessionId: string,
|
||||
): SidebarSessionSummary[] {
|
||||
return Object.values(byId).filter(
|
||||
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId,
|
||||
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId
|
||||
&& !isSideThreadSummary(summary),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -145,56 +150,14 @@ function CatalogLoadingRows(props: {
|
||||
}
|
||||
|
||||
/**
|
||||
* The live lines of one RUNNING subagent card: the last text output and the
|
||||
* last tool call of the child's history tail, refreshed every few seconds
|
||||
* while the page is visible. Idle cards render nothing (a quiet topology); a
|
||||
* running child with neither output yet reads "thinking…".
|
||||
* The live lines of one RUNNING subagent card: a pure presentation of the
|
||||
* batch `subagents.live` activity. The polling lives in one place (the
|
||||
* SubagentView hook), not per card. A running child with neither output yet
|
||||
* reads "thinking…".
|
||||
*/
|
||||
function SubagentLiveLines(props: {
|
||||
ctx: Context
|
||||
parentSessionId: string
|
||||
childSessionId: string
|
||||
mode: SidebarSubagentAddress['mode']
|
||||
running: boolean
|
||||
/** The page is visible (active tab + open panel): skip polling otherwise. */
|
||||
active: boolean
|
||||
}) {
|
||||
const { ctx, parentSessionId, childSessionId, mode, running, active } = props
|
||||
const [live, setLive] = useState<ReturnType<typeof lastActivity>>({})
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
const address = useMemo(
|
||||
() => ({ parentSessionId, childSessionId, mode }),
|
||||
[parentSessionId, childSessionId, mode],
|
||||
)
|
||||
|
||||
const load = useCallback(async (): Promise<void> => {
|
||||
controllerRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const response = await ctx.connection.api.subagents.history(
|
||||
{ ...address, maxMessages: 12 },
|
||||
controller.signal,
|
||||
)
|
||||
if (!response.result.ok) return
|
||||
setLive(lastActivity(response.result.value.events))
|
||||
} catch {
|
||||
// Aborted by a newer pull or a wire failure: keep the last known lines.
|
||||
}
|
||||
}, [ctx, address])
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
void load()
|
||||
if (!running) return
|
||||
const timer = window.setInterval(() => { void load() }, POLL_MS)
|
||||
return () => { window.clearInterval(timer) }
|
||||
}, [load, running, active])
|
||||
|
||||
useEffect(() => () => { controllerRef.current?.abort() }, [])
|
||||
|
||||
if (!running) return null
|
||||
if (live.text === undefined && live.tool === undefined) {
|
||||
function SubagentLiveLines(props: { live: LastActivity | undefined }) {
|
||||
const { live } = props
|
||||
if (live?.text === undefined && live?.tool === undefined) {
|
||||
return <span className={css.subagentLive}>{t('subagentThinking')}</span>
|
||||
}
|
||||
return (
|
||||
@@ -214,6 +177,59 @@ function SubagentLiveLines(props: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared live-preview poller for the whole Subagent tree. Unlike the old
|
||||
* per-card `subagents.history` timers, this sends at most ONE `subagents.live`
|
||||
* request at a time: a recursive timeout starts only after the previous
|
||||
* request settles, so a slow host never sees abort/restart storms.
|
||||
*/
|
||||
function useSubagentLive(
|
||||
rootId: string | undefined,
|
||||
active: boolean,
|
||||
): Readonly<Record<string, LastActivity>> {
|
||||
const [live, setLive] = useState<Record<string, LastActivity>>({})
|
||||
const controllerRef = useRef<AbortController | undefined>(undefined)
|
||||
|
||||
// A new tree must never inherit another root's live previews.
|
||||
useEffect(() => { setLive({}) }, [rootId])
|
||||
|
||||
useEffect(() => {
|
||||
if (rootId === undefined || !active) return
|
||||
const targetRootId = rootId
|
||||
let disposed = false
|
||||
let timer: number | undefined
|
||||
|
||||
const schedule = (): void => {
|
||||
if (disposed) return
|
||||
timer = window.setTimeout(() => { void load() }, POLL_MS)
|
||||
}
|
||||
async function load(): Promise<void> {
|
||||
if (disposed) return
|
||||
const controller = new AbortController()
|
||||
controllerRef.current = controller
|
||||
try {
|
||||
const result = await api.subagentsLive(targetRootId, controller.signal)
|
||||
if (!disposed) setLive(result.live)
|
||||
} catch {
|
||||
// Keep the last known live map; the next scheduled poll retries.
|
||||
} finally {
|
||||
if (controllerRef.current === controller) controllerRef.current = undefined
|
||||
if (!disposed) schedule()
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
return () => {
|
||||
disposed = true
|
||||
if (timer !== undefined) window.clearTimeout(timer)
|
||||
controllerRef.current?.abort()
|
||||
controllerRef.current = undefined
|
||||
}
|
||||
}, [rootId, active])
|
||||
|
||||
return live
|
||||
}
|
||||
|
||||
interface RowsProps {
|
||||
parentSessionId: string
|
||||
catalog: SidebarSubagentCatalog | undefined
|
||||
@@ -222,19 +238,26 @@ interface RowsProps {
|
||||
level: number
|
||||
/** The currently-open session id (highlighted in the topology). */
|
||||
currentSessionId: string
|
||||
/** The page is visible (active tab + open panel): live polling pauses otherwise. */
|
||||
active: boolean
|
||||
ctx: Context
|
||||
/** The batch live-preview map (child id → latest activity). */
|
||||
live: Readonly<Record<string, LastActivity>>
|
||||
openChild: (address: SidebarSubagentAddress) => void
|
||||
refresh: (parentSessionId: string) => void
|
||||
}
|
||||
|
||||
/** Render one topology level; branches are always expanded (lazy catalogs). */
|
||||
function CatalogRows({
|
||||
parentSessionId, catalog, catalogs, byId, level, currentSessionId, active, ctx,
|
||||
parentSessionId, catalog, catalogs, byId, level, currentSessionId, live,
|
||||
openChild, refresh,
|
||||
}: RowsProps) {
|
||||
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
|
||||
// Side Chat threads are honest catalog citizens (durable descriptor, 'Side: '
|
||||
// label) but they are NOT subagent topology — filter them out here (the tab
|
||||
// strip owns them). Legacy threads created before the descriptor fix still
|
||||
// arrive as corrupt diagnostics; they are recognized by summary title.
|
||||
const visibleEntries = (catalog?.entries ?? []).filter((entry) => {
|
||||
if (entry.kind === 'child') return !(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false)
|
||||
return !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false)
|
||||
})
|
||||
return (
|
||||
<>
|
||||
{emptyLoading && (
|
||||
@@ -253,7 +276,7 @@ function CatalogRows({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(catalog?.entries ?? []).map((entry) => {
|
||||
{visibleEntries.map((entry) => {
|
||||
if (entry.kind === 'diagnostic') {
|
||||
return (
|
||||
<div key={entry.id} className={css.subagentNode}>
|
||||
@@ -314,14 +337,9 @@ function CatalogRows({
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{label}</span>
|
||||
<span className={css.subagentSecondary}>{secondary}</span>
|
||||
<SubagentLiveLines
|
||||
ctx={ctx}
|
||||
parentSessionId={parentSessionId}
|
||||
childSessionId={entry.id}
|
||||
mode={entry.mode}
|
||||
running={entry.activity === 'running'}
|
||||
active={active}
|
||||
/>
|
||||
{entry.activity === 'running' && (
|
||||
<SubagentLiveLines live={live[entry.id]} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{!knownLeaf && (
|
||||
@@ -342,8 +360,7 @@ function CatalogRows({
|
||||
byId={byId}
|
||||
level={level + 1}
|
||||
currentSessionId={currentSessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
live={live}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
@@ -645,6 +662,7 @@ export function SubagentView(props: {
|
||||
const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
|
||||
const rootCatalog = rootId === undefined ? undefined : catalogs[rootId]
|
||||
const rootSummary = rootId === undefined ? undefined : byId[rootId]
|
||||
const live = useSubagentLive(rootId, active)
|
||||
|
||||
/** Catalog owners currently consuming live membership updates. */
|
||||
const observedRef = useRef(new Set<string>())
|
||||
@@ -839,8 +857,7 @@ export function SubagentView(props: {
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
live={live}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
|
||||
+141
-2
@@ -6,16 +6,40 @@
|
||||
* open). Owns its refresh tick: the icon next to the search input clears
|
||||
* the tree cache. EditorHost docks it as the tab's right panel (wrapped in
|
||||
* a drag-resize handle) and provides the file context-menu open escapes.
|
||||
*
|
||||
* Uploads (header pickers, the tree's drag-drop and "upload here" menu)
|
||||
* all funnel through here: one session at a time, shown in a full-window
|
||||
* progress overlay with cancel, followed by a tree refresh and a one-line
|
||||
* hint under the search row (success fades, failures and cancels stay).
|
||||
* OS file drags are shielded at the panel host (see Sidebar.tsx), so a
|
||||
* drop over the file window uploads here and never reaches DSH's chat
|
||||
* intake.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type InputHTMLAttributes } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconFolderOpen16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api } from './api.ts'
|
||||
import { FileTree } from './FileTree.tsx'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { resolveSidebarPath } from './produced-files.ts'
|
||||
import { UploadOverlay } from './UploadOverlay.tsx'
|
||||
import {
|
||||
summarizeResults, uploadHintText, uploadItemsFromFiles, uploadToDir,
|
||||
UPLOAD_HINT_MS, type UploadItem,
|
||||
} from './upload.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** One in-flight upload session (the overlay's progress source). */
|
||||
interface UploadSession {
|
||||
dir: string
|
||||
done: number
|
||||
total: number
|
||||
/** Relative path of the file being uploaded ('' when none is in flight). */
|
||||
current: string
|
||||
controller: AbortController
|
||||
}
|
||||
|
||||
export function TreePanel(props: {
|
||||
sessionId: string
|
||||
cwd: string | undefined
|
||||
@@ -36,6 +60,63 @@ export function TreePanel(props: {
|
||||
const [results, setResults] = useState<{ matches: string[]; truncated: boolean } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refreshTick, setRefreshTick] = useState(0)
|
||||
/** One-line upload status under the search row ('' hides the hint). */
|
||||
const [uploadStatus, setUploadStatus] = useState('')
|
||||
/** Whether the status line is a failure/cancel (error color, stays visible). */
|
||||
const [uploadFailed, setUploadFailed] = useState(false)
|
||||
/** The in-flight upload session (null → no overlay, buttons enabled). */
|
||||
const [upload, setUpload] = useState<UploadSession | null>(null)
|
||||
/** True between the cancel click and the session settling (button disabled). */
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
/** Set by cancelUpload; the settle path shows 'upload cancelled' instead of
|
||||
* summarizing the partial results. */
|
||||
const cancelledRef = useRef(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/** Start one upload session into `dir` (absolute, inside the workspace). */
|
||||
const startUpload = (dir: string, items: UploadItem[]): void => {
|
||||
if (items.length === 0 || cwd === undefined || upload !== null) return
|
||||
cancelledRef.current = false
|
||||
const controller = new AbortController()
|
||||
setUploadFailed(false)
|
||||
setUploadStatus(uploadHintText(0, items.length, '', dir, t))
|
||||
setUpload({ dir, done: 0, total: items.length, current: '', controller })
|
||||
void uploadToDir({ sessionId, cwd }, dir, items, (done, total, current) => {
|
||||
if (current !== '') setUploadStatus(uploadHintText(done, total, current, dir, t))
|
||||
setUpload(session => session === null ? session : { ...session, done, total, current })
|
||||
}, controller.signal).then((results) => {
|
||||
setUpload(null)
|
||||
setCancelling(false)
|
||||
// Reload the tree whatever the outcome: files may have landed before a
|
||||
// cancel, and failures leave whatever did succeed visible.
|
||||
setRefreshTick(tick => tick + 1)
|
||||
if (cancelledRef.current) {
|
||||
setUploadStatus(t('uploadCancelled'))
|
||||
setUploadFailed(true)
|
||||
return
|
||||
}
|
||||
const status = summarizeResults(results, t)
|
||||
setUploadStatus(status)
|
||||
setUploadFailed(results.some(result => !result.ok))
|
||||
// Success messages are transient; failures stay until the next action.
|
||||
if (results.every(result => result.ok)) {
|
||||
window.setTimeout(() => {
|
||||
setUploadStatus(current => current === status ? '' : current)
|
||||
}, UPLOAD_HINT_MS)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Cancel the in-flight upload (aborts the request; the host drops its temp). */
|
||||
const cancelUpload = (): void => {
|
||||
if (upload === null || cancelling) return
|
||||
cancelledRef.current = true
|
||||
setCancelling(true)
|
||||
upload.controller.abort()
|
||||
}
|
||||
|
||||
const folderInputProps = { webkitdirectory: '' } as InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const needle = query.trim()
|
||||
useEffect(() => {
|
||||
@@ -61,6 +142,8 @@ export function TreePanel(props: {
|
||||
}
|
||||
}, [sessionId, cwd, needle])
|
||||
|
||||
const busy = upload !== null
|
||||
|
||||
return (
|
||||
<div className={clsx(css.editorTreePanel, full === true && css.editorTreePanelFull)}>
|
||||
<div className={css.editorTreeSearch}>
|
||||
@@ -80,7 +163,51 @@ export function TreePanel(props: {
|
||||
>
|
||||
<IconRefreshOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('uploadFiles')}
|
||||
title={t('uploadFiles')}
|
||||
disabled={busy}
|
||||
onClick={() => { fileInputRef.current?.click() }}
|
||||
>
|
||||
<IconUploadOutline16 size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('uploadFolder')}
|
||||
title={t('uploadFolder')}
|
||||
disabled={busy}
|
||||
onClick={() => { folderInputRef.current?.click() }}
|
||||
>
|
||||
<IconFolderOpen16 size={14} />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
{...folderInputProps}
|
||||
style={{ display: 'none' }}
|
||||
onChange={(event) => {
|
||||
if (cwd !== undefined) startUpload(cwd, uploadItemsFromFiles(event.target.files ?? []))
|
||||
event.target.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{uploadStatus !== '' && (
|
||||
<div className={clsx(css.editorSearchHint, uploadFailed && css.editorError)} title={uploadStatus}>{uploadStatus}</div>
|
||||
)}
|
||||
{needle === '' ? (
|
||||
<FileTree
|
||||
sessionId={sessionId}
|
||||
@@ -92,6 +219,8 @@ export function TreePanel(props: {
|
||||
onOpenFileSide={onOpenFileSide}
|
||||
onReferenceFile={onReferenceFile}
|
||||
refreshTick={refreshTick}
|
||||
onUploadRequest={startUpload}
|
||||
busy={busy}
|
||||
/>
|
||||
) : (
|
||||
<div className={css.explorerBody}>
|
||||
@@ -116,6 +245,16 @@ export function TreePanel(props: {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{upload !== null && (
|
||||
<UploadOverlay
|
||||
dir={upload.dir}
|
||||
done={upload.done}
|
||||
total={upload.total}
|
||||
current={upload.current}
|
||||
onCancel={cancelUpload}
|
||||
cancelling={cancelling}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Full-window upload progress over the files tree: a blurred scrim (same mask
|
||||
* token as the repo's Modal primitive) with a card showing the target
|
||||
* directory, file-level progress, and a cancel button. Esc cancels too —
|
||||
* clicking the scrim does not, so a stray click can never abort an upload.
|
||||
* Rendered inside TreePanel (absolute inset-0), so it covers only the file
|
||||
* window and never the conversation column.
|
||||
*/
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { IconUploadOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { uploadHintText } from './upload.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function UploadOverlay(props: {
|
||||
/** Absolute upload directory (the session workspace or a tree directory). */
|
||||
dir: string
|
||||
done: number
|
||||
total: number
|
||||
/** Relative path of the file being uploaded ('' when none is in flight). */
|
||||
current: string
|
||||
onCancel: () => void
|
||||
/** True while cancellation is in flight (disables the cancel button). */
|
||||
cancelling?: boolean
|
||||
}): ReactNode {
|
||||
const { dir, done, total, current, onCancel, cancelling } = props
|
||||
// Esc cancels (MermaidModal-style native listener; the overlay is not a
|
||||
// focus-trapped dialog, so a window listener is the honest scope).
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') onCancel()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => { window.removeEventListener('keydown', onKey) }
|
||||
}, [onCancel])
|
||||
|
||||
const percent = total === 0 ? 0 : Math.min(100, Math.round((done / total) * 100))
|
||||
return (
|
||||
<div className={css.uploadOverlay} role="dialog" aria-modal="true" aria-label={t('uploadingTo', { dir })}>
|
||||
<div className={css.uploadOverlayCard}>
|
||||
<div className={css.uploadOverlayTitle} title={dir}>
|
||||
<IconUploadOutline16 size={16} />
|
||||
<span>{t('uploadingTo', { dir })}</span>
|
||||
</div>
|
||||
<div
|
||||
className={css.uploadOverlayProgress}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={total}
|
||||
aria-valuenow={done}
|
||||
aria-valuetext={t('uploadProgress', { done, total, name: current })}
|
||||
>
|
||||
<div className={css.uploadOverlayProgressFill} style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div className={css.uploadOverlayStatus}>{uploadHintText(done, total, current, dir, t)}</div>
|
||||
<button type="button" className={css.uploadOverlayCancel} disabled={cancelling} onClick={onCancel}>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,8 @@
|
||||
* request). Failures surface as {@link SidebarApiError} with the wire code.
|
||||
*/
|
||||
import { encodeHtmlUrl } from '../html-route.ts'
|
||||
import type { LastActivity } from '../subagent-activity.ts'
|
||||
import type { SidechatThreadInfo } from '../sidechat-core.ts'
|
||||
import type { BrowserProbeResult } from './browser.ts'
|
||||
|
||||
/** One wire failure. */
|
||||
@@ -79,6 +81,9 @@ export interface JobOutputResult {
|
||||
read: boolean
|
||||
}
|
||||
|
||||
/** The `subagents.live` response: running child id → latest activity. */
|
||||
export type SubagentLiveResult = { live: Record<string, LastActivity> }
|
||||
|
||||
/** Terminal dependency status (mirror of the host's depsStatus; issue #140). */
|
||||
export type TerminalDepsStatus =
|
||||
| { ok: true }
|
||||
@@ -117,6 +122,45 @@ async function call<T>(method: string, payload: Record<string, unknown>, signal?
|
||||
return parsed.value as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload one file to the sidebar's raw upload route: the File goes straight
|
||||
* into the POST body (no JSON/base64 re-encoding — the host streams it into
|
||||
* the workspace). Failure surfaces as {@link SidebarApiError} with the wire
|
||||
* code, exactly like every `/sidebar/api` call. An aborted `signal` rejects
|
||||
* with the DOMException as-is (the caller decides whether that is an error).
|
||||
*/
|
||||
async function fetchUpload<T>(
|
||||
scope: SessionScope,
|
||||
dir: string,
|
||||
relativePath: string,
|
||||
body: Blob,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const params = new URLSearchParams({ sessionId: scope.sessionId, dir, relativePath })
|
||||
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(`/sidebar/upload?${params.toString()}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
body,
|
||||
signal,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') throw error
|
||||
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
|
||||
= await response.json().catch(() => null)
|
||||
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
|
||||
throw new SidebarApiError(
|
||||
parsed?.error?.code ?? 'http',
|
||||
parsed?.error?.message ?? `HTTP ${response.status}`,
|
||||
)
|
||||
}
|
||||
return parsed.value as T
|
||||
}
|
||||
|
||||
/** One request's session scope: the conversation id plus its cwd when known. */
|
||||
export interface SessionScope {
|
||||
sessionId: string
|
||||
@@ -143,6 +187,10 @@ export const api = {
|
||||
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
|
||||
fsWrite: (scope: SessionScope, path: string, content: string) =>
|
||||
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
|
||||
/** Upload one file's raw bytes into `dir` (keeps the folder tree via
|
||||
* `relativePath`); the host streams it under the session workspace. */
|
||||
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
|
||||
fetchUpload<{ path: string; size: number }>(scope, dir, relativePath, body, signal),
|
||||
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
|
||||
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
|
||||
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
|
||||
@@ -201,6 +249,30 @@ export const api = {
|
||||
id,
|
||||
...(reason !== undefined ? { reason } : {}),
|
||||
})),
|
||||
/**
|
||||
* One batch live-preview fetch for the whole Subagent tree. The payload is
|
||||
* the already-resolved topology ROOT (not a session scope); the host
|
||||
* enumerates descendants once and folds running children's activity.
|
||||
*/
|
||||
subagentsLive: (rootSessionId: string, signal?: AbortSignal) =>
|
||||
call<SubagentLiveResult>('subagents.live', { rootSessionId }, signal),
|
||||
/** Create a Side Chat thread: a child session seeded with the parent's
|
||||
* full log up to now. Empty question = immediate create (Codex-style):
|
||||
* the thread opens empty, the first prompt carries the boundary. */
|
||||
sidechatStart: (sessionId: string, question?: string) =>
|
||||
call<{ childId: string }>('sidechat.start', { sessionId, question: question ?? '' }),
|
||||
/** Deliver one follow-up message to a Side Chat thread. */
|
||||
sidechatPrompt: (childId: string, text: string) =>
|
||||
call<{ accepted: true }>('sidechat.prompt', { childId, text }),
|
||||
/** Abort a Side Chat thread's running turn (queued work is preserved). */
|
||||
sidechatCancel: (childId: string) =>
|
||||
call<{ accepted: true }>('sidechat.cancel', { childId }),
|
||||
/** Release a Side Chat thread's live agent (history stays persisted). */
|
||||
sidechatDispose: (childId: string) =>
|
||||
call<{ accepted: true }>('sidechat.dispose', { childId }),
|
||||
/** Live state + agent identity (provider/model/preset) of a thread. */
|
||||
sidechatInfo: (childId: string) =>
|
||||
call<SidechatThreadInfo>('sidechat.info', { childId }),
|
||||
/** The effective terminal shell and its display name (plugin-global). */
|
||||
shellGet: () =>
|
||||
call<{ shell: string; name: string }>('shell.get', {}),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* The 6 built-in tab descriptors: the plugin registers its own pages
|
||||
* (editor / git / terminal / browser / subagent / diff) through
|
||||
* The 7 built-in tab descriptors: the plugin registers its own pages
|
||||
* (editor / git / subagent / sidechat / terminal / browser / diff) through
|
||||
* the same {@link BetterSidebarService} external plugins use — eating its
|
||||
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
|
||||
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
|
||||
* `browser:<n>` the same way (no quota). The editor IS the files window
|
||||
* (the old standalone explorer merged into it).
|
||||
*/
|
||||
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from '../../context-types.ts'
|
||||
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
|
||||
import { t } from '../locales.ts'
|
||||
@@ -17,6 +17,8 @@ import { lazyChunkComponent } from '../lazy-chunk.tsx'
|
||||
import { GitView } from '../GitView.tsx'
|
||||
import { DiffTab } from '../DiffTab.tsx'
|
||||
import { SubagentView } from '../SubagentView.tsx'
|
||||
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
|
||||
import { api } from '../api.ts'
|
||||
import { BrowserView } from '../BrowserView.tsx'
|
||||
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
|
||||
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
|
||||
@@ -165,6 +167,51 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sidechat',
|
||||
title: () => t('sideChat'),
|
||||
icon: (size: number) => <IconNewChatOutline16 size={size} />,
|
||||
order: 35,
|
||||
// Codex-style: EVERY side conversation is its own tab. A plain open
|
||||
// mints a fresh tab flagged `autoCreate` (the view creates the EMPTY
|
||||
// thread on mount); a thread switch from the header menu parks the
|
||||
// target id for a deterministic `sidechat:<threadId>` reattach tab.
|
||||
createTab: () => {
|
||||
const threadId = consumeSidechatSeed()
|
||||
if (threadId !== undefined) {
|
||||
return {
|
||||
tab: {
|
||||
id: `sidechat:${threadId}`,
|
||||
type: 'sidechat',
|
||||
title: t('sideChat'),
|
||||
meta: { threadId },
|
||||
},
|
||||
}
|
||||
}
|
||||
return {
|
||||
tab: {
|
||||
id: `sidechat:new-${crypto.randomUUID()}`,
|
||||
type: 'sidechat',
|
||||
title: t('sideChatUntitled'),
|
||||
meta: { autoCreate: true },
|
||||
},
|
||||
}
|
||||
},
|
||||
// One tab per thread: an already-open thread focuses instead of
|
||||
// duplicating; unbound fresh tabs never dedupe (each mints its own).
|
||||
dedupeKey: (tab) => sidechatThreadIdOf(tab),
|
||||
// Closing the tab releases the thread's live agent; the session and
|
||||
// its history stay persisted (reopen from any thread's header menu).
|
||||
onClose: (tab) => {
|
||||
const threadId = sidechatThreadIdOf(tab)
|
||||
if (threadId !== undefined) {
|
||||
void api.sidechatDispose(threadId).catch(() => {})
|
||||
}
|
||||
},
|
||||
component: ({ ctx, scope, tab, visible }) => (
|
||||
<SideChatView ctx={ctx} scope={scope} tab={tab} visible={visible} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'terminal',
|
||||
title: () => t('terminal'),
|
||||
|
||||
+38
-24
@@ -1,21 +1,18 @@
|
||||
/**
|
||||
* Desktop-shell detection for the sidebar. The official DSH Desktop shell
|
||||
* (Electron) stamps every render URL with `dsh-desktop-mode` and
|
||||
* `dsh-desktop-platform` and exposes `window.__DSH_DESKTOP_FILE_PATH__`
|
||||
* through its preload; community Tauri shells keep the native system frame
|
||||
* (no stamps — plain-browser semantics). Parsed once per page and memoized
|
||||
* (the URL never changes mid-session); `resetDesktopEnvForTests` clears the
|
||||
* memo for unit tests.
|
||||
* Desktop-shell detection for the sidebar. Shells may stamp the render URL
|
||||
* with `dsh-desktop-mode` / `dsh-desktop-platform` (the official Electron
|
||||
* shell does) and expose `window.__DSH_DESKTOP_FILE_PATH__` through a
|
||||
* preload. Parsed once per page and memoized (the URL never changes
|
||||
* mid-session); `resetDesktopEnvForTests` clears the memo for unit tests.
|
||||
*
|
||||
* Geometry facts the sidebar adapts to (from the Electron runtime):
|
||||
* - win32 `advanced` mode draws the window controls in a 32px overlay strip
|
||||
* at the top-right of the web content (`titleBarOverlay: { height: 32 }`)
|
||||
* — exactly where the toggle cluster sits, so it must drop below the
|
||||
* strip and the right panel's content must clear it.
|
||||
* - darwin `advanced` mode keeps the traffic lights at (16,16) in the
|
||||
* top-left; nothing on the plugin's side touches that corner today, but
|
||||
* the platform is still reported so left-edge controls can avoid it.
|
||||
* - `compatibility` mode keeps the native frame — no adaptation needed.
|
||||
* GEOMETRY POLICY: this module only REPORTS shell facts — it never decides
|
||||
* how to adapt. The strip height comes from standard signals first (the
|
||||
* Window Controls Overlay API, see wco.ts), then the documented contract
|
||||
* parameter `dsh-desktop-titlebar-inset` (a shell may stamp the real pixels
|
||||
* it reserves at the top), then the user's chosen scheme (preset / custom).
|
||||
* The legacy win32-advanced 32px constant is gone from the core: it lives
|
||||
* in the opt-in shell preset (shell-presets.ts) as a fallback for shells
|
||||
* without the WCO API.
|
||||
*/
|
||||
export interface DesktopEnv {
|
||||
/** Running inside a desktop shell (any URL stamp or preload marker). */
|
||||
@@ -24,34 +21,51 @@ export interface DesktopEnv {
|
||||
readonly mode: 'compatibility' | 'advanced' | null
|
||||
/** Shell platform stamp ('darwin' | 'win32' | …), lowercased, or null. */
|
||||
readonly platform: string | null
|
||||
/** Pixels the win32 advanced shell reserves at the top-right for window
|
||||
* controls (0 elsewhere). */
|
||||
readonly win32OverlayTop: number
|
||||
/**
|
||||
* Contract parameter `dsh-desktop-titlebar-inset`: pixels the shell
|
||||
* reserves at the top of the web content for its own chrome (0–120,
|
||||
* clamped; 0 when absent). Standard WCO geometry takes precedence over
|
||||
* this whenever the API is available.
|
||||
*/
|
||||
readonly titlebarInset: number
|
||||
}
|
||||
|
||||
let cached: DesktopEnv | undefined
|
||||
|
||||
/** Read the shell's desktop stamps (memoized per page). */
|
||||
/** Read the shell's desktop stamps (memoized per page; SSR-safe). */
|
||||
export function parseDesktopEnv(): DesktopEnv {
|
||||
if (cached !== undefined) return cached
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
const hasPreloadMarker = hasWindow
|
||||
&& typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
|
||||
// location.search includes the leading '?', which URLSearchParams does NOT
|
||||
// strip (it would become part of the first key) — drop it explicitly.
|
||||
const params = new URLSearchParams(window.location.search.replace(/^\?/, ''))
|
||||
// SSR (no window) resolves the empty environment: nothing desktop.
|
||||
const params = hasWindow
|
||||
? new URLSearchParams(window.location.search.replace(/^\?/, ''))
|
||||
: new URLSearchParams()
|
||||
const modeParam = params.get('dsh-desktop-mode')
|
||||
const mode = modeParam === 'compatibility' || modeParam === 'advanced' ? modeParam : null
|
||||
const platformParam = params.get('dsh-desktop-platform')
|
||||
const platform = platformParam !== null && platformParam !== '' ? platformParam.toLowerCase() : null
|
||||
const desktop = mode !== null
|
||||
|| typeof (window as { __DSH_DESKTOP_FILE_PATH__?: unknown }).__DSH_DESKTOP_FILE_PATH__ !== 'undefined'
|
||||
const desktop = mode !== null || hasPreloadMarker
|
||||
cached = {
|
||||
desktop,
|
||||
mode,
|
||||
platform,
|
||||
win32OverlayTop: desktop && mode === 'advanced' && platform === 'win32' ? 32 : 0,
|
||||
titlebarInset: parseTitlebarInset(params.get('dsh-desktop-titlebar-inset')),
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
/** Clamp the contract inset parameter into 0–120 (invalid/absent → 0). */
|
||||
function parseTitlebarInset(raw: string | null): number {
|
||||
if (raw === null) return 0
|
||||
const parsed = Number(raw)
|
||||
if (!Number.isFinite(parsed)) return 0
|
||||
return Math.min(120, Math.max(0, Math.round(parsed)))
|
||||
}
|
||||
|
||||
/** Test hook: drop the memo so the next parse re-reads the URL/globals. */
|
||||
export function resetDesktopEnvForTests(): void {
|
||||
cached = undefined
|
||||
|
||||
@@ -61,6 +61,15 @@ export const IconStopOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Upload glyph in the app's outline style: an arrow rising into a tray
|
||||
* (the file-manager "upload into the workspace" action). */
|
||||
export const IconUploadOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 10V2.75M4.75 5.5 8 2.25 11.25 5.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2.75 10.5v2.25A1.25 1.25 0 0 0 4 14h8a1.25 1.25 0 0 0 1.25-1.25V10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
|
||||
|
||||
/** Image viewer glyph: a picture frame with a sun and a mountain. */
|
||||
@@ -108,3 +117,23 @@ export const IconGlobeOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<path d="M1.5 8h13M8 1.5c-2.4 1.8-2.4 11.2 0 13M8 1.5c2.4 1.8 2.4 11.2 0 13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** History glyph (thread switcher): a clock with a counterclockwise arrow,
|
||||
* in the app's outline style — the "past conversations" mark. */
|
||||
export const IconHistoryOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2.4 6.8A5.6 5.6 0 1 1 2.4 9.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path d="M2.2 3.4v3.4h3.4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M8 5.4V8l1.9 1.2" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Save glyph (save-as-new-session): the classic floppy disk, in the app's
|
||||
* outline style. */
|
||||
export const IconSaveOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.2 14.5h7.6a1.2 1.2 0 0 0 1.2-1.2V4.9L10.6 2.5H4.2A1.2 1.2 0 0 0 3 3.7v9.6a1.2 1.2 0 0 0 1.2 1.2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M10 2.5v2.6H5.6V2.5" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
<path d="M5.4 14.5v-4.2h5.2v4.2" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
+12
-4
@@ -35,8 +35,14 @@
|
||||
width var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* The AppFrame's center column, anchored by data attributes (see above). */
|
||||
#root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
/* The AppFrame's center column, anchored by data attributes (see above).
|
||||
Composite selector: DSH 0.1.x versions name the center grid item
|
||||
`[data-pane="conversation"]`, while rc.8-era shells put a
|
||||
`[data-slot="conversation"]` child inside it — both selectors resolve to
|
||||
the SAME element on live pages (verified), and keeping both future-proofs
|
||||
the rule against a host rename without touching shell-specific markup. */
|
||||
#root [data-dsh-frame] > [data-pane="conversation"],
|
||||
#root :has(> [data-slot="conversation"]) {
|
||||
margin-bottom: var(--dsh-sidebar-height, 0px);
|
||||
transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
@@ -55,7 +61,8 @@ body[data-dsh-sidebar-collapsed] [data-slot="conversation.session.header"] > hea
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-dragging] #root,
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversation"],
|
||||
body[data-dsh-sidebar-dragging] #root :has(> [data-slot="conversation"]) {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@@ -80,7 +87,8 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-frame] > [data-pane="conversatio
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#root,
|
||||
#root [data-dsh-frame] > [data-pane="conversation"] {
|
||||
#root [data-dsh-frame] > [data-pane="conversation"],
|
||||
#root :has(> [data-slot="conversation"]) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
+106
-4
@@ -129,6 +129,19 @@ export const zh = {
|
||||
copyRelative: '复制相对地址',
|
||||
copyAbsolute: '复制绝对地址',
|
||||
download: '下载',
|
||||
uploadFiles: '上传文件',
|
||||
uploadFolder: '上传文件夹',
|
||||
uploadHere: '上传到此处',
|
||||
uploadDropHint: '拖拽文件/文件夹到此处上传',
|
||||
uploadDropChat: '拖放到聊天区:添加图片到对话',
|
||||
uploadTo: '上传到 {dir}',
|
||||
uploadingTo: '正在上传到 {dir}…',
|
||||
uploadProgress: '正在上传 {done}/{total}: {name}',
|
||||
uploadDone: '已上传 {count} 个文件',
|
||||
uploadFailed: '上传失败:{error}',
|
||||
uploadFailedUnknown: '未知错误',
|
||||
uploadTooLarge: '文件过大,超出上传上限',
|
||||
uploadCancelled: '上传已取消',
|
||||
settingsNav: '侧边卡片',
|
||||
settingsIntro: '管理侧边卡片的显示内容与默认行为',
|
||||
settingsPopupDesc: '为「{feature}」配置相关选项',
|
||||
@@ -141,9 +154,19 @@ export const zh = {
|
||||
settingsOpenPathTitle: '聊天区文件在侧边栏打开',
|
||||
settingsOpenPathDesc: '在聊天里点击文件链接(工具行、产物列表、文件提及)时,在侧边栏编辑器中打开,不再调用系统默认应用',
|
||||
settingsTitleBarTitle: '位置兼容模式',
|
||||
settingsTitleBarDesc: '为 Windows 右上角的原生标题栏预留空间:侧边栏按钮与侧边栏内容整体下移,避免被标题栏遮挡',
|
||||
settingsTitleBarDesc: '选择顶栏兼容方案:自动检测(默认,保守)/ DSH官方Web / 已知桌面壳 / 自定义方案(下移距离 + 自定义 CSS)',
|
||||
settingsTitleBarStripTitle: '下移距离',
|
||||
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40)',
|
||||
settingsTitleBarStripDesc: '标题栏条带高度:侧边栏按钮与内容下移的像素数(0–120,默认 40;自定义方案下生效)',
|
||||
settingsSchemeAutoTitle: '自动检测',
|
||||
settingsSchemeAutoDesc: '保守方案:仅在 Window Controls Overlay 标准 API 可用时按真实标题栏高度让位;网页环境下不做任何修改',
|
||||
settingsSchemeWebTitle: 'DSH官方Web',
|
||||
settingsSchemeWebDesc: '显式声明运行在官方网页版:不做任何适配(连标准 WCO 几何也不适用)',
|
||||
settingsSchemeCustomTitle: '自定义方案',
|
||||
settingsSchemeCustomDesc: '完全由你控制:注入自定义 CSS(可覆盖内置样式),并指定标题栏下移距离',
|
||||
settingsSchemeDetectedSuffix: '已检测',
|
||||
settingsCustomCssTitle: '自定义 CSS',
|
||||
settingsCustomCssDesc: '追加到页面末尾的样式(同优先级下后写胜出;覆盖 JS 内联变量需用 !important)',
|
||||
settingsCustomCssPlaceholder: '/* 例:为自绘标题栏的壳预留 36px */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
@@ -224,6 +247,29 @@ export const zh = {
|
||||
subagentDiagUnsupported: '不支持的条目',
|
||||
subagentDiagUnavailable: '不可用',
|
||||
subagentThinking: '思考中…',
|
||||
sideChat: '侧边对话(beta)',
|
||||
sideChatNew: '新建对话',
|
||||
sideChatUntitled: '新对话',
|
||||
sideChatEmpty: '暂无侧边对话',
|
||||
sideChatEmptyDesc: '每个侧边对话是标签栏里的独立 Tab,继承当前会话的上下文运行,不会进入主会话',
|
||||
sideChatCreating: '正在创建侧边对话…',
|
||||
sideChatRetry: '重试',
|
||||
sideChatThreads: '切换线程 / 新建',
|
||||
sideChatSave: '保存为新会话',
|
||||
sideChatSaveTitle: '把该线程提升为顶层会话,出现在主会话列表中',
|
||||
sideChatSaved: '已保存为新会话',
|
||||
sideChatNoTurn: '至少完成一轮对话后才能保存',
|
||||
sideChatPendingDrop: '最后一条未完成的追问不会包含在新会话中',
|
||||
sideChatFirstPlaceholder: '输入第一个问题,已继承当前会话上下文…',
|
||||
sideChatComposerPlaceholder: '追问…',
|
||||
sideChatThinking: '正在深入…',
|
||||
sideChatThink: '思考过程',
|
||||
sideChatSend: '发送',
|
||||
sideChatCancel: '停止',
|
||||
sideChatCancelTitle: '中止当前回合(保留队列)',
|
||||
sideChatClose: '关闭线程',
|
||||
sideChatCloseTitle: '释放线程的 agent(历史保留)',
|
||||
sideChatError: '侧边对话出错:{message}',
|
||||
jobs: '后台任务',
|
||||
jobsCount: '{count} 个后台任务',
|
||||
jobsCountRunning: '{count} 个后台任务 · {running} 运行中',
|
||||
@@ -258,10 +304,15 @@ export const zh = {
|
||||
openPlugin: '跳转',
|
||||
copyInstall: '复制安装命令',
|
||||
pluginOfficeDesc: '为 better-sidebar 编辑器提供 Office 三件套预览(.docx / .xlsx / .pptx),把重型 Office 渲染库拆出主包、按需安装',
|
||||
pluginFlowglassDesc: '实时会话流程图:三列泳道展示用户、助手与工具调用,支持并行分组、子代理支线、逐层钻取和实时状态;安装 better-sidebar 后注册原生「流镜」Tab,未安装时保留独立抽屉',
|
||||
pluginGitForgeDesc: 'better-sidebar「Git 凭据」Tab:GitHub/Gitea 等 Forge 账号库 + 按项目授权 + push 策略硬拦;token 仅存本地 secrets,不进模型上下文;提供只读 GitForge 工具与 agent HTTPS credential helper',
|
||||
pluginGitRemotesDesc: 'better-sidebar Git 远程 Tab:看分支/上游/ahead-behind,fetch(可 prune)、ff-only pull、确认后才 push。不替换内置 Git 的暂存/提交,也不提供 force-push 或模型自动推送',
|
||||
pluginSentinelDesc: '条件驱动的 agent 唤醒系统:文件/进程/端口/HTTP/命令/webhook 传感器,条件达成自动唤醒休眠会话;注册「哨兵」Tab 展示服务器全局监控表',
|
||||
pluginSidebarQaDesc: '基于 better-sidebar 的划选提问tab分页: 对话划选 → 右侧面板提问 → 同工作区独立追问会话(❓追问·主题):快速无思考模型压缩主对话上下文后与引文一起注入,不打断主对话;追问可嵌套、可继续、可归档',
|
||||
pluginSshTunnelDesc: 'better-sidebar「SSH 隧道」Tab:多机主机清单 + 按项目授权 + 密钥本地保管;模型工具 SSHManager(exec/SFTP/会话策略);中央交互终端与双栏 SFTP',
|
||||
pluginTurnReviewDesc: '对「刚刚这一回合」的 diff 做 Approve / Request changes 的人闸门:只审上一回合,不 fork 会话;文件按主会话/子代理/未归因分组,按文件勾选打回 + 可选评语,点文件先看回合开始快照 vs 现在的 diff。不是 /rewind',
|
||||
pluginVideoPreviewDesc: '在 better-sidebar 编辑器内联预览视频文件(.mp4/.webm/.mov/.mkv/.avi 等),自带支持 HTTP Range(206)的 /video 宿主路由,可拖动进度条、不受 20MB mediaLimit 限制',
|
||||
pluginDocsPanelDesc: 'DSH 侧边栏里的「全局文档」:全局 Markdown 笔记,任何工作区随时可读——列表点选阅读、悬浮大纲跳转、Chrome / VS Code 外部打开、代码复制,目录可配置(默认 ~/.dsh/docs)',
|
||||
}
|
||||
|
||||
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
|
||||
@@ -384,6 +435,19 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
copyRelative: 'Copy relative path',
|
||||
copyAbsolute: 'Copy absolute path',
|
||||
download: 'Download',
|
||||
uploadFiles: 'Upload files',
|
||||
uploadFolder: 'Upload folder',
|
||||
uploadHere: 'Upload here',
|
||||
uploadDropHint: 'Drop files/folders here to upload',
|
||||
uploadDropChat: 'Drop onto the chat to add images',
|
||||
uploadTo: 'Upload into {dir}',
|
||||
uploadingTo: 'Uploading into {dir}…',
|
||||
uploadProgress: 'Uploading {done}/{total}: {name}',
|
||||
uploadDone: 'Uploaded {count} file(s)',
|
||||
uploadFailed: 'Upload failed: {error}',
|
||||
uploadFailedUnknown: 'Unknown error',
|
||||
uploadTooLarge: 'File too large (over the upload limit)',
|
||||
uploadCancelled: 'Upload cancelled',
|
||||
settingsNav: 'Side card',
|
||||
settingsIntro: 'Manage what the side card shows and how it behaves',
|
||||
settingsPopupDesc: 'Configure related options for {feature}',
|
||||
@@ -396,9 +460,19 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
settingsOpenPathTitle: 'Open chat files in the sidebar',
|
||||
settingsOpenPathDesc: 'Open file links in the chat (tool rows, produced files, mentions) in the sidebar editor instead of the system default app',
|
||||
settingsTitleBarTitle: 'Position compatibility mode',
|
||||
settingsTitleBarDesc: 'Reserve space for the native Windows title bar at the top-right so the sidebar buttons and content sit below it instead of underneath',
|
||||
settingsTitleBarDesc: 'Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)',
|
||||
settingsTitleBarStripTitle: 'Shift distance',
|
||||
settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40)',
|
||||
settingsTitleBarStripDesc: 'Title-bar strip height: how far the sidebar buttons and content move down in px (0–120, default 40; applies under the custom scheme)',
|
||||
settingsSchemeAutoTitle: 'Auto-detect',
|
||||
settingsSchemeAutoDesc: 'Conservative: only the standard Window Controls Overlay API contributes (real caption-overlay height); plain web environments get no modification',
|
||||
settingsSchemeWebTitle: 'DSH official web',
|
||||
settingsSchemeWebDesc: 'Explicitly declare the official web UI: no adaptation at all (not even standard WCO geometry)',
|
||||
settingsSchemeCustomTitle: 'Custom',
|
||||
settingsSchemeCustomDesc: 'Full control: inject custom CSS (can override built-in styles) and set the title-bar shift distance',
|
||||
settingsSchemeDetectedSuffix: 'detected',
|
||||
settingsCustomCssTitle: 'Custom CSS',
|
||||
settingsCustomCssDesc: 'Styles appended at the end of the page (later in the cascade wins ties; use !important to override JS-written inline variables)',
|
||||
settingsCustomCssPlaceholder: '/* e.g. reserve 36px for a shell with a custom-drawn title bar */\nhtml[data-dsh-title-bar-height="36"] {\n --dsh-title-bar-strip: 36px !important;\n}',
|
||||
settingsSaveFailed: 'Failed to save',
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
binaryNoPreview: 'This file type cannot be previewed',
|
||||
@@ -479,6 +553,29 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
subagentDiagUnsupported: 'Unsupported',
|
||||
subagentDiagUnavailable: 'Unavailable',
|
||||
subagentThinking: 'Thinking…',
|
||||
sideChat: 'Side Chat (beta)',
|
||||
sideChatNew: 'New thread',
|
||||
sideChatUntitled: 'New thread',
|
||||
sideChatEmpty: 'No side conversations',
|
||||
sideChatEmptyDesc: 'Every side conversation is its own tab in the tab strip — it inherits the current session\'s context and never enters the main conversation',
|
||||
sideChatCreating: 'Creating side conversation…',
|
||||
sideChatRetry: 'Retry',
|
||||
sideChatThreads: 'Switch thread / new',
|
||||
sideChatSave: 'Save as new session',
|
||||
sideChatSaveTitle: 'Promote this thread to a top-level session in the main session list',
|
||||
sideChatSaved: 'Saved as a new session',
|
||||
sideChatNoTurn: 'Save is available after the first completed turn',
|
||||
sideChatPendingDrop: 'The last unanswered follow-up will not be included in the saved session',
|
||||
sideChatFirstPlaceholder: 'Ask the first question — context inherited…',
|
||||
sideChatComposerPlaceholder: 'Ask a follow-up…',
|
||||
sideChatThinking: 'Deep diving…',
|
||||
sideChatThink: 'Thinking',
|
||||
sideChatSend: 'Send',
|
||||
sideChatCancel: 'Stop',
|
||||
sideChatCancelTitle: 'Abort the running turn (queued work is kept)',
|
||||
sideChatClose: 'Close thread',
|
||||
sideChatCloseTitle: 'Release the thread\'s agent (history is kept)',
|
||||
sideChatError: 'Side Chat error: {message}',
|
||||
jobs: 'Background jobs',
|
||||
jobsCount: '{count} background jobs',
|
||||
jobsCountRunning: '{count} background jobs · {running} running',
|
||||
@@ -513,10 +610,15 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
openPlugin: 'Open',
|
||||
copyInstall: 'Copy install command',
|
||||
pluginOfficeDesc: 'Office-suite preview (.docx / .xlsx / .pptx) for the better-sidebar editor, keeping the heavy Office render libraries out of the core bundle',
|
||||
pluginFlowglassDesc: 'Live session flowgraph with three lanes for user, assistant, and tool calls, plus parallel groups, sub-agent branches, drill-down, and live status; registers a native Flowglass tab when better-sidebar is installed and keeps its standalone drawer as a fallback',
|
||||
pluginGitForgeDesc: 'Git Forge tab: GitHub/Gitea (and other forge) account library + per-project grants + hard push policy; tokens stay in local secrets (never in model context); read-only GitForge tool and agent HTTPS credential helper',
|
||||
pluginGitRemotesDesc: 'Git Remotes tab: branch/upstream/ahead-behind, fetch (optional prune), ff-only pull, and push only after an in-tab confirm. Does not replace the built-in Git stage/commit tab, and does not offer force-push or a model auto-push tool',
|
||||
pluginSentinelDesc: 'Condition-driven agent wakeup: file/process/port/http/command/webhook sensors wake dormant sessions when conditions fire; registers a "Sentinel" tab with the server-wide watch table',
|
||||
pluginSidebarQaDesc: 'Select-and-ask: Select conversation text → ask in the right-side panel → a dedicated follow-up session (❓追问) in the same workspace; a fast no-thinking model compresses the main context and injects it with the quote, without interrupting the main conversation. Follow-ups nest, continue, and archive',
|
||||
pluginSshTunnelDesc: 'SSH Tunnel tab: multi-host inventory + per-project grants + local secrets; SSHManager tool (exec/SFTP/session strategies); center interactive terminal and dual-pane SFTP',
|
||||
pluginTurnReviewDesc: 'A human gate on the just-finished turn: Approve / Request changes per path with an optional comment; paths grouped by main session / subagent / unattributed; inline snapshot-vs-now diff before you decide. No fork, no /rewind',
|
||||
pluginVideoPreviewDesc: 'Inline video preview (.mp4/.webm/.mov/.mkv/.avi etc.) for the better-sidebar editor, backed by a dedicated /video host route with HTTP Range (206) support — scrubbing works and files are not capped by the 20MB mediaLimit',
|
||||
pluginDocsPanelDesc: 'Global docs in the DSH sidebar: read your own Markdown notes from any workspace — a file list, an outline, open in Chrome / VS Code, and copy buttons; the docs directory is configurable (default ~/.dsh/docs)',
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,33 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
// context consistent with the other entries.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add "github:fuhefei/dsh-sentinel#v0.7.0"',
|
||||
},
|
||||
{
|
||||
id: 'dsh-docs-panel',
|
||||
name: 'dsh-docs-panel 全局文档',
|
||||
url: 'https://github.com/mlosun/dsh-docs-panel',
|
||||
description: () => t('pluginDocsPanelDesc'),
|
||||
// dsh-docs-panel hard-depends on dsh-better-sidebar (required peer), so
|
||||
// the install line installs the prerequisite first, then the plugin.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add dsh-docs-panel',
|
||||
},
|
||||
{
|
||||
id: 'dsh-flowglass',
|
||||
name: 'dsh-flowglass 流镜',
|
||||
url: 'https://github.com/Iwctwbh/dsh-flowglass',
|
||||
description: () => t('pluginFlowglassDesc'),
|
||||
// Flowglass keeps its standalone drawer as a fallback and registers the
|
||||
// native tab automatically when better-sidebar is present.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add dsh-flowglass',
|
||||
},
|
||||
{
|
||||
id: 'dsh-git-forge',
|
||||
name: 'dsh-git-forge Git 凭据',
|
||||
url: 'https://github.com/thirsty5034/dsh-git-forge',
|
||||
description: () => t('pluginGitForgeDesc'),
|
||||
// Peer-depends on dsh-better-sidebar (Git Forge tab). Install the
|
||||
// prerequisite first; package is GitHub-sourced until npm publish.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add "dsh-git-forge@github:thirsty5034/dsh-git-forge"',
|
||||
},
|
||||
{
|
||||
id: 'dsh-git-remotes',
|
||||
name: 'dsh-git-remotes Git 远程',
|
||||
@@ -40,4 +67,21 @@ export const builtinTabPlugins: readonly PluginEntry[] = [
|
||||
// the install line installs the prerequisite first, then the plugin.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/ChenRuoT/dsh-sidebar-qa.git',
|
||||
},
|
||||
{
|
||||
id: 'dsh-ssh-tunnel',
|
||||
name: 'dsh-ssh-tunnel SSH 隧道',
|
||||
url: 'https://github.com/thirsty5034/dsh-ssh-tunnel',
|
||||
description: () => t('pluginSshTunnelDesc'),
|
||||
// Peer-depends on dsh-better-sidebar (SSH Tunnel tab + center terminal/SFTP).
|
||||
// Install the prerequisite first; package is GitHub-sourced until npm publish.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add "dsh-ssh-tunnel@github:thirsty5034/dsh-ssh-tunnel"',
|
||||
},
|
||||
{
|
||||
id: 'dsh-turn-review',
|
||||
name: 'dsh-turn-review 本轮审查',
|
||||
url: 'https://github.com/yq04/dsh-turn-review',
|
||||
description: () => t('pluginTurnReviewDesc'),
|
||||
// Needs dsh-better-sidebar (optional peer) for the tab; no model tools.
|
||||
install: 'cd ~/.dsh && dsh plugin --profile web add dsh-better-sidebar && dsh plugin --profile web add git+https://github.com/yq04/dsh-turn-review.git',
|
||||
},
|
||||
]
|
||||
|
||||
+43
-2
@@ -14,11 +14,21 @@ import {
|
||||
clampTitleBarStrip,
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
TITLE_BAR_SCHEMES,
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
type SidebarPrefs,
|
||||
type TitleBarScheme,
|
||||
} from '../prefs-shared.ts'
|
||||
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampTerminalFontSize, clampTitleBarStrip, clampWidthPercent }
|
||||
export type { SidebarPrefs }
|
||||
export {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
TITLE_BAR_SCHEMES,
|
||||
TITLE_BAR_STRIP_DEFAULT,
|
||||
clampTerminalFontSize,
|
||||
clampTitleBarStrip,
|
||||
clampWidthPercent,
|
||||
}
|
||||
export type { SidebarPrefs, TitleBarScheme }
|
||||
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
|
||||
@@ -69,6 +79,22 @@ export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
editorExplorer: typeof record.editorExplorer === 'boolean'
|
||||
? record.editorExplorer
|
||||
: SIDEBAR_PREFS_DEFAULTS.editorExplorer,
|
||||
// The title-bar scheme (auto | web | preset | custom). The schema
|
||||
// declares the field WITHOUT a default, so documents written by older
|
||||
// plugin versions resolve without it — migrate from the legacy fields:
|
||||
// a document that ALREADY HAS VALUES (the manual compat flag on, or a
|
||||
// non-default strip px — both only reachable through the old gear
|
||||
// popup) maps to the `custom` scheme so the user's numbers keep
|
||||
// working; a pristine document keeps the conservative `auto` scheme.
|
||||
titleBarScheme: isTitleBarScheme(record.titleBarScheme)
|
||||
? record.titleBarScheme
|
||||
: (record.titleBarCompat === true || hasLegacyStripValue(record.titleBarStripPx) ? 'custom' : 'auto'),
|
||||
titleBarPresetId: typeof record.titleBarPresetId === 'string'
|
||||
? record.titleBarPresetId
|
||||
: SIDEBAR_PREFS_DEFAULTS.titleBarPresetId,
|
||||
customCss: typeof record.customCss === 'string'
|
||||
? record.customCss
|
||||
: SIDEBAR_PREFS_DEFAULTS.customCss,
|
||||
titleBarCompat: typeof record.titleBarCompat === 'boolean'
|
||||
? record.titleBarCompat
|
||||
: SIDEBAR_PREFS_DEFAULTS.titleBarCompat,
|
||||
@@ -130,6 +156,21 @@ function booleanMapOf(value: unknown): Record<string, boolean> {
|
||||
return out
|
||||
}
|
||||
|
||||
/** Type guard for the title-bar scheme union (anything else falls back). */
|
||||
function isTitleBarScheme(value: unknown): value is TitleBarScheme {
|
||||
return typeof value === 'string' && (TITLE_BAR_SCHEMES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the legacy document carries an explicit strip value (only
|
||||
* reachable through the old gear popup): a stored number different from the
|
||||
* default counts as "the user already configured something" and migrates to
|
||||
* the `custom` scheme.
|
||||
*/
|
||||
function hasLegacyStripValue(value: unknown): boolean {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value !== TITLE_BAR_STRIP_DEFAULT
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved side card preferences through the plugin's settings route.
|
||||
* @param settings - the settings wire face (the plugin api by default).
|
||||
|
||||
@@ -470,7 +470,7 @@ export function matchUrlTarget(tabs: readonly TabDescriptor[], url: URL): TabDes
|
||||
* The plugin version this service instance reports. Keep in lockstep with
|
||||
* `package.json`'s version — `tests/service.spec.ts` asserts the pair.
|
||||
*/
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.14.0'
|
||||
export const SIDEBAR_SERVICE_VERSION = '0.15.0'
|
||||
|
||||
/**
|
||||
* Monotonic capability list consumers use to gate new API usage (features
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Built-in desktop-shell presets for the "位置兼容模式" secondary setting
|
||||
* (scheme `preset`). DATA-DRIVEN by design: each shell's adaptation lives
|
||||
* in one entry (strip values / optional CSS), the core applies it through
|
||||
* the SAME generic strip variable and CSS-injection mechanism — adding a
|
||||
* shell is adding data, never a code path.
|
||||
*
|
||||
* Inclusion rule (maintained in AGENTS.md §8): only shells that (a) appear
|
||||
* in this repo's issues/PRs (a user actually hit a problem) and (b) have
|
||||
* 100+ GitHub stars (a real user base). The mechanism is opt-in: auto
|
||||
* detection never applies a preset — the settings badge only SUGGESTS it.
|
||||
*/
|
||||
import type { DesktopEnv } from './desktop-env.ts'
|
||||
|
||||
export interface ShellPreset {
|
||||
/** Stable preset id (persisted in `titleBarPresetId`). */
|
||||
readonly id: string
|
||||
/** User-facing name of the shell. */
|
||||
readonly title: string
|
||||
/** One-line description shown in the settings popup. */
|
||||
readonly desc: string
|
||||
/**
|
||||
* The top strip (px) this shell reserves over web content, per
|
||||
* environment — the fallback used when neither the standard WCO API nor
|
||||
* the `dsh-desktop-titlebar-inset` contract parameter is available.
|
||||
* Return undefined when the shell needs no strip in that environment.
|
||||
* MUST be pure (called during render).
|
||||
*/
|
||||
readonly stripFor?: (env: DesktopEnv) => number | undefined
|
||||
/**
|
||||
* Extra CSS applied while the preset is enabled (injected last, after the
|
||||
* plugin's own styles). Targets the plugin's stable data attributes and
|
||||
* shell-declared body/URL markers only — never other shells' class names.
|
||||
* Empty for presets fully covered by the strip variable.
|
||||
*/
|
||||
readonly css?: string
|
||||
/**
|
||||
* Whether this shell's marker is currently visible (URL stamps / preload
|
||||
* markers — see desktop-env.ts). Used ONLY for the settings "已检测"
|
||||
* badge; never auto-applies anything. MUST be pure.
|
||||
*/
|
||||
readonly detect?: (env: DesktopEnv) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* DeepSeek Harness Desktop (anywhere-labs, Electron, 16k+ stars — the most
|
||||
* reported shell in this repo's issues/PRs). Advanced mode: macOS reserves
|
||||
* a 20px caption row (traffic lights top-left), win32 draws the native
|
||||
* window controls in a ~32px overlay (WCO reports the real height when
|
||||
* available, which the auto scheme already consumes; the 32 is the
|
||||
* no-WCO fallback). Compatibility mode keeps the native frame — nothing.
|
||||
*/
|
||||
const DSH_DESKTOP: ShellPreset = {
|
||||
id: 'dsh-desktop',
|
||||
title: 'DeepSeek Harness Desktop',
|
||||
desc: 'Electron 高级模式(无边框):macOS 顶栏 20px、Windows 无 WCO 时 32px 标题栏让位',
|
||||
stripFor: (env) => {
|
||||
if (env.mode !== 'advanced') return undefined
|
||||
if (env.platform === 'darwin') return 20
|
||||
if (env.platform === 'win32') return 32
|
||||
return undefined
|
||||
},
|
||||
detect: (env) => env.mode === 'advanced',
|
||||
}
|
||||
|
||||
const PRESETS: readonly ShellPreset[] = [
|
||||
DSH_DESKTOP,
|
||||
]
|
||||
|
||||
/** All built-in shell presets (registration order = settings list order). */
|
||||
export function getShellPresets(): readonly ShellPreset[] {
|
||||
return PRESETS
|
||||
}
|
||||
|
||||
/** One preset by id, or undefined for an unknown/empty id. */
|
||||
export function getShellPreset(id: string): ShellPreset | undefined {
|
||||
return PRESETS.find(preset => preset.id === id)
|
||||
}
|
||||
|
||||
/** The strip the active preset contributes for the given environment. */
|
||||
export function presetStripFor(preset: ShellPreset | undefined, env: DesktopEnv): number | undefined {
|
||||
return preset?.stripFor?.(env)
|
||||
}
|
||||
@@ -282,6 +282,21 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
/* ── Desktop drag-region opt-out ──────────────────────────────────────── */
|
||||
|
||||
/* Frameless desktop shells make the top strip of the web content draggable
|
||||
(Electron `-webkit-app-region: drag`); pointer events over that strip are
|
||||
swallowed as window drags unless the interactive element opts out
|
||||
(issues #103/#111). The plugin's viewport-corner chrome must declare
|
||||
`no-drag` itself — some shells (Tauri, older desktop builds, third-party
|
||||
wrappers) have no blanket button rule. The property is a no-op in plain
|
||||
browsers and in shells without drag regions — safe everywhere. */
|
||||
.toggleCluster,
|
||||
.toggleButton,
|
||||
.tabBar {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* ── Position compatibility mode (Windows native title bar) ────────────── */
|
||||
|
||||
/* Windows frameless / hidden-title-bar windows draw the native caption
|
||||
@@ -832,6 +847,198 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Drag a file/folder over a tree row: the target directory row gets a filled
|
||||
highlight (drops land inside it; file rows target their parent directory).
|
||||
The zone frame around the whole tree is the portaled .uploadDropZone. */
|
||||
.explorerRowDropTarget {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
outline: 1px dashed var(--dsw-alias-interactive-bg-hover-accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
/* The sidebar's drop surface: a pointer-inert frame portaled to
|
||||
document.body, fixed over the explorer body's viewport rect at z-1001 —
|
||||
above DSH's own whole-page drop mask (z-1000, see InputBar's
|
||||
document-level intake). The giant box-shadow spread IS the full-viewport
|
||||
mask (same token DSH uses for its drop mask): everything dims except the
|
||||
zone rect, teaching the zone split — the dimmed conversation column still
|
||||
takes drops into the chat natively, the clear frame marks the tree as the
|
||||
workspace-upload zone. Deliberate exception to the "panel stays below the
|
||||
DSH float stack" rule: transient and pointer-events none, so the drop
|
||||
always lands on the row beneath. The hint pill docks at the TOP edge of
|
||||
the zone (right under the search row, the first thing the eye meets),
|
||||
leaving the rows — the actual drop targets — aimable. */
|
||||
.uploadDropZone {
|
||||
position: fixed;
|
||||
z-index: 1001;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 12px;
|
||||
border: 2px dashed var(--dsw-alias-interactive-bg-hover-accent);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 0 200vmax var(--dsw-alias-bg-mask-drop);
|
||||
animation: dsh-row-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* The zone's drop hero, docked at the TOP edge (right under the search
|
||||
row, the first thing the eye meets): the colorful tray illustration over
|
||||
the hint pill — stacked so the rows below stay aimable. */
|
||||
.uploadDropHero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
max-width: 100%;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.uploadDropZonePill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 999px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
}
|
||||
|
||||
.uploadDropZoneText {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The left zone's invitation: centered in the viewport space beside the
|
||||
tree (inline width = the zone's left edge), telling the user the dimmed
|
||||
conversation column still accepts drops — into the chat, natively. */
|
||||
.uploadDropChatHint {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 1002;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
animation: dsh-row-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* The invitation's content: the colorful photo-cards illustration over the
|
||||
label — the drop overlay is this flow's one brand moment, so it gets the
|
||||
color the rest of the UI never does (palette borrowed from DSH's native
|
||||
drop illustration so the two zones read as one family). */
|
||||
.uploadDropChatCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-s-strong-14);
|
||||
}
|
||||
|
||||
/* Full-window upload progress: blurred scrim + card (same mask/backdrop
|
||||
tokens as the repo's Modal primitive; absolute within the tree panel so it
|
||||
never covers the conversation column). */
|
||||
.uploadOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--dsw-alias-bg-mask-1);
|
||||
backdrop-filter: var(--dsw-mask-blur);
|
||||
animation: dsh-row-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.uploadOverlayCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 280px;
|
||||
max-width: min(420px, calc(100% - 48px));
|
||||
padding: 20px 24px;
|
||||
border: 1px solid var(--dsw-alias-border-inverted);
|
||||
border-radius: 24px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.uploadOverlayTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font: var(--dsw-font-s-strong-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.uploadOverlayTitle > svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.uploadOverlayTitle > span {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.uploadOverlayProgress {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.uploadOverlayProgressFill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
transition: width 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.uploadOverlayStatus {
|
||||
min-height: 1em;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.uploadOverlayCancel {
|
||||
align-self: flex-end;
|
||||
height: 28px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.uploadOverlayCancel:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.uploadOverlayCancel:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Editor ────────────────────────────────────────────────────────────── */
|
||||
|
||||
.editor {
|
||||
@@ -929,6 +1136,8 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
}
|
||||
|
||||
.editorTreePanel {
|
||||
/* Relative so the upload overlay and drop pill can cover the tree exactly. */
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Side Chat transcript mapping (browser half): turns a thread child's
|
||||
* history rows (`session.history` — the generic RPC, which reads the durable
|
||||
* log without activating the child) into compact display rows.
|
||||
*
|
||||
* A thread child's log starts with the ENTIRE inherited parent log as its
|
||||
* fork seed. The mapping therefore cuts everything up to the LAST
|
||||
* `session/end-seed` marker and drops the "Side conversation boundary"
|
||||
* prompt row, so the view shows only the thread's own conversation.
|
||||
*
|
||||
* Live streaming: `assistant/message` events only land when a step
|
||||
* completes, but `assistant/chunk` events stream token-level text and
|
||||
* reasoning deltas. The mapping accumulates both per block and supersedes
|
||||
* them with the assembled message once it lands (settled rows).
|
||||
*/
|
||||
import type { SidebarHistoryEntry } from '../context-types.ts'
|
||||
import { SIDE_BOUNDARY_PREFIX } from '../sidechat-core.ts'
|
||||
|
||||
/** One compact transcript row rendered in the thread view. `seq` is the
|
||||
* source event's log sequence — stable row identity for React keys across
|
||||
* polls (streaming caches ride the key, so window slides must not re-key
|
||||
* rows). */
|
||||
export type SidechatTranscriptRow =
|
||||
| { kind: 'user'; seq: number; text: string }
|
||||
/** `settled` distinguishes an assembled message from a still-streaming
|
||||
* chunk accumulation (streaming rows are superseded by the settle). */
|
||||
| { kind: 'assistant'; seq: number; text: string; settled: boolean }
|
||||
| { kind: 'reasoning'; seq: number; text: string; settled: boolean }
|
||||
| {
|
||||
kind: 'tool'
|
||||
seq: number
|
||||
name: string
|
||||
failed: boolean
|
||||
/** Raw arguments JSON as the model produced it. */
|
||||
args?: string
|
||||
/** Plain text of the paired result. */
|
||||
resultText?: string
|
||||
/** True while the call's result has not landed yet. */
|
||||
executing?: boolean
|
||||
}
|
||||
|
||||
/** Extract the visible text of a content-block list (`text` blocks verbatim,
|
||||
* joined by blank lines); empty reads `…` so rows never render blank. */
|
||||
export function blockText(content: readonly unknown[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
||||
parts.push(candidate.text)
|
||||
}
|
||||
}
|
||||
const text = parts.join('\n\n')
|
||||
return text === '' ? '…' : text
|
||||
}
|
||||
|
||||
/** The plain text of a tool/result message (text blocks inside its
|
||||
* `tool-result` content block). */
|
||||
function resultTextOf(data: Record<string, unknown>): string {
|
||||
const message = data.message as { content?: unknown } | undefined
|
||||
const content = message?.content
|
||||
if (!Array.isArray(content)) return ''
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; content?: unknown }
|
||||
if (candidate.type !== 'tool-result') continue
|
||||
const inner = candidate.content
|
||||
if (!Array.isArray(inner)) continue
|
||||
for (const item of inner) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const textItem = item as { type?: unknown; text?: unknown }
|
||||
if (textItem.type === 'text' && typeof textItem.text === 'string') {
|
||||
parts.push(textItem.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/** Index of the last `session/end-seed` event (fork seed marker), or -1. */
|
||||
function lastSeedEnd(events: readonly { type: string }[]): number {
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
if (events[index]?.type === 'session/end-seed') return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the thread's OWN events on first attach: walk backward from the
|
||||
* log tail (oldest-first accumulation) until the `session/end-seed` marker
|
||||
* surfaces, then keep everything after it.
|
||||
*
|
||||
* Page size matters: cold reads re-expand persisted chunk-rows into one
|
||||
* `assistant/chunk` event per delta, so a single streamed answer can be
|
||||
* HUNDREDS of events. A small walk window (the old 8×32 = 256 events) let
|
||||
* earlier `tool/call` events fall out of the loaded window — the tool rows
|
||||
* vanished on re-entry while the settled text survived. The walk therefore
|
||||
* pages big; tail polls stay small.
|
||||
*
|
||||
* Exhaustion (log start reached without a marker — a thread created before
|
||||
* seeding existed, or a pathological log) returns `seedBoundary: 0` so the
|
||||
* caller stops re-walking and renders the window as-is.
|
||||
*
|
||||
* @param fetchPage - one history page (newest-first window ending at
|
||||
* `beforeSeq`, exclusive; omit for the tail page).
|
||||
* @param pageCap - safety bound on backward pages.
|
||||
*/
|
||||
export async function collectOwnEvents(
|
||||
fetchPage: (beforeSeq?: number) => Promise<readonly SidebarHistoryEntry[]>,
|
||||
pageCap = 40,
|
||||
): Promise<{ seedBoundary: number; entries: SidebarHistoryEntry[] }> {
|
||||
const collected: SidebarHistoryEntry[] = []
|
||||
let beforeSeq: number | undefined
|
||||
for (let page = 0; page < pageCap; page++) {
|
||||
const events = await fetchPage(beforeSeq)
|
||||
if (events.length === 0) {
|
||||
// Log start reached without a marker: the window IS the whole log.
|
||||
return { seedBoundary: 0, entries: collected }
|
||||
}
|
||||
const olderThan = collected.length > 0 ? collected[0]!.event.seq : undefined
|
||||
const fresh = olderThan === undefined
|
||||
? [...events]
|
||||
: events.filter(entry => entry.event.seq < olderThan)
|
||||
const seedEnd = fresh.findLastIndex(entry => entry.event.type === 'session/end-seed')
|
||||
if (seedEnd >= 0) {
|
||||
collected.unshift(...fresh.slice(seedEnd + 1))
|
||||
return { seedBoundary: fresh[seedEnd]!.event.seq, entries: collected }
|
||||
}
|
||||
collected.unshift(...fresh)
|
||||
if (fresh.length === 0) {
|
||||
// The page overlaps entirely with what we have: nothing older exists.
|
||||
return { seedBoundary: 0, entries: collected }
|
||||
}
|
||||
beforeSeq = fresh[0]!.event.seq
|
||||
}
|
||||
// Cap hit: accept the window (it is overwhelmingly the thread's own tail)
|
||||
// rather than re-walking on every poll.
|
||||
return { seedBoundary: 0, entries: collected }
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a thread child's history rows onto compact transcript rows: the
|
||||
* inherited fork seed is cut at the last `session/end-seed`, the boundary
|
||||
* prompt row is dropped, `assistant/chunk` deltas accumulate into streaming
|
||||
* rows per (turn, step, block) and are superseded by the assembled
|
||||
* `assistant/message`, and tool invocations render one expandable line each
|
||||
* (arguments, paired result text, failure marker; a still-executing call is
|
||||
* marked until its result lands).
|
||||
* @param entries - history rows (event + host-computed view) in seq order.
|
||||
* @returns display rows in log order.
|
||||
*/
|
||||
export function transcriptRows(entries: readonly SidebarHistoryEntry[]): SidechatTranscriptRow[] {
|
||||
const events = entries.map(entry => entry.event)
|
||||
const seedEnd = lastSeedEnd(events)
|
||||
const rows: SidechatTranscriptRow[] = []
|
||||
/** (turn, step, index, kind) key → index of its accumulating stream row. */
|
||||
const streamRows = new Map<string, number>()
|
||||
/** tool callId → index of its tool row in `rows` (result pairing). */
|
||||
const callRows = new Map<string, number>()
|
||||
for (let index = 0; index < events.length; index++) {
|
||||
if (index <= seedEnd) continue
|
||||
const event = events[index]
|
||||
if (event === undefined) continue
|
||||
const data = event.data as Record<string, unknown>
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
const text = blockText(Array.isArray(data.content) ? data.content : [])
|
||||
if (text.startsWith(SIDE_BOUNDARY_PREFIX)) break
|
||||
rows.push({ kind: 'user', seq: event.seq, text })
|
||||
break
|
||||
}
|
||||
case 'assistant/chunk': {
|
||||
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
|
||||
if (chunk === null || typeof chunk !== 'object') break
|
||||
const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null
|
||||
if (kind === null || typeof chunk.text !== 'string' || chunk.text === '') break
|
||||
const turn = data.turn
|
||||
const step = data.step
|
||||
const blockIndex = (chunk as { index?: unknown }).index
|
||||
const key = `${String(turn)}:${String(step)}:${String(blockIndex)}:${kind}`
|
||||
const existing = streamRows.get(key)
|
||||
if (existing !== undefined) {
|
||||
const row = rows[existing]
|
||||
if (row !== undefined && row.kind === kind && !row.settled) {
|
||||
rows[existing] = { ...row, text: row.text + chunk.text }
|
||||
}
|
||||
} else {
|
||||
streamRows.set(key, rows.length)
|
||||
rows.push({ kind, seq: event.seq, text: chunk.text, settled: false })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
const prefix = `${String(data.turn)}:${String(data.step)}:`
|
||||
const streamed = [...streamRows.entries()]
|
||||
.filter(([key]) => key.startsWith(prefix))
|
||||
.map(([, rowIndex]) => rowIndex)
|
||||
for (const key of [...streamRows.keys()]) {
|
||||
if (key.startsWith(prefix)) streamRows.delete(key)
|
||||
}
|
||||
const content = Array.isArray((data.message as { content?: unknown } | undefined)?.content)
|
||||
? (data.message as { content: readonly unknown[] }).content
|
||||
: []
|
||||
const settled: SidechatTranscriptRow[] = content.flatMap((block): SidechatTranscriptRow[] => {
|
||||
if (block === null || typeof block !== 'object') return []
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'reasoning' && typeof candidate.text === 'string' && candidate.text !== '') {
|
||||
return [{ kind: 'reasoning', seq: event.seq, text: candidate.text, settled: true }]
|
||||
}
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string' && candidate.text !== '') {
|
||||
return [{ kind: 'assistant', seq: event.seq, text: candidate.text, settled: true }]
|
||||
}
|
||||
return []
|
||||
})
|
||||
if (streamed.length === 0) rows.push(...settled)
|
||||
else rows.splice(Math.min(...streamed), streamed.length, ...settled)
|
||||
break
|
||||
}
|
||||
case 'tool/call': {
|
||||
const callId = data.callId
|
||||
const name = typeof data.name === 'string' ? data.name : 'tool'
|
||||
const args = typeof data.arguments === 'string' ? data.arguments : undefined
|
||||
const rowIndex = rows.length
|
||||
if (typeof callId === 'string') callRows.set(callId, rowIndex)
|
||||
rows.push({ kind: 'tool', seq: event.seq, name, failed: false, args, executing: true })
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
const source = data.message as { source?: { callId?: unknown } } | undefined
|
||||
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
|
||||
const rowIndex = callId === undefined ? undefined : callRows.get(callId)
|
||||
const failed = data.error !== undefined
|
||||
const resultText = resultTextOf(data)
|
||||
if (rowIndex !== undefined) {
|
||||
const row = rows[rowIndex]
|
||||
if (row !== undefined && row.kind === 'tool') {
|
||||
rows[rowIndex] = {
|
||||
...row,
|
||||
failed: row.failed || failed,
|
||||
resultText: resultText === '' ? row.resultText : resultText,
|
||||
executing: false,
|
||||
}
|
||||
}
|
||||
} else if (failed || resultText !== '') {
|
||||
// Orphan result (no call row in the window): surface it so the row
|
||||
// stays informative and expandable.
|
||||
rows.push({
|
||||
kind: 'tool',
|
||||
seq: event.seq,
|
||||
name: callId === undefined ? 'tool' : `tool:${callId.slice(0, 8)}`,
|
||||
failed,
|
||||
resultText: resultText === '' ? undefined : resultText,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
default: {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Pure derivation of the compact LIVE line shown on a running subagent card:
|
||||
* the last text output and the last tool call of the child's history tail
|
||||
* (`subagent.history` events, oldest → newest). Renders nothing itself — the
|
||||
* SubagentLiveLine component turns this into the card's status lines.
|
||||
* Kept framework-free so the parser is unit-testable in the node environment.
|
||||
*/
|
||||
import type { SidebarHistoryEntry } from '../context-types.ts'
|
||||
|
||||
/**
|
||||
* Extract the concatenated plain text of a content-block list (the durable
|
||||
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
|
||||
* `text`; anything else — tool_use, image, … — contributes nothing).
|
||||
* @param content - the raw `content` field of a message event.
|
||||
* @returns the joined text, or undefined when the message carries no text.
|
||||
*/
|
||||
export function contentText(content: unknown): string | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
||||
parts.push(candidate.text)
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined
|
||||
}
|
||||
|
||||
/** The live status of one subagent card (both fields optional). */
|
||||
export interface LastActivity {
|
||||
/** The latest assembled assistant text output in the tail. */
|
||||
text?: string
|
||||
/** The latest tool call in the tail. */
|
||||
tool?: { name: string; args: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a history tail into the last text output + last tool call (each is
|
||||
* the LAST occurrence in event order). Lifecycle events and raw
|
||||
* `assistant/chunk` rows are ignored — the card shows what the subagent is
|
||||
* doing right now, not its plumbing.
|
||||
* @param entries - the tail page from `subagent.history` (oldest → newest).
|
||||
* @returns the last text and/or tool call; an empty object when the tail has neither.
|
||||
*/
|
||||
export function lastActivity(entries: SidebarHistoryEntry[]): LastActivity {
|
||||
let text: string | undefined
|
||||
let tool: { name: string; args: string } | undefined
|
||||
for (const entry of entries) {
|
||||
const { type, data } = entry.event
|
||||
if (type === 'assistant/message') {
|
||||
const message = data.message as { content?: unknown } | undefined
|
||||
const extracted = contentText(message?.content)
|
||||
if (extracted !== undefined) text = extracted
|
||||
} else if (type === 'tool/call') {
|
||||
tool = {
|
||||
name: typeof data.name === 'string' ? data.name : 'tool',
|
||||
args: typeof data.arguments === 'string' ? data.arguments : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text === undefined && tool === undefined) return {}
|
||||
return {
|
||||
...(text === undefined ? {} : { text }),
|
||||
...(tool === undefined ? {} : { tool }),
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,17 @@ import type {
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentCatalog,
|
||||
} from '../context-types.ts'
|
||||
import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts'
|
||||
|
||||
/**
|
||||
* Side Chat threads ride the subagent origin (main-list hiding + the RPC
|
||||
* ownership fence) but they are NOT subagent topology: they carry the
|
||||
* durable 'Side: ' label and live as sidebar tabs. Excluding them here
|
||||
* keeps the auto-open trigger and the Subagent page counts clean.
|
||||
*/
|
||||
export function isSideThreadSummary(summary: SidebarSessionSummary): boolean {
|
||||
return summary.origin === 'subagent' && summary.displayTitle.startsWith(SIDE_LABEL_PREFIX)
|
||||
}
|
||||
|
||||
/** Count the direct subagent children of one session (durable `origin` rows). */
|
||||
export function directSubagentCount(
|
||||
@@ -23,7 +34,8 @@ export function directSubagentCount(
|
||||
): number {
|
||||
let count = 0
|
||||
for (const summary of Object.values(byId)) {
|
||||
if (summary.origin === 'subagent' && summary.parentId === sessionId) count += 1
|
||||
if (summary.origin === 'subagent' && summary.parentId === sessionId
|
||||
&& !isSideThreadSummary(summary)) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -107,7 +119,7 @@ export function countSubagentDescendants(
|
||||
): SubagentDescendantTotals {
|
||||
const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 }
|
||||
for (const descendant of Object.values(byId)) {
|
||||
if (descendant.origin !== 'subagent') continue
|
||||
if (descendant.origin !== 'subagent' || isSideThreadSummary(descendant)) continue
|
||||
const seen = new Set<string>()
|
||||
let current: SidebarSessionSummary | undefined = descendant
|
||||
while (current?.origin === 'subagent' && current.parentId !== undefined
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* The title-bar strip resolution chain — the ONE place that decides how
|
||||
* many pixels the sidebar yields at the top. Standard signals first, then
|
||||
* the user's chosen scheme; never a per-shell branch:
|
||||
*
|
||||
* 0. `web` scheme — EXPLICIT "DSH official web": never adapt, not even
|
||||
* standard WCO geometry (the user declares the plain web UI).
|
||||
* 1. Window Controls Overlay real geometry (standard API, authoritative
|
||||
* when present — even 0, e.g. the overlay is hidden while maximized).
|
||||
* 2. The `dsh-desktop-titlebar-inset` URL contract parameter (a shell
|
||||
* declares the exact pixels it reserves).
|
||||
* 3. The active shell preset's strip (scheme `preset` — opt-in data).
|
||||
* 4. The legacy manual `titleBarStripPx` (scheme `custom`).
|
||||
* 5. 0 — plain-browser semantics, nothing modified.
|
||||
*
|
||||
* The result drives `--dsh-title-bar-strip` + `body[data-dsh-title-bar-compat]`
|
||||
* exactly like the legacy boolean did; only the VALUE source changed.
|
||||
*/
|
||||
import type { DesktopEnv } from './desktop-env.ts'
|
||||
import type { TitleBarScheme } from '../prefs-shared.ts'
|
||||
import type { WcoSnapshot } from './wco.ts'
|
||||
import { presetStripFor, type ShellPreset } from './shell-presets.ts'
|
||||
|
||||
export function computeTitleBarStrip(
|
||||
env: DesktopEnv,
|
||||
wco: WcoSnapshot,
|
||||
scheme: TitleBarScheme,
|
||||
preset: ShellPreset | undefined,
|
||||
customStripPx: number,
|
||||
): number {
|
||||
if (scheme === 'web') return 0
|
||||
if (wco.present) return wco.height
|
||||
if (env.titlebarInset > 0) return env.titlebarInset
|
||||
if (scheme === 'preset') return presetStripFor(preset, env) ?? 0
|
||||
if (scheme === 'custom') return customStripPx
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* File-upload plumbing for the files window: turn a file picker or a drag-drop
|
||||
* into per-file raw-byte uploads through the sidebar's `/sidebar/upload` route.
|
||||
*
|
||||
* Folders keep their tree in both flows: the picker's `webkitdirectory`
|
||||
* selection arrives as Files with `webkitRelativePath` filled, and dropped
|
||||
* folders — which never surface in `dataTransfer.files` — are traversed via
|
||||
* `webkitGetAsEntry`, so the relative path is preserved for every nested file
|
||||
* and the host recreates the tree under the chosen directory. The File is
|
||||
* streamed straight into the POST body (no base64 inflation); uploads run
|
||||
* sequentially so one slow file cannot starve the others, and each result
|
||||
* reports its own outcome (the tree keeps going after a failure). An
|
||||
* optional `AbortSignal` stops the queue at the next item boundary and
|
||||
* aborts the in-flight request; the host cleans up its temp file when the
|
||||
* request stream dies.
|
||||
*/
|
||||
import { api, SidebarApiError, type SessionScope } from './api.ts'
|
||||
import { isAbsolutePath } from './paths.ts'
|
||||
import type { CopyKey } from './locales.ts'
|
||||
|
||||
/** One pending file: the browser File plus the workspace-relative target path. */
|
||||
export interface UploadItem {
|
||||
file: File
|
||||
relativePath: string
|
||||
}
|
||||
|
||||
/** One settled upload. */
|
||||
export interface UploadResult {
|
||||
relativePath: string
|
||||
ok: boolean
|
||||
path?: string
|
||||
/** Wire error code when the host refused the upload ('too-large', ...). */
|
||||
code?: string
|
||||
/** The host's error message (English wire text; localize via `code`). */
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Sanitize a relative target: absolute paths, traversal, and empty segments
|
||||
* are rejected (the host enforces the same rules with a 400). */
|
||||
function sanitizeRelativePath(rel: string): string | undefined {
|
||||
if (rel === '' || isAbsolutePath(rel)) return undefined
|
||||
if (rel.split(/[\\/]/).some((s) => s === '' || s === '.' || s === '..')) return undefined
|
||||
return rel
|
||||
}
|
||||
|
||||
/** The picker's relative path: webkitRelativePath when present, else the name. */
|
||||
function relativePathOf(file: File): string | undefined {
|
||||
return sanitizeRelativePath(file.webkitRelativePath || file.name || '')
|
||||
}
|
||||
|
||||
/** Collect a picker selection (webkitdirectory folders carry relative paths). */
|
||||
export function uploadItemsFromFiles(files: FileList | readonly File[]): UploadItem[] {
|
||||
const items: UploadItem[] = []
|
||||
for (const file of files) {
|
||||
const rel = relativePathOf(file)
|
||||
if (rel !== undefined) items.push({ file, relativePath: rel })
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/** Read one dropped file-system entry into upload items; directories
|
||||
* recurse, prefixing their name onto every descendant's relative path. */
|
||||
async function itemsFromEntry(entry: FileSystemEntry, prefix: string): Promise<UploadItem[]> {
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise<File>((resolve, reject) => {
|
||||
;(entry as FileSystemFileEntry).file(resolve, reject)
|
||||
})
|
||||
const rel = sanitizeRelativePath(prefix + file.name)
|
||||
return rel === undefined ? [] : [{ file, relativePath: rel }]
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader()
|
||||
const entries: FileSystemEntry[] = []
|
||||
// readEntries returns one BATCH per call; drain until an empty batch.
|
||||
for (;;) {
|
||||
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject)
|
||||
})
|
||||
if (batch.length === 0) break
|
||||
entries.push(...batch)
|
||||
}
|
||||
const nested = await Promise.all(entries.map(child => itemsFromEntry(child, `${prefix}${entry.name}/`)))
|
||||
return nested.flat()
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect a drag-drop payload. Dropped folders do NOT surface in
|
||||
* `dataTransfer.files` — they arrive as directory items, so entries are
|
||||
* captured via `webkitGetAsEntry` and traversed (draining readEntries
|
||||
* batches), keeping each nested file's relative path. MUST be invoked
|
||||
* synchronously from the drop handler: the dataTransfer enters protected
|
||||
* mode once the event dispatch ends, while the captured entry handles stay
|
||||
* readable asynchronously. Falls back to the flat file list when the entry
|
||||
* API is unavailable; an entry that fails to read is skipped, not fatal.
|
||||
*/
|
||||
export async function uploadItemsFromDrop(data: DataTransfer | undefined): Promise<UploadItem[]> {
|
||||
if (data === undefined) return []
|
||||
// Synchronous section: capture every entry handle before returning.
|
||||
const entries = [...data.items]
|
||||
.map(item => (item.kind === 'file' ? item.webkitGetAsEntry() : null))
|
||||
.filter((entry): entry is FileSystemEntry => entry !== null)
|
||||
if (entries.length === 0) return uploadItemsFromFiles(data.files)
|
||||
const nested = await Promise.all(entries.map(entry => itemsFromEntry(entry, '').catch(() => [])))
|
||||
return nested.flat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side pre-check cap. Mirrors the host's default (config `uploadLimit`)
|
||||
* only — the host enforces the real, configurable limit, so a lowered host
|
||||
* limit surfaces as a `too-large` wire error, never as a silent pass.
|
||||
*/
|
||||
export const MAX_UPLOAD_BYTES = 128 * 1024 * 1024
|
||||
|
||||
/** How long a success hint stays before fading (failures stay until the next action). */
|
||||
export const UPLOAD_HINT_MS = 3500
|
||||
|
||||
/**
|
||||
* One-line upload progress text: 'Uploading into {dir}…' while no file is in
|
||||
* flight, then 'Uploading {done}/{total}: {name}' per file. Shared by the tree
|
||||
* hint and the full-window upload overlay.
|
||||
*/
|
||||
export function uploadHintText(
|
||||
done: number,
|
||||
total: number,
|
||||
current: string,
|
||||
dir: string,
|
||||
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
return current === '' ? t('uploadingTo', { dir }) : t('uploadProgress', { done, total, name: current })
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload every item into `dir` (absolute, inside the session workspace),
|
||||
* sequentially, reporting progress as `(done, total, currentRelativePath)`.
|
||||
* Resolves with one result per item — never rejects; `signal.aborted` stops
|
||||
* the queue at the next item boundary (completed items stay uploaded).
|
||||
*/
|
||||
export async function uploadToDir(
|
||||
scope: SessionScope,
|
||||
dir: string,
|
||||
items: UploadItem[],
|
||||
onProgress?: (done: number, total: number, current: string) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<UploadResult[]> {
|
||||
const results: UploadResult[] = []
|
||||
let done = 0
|
||||
for (const item of items) {
|
||||
if (signal?.aborted) break
|
||||
onProgress?.(done, items.length, item.relativePath)
|
||||
try {
|
||||
if (item.file.size > MAX_UPLOAD_BYTES) {
|
||||
results.push({ relativePath: item.relativePath, ok: false, code: 'too-large' })
|
||||
} else {
|
||||
const res = await api.uploadFile(scope, dir, item.relativePath, item.file, signal)
|
||||
results.push({ relativePath: item.relativePath, ok: true, path: res.path })
|
||||
}
|
||||
} catch (error) {
|
||||
// An aborted in-flight request is a cancel, not a failure: stop quietly.
|
||||
if (error instanceof DOMException && error.name === 'AbortError') break
|
||||
results.push({
|
||||
relativePath: item.relativePath,
|
||||
ok: false,
|
||||
code: error instanceof SidebarApiError ? error.code : undefined,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
done++
|
||||
}
|
||||
onProgress?.(done, items.length, '')
|
||||
return results
|
||||
}
|
||||
|
||||
/** Fold a result list into a one-line status for the tree hint. */
|
||||
export function summarizeResults(
|
||||
results: UploadResult[],
|
||||
t: (key: CopyKey, params?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
const okCount = results.filter((r) => r.ok).length
|
||||
const failed = results.find((r) => !r.ok)
|
||||
if (failed !== undefined) {
|
||||
const detail = failed.code === 'too-large' ? t('uploadTooLarge') : (failed.error ?? t('uploadFailedUnknown'))
|
||||
return t('uploadFailed', { error: detail })
|
||||
}
|
||||
return t('uploadDone', { count: okCount })
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Reactive Window Controls Overlay geometry — the STANDARD web mechanism
|
||||
* for frameless Chromium shells that draw the native caption buttons
|
||||
* (minimize / maximize / close) OVER the web content (Electron
|
||||
* `titleBarOverlay` on Windows; issue #257). Feature-detected: absent in
|
||||
* plain browsers, macOS, Tauri etc., where the snapshot stays `NONE` and
|
||||
* the sidebar adapts nothing — this is the conservative "auto" signal.
|
||||
*
|
||||
* The API reports the real titlebar rect (CSS px), which differs between
|
||||
* shells and Electron versions (32 / 36 …), and fires `geometrychange`
|
||||
* when the window maximizes / restores / moves — so the strip must be
|
||||
* reactive, not memoized like the URL stamps.
|
||||
*
|
||||
* Module-level store so the Sidebar shell can subscribe via
|
||||
* `useSyncExternalStore`; `setWcoSourceForTests` swaps the source for unit
|
||||
* tests (no real API exists in jsdom/headless).
|
||||
*/
|
||||
export interface WcoSource {
|
||||
readonly visible: boolean
|
||||
getTitlebarAreaRect(): { readonly x: number; readonly y: number; readonly width: number; readonly height: number }
|
||||
addEventListener(type: 'geometrychange', listener: () => void): void
|
||||
removeEventListener(type: 'geometrychange', listener: () => void): void
|
||||
}
|
||||
|
||||
/** The current overlay geometry (present=false = API unavailable). */
|
||||
export interface WcoSnapshot {
|
||||
readonly present: boolean
|
||||
readonly height: number
|
||||
}
|
||||
|
||||
/** Snapshot when the API is unavailable (plain browser / non-overlay shell). */
|
||||
export const WCO_NONE: WcoSnapshot = Object.freeze({ present: false, height: 0 })
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
let source: WcoSource | undefined
|
||||
let snapshot: WcoSnapshot = WCO_NONE
|
||||
let attached = false
|
||||
let sourceListener: (() => void) | undefined
|
||||
const listeners = new Set<Listener>()
|
||||
|
||||
function read(): WcoSnapshot {
|
||||
if (source === undefined) return WCO_NONE
|
||||
try {
|
||||
// A present-but-NOT-visible API is a PHANTOM: headless Chromium and
|
||||
// macOS builds expose the interface but draw no overlay (empty rect,
|
||||
// visible=false). Only a genuinely visible overlay is authoritative —
|
||||
// otherwise the resolution chain would trust a zero rect and silently
|
||||
// skip the shell's real reserved space.
|
||||
if (source.visible !== true) return { present: false, height: 0 }
|
||||
const rect = source.getTitlebarAreaRect()
|
||||
const height = Math.round(rect.height)
|
||||
return Number.isFinite(height) && height > 0
|
||||
? { present: true, height }
|
||||
: { present: true, height: 0 }
|
||||
} catch {
|
||||
// A hostile/racy API must never break the layout — treat as absent.
|
||||
return { present: false, height: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function onGeometryChange(): void {
|
||||
snapshot = read()
|
||||
emit()
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
/** Attach the native geometrychange listener (once). */
|
||||
function attach(): void {
|
||||
if (attached) return
|
||||
attached = true
|
||||
// The test hook may have installed a source already; otherwise attach to
|
||||
// the real navigator API (absent in plain browsers → stay NONE).
|
||||
const candidate = source ?? (navigator as unknown as { windowControlsOverlay?: WcoSource }).windowControlsOverlay
|
||||
if (candidate === undefined) return
|
||||
source = candidate
|
||||
sourceListener = onGeometryChange
|
||||
snapshot = read()
|
||||
source.addEventListener('geometrychange', sourceListener)
|
||||
}
|
||||
|
||||
/** Detach the native listener (last subscriber left or source swapped). */
|
||||
function detach(): void {
|
||||
if (source !== undefined && sourceListener !== undefined) {
|
||||
source.removeEventListener('geometrychange', sourceListener)
|
||||
}
|
||||
sourceListener = undefined
|
||||
attached = false
|
||||
}
|
||||
|
||||
/** Read the current snapshot (returns the frozen NONE when unavailable). */
|
||||
export function getWcoSnapshot(): WcoSnapshot {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to overlay geometry changes. Attaches to the real
|
||||
* `navigator.windowControlsOverlay` on first subscribe; the disposer
|
||||
* detaches the native listener when the last subscriber leaves.
|
||||
*/
|
||||
export function subscribeWco(onChange: Listener): () => void {
|
||||
listeners.add(onChange)
|
||||
attach()
|
||||
return () => {
|
||||
listeners.delete(onChange)
|
||||
if (listeners.size === 0) detach()
|
||||
}
|
||||
}
|
||||
|
||||
/** Test hook: swap the geometry source (undefined = API unavailable). */
|
||||
export function setWcoSourceForTests(next: WcoSource | undefined): void {
|
||||
detach()
|
||||
source = next
|
||||
snapshot = next === undefined ? WCO_NONE : read()
|
||||
if (next !== undefined && listeners.size > 0) attach()
|
||||
emit()
|
||||
}
|
||||
@@ -42,6 +42,8 @@ export interface SidebarConfig {
|
||||
readLimit?: number
|
||||
/** Media route cap (bytes); larger binaries are refused. */
|
||||
mediaLimit?: number
|
||||
/** Upload route cap (bytes); larger files are refused without touching disk. */
|
||||
uploadLimit?: number
|
||||
/** Explorer row bound of one level. */
|
||||
listLimit?: number
|
||||
/** Terminals per session. */
|
||||
@@ -70,6 +72,7 @@ export interface SidebarConfig {
|
||||
export const Config: z<SidebarConfig> = z.object({
|
||||
readLimit: z.number().step(1).min(1).default(512 * 1024),
|
||||
mediaLimit: z.number().step(1).min(1).default(20 * 1024 * 1024),
|
||||
uploadLimit: z.number().step(1).min(1).default(128 * 1024 * 1024),
|
||||
listLimit: z.number().step(1).min(1).default(1000),
|
||||
terminalsPerSession: z.number().step(1).min(1).default(3),
|
||||
reconnectGraceMs: z.number().step(1).min(0).default(30_000),
|
||||
@@ -81,6 +84,7 @@ export const Config: z<SidebarConfig> = z.object({
|
||||
export interface ResolvedSidebarConfig {
|
||||
readLimit: number
|
||||
mediaLimit: number
|
||||
uploadLimit: number
|
||||
listLimit: number
|
||||
terminalsPerSession: number
|
||||
reconnectGraceMs: number
|
||||
@@ -100,6 +104,7 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
|
||||
return {
|
||||
readLimit: config?.readLimit ?? 512 * 1024,
|
||||
mediaLimit: config?.mediaLimit ?? 20 * 1024 * 1024,
|
||||
uploadLimit: config?.uploadLimit ?? 128 * 1024 * 1024,
|
||||
listLimit: config?.listLimit ?? 1000,
|
||||
terminalsPerSession: config?.terminalsPerSession ?? 3,
|
||||
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
|
||||
@@ -124,6 +129,9 @@ export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
editorExplorer: z.boolean().default(false),
|
||||
terminalShell: z.string().default(''),
|
||||
terminalShellArgs: z.string().default(''),
|
||||
titleBarScheme: z.union([z.const('auto'), z.const('web'), z.const('preset'), z.const('custom')]),
|
||||
titleBarPresetId: z.string(),
|
||||
customCss: z.string(),
|
||||
titleBarCompat: z.boolean().default(false),
|
||||
titleBarStripPx: z.number().step(1).min(TITLE_BAR_STRIP_MIN).max(TITLE_BAR_STRIP_MAX).default(TITLE_BAR_STRIP_DEFAULT),
|
||||
htmlViewerNoSandbox: z.boolean().default(false),
|
||||
|
||||
+126
-1
@@ -225,6 +225,80 @@ export interface SidebarJobsService {
|
||||
export interface SidebarAgentsService {
|
||||
/** The live agent registered under a session id, or undefined when not live. */
|
||||
get(id: string): SidebarAgent | undefined
|
||||
/**
|
||||
* Create a session + agent with a custom seed (mirror of the runtime
|
||||
* AgentRegistry.create) — the Side Chat thread-creation seam: the SAME
|
||||
* public seam api-proxy's session.fork and the subagent fork provider use.
|
||||
*/
|
||||
create?(options: unknown): Promise<{ agent: SidebarAgent; dispose(): Promise<void> }>
|
||||
/**
|
||||
* Resume an agent on a persisted session (mirror of the runtime
|
||||
* AgentRegistry.resume) — the Side Chat cold-continuation seam after a
|
||||
* DSH restart or a closed thread.
|
||||
*/
|
||||
resume?(options: unknown): Promise<{ agent: SidebarAgent; dispose(): Promise<void> }>
|
||||
}
|
||||
|
||||
/** The host subagent runtime face (`ctx.subagents`; optional — the live
|
||||
* batch route degrades to a 503 when the deployment lacks it). Only the
|
||||
* read-only descendant enumeration this plugin needs is mirrored. */
|
||||
export interface SidebarSubagentsService {
|
||||
/**
|
||||
* Enumerate the root's complete session-backed subagent tree in stable
|
||||
* pre-order without loading or resuming an Agent (mirror of
|
||||
* `SubagentRuntime.listDescendants`).
|
||||
*/
|
||||
listDescendants(
|
||||
rootSessionId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SidebarSubagentDescendantEntry[]>
|
||||
}
|
||||
|
||||
/** One descendant row of `ctx.subagents.listDescendants` (structural mirror). */
|
||||
export type SidebarSubagentDescendantEntry =
|
||||
| {
|
||||
kind: 'child'
|
||||
id: string
|
||||
activity: 'running' | 'inactive'
|
||||
hasChildren: boolean
|
||||
mode: 'one-shot' | 'continuable'
|
||||
label?: string
|
||||
parentId: string
|
||||
depth: number
|
||||
}
|
||||
| {
|
||||
kind: 'diagnostic'
|
||||
id: string
|
||||
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
parentId: string
|
||||
depth: number
|
||||
}
|
||||
|
||||
/** The host agent-presets service face (mirror of the runtime agentPresets
|
||||
* service): resolves and mounts the preset composition a session recorded,
|
||||
* so a resumed or forked session rebuilds the same tool/prompt world its
|
||||
* history was produced under. */
|
||||
export interface SidebarAgentPresetsService {
|
||||
/** Resolve a preset id; undefined resolves the deployment default. */
|
||||
resolve(presetId?: string): Promise<{ id: string }>
|
||||
/** Mount a preset's composition into an agent scope before publication. */
|
||||
mount(agentCtx: unknown, presetId: string): Promise<void>
|
||||
}
|
||||
|
||||
/** The host session-title service face (mirror of the sessionTitle service). */
|
||||
export interface SidebarSessionTitleService {
|
||||
/** Rename one live session's title (pins it against auto-regeneration). */
|
||||
rename(session: unknown, title: string): { title: string; eventSeq: number }
|
||||
}
|
||||
|
||||
/** The host session-persistence face (mirror of the sessionPersistence
|
||||
* service): detached inspection of a persisted session, used to compose the
|
||||
* recorded preset when a Side Chat thread cold-resumes. */
|
||||
export interface SidebarSessionPersistenceService {
|
||||
inspect(sessionId: string): Promise<{
|
||||
meta: { cwd?: string; agentPreset?: string }
|
||||
events: readonly SidebarSessionEvent[]
|
||||
}>
|
||||
}
|
||||
|
||||
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
|
||||
@@ -236,9 +310,21 @@ export interface SidebarRpcResponse<T> {
|
||||
result: SidebarRpcResult<T>
|
||||
}
|
||||
|
||||
/** The generic session-history RPC face the Side Chat transcript polls
|
||||
* (subagent.history verifies subagent-catalog membership, which our custom
|
||||
* side-thread children do not have — the generic session.history reads any
|
||||
* durable log directly). */
|
||||
export interface SidebarSessionHistoryRpc {
|
||||
history(
|
||||
payload: { sessionId: string; beforeSeq?: number; maxMessages?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
|
||||
}
|
||||
|
||||
/** The wire face the Subagent activity summary needs (subset of `ctx.connection`). */
|
||||
export interface SidebarConnectionHandle {
|
||||
api: {
|
||||
sessions: SidebarSessionHistoryRpc
|
||||
subagents: {
|
||||
history(
|
||||
payload: SidebarSubagentAddress & { beforeSeq?: number; maxMessages?: number },
|
||||
@@ -273,6 +359,23 @@ export interface SidebarSessionsService {
|
||||
* — used to jump back to the main agent from the topology root node.
|
||||
*/
|
||||
open?(id: string): void
|
||||
/**
|
||||
* Fork a session from a completed-turn prefix of the source and resolve
|
||||
* the child session id (mirror of the runtime ISessions.fork — throws on
|
||||
* failure). The Side Chat "save as new session" action uses this to
|
||||
* promote a hidden side thread into a top-level session.
|
||||
*/
|
||||
fork?(opts: { sessionId: string; atSeq?: number; increaseTitle?: boolean }): Promise<string>
|
||||
/**
|
||||
* Resolve the stable session binding of one listed session (mirror of the
|
||||
* runtime ISessions.binding); the saved-session rename uses the face's
|
||||
* behavior verbs.
|
||||
*/
|
||||
binding?(id: string): {
|
||||
session: {
|
||||
rename(title: string): Promise<unknown>
|
||||
}
|
||||
} | undefined
|
||||
/**
|
||||
* Resolve an Agent-scoped context view for one session (mirror of the
|
||||
* runtime ISessions.scope) — the ticket `ctx.conversation.input.for`
|
||||
@@ -442,9 +545,31 @@ declare module 'cordis' {
|
||||
jobs: SidebarJobsService
|
||||
/**
|
||||
* The host live-agent registry (`ctx.get('agents')`; optional — used to
|
||||
* resolve the caller the jobs fence compares against).
|
||||
* resolve the caller the jobs fence compares against, and to create /
|
||||
* resume the Side Chat thread agents).
|
||||
*/
|
||||
agents: SidebarAgentsService
|
||||
/**
|
||||
* The host subagent runtime (`ctx.subagents`; optional — the Subagent
|
||||
* page's live batch route reads descendant catalogs through it).
|
||||
*/
|
||||
subagents: SidebarSubagentsService
|
||||
/**
|
||||
* The host agent-presets service (`ctx.get('agentPresets')`; optional —
|
||||
* absent deployments compose nothing and every session shares the host
|
||||
* composition).
|
||||
*/
|
||||
agentPresets: SidebarAgentPresetsService
|
||||
/**
|
||||
* The host session-title service (`ctx.get('sessionTitle')`; optional —
|
||||
* the Side Chat thread label pin degrades to the auto-generated title).
|
||||
*/
|
||||
sessionTitle: SidebarSessionTitleService
|
||||
/**
|
||||
* The host session-persistence service (`ctx.get('sessionPersistence')`;
|
||||
* optional — needed only for the Side Chat cold-resume composition).
|
||||
*/
|
||||
sessionPersistence: SidebarSessionPersistenceService
|
||||
/**
|
||||
* The client-side sidebar registry: external plugins register tab types
|
||||
* and file previewers here. Provided by the client half (see
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Workspace-safe file mutations for the sidebar (the upload route today).
|
||||
*
|
||||
* Every write is lexically confined to the session workspace: the upload
|
||||
* directory is resolved absolute and must sit inside the session cwd, the
|
||||
* relative path is sanitized (absolute paths, '.', '..' and empty segments
|
||||
* are refused), and the final target must stay inside both. Containment is
|
||||
* lexical (no symlink resolution) — a symlinked directory inside the cwd can
|
||||
* redirect writes outside, matching the trust model of the other /sidebar/*
|
||||
* routes. Bytes stream from the request body to a uniquely named temp sibling
|
||||
* and are renamed into place, so a failed, aborted, or oversized upload never
|
||||
* leaves a partial file at the target path.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { once } from 'node:events'
|
||||
import { createWriteStream } from 'node:fs'
|
||||
import { mkdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import type { SidebarHttpRequest } from './context-types.ts'
|
||||
import { isWithin, requireAbsolute } from './fs-tree.ts'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** Inputs of one upload: the session scope plus the request body stream. */
|
||||
export interface WorkspaceUploadInput {
|
||||
/** The session workspace root; target and directory must stay inside it. */
|
||||
cwd: string
|
||||
/** Absolute upload directory chosen by the client (inside `cwd`). */
|
||||
dir: string
|
||||
/** Relative path below `dir` (absolute paths, '.', '..' and empty segments refused). */
|
||||
relativePath: string
|
||||
/** The request body stream (raw bytes). */
|
||||
chunks: AsyncIterable<string | Uint8Array>
|
||||
/** Byte cap; an oversized upload is refused without touching the target. */
|
||||
limit: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream `chunks` into `dir/relativePath` atomically: a uniquely named temp
|
||||
* sibling receives the bytes, then is renamed over the target. The parent
|
||||
* directory is created on demand (recursive), so folder uploads work before
|
||||
* any level exists. The unique temp name keeps concurrent uploads to the same
|
||||
* target independent (each writes and renames its own file; the last rename
|
||||
* wins) and never blocks later uploads after a crashed process.
|
||||
*
|
||||
* @throws SidebarError with a wire code for containment, shape, and size
|
||||
* failures; the temp file is always removed on failure.
|
||||
*/
|
||||
export async function writeWorkspaceUpload(input: WorkspaceUploadInput): Promise<{ path: string; size: number }> {
|
||||
const { cwd, dir, relativePath, chunks, limit } = input
|
||||
const base = requireAbsolute(dir)
|
||||
if (!isWithin(cwd, base)) {
|
||||
throw new SidebarError('forbidden', 'upload directory escapes the session workspace', 403)
|
||||
}
|
||||
if (relativePath === '' || relativePath.startsWith('/') || relativePath.startsWith('\\')) {
|
||||
throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
|
||||
}
|
||||
const segments = relativePath.split(/[\\/]/)
|
||||
if (segments.some(part => part === '' || part === '.' || part === '..')) {
|
||||
throw new SidebarError('bad-request', 'relativePath must stay below the upload directory', 400)
|
||||
}
|
||||
const target = join(base, ...segments)
|
||||
if (!isWithin(cwd, target) || !isWithin(base, target)) {
|
||||
throw new SidebarError('forbidden', 'target escapes the session workspace', 403)
|
||||
}
|
||||
const tmp = join(dirname(target), `.${basename(target)}.dsh-upload-${randomUUID()}.tmp`)
|
||||
await mkdir(dirname(target), { recursive: true })
|
||||
const stream = createWriteStream(tmp, { flags: 'wx' })
|
||||
// Resolves once the stream fully closes; created up front so a stream that
|
||||
// already closed (successful end, later failure) cannot leave the wait hanging.
|
||||
const closed = new Promise<void>((resolve) => { stream.once('close', () => resolve()) })
|
||||
let size = 0
|
||||
let streamError: unknown
|
||||
// A permanent 'error' listener keeps a failing disk from crashing the host:
|
||||
// every await below surfaces the failure through the promise chain instead.
|
||||
stream.on('error', (error) => { streamError = error })
|
||||
try {
|
||||
for await (const chunk of chunks) {
|
||||
const buffer = Buffer.from(chunk)
|
||||
size += buffer.length
|
||||
if (size > limit) throw new SidebarError('too-large', `upload exceeds the ${limit} byte limit`, 413)
|
||||
if (!stream.write(buffer)) await once(stream, 'drain')
|
||||
if (streamError !== undefined) throw streamError
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.end((error?: Error | null) => (error === undefined || error === null ? resolve() : reject(error)))
|
||||
})
|
||||
if (streamError !== undefined) throw streamError
|
||||
await rename(tmp, target)
|
||||
const info = await stat(target)
|
||||
return { path: target, size: info.size }
|
||||
} catch (error) {
|
||||
// Wait for the stream to fully close before unlinking (Windows locks open
|
||||
// files), then remove our own uniquely named temp file.
|
||||
stream.destroy()
|
||||
await closed.catch(() => {})
|
||||
await rm(tmp, { force: true }).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type SidebarPrefs,
|
||||
} from './config.ts'
|
||||
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
||||
import { writeWorkspaceUpload } from './fs-operations.ts'
|
||||
import { searchFiles } from './fs-search.ts'
|
||||
import { decodeHtmlUrl } from './html-route.ts'
|
||||
import { extractFrameAncestors } from './browser-probe.ts'
|
||||
@@ -46,6 +47,8 @@ import {
|
||||
} from './pty-deps.ts'
|
||||
import { registerTools } from './tools.ts'
|
||||
import { buildJobsApi, type SidebarJobsRoutes } from './jobs-routes.ts'
|
||||
import { buildSubagentLiveApi, type SidebarSubagentLiveRoutes } from './subagent-live-route.ts'
|
||||
import { buildSidechatApi } from './sidechat-routes.ts'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
|
||||
export { Config }
|
||||
@@ -232,6 +235,10 @@ function buildApi(
|
||||
// job_output cursor is never consumed) and kill (the registry's stock
|
||||
// API). A deployment without the jobs registry downgrades kill to a 503.
|
||||
const jobsApi: SidebarJobsRoutes = buildJobsApi(ctx, resolved.readLimit)
|
||||
// Subagent live previews: one batch request instead of N per-child
|
||||
// `subagents.history` calls. The route degrades to a 503 when the host
|
||||
// subagent runtime is absent (the page has no topology to show anyway).
|
||||
const subagentLiveApi: SidebarSubagentLiveRoutes = buildSubagentLiveApi(ctx)
|
||||
return {
|
||||
'session.cwd': (payload) => {
|
||||
const { sessionId, cwd } = cwdOf(payload)
|
||||
@@ -384,6 +391,9 @@ function buildApi(
|
||||
// exists. Kill is fenced to the owning session by the jobs registry.
|
||||
'jobs.output': (payload) => jobsApi.output(payload),
|
||||
'jobs.kill': (payload) => jobsApi.kill(payload),
|
||||
// Subagent live previews: one batch request per refresh; the route folds
|
||||
// the newest text/tool activity of every running child in the tree.
|
||||
'subagents.live': (payload) => subagentLiveApi.live(payload),
|
||||
// The effective terminal shell and its display name. The client uses
|
||||
// this to title terminal tabs with the shell name instead of a numbered
|
||||
// "Terminal N" label; the shell itself is configured through
|
||||
@@ -469,6 +479,14 @@ function buildApi(
|
||||
clearTimeout(timer)
|
||||
}
|
||||
},
|
||||
// Side Chat: create a side-thread child seeded with the parent's full
|
||||
// log up to now, deliver follow-ups (cold-resuming when the thread's
|
||||
// agent is gone), abort a running thread, and release a thread's agent.
|
||||
// Every operation runs through these routes because subagent-origin
|
||||
// identities are fenced from the generic session RPCs (agent-lookup
|
||||
// ownership), and the thread is created with a CUSTOM seed the stock
|
||||
// fork APIs cannot express.
|
||||
...buildSidechatApi(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -622,6 +640,47 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
},
|
||||
}), 'dsh-better-sidebar: /sidebar/api routes')
|
||||
|
||||
// ── Raw upload route ───────────────────────────────────────────────────
|
||||
// One request writes one file without JSON/base64 inflation. Folder uploads
|
||||
// send each file with a relativePath, preserving the selected directory
|
||||
// tree. Bytes stream to a temp sibling and are renamed into place, so a
|
||||
// failed or oversized upload never leaves a partial file (see
|
||||
// fs-operations.ts for the containment and shape rules).
|
||||
ctx.effect(() => ctx.webServer.register({
|
||||
kind: 'exact',
|
||||
path: '/sidebar/upload',
|
||||
handler: async (req, res) => {
|
||||
if (!fence(req)) {
|
||||
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
|
||||
return
|
||||
}
|
||||
if (req.method !== 'POST') {
|
||||
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
const dir = url.searchParams.get('dir')
|
||||
const relativePath = url.searchParams.get('relativePath')
|
||||
if (sessionId === null || dir === null || relativePath === null || relativePath.trim() === '') {
|
||||
throw new SidebarError('bad-request', 'sessionId, dir, and relativePath are required')
|
||||
}
|
||||
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
|
||||
const { path, size } = await writeWorkspaceUpload({
|
||||
cwd,
|
||||
dir,
|
||||
relativePath,
|
||||
chunks: req,
|
||||
limit: resolved.uploadLimit,
|
||||
})
|
||||
writeOk(res, { path, size })
|
||||
} catch (error) {
|
||||
writeError(res, error)
|
||||
}
|
||||
},
|
||||
}), 'dsh-better-sidebar: /sidebar/upload route')
|
||||
|
||||
// ── Lazy chunk route (client bundle splits) ─────────────────────────────
|
||||
// Serves the client half's split bundles (lib/client-<name>.js) so the
|
||||
// heavy preview/terminal libraries load on first use, not at page start
|
||||
|
||||
+41
-8
@@ -85,17 +85,43 @@ export interface SidebarPrefs {
|
||||
*/
|
||||
terminalShellArgs: string
|
||||
/**
|
||||
* Position compatibility mode: reserves space at the top for the native
|
||||
* Windows title bar (drawn at the window's top-right corner over the web
|
||||
* content in frameless/hidden-title-bar windows). When on, the toggle
|
||||
* cluster drops below the strip and the right panel's content starts
|
||||
* below it. Off by default — the sidebar layout is untouched.
|
||||
* Title-bar / shell compatibility scheme (the "位置兼容模式" setting):
|
||||
* - `auto` (default): CONSERVATIVE — only the standard Window Controls
|
||||
* Overlay API (present in frameless Chromium shells that draw the
|
||||
* native caption buttons over web content) contributes real geometry;
|
||||
* without it nothing is modified, so plain-browser (web) behavior is
|
||||
* untouched.
|
||||
* - `web`: EXPLICIT "DSH official web" — never adapt, not even WCO
|
||||
* geometry (the user declares they run the plain web UI).
|
||||
* - `preset`: apply the built-in shell preset named by
|
||||
* `titleBarPresetId` (data-driven, opt-in — see shell-presets.ts).
|
||||
* - `custom`: apply the free-form `customCss` (and the legacy
|
||||
* `titleBarStripPx` strip).
|
||||
*/
|
||||
titleBarScheme: TitleBarScheme
|
||||
/**
|
||||
* The built-in shell preset id applied while `titleBarScheme` is
|
||||
* `preset` ('' = no preset — nothing extra is applied).
|
||||
*/
|
||||
titleBarPresetId: string
|
||||
/**
|
||||
* Free-form CSS injected into the page (last in the cascade, so it can
|
||||
* override the plugin's styles; use `!important` to override JS-written
|
||||
* inline CSS variables). Applied while `titleBarScheme` is `custom`.
|
||||
*/
|
||||
customCss: string
|
||||
/**
|
||||
* LEGACY (kept for read-migration and downgrade mirroring only): position
|
||||
* compatibility mode flag. The UI writes `titleBarScheme` instead; a
|
||||
* stored `true` without a scheme migrates to the `custom` scheme (with
|
||||
* `titleBarStripPx` preserved).
|
||||
*/
|
||||
titleBarCompat: boolean
|
||||
/**
|
||||
* The reserved top strip height in px when `titleBarCompat` is on
|
||||
* (0–120, default 40). Drives the `--dsh-title-bar-strip` CSS variable:
|
||||
* the toggle cluster drops `strip + 3px` and the right panel's content
|
||||
* LEGACY (kept for read-migration and downgrade mirroring only): the
|
||||
* reserved top strip height in px used by the `custom` scheme (0–120,
|
||||
* default 40). Drives the `--dsh-title-bar-strip` CSS variable: the
|
||||
* toggle cluster drops `strip + 3px` and the right panel's content
|
||||
* starts `strip` px below its top edge.
|
||||
*/
|
||||
titleBarStripPx: number
|
||||
@@ -192,6 +218,10 @@ export const TITLE_BAR_STRIP_MIN = 0
|
||||
export const TITLE_BAR_STRIP_MAX = 120
|
||||
export const TITLE_BAR_STRIP_DEFAULT = 40
|
||||
|
||||
/** The title-bar / shell compatibility schemes (see {@link SidebarPrefs.titleBarScheme}). */
|
||||
export const TITLE_BAR_SCHEMES = ['auto', 'web', 'preset', 'custom'] as const
|
||||
export type TitleBarScheme = typeof TITLE_BAR_SCHEMES[number]
|
||||
|
||||
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
||||
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: false,
|
||||
@@ -206,6 +236,9 @@ export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: TITLE_BAR_STRIP_DEFAULT,
|
||||
htmlViewerNoSandbox: false,
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
/**
|
||||
* Pure side-conversation ("Side Chat") logic shared by the host routes and
|
||||
* the client tab. Framework-free (no React, no Node) so both halves and the
|
||||
* node test environment can import it.
|
||||
*
|
||||
* A side thread is a child session the plugin creates ITSELF with a custom
|
||||
* seed — the parent session's FULL event log up to the click moment
|
||||
* (completed turns, the unanswered user message, and — when the parent is
|
||||
* mid-turn — the in-progress assistant output and tool activity). The log
|
||||
* model forbids open-turn seeds, so an in-flight parent turn is copied
|
||||
* verbatim and CLOSED with synthetic `step/end` + `turn/end{reason:
|
||||
* 'interrupted'}` events: the child sees the partial turn as honestly
|
||||
* frozen ("cut off"), never as a completed answer. The one case that cannot
|
||||
* be closed honestly — a tool call still executing (no `tool/result` yet;
|
||||
* providers reject dangling assistant calls) — falls back to cutting before
|
||||
* the open turn and carrying the partial content as a structured text
|
||||
* snapshot inside the boundary prompt.
|
||||
*/
|
||||
import type { SidebarHistoryEntry, SidebarSessionSummary } from './context-types.ts'
|
||||
|
||||
/** The durable thread-label prefix (also the row filter in the client list). */
|
||||
export const SIDE_LABEL_PREFIX = 'Side: '
|
||||
|
||||
/** The pinned label of a freshly created thread that no prompt has reached
|
||||
* yet (Codex-style immediate create: the tab opens an EMPTY thread, the
|
||||
* first composer message carries the boundary and earns the real label).
|
||||
* The client renders it localized; the prefix keeps the row filter honest. */
|
||||
export const SIDE_NEW_THREAD_TITLE = 'Side: New thread'
|
||||
|
||||
/** Maximum code points kept in a durable thread label (matches subagent labels). */
|
||||
export const LABEL_MAX_CHARS = 48
|
||||
|
||||
/** The boundary message's opening line — the transcript mapping drops user
|
||||
* rows starting with it (same first line as dsh-sidechain's boundary, so
|
||||
* the two plugins' threads render consistently in either UI). */
|
||||
export const SIDE_BOUNDARY_PREFIX = 'Side conversation boundary'
|
||||
|
||||
/**
|
||||
* The boundary prompt delivered as the thread's first user message: the
|
||||
* inherited seed is reference context only, never active instruction.
|
||||
* Model-facing contract — change only with intent, tests pin the sentences.
|
||||
*/
|
||||
export const SIDE_BOUNDARY_PROMPT = `Side conversation boundary.
|
||||
|
||||
Everything before this boundary is inherited history from the parent session: its completed turns, its pending question, and — if the parent was mid-turn — its in-progress output frozen at the moment this side conversation started. It is reference context only. It is not your current task.
|
||||
|
||||
Do not continue, execute, or complete any instructions, plans, tool calls, approvals, edits, or requests from before this boundary. Only messages submitted after this boundary are active user instructions for this side conversation.
|
||||
|
||||
Mode: this is a continuable side conversation. Your answers stay in this side thread and are viewed in the side panel; they are never delivered into the parent session.`
|
||||
|
||||
/** One seed event (structural mirror of the durable SessionEvent). The
|
||||
* envelope fields are preserved verbatim: surface-eligible events
|
||||
* (user/message, assistant/message, tool/result) REQUIRE the `surfaceOp`
|
||||
* marker (and may carry `sourceEventSeqs`) — the seed validator rejects
|
||||
* them without it. */
|
||||
export interface SeedEvent {
|
||||
type: string
|
||||
seq: number
|
||||
time: number
|
||||
data: Record<string, unknown>
|
||||
/** Surface marker of message-producing events ('append' | replace op). */
|
||||
surfaceOp?: unknown
|
||||
/** Seq numbers of earlier events this event cites as sources. */
|
||||
sourceEventSeqs?: unknown
|
||||
/** Reader-skip marker of purely informational events. */
|
||||
ignorable?: true
|
||||
}
|
||||
|
||||
/** The minimal structural face of a session-log event this module reads
|
||||
* (loose enough to accept both the host's real SessionEvent and the
|
||||
* client's SidebarSessionEvent mirror). */
|
||||
export interface SidechatLogEvent {
|
||||
type: string
|
||||
seq: number
|
||||
time: number
|
||||
data: unknown
|
||||
}
|
||||
|
||||
/** The result of cutting a parent log into a side-thread inheritance. */
|
||||
export interface SidechatInheritance {
|
||||
/** The child seed: contiguous from seq 0, ends outside any open turn. */
|
||||
seed: SeedEvent[]
|
||||
/**
|
||||
* Structured snapshot of the parent's in-progress turn when it could NOT
|
||||
* be included as events (a tool call was still executing); null when the
|
||||
* seed already carries the whole picture.
|
||||
*/
|
||||
snapshot: string | null
|
||||
}
|
||||
|
||||
/** The data record of one event (narrowed from the loose face). */
|
||||
function dataOf(event: SidechatLogEvent): Record<string, unknown> {
|
||||
return event.data as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Copy parent events verbatim (their live seq === array index contract).
|
||||
* The FULL envelope is preserved — stripping `surfaceOp` would make the
|
||||
* seed validator reject every surface-eligible message event. */
|
||||
function copyEvents(events: readonly SidechatLogEvent[]): SeedEvent[] {
|
||||
return events.map(event => {
|
||||
const source = event as SidechatLogEvent & {
|
||||
surfaceOp?: unknown
|
||||
sourceEventSeqs?: unknown
|
||||
ignorable?: true
|
||||
}
|
||||
return {
|
||||
type: source.type,
|
||||
seq: source.seq,
|
||||
time: source.time,
|
||||
data: dataOf(source),
|
||||
...(source.surfaceOp === undefined ? {} : { surfaceOp: source.surfaceOp }),
|
||||
...(source.sourceEventSeqs === undefined ? {} : { sourceEventSeqs: source.sourceEventSeqs }),
|
||||
...(source.ignorable === undefined ? {} : { ignorable: source.ignorable }),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Index of the last `turn/start` or `turn/end`, or -1. */
|
||||
function lastTurnBoundary(events: readonly SidechatLogEvent[]): number {
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const type = events[index]?.type
|
||||
if (type === 'turn/start' || type === 'turn/end') return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/** Numeric field of an event's data (turn / step numbers). */
|
||||
function numberAt(data: Record<string, unknown>, key: string): number {
|
||||
const value = data[key]
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) ? value : 0
|
||||
}
|
||||
|
||||
/** The step number still open at the log tail inside the turn starting at
|
||||
* `turnStart` (undefined when no step is open). */
|
||||
function openStepInTurn(events: readonly SidechatLogEvent[], turnStart: number): number | undefined {
|
||||
let open: number | undefined
|
||||
for (let index = turnStart + 1; index < events.length; index++) {
|
||||
const event = events[index]
|
||||
if (event === undefined) continue
|
||||
if (event.type === 'step/start') open = numberAt(dataOf(event), 'step')
|
||||
else if (event.type === 'step/end') open = undefined
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the open turn ending the log has a `tool/call` without its paired
|
||||
* `tool/result` in the CURRENT open step. Providers reject dangling
|
||||
* assistant calls, so such a turn cannot be honestly closed and the
|
||||
* inheritance must fall back to the snapshot.
|
||||
*/
|
||||
export function hasDanglingToolCall(events: readonly SidechatLogEvent[], turnStart: number): boolean {
|
||||
const pending = new Set<string>()
|
||||
for (let index = turnStart + 1; index < events.length; index++) {
|
||||
const event = events[index]
|
||||
if (event === undefined) continue
|
||||
const data = dataOf(event)
|
||||
if (event.type === 'step/end') {
|
||||
pending.clear()
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/call') {
|
||||
const callId = data.callId
|
||||
if (typeof callId === 'string') pending.add(callId)
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
const source = data.message as { source?: { callId?: unknown } } | undefined
|
||||
const callId = source?.source?.callId
|
||||
if (typeof callId === 'string') pending.delete(callId)
|
||||
}
|
||||
}
|
||||
return pending.size > 0
|
||||
}
|
||||
|
||||
/** The plain text of one tool/result message (text blocks inside its
|
||||
* `tool-result` content block). */
|
||||
function toolResultText(data: Record<string, unknown>): string {
|
||||
const message = data.message as { content?: unknown } | undefined
|
||||
const content = message?.content
|
||||
if (!Array.isArray(content)) return ''
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; content?: unknown }
|
||||
if (candidate.type !== 'tool-result') continue
|
||||
const inner = candidate.content
|
||||
if (!Array.isArray(inner)) continue
|
||||
for (const item of inner) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const textItem = item as { type?: unknown; text?: unknown }
|
||||
if (textItem.type === 'text' && typeof textItem.text === 'string') {
|
||||
parts.push(textItem.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/** Cap applied to one tool-result's text inside a snapshot (prompt budget). */
|
||||
const SNAPSHOT_RESULT_CAP = 2000
|
||||
/** Cap applied to the whole snapshot (prompt budget). */
|
||||
const SNAPSHOT_TOTAL_CAP = 8000
|
||||
|
||||
/**
|
||||
* Build the side-thread inheritance for one parent log: the full event log
|
||||
* up to the click moment, honestly closed when it ends inside an open turn.
|
||||
*/
|
||||
export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): SidechatInheritance {
|
||||
if (events.length === 0) return { seed: [], snapshot: null }
|
||||
const boundary = lastTurnBoundary(events)
|
||||
if (boundary < 0 || events[boundary]?.type === 'turn/end') {
|
||||
// Ends outside any turn (or has no turns at all): the whole log is a
|
||||
// valid, balanced seed — possibly ending with a pending user message.
|
||||
return { seed: copyEvents(events), snapshot: null }
|
||||
}
|
||||
// Ends inside the open turn starting at `boundary`.
|
||||
if (hasDanglingToolCall(events, boundary)) {
|
||||
// Cannot close honestly: cut before the open turn; the caller attaches
|
||||
// the structured snapshot to the boundary prompt instead.
|
||||
return {
|
||||
seed: copyEvents(events.slice(0, boundary)),
|
||||
snapshot: buildOpenTurnSnapshot(events),
|
||||
}
|
||||
}
|
||||
const seed = copyEvents(events)
|
||||
const last = events[events.length - 1]
|
||||
const turn = numberAt(dataOf(events[boundary]!), 'turn')
|
||||
const now = last?.time ?? 0
|
||||
const openStep = openStepInTurn(events, boundary)
|
||||
if (openStep !== undefined) {
|
||||
seed.push({ type: 'step/end', seq: seed.length, time: now, data: { turn, step: openStep } })
|
||||
}
|
||||
seed.push({
|
||||
type: 'turn/end',
|
||||
seq: seed.length,
|
||||
time: now,
|
||||
data: { turn, reason: { kind: 'interrupted' } },
|
||||
})
|
||||
return { seed, snapshot: null }
|
||||
}
|
||||
|
||||
/** The seed half of {@link buildSidechatInheritance} (test convenience). */
|
||||
export function sidechatSeed(events: readonly SidechatLogEvent[]): SeedEvent[] {
|
||||
return buildSidechatInheritance(events).seed
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured text snapshot of the parent's OPEN turn (from its `turn/start`
|
||||
* to the log tail): the accumulated assistant/reasoning output verbatim
|
||||
* (code blocks ride the raw deltas) and the tool activity — executed tools
|
||||
* with their result text, the still-executing one marked. Returns null when
|
||||
* there is no open turn or nothing to show.
|
||||
*/
|
||||
export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): string | null {
|
||||
const boundary = lastTurnBoundary(events)
|
||||
if (boundary < 0 || events[boundary]?.type !== 'turn/start') return null
|
||||
let text = ''
|
||||
let reasoning = ''
|
||||
const tools: string[] = []
|
||||
const pendingCalls = new Map<string, { name: string; args: string }>()
|
||||
let total = 0
|
||||
for (let index = boundary + 1; index < events.length; index++) {
|
||||
const event = events[index]
|
||||
if (event === undefined) continue
|
||||
const data = dataOf(event)
|
||||
if (event.type === 'step/end') {
|
||||
pendingCalls.clear()
|
||||
continue
|
||||
}
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined
|
||||
if (chunk === null || typeof chunk !== 'object') continue
|
||||
if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text
|
||||
else if (chunk.type === 'reasoning-delta' && typeof chunk.text === 'string') reasoning += chunk.text
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/call') {
|
||||
const callId = data.callId
|
||||
if (typeof callId === 'string') {
|
||||
pendingCalls.set(callId, {
|
||||
name: typeof data.name === 'string' ? data.name : 'tool',
|
||||
args: typeof data.arguments === 'string' ? data.arguments : '',
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
const source = data.message as { source?: { callId?: unknown } } | undefined
|
||||
const callId = typeof source?.source?.callId === 'string' ? source.source.callId : undefined
|
||||
const name = callId !== undefined ? pendingCalls.get(callId)?.name : undefined
|
||||
const args = callId !== undefined ? pendingCalls.get(callId)?.args : undefined
|
||||
if (callId !== undefined) pendingCalls.delete(callId)
|
||||
const result = toolResultText(data).slice(0, SNAPSHOT_RESULT_CAP)
|
||||
const failed = data.error !== undefined
|
||||
const line = [
|
||||
`- \`${name ?? 'tool'}\`${failed ? ' (failed)' : ''}`
|
||||
+ (args !== undefined && args !== '' ? ` — arguments: \`${args}\`` : ''),
|
||||
...(result === '' ? [] : [` Result: ${result}`]),
|
||||
].join('\n')
|
||||
tools.push(line)
|
||||
total += line.length
|
||||
}
|
||||
}
|
||||
for (const [, call] of pendingCalls) {
|
||||
const line = `- \`${call.name}\` (executing) — arguments: \`${call.args}\``
|
||||
tools.push(line)
|
||||
total += line.length
|
||||
}
|
||||
const sections: string[] = []
|
||||
if (text.trim() !== '') sections.push(`Assistant output so far:\n\n${text}`)
|
||||
if (reasoning.trim() !== '') sections.push(`Reasoning so far:\n\n${reasoning}`)
|
||||
if (tools.length > 0) sections.push(`Tool activity:\n${tools.join('\n')}`)
|
||||
if (sections.length === 0) return null
|
||||
const body = sections.join('\n\n')
|
||||
return body.length > SNAPSHOT_TOTAL_CAP
|
||||
? `Parent session in-progress turn (reference only):\n\n${body.slice(0, SNAPSHOT_TOTAL_CAP)}…`
|
||||
: `Parent session in-progress turn (reference only):\n\n${body}`
|
||||
}
|
||||
|
||||
/** One side-thread row in the client's thread list. */
|
||||
export interface SideThreadRow {
|
||||
id: string
|
||||
/** The durable thread title ('Side: …'). */
|
||||
title: string
|
||||
/** Whether the thread's agent is currently running. */
|
||||
running: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the side threads of one parent session from the client session list:
|
||||
* durable `origin: 'subagent'` children of the parent whose pinned title
|
||||
* carries the thread label prefix (our creation path pins it via
|
||||
* sessionTitle.rename; dsh-sidechain threads share the convention, so they
|
||||
* are visible here too).
|
||||
*/
|
||||
export function sideThreadRows(
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||
sessionId: string,
|
||||
): SideThreadRow[] {
|
||||
const rows: SideThreadRow[] = []
|
||||
for (const summary of Object.values(byId)) {
|
||||
if (summary.origin !== 'subagent' || summary.parentId !== sessionId) continue
|
||||
if (!summary.displayTitle.startsWith(SIDE_LABEL_PREFIX)) continue
|
||||
rows.push({ id: summary.id, title: summary.displayTitle, running: summary.running === true })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Truncate + prefix a question into a durable thread label. */
|
||||
export function sideLabel(question: string): string {
|
||||
const flat = question.replace(/\s+/g, ' ').trim()
|
||||
const max = Math.max(1, LABEL_MAX_CHARS - SIDE_LABEL_PREFIX.length)
|
||||
const body = flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
|
||||
return `${SIDE_LABEL_PREFIX}${body}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the thread log already carries the side boundary message — i.e.
|
||||
* the first prompt was delivered. Tolerant to the content shape (block
|
||||
* array or bare string) and to inherited seed messages (only an OWN
|
||||
* boundary message starts with the prefix; seed messages came from the
|
||||
* parent's log, which never contains one).
|
||||
*/
|
||||
export function boundaryDelivered(events: readonly SidechatLogEvent[]): boolean {
|
||||
for (const event of events) {
|
||||
if (event.type !== 'user/message') continue
|
||||
const content = dataOf(event).content
|
||||
const first = Array.isArray(content) ? content[0] : content
|
||||
const text = typeof first === 'string'
|
||||
? first
|
||||
: (typeof first === 'object' && first !== null && 'text' in first
|
||||
? String((first as { text: unknown }).text)
|
||||
: '')
|
||||
if (text.startsWith(SIDE_BOUNDARY_PREFIX)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The info the thread header shows (live runtime state + agent identity). */
|
||||
export interface SidechatThreadInfo {
|
||||
/** A live agent drives the thread right now (false = cold/persisted). */
|
||||
live: boolean
|
||||
/** Live lifecycle state; absent on cold threads. */
|
||||
status?: 'idle' | 'running'
|
||||
/** Provider route of the live agent. */
|
||||
provider?: string
|
||||
/** Model id of the live agent. */
|
||||
model?: string
|
||||
/** The recorded agent preset (live header, or persisted on cold reads). */
|
||||
preset?: string
|
||||
}
|
||||
|
||||
/** The events a thread produced itself: everything after the LAST
|
||||
* `session/end-seed` marker (the fork-seed boundary). */
|
||||
export function threadOwnEvents(entries: readonly SidebarHistoryEntry[]): SidechatLogEvent[] {
|
||||
const events = entries.map(entry => entry.event)
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
if (events[index]?.type === 'session/end-seed') return events.slice(index + 1)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the thread has at least one completed turn — the save-as-new-
|
||||
* session precondition (`session.fork` refuses to fork before the first
|
||||
* `turn/end`).
|
||||
*/
|
||||
export function threadHasCompletedTurn(entries: readonly SidebarHistoryEntry[]): boolean {
|
||||
return threadOwnEvents(entries).some(event => event.type === 'turn/end')
|
||||
}
|
||||
|
||||
/** Whether the thread ends with a user message that no completed turn
|
||||
* answered yet — such a pending follow-up is NOT carried into the saved
|
||||
* session (the fork cut is the last `turn/end`). */
|
||||
export function threadTrailingPending(entries: readonly SidebarHistoryEntry[]): boolean {
|
||||
const own = threadOwnEvents(entries)
|
||||
let lastUser = -1
|
||||
let lastTurnEnd = -1
|
||||
own.forEach((event, index) => {
|
||||
if (event.type === 'user/message') lastUser = index
|
||||
if (event.type === 'turn/end') lastTurnEnd = index
|
||||
})
|
||||
return lastUser > lastTurnEnd
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent preset a session actually runs: newest `agent-preset/selected`
|
||||
* event wins, else the creation header (mirror of the dsh-agent-presets
|
||||
* resolveSessionPreset helper — replicated here to avoid a host dependency
|
||||
* on that package).
|
||||
*/
|
||||
export function resolvePresetId(
|
||||
header: { agentPreset?: string },
|
||||
events: readonly SidechatLogEvent[],
|
||||
): string | undefined {
|
||||
for (let index = events.length - 1; index >= 0; index--) {
|
||||
const event = events[index]
|
||||
if (event?.type !== 'agent-preset/selected') continue
|
||||
const preset = dataOf(event).agentPreset
|
||||
if (typeof preset === 'string') return preset
|
||||
}
|
||||
return header.agentPreset
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Side Chat routes of the /sidebar JSON API ('sidechat.start' /
|
||||
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose').
|
||||
*
|
||||
* A side thread is a child session the plugin creates ITSELF with a custom
|
||||
* seed — the parent's full event log up to the click moment, honestly closed
|
||||
* at an in-progress turn (see sidechat-core.ts). The child is marked
|
||||
* `origin: 'subagent'` so the main session list hides it, and EVERY
|
||||
* operation goes through these routes because the generic session RPCs are
|
||||
* fenced away from subagent-origin identities (the api-remotes
|
||||
* agent-lookup ownership fence). No DSH source is touched:
|
||||
*
|
||||
* - creation uses the public AgentRegistry.create seam (the same one
|
||||
* api-proxy's session.fork and the subagent fork provider use), with the
|
||||
* parent's preset composition and provider/model selection so the child's
|
||||
* first request shares the parent's token prefix (provider-side prefix
|
||||
* cache reuse);
|
||||
* - the first prompt (boundary + question) and every follow-up are admitted
|
||||
* with the stock `agent.followup`;
|
||||
* - a cold thread (DSH restart, or a closed thread) is resumed with
|
||||
* AgentRegistry.resume, composing the preset the child recorded.
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createUserMessage, type ContentBlock, type UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentSetup, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import type { Context as CordisContext } from '@deepseek-ai/cordis'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
Context,
|
||||
SidebarAgentPresetsService,
|
||||
SidebarSessionPersistenceService,
|
||||
SidebarSessionTitleService,
|
||||
} from './context-types.ts'
|
||||
import {
|
||||
boundaryDelivered,
|
||||
buildSidechatInheritance,
|
||||
resolvePresetId,
|
||||
SIDE_BOUNDARY_PROMPT,
|
||||
SIDE_NEW_THREAD_TITLE,
|
||||
sideLabel,
|
||||
type SeedEvent,
|
||||
type SidechatLogEvent,
|
||||
type SidechatThreadInfo,
|
||||
} from './sidechat-core.ts'
|
||||
import { requireString, SidebarError } from './wire.ts'
|
||||
|
||||
/** The five Side Chat routes of the sidebar API (wire method names). */
|
||||
export interface SidechatRoutes {
|
||||
/** Create a side thread child seeded with the parent's log up to now.
|
||||
* `question` is optional: empty creates an EMPTY thread (Codex-style
|
||||
* immediate create); the first `sidechat.prompt` then carries the
|
||||
* boundary + snapshot and earns the thread its real label. */
|
||||
'sidechat.start'(payload: unknown): Promise<{ childId: string }>
|
||||
/** Deliver one follow-up message to a thread (live, or cold-resumed). */
|
||||
'sidechat.prompt'(payload: unknown): Promise<{ accepted: true }>
|
||||
/** Abort the thread's running turn (queued work is preserved). */
|
||||
'sidechat.cancel'(payload: unknown): Promise<{ accepted: true }>
|
||||
/** Release the thread's live agent (session and history stay persisted). */
|
||||
'sidechat.dispose'(payload: unknown): Promise<{ accepted: true }>
|
||||
/** Live state + agent identity for the thread header. */
|
||||
'sidechat.info'(payload: unknown): Promise<SidechatThreadInfo>
|
||||
}
|
||||
|
||||
/** Timeout guarding the create call (the registry detaches it before the
|
||||
* handle becomes visible, so the child is never cancelled by it). */
|
||||
const CREATE_TIMEOUT_MS = 15_000
|
||||
|
||||
/** Per-activation disposers of created thread agents (the dispose route
|
||||
* releases them; the session and its history always stay persisted). */
|
||||
const threadDisposers = new Map<string, () => Promise<void>>()
|
||||
|
||||
/** The in-progress-turn snapshot captured at creation of an EMPTY thread,
|
||||
* waiting to ride the first prompt (lost on a host restart — the boundary
|
||||
* prompt is then delivered alone, a logged degradation). */
|
||||
const pendingSnapshots = new Map<string, string>()
|
||||
|
||||
/** Resolve the parent's preset and build the child's composition setup
|
||||
* (mirror of api-proxy's composeAgent minus the model-selection install —
|
||||
* the child carries the parent's provider/model in agentOptions). */
|
||||
async function composeChildSetup(
|
||||
ctx: Context,
|
||||
presetId: string | undefined,
|
||||
): Promise<{ agentPreset?: string; setup: AgentSetup }> {
|
||||
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
|
||||
if (presets === undefined) {
|
||||
return { setup: () => Promise.resolve() }
|
||||
}
|
||||
const resolved = await presets.resolve(presetId)
|
||||
return {
|
||||
agentPreset: resolved.id,
|
||||
setup: async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) },
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the cold-resume setup from the thread's PERSISTED record (the
|
||||
* recorded preset wins, newest selection event first). */
|
||||
async function composePersistedSetup(
|
||||
ctx: Context,
|
||||
childId: string,
|
||||
): Promise<AgentSetup> {
|
||||
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
|
||||
if (persistence === undefined) {
|
||||
return () => Promise.resolve()
|
||||
}
|
||||
const inspected = await persistence.inspect(childId)
|
||||
const presetId = resolvePresetId(inspected.meta, inspected.events)
|
||||
const presets = ctx.get('agentPresets') as SidebarAgentPresetsService | undefined
|
||||
if (presets === undefined || presetId === undefined) {
|
||||
return () => Promise.resolve()
|
||||
}
|
||||
const resolved = await presets.resolve(presetId)
|
||||
return async (agentCtx: CordisContext) => { await presets.mount(agentCtx, resolved.id) }
|
||||
}
|
||||
|
||||
/** One text-block prompt (the thread boundary + question, or a follow-up). */
|
||||
function textPrompt(text: string): ContentBlock[] {
|
||||
return [{ type: 'text', text }]
|
||||
}
|
||||
|
||||
/** Admit one user message to a live agent through the stock followup path. */
|
||||
function admitFollowup(agent: Agent, blocks: ContentBlock[]): void {
|
||||
const message: UserMessage = createUserMessage({ content: blocks, source: { kind: 'user' } })
|
||||
agent.followup(message)
|
||||
}
|
||||
|
||||
/** The live thread agent, or undefined (cold — the caller resumes). */
|
||||
function liveThreadAgent(ctx: Context, childId: string): Agent | undefined {
|
||||
const agents = ctx.get('agents') as { get(id: string): Agent | undefined } | undefined
|
||||
return agents?.get(childId)
|
||||
}
|
||||
|
||||
/** Build the Side Chat routes (all optional services degrade to a wire
|
||||
* error the tab surfaces inline). The record keys are the FULL wire method
|
||||
* names the /sidebar/api dispatcher looks up (`api[method]`). */
|
||||
export function buildSidechatApi(ctx: Context): SidechatRoutes {
|
||||
return {
|
||||
'sidechat.start': async (payload: unknown) => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const rawQuestion = (payload as { question?: unknown }).question
|
||||
const question = typeof rawQuestion === 'string' ? rawQuestion.trim() : ''
|
||||
const parent = liveThreadAgent(ctx, sessionId)
|
||||
if (parent === undefined) {
|
||||
throw new SidebarError('sidechat-error', `parent session "${sessionId}" is not running`, 409)
|
||||
}
|
||||
const parentSession = parent.session
|
||||
const inheritance = buildSidechatInheritance(
|
||||
parentSession.events as unknown as readonly SidechatLogEvent[],
|
||||
)
|
||||
const { agentPreset, setup } = await composeChildSetup(
|
||||
ctx,
|
||||
resolvePresetId(parentSession.header, parentSession.events),
|
||||
)
|
||||
const childId = `session-${randomUUID()}` as SessionId
|
||||
const label = question === '' ? SIDE_NEW_THREAD_TITLE : sideLabel(question)
|
||||
// Honest catalog citizenship: the durable descriptor keeps the thread
|
||||
// a HEALTHY row in the host's subagents.list — a cold child without
|
||||
// one is deterministically rendered as a 'corrupt' diagnostic. The
|
||||
// SubagentView filters the 'Side: ' label out, so the topology UI
|
||||
// stays noise-free; the row only serves enumeration correctness.
|
||||
const descriptor = snapshotSubagentDescriptor({
|
||||
mode: 'continuable',
|
||||
provider: 'sidechat',
|
||||
label,
|
||||
...(parent.options.provider === undefined ? {} : { agentProvider: parent.options.provider }),
|
||||
...(parent.options.model === undefined ? {} : { agentModel: parent.options.model }),
|
||||
})
|
||||
const descriptorEvent: SeedEvent = {
|
||||
type: 'subagent/descriptor',
|
||||
seq: inheritance.seed.length,
|
||||
time: Date.now(),
|
||||
data: descriptor as unknown as Record<string, unknown>,
|
||||
}
|
||||
const seed = [...inheritance.seed, descriptorEvent]
|
||||
const options: CreateAgentOptions = {
|
||||
sessionId: childId,
|
||||
meta: {
|
||||
...(parentSession.header.cwd === undefined ? {} : { cwd: parentSession.header.cwd }),
|
||||
parentSession: parentSession.id,
|
||||
seedLength: seed.length,
|
||||
origin: 'subagent',
|
||||
delegationDepth: (parentSession.header.delegationDepth ?? 0) + 1,
|
||||
...(agentPreset === undefined ? {} : { agentPreset }),
|
||||
},
|
||||
seed: seed as unknown as readonly SessionEvent[],
|
||||
agentOptions: { ...parent.options },
|
||||
setup,
|
||||
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),
|
||||
}
|
||||
const agents = ctx.get('agents') as { create(options: CreateAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
|
||||
if (agents?.create === undefined) {
|
||||
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
|
||||
}
|
||||
let handle: { agent: Agent; dispose(): Promise<void> }
|
||||
try {
|
||||
handle = await agents.create(options)
|
||||
} catch (error) {
|
||||
throw new SidebarError('sidechat-error', `thread creation failed: ${error instanceof Error ? error.message : String(error)}`, 500)
|
||||
}
|
||||
threadDisposers.set(childId, () => handle.dispose())
|
||||
// Pin the thread label so the client can identify its threads by
|
||||
// title prefix (the rename is a live-session op, no RPC fence).
|
||||
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
|
||||
const pinTitle = (label: string): void => {
|
||||
if (titles === undefined) return
|
||||
try {
|
||||
titles.rename(handle.agent.session, label)
|
||||
} catch {
|
||||
// Keep the auto-generated title; the thread stays usable.
|
||||
}
|
||||
}
|
||||
if (question === '') {
|
||||
// Codex-style immediate create: no prompt yet — the composer owns
|
||||
// the first message; the snapshot waits for it.
|
||||
if (inheritance.snapshot !== null) pendingSnapshots.set(childId, inheritance.snapshot)
|
||||
pinTitle(SIDE_NEW_THREAD_TITLE)
|
||||
} else {
|
||||
const promptParts = [SIDE_BOUNDARY_PROMPT]
|
||||
if (inheritance.snapshot !== null) promptParts.push(inheritance.snapshot)
|
||||
promptParts.push(question)
|
||||
admitFollowup(handle.agent, textPrompt(promptParts.join('\n\n')))
|
||||
pinTitle(sideLabel(question))
|
||||
}
|
||||
return { childId }
|
||||
},
|
||||
|
||||
'sidechat.prompt': async (payload: unknown) => {
|
||||
const childId = requireString(payload, 'childId')
|
||||
const text = requireString(payload, 'text').trim()
|
||||
if (text === '') {
|
||||
throw new SidebarError('bad-request', 'text is required')
|
||||
}
|
||||
let agent = liveThreadAgent(ctx, childId)
|
||||
if (agent === undefined) {
|
||||
// Cold thread: resume the persisted session under its recorded
|
||||
// composition, then deliver the follow-up.
|
||||
const agents = ctx.get('agents') as { resume(options: ResumeAgentOptions): Promise<{ agent: Agent; dispose(): Promise<void> }> } | undefined
|
||||
if (agents?.resume === undefined) {
|
||||
throw new SidebarError('sidechat-error', 'the agents service is unavailable', 503)
|
||||
}
|
||||
const setup = await composePersistedSetup(ctx, childId)
|
||||
try {
|
||||
const handle = await agents.resume({ resumeSessionId: childId as SessionId, setup })
|
||||
threadDisposers.set(childId, () => handle.dispose())
|
||||
agent = handle.agent
|
||||
} catch (error) {
|
||||
throw new SidebarError('sidechat-error', `thread resume failed: ${error instanceof Error ? error.message : String(error)}`, 500)
|
||||
}
|
||||
}
|
||||
if (boundaryDelivered(agent.session.events as unknown as readonly SidechatLogEvent[])) {
|
||||
admitFollowup(agent, textPrompt(text))
|
||||
} else {
|
||||
// First message of an immediately-created thread: it carries the
|
||||
// boundary (+ the snapshot parked at creation, if still around)
|
||||
// and earns the thread its real label.
|
||||
const parts = [SIDE_BOUNDARY_PROMPT]
|
||||
const snapshot = pendingSnapshots.get(childId)
|
||||
pendingSnapshots.delete(childId)
|
||||
if (snapshot !== undefined) parts.push(snapshot)
|
||||
parts.push(text)
|
||||
admitFollowup(agent, textPrompt(parts.join('\n\n')))
|
||||
const titles = ctx.get('sessionTitle') as SidebarSessionTitleService | undefined
|
||||
if (titles !== undefined) {
|
||||
try {
|
||||
titles.rename(agent.session, sideLabel(text))
|
||||
} catch {
|
||||
// Keep the placeholder title; the thread stays usable.
|
||||
}
|
||||
}
|
||||
}
|
||||
return { accepted: true as const }
|
||||
},
|
||||
|
||||
'sidechat.cancel': async (payload: unknown) => {
|
||||
const childId = requireString(payload, 'childId')
|
||||
const agent = liveThreadAgent(ctx, childId)
|
||||
if (agent !== undefined) {
|
||||
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
||||
}
|
||||
return { accepted: true as const }
|
||||
},
|
||||
|
||||
'sidechat.dispose': async (payload: unknown) => {
|
||||
const childId = requireString(payload, 'childId')
|
||||
pendingSnapshots.delete(childId)
|
||||
const dispose = threadDisposers.get(childId)
|
||||
if (dispose !== undefined) {
|
||||
threadDisposers.delete(childId)
|
||||
try {
|
||||
await dispose()
|
||||
} catch {
|
||||
// The agent may already be gone (restart); the session persists.
|
||||
}
|
||||
}
|
||||
return { accepted: true as const }
|
||||
},
|
||||
|
||||
'sidechat.info': async (payload: unknown) => {
|
||||
const childId = requireString(payload, 'childId')
|
||||
const agent = liveThreadAgent(ctx, childId)
|
||||
if (agent !== undefined) {
|
||||
const preset = agent.session.header.agentPreset
|
||||
return {
|
||||
live: true,
|
||||
status: agent.status,
|
||||
...(agent.options.provider === undefined ? {} : { provider: agent.options.provider }),
|
||||
...(agent.options.model === undefined ? {} : { model: agent.options.model }),
|
||||
...(preset === undefined ? {} : { preset }),
|
||||
}
|
||||
}
|
||||
// Cold thread: only the persisted preset is worth reading back.
|
||||
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
|
||||
if (persistence !== undefined) {
|
||||
try {
|
||||
const inspected = await persistence.inspect(childId)
|
||||
const preset = resolvePresetId(inspected.meta, inspected.events)
|
||||
return { live: false, ...(preset === undefined ? {} : { preset }) }
|
||||
} catch {
|
||||
// Unknown/gone session: report a bare cold info.
|
||||
}
|
||||
}
|
||||
return { live: false }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Pure derivation of the compact LIVE line shown on a running subagent card:
|
||||
* the last text output and the last tool call of the child's session events.
|
||||
* The host batch route feeds raw session events into this parser; the client
|
||||
* only receives the already-folded `LastActivity` map. Renders nothing
|
||||
* itself — the SubagentView component turns this into the card's status
|
||||
* lines. Kept framework-free so the parser is unit-testable in the node
|
||||
* environment.
|
||||
*/
|
||||
import type { SidebarSessionEvent } from './context-types.ts'
|
||||
|
||||
/**
|
||||
* Extract the concatenated plain text of a content-block list (the durable
|
||||
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
|
||||
* `text`; anything else — tool_use, image, … — contributes nothing).
|
||||
* @param content - the raw `content` field of a message event.
|
||||
* @returns the joined text, or undefined when the message carries no text.
|
||||
*/
|
||||
export function contentText(content: unknown): string | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
||||
parts.push(candidate.text)
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined
|
||||
}
|
||||
|
||||
/** The live status of one subagent card (both fields optional). */
|
||||
export interface LastActivity {
|
||||
/** The latest assembled assistant text output in the tail. */
|
||||
text?: string
|
||||
/** The latest tool call in the tail. */
|
||||
tool?: { name: string; args: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a session event log into the last text output + last tool call (each
|
||||
* is the LAST occurrence in event order). Lifecycle events and raw
|
||||
* `assistant/chunk` rows are ignored — the card shows what the subagent is
|
||||
* doing right now, not its plumbing. The scan runs BACKWARD from the newest
|
||||
* event and stops once both fields are found, so a long history costs only
|
||||
* the recent tail in the common case.
|
||||
* @param events - the session's append-only event log (oldest → newest).
|
||||
* @param maxMessages - optional message-boundary window: only the tail's
|
||||
* last `maxMessages` surface messages (`user/message`, `assistant/message`)
|
||||
* and the events between them are considered, mirroring the old
|
||||
* `subagents.history({ maxMessages })` window. Stale activity older than
|
||||
* the window is never surfaced, and a long log is never scanned in full.
|
||||
* @returns the last text and/or tool call; an empty object when the log has neither.
|
||||
*/
|
||||
export function lastActivity(
|
||||
events: readonly SidebarSessionEvent[],
|
||||
maxMessages = Infinity,
|
||||
): LastActivity {
|
||||
let text: string | undefined
|
||||
let tool: { name: string; args: string } | undefined
|
||||
let messagesSeen = 0
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
if (text !== undefined && tool !== undefined) break
|
||||
const event = events[index]
|
||||
if (event === undefined) continue
|
||||
const { type, data } = event
|
||||
if (type === 'user/message' || type === 'assistant/message') {
|
||||
messagesSeen += 1
|
||||
if (messagesSeen > maxMessages) break
|
||||
} else if (messagesSeen >= maxMessages) {
|
||||
// The window already holds its `maxMessages` messages: anything older
|
||||
// than the oldest in-window message sits outside the recent window.
|
||||
continue
|
||||
}
|
||||
if (text === undefined && type === 'assistant/message') {
|
||||
const message = data.message as { content?: unknown } | undefined
|
||||
const extracted = contentText(message?.content)
|
||||
if (extracted !== undefined) text = extracted
|
||||
} else if (tool === undefined && type === 'tool/call') {
|
||||
tool = {
|
||||
name: typeof data.name === 'string' ? data.name : 'tool',
|
||||
args: typeof data.arguments === 'string' ? data.arguments : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text === undefined && tool === undefined) return {}
|
||||
return {
|
||||
...(text === undefined ? {} : { text }),
|
||||
...(tool === undefined ? {} : { tool }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The live-preview route of the Subagent page ('subagents.live'): one
|
||||
* request per refresh instead of N per-child `subagents.history` calls.
|
||||
*
|
||||
* The route takes the already-resolved topology root (`rootSessionId`),
|
||||
* enumerates the whole descendant tree ONCE through the host subagent
|
||||
* runtime (`ctx.get('subagents')` / `listDescendants`), keeps only rows the
|
||||
* catalog reports running (`activity: 'running'` — the same gate the client
|
||||
* renders cards on), and folds the newest text/tool activity from each
|
||||
* child's attached session event log. It never touches DSH source and never
|
||||
* reads the model's `job_output` cursor.
|
||||
*
|
||||
* Degradation contract:
|
||||
* - `ctx.get('subagents')` missing or `listDescendants` failure → 503 (the
|
||||
* Subagent page has no topology to show in such deployments anyway).
|
||||
* - One child's events missing/corrupt → that child is skipped, the rest of
|
||||
* the batch still returns.
|
||||
*/
|
||||
import type { Context, SidebarSubagentsService } from './context-types.ts'
|
||||
import { SIDE_LABEL_PREFIX } from './sidechat-core.ts'
|
||||
import { lastActivity, type LastActivity } from './subagent-activity.ts'
|
||||
import { requireString, SidebarError } from './wire.ts'
|
||||
|
||||
/** The live-preview routes of the /sidebar JSON API. */
|
||||
export interface SidebarSubagentLiveRoutes {
|
||||
/**
|
||||
* Fold one tree's running subagent histories into a compact live map.
|
||||
* @param payload - `{ rootSessionId }`.
|
||||
* @returns `{ live: Record<childSessionId, LastActivity> }`; children with
|
||||
* no text/tool yet are omitted.
|
||||
*/
|
||||
live(payload: unknown): Promise<{ live: Record<string, LastActivity> }>
|
||||
}
|
||||
|
||||
/**
|
||||
* The recent-message window of the live preview: only the last 12 surface
|
||||
* messages of a child's log are folded, matching the old per-card
|
||||
* `subagents.history({ maxMessages: 12 })` window. Keeps stale tool calls
|
||||
* out of the preview and bounds the backward scan per child.
|
||||
*/
|
||||
export const LIVE_WINDOW_MESSAGES = 12
|
||||
|
||||
/**
|
||||
* Build the live-preview routes bound to the plugin context.
|
||||
* @param ctx - host plugin context.
|
||||
*/
|
||||
export function buildSubagentLiveApi(ctx: Context): SidebarSubagentLiveRoutes {
|
||||
return {
|
||||
async live(payload) {
|
||||
const rootSessionId = requireString(payload, 'rootSessionId')
|
||||
const subagents = ctx.get('subagents') as SidebarSubagentsService | undefined
|
||||
if (subagents === undefined || typeof subagents.listDescendants !== 'function') {
|
||||
throw new SidebarError(
|
||||
'subagents-unavailable',
|
||||
'the subagent service is not mounted in this deployment',
|
||||
503,
|
||||
)
|
||||
}
|
||||
let descendants
|
||||
try {
|
||||
descendants = await subagents.listDescendants(rootSessionId)
|
||||
} catch (error) {
|
||||
throw new SidebarError(
|
||||
'subagents-unavailable',
|
||||
`subagent catalog read failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
503,
|
||||
)
|
||||
}
|
||||
|
||||
const live: Record<string, LastActivity> = {}
|
||||
for (const entry of descendants) {
|
||||
// Same gate the client renders cards on: only catalog-running
|
||||
// children get live lines (spec: "仅对 running 且非 Side Chat").
|
||||
if (entry.kind !== 'child' || entry.activity !== 'running') continue
|
||||
// Side Chat threads ride the subagent origin but are sidebar tabs,
|
||||
// never topology — keep them out of the live map too.
|
||||
if (entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false) continue
|
||||
try {
|
||||
const activity = lastActivity(
|
||||
ctx.sessions.get(entry.id)?.events ?? [],
|
||||
LIVE_WINDOW_MESSAGES,
|
||||
)
|
||||
if (activity.text !== undefined || activity.tool !== undefined) {
|
||||
live[entry.id] = activity
|
||||
}
|
||||
} catch {
|
||||
// One child's event log is not readable: skip only that child.
|
||||
}
|
||||
}
|
||||
return { live }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,14 @@ export type SidebarErrorCode =
|
||||
| 'not-found'
|
||||
| 'forbidden'
|
||||
| 'method-error'
|
||||
| 'too-large'
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'pty-deps-missing'
|
||||
| 'job-error'
|
||||
| 'sidechat-error'
|
||||
| 'subagents-unavailable'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
| 'internal'
|
||||
|
||||
+32
-3
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Built-in registration tests: the plugin registers 6 tabs and 6 file
|
||||
* Built-in registration tests: the plugin registers 7 tabs and 6 file
|
||||
* viewers through the same service external plugins use (dogfooding);
|
||||
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
|
||||
* and the html sandbox settings pin the registry's behavior. (Office
|
||||
@@ -16,6 +16,7 @@ import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { allLeaves } from '../src/client/state.ts'
|
||||
import { registerBuiltins } from '../src/client/builtins/index.ts'
|
||||
import type { BuiltinTabOptions } from '../src/client/builtins/tabs.tsx'
|
||||
import { parkSidechatReopen } from '../src/client/SideChatView.tsx'
|
||||
import { t } from '../src/client/locales.ts'
|
||||
|
||||
function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
|
||||
@@ -26,10 +27,10 @@ function setup(options: BuiltinTabOptions = {}): { service: ReturnType<typeof cr
|
||||
}
|
||||
|
||||
describe('built-in tab registrations', () => {
|
||||
it('registers the 6 built-in tabs', () => {
|
||||
it('registers the 7 built-in tabs', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
||||
['browser', 'diff', 'editor', 'git', 'subagent', 'terminal'],
|
||||
['browser', 'diff', 'editor', 'git', 'sidechat', 'subagent', 'terminal'],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -48,6 +49,34 @@ describe('built-in tab registrations', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('the side chat tab sits between tasks and terminal in the + menu', () => {
|
||||
const { service } = setup()
|
||||
const sidechat = service.getTab('sidechat')
|
||||
expect(sidechat?.order).toBe(35)
|
||||
expect(sidechat?.hidden).not.toBe(true)
|
||||
})
|
||||
|
||||
it('side chat mints one tab per thread (Codex-style multi-instance)', () => {
|
||||
const { service } = setup()
|
||||
const sidechat = service.getTab('sidechat')
|
||||
expect(sidechat?.single).not.toBe(true)
|
||||
// A plain open mints a fresh autoCreate tab (the view creates the
|
||||
// thread on mount); two opens never collide. (createTab ignores the
|
||||
// state argument for sidechat — the cast stands in for it.)
|
||||
const first = sidechat?.createTab?.(undefined as never)
|
||||
const second = sidechat?.createTab?.(undefined as never)
|
||||
expect(first?.tab.meta).toEqual({ autoCreate: true })
|
||||
expect(first?.tab.id).not.toBe(second?.tab.id)
|
||||
// A parked reopen target mints the deterministic reattach tab, and
|
||||
// dedupeKey focuses an already-open thread instead of duplicating it.
|
||||
parkSidechatReopen('session-t1')
|
||||
const reopen = sidechat?.createTab?.(undefined as never)
|
||||
expect(reopen?.tab.id).toBe('sidechat:session-t1')
|
||||
expect(reopen?.tab.meta).toEqual({ threadId: 'session-t1' })
|
||||
expect(sidechat?.dedupeKey?.(reopen!.tab)).toBe('session-t1')
|
||||
expect(sidechat?.dedupeKey?.(first!.tab)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the subagent tab declares its auto-open related settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Desktop-shell detection tests: URL stamps (dsh-desktop-mode/platform)
|
||||
* from the official Electron shell, the preload marker
|
||||
* (__DSH_DESKTOP_FILE_PATH__), and the win32 advanced overlay adaptation.
|
||||
* (__DSH_DESKTOP_FILE_PATH__), and the `dsh-desktop-titlebar-inset`
|
||||
* contract parameter. Geometry ADAPTATION is deliberately NOT here — the
|
||||
* strip resolution chain lives in titlebar-strip.ts (standard WCO first,
|
||||
* see wco.spec.ts), keeping this module a pure environment reporter.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import './browser-globals.ts'
|
||||
@@ -18,25 +21,25 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('parseDesktopEnv', () => {
|
||||
it('reports a plain browser page as non-desktop', () => {
|
||||
expect(parseDesktopEnv()).toEqual({ desktop: false, mode: null, platform: null, win32OverlayTop: 0 })
|
||||
it('reports a plain browser page as non-desktop with no inset', () => {
|
||||
expect(parseDesktopEnv()).toEqual({ desktop: false, mode: null, platform: null, titlebarInset: 0 })
|
||||
})
|
||||
|
||||
it('parses win32 advanced stamps with the 32px window-control overlay', () => {
|
||||
it('parses win32 advanced stamps (no overlay guess — geometry comes from WCO/preset)', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32')
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBe('advanced')
|
||||
expect(env.platform).toBe('win32')
|
||||
expect(env.win32OverlayTop).toBe(32)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('parses darwin advanced stamps without an overlay (traffic lights are top-left)', () => {
|
||||
it('parses darwin advanced stamps', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=darwin')
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.platform).toBe('darwin')
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('parses compatibility mode as desktop with the native frame (no adaptation)', () => {
|
||||
@@ -44,7 +47,7 @@ describe('parseDesktopEnv', () => {
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBe('compatibility')
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('detects the desktop preload marker even without URL stamps', () => {
|
||||
@@ -52,7 +55,7 @@ describe('parseDesktopEnv', () => {
|
||||
const env = parseDesktopEnv()
|
||||
expect(env.desktop).toBe(true)
|
||||
expect(env.mode).toBeNull()
|
||||
expect(env.win32OverlayTop).toBe(0)
|
||||
expect(env.titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores unknown mode values (exotic shells keep plain-browser semantics)', () => {
|
||||
@@ -61,6 +64,20 @@ describe('parseDesktopEnv', () => {
|
||||
expect(parseDesktopEnv().desktop).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the documented titlebar-inset contract parameter (clamped 0–120)', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32&dsh-desktop-titlebar-inset=36')
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(36)
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-titlebar-inset=200')
|
||||
resetDesktopEnvForTests()
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(120)
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-titlebar-inset=-5')
|
||||
resetDesktopEnvForTests()
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(0)
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-titlebar-inset=abc')
|
||||
resetDesktopEnvForTests()
|
||||
expect(parseDesktopEnv().titlebarInset).toBe(0)
|
||||
})
|
||||
|
||||
it('memoizes across calls until the test hook resets', () => {
|
||||
setSearch('?dsh-desktop-mode=advanced&dsh-desktop-platform=win32')
|
||||
const first = parseDesktopEnv()
|
||||
|
||||
+317
-19
@@ -29,7 +29,7 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test, expect, request, type APIRequestContext } from '@playwright/test'
|
||||
import { test, expect, request, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.DSH_E2E_URL
|
||||
if (!BASE_URL) {
|
||||
@@ -304,24 +304,9 @@ test('a very fast width drag still commits the dragged position (no rollback on
|
||||
})
|
||||
const widthBefore = await readWidth()
|
||||
|
||||
const stripBox = await page.evaluate(() => {
|
||||
const host = document.querySelector('[data-dsh-better-sidebar]')
|
||||
if (host === null) return null
|
||||
const boxes = [...host.querySelectorAll('*')]
|
||||
.filter(el => getComputedStyle(el).cursor === 'col-resize')
|
||||
.map(el => el.getBoundingClientRect())
|
||||
.filter(r => r.width > 0 && r.height > 0)
|
||||
.sort((a, b) => a.x - b.x)
|
||||
const r = boxes[0]
|
||||
if (r === undefined) return null
|
||||
const varWidth = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||||
return {
|
||||
x: r.x, y: r.y, width: r.width, height: r.height,
|
||||
varWidth: Number.isNaN(varWidth) ? 0 : varWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
}
|
||||
})
|
||||
expect(stripBox, 'the width drag strip must be present').not.toBeNull()
|
||||
// The panel slides in from the right on expand: read the strip box only
|
||||
// AFTER it settles at the pushed layout edge (a mid-animation read sits
|
||||
// off-viewport and the drag never reaches the strip).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await page.evaluate(() => {
|
||||
@@ -338,6 +323,19 @@ test('a very fast width drag still commits the dragged position (no rollback on
|
||||
return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8
|
||||
}, { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
const stripBox = await page.evaluate(() => {
|
||||
const host = document.querySelector('[data-dsh-better-sidebar]')
|
||||
if (host === null) return null
|
||||
const boxes = [...host.querySelectorAll('*')]
|
||||
.filter(el => getComputedStyle(el).cursor === 'col-resize')
|
||||
.map(el => el.getBoundingClientRect())
|
||||
.filter(r => r.width > 0 && r.height > 0)
|
||||
.sort((a, b) => a.x - b.x)
|
||||
const r = boxes[0]
|
||||
if (r === undefined) return null
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height }
|
||||
})
|
||||
expect(stripBox, 'the width drag strip must be present').not.toBeNull()
|
||||
|
||||
const startX = stripBox!.x + stripBox!.width / 2
|
||||
const startY = stripBox!.y + Math.min(120, stripBox!.height / 2 + 60)
|
||||
@@ -359,3 +357,303 @@ test('a very fast width drag still commits the dragged position (no rollback on
|
||||
await page.waitForTimeout(600)
|
||||
expect(await readWidth(), 'the fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100)
|
||||
})
|
||||
|
||||
/* ── issue #247: interrupted fast drags must not roll back ───────────── */
|
||||
|
||||
/** Dismiss whatever onboarding takeover is present (same dance as above). */
|
||||
async function dismissOnboarding(page: Page): Promise<void> {
|
||||
try {
|
||||
await expect
|
||||
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later)$/ }).count(), { timeout: 60_000 })
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
console.warn('[e2e-drag] no onboarding takeover appeared; proceeding')
|
||||
}
|
||||
for (let round = 0; round < 8; round++) {
|
||||
let dismissed = false
|
||||
for (const name of ['Continue', 'Configure later']) {
|
||||
const button = page.getByRole('button', { name, exact: true }).first()
|
||||
if ((await button.count()) === 0) continue
|
||||
try {
|
||||
await button.click({ timeout: 4_000 })
|
||||
dismissed = true
|
||||
await page.waitForTimeout(1_000)
|
||||
} catch {
|
||||
// Masked by a takeover stacked above; retry in the next round.
|
||||
}
|
||||
}
|
||||
if (!dismissed) break
|
||||
}
|
||||
}
|
||||
|
||||
/** Open the sidebar panel and wait for the layout push to go live. */
|
||||
async function expandSidebar(page: Page, sidebar: Locator): Promise<void> {
|
||||
const expandButton = sidebar.getByRole('button', { name: 'Expand sidebar' })
|
||||
await expect(expandButton, 'the collapsed toggle cluster must offer the expand button').toHaveCount(1)
|
||||
await expandButton.click()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||||
return value !== '' && value !== '0px'
|
||||
}, { timeout: 90_000 })
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
/** Locate the width drag strip (the leftmost col-resize element inside the
|
||||
* sidebar host), wait for it to settle at the pushed layout edge (the
|
||||
* panel slides in from the right on expand — a strip read during the
|
||||
* animation sits off-viewport and no pointer event ever reaches it), then
|
||||
* read its FINAL box and return a drag start point on it. */
|
||||
async function settleWidthStrip(page: Page): Promise<{ startX: number; startY: number }> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await page.evaluate(() => {
|
||||
const host = document.querySelector('[data-dsh-better-sidebar]')
|
||||
const el = [...host!.querySelectorAll('*')]
|
||||
.filter(e => getComputedStyle(e).cursor === 'col-resize')
|
||||
.map(e => e.getBoundingClientRect())
|
||||
.filter(r => r.width > 0 && r.height > 0)
|
||||
.sort((a, b) => a.x - b.x)[0]
|
||||
return el === undefined ? null : { x: el.x + el.width, innerWidth: window.innerWidth }
|
||||
})
|
||||
if (box === null) return false
|
||||
const varWidth = await page.evaluate(() => parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width')) || 0)
|
||||
return Math.abs(box.x - (box.innerWidth - varWidth)) <= 8
|
||||
}, { timeout: 30_000 })
|
||||
.toBe(true)
|
||||
const stripBox = await page.evaluate(() => {
|
||||
const host = document.querySelector('[data-dsh-better-sidebar]')
|
||||
if (host === null) return null
|
||||
const boxes = [...host.querySelectorAll('*')]
|
||||
.filter(el => getComputedStyle(el).cursor === 'col-resize')
|
||||
.map(el => el.getBoundingClientRect())
|
||||
.filter(r => r.width > 0 && r.height > 0)
|
||||
.sort((a, b) => a.x - b.x)
|
||||
const r = boxes[0]
|
||||
if (r === undefined) return null
|
||||
return { x: r.x, y: r.y, width: r.width, height: r.height }
|
||||
})
|
||||
expect(stripBox, 'the width drag strip must be present').not.toBeNull()
|
||||
return {
|
||||
startX: stripBox!.x + stripBox!.width / 2,
|
||||
startY: stripBox!.y + Math.min(120, stripBox!.height / 2 + 60),
|
||||
}
|
||||
}
|
||||
|
||||
/** Dispatch a synthetic pointer event (pointercancel / lostpointercapture)
|
||||
* onto the width strip. Synthetic events bypass the real pointer-capture
|
||||
* pipeline, which is exactly the point: the handlers must survive streams
|
||||
* that end without a component-visible pointerup. */
|
||||
async function dispatchPointerOnStrip(
|
||||
page: Page,
|
||||
type: 'pointercancel' | 'lostpointercapture',
|
||||
init: { clientX?: number; clientY?: number },
|
||||
): Promise<void> {
|
||||
await page.evaluate(({ type, init }) => {
|
||||
const host = document.querySelector('[data-dsh-better-sidebar]')
|
||||
if (host === null) throw new Error('sidebar host missing')
|
||||
const el = [...host.querySelectorAll<HTMLElement>('*')]
|
||||
.filter(el => getComputedStyle(el).cursor === 'col-resize')
|
||||
.map(el => ({ el, r: el.getBoundingClientRect() }))
|
||||
.filter(({ r }) => r.width > 0 && r.height > 0)
|
||||
.sort((a, b) => a.r.x - b.r.x)[0]?.el
|
||||
if (el === undefined) throw new Error('width drag strip not found')
|
||||
el.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 1,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerType: 'mouse',
|
||||
isPrimary: true,
|
||||
...init,
|
||||
}))
|
||||
}, { type, init })
|
||||
}
|
||||
|
||||
test('an interrupted fast drag (pointercancel → lostpointercapture) keeps the dragged width (issue #247)', async ({ page }) => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
await dismissOnboarding(page)
|
||||
await expandSidebar(page, sidebar)
|
||||
const readWidth = (): Promise<number> => page.evaluate(() => {
|
||||
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||||
return Number.isNaN(value) ? 0 : value
|
||||
})
|
||||
const widthBefore = await readWidth()
|
||||
const { startX, startY } = await settleWidthStrip(page)
|
||||
|
||||
// Ultra-fast flick whose stream the browser then CANCELS (touchpad /
|
||||
// gesture interception): pointercancel carries the final position and
|
||||
// lostpointercapture follows it. The double fire must commit ONCE — the
|
||||
// second interrupt event used to roll the drag back to the pre-drag
|
||||
// width (the abort path never marked the drag committed).
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(startX - 140, startY, { steps: 1 })
|
||||
await dispatchPointerOnStrip(page, 'pointercancel', { clientX: startX - 140, clientY: startY })
|
||||
await dispatchPointerOnStrip(page, 'lostpointercapture', {})
|
||||
|
||||
await expect
|
||||
.poll(async () => await readWidth(), { timeout: 10_000 })
|
||||
.toBeGreaterThan(widthBefore + 100)
|
||||
await page.waitForTimeout(600)
|
||||
expect(await readWidth(), 'the interrupted fast drag must not roll back after settling').toBeGreaterThan(widthBefore + 100)
|
||||
|
||||
// Release the real pointer stream the synthetic events bypassed.
|
||||
await page.mouse.up()
|
||||
})
|
||||
|
||||
test('a capture-lost drag with no usable coordinates keeps the last applied width (issue #247)', async ({ page }) => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
await dismissOnboarding(page)
|
||||
await expandSidebar(page, sidebar)
|
||||
const readWidth = (): Promise<number> => page.evaluate(() => {
|
||||
const value = parseFloat(document.documentElement.style.getPropertyValue('--dsh-sidebar-width'))
|
||||
return Number.isNaN(value) ? 0 : value
|
||||
})
|
||||
const widthBefore = await readWidth()
|
||||
const { startX, startY } = await settleWidthStrip(page)
|
||||
|
||||
// A flick whose move DID reach the DOM (the rAF flushed it), followed by
|
||||
// capture loss with NO coordinates (lostpointercapture does not carry a
|
||||
// position). The abort must adopt the last applied size — the old code
|
||||
// explicitly reverted the DOM to the pre-drag width here.
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(startX - 120, startY, { steps: 1 })
|
||||
await page.waitForTimeout(100) // let the rAF consume the pending write
|
||||
await dispatchPointerOnStrip(page, 'lostpointercapture', {})
|
||||
|
||||
await expect
|
||||
.poll(async () => await readWidth(), { timeout: 10_000 })
|
||||
.toBeGreaterThan(widthBefore + 80)
|
||||
await page.waitForTimeout(600)
|
||||
expect(await readWidth(), 'the no-coordinate capture loss must not roll back after settling').toBeGreaterThan(widthBefore + 80)
|
||||
await page.mouse.up()
|
||||
})
|
||||
|
||||
/* ── issue #258: the bottom panel must not flash full-width after a width
|
||||
drag release ─────────────────────────────────────────────────────── */
|
||||
|
||||
test('bottom panel never flashes full-width after a width drag release (issue #258)', async ({ page }) => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
await dismissOnboarding(page)
|
||||
await expandSidebar(page, sidebar)
|
||||
|
||||
// The bottom panel must be OPEN too — its right edge is what tracks the
|
||||
// center column (and what flashes full-width on release).
|
||||
const bottomExpand = sidebar.getByRole('button', { name: 'Expand bottom panel' })
|
||||
await expect(bottomExpand, 'the toggle cluster must offer the bottom-panel expand button').toHaveCount(1)
|
||||
await bottomExpand.click()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const value = await page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-sidebar-height'))
|
||||
return value !== '' && value !== '0px'
|
||||
}, { timeout: 90_000 })
|
||||
.toBe(true)
|
||||
|
||||
const { startX, startY } = await settleWidthStrip(page)
|
||||
|
||||
// Per-frame sampler: the bottom panel's right edge vs the center column's
|
||||
// right edge (where the layout push lands). The release path used to
|
||||
// TRANSIENTLY REMOVE the push variables between the layout effect's
|
||||
// cleanup and setup phases — React can yield a render frame in that gap,
|
||||
// so the browser painted the push-less layout (center column full width),
|
||||
// the drag-end measure cached that full-width rect, and the bottom panel
|
||||
// rendered full-width while #root's margin transition animated back to
|
||||
// the new width. Every post-release frame must keep the two edges glued
|
||||
// at the committed width: no full-width frame, no drift, no margin
|
||||
// animation.
|
||||
await page.evaluate(() => {
|
||||
const samples: Array<{ t: number; bottomRight: number; colRight: number; varW: string; dragging: boolean; iw: number }> = []
|
||||
const loop = (): void => {
|
||||
const bottom = document.querySelector('[data-dsh-better-sidebar] [data-dsh-bottom-panel]')
|
||||
const col = document.querySelector('#root [data-slot="conversation"]')?.parentElement
|
||||
samples.push({
|
||||
t: performance.now(),
|
||||
bottomRight: bottom?.getBoundingClientRect().right ?? -1,
|
||||
colRight: col?.getBoundingClientRect().right ?? -1,
|
||||
varW: document.documentElement.style.getPropertyValue('--dsh-sidebar-width'),
|
||||
dragging: document.body.hasAttribute('data-dsh-sidebar-dragging'),
|
||||
iw: window.innerWidth,
|
||||
})
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
requestAnimationFrame(loop)
|
||||
;(window as unknown as { __flashSamples: typeof samples }).__flashSamples = samples
|
||||
})
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
await page.mouse.move(startX - i * 12, startY, { steps: 2 })
|
||||
await page.waitForTimeout(30)
|
||||
}
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const samples = await page.evaluate(
|
||||
() => (window as unknown as { __flashSamples: Array<{ t: number; bottomRight: number; colRight: number; varW: string; dragging: boolean; iw: number }> }).__flashSamples,
|
||||
)
|
||||
expect(samples.length, 'the frame sampler must have collected frames').toBeGreaterThan(20)
|
||||
const lastDrag = [...samples].reverse().find(s => s.dragging)
|
||||
expect(lastDrag, 'the dragging attribute must appear during the drag').toBeDefined()
|
||||
const releaseIdx = samples.indexOf(lastDrag!) + 1
|
||||
const release = samples[releaseIdx]!
|
||||
expect(release.colRight, 'the center column must be measurable after release').toBeGreaterThan(0)
|
||||
expect(release.bottomRight, 'the bottom panel must be measurable after release').toBeGreaterThan(0)
|
||||
const settled = samples[samples.length - 1]!
|
||||
for (const s of samples.slice(releaseIdx)) {
|
||||
expect(
|
||||
Math.abs(s.bottomRight - s.colRight),
|
||||
`bottom panel drifted from the center column at t=${Math.round(s.t)} (bottom ${s.bottomRight} vs column ${s.colRight})`,
|
||||
).toBeLessThanOrEqual(8)
|
||||
const fullWidth = s.bottomRight >= s.iw - 1
|
||||
expect(
|
||||
fullWidth && s.varW !== '' && s.varW !== '0px',
|
||||
`bottom panel flashed full-width at t=${Math.round(s.t)} (right ${s.bottomRight}, push ${s.varW})`,
|
||||
).toBe(false)
|
||||
expect(
|
||||
Math.abs(s.colRight - settled.colRight),
|
||||
`center column edge animated after release at t=${Math.round(s.t)} (${s.colRight} vs settled ${settled.colRight})`,
|
||||
).toBeLessThanOrEqual(8)
|
||||
}
|
||||
})
|
||||
|
||||
test('the bottom-push anchor resolves through the composite selectors (at least one; same element when both)', async ({ page }) => {
|
||||
// layout.css pushes the bottom panel via the center column. The selector
|
||||
// is COMPOSITE on purpose: `[data-pane="conversation"]` (0.1.x naming)
|
||||
// and `:has(> [data-slot="conversation"])` (rc.8-era naming) — HOST
|
||||
// VERSIONS MAY RENAME THE ATTRIBUTE (issue #208 comment / PR #226), so
|
||||
// the contract is "at least one resolves", and when both resolve they
|
||||
// must hit the SAME element (otherwise the push would land twice).
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
const anchors = await page.evaluate(() => {
|
||||
const a = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]')
|
||||
const b = document.querySelector('#root :has(> [data-slot="conversation"])')
|
||||
const frame = document.querySelector('#root [data-dsh-frame]')
|
||||
return {
|
||||
a: a !== null,
|
||||
b: b !== null,
|
||||
same: a !== null && b !== null && a === b,
|
||||
frameChildren: frame !== null
|
||||
? [...frame.children].map(el => `${el.tagName}[${[...el.attributes].map(attr => attr.name).filter(name => name.startsWith('data-')).join(',')}]`)
|
||||
: [],
|
||||
}
|
||||
})
|
||||
expect(
|
||||
anchors.a || anchors.b,
|
||||
`at least one bottom-push anchor must resolve on this host (frame children: ${anchors.frameChildren.join(' / ') || 'no [data-dsh-frame]'})`,
|
||||
).toBe(true)
|
||||
if (anchors.a && anchors.b) {
|
||||
expect(anchors.same, 'both selectors must hit the SAME center-column element').toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
+185
-10
@@ -56,9 +56,11 @@ const SEEDED_MD_FILE = 'diagram.md'
|
||||
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
|
||||
|
||||
/** Built-in tab titles the sweep drives (en-US copy; follows DSH locale). */
|
||||
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
|
||||
const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Side Chat (beta)', 'Terminal', 'Browser']
|
||||
|
||||
let api: APIRequestContext
|
||||
/** The seeded session id (captured by seedSession; the Side Chat smoke's parent). */
|
||||
let seededSessionId: string
|
||||
|
||||
/** Seed one workspace + one session (plus files for the editor/mermaid-chunk
|
||||
* probes) through the host's unary RPC surface. */
|
||||
@@ -99,6 +101,11 @@ async function seedSession(): Promise<void> {
|
||||
data: { type: 'client-request', rpcId: 'e2e-session', method: 'session.create', payload: { workspaceId } },
|
||||
})
|
||||
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
|
||||
const sessionBody = (await session.json()) as {
|
||||
result: { ok: true; value: { sessionId: string } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(sessionBody.result.ok).toBe(true)
|
||||
seededSessionId = (sessionBody.result as { value: { sessionId: string } }).value.sessionId
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
@@ -228,6 +235,72 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
await assertNoCrash()
|
||||
}
|
||||
|
||||
// Side Chat host-route smoke against the REAL host: create a thread child
|
||||
// under the seeded session (custom-seed creation through AgentRegistry),
|
||||
// deliver a follow-up, cancel, and release it. The turn itself cannot run
|
||||
// (keyless boot has no model route), but admission + creation + the wire
|
||||
// envelope must all succeed — this is the deepest functional proof the
|
||||
// mount lane can make without a provider.
|
||||
const start = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
|
||||
data: { sessionId: seededSessionId, question: 'mount lane smoke' },
|
||||
})
|
||||
expect(start.ok(), `sidechat.start: ${start.status()} ${await start.text()}`).toBe(true)
|
||||
const startBody = (await start.json()) as {
|
||||
ok: boolean
|
||||
value?: { childId?: string }
|
||||
error?: { code?: string; message?: string }
|
||||
}
|
||||
expect(startBody.ok, `sidechat.start envelope: ${JSON.stringify(startBody)}`).toBe(true)
|
||||
const childId = startBody.value?.childId
|
||||
expect(childId, 'sidechat.start must return a child session id').toMatch(/^session-/)
|
||||
// The child must be a REAL session in the host's store (provider-free
|
||||
// proof of the custom-seed creation; the boundary message itself only
|
||||
// becomes durable when a turn claims it, which needs a model route).
|
||||
const list = await api.post(`${BASE_URL}/api/session.list`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-sidechat-list', method: 'session.list', payload: {} },
|
||||
})
|
||||
expect(list.ok(), `session.list: ${list.status()} ${await list.text()}`).toBe(true)
|
||||
const listBody = (await list.json()) as {
|
||||
result: { ok: true; value: { items: Array<{ sessionId: string }> } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(listBody.result.ok, `session.list envelope: ${JSON.stringify(listBody)}`).toBe(true)
|
||||
const listedItems = (listBody.result as { value: { items: Array<{ sessionId: string }> } }).value.items
|
||||
expect(
|
||||
listedItems.some(item => item.sessionId === childId),
|
||||
'the thread child must appear in the host session list',
|
||||
).toBe(true)
|
||||
for (const method of ['sidechat.prompt', 'sidechat.cancel', 'sidechat.dispose']) {
|
||||
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
|
||||
data: method === 'sidechat.prompt' ? { childId, text: 'follow-up' } : { childId },
|
||||
})
|
||||
expect(response.ok(), `${method}: ${response.status()} ${await response.text()}`).toBe(true)
|
||||
}
|
||||
|
||||
// The Codex-style immediate-create flow: a blank question creates an
|
||||
// EMPTY thread (no prompt admitted), sidechat.info reports the live
|
||||
// agent, and the first prompt delivers the boundary host-side.
|
||||
const empty = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
|
||||
data: { sessionId: seededSessionId, question: '' },
|
||||
})
|
||||
expect(empty.ok(), `sidechat.start (empty): ${empty.status()} ${await empty.text()}`).toBe(true)
|
||||
const emptyBody = (await empty.json()) as { ok: boolean; value?: { childId?: string } }
|
||||
const emptyChildId = emptyBody.value?.childId
|
||||
expect(emptyChildId, 'immediate create must return a child session id').toMatch(/^session-/)
|
||||
const info = await api.post(`${BASE_URL}/sidebar/api/sidechat.info`, {
|
||||
data: { childId: emptyChildId },
|
||||
})
|
||||
expect(info.ok(), `sidechat.info: ${info.status()} ${await info.text()}`).toBe(true)
|
||||
const infoBody = (await info.json()) as { ok: boolean; value?: { live?: boolean; preset?: string } }
|
||||
expect(infoBody.ok, `sidechat.info envelope: ${JSON.stringify(infoBody)}`).toBe(true)
|
||||
expect(infoBody.value?.live, 'the fresh thread must have a live agent').toBe(true)
|
||||
for (const method of ['sidechat.prompt', 'sidechat.dispose']) {
|
||||
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
|
||||
data: method === 'sidechat.prompt' ? { childId: emptyChildId, text: 'first message' } : { childId: emptyChildId },
|
||||
})
|
||||
expect(response.ok(), `${method} (immediate thread): ${response.status()} ${await response.text()}`).toBe(true)
|
||||
}
|
||||
await assertNoCrash()
|
||||
|
||||
// The editor chunk (client-editor.js) only loads when a files-window tab
|
||||
// renders. Exercise the file-open path explicitly through the Files window's
|
||||
// own tree: the seeded home tab ("Files") is already open with its tree
|
||||
@@ -341,20 +414,122 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
await page.screenshot({ path: 'test-results/mount-final.png' })
|
||||
})
|
||||
|
||||
test('desktop shell stamps auto-enable the win32 title-bar compatibility mode', async ({ page }) => {
|
||||
test('conservative auto: URL stamps alone never modify the layout; plugin chrome carries the stable data attributes', async ({ page }) => {
|
||||
// The official DSH Desktop shell stamps every render URL with
|
||||
// dsh-desktop-mode / dsh-desktop-platform (and exposes a preload marker).
|
||||
// The win32 advanced shell reserves a 32px overlay for the window
|
||||
// controls where the toggle cluster sits — the sidebar must auto-drop
|
||||
// below it (body[data-dsh-title-bar-compat]) without any manual pref.
|
||||
// dsh-desktop-mode / dsh-desktop-platform. Under the conservative AUTO
|
||||
// scheme, shell stamps are REPORTS, not geometry: without the standard
|
||||
// Window Controls Overlay API the layout must stay untouched (plain-web
|
||||
// semantics) — the strip/body attribute appear only for real standard
|
||||
// geometry (see the WCO scenario below) or an opt-in preset.
|
||||
await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(
|
||||
page.locator('body[data-dsh-title-bar-compat]'),
|
||||
'win32 advanced shell must auto-enable title-bar compatibility',
|
||||
).toBeAttached({ timeout: 90_000 })
|
||||
// The strip variable must be sized to the shell's 32px overlay.
|
||||
'stamps alone must NOT auto-enable title-bar compatibility under auto',
|
||||
).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('32px')
|
||||
.toBe('')
|
||||
// The stable addressing surface for presets / custom CSS is mounted.
|
||||
await expect(page.locator('[data-dsh-toggle-cluster]')).toBeAttached()
|
||||
await expect(page.locator('[data-dsh-panel]').first()).toBeAttached()
|
||||
// The plugin's interactive chrome opts out of Electron drag regions
|
||||
// (issues #103/#111) — inert in plain browsers, present in the bundle
|
||||
// (the bundler minifies the property's whitespace, so match loosely).
|
||||
const hasNoDragRule = await page.evaluate(() => {
|
||||
for (const tag of document.querySelectorAll('style')) {
|
||||
if (tag.textContent !== null && /-webkit-app-region:\s*no-drag/.test(tag.textContent)) return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
expect(hasNoDragRule, 'the bundle must ship the drag-region opt-out rule').toBe(true)
|
||||
})
|
||||
|
||||
test('standard WCO geometry drives the strip reactively (issue #257)', async ({ page }) => {
|
||||
// The Window Controls Overlay API is the STANDARD signal for shells that
|
||||
// draw the native caption buttons over web content (Electron
|
||||
// `titleBarOverlay`). Mock it with the real API shape: the strip must
|
||||
// follow the reported rect and react to geometrychange (maximize/restore).
|
||||
await page.addInitScript(() => {
|
||||
const rect = { x: 0, y: 0, width: 138, height: 36 }
|
||||
const listeners = new Set<() => void>()
|
||||
Object.defineProperty(navigator, 'windowControlsOverlay', {
|
||||
configurable: true,
|
||||
value: {
|
||||
visible: true,
|
||||
getTitlebarAreaRect: () => ({ ...rect }),
|
||||
addEventListener: (type: string, listener: () => void) => { if (type === 'geometrychange') listeners.add(listener) },
|
||||
removeEventListener: (type: string, listener: () => void) => { if (type === 'geometrychange') listeners.delete(listener) },
|
||||
},
|
||||
})
|
||||
;(globalThis as { __wcoMock?: { setHeight: (height: number) => void } }).__wcoMock = {
|
||||
setHeight: (height: number) => {
|
||||
rect.height = height
|
||||
for (const listener of listeners) listener()
|
||||
},
|
||||
}
|
||||
})
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
// Real reported height (36px, not a hardcoded 32) drives the strip.
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('36px')
|
||||
// Maximize → the overlay reports a zero rect → the strip is removed.
|
||||
await page.evaluate(() => (globalThis as { __wcoMock?: { setHeight: (height: number) => void } }).__wcoMock?.setHeight(0))
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('')
|
||||
// Restore → the strip comes back.
|
||||
await page.evaluate(() => (globalThis as { __wcoMock?: { setHeight: (height: number) => void } }).__wcoMock?.setHeight(36))
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached()
|
||||
})
|
||||
|
||||
test('opt-in shell preset applies its strip when WCO is absent (data-driven, manual)', async ({ request, page }) => {
|
||||
// The anywhere-labs DSH Desktop preset (shell-presets.ts) is OPT-IN: under
|
||||
// the preset scheme the win32 advanced stamp resolves to its 32px fallback
|
||||
// even without the WCO API; auto never does this.
|
||||
const update = await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'preset', titleBarPresetId: 'dsh-desktop', titleBarCompat: true } },
|
||||
})
|
||||
expect(update.ok(), `settings.update: ${update.status()}`).toBe(true)
|
||||
try {
|
||||
await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => document.documentElement.style.getPropertyValue('--dsh-title-bar-strip')))
|
||||
.toBe('32px')
|
||||
// The v1 anywhere-labs preset is PURE STRIP DATA (no extra CSS — the
|
||||
// injection mechanism is exercised by the custom-scheme test). Assert
|
||||
// the absence explicitly: a future preset that ADDS css would trip
|
||||
// here instead of silently shipping unstyled.
|
||||
await expect(page.locator('style[data-dsh-preset-css]')).toHaveCount(0)
|
||||
} finally {
|
||||
// Restore the shared server state for the lanes after this one.
|
||||
await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false } },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('custom scheme injects the user stylesheet live', async ({ request, page }) => {
|
||||
const update = await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'custom', customCss: 'html { --dsh-e2e-marker: 1; }', titleBarCompat: true } },
|
||||
})
|
||||
expect(update.ok(), `settings.update: ${update.status()}`).toBe(true)
|
||||
try {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(page.locator('style[data-dsh-custom-css="custom"]')).toBeAttached()
|
||||
// The injected CSS is live (a custom property the page can read back).
|
||||
const marker = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--dsh-e2e-marker').trim())
|
||||
expect(marker).toBe('1')
|
||||
} finally {
|
||||
await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
data: { patch: { titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false } },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* FileTree drag-drop surface: an OS file drag over the tree shows the
|
||||
* portaled drop zone (document.body, above DSH's z-1000 page mask) with the
|
||||
* hint pill docked at the bottom; row hovers retarget the drop directory.
|
||||
* The enter/leave DEPTH COUNTER is the regression guard for the mask
|
||||
* flicker: child-element transitions must never hide the zone mid-drag —
|
||||
* only the drag actually leaving the tree does. Drops report through
|
||||
* onUploadRequest (file rows target their parent directory) and reset all
|
||||
* drag state.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { beforeAll, beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { FileTree } from '../src/client/FileTree.tsx'
|
||||
import type { UploadItem } from '../src/client/upload.ts'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
// vitest 4.1.11+ follows the OS locale; pin en-US so hint assertions are
|
||||
// deterministic regardless of the developer machine.
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
})
|
||||
|
||||
vi.mock('../src/client/api.ts', () => ({
|
||||
api: {
|
||||
fsTree: async () => ({
|
||||
entries: [
|
||||
{ name: 'src', path: '/tmp/src', isDir: true },
|
||||
{ name: 'a.ts', path: '/tmp/a.ts', isDir: false },
|
||||
],
|
||||
}),
|
||||
},
|
||||
downloadUrl: () => '/sidebar/file',
|
||||
}))
|
||||
|
||||
interface Harness {
|
||||
container: HTMLDivElement
|
||||
body: HTMLElement
|
||||
uploads: { dir: string; items: UploadItem[] }[]
|
||||
unmount: () => void
|
||||
}
|
||||
|
||||
/** Mount the tree rooted at /tmp (one level loaded: dir 'src', file 'a.ts'). */
|
||||
async function mountTree(busy = false): Promise<Harness> {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
const uploads: { dir: string; items: UploadItem[] }[] = []
|
||||
await act(async () => {
|
||||
root.render(createElement(FileTree, {
|
||||
sessionId: 's1',
|
||||
cwd: '/tmp',
|
||||
expanded: [],
|
||||
onToggle: () => {},
|
||||
onOpenFile: () => {},
|
||||
onReferenceFile: () => {},
|
||||
refreshTick: 0,
|
||||
onUploadRequest: (dir, items) => { uploads.push({ dir, items }) },
|
||||
busy,
|
||||
}))
|
||||
})
|
||||
return {
|
||||
container,
|
||||
body: container.firstElementChild as HTMLElement,
|
||||
uploads,
|
||||
unmount: () => { act(() => { root.unmount() }) },
|
||||
}
|
||||
}
|
||||
|
||||
/** A bubbling drag event carrying a stub dataTransfer (jsdom has no DragEvent). */
|
||||
function dragEvent(type: string): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', {
|
||||
// No entry API on the items, so drops fall back to the flat file list.
|
||||
value: {
|
||||
types: ['Files'],
|
||||
items: [],
|
||||
files: [new File(['x'], 'dropped.txt')],
|
||||
dropEffect: '',
|
||||
},
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
/** Dispatch inside act() so React flushes the state update. */
|
||||
function fire(target: Element, type: string): Event {
|
||||
const event = dragEvent(type)
|
||||
act(() => { target.dispatchEvent(event) })
|
||||
return event
|
||||
}
|
||||
|
||||
const dropZone = (): HTMLElement | null => document.body.querySelector<HTMLElement>('[class*="uploadDropZone"]')
|
||||
|
||||
/** The row whose label text matches (rows are role="button" divs). */
|
||||
function rowByName(container: HTMLElement, name: string): HTMLElement {
|
||||
const row = [...container.querySelectorAll<HTMLElement>('[role="button"]')]
|
||||
.find(el => el.querySelector('[class*="explorerName"]')?.textContent === name)
|
||||
if (row === undefined) throw new Error(`row not found: ${name}`)
|
||||
return row
|
||||
}
|
||||
|
||||
describe('FileTree drag-drop surface', () => {
|
||||
let harness: Harness
|
||||
beforeEach(async () => {
|
||||
harness = await mountTree()
|
||||
})
|
||||
afterEach(() => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('shows the portaled drop zone with the generic hint while a file drag hovers the tree', () => {
|
||||
expect(dropZone()).toBeNull()
|
||||
fire(harness.body, 'dragenter')
|
||||
const zone = dropZone()
|
||||
expect(zone).not.toBeNull()
|
||||
expect(zone!.parentElement).toBe(document.body)
|
||||
expect(zone!.textContent).toContain('Drop files/folders here to upload')
|
||||
// jsdom's rects are all-zero, so the left space is 0px wide and the
|
||||
// chat-area hint is skipped (its floor is 200px).
|
||||
expect(document.body.querySelector('[class*="uploadDropChatHint"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the zone through child-element transitions and hides it only when the drag leaves the tree', () => {
|
||||
const file = rowByName(harness.container, 'a.ts')
|
||||
fire(harness.body, 'dragenter') // depth 1
|
||||
fire(file, 'dragenter') // bubbles: depth 2 (entered the row)
|
||||
fire(file, 'dragleave') // bubbles: depth 1 — a child transition must not hide the zone
|
||||
expect(dropZone()).not.toBeNull()
|
||||
fire(harness.body, 'dragleave') // depth 0: the drag left the tree
|
||||
expect(dropZone()).toBeNull()
|
||||
})
|
||||
|
||||
it('retargets the pill to the hovered directory row and back to the root over gaps', () => {
|
||||
const dir = rowByName(harness.container, 'src')
|
||||
fire(harness.body, 'dragenter')
|
||||
fire(dir, 'dragover')
|
||||
expect(dropZone()!.textContent).toContain('Upload into /tmp/src')
|
||||
expect(dir.className).toContain('explorerRowDropTarget')
|
||||
fire(harness.body, 'dragover') // a non-row region: back to the workspace root
|
||||
expect(dropZone()!.textContent).toContain('Drop files/folders here to upload')
|
||||
expect(dir.className).not.toContain('explorerRowDropTarget')
|
||||
})
|
||||
|
||||
it('drops onto a file row upload into its parent directory and reset the drag state', async () => {
|
||||
const file = rowByName(harness.container, 'a.ts')
|
||||
fire(harness.body, 'dragenter')
|
||||
const drop = fire(file, 'drop')
|
||||
// The payload collection is async (entry traversal); flush the then.
|
||||
await act(async () => {})
|
||||
expect(drop.defaultPrevented).toBe(true)
|
||||
expect(harness.uploads).toHaveLength(1)
|
||||
expect(harness.uploads[0]!.dir).toBe('/tmp')
|
||||
expect(harness.uploads[0]!.items.map(item => item.relativePath)).toEqual(['dropped.txt'])
|
||||
expect(dropZone()).toBeNull()
|
||||
})
|
||||
|
||||
it('drops onto the tree body upload into the workspace root', async () => {
|
||||
fire(harness.body, 'dragenter')
|
||||
fire(harness.body, 'drop')
|
||||
await act(async () => {})
|
||||
expect(harness.uploads).toHaveLength(1)
|
||||
expect(harness.uploads[0]!.dir).toBe('/tmp')
|
||||
})
|
||||
|
||||
it('suppresses the affordance while an upload is in flight', async () => {
|
||||
harness.unmount()
|
||||
document.body.innerHTML = ''
|
||||
const busyHarness = await mountTree(true)
|
||||
fire(busyHarness.body, 'dragenter')
|
||||
fire(busyHarness.body, 'dragover')
|
||||
expect(dropZone()).toBeNull()
|
||||
fire(busyHarness.body, 'drop')
|
||||
await act(async () => {})
|
||||
expect(busyHarness.uploads).toHaveLength(0)
|
||||
busyHarness.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { writeWorkspaceUpload } from '../src/fs-operations.ts'
|
||||
|
||||
/** The test workspace root (each suite gets its own temp tree). */
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-'))
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** Names of leftover temp files under `dir` (must be empty after every run). */
|
||||
function tmpLeftovers(dir: string): string[] {
|
||||
return readdirSync(dir).filter(name => name.includes('.dsh-upload-'))
|
||||
}
|
||||
|
||||
/** Turn a string into the async-iterable chunk shape the route receives. */
|
||||
function chunksOf(text: string): AsyncIterable<string | Uint8Array> {
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// Split on two-byte boundaries so UTF-8 multi-byte sequences cross
|
||||
// chunk edges (streaming must reassemble them as raw bytes, not text).
|
||||
for (let i = 0; i < text.length; i += 2) yield text.slice(i, i + 2)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('writeWorkspaceUpload', () => {
|
||||
it('writes a file under the upload directory and returns its size', async () => {
|
||||
const { path, size } = await writeWorkspaceUpload({
|
||||
cwd: root,
|
||||
dir: root,
|
||||
relativePath: 'a.txt',
|
||||
chunks: chunksOf('hello 世界'),
|
||||
limit: 1024,
|
||||
})
|
||||
expect(path).toBe(join(root, 'a.txt'))
|
||||
expect(size).toBe(Buffer.byteLength('hello 世界'))
|
||||
expect(readFileSync(path, 'utf8')).toBe('hello 世界')
|
||||
})
|
||||
|
||||
it('creates nested directories on demand (folder uploads)', async () => {
|
||||
const { path } = await writeWorkspaceUpload({
|
||||
cwd: root,
|
||||
dir: root,
|
||||
relativePath: 'docs/nested/deep.txt',
|
||||
chunks: chunksOf('x'),
|
||||
limit: 1024,
|
||||
})
|
||||
expect(existsSync(path)).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves relativePaths against the chosen directory', async () => {
|
||||
const { path } = await writeWorkspaceUpload({
|
||||
cwd: root,
|
||||
dir: join(root, 'docs'),
|
||||
relativePath: 'b.txt',
|
||||
chunks: chunksOf('y'),
|
||||
limit: 1024,
|
||||
})
|
||||
expect(path).toBe(join(root, 'docs', 'b.txt'))
|
||||
})
|
||||
|
||||
it('refuses traversal, empty segments, and absolute relativePaths', async () => {
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: '../evil.txt', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'bad-request' })
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: './a.txt', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'bad-request' })
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: 'a/../b.txt', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'bad-request' })
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: '//', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'bad-request' })
|
||||
// Empty segments are refused, not silently collapsed.
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: 'a//b.txt', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'bad-request' })
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: 'a/b/', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'bad-request' })
|
||||
// Absolute paths (POSIX and Windows separators) are refused, not re-anchored.
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: '/x.txt', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'bad-request' })
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: '\\x.txt', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'bad-request' })
|
||||
})
|
||||
|
||||
it('refuses an upload directory outside the workspace', async () => {
|
||||
const outside = mkdtempSync(join(tmpdir(), 'dsh-sidebar-upload-outside-'))
|
||||
try {
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: outside, relativePath: 'x.txt', chunks: chunksOf('x'), limit: 1024,
|
||||
})).rejects.toMatchObject({ code: 'forbidden' })
|
||||
} finally {
|
||||
rmSync(outside, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses oversized uploads without leaving a target or temp file', async () => {
|
||||
const target = join(root, 'big.bin')
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: 'big.bin', chunks: chunksOf('1234567890'), limit: 4,
|
||||
})).rejects.toMatchObject({ code: 'too-large' })
|
||||
expect(existsSync(target)).toBe(false)
|
||||
expect(tmpLeftovers(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps concurrent uploads to the same target independent', async () => {
|
||||
const target = join(root, 'race.txt')
|
||||
await Promise.all([
|
||||
writeWorkspaceUpload({ cwd: root, dir: root, relativePath: 'race.txt', chunks: chunksOf('first'), limit: 1024 }),
|
||||
writeWorkspaceUpload({ cwd: root, dir: root, relativePath: 'race.txt', chunks: chunksOf('second'), limit: 1024 }),
|
||||
])
|
||||
// Both renames succeed (unique temp names, no EEXIST cross-talk); the last
|
||||
// rename wins and the losers leave nothing behind.
|
||||
expect(['first', 'second']).toContain(readFileSync(target, 'utf8'))
|
||||
expect(tmpLeftovers(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not overwrite an existing file on a failed (oversized) retry', async () => {
|
||||
const target = join(root, 'keep.txt')
|
||||
await writeWorkspaceUpload({ cwd: root, dir: root, relativePath: 'keep.txt', chunks: chunksOf('original'), limit: 1024 })
|
||||
await expect(writeWorkspaceUpload({
|
||||
cwd: root, dir: root, relativePath: 'keep.txt', chunks: chunksOf('0123456789'), limit: 2,
|
||||
})).rejects.toMatchObject({ code: 'too-large' })
|
||||
expect(readFileSync(target, 'utf8')).toBe('original')
|
||||
})
|
||||
})
|
||||
@@ -69,8 +69,15 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
// and 13px.
|
||||
expect(resolved.terminalFontFamily).toBe('')
|
||||
expect(resolved.terminalFontSize).toBe(13)
|
||||
// The position-compat mode defaults OFF (the normal layout is default),
|
||||
// with the strip defaulting to 40px.
|
||||
// The position-compat scheme is declared WITHOUT a schema default so a
|
||||
// stored document that predates it resolves without the field — the
|
||||
// CLIENT parsePrefs then applies the conservative `auto` default (or
|
||||
// migrates the legacy boolean), which is exactly what makes old
|
||||
// documents migrate instead of silently flipping to a scheme. The
|
||||
// legacy strip keeps its schema default of 40px.
|
||||
expect(resolved.titleBarScheme).toBeUndefined()
|
||||
expect(resolved.titleBarPresetId).toBeUndefined()
|
||||
expect(resolved.customCss).toBeUndefined()
|
||||
expect(resolved.titleBarCompat).toBe(false)
|
||||
expect(resolved.titleBarStripPx).toBe(40)
|
||||
// The enable-switch maps resolve to {} (everything on) for old documents.
|
||||
|
||||
+50
-13
@@ -45,6 +45,9 @@ describe('side card preferences', () => {
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
@@ -74,6 +77,9 @@ describe('side card preferences', () => {
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
@@ -103,6 +109,9 @@ describe('side card preferences', () => {
|
||||
editorExplorer: false,
|
||||
terminalShell: '',
|
||||
terminalShellArgs: '',
|
||||
titleBarScheme: 'auto',
|
||||
titleBarPresetId: '',
|
||||
customCss: '',
|
||||
titleBarCompat: false,
|
||||
titleBarStripPx: 40,
|
||||
htmlViewerNoSandbox: false,
|
||||
@@ -151,14 +160,42 @@ describe('side card preferences', () => {
|
||||
expect((await loadPrefs(wire({ editorExplorer: true }))).editorExplorer).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults titleBarCompat to false; only an explicit true turns the position-compat mode on', async () => {
|
||||
// Absent or malformed → off (the normal layout is the default).
|
||||
expect((await loadPrefs(wire({}))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 'yes' }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: 1 }))).titleBarCompat).toBe(false)
|
||||
// Explicit booleans survive verbatim.
|
||||
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarCompat).toBe(false)
|
||||
expect((await loadPrefs(wire({ titleBarCompat: true }))).titleBarCompat).toBe(true)
|
||||
it('defaults the title-bar scheme to the conservative auto with no preset or custom CSS', async () => {
|
||||
// Absent or malformed → auto (plain web keeps the untouched layout).
|
||||
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({ titleBarScheme: 'weird' }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({ titleBarScheme: 1 }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({}))).titleBarPresetId).toBe('')
|
||||
expect((await loadPrefs(wire({ titleBarPresetId: 5 }))).titleBarPresetId).toBe('')
|
||||
expect((await loadPrefs(wire({}))).customCss).toBe('')
|
||||
expect((await loadPrefs(wire({ customCss: 7 }))).customCss).toBe('')
|
||||
// Valid values survive verbatim (including the explicit web scheme).
|
||||
const picked = await loadPrefs(wire({ titleBarScheme: 'preset', titleBarPresetId: 'dsh-desktop', customCss: 'html { }' }))
|
||||
expect(picked.titleBarScheme).toBe('preset')
|
||||
expect(picked.titleBarPresetId).toBe('dsh-desktop')
|
||||
expect(picked.customCss).toBe('html { }')
|
||||
expect((await loadPrefs(wire({ titleBarScheme: 'web' }))).titleBarScheme).toBe('web')
|
||||
})
|
||||
|
||||
it('migrates LEGACY documents that ALREADY HAVE VALUES into the custom scheme', async () => {
|
||||
// A pre-scheme document with the manual compat flag on maps to the
|
||||
// custom scheme, keeping the strip px the user chose.
|
||||
const migrated = await loadPrefs(wire({ titleBarCompat: true, titleBarStripPx: 56 }))
|
||||
expect(migrated.titleBarScheme).toBe('custom')
|
||||
expect(migrated.titleBarStripPx).toBe(56)
|
||||
// A non-default strip px alone (only reachable through the old gear
|
||||
// popup) also counts as "already has values" → custom.
|
||||
const stripOnly = await loadPrefs(wire({ titleBarStripPx: 48 }))
|
||||
expect(stripOnly.titleBarScheme).toBe('custom')
|
||||
expect(stripOnly.titleBarStripPx).toBe(48)
|
||||
// A stored scheme always wins over the legacy fields (round-trip of the
|
||||
// mirrored write: preset stays preset even though the mirror is true).
|
||||
const roundTrip = await loadPrefs(wire({ titleBarScheme: 'preset', titleBarCompat: true }))
|
||||
expect(roundTrip.titleBarScheme).toBe('preset')
|
||||
// Legacy off / absent / default strip → the conservative auto scheme.
|
||||
expect((await loadPrefs(wire({ titleBarCompat: false }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({ titleBarStripPx: 40 }))).titleBarScheme).toBe('auto')
|
||||
expect((await loadPrefs(wire({}))).titleBarScheme).toBe('auto')
|
||||
})
|
||||
|
||||
it('defaults titleBarStripPx to 40 and clamps stored values into the contract range', async () => {
|
||||
@@ -223,9 +260,9 @@ describe('side card preferences', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
@@ -261,7 +298,7 @@ describe('side card preferences', () => {
|
||||
|
||||
it('skips the default seed tab when the editor (files window) type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession('no-editor')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
@@ -271,7 +308,7 @@ describe('side card preferences', () => {
|
||||
// editorExplorer modes.
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
openStore.setSession(`with-editor-${editorExplorer}`)
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
|
||||
@@ -281,7 +318,7 @@ describe('side card preferences', () => {
|
||||
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
|
||||
for (const editorExplorer of [true, false]) {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
|
||||
store.setSession(`fresh-${editorExplorer}`)
|
||||
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(tabs).toHaveLength(1)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Built-in shell-preset data tests: registry integrity (unique ids, non-empty
|
||||
* titles, pure strip/detect functions) and the anywhere-labs DSH Desktop
|
||||
* entry's strip values (darwin advanced 20px, win32 advanced 32px as the
|
||||
* no-WCO fallback, compatibility/plain browser nothing).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getShellPreset, getShellPresets, presetStripFor } from '../src/client/shell-presets.ts'
|
||||
import type { DesktopEnv } from '../src/client/desktop-env.ts'
|
||||
|
||||
const env = (partial: Partial<DesktopEnv>): DesktopEnv => ({
|
||||
desktop: false,
|
||||
mode: null,
|
||||
platform: null,
|
||||
titlebarInset: 0,
|
||||
...partial,
|
||||
})
|
||||
|
||||
describe('shell presets', () => {
|
||||
it('keeps the registry well-formed (unique ids, titles, pure strip functions)', () => {
|
||||
const presets = getShellPresets()
|
||||
expect(presets.length).toBeGreaterThan(0)
|
||||
const ids = new Set<string>()
|
||||
for (const preset of presets) {
|
||||
expect(ids.has(preset.id)).toBe(false)
|
||||
ids.add(preset.id)
|
||||
expect(preset.title.length).toBeGreaterThan(0)
|
||||
expect(preset.desc.length).toBeGreaterThan(0)
|
||||
// The strip function must be total — an unknown environment yields
|
||||
// undefined, never a throw.
|
||||
expect(() => presetStripFor(preset, env({ desktop: false, mode: null, platform: null }))).not.toThrow()
|
||||
expect(() => preset.detect?.(env({ desktop: false, mode: null, platform: null }))).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('looks up presets by id; unknown/empty ids resolve to undefined', () => {
|
||||
expect(getShellPreset('dsh-desktop')).toBeDefined()
|
||||
expect(getShellPreset('')).toBeUndefined()
|
||||
expect(getShellPreset('no-such-shell')).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('dsh-desktop (DeepSeek Harness Desktop, anywhere-labs)', () => {
|
||||
const preset = getShellPreset('dsh-desktop')!
|
||||
|
||||
it('reserves 20px on darwin advanced (the caption row above the surfaces)', () => {
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'advanced', platform: 'darwin' }))).toBe(20)
|
||||
})
|
||||
|
||||
it('reserves 32px on win32 advanced as the no-WCO fallback', () => {
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'advanced', platform: 'win32' }))).toBe(32)
|
||||
})
|
||||
|
||||
it('reserves nothing in compatibility mode or on unknown platforms', () => {
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'compatibility', platform: 'win32' }))).toBeUndefined()
|
||||
expect(presetStripFor(preset, env({ desktop: true, mode: 'advanced', platform: 'linux' }))).toBeUndefined()
|
||||
expect(presetStripFor(preset, env({ desktop: false, mode: null, platform: null }))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('detects advanced shells only as a SUGGESTION signal (never auto-applies)', () => {
|
||||
expect(preset.detect?.(env({ desktop: true, mode: 'advanced', platform: 'darwin' }))).toBe(true)
|
||||
expect(preset.detect?.(env({ desktop: true, mode: 'advanced', platform: 'win32' }))).toBe(true)
|
||||
expect(preset.detect?.(env({ desktop: true, mode: 'compatibility', platform: 'win32' }))).toBe(false)
|
||||
expect(preset.detect?.(env({ desktop: false, mode: null, platform: null }))).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -125,8 +125,10 @@ describe('SideCardSection declarative inventory', () => {
|
||||
const html = renderSection(store, service)
|
||||
// Subagents declares a toggle → its card carries the settings gear
|
||||
// (aria-label = "<title> Feature settings"); Explorer and Image declare
|
||||
// none → no gear.
|
||||
// none → no gear. The position-compat row is a DROPDOWN (no gear unless
|
||||
// the custom scheme is active), so the total is 1.
|
||||
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
|
||||
expect(html).toContain('Subagents Feature settings')
|
||||
})
|
||||
|
||||
it('renders the two dashed "add plugin" cards (tab grid + viewer grid)', () => {
|
||||
@@ -159,28 +161,35 @@ describe('SideCardSection declarative inventory', () => {
|
||||
const { store, service } = mount()
|
||||
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } })
|
||||
const html = renderSection(store, service)
|
||||
// The disabled Subagents card loses its gear; the position-compat row
|
||||
// is a dropdown (default auto → no gear either).
|
||||
expect(html).not.toContain('Feature settings')
|
||||
})
|
||||
|
||||
it('renders the position-compat mode general row: off by default, checked when the pref is on', () => {
|
||||
it('renders the position-compat mode row as a scheme dropdown: auto default, custom keeps the gear', () => {
|
||||
const { store, service } = mount()
|
||||
let html = renderSection(store, service)
|
||||
// The general row renders its title and description.
|
||||
// The general row renders its title and description; the scheme is the
|
||||
// conservative auto by default and the row is the shared SelectMenu
|
||||
// dropdown (the closed anchor shows the picked option — NOT a native
|
||||
// <select>).
|
||||
expect(html).toContain('Position compatibility mode')
|
||||
expect(html).toContain('Reserve space for the native Windows title bar')
|
||||
// Three general rows now: only interceptOpenPath is checked by default
|
||||
// (openByDefault defaults off, the titleBarCompat row starts UNCHECKED) —
|
||||
// the checked checkbox count is 1 while the total checkbox count is 3.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
|
||||
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
|
||||
expect(html).not.toContain('<select')
|
||||
expect(html).toContain('>Auto-detect<')
|
||||
// Two general-row switches remain (openByDefault + interceptOpenPath),
|
||||
// only interceptOpenPath checked by default — the scheme row is a
|
||||
// dropdown, not a switch.
|
||||
expect(html.match(/type="checkbox"/g)?.length).toBe(2)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(1)
|
||||
// The row's gear (customize the shift distance) is dormant while the
|
||||
// mode is off — the feature-card convention.
|
||||
// Auto (default) needs no further settings → no gear.
|
||||
expect(html).not.toContain('Position compatibility mode Feature settings')
|
||||
|
||||
// When the pref is on, the new switch is checked and the gear appears.
|
||||
store.setPrefs({ ...store.getPrefs(), titleBarCompat: true })
|
||||
// The custom scheme keeps its settings button (the px + CSS popup);
|
||||
// the anchor now shows the picked custom option.
|
||||
store.setPrefs({ ...store.getPrefs(), titleBarScheme: 'custom', titleBarCompat: true })
|
||||
html = renderSection(store, service)
|
||||
expect(html.match(/checked=""/g)?.length).toBe(2)
|
||||
expect(html).toContain('>Custom<')
|
||||
expect(html).toContain('aria-label="Position compatibility mode Feature settings"')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Unit tests for the pure Side Chat core (src/sidechat-core.ts): the seed
|
||||
* construction (full-log inheritance with honest synthetic closing of an
|
||||
* in-progress parent turn), the structured in-progress snapshot (with the
|
||||
* dangling-tool-call fallback), thread-row derivation, save eligibility, and
|
||||
* the preset resolution.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SidebarHistoryEntry, SidebarSessionEvent, SidebarSessionSummary } from '../src/context-types.ts'
|
||||
import {
|
||||
boundaryDelivered,
|
||||
buildOpenTurnSnapshot,
|
||||
buildSidechatInheritance,
|
||||
hasDanglingToolCall,
|
||||
resolvePresetId,
|
||||
sideLabel,
|
||||
sideThreadRows,
|
||||
threadHasCompletedTurn,
|
||||
threadTrailingPending,
|
||||
SIDE_BOUNDARY_PROMPT,
|
||||
SIDE_LABEL_PREFIX,
|
||||
type SeedEvent,
|
||||
type SidechatLogEvent,
|
||||
} from '../src/sidechat-core.ts'
|
||||
|
||||
/** One log event fixture (structural, seq === index like the live contract).
|
||||
* Surface-eligible events carry their required `surfaceOp: 'append'`
|
||||
* marker, exactly like the live pipeline appends them. */
|
||||
function ev(type: string, seq: number, data: Record<string, unknown> = {}): SidebarSessionEvent {
|
||||
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
|
||||
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
|
||||
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/** A completed user→assistant turn block (turn T, step 1, optional tools). */
|
||||
function completedTurn(seq: number, turn: number, over: {
|
||||
text?: string
|
||||
tools?: Array<{ callId: string; name?: string; args?: string; result?: string }>
|
||||
} = {}): SidebarSessionEvent[] {
|
||||
const events: SidebarSessionEvent[] = [
|
||||
ev('user/message', seq, { content: [{ type: 'text', text: `q${turn}` }], source: { kind: 'user' } }),
|
||||
ev('turn/start', seq + 1, { turn }),
|
||||
ev('step/start', seq + 2, { turn, step: 1 }),
|
||||
]
|
||||
let next = seq + 3
|
||||
for (const tool of over.tools ?? []) {
|
||||
events.push(ev('tool/call', next++, { turn, step: 1, callId: tool.callId, name: tool.name ?? 'bash', arguments: tool.args ?? '{}' }))
|
||||
events.push(ev('tool/result', next++, {
|
||||
turn,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: tool.callId },
|
||||
content: [{ type: 'tool-result', toolCallId: tool.callId, isError: false, content: [{ type: 'text', text: tool.result ?? 'ok' }] }],
|
||||
},
|
||||
}))
|
||||
}
|
||||
if (over.text !== undefined) {
|
||||
events.push(ev('assistant/message', next++, {
|
||||
turn,
|
||||
step: 1,
|
||||
message: { content: [{ type: 'text', text: over.text }] },
|
||||
}))
|
||||
}
|
||||
events.push(ev('step/end', next++, { turn, step: 1 }))
|
||||
events.push(ev('turn/end', next, { turn, reason: { kind: 'completed' } }))
|
||||
return events
|
||||
}
|
||||
|
||||
/** Assert the seed is contiguous from seq 0. */
|
||||
function expectContiguous(seed: readonly SeedEvent[]): void {
|
||||
seed.forEach((event, index) => expect(event.seq).toBe(index))
|
||||
}
|
||||
|
||||
describe('buildSidechatInheritance', () => {
|
||||
it('returns an empty seed for an empty log', () => {
|
||||
const { seed, snapshot } = buildSidechatInheritance([])
|
||||
expect(seed).toEqual([])
|
||||
expect(snapshot).toBeNull()
|
||||
})
|
||||
|
||||
it('copies a log that ends outside any turn verbatim (pending user message)', () => {
|
||||
const events = [
|
||||
...completedTurn(0, 1, { text: 'a' }),
|
||||
ev('user/message', 6, { content: [{ type: 'text', text: 'pending?' }], source: { kind: 'user' } }),
|
||||
]
|
||||
const { seed, snapshot } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
expect(seed).toHaveLength(events.length)
|
||||
expect(seed.at(-1)?.type).toBe('user/message')
|
||||
expect(seed.some(event => event.type === 'turn/end')).toBe(true)
|
||||
expect(snapshot).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves the surface envelope of message events in the seed', () => {
|
||||
// Regression: the seed validator rejects surface-eligible events
|
||||
// (user/message, assistant/message, tool/result) whose `surfaceOp`
|
||||
// marker was stripped — the copy must keep the FULL event envelope.
|
||||
const events = [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{}' }),
|
||||
ev('tool/result', 4, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: 'c1' },
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }],
|
||||
},
|
||||
}),
|
||||
ev('assistant/message', 5, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'a' }] } }),
|
||||
ev('step/end', 6, { turn: 1, step: 1 }),
|
||||
ev('turn/end', 7, { turn: 1, reason: { kind: 'completed' } }),
|
||||
]
|
||||
const { seed } = buildSidechatInheritance(events)
|
||||
expect(seed.filter(event => event.type === 'user/message').every(event => event.surfaceOp === 'append')).toBe(true)
|
||||
expect(seed.filter(event => event.type === 'assistant/message').every(event => event.surfaceOp === 'append')).toBe(true)
|
||||
expect(seed.filter(event => event.type === 'tool/result').every(event => event.surfaceOp === 'append')).toBe(true)
|
||||
// Non-surface events never gain a marker.
|
||||
expect(seed.filter(event => event.type === 'turn/end').every(event => event.surfaceOp === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('copies a log with no turns at all', () => {
|
||||
const events = [ev('user/message', 0, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })]
|
||||
const { seed } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
expect(seed).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('closes an open streaming turn with synthetic step/end + interrupted turn/end', () => {
|
||||
const events = [
|
||||
...completedTurn(0, 1, { text: 'done' }),
|
||||
ev('user/message', 6, { content: [{ type: 'text', text: 'next?' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 7, { turn: 2 }),
|
||||
ev('step/start', 8, { turn: 2, step: 1 }),
|
||||
ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'partial answer' } }),
|
||||
]
|
||||
const { seed, snapshot } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
expect(seed).toHaveLength(events.length + 2)
|
||||
const stepEnd = seed.at(-2)!
|
||||
const turnEnd = seed.at(-1)!
|
||||
expect(stepEnd).toMatchObject({ type: 'step/end', data: { turn: 2, step: 1 } })
|
||||
expect(turnEnd).toMatchObject({ type: 'turn/end', data: { turn: 2, reason: { kind: 'interrupted' } } })
|
||||
// The pending user message and the partial chunk survive as REAL events.
|
||||
expect(seed.some(event => event.type === 'user/message'
|
||||
&& (event.data.content as Array<{ text: string }>)[0]?.text === 'next?')).toBe(true)
|
||||
expect(seed.some(event => event.type === 'assistant/chunk')).toBe(true)
|
||||
expect(snapshot).toBeNull()
|
||||
})
|
||||
|
||||
it('closes an open turn whose tool calls already have results', () => {
|
||||
const events = [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'grep', arguments: '{"q":"x"}' }),
|
||||
ev('tool/result', 4, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: 'c1' },
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'hit' }] }],
|
||||
},
|
||||
}),
|
||||
ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'found ' } }),
|
||||
]
|
||||
const { seed, snapshot } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
expect(seed.at(-1)?.type).toBe('turn/end')
|
||||
expect(hasDanglingToolCall(events, 1)).toBe(false)
|
||||
expect(snapshot).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a snapshot cut when a tool call is still executing', () => {
|
||||
const events = [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'running ' } }),
|
||||
ev('tool/call', 4, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep 9"}' }),
|
||||
]
|
||||
expect(hasDanglingToolCall(events, 1)).toBe(true)
|
||||
const { seed, snapshot } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
// The seed stops BEFORE the open turn; the pending user message stays.
|
||||
expect(seed.at(-1)?.type).toBe('user/message')
|
||||
expect(seed.some(event => event.type === 'turn/start')).toBe(false)
|
||||
expect(snapshot).not.toBeNull()
|
||||
expect(snapshot).toContain('running ')
|
||||
expect(snapshot).toContain('`bash` (executing)')
|
||||
expect(snapshot).toContain('sleep 9')
|
||||
})
|
||||
|
||||
it('closes only the current step when the open turn has completed steps', () => {
|
||||
const events = [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
|
||||
ev('step/end', 4, { turn: 1, step: 1 }),
|
||||
ev('step/start', 5, { turn: 1, step: 2 }),
|
||||
ev('assistant/chunk', 6, { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'second' } }),
|
||||
]
|
||||
const { seed } = buildSidechatInheritance(events)
|
||||
expectContiguous(seed)
|
||||
expect(seed.at(-2)).toMatchObject({ type: 'step/end', data: { turn: 1, step: 2 } })
|
||||
expect(seed.at(-1)).toMatchObject({ type: 'turn/end', data: { turn: 1 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildOpenTurnSnapshot', () => {
|
||||
it('returns null without an open turn', () => {
|
||||
expect(buildOpenTurnSnapshot([ev('user/message', 0, { content: [], source: { kind: 'user' } })])).toBeNull()
|
||||
expect(buildOpenTurnSnapshot(completedTurn(0, 1, { text: 'x' }))).toBeNull()
|
||||
})
|
||||
|
||||
it('preserves streamed text and tool detail verbatim', () => {
|
||||
const events = [
|
||||
ev('turn/start', 0, { turn: 1 }),
|
||||
ev('step/start', 1, { turn: 1, step: 1 }),
|
||||
ev('assistant/chunk', 2, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'think' } }),
|
||||
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '```js\ncode' } }),
|
||||
ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '\n```' } }),
|
||||
ev('tool/call', 5, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a.txt"}' }),
|
||||
ev('tool/result', 6, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: 'c1' },
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'file body' }] }],
|
||||
},
|
||||
}),
|
||||
ev('tool/call', 7, { turn: 1, step: 1, callId: 'c2', name: 'bash', arguments: '{"cmd":"long"}' }),
|
||||
]
|
||||
const snapshot = buildOpenTurnSnapshot(events)
|
||||
expect(snapshot).not.toBeNull()
|
||||
expect(snapshot).toContain('```js\ncode\n```')
|
||||
expect(snapshot).toContain('Reasoning so far')
|
||||
expect(snapshot).toContain('`read` — arguments: `{"path":"a.txt"}`')
|
||||
expect(snapshot).toContain('Result: file body')
|
||||
expect(snapshot).toContain('`bash` (executing)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sideLabel', () => {
|
||||
it('prefixes and truncates', () => {
|
||||
expect(sideLabel('hello')).toBe('Side: hello')
|
||||
expect(sideLabel(' a b ')).toBe('Side: a b')
|
||||
const long = 'x'.repeat(100)
|
||||
const label = sideLabel(long)
|
||||
expect(label.startsWith(SIDE_LABEL_PREFIX)).toBe(true)
|
||||
expect(label.length).toBeLessThanOrEqual(48)
|
||||
expect(label.endsWith('…')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sideThreadRows', () => {
|
||||
const summary = (over: Partial<SidebarSessionSummary>): SidebarSessionSummary => ({
|
||||
id: 's1',
|
||||
displayTitle: 'x',
|
||||
...over,
|
||||
})
|
||||
|
||||
it('keeps only subagent-origin children of the session with the label prefix', () => {
|
||||
const byId: Record<string, SidebarSessionSummary> = {
|
||||
main: summary({ id: 'main', displayTitle: 'Main' }),
|
||||
thread: summary({ id: 'thread', origin: 'subagent', parentId: 'main', displayTitle: 'Side: q', running: true }),
|
||||
plainSubagent: summary({ id: 'plain', origin: 'subagent', parentId: 'main', displayTitle: 'Read files' }),
|
||||
foreignThread: summary({ id: 'foreign', origin: 'subagent', parentId: 'other', displayTitle: 'Side: other' }),
|
||||
}
|
||||
const rows = sideThreadRows(byId, 'main')
|
||||
expect(rows).toEqual([{ id: 'thread', title: 'Side: q', running: true }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('save eligibility', () => {
|
||||
const entry = (event: SidebarSessionEvent): SidebarHistoryEntry => ({ event })
|
||||
|
||||
it('requires a completed turn after the seed boundary', () => {
|
||||
const seed = [ev('session/end-seed', 0)]
|
||||
expect(threadHasCompletedTurn([entry(seed[0]!)])).toBe(false)
|
||||
expect(threadHasCompletedTurn([entry(seed[0]!), entry(ev('user/message', 1, { content: [], source: { kind: 'user' } }))])).toBe(false)
|
||||
const withTurn = [...completedTurn(2, 1, { text: 'a' })]
|
||||
const entries = [entry(seed[0]!), ...withTurn.map(entry)]
|
||||
expect(threadHasCompletedTurn(entries)).toBe(true)
|
||||
})
|
||||
|
||||
it('detects a trailing unanswered follow-up', () => {
|
||||
const entries = [
|
||||
...completedTurn(2, 1, { text: 'a' }).map(entry),
|
||||
entry(ev('user/message', 9, { content: [], source: { kind: 'user' } })),
|
||||
]
|
||||
expect(threadTrailingPending(entries)).toBe(true)
|
||||
expect(threadTrailingPending(completedTurn(2, 1, { text: 'a' }).map(entry))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePresetId', () => {
|
||||
it('prefers the newest selection event over the header', () => {
|
||||
const events = [ev('agent-preset/selected', 0, { agentPreset: 'later' })]
|
||||
expect(resolvePresetId({ agentPreset: 'early' }, events)).toBe('later')
|
||||
expect(resolvePresetId({ agentPreset: 'early' }, [])).toBe('early')
|
||||
expect(resolvePresetId({}, [])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SIDE_BOUNDARY_PROMPT contract', () => {
|
||||
it('opens with the boundary prefix the transcript drops', () => {
|
||||
expect(SIDE_BOUNDARY_PROMPT.startsWith('Side conversation boundary')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boundaryDelivered', () => {
|
||||
it('detects the boundary message and ignores everything else', () => {
|
||||
expect(boundaryDelivered([])).toBe(false)
|
||||
expect(boundaryDelivered([ev('user/message', 0, {
|
||||
content: [{ type: 'text', text: 'ordinary question' }],
|
||||
})])).toBe(false)
|
||||
// The inherited seed never contains a boundary: parent messages are
|
||||
// ordinary user rows.
|
||||
expect(boundaryDelivered([
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'parent q' }] }),
|
||||
ev('user/message', 1, {
|
||||
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nfirst` }],
|
||||
}),
|
||||
])).toBe(true)
|
||||
// Bare-string content is tolerated too.
|
||||
expect(boundaryDelivered([ev('user/message', 0, {
|
||||
content: `${SIDE_BOUNDARY_PROMPT}\n\nfirst`,
|
||||
})])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Host route tests for the Side Chat API ('sidechat.start' / 'sidechat.prompt'
|
||||
* / 'sidechat.cancel' / 'sidechat.dispose'): the custom-seed thread creation
|
||||
* (with the in-progress-turn synthetic close and the dangling-tool-call
|
||||
* snapshot fallback), the boundary+question first prompt, follow-ups on live
|
||||
* and cold (resumed) agents, cancel, and dispose.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildSidechatApi } from '../src/sidechat-routes.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import { SIDE_BOUNDARY_PROMPT, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
|
||||
/** A fake live agent (followup/cancel spied). */
|
||||
function agent(id: string, over: { events?: unknown[]; header?: Record<string, unknown>; provider?: string; model?: string } = {}) {
|
||||
return {
|
||||
id,
|
||||
status: 'idle' as const,
|
||||
options: { provider: over.provider ?? 'test', model: over.model ?? 'model-x' },
|
||||
session: {
|
||||
id,
|
||||
header: { cwd: '/p', delegationDepth: 0, agentPreset: 'preset-a', ...over.header },
|
||||
events: over.events ?? [],
|
||||
},
|
||||
followup: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
type AgentLike = ReturnType<typeof agent>
|
||||
type Handle = { agent: AgentLike; dispose: () => Promise<void> }
|
||||
|
||||
/** The standard services the happy paths need. */
|
||||
function happyServices(parent: AgentLike | undefined, child: AgentLike) {
|
||||
const create = vi.fn(async (_options: unknown): Promise<Handle> => ({
|
||||
agent: child,
|
||||
dispose: vi.fn(async () => {}),
|
||||
}))
|
||||
const resume = vi.fn(async (_options: unknown): Promise<Handle> => ({
|
||||
agent: child,
|
||||
dispose: vi.fn(async () => {}),
|
||||
}))
|
||||
const get = vi.fn((id: unknown) => (id === child.id ? child : parent))
|
||||
const rename = vi.fn((_session: unknown, _title: string) => ({ title: 'x', eventSeq: 1 }))
|
||||
const resolve = vi.fn(async (_id?: string) => ({ id: 'preset-a' }))
|
||||
const mount = vi.fn(async () => {})
|
||||
const inspect = vi.fn(async () => ({ meta: { agentPreset: 'preset-a' }, events: [] }))
|
||||
return {
|
||||
agents: { get, create, resume },
|
||||
agentPresets: { resolve, mount },
|
||||
sessionTitle: { rename },
|
||||
sessionPersistence: { inspect },
|
||||
create,
|
||||
resume,
|
||||
get,
|
||||
rename,
|
||||
mount,
|
||||
inspect,
|
||||
}
|
||||
}
|
||||
|
||||
/** A context serving the optional services the routes read via ctx.get. */
|
||||
function ctxWith(services: {
|
||||
agents?: unknown
|
||||
agentPresets?: unknown
|
||||
sessionTitle?: unknown
|
||||
sessionPersistence?: unknown
|
||||
}): Context {
|
||||
const table: Record<string, unknown> = {
|
||||
...(services.agents === undefined ? {} : { agents: services.agents }),
|
||||
...(services.agentPresets === undefined ? {} : { agentPresets: services.agentPresets }),
|
||||
...(services.sessionTitle === undefined ? {} : { sessionTitle: services.sessionTitle }),
|
||||
...(services.sessionPersistence === undefined ? {} : { sessionPersistence: services.sessionPersistence }),
|
||||
}
|
||||
return {
|
||||
get: (key: string) => table[key],
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
function ev(type: string, seq: number, data: Record<string, unknown> = {}): { type: string; seq: number; time: number; data: Record<string, unknown>; surfaceOp?: string } {
|
||||
const event = { type, seq, time: seq * 1000, data }
|
||||
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
|
||||
return { ...event, surfaceOp: 'append' }
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
describe('sidechat.start', () => {
|
||||
it('creates a seeded subagent-origin child and admits the boundary + question', async () => {
|
||||
const parent = agent('parent', {
|
||||
events: [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'a' }] } }),
|
||||
ev('step/end', 4, { turn: 1, step: 1 }),
|
||||
ev('turn/end', 5, { turn: 1, reason: { kind: 'completed' } }),
|
||||
],
|
||||
})
|
||||
const child = agent('child')
|
||||
const services = happyServices(parent, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
|
||||
const result = await api['sidechat.start']({ sessionId: 'parent', question: 'explain the event flow' })
|
||||
|
||||
expect(result.childId).toMatch(/^session-/)
|
||||
expect(services.create).toHaveBeenCalledTimes(1)
|
||||
const options = services.create.mock.calls[0]![0] as {
|
||||
sessionId: string
|
||||
meta: Record<string, unknown>
|
||||
seed: readonly { type: string; data: Record<string, unknown> }[]
|
||||
agentOptions: { provider: string; model: string }
|
||||
setup: unknown
|
||||
}
|
||||
expect(options.sessionId).toBe(result.childId)
|
||||
expect(options.meta).toMatchObject({
|
||||
parentSession: 'parent',
|
||||
origin: 'subagent',
|
||||
seedLength: 7,
|
||||
delegationDepth: 1,
|
||||
agentPreset: 'preset-a',
|
||||
cwd: '/p',
|
||||
})
|
||||
expect(options.agentOptions).toEqual({ provider: 'test', model: 'model-x' })
|
||||
// The child carries the parent's completed turns as a verbatim seed,
|
||||
// closed by the durable subagent descriptor (honest catalog citizenship:
|
||||
// without it the host's subagents.list renders the cold thread as a
|
||||
// 'corrupt' diagnostic row).
|
||||
expect(options.seed.map(event => event.type)).toEqual([
|
||||
'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end',
|
||||
'subagent/descriptor',
|
||||
])
|
||||
expect(options.seed.at(-1)?.data).toMatchObject({
|
||||
version: 2,
|
||||
mode: 'continuable',
|
||||
provider: 'sidechat',
|
||||
label: sideLabel('explain the event flow'),
|
||||
agentProvider: 'test',
|
||||
agentModel: 'model-x',
|
||||
})
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string } }
|
||||
expect(message.source).toEqual({ kind: 'user' })
|
||||
const promptText = message.content[0]!.text
|
||||
expect(promptText.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(promptText).toContain('explain the event flow')
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
|
||||
})
|
||||
|
||||
it('synthetically closes an in-progress parent turn inside the seed', async () => {
|
||||
const parent = agent('parent', {
|
||||
events: [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'streaming' } }),
|
||||
],
|
||||
})
|
||||
const child = agent('child')
|
||||
const services = happyServices(parent, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
|
||||
await api['sidechat.start']({ sessionId: 'parent', question: 'what now?' })
|
||||
|
||||
const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string; data: Record<string, unknown> }> }
|
||||
expect(options.seed.map(event => event.type)).toEqual([
|
||||
'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end',
|
||||
'subagent/descriptor',
|
||||
])
|
||||
expect(options.seed.at(-2)?.data).toEqual({ turn: 1, reason: { kind: 'interrupted' } })
|
||||
})
|
||||
|
||||
it('falls back to the snapshot when a tool call is still executing', async () => {
|
||||
const parent = agent('parent', {
|
||||
events: [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
|
||||
],
|
||||
})
|
||||
const child = agent('child')
|
||||
const services = happyServices(parent, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
|
||||
await api['sidechat.start']({ sessionId: 'parent', question: 'what now?' })
|
||||
|
||||
const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string }> }
|
||||
// Cut BEFORE the open turn: only the pending user message is inherited.
|
||||
expect(options.seed.map(event => event.type)).toEqual(['user/message', 'subagent/descriptor'])
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text).toContain('`bash` (executing)')
|
||||
})
|
||||
|
||||
it('creates an EMPTY thread on a blank question (Codex-style immediate create)', async () => {
|
||||
const parent = agent('parent', {
|
||||
events: [
|
||||
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
|
||||
],
|
||||
})
|
||||
const child = agent('child')
|
||||
const services = happyServices(parent, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
|
||||
const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: ' ' })
|
||||
|
||||
// No prompt yet: the composer owns the first message; the placeholder
|
||||
// label is pinned and the in-progress snapshot is parked for it.
|
||||
expect(child.followup).not.toHaveBeenCalled()
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, SIDE_NEW_THREAD_TITLE)
|
||||
|
||||
// The first prompt carries the boundary + the parked snapshot and earns
|
||||
// the thread its real label.
|
||||
services.agents.get = vi.fn((id: unknown) => (id === childId ? child : parent))
|
||||
await api['sidechat.prompt']({ childId, text: 'explain the event flow' })
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
|
||||
expect(message.content[0]!.text).toContain('`bash` (executing)')
|
||||
expect(message.content[0]!.text).toContain('explain the event flow')
|
||||
expect(services.rename).toHaveBeenCalledWith(child.session, sideLabel('explain the event flow'))
|
||||
})
|
||||
|
||||
it('rejects a non-running parent', async () => {
|
||||
const parent = agent('parent', { events: [] })
|
||||
const child = agent('child')
|
||||
const services = happyServices(parent, child)
|
||||
const idle = ctxWith({
|
||||
...services,
|
||||
agents: { ...services.agents, get: vi.fn((_id: unknown) => undefined) },
|
||||
})
|
||||
const idleApi = buildSidechatApi(idle)
|
||||
await expect(idleApi['sidechat.start']({ sessionId: 'parent', question: 'q' })).rejects.toThrow(/not running/)
|
||||
})
|
||||
|
||||
it('degrades when the agents service is absent', async () => {
|
||||
const api = buildSidechatApi(ctxWith({}))
|
||||
await expect(api['sidechat.start']({ sessionId: 'parent', question: 'q' })).rejects.toThrow(SidebarError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidechat.prompt', () => {
|
||||
it('delivers follow-ups to a live agent (boundary already delivered)', async () => {
|
||||
const child = agent('child', {
|
||||
events: [
|
||||
ev('user/message', 0, {
|
||||
content: [{ type: 'text', text: `${SIDE_BOUNDARY_PROMPT}\n\nfirst question` }],
|
||||
source: { kind: 'user' },
|
||||
}),
|
||||
],
|
||||
})
|
||||
const services = happyServices(undefined, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
const result = await api['sidechat.prompt']({ childId: 'child', text: 'tell me more' })
|
||||
expect(result).toEqual({ accepted: true })
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
const message = child.followup.mock.calls[0]![0] as { content: Array<{ text: string }> }
|
||||
expect(message.content[0]!.text).toBe('tell me more')
|
||||
})
|
||||
|
||||
it('cold-resumes the thread when the agent is gone', async () => {
|
||||
const child = agent('child')
|
||||
const services = happyServices(undefined, child)
|
||||
services.agents.get = vi.fn((_id: unknown) => undefined)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
await api['sidechat.prompt']({ childId: 'child', text: 'after restart' })
|
||||
expect(services.resume).toHaveBeenCalledTimes(1)
|
||||
const resumeOptions = services.resume.mock.calls[0]![0] as { resumeSessionId: string; setup: unknown }
|
||||
expect(resumeOptions.resumeSessionId).toBe('child')
|
||||
expect(typeof resumeOptions.setup).toBe('function')
|
||||
expect(services.inspect).toHaveBeenCalledWith('child')
|
||||
expect(child.followup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects blank text', async () => {
|
||||
const child = agent('child')
|
||||
const services = happyServices(undefined, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
await expect(api['sidechat.prompt']({ childId: 'child', text: '' })).rejects.toThrow(SidebarError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidechat.cancel and sidechat.dispose', () => {
|
||||
it('cancels the running turn with the user authority', async () => {
|
||||
const child = agent('child')
|
||||
const services = happyServices(undefined, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
await api['sidechat.cancel']({ childId: 'child' })
|
||||
expect(child.cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
|
||||
})
|
||||
|
||||
it('disposes the created thread agent', async () => {
|
||||
const parent = agent('parent', { events: [] })
|
||||
const dispose = vi.fn(async () => {})
|
||||
const create = vi.fn(async (_options: unknown): Promise<Handle> => ({
|
||||
agent: agent('child'),
|
||||
dispose,
|
||||
}))
|
||||
const services = happyServices(parent, agent('child'))
|
||||
services.agents.create = create
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
const { childId } = await api['sidechat.start']({ sessionId: 'parent', question: 'q' })
|
||||
await api['sidechat.dispose']({ childId })
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
// A second dispose is a harmless no-op.
|
||||
await api['sidechat.dispose']({ childId })
|
||||
expect(dispose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidechat.info', () => {
|
||||
it('reports the live agent state and identity', async () => {
|
||||
const child = agent('child')
|
||||
const services = happyServices(undefined, child)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
const info = await api['sidechat.info']({ childId: 'child' })
|
||||
expect(info).toEqual({
|
||||
live: true,
|
||||
status: 'idle',
|
||||
provider: 'test',
|
||||
model: 'model-x',
|
||||
preset: 'preset-a',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports the persisted preset of a cold thread', async () => {
|
||||
const child = agent('child')
|
||||
const services = happyServices(undefined, child)
|
||||
services.agents.get = vi.fn((_id: unknown) => undefined)
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
const info = await api['sidechat.info']({ childId: 'child' })
|
||||
expect(info).toEqual({ live: false, preset: 'preset-a' })
|
||||
expect(services.inspect).toHaveBeenCalledWith('child')
|
||||
})
|
||||
|
||||
it('degrades to a bare cold info when the session is gone', async () => {
|
||||
const services = happyServices(undefined, agent('child'))
|
||||
services.agents.get = vi.fn((_id: unknown) => undefined)
|
||||
services.inspect = vi.fn(async () => { throw new Error('unknown session') })
|
||||
services.sessionPersistence = { inspect: services.inspect }
|
||||
const api = buildSidechatApi(ctxWith(services))
|
||||
await expect(api['sidechat.info']({ childId: 'ghost' })).resolves.toEqual({ live: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Seed-validator integration test: the seed built by {@link buildSidechatInheritance}
|
||||
* must be accepted by the REAL dsh-session validator (`Session.create`).
|
||||
*
|
||||
* Regression: the seed copy originally stripped the event envelope's
|
||||
* `surfaceOp` marker, and the real validator rejects surface-eligible events
|
||||
* (user/message, assistant/message, tool/result) without it — the live host
|
||||
* surfaced it as "invalid seed event at index N: ... requires a surfaceOp
|
||||
* marker" during real thread creation. This test runs the ACTUAL validator,
|
||||
* not a mock, so the class of bug cannot silently return.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
|
||||
import type { SidebarSessionEvent } from '../src/context-types.ts'
|
||||
import { buildSidechatInheritance } from '../src/sidechat-core.ts'
|
||||
|
||||
/** One live-style event with the surface marker message events carry, and
|
||||
* the REAL message shapes the validator demands (id/role/source/content;
|
||||
* tool/result messages carry role 'user' + one tool-result block). */
|
||||
function ev(type: string, seq: number, data: Record<string, unknown>): SidebarSessionEvent {
|
||||
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
|
||||
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
|
||||
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
function userMessage(text: string): Record<string, unknown> {
|
||||
return { id: `m-${text}`, role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }
|
||||
}
|
||||
|
||||
function assistantMessage(text: string): Record<string, unknown> {
|
||||
return {
|
||||
id: `m-${text}`,
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'model', provider: 'test', model: 'model-x' },
|
||||
}
|
||||
}
|
||||
|
||||
function toolResultMessage(callId: string, text: string): Record<string, unknown> {
|
||||
return {
|
||||
id: `m-${callId}`,
|
||||
role: 'user',
|
||||
content: [{ type: 'tool-result', toolCallId: callId, content: [{ type: 'text', text }] }],
|
||||
source: { kind: 'tool', callId },
|
||||
}
|
||||
}
|
||||
|
||||
/** A parent log with a completed turn, a pending question, and an open
|
||||
* in-progress turn (the exact shape a mid-stream thread creation sees). */
|
||||
function parentLog(): SidebarSessionEvent[] {
|
||||
return [
|
||||
ev('user/message', 0, userMessage('first question')),
|
||||
ev('turn/start', 1, { turn: 1 }),
|
||||
ev('step/start', 2, { turn: 1, step: 1 }),
|
||||
ev('assistant/message', 3, { turn: 1, step: 1, message: assistantMessage('first answer') }),
|
||||
ev('step/end', 4, { turn: 1, step: 1 }),
|
||||
ev('turn/end', 5, { turn: 1, reason: { kind: 'completed' } }),
|
||||
ev('user/message', 6, userMessage('pending question')),
|
||||
ev('turn/start', 7, { turn: 2 }),
|
||||
ev('step/start', 8, { turn: 2, step: 1 }),
|
||||
ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'in-progress' } }),
|
||||
]
|
||||
}
|
||||
|
||||
describe('sidechat seed against the real dsh-session validator', () => {
|
||||
it('accepts a seed with a completed turn + pending user message + synthetically closed open turn', () => {
|
||||
const { seed, snapshot } = buildSidechatInheritance(parentLog())
|
||||
expect(snapshot).toBeNull()
|
||||
// The REAL validator: this throws when an envelope field was stripped
|
||||
// (the reported regression) or the turn balance is off.
|
||||
const child = Session.create('session-validator-test' as SessionId, seed as never)
|
||||
const types = child.events.map(event => event.type)
|
||||
expect(types).toEqual([
|
||||
'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end',
|
||||
'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end',
|
||||
'session/end-seed',
|
||||
])
|
||||
// The synthetic close is honest: the frozen turn ends interrupted.
|
||||
const turnEnd = child.events.findLast(event => event.type === 'turn/end')
|
||||
expect(turnEnd?.data).toEqual({ turn: 2, reason: { kind: 'interrupted' } })
|
||||
})
|
||||
|
||||
it('accepts a dangling-tool-call fallback seed (cut before the open turn)', () => {
|
||||
const log = [
|
||||
...parentLog().slice(0, 7),
|
||||
ev('turn/start', 7, { turn: 2 }),
|
||||
ev('step/start', 8, { turn: 2, step: 1 }),
|
||||
ev('tool/call', 9, { turn: 2, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep"}' }),
|
||||
]
|
||||
const { seed, snapshot } = buildSidechatInheritance(log)
|
||||
expect(snapshot).not.toBeNull()
|
||||
const child = Session.create('session-validator-fallback' as SessionId, seed as never)
|
||||
expect(child.events.map(event => event.type).at(-1)).toBe('session/end-seed')
|
||||
})
|
||||
|
||||
it('accepts the durable subagent descriptor the routes append to the seed', () => {
|
||||
// Regression guard for the catalog-corrupt fix: a cold thread WITHOUT a
|
||||
// descriptor renders as a 'corrupt' diagnostic in the host subagents.list.
|
||||
// The descriptor is a log-only event appended INSIDE the seed (before the
|
||||
// end-seed marker); the real validator must accept the combined seed.
|
||||
const { seed } = buildSidechatInheritance(parentLog())
|
||||
const descriptor = snapshotSubagentDescriptor({
|
||||
mode: 'continuable',
|
||||
provider: 'sidechat',
|
||||
label: 'Side: test',
|
||||
agentProvider: 'test',
|
||||
agentModel: 'model-x',
|
||||
})
|
||||
const withDescriptor = [
|
||||
...seed,
|
||||
{ type: 'subagent/descriptor', seq: seed.length, time: Date.now(), data: descriptor },
|
||||
]
|
||||
const child = Session.create('session-validator-descriptor' as SessionId, withDescriptor as never)
|
||||
const types = child.events.map(event => event.type)
|
||||
expect(types.at(-2)).toBe('subagent/descriptor')
|
||||
expect(types.at(-1)).toBe('session/end-seed')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Unit tests for the Side Chat transcript mapping (src/client/sidechat-
|
||||
* transcript.ts): the seed cut at session/end-seed, the boundary-row drop,
|
||||
* chunk streaming accumulation superseded by assembled messages, tool
|
||||
* call/result pairing, and orphan failed results.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SidebarHistoryEntry, SidebarSessionEvent } from '../src/context-types.ts'
|
||||
import { SIDE_BOUNDARY_PREFIX } from '../src/sidechat-core.ts'
|
||||
import { collectOwnEvents, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
|
||||
|
||||
/** One history entry (event + optional view). */
|
||||
function entry(event: SidebarSessionEvent): SidebarHistoryEntry {
|
||||
return { event }
|
||||
}
|
||||
|
||||
/** One log event fixture. Surface-eligible events carry their required
|
||||
* `surfaceOp: 'append'` marker, exactly like the live pipeline appends them. */
|
||||
function ev(type: string, seq: number, data: Record<string, unknown> = {}): SidebarSessionEvent {
|
||||
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
|
||||
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
|
||||
return { ...event, surfaceOp: 'append' } as SidebarSessionEvent
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
function textBlocks(...texts: string[]): unknown[] {
|
||||
return texts.map(text => ({ type: 'text', text }))
|
||||
}
|
||||
|
||||
describe('transcriptRows', () => {
|
||||
it('cuts the inherited seed at the last end-seed and drops the boundary row', () => {
|
||||
const entries = [
|
||||
entry(ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } })),
|
||||
entry(ev('session/end-seed', 1)),
|
||||
entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: 'user' } })),
|
||||
entry(ev('user/message', 3, { content: textBlocks('the side question'), source: { kind: 'user' } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows).toEqual([{ kind: 'user', seq: 3, text: 'the side question' }])
|
||||
})
|
||||
|
||||
it('accumulates chunk deltas per block and supersedes them on settle', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
|
||||
entry(ev('turn/start', 2, { turn: 1 })),
|
||||
entry(ev('step/start', 3, { turn: 1, step: 1 })),
|
||||
entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'Hel' } })),
|
||||
entry(ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'lo' } })),
|
||||
entry(ev('assistant/chunk', 6, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
const assistant = rows.find(row => row.kind === 'assistant') as Extract<SidechatTranscriptRow, { kind: 'assistant' }>
|
||||
expect(assistant.text).toBe('Hello')
|
||||
expect(assistant.settled).toBe(false)
|
||||
const reasoning = rows.find(row => row.kind === 'reasoning') as Extract<SidechatTranscriptRow, { kind: 'reasoning' }>
|
||||
expect(reasoning.text).toBe('think')
|
||||
})
|
||||
|
||||
it('replaces streaming rows with the settled assistant message', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
|
||||
entry(ev('turn/start', 2, { turn: 1 })),
|
||||
entry(ev('step/start', 3, { turn: 1, step: 1 })),
|
||||
entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'par' } })),
|
||||
entry(ev('assistant/message', 5, { turn: 1, step: 1, message: { content: textBlocks('final answer') } })),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
const assistants = rows.filter(row => row.kind === 'assistant')
|
||||
expect(assistants).toHaveLength(1)
|
||||
expect(assistants[0]).toMatchObject({ kind: 'assistant', text: 'final answer', settled: true })
|
||||
})
|
||||
|
||||
it('pairs tool calls with results and marks failures', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('turn/start', 1, { turn: 1 })),
|
||||
entry(ev('step/start', 2, { turn: 1, step: 1 })),
|
||||
entry(ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a"}' })),
|
||||
entry(ev('tool/result', 4, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: 'c1' },
|
||||
content: [{ type: 'tool-result', toolCallId: 'c1', isError: true, content: [{ type: 'text', text: 'denied' }] }],
|
||||
},
|
||||
error: { name: 'EACCES', code: 'EACCES' },
|
||||
})),
|
||||
]
|
||||
const rows = transcriptRows(entries)
|
||||
expect(rows).toHaveLength(1)
|
||||
const tool = rows[0]
|
||||
expect(tool).toMatchObject({
|
||||
kind: 'tool',
|
||||
name: 'read',
|
||||
args: '{"path":"a"}',
|
||||
resultText: 'denied',
|
||||
failed: true,
|
||||
executing: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a call executing until its result lands and surfaces orphan failures', () => {
|
||||
const entries = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('turn/start', 1, { turn: 1 })),
|
||||
entry(ev('step/start', 2, { turn: 1, step: 1 })),
|
||||
entry(ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{}' })),
|
||||
]
|
||||
let rows = transcriptRows(entries)
|
||||
expect(rows[0]).toMatchObject({ kind: 'tool', executing: true })
|
||||
|
||||
// Orphan failed result outside the fetched window still surfaces.
|
||||
const orphan = [
|
||||
entry(ev('session/end-seed', 0)),
|
||||
entry(ev('tool/result', 1, {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: 'gone' },
|
||||
content: [{ type: 'tool-result', toolCallId: 'gone', isError: true, content: [{ type: 'text', text: 'boom' }] }],
|
||||
},
|
||||
error: { name: 'X', code: 'X' },
|
||||
})),
|
||||
]
|
||||
rows = transcriptRows(orphan)
|
||||
expect(rows[0]).toMatchObject({ kind: 'tool', failed: true, resultText: 'boom' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectOwnEvents', () => {
|
||||
/** A paginated fake log: fetchPage(beforeSeq) returns the ≤pageSize events
|
||||
* ENDING before beforeSeq (exclusive); the tail page when omitted. */
|
||||
function pagedLog(events: SidebarSessionEvent[], pageSize: number) {
|
||||
const calls: Array<number | undefined> = []
|
||||
const fetchPage = async (beforeSeq?: number): Promise<SidebarHistoryEntry[]> => {
|
||||
calls.push(beforeSeq)
|
||||
const window = beforeSeq === undefined ? events : events.filter(e => e.seq < beforeSeq)
|
||||
return window.slice(-pageSize).map(entry)
|
||||
}
|
||||
return { fetchPage, calls }
|
||||
}
|
||||
|
||||
it('finds the seed boundary on the tail page in one fetch', async () => {
|
||||
const { fetchPage, calls } = pagedLog([
|
||||
ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } }),
|
||||
ev('session/end-seed', 1),
|
||||
ev('user/message', 2, { content: textBlocks('own'), source: { kind: 'user' } }),
|
||||
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
|
||||
], 200)
|
||||
const result = await collectOwnEvents(fetchPage)
|
||||
expect(calls).toEqual([undefined])
|
||||
expect(result.seedBoundary).toBe(1)
|
||||
expect(result.entries.map(e => e.event.seq)).toEqual([2, 3])
|
||||
})
|
||||
|
||||
it('walks back across pages until the boundary surfaces', async () => {
|
||||
// A dense chunk tail (cold reads re-expand chunk-rows): the boundary is
|
||||
// several pages back and earlier tool/call events must NOT fall out of
|
||||
// the loaded window (the re-entry tool-row regression).
|
||||
const events = [
|
||||
ev('session/end-seed', 0),
|
||||
ev('tool/call', 1, { turn: 1, step: 1, callId: 'c1', name: 'bash' }),
|
||||
...Array.from({ length: 12 }, (_, i) => ev('assistant/chunk', 2 + i, { turn: 1, step: 1 })),
|
||||
]
|
||||
const { fetchPage, calls } = pagedLog(events, 5)
|
||||
const result = await collectOwnEvents(fetchPage)
|
||||
expect(calls.length).toBeGreaterThan(1)
|
||||
expect(result.seedBoundary).toBe(0)
|
||||
// Everything after the marker, oldest-first, deduped.
|
||||
expect(result.entries.map(e => e.event.seq)).toEqual(events.slice(1).map(e => e.seq))
|
||||
})
|
||||
|
||||
it('stops at the log start when no marker exists and reports boundary 0', async () => {
|
||||
const { fetchPage } = pagedLog([
|
||||
ev('user/message', 0, { content: textBlocks('legacy'), source: { kind: 'user' } }),
|
||||
ev('assistant/message', 1, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
|
||||
], 200)
|
||||
const result = await collectOwnEvents(fetchPage)
|
||||
expect(result.seedBoundary).toBe(0)
|
||||
expect(result.entries.map(e => e.event.seq)).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('honours the page cap instead of re-walking forever', async () => {
|
||||
const events = Array.from({ length: 30 }, (_, i) => ev('assistant/chunk', i, { turn: 1, step: 1 }))
|
||||
const { fetchPage, calls } = pagedLog(events, 5)
|
||||
const result = await collectOwnEvents(fetchPage, 2)
|
||||
expect(calls).toEqual([undefined, 25])
|
||||
expect(result.seedBoundary).toBe(0)
|
||||
expect(result.entries.map(e => e.event.seq)).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
|
||||
})
|
||||
})
|
||||
+7
-1
@@ -88,7 +88,13 @@ describe('host plugin smoke', () => {
|
||||
get: () => undefined,
|
||||
}
|
||||
apply(ctx as never)
|
||||
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/bundle', '/sidebar/file', '/sidebar/html'])
|
||||
expect(routes.map(route => route.path)).toEqual([
|
||||
'/sidebar/api',
|
||||
'/sidebar/upload',
|
||||
'/sidebar/bundle',
|
||||
'/sidebar/file',
|
||||
'/sidebar/html',
|
||||
])
|
||||
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
|
||||
// Teardown runs without throwing (pty manager has nothing open).
|
||||
for (const cleanup of effects) cleanup()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { contentText, lastActivity } from '../src/client/subagent-activity.ts'
|
||||
import type { SidebarHistoryEntry } from '../src/context-types.ts'
|
||||
import { contentText, lastActivity } from '../src/subagent-activity.ts'
|
||||
import type { SidebarSessionEvent } from '../src/context-types.ts'
|
||||
|
||||
describe('subagent activity summary parser', () => {
|
||||
/** One history entry from raw event fields. */
|
||||
const entry = (type: string, data: Record<string, unknown>): SidebarHistoryEntry => ({
|
||||
event: { type, seq: 0, time: 0, data },
|
||||
/** One raw session event. */
|
||||
const entry = (type: string, data: Record<string, unknown>): SidebarSessionEvent => ({
|
||||
type, seq: 0, time: 0, data,
|
||||
})
|
||||
|
||||
it('extracts text blocks and skips non-text content', () => {
|
||||
@@ -37,7 +37,7 @@ describe('subagent activity summary parser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lastActivity keeps only the fields the tail actually has', () => {
|
||||
it('lastActivity keeps only the fields the log actually has', () => {
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }),
|
||||
])).toEqual({ tool: { name: 'bash', args: '{"command":"ls"}' } })
|
||||
@@ -64,4 +64,42 @@ describe('subagent activity summary parser', () => {
|
||||
])
|
||||
expect(live.tool).toEqual({ name: 'web', args: '' })
|
||||
})
|
||||
|
||||
it('lastActivity window excludes activity older than the last maxMessages messages', () => {
|
||||
// One stale tool call before the window: with maxMessages=1 only the
|
||||
// latest message (and the events around it) are considered.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }),
|
||||
], 1)).toEqual({ text: 'new text' })
|
||||
|
||||
// The same log with a wider window surfaces the tool call again.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'new text' }] } }),
|
||||
], 2)).toEqual({
|
||||
text: 'new text',
|
||||
tool: { name: 'bash', args: '{"command":"old"}' },
|
||||
})
|
||||
|
||||
// Non-message events (tool calls) do not consume quota, but once the
|
||||
// window holds its messages, older events are dropped even without an
|
||||
// older message to stop at: the second message pushes the tool call out.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '继续' }] }),
|
||||
], 1)).toEqual({})
|
||||
|
||||
// A tool call between the two in-window messages stays visible.
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
|
||||
entry('tool/call', { callId: 'fresh', name: 'read', arguments: '{"path":"a.ts"}' }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '继续' }] }),
|
||||
], 2)).toEqual({
|
||||
text: 'first',
|
||||
tool: { name: 'read', args: '{"path":"a.ts"}' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -75,6 +75,29 @@ describe('subagent detection over the sessions list feed', () => {
|
||||
expect(countSubagentDescendants(cyclic, 'a')).toEqual({ count: 2, runningCount: 0 })
|
||||
})
|
||||
|
||||
it('excludes Side Chat threads (subagent origin, "Side: " label) from counts', () => {
|
||||
// Side threads ride the subagent origin for list hiding + the RPC fence
|
||||
// but they are tab-strip conversations, never topology: they must not
|
||||
// fire the auto-open trigger nor inflate the Subagent page totals.
|
||||
const byId: SidebarSessionList['byId'] = {
|
||||
p1: { id: 'p1', displayTitle: 'P1' },
|
||||
c1: { id: 'c1', displayTitle: 'C1', origin: 'subagent', parentId: 'p1' },
|
||||
s1: { id: 's1', displayTitle: 'Side: refactor plan', origin: 'subagent', parentId: 'p1', running: true },
|
||||
}
|
||||
expect(directSubagentCount(byId, 'p1')).toBe(1)
|
||||
expect(countSubagentDescendants(byId, 'p1')).toEqual({ count: 1, runningCount: 0 })
|
||||
// A side thread appearing under an empty session never trips 0 → N.
|
||||
const before: SidebarSessionList = { current: 'p2', byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
|
||||
const after: SidebarSessionList = {
|
||||
current: 'p2',
|
||||
byId: {
|
||||
p2: { id: 'p2', displayTitle: 'P2' },
|
||||
s2: { id: 's2', displayTitle: 'Side: New thread', origin: 'subagent', parentId: 'p2' },
|
||||
},
|
||||
}
|
||||
expect(detectNewDirectSubagent(before, after, 'p2')).toBe(false)
|
||||
})
|
||||
|
||||
it('resolves the main-agent root of the current session tree', () => {
|
||||
const byId: SidebarSessionList['byId'] = {
|
||||
main: { id: 'main', displayTitle: 'Main' },
|
||||
|
||||
@@ -96,21 +96,24 @@ beforeEach(() => {
|
||||
killCalls.length = 0
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
const body = JSON.parse(String(init?.body)) as { sessionId: string; id: string }
|
||||
const body = JSON.parse(String(init?.body)) as { sessionId?: string; id?: string; rootSessionId?: string }
|
||||
if (method === 'subagents.live') {
|
||||
return jsonResponse({ ok: true, value: { live: {} } })
|
||||
}
|
||||
if (method === 'jobs.output') {
|
||||
outputCalls.push({ sessionId: body.sessionId, id: body.id })
|
||||
outputCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
|
||||
// bash-9 stands for a job the model never read (read:false).
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
value: {
|
||||
text: body.id === 'bash-9' ? '' : `output-of-${body.id}`,
|
||||
text: body.id === 'bash-9' ? '' : `output-of-${body.id ?? ''}`,
|
||||
truncated: false,
|
||||
read: body.id !== 'bash-9',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (method === 'jobs.kill') {
|
||||
killCalls.push({ sessionId: body.sessionId, id: body.id })
|
||||
killCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
|
||||
return jsonResponse({ ok: true, value: { ok: true, outcome: 'requested' } })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Component-level regression for the Subagent page live preview polling:
|
||||
* one shared `subagents.live` request per refresh, never a per-child
|
||||
* `subagents.history` call, and never a second in-flight request while a
|
||||
* slow host is still answering.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
import { SubagentView } from '../src/client/SubagentView.tsx'
|
||||
import type { Context, SidebarSessionList } from '../src/context-types.ts'
|
||||
|
||||
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
|
||||
function makeStore(initial: SidebarSessionList) {
|
||||
let snapshot = initial
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe: (fn: () => void) => {
|
||||
listeners.add(fn)
|
||||
return () => { listeners.delete(fn) }
|
||||
},
|
||||
set(next: SidebarSessionList) {
|
||||
snapshot = next
|
||||
for (const fn of [...listeners]) fn()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type Store = ReturnType<typeof makeStore>
|
||||
|
||||
/** The client context face SubagentView touches; history is spied for the
|
||||
* regression assertion (the new page must never call it). */
|
||||
function makeCtx(store: Store, historySpy: ReturnType<typeof vi.fn>): Context {
|
||||
return {
|
||||
sessions: {
|
||||
list: store,
|
||||
setSubagentCatalogOpen: () => {},
|
||||
openSubagent: () => {},
|
||||
open: () => {},
|
||||
refreshSubagents: async () => {},
|
||||
},
|
||||
connection: {
|
||||
api: {
|
||||
sessions: { history: historySpy },
|
||||
subagents: { history: historySpy },
|
||||
},
|
||||
},
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
/** Render `node` into a detached body container under React's act(). */
|
||||
function mount(node: ReactNode): {
|
||||
container: HTMLDivElement
|
||||
rerender: (next: ReactNode) => void
|
||||
unmount: () => void
|
||||
} {
|
||||
const container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
const root: Root = createRoot(container)
|
||||
const rerender = (next: ReactNode): void => { act(() => { root.render(next) }) }
|
||||
act(() => { root.render(node) })
|
||||
const unmount = (): void => {
|
||||
act(() => { root.unmount() })
|
||||
container.remove()
|
||||
}
|
||||
return { container, rerender, unmount }
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return { ok: true, status: 200, json: async () => value } as unknown as Response
|
||||
}
|
||||
|
||||
/** A topology snapshot with two running direct subagents and a ready catalog. */
|
||||
function runningSnapshot(): SidebarSessionList {
|
||||
return {
|
||||
current: 'root',
|
||||
byId: {
|
||||
root: { id: 'root', displayTitle: '主会话' },
|
||||
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'root', running: true },
|
||||
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'root', running: true },
|
||||
},
|
||||
subagentsByParent: {
|
||||
root: {
|
||||
entries: [
|
||||
{ kind: 'child', id: 'a', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'A' },
|
||||
{ kind: 'child', id: 'b', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'B' },
|
||||
],
|
||||
parentAvailable: true,
|
||||
state: 'ready',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
jobsBySession: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** The same tree re-rooted under a new ancestor ('grand' becomes the root). */
|
||||
function reRootedSnapshot(): SidebarSessionList {
|
||||
return {
|
||||
current: 'root',
|
||||
byId: {
|
||||
grand: { id: 'grand', displayTitle: '主会话' },
|
||||
root: { id: 'root', displayTitle: 'R', origin: 'subagent', parentId: 'grand', running: true },
|
||||
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'root', running: true },
|
||||
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'root', running: true },
|
||||
},
|
||||
subagentsByParent: {
|
||||
grand: {
|
||||
entries: [
|
||||
{ kind: 'child', id: 'root', activity: 'running', hasChildren: true, mode: 'continuable', label: 'R' },
|
||||
],
|
||||
parentAvailable: true,
|
||||
state: 'ready',
|
||||
error: null,
|
||||
},
|
||||
root: {
|
||||
entries: [
|
||||
{ kind: 'child', id: 'a', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'A' },
|
||||
{ kind: 'child', id: 'b', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'B' },
|
||||
],
|
||||
parentAvailable: true,
|
||||
state: 'ready',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
jobsBySession: {},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
for (const el of document.querySelectorAll('body > div')) el.remove()
|
||||
})
|
||||
|
||||
describe('SubagentView live polling', () => {
|
||||
it('sends one subagents.live per poll and never calls per-child history', async () => {
|
||||
vi.useFakeTimers()
|
||||
const historySpy = vi.fn()
|
||||
const liveCalls: string[] = []
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
return jsonResponse({ ok: true, value: { live: {} } })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
const store = makeStore(runningSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
|
||||
)
|
||||
// Initial load plus the first scheduled tick.
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
expect(historySpy).not.toHaveBeenCalled()
|
||||
expect(container.textContent).toContain('思考中…')
|
||||
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
expect(liveCalls).toEqual(['root', 'root'])
|
||||
expect(historySpy).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('never starts a second request while one is in flight', async () => {
|
||||
vi.useFakeTimers()
|
||||
const historySpy = vi.fn()
|
||||
const liveCalls: string[] = []
|
||||
let resolveFirst: ((response: Response) => void) | undefined
|
||||
vi.stubGlobal('fetch', (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
return new Promise<Response>((resolve) => { resolveFirst = resolve })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
const store = makeStore(runningSnapshot())
|
||||
const { unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
expect(resolveFirst).toBeTypeOf('function')
|
||||
|
||||
// While the first request is pending, no timer can fire a second one.
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
|
||||
// Settle the first request; only then does the next 3s tick fire.
|
||||
await act(async () => {
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: {} } }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
|
||||
expect(liveCalls).toEqual(['root', 'root'])
|
||||
expect(historySpy).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('stops polling and drops the in-flight request when the page hides', async () => {
|
||||
vi.useFakeTimers()
|
||||
const historySpy = vi.fn()
|
||||
const liveCalls: string[] = []
|
||||
let resolveFirst: ((response: Response) => void) | undefined
|
||||
vi.stubGlobal('fetch', (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
return new Promise<Response>((resolve) => { resolveFirst = resolve })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
const store = makeStore(runningSnapshot())
|
||||
const { rerender, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
|
||||
// Hide the page: the effect cleanup aborts the in-flight request and no
|
||||
// timer may fire afterwards.
|
||||
rerender(createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store, historySpy) }))
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
|
||||
// Settling the stale response must neither render nor schedule a poll.
|
||||
await act(async () => {
|
||||
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: { text: 'stale' } } } }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
expect(historySpy).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('clears the live map and re-polls when the topology root changes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const historySpy = vi.fn()
|
||||
const liveCalls: string[] = []
|
||||
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const method = String(url).split('/').pop()
|
||||
if (method === 'subagents.live') {
|
||||
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
|
||||
liveCalls.push(body.rootSessionId ?? '')
|
||||
const live = body.rootSessionId === 'root' ? { a: { text: 'hello' } } : {}
|
||||
return jsonResponse({ ok: true, value: { live } })
|
||||
}
|
||||
throw new Error(`unexpected fetch ${String(url)}`)
|
||||
})
|
||||
|
||||
const store = makeStore(runningSnapshot())
|
||||
const { container, unmount } = mount(
|
||||
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
|
||||
)
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root'])
|
||||
expect(container.textContent).toContain('hello')
|
||||
|
||||
// The tree is re-rooted under a new ancestor: old rows must not leak.
|
||||
store.set(reRootedSnapshot())
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(liveCalls).toEqual(['root', 'grand'])
|
||||
expect(container.textContent).not.toContain('hello')
|
||||
expect(historySpy).not.toHaveBeenCalled()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Host route tests for the Subagent live-preview batch API ('subagents.live').
|
||||
* The route must enumerate the tree ONCE, keep only catalog-running
|
||||
* non-Side-Chat children, fold only non-empty activity from their session
|
||||
* logs, and degrade to a 503 when the host subagent runtime is absent.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { buildSubagentLiveApi } from '../src/subagent-live-route.ts'
|
||||
import { SidebarError } from '../src/wire.ts'
|
||||
import type {
|
||||
Context,
|
||||
SidebarSessionEvent,
|
||||
SidebarSubagentDescendantEntry,
|
||||
SidebarSubagentsService,
|
||||
} from '../src/context-types.ts'
|
||||
|
||||
/** One child descendant row. */
|
||||
function child(
|
||||
id: string,
|
||||
over: Partial<Extract<SidebarSubagentDescendantEntry, { kind: 'child' }>> = {},
|
||||
): SidebarSubagentDescendantEntry {
|
||||
return {
|
||||
kind: 'child',
|
||||
id,
|
||||
activity: 'running',
|
||||
hasChildren: false,
|
||||
mode: 'one-shot',
|
||||
parentId: 'root',
|
||||
depth: 1,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
/** One diagnostic descendant row. */
|
||||
function diagnostic(id: string): SidebarSubagentDescendantEntry {
|
||||
return { kind: 'diagnostic', id, reason: 'corrupt', parentId: 'root', depth: 1 }
|
||||
}
|
||||
|
||||
/** A session with the given raw events. */
|
||||
function session(events: SidebarSessionEvent[]): { header: { cwd: string }; events: SidebarSessionEvent[] } {
|
||||
return { header: { cwd: '/p' }, events }
|
||||
}
|
||||
|
||||
/** A context whose `get` serves only the subagents face, with a session store. */
|
||||
function ctxWith(subagents: unknown, sessions: unknown): Context {
|
||||
return {
|
||||
sessions,
|
||||
get: (key: string) => (key === 'subagents' ? subagents : undefined),
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
describe('subagents.live route', () => {
|
||||
it('returns non-empty activity for running children only', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [
|
||||
child('running-a', { label: 'A' }),
|
||||
child('running-b', { label: 'B' }),
|
||||
child('inactive', { activity: 'inactive', label: 'C' }),
|
||||
child('side-chat', { label: 'Side: chat' }),
|
||||
diagnostic('corrupt-row'),
|
||||
]),
|
||||
}
|
||||
const sessions = {
|
||||
get: (id: string) => {
|
||||
if (id === 'running-a') {
|
||||
return session([
|
||||
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'hello' }] } } },
|
||||
])
|
||||
}
|
||||
if (id === 'running-b') {
|
||||
return session([
|
||||
{ type: 'tool/call', seq: 0, time: 0, data: { name: 'bash', arguments: '{"command":"ls"}' } },
|
||||
])
|
||||
}
|
||||
return session([])
|
||||
},
|
||||
}
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: {
|
||||
'running-a': { text: 'hello' },
|
||||
'running-b': { tool: { name: 'bash', args: '{"command":"ls"}' } },
|
||||
},
|
||||
})
|
||||
expect(subagents.listDescendants).toHaveBeenCalledWith('root')
|
||||
})
|
||||
|
||||
it('omits children with no text/tool yet', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [child('empty', { label: 'Empty' })]),
|
||||
}
|
||||
const sessions = { get: () => session([]) }
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({ live: {} })
|
||||
})
|
||||
|
||||
it('folds only activity inside the recent 12-message window', async () => {
|
||||
// One stale tool call sits before 13 user messages; the recent window
|
||||
// (the tail's last 12 messages) must not surface it.
|
||||
const staleTool: SidebarSessionEvent = {
|
||||
type: 'tool/call', seq: 0, time: 0,
|
||||
data: { callId: 'stale', name: 'bash', arguments: '{"command":"old"}' },
|
||||
}
|
||||
const oldMessages: SidebarSessionEvent[] = Array.from({ length: 13 }, (_, i) => ({
|
||||
type: 'user/message', seq: i + 1, time: 0,
|
||||
data: { content: [{ type: 'text', text: `m${i}` }] },
|
||||
}))
|
||||
const recentText: SidebarSessionEvent = {
|
||||
type: 'assistant/message', seq: 99, time: 0,
|
||||
data: { message: { content: [{ type: 'text', text: 'recent' }] } },
|
||||
}
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [child('windowed', { label: 'W' })]),
|
||||
}
|
||||
const sessions = { get: () => session([staleTool, ...oldMessages, recentText]) }
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: { windowed: { text: 'recent' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('skips a child whose session log is unavailable without failing the batch', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => [
|
||||
child('good', { label: 'Good' }),
|
||||
child('bad', { label: 'Bad' }),
|
||||
]),
|
||||
}
|
||||
const sessions = {
|
||||
get: (id: string) => {
|
||||
if (id === 'good') {
|
||||
return session([
|
||||
{ type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [{ type: 'text', text: 'ok' }] } } },
|
||||
])
|
||||
}
|
||||
throw new Error('missing')
|
||||
},
|
||||
}
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, sessions))
|
||||
await expect(api.live({ rootSessionId: 'root' })).resolves.toEqual({
|
||||
live: { good: { text: 'ok' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('degrades to a 503 when the subagent runtime is absent', async () => {
|
||||
const api = buildSubagentLiveApi(ctxWith(undefined, { get: () => undefined }))
|
||||
await expect(api.live({ rootSessionId: 'root' })).rejects.toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'subagents-unavailable', status: 503 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('degrades to a 503 when listDescendants fails', async () => {
|
||||
const subagents: SidebarSubagentsService = {
|
||||
listDescendants: vi.fn(async () => { throw new Error('projection unavailable') }),
|
||||
}
|
||||
const api = buildSubagentLiveApi(ctxWith(subagents, { get: () => undefined }))
|
||||
await expect(api.live({ rootSessionId: 'root' })).rejects.toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'subagents-unavailable', status: 503 }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a missing rootSessionId as bad-request', async () => {
|
||||
const api = buildSubagentLiveApi(ctxWith(undefined, { get: () => undefined }))
|
||||
await expect(api.live({})).rejects.toThrowError(
|
||||
expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* The title-bar strip resolution chain: standard WCO geometry first
|
||||
* (authoritative even when 0), then the documented URL inset contract
|
||||
* parameter, then the opt-in preset / custom scheme — and 0 (plain web,
|
||||
* nothing modified) otherwise. No per-shell branch lives in the core.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { computeTitleBarStrip } from '../src/client/titlebar-strip.ts'
|
||||
import type { DesktopEnv } from '../src/client/desktop-env.ts'
|
||||
import type { WcoSnapshot } from '../src/client/wco.ts'
|
||||
import type { ShellPreset } from '../src/client/shell-presets.ts'
|
||||
|
||||
const env = (partial: Partial<DesktopEnv>): DesktopEnv => ({
|
||||
desktop: false,
|
||||
mode: null,
|
||||
platform: null,
|
||||
titlebarInset: 0,
|
||||
...partial,
|
||||
})
|
||||
|
||||
const wco = (present: boolean, height = 0): WcoSnapshot => ({ present, height })
|
||||
|
||||
const noPreset: ShellPreset | undefined = undefined
|
||||
|
||||
describe('computeTitleBarStrip', () => {
|
||||
it('keeps plain web untouched: no WCO, no inset, no scheme → 0', () => {
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'auto', noPreset, 40)).toBe(0)
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'custom', noPreset, 40)).toBe(40)
|
||||
})
|
||||
|
||||
it('the explicit WEB scheme forces 0 — not even standard WCO geometry applies', () => {
|
||||
const desktop = env({ desktop: true, mode: 'advanced', platform: 'win32' })
|
||||
// The user declared "DSH official web": no adaptation at all, even when
|
||||
// a real overlay exists or a preset is active.
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'web', noPreset, 40)).toBe(0)
|
||||
expect(computeTitleBarStrip(desktop, wco(false), 'web', noPreset, 40)).toBe(0)
|
||||
expect(computeTitleBarStrip(desktop, wco(false), 'web', {
|
||||
id: 't', title: 't', desc: '', stripFor: () => 20,
|
||||
}, 40)).toBe(0)
|
||||
})
|
||||
|
||||
it('trusts the standard WCO geometry first in every scheme (even 0 — e.g. maximized)', () => {
|
||||
const desktop = env({ desktop: true, mode: 'advanced', platform: 'win32' })
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'auto', noPreset, 0)).toBe(36)
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'preset', noPreset, 0)).toBe(36)
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 36), 'custom', noPreset, 40)).toBe(36)
|
||||
// Authoritative zero: the overlay is hidden while maximized — no strip.
|
||||
expect(computeTitleBarStrip(desktop, wco(true, 0), 'auto', noPreset, 40)).toBe(0)
|
||||
})
|
||||
|
||||
it('applies the documented URL inset contract parameter before any scheme', () => {
|
||||
const stamped = env({ desktop: true, mode: 'advanced', platform: 'darwin', titlebarInset: 20 })
|
||||
expect(computeTitleBarStrip(stamped, wco(false), 'auto', noPreset, 0)).toBe(20)
|
||||
expect(computeTitleBarStrip(stamped, wco(false), 'preset', noPreset, 0)).toBe(20)
|
||||
})
|
||||
|
||||
it('uses the opt-in preset strip under the preset scheme only', () => {
|
||||
const preset: ShellPreset = {
|
||||
id: 't', title: 't', desc: '',
|
||||
stripFor: (e) => e.platform === 'darwin' ? 20 : undefined,
|
||||
}
|
||||
const darwin = env({ desktop: true, mode: 'advanced', platform: 'darwin' })
|
||||
expect(computeTitleBarStrip(darwin, wco(false), 'preset', preset, 0)).toBe(20)
|
||||
// Auto never applies the preset — only standard signals.
|
||||
expect(computeTitleBarStrip(darwin, wco(false), 'auto', preset, 0)).toBe(0)
|
||||
// Custom never applies the preset either.
|
||||
expect(computeTitleBarStrip(darwin, wco(false), 'custom', preset, 0)).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the manual strip px under the custom scheme only', () => {
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'custom', noPreset, 56)).toBe(56)
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'auto', noPreset, 56)).toBe(0)
|
||||
expect(computeTitleBarStrip(env({}), wco(false), 'preset', noPreset, 56)).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api, SidebarApiError, type SessionScope } from '../src/client/api.ts'
|
||||
import {
|
||||
MAX_UPLOAD_BYTES, summarizeResults, uploadHintText, uploadItemsFromDrop, uploadItemsFromFiles,
|
||||
uploadToDir, type UploadItem,
|
||||
} from '../src/client/upload.ts'
|
||||
|
||||
/** Fake t(): returns the key (and params) so assertions see which copy fired. */
|
||||
const t: Parameters<typeof summarizeResults>[1] = (key, params) =>
|
||||
params === undefined ? key : `${key}:${JSON.stringify(params)}`
|
||||
|
||||
const scope: SessionScope = { sessionId: 's', cwd: '/ws' }
|
||||
|
||||
/** A File with an optional webkitRelativePath (folder entries). */
|
||||
function fileOf(name: string, rel?: string): File {
|
||||
const file = new File(['x'], name)
|
||||
if (rel !== undefined) Object.defineProperty(file, 'webkitRelativePath', { value: rel })
|
||||
return file
|
||||
}
|
||||
|
||||
const items = (names: string[]): UploadItem[] =>
|
||||
names.map(name => ({ file: fileOf(name), relativePath: name }))
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('uploadItemsFromFiles', () => {
|
||||
it('keeps plain file names as their relative path', () => {
|
||||
expect(uploadItemsFromFiles([fileOf('a.txt')])).toEqual([{ file: expect.any(File), relativePath: 'a.txt' }])
|
||||
})
|
||||
|
||||
it('keeps the directory structure of folder entries', () => {
|
||||
expect(uploadItemsFromFiles([fileOf('a.txt', 'docs/nested/a.txt')]))
|
||||
.toEqual([{ file: expect.any(File), relativePath: 'docs/nested/a.txt' }])
|
||||
})
|
||||
|
||||
it('rejects absolute paths (POSIX, Windows separators, drive letters)', () => {
|
||||
expect(uploadItemsFromFiles([
|
||||
fileOf('a', '/abs.txt'),
|
||||
fileOf('a', '\\abs.txt'),
|
||||
fileOf('a', 'C:/abs.txt'),
|
||||
])).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects traversal and empty segments', () => {
|
||||
expect(uploadItemsFromFiles([
|
||||
fileOf('a', 'a/../b.txt'),
|
||||
fileOf('a', './a.txt'),
|
||||
fileOf('a', 'a//b.txt'),
|
||||
])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadItemsFromDrop', () => {
|
||||
/** A DataTransfer stub: flat file list, no entry API. */
|
||||
const dataOf = (files: File[], items: unknown[] = []): DataTransfer =>
|
||||
({ files, items }) as unknown as DataTransfer
|
||||
/** A dropped file entry stub (webkitGetAsEntry world). */
|
||||
const fileEntryOf = (name: string): FileSystemEntry =>
|
||||
({
|
||||
isFile: true, isDirectory: false, name,
|
||||
file: (ok: (file: File) => void) => { ok(new File(['x'], name)) },
|
||||
}) as unknown as FileSystemEntry
|
||||
/** A dropped directory entry stub: readEntries serves one batch, then empty. */
|
||||
const dirEntryOf = (name: string, children: FileSystemEntry[]): FileSystemEntry =>
|
||||
({
|
||||
isFile: false, isDirectory: true, name,
|
||||
createReader: () => {
|
||||
let drained = false
|
||||
return {
|
||||
readEntries: (ok: (entries: FileSystemEntry[]) => void) => {
|
||||
ok(drained ? [] : children)
|
||||
drained = true
|
||||
},
|
||||
}
|
||||
},
|
||||
}) as unknown as FileSystemEntry
|
||||
const itemOf = (entry: FileSystemEntry): unknown => ({ kind: 'file', webkitGetAsEntry: () => entry })
|
||||
|
||||
it('falls back to the flat file list when the entry API is unavailable', async () => {
|
||||
const data = dataOf([fileOf('a.txt'), fileOf('b.txt', 'dir/b.txt')])
|
||||
const items = await uploadItemsFromDrop(data)
|
||||
expect(items.map(item => item.relativePath)).toEqual(['a.txt', 'dir/b.txt'])
|
||||
})
|
||||
|
||||
it('returns nothing for an empty drop', async () => {
|
||||
expect(await uploadItemsFromDrop(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('traverses dropped directories, keeping every nested relative path', async () => {
|
||||
const tree = dirEntryOf('docs', [
|
||||
fileEntryOf('a.txt'),
|
||||
dirEntryOf('nested', [fileEntryOf('b.txt')]),
|
||||
])
|
||||
const data = dataOf([], [itemOf(fileEntryOf('top.txt')), itemOf(tree)])
|
||||
const items = await uploadItemsFromDrop(data)
|
||||
expect(items.map(item => item.relativePath)).toEqual(['top.txt', 'docs/a.txt', 'docs/nested/b.txt'])
|
||||
})
|
||||
|
||||
it('skips an unreadable entry instead of failing the whole drop', async () => {
|
||||
const broken = {
|
||||
isFile: true, isDirectory: false, name: 'bad.txt',
|
||||
file: (_ok: unknown, fail: (error: Error) => void) => { fail(new Error('denied')) },
|
||||
} as unknown as FileSystemEntry
|
||||
const data = dataOf([], [itemOf(broken), itemOf(fileEntryOf('good.txt'))])
|
||||
const items = await uploadItemsFromDrop(data)
|
||||
expect(items.map(item => item.relativePath)).toEqual(['good.txt'])
|
||||
})
|
||||
|
||||
it('sanitizes entry-derived paths (absolute and traversal segments rejected)', async () => {
|
||||
const data = dataOf([], [itemOf(dirEntryOf('..', [fileEntryOf('evil.txt')]))])
|
||||
expect(await uploadItemsFromDrop(data)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadHintText', () => {
|
||||
it('shows the target directory while no file is in flight', () => {
|
||||
expect(uploadHintText(0, 3, '', '/ws', t)).toBe('uploadingTo:{"dir":"/ws"}')
|
||||
})
|
||||
|
||||
it('shows per-file progress once a file is being uploaded', () => {
|
||||
expect(uploadHintText(1, 3, 'docs/a.txt', '/ws', t)).toBe('uploadProgress:{"done":1,"total":3,"name":"docs/a.txt"}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('summarizeResults', () => {
|
||||
it('reports the uploaded count when everything succeeded', () => {
|
||||
expect(summarizeResults([
|
||||
{ relativePath: 'a.txt', ok: true, path: '/ws/a.txt' },
|
||||
{ relativePath: 'b.txt', ok: true, path: '/ws/b.txt' },
|
||||
], t)).toBe('uploadDone:{"count":2}')
|
||||
})
|
||||
|
||||
it('localizes the too-large wire code instead of showing the raw message', () => {
|
||||
expect(summarizeResults([
|
||||
{ relativePath: 'big.bin', ok: false, code: 'too-large', error: 'upload exceeds the 134217728 byte limit' },
|
||||
], t)).toBe('uploadFailed:{"error":"uploadTooLarge"}')
|
||||
})
|
||||
|
||||
it('passes through other failures raw, falling back to the unknown copy', () => {
|
||||
expect(summarizeResults([
|
||||
{ relativePath: 'a.txt', ok: false, error: 'boom' },
|
||||
], t)).toBe('uploadFailed:{"error":"boom"}')
|
||||
expect(summarizeResults([
|
||||
{ relativePath: 'a.txt', ok: false, code: 'internal' },
|
||||
], t)).toBe('uploadFailed:{"error":"uploadFailedUnknown"}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadToDir', () => {
|
||||
it('uploads every item sequentially and reports progress', async () => {
|
||||
const spy = vi.spyOn(api, 'uploadFile')
|
||||
.mockResolvedValueOnce({ path: '/ws/a.txt', size: 1 })
|
||||
.mockResolvedValueOnce({ path: '/ws/b.txt', size: 1 })
|
||||
const progress: Array<[number, number, string]> = []
|
||||
const results = await uploadToDir(scope, '/ws', items(['a.txt', 'b.txt']), (done, total, current) => {
|
||||
progress.push([done, total, current])
|
||||
})
|
||||
expect(results).toEqual([
|
||||
{ relativePath: 'a.txt', ok: true, path: '/ws/a.txt' },
|
||||
{ relativePath: 'b.txt', ok: true, path: '/ws/b.txt' },
|
||||
])
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
expect(progress).toEqual([
|
||||
[0, 2, 'a.txt'],
|
||||
[1, 2, 'b.txt'],
|
||||
[2, 2, ''],
|
||||
])
|
||||
})
|
||||
|
||||
it('pre-checks the client cap without calling the route', async () => {
|
||||
const spy = vi.spyOn(api, 'uploadFile')
|
||||
const big = fileOf('big.bin')
|
||||
Object.defineProperty(big, 'size', { value: MAX_UPLOAD_BYTES + 1 })
|
||||
const results = await uploadToDir(scope, '/ws', [{ file: big, relativePath: 'big.bin' }])
|
||||
expect(results).toEqual([{ relativePath: 'big.bin', ok: false, code: 'too-large' }])
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the wire error code from the route', async () => {
|
||||
vi.spyOn(api, 'uploadFile').mockRejectedValueOnce(new SidebarApiError('forbidden', 'target escapes the session workspace'))
|
||||
const results = await uploadToDir(scope, '/ws', items(['a.txt']))
|
||||
expect(results).toEqual([{
|
||||
relativePath: 'a.txt', ok: false, code: 'forbidden', error: 'target escapes the session workspace',
|
||||
}])
|
||||
})
|
||||
|
||||
it('an already-aborted signal uploads nothing', async () => {
|
||||
const spy = vi.spyOn(api, 'uploadFile')
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const results = await uploadToDir(scope, '/ws', items(['a.txt', 'b.txt']), undefined, controller.signal)
|
||||
expect(results).toEqual([])
|
||||
expect(spy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('an abort mid-flight keeps completed files and stops the queue', async () => {
|
||||
const spy = vi.spyOn(api, 'uploadFile')
|
||||
.mockResolvedValueOnce({ path: '/ws/a.txt', size: 1 })
|
||||
.mockRejectedValueOnce(new DOMException('aborted', 'AbortError'))
|
||||
const controller = new AbortController()
|
||||
const results = await uploadToDir(scope, '/ws', items(['a.txt', 'b.txt', 'c.txt']), undefined, controller.signal)
|
||||
expect(results).toEqual([{ relativePath: 'a.txt', ok: true, path: '/ws/a.txt' }])
|
||||
// a resolved, b was aborted mid-flight, c never started.
|
||||
expect(spy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Window Controls Overlay (WCO) geometry store tests: feature detection,
|
||||
* subscribe/getSnapshot, geometrychange re-reads, and the absent-API
|
||||
* fallback (the conservative "auto" signal — plain browsers and non-overlay
|
||||
* shells report NONE and the sidebar adapts nothing).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import './browser-globals.ts'
|
||||
import {
|
||||
getWcoSnapshot, setWcoSourceForTests, subscribeWco, WCO_NONE,
|
||||
type WcoSource,
|
||||
} from '../src/client/wco.ts'
|
||||
|
||||
/** A controllable fake of the standard API surface. */
|
||||
function makeSource(initial: { width: number; height: number; visible?: boolean }): {
|
||||
source: WcoSource
|
||||
setRect: (width: number, height: number) => void
|
||||
fireGeometryChange: () => void
|
||||
listeners: Set<() => void>
|
||||
} {
|
||||
const listeners = new Set<() => void>()
|
||||
let rect = { x: 0, y: 0, width: initial.width, height: initial.height }
|
||||
const source: WcoSource = {
|
||||
visible: initial.visible ?? true,
|
||||
getTitlebarAreaRect: () => rect,
|
||||
addEventListener: (type, listener) => { if (type === 'geometrychange') listeners.add(listener) },
|
||||
removeEventListener: (type, listener) => { if (type === 'geometrychange') listeners.delete(listener) },
|
||||
}
|
||||
return {
|
||||
source,
|
||||
setRect: (width, height) => { rect = { x: 0, y: 0, width, height } },
|
||||
fireGeometryChange: () => { for (const listener of listeners) listener() },
|
||||
listeners,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setWcoSourceForTests(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setWcoSourceForTests(undefined)
|
||||
})
|
||||
|
||||
describe('wco geometry store', () => {
|
||||
it('reports NONE (absent API) in plain browsers / non-overlay shells', () => {
|
||||
expect(getWcoSnapshot()).toBe(WCO_NONE)
|
||||
expect(getWcoSnapshot().present).toBe(false)
|
||||
expect(getWcoSnapshot().height).toBe(0)
|
||||
})
|
||||
|
||||
it('reads the real overlay rect once a source is present', () => {
|
||||
const { source } = makeSource({ width: 138, height: 36 })
|
||||
setWcoSourceForTests(source)
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 36 })
|
||||
})
|
||||
|
||||
it('rounds fractional overlay heights to whole pixels', () => {
|
||||
const { source } = makeSource({ width: 100, height: 35.6 })
|
||||
setWcoSourceForTests(source)
|
||||
expect(getWcoSnapshot().height).toBe(36)
|
||||
})
|
||||
|
||||
it('re-reads geometry on geometrychange (maximize/restore) and notifies subscribers', () => {
|
||||
const { source, setRect, fireGeometryChange, listeners } = makeSource({ width: 138, height: 32 })
|
||||
setWcoSourceForTests(source)
|
||||
const calls: number[] = []
|
||||
const off = subscribeWco(() => { calls.push(getWcoSnapshot().height) })
|
||||
// Maximized: the overlay reports a zero/empty rect.
|
||||
setRect(0, 0)
|
||||
fireGeometryChange()
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 0 })
|
||||
expect(calls).toEqual([0])
|
||||
// Restored.
|
||||
setRect(138, 36)
|
||||
fireGeometryChange()
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 36 })
|
||||
expect(calls).toEqual([0, 36])
|
||||
// The disposer stops notifications.
|
||||
off()
|
||||
setRect(138, 40)
|
||||
fireGeometryChange()
|
||||
expect(calls).toEqual([0, 36])
|
||||
expect(listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
it('treats a PHANTOM API (present but not visible) as absent — headless/macOS expose the interface with no overlay', () => {
|
||||
const { source } = makeSource({ width: 0, height: 0, visible: false })
|
||||
setWcoSourceForTests(source)
|
||||
// present=false lets the strip chain fall through to the inset / preset
|
||||
// / custom scheme instead of trusting an empty rect.
|
||||
expect(getWcoSnapshot()).toEqual({ present: false, height: 0 })
|
||||
// A real overlay flips to authoritative immediately.
|
||||
const real = makeSource({ width: 138, height: 36, visible: true })
|
||||
setWcoSourceForTests(real.source)
|
||||
expect(getWcoSnapshot()).toEqual({ present: true, height: 36 })
|
||||
})
|
||||
|
||||
it('treats a throwing API as absent (never breaks the layout)', () => {
|
||||
const source: WcoSource = {
|
||||
visible: true,
|
||||
getTitlebarAreaRect: () => { throw new Error('racy API') },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}
|
||||
setWcoSourceForTests(source)
|
||||
expect(getWcoSnapshot()).toEqual({ present: false, height: 0 })
|
||||
})
|
||||
|
||||
it('re-attaches cleanly when the source is swapped (test hook)', () => {
|
||||
const first = makeSource({ width: 138, height: 32 })
|
||||
const second = makeSource({ width: 138, height: 36 })
|
||||
setWcoSourceForTests(first.source)
|
||||
expect(first.listeners.size).toBe(0) // no subscriber yet
|
||||
const off = subscribeWco(() => {})
|
||||
expect(first.listeners.size).toBe(1)
|
||||
setWcoSourceForTests(second.source)
|
||||
expect(first.listeners.size).toBe(0)
|
||||
expect(second.listeners.size).toBe(1)
|
||||
expect(getWcoSnapshot().height).toBe(36)
|
||||
off()
|
||||
setWcoSourceForTests(undefined)
|
||||
expect(getWcoSnapshot()).toBe(WCO_NONE)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user