Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61b18cbefe | ||
|
|
1064ce954f | ||
|
|
eb5059574e | ||
|
|
a6befc8b6b | ||
|
|
4060bd53e1 | ||
|
|
a1286191d6 | ||
|
|
9b00135de4 | ||
|
|
06440d0365 | ||
|
|
c4851dceb7 |
@@ -142,3 +142,6 @@ vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
lib/
|
||||
|
||||
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
|
||||
registry/
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
# dsh-better-sidebar
|
||||
|
||||
> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件 —— 文件管理、编辑预览、真实终端、Git 面板,一个面板全部搞定。
|
||||
> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件 —— 文件管理、编辑预览、真实终端、Git 面板、Subagent 页面,一个面板全部搞定。
|
||||
|
||||
<img width="4632" height="2720" alt="image" src="https://github.com/user-attachments/assets/39d86636-7654-412f-86ea-c60a2d5f20f0" />
|
||||
<img width="990" height="818" alt="image" src="https://github.com/user-attachments/assets/27b9c9b9-3799-4e63-8169-dd4a3dc8b2e1" />
|
||||
<img width="1000" height="1186" alt="image" src="https://github.com/user-attachments/assets/9dadffe0-0738-4b6d-b929-f452f51768a2" />
|
||||
|
||||
## 🚀 通过 plugin-registry 安装(标准)
|
||||
|
||||
前置条件:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 命令可用;集成步骤见其 README)。
|
||||
|
||||
```sh
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git
|
||||
cd DSH-better-sidebar
|
||||
pnpm install && pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存目录(只含清单 + 构建产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用——信任边界)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar # 启用
|
||||
```
|
||||
|
||||
> **通道互斥**:registry 安装与下方「不依赖 plugin-registry 的官方 profile 安装」是同一插件的两种安装方式,**每个部署二选一**——同时启用会双挂载(Node 半挂两次、页面渲染两个侧边栏)。已用官方方式装过时,先移除 `~/.dsh/profiles/web/cordis.patch.yml` 里的 better-sidebar 挂载行与 `package.json` 的 `link:` 依赖,再走 registry。
|
||||
|
||||
### 更新(registry 通道)
|
||||
|
||||
```sh
|
||||
cd DSH-better-sidebar && git pull && pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs
|
||||
dsh registry uninstall dsh-external/dsh-better-sidebar
|
||||
dsh registry install ./registry
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
## 🚀 一键安装(把提示词发给 DSH)
|
||||
|
||||
@@ -29,6 +53,25 @@
|
||||
|
||||
> 安装 = 把插件登记进 web profile 的依赖清单(等价于 `dsh plugin --profile web add link:<路径>`)+ 一行 cordis 挂载行,**与 portal 无关**——portal 只指侧边栏面板在页面上的渲染方式(见下文[规范符合性](#规范符合性))。
|
||||
|
||||
### 更新(同样把提示词发给 DSH)
|
||||
|
||||
已安装过(`link:` 引用)时,把下面提示词**整段**发给 DSH 即可更新到最新版:
|
||||
|
||||
```text
|
||||
请帮我更新 dsh-better-sidebar 插件(仓库在 ~/Code/DSH-better-sidebar,已通过 link: 安装到我的 web profile):
|
||||
|
||||
1. 拉取最新代码并重新构建:
|
||||
cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build
|
||||
(若 pnpm install 因 @deepseek-ai/* 的 link: 依赖解析失败,说明 DSH 源码 checkout 不在 ~/.dsh/source/current —— 停下来告诉我,不要继续)
|
||||
2. 核对注册仍然有效(缺失才需要补):
|
||||
a. ~/.dsh/profiles/web/package.json 的 dependencies 中含 "dsh-better-sidebar": "link:<仓库目录的绝对路径>"
|
||||
b. ~/.dsh/profiles/web/cordis.patch.yml 中含挂载行(id: better-sidebar, name: 'dsh-better-sidebar')
|
||||
c. 若有缺失,补齐后在该 profile 目录执行 pnpm install
|
||||
3. 完成后告诉我本次改动涉及 client 还是 host:
|
||||
- 仅 client(src/client/*)→ 我硬刷新(Cmd/Ctrl+Shift+R)即可
|
||||
- 含 host(src/index.ts、src/config.ts 等)→ 我需要重启 DSH 再硬刷新
|
||||
```
|
||||
|
||||
## 为什么用它
|
||||
|
||||
- **⚡ 工作流不断档**:编辑文件、跑终端、看 Git 状态、拖 Tab 分栏对比,全在对话旁边完成,无需切窗口
|
||||
@@ -51,6 +94,12 @@
|
||||
- **CodeMirror 6**:自动换行 + 按扩展名语法高亮(js/ts/jsx/tsx、json、python、html、css、xml、yaml、sql、java、c/c++、rust、go、php、shell、toml、nginx、dockerfile、properties…)
|
||||
- 脏点标记 + **Ctrl/Cmd+S 保存**(原子写入);512KB 截断横幅、二进制文件提示
|
||||
- 图片直接预览;**Markdown 预览/编辑切换**,预览实时渲染未保存草稿
|
||||
- **PDF 原生预览**:使用浏览器内置 PDF 查看器,预览区始终保留下载入口
|
||||
- **Office 三件套预览**:
|
||||
- `.docx` — 保真渲染(保留样式 / 页眉页脚 / 图片 / 表格),基于 [docx-preview](https://github.com/VolodymyrBaydalka/docxjs)
|
||||
- `.xlsx` — 完整电子表格(多 sheet / 公式计算 / 列宽行高 / 合并单元格),基于 [Univer](https://univer.ai) + [SheetJS](https://sheetjs.com)
|
||||
- `.pptx` — 高保真幻灯片预览(图片 / 表格 / 图表 / SmartArt)+ 上一页 / 下一页导航,基于 [pptx-renderer](https://github.com/aiden0z/pptx-renderer)
|
||||
- `.doc` / `.xls` / `.ppt` 及其他二进制 — 下载按钮(旧 OLE 格式纯前端无成熟渲染库)
|
||||
- 切换 Tab 不卸载:编辑器保留草稿与撤销栈
|
||||
|
||||
### 💻 终端
|
||||
@@ -63,6 +112,13 @@
|
||||
|
||||
- status 暂存/未暂存分组、按文件 diff(HEAD vs 工作区/暂存区)、stage/unstage(单个与全部)、commit、分支切换、提交历史
|
||||
|
||||
### 🧩 Subagent 页面
|
||||
|
||||
- **主会话 agent 拓扑(分层显示)**:`+` 菜单(或空分栏卡片)打开 Subagent 标签页,显示**主会话(主代理)的完整 agent 拓扑**——主代理作为根节点卡片置顶(**点击它直接回到主代理会话**),其**所有层级的子代理**(含子代理再派生的后代)从它下面按层级展开,树连接线 + 逐级缩进区分层级;无论当前选中/跳转到多深的子代理,所有子代理都共享这一个主会话拓扑视图
|
||||
- **点击直达执行记录**:点击子代理节点直接跳转到该子代理执行记录(官方 `openSubagent`);**跳转后 Subagent 页面保持打开**,主会话拓扑不变,当前所在节点**高亮**(`interactive-bg-active` 选中填充,同官方会话行)
|
||||
- **运行中实时状态**:**仅运行中的节点**显示最后 Text 输出 + 最后工具调用(工具名 + 参数摘要,等宽字体),页面可见时每 3 秒自动刷新;暂无输出时显示「思考中…」;空闲节点保持安静;页面不可见(切标签/折叠面板)时暂停轮询
|
||||
- **自动激活**:设置项「检测到子代理时自动展开子代理页面」默认开启——当前会话**新增**第一个子代理时,侧边栏自动展开并聚焦 Subagent 页面;关闭后需手动打开(切换会话/刷新已有子代理不会打扰)
|
||||
|
||||
### 🧩 VSCode 式分栏工作台
|
||||
|
||||
- **拖 Tab 到分栏边缘**(左/右/上/下)即拆分新分栏,拖到中间合并,拖到另一个 Tab 上插入其前
|
||||
@@ -71,7 +127,7 @@
|
||||
### 📑 Tab 栏
|
||||
|
||||
- **Tab 与 `+` 菜单均带类型图标**(文件夹 / 分支 / 终端 / 代码文件)
|
||||
- `+` 菜单新建 Explorer / Git / 终端(终端受 3 个上限约束);**中键点击关闭 Tab**
|
||||
- `+` 菜单新建 Explorer / Git / Subagent / 终端(终端受 3 个上限约束);**中键点击关闭 Tab**
|
||||
- 空分栏显示可打开类型卡片,点击即开
|
||||
|
||||
### 🔁 会话隔离与状态自愈
|
||||
@@ -83,9 +139,16 @@
|
||||
|
||||
### 🎨 界面风格
|
||||
|
||||
- 行/按钮/间距/动效对齐应用自身组件:34px 圆角树行、28px 圆形图标按钮、挂载淡入
|
||||
- 完全对齐 DSH 原生 chrome:**无阴影**的扁平面板(`--dsw-specific-sidebar-fill` 表面 + hairline 边框)、原生 rail 圆形图标按钮、`interactive-bg-active` 选中填充、34px/14px 原生行规格
|
||||
- 键盘焦点可见(focus-visible 环)、`prefers-reduced-motion` 下关闭全部动画
|
||||
|
||||
### ⚙️ Side card 设置(DSH 设置页)
|
||||
|
||||
- 设置页新增 **Side card(侧边卡片)** 分区,持久化到 DSH 用户设置文档:
|
||||
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
|
||||
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
|
||||
- **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
|
||||
|
||||
## ⌨️ 快捷键速查
|
||||
|
||||
| 操作 | 按键 |
|
||||
@@ -115,7 +178,7 @@
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git
|
||||
cd DSH-better-sidebar
|
||||
pnpm install
|
||||
pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js (client) + lib/types/*.d.ts
|
||||
pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types/*.d.ts
|
||||
```
|
||||
|
||||
> 运行期依赖(cordis、react、`@deepseek-ai/dsh-client-*` 等)按官方插件清单规范声明在 `peerDependencies`——web profile 已内置全部 peer 依赖,安装本插件无需额外装包;`dependencies` 只保留插件自有的运行时依赖(node-pty、ws、xterm、CodeMirror、schemastery 等)。
|
||||
@@ -142,8 +205,10 @@ dsh plugin --profile web add link:/绝对路径/DSH-better-sidebar
|
||||
|
||||
### 更新
|
||||
|
||||
不想用提示词时手动更新(等价于[更新提示词](#更新同样把提示词发给-dsh)):
|
||||
|
||||
```sh
|
||||
pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js (client) + lib/types
|
||||
git pull && pnpm install && pnpm build # 在插件仓库目录执行
|
||||
```
|
||||
|
||||
- 只改了 **client** 代码(`src/client/*`)→ 硬刷新页面即可(bundle 由服务器按请求读取)
|
||||
@@ -151,22 +216,49 @@ pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/cli
|
||||
|
||||
`link:` 引用无需重新 install。
|
||||
|
||||
### 通过 registry 安装(手动,与上面的官方方案二选一)
|
||||
|
||||
前置条件:`dsh registry` 命令可用(DSH 已集成 plugin-registry)。安装源是 `scripts/package-registry.mjs` 组装的 `registry/` 暂存目录——只含清单 + 构建产物 + README,避免把 `node_modules/`(本机 `link:` 符号链接、node-pty 二进制)与 `.git/` 一并装进 `<dshHome>/plugins`。
|
||||
|
||||
```sh
|
||||
pnpm build # 先构建(产物见上)
|
||||
node scripts/package-registry.mjs # 组装 registry/(重新运行会整体重建)
|
||||
dsh registry install ./registry # 安装(默认禁用)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```sh
|
||||
git pull && pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs
|
||||
dsh registry uninstall dsh-external/dsh-better-sidebar
|
||||
dsh registry install ./registry
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
- 只改了 **client** 代码 → 硬刷新页面即可(registry 通道的 bundle 由服务器按请求读取)
|
||||
- 改了 **host** 代码 → 重启 DSH + 硬刷新
|
||||
- 与官方方案的互斥见上文「通道互斥」说明
|
||||
|
||||
## 🛠️ 开发
|
||||
|
||||
```sh
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm test # vitest(单元 + 冒烟 + 插件形态 guard:真实 git/fs/node-pty 交互)
|
||||
pnpm build # rm -rf lib && tsc(声明) + tsdown → lib/index.js + lib/invariant.js + lib/client.js + lib/types
|
||||
pnpm build # rm -rf lib && tsc(声明) + tsdown → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest(单元 + 冒烟 + 插件形态 guard:真实 git/fs/node-pty 交互;manifest 一致性测试读取 lib/,需先 build)
|
||||
pnpm watch # tsdown --watch(client bundle 热重建)
|
||||
```
|
||||
|
||||
### 规范符合性
|
||||
|
||||
插件按 DSH 官方插件规范组织(参考 [dsh-external/turtle-ui](https://github.com/dsh-external/turtle-ui) 与 mainline `packages/client/AGENTS.md`)。**安装通道与渲染方式是两个独立概念**:安装始终走 profile 清单协议(`dsh plugin` / cordis.yml 行,见[一键安装](#一键安装把提示词发给-dsh));下面的 portal 条目只描述面板在页面上的渲染方式。
|
||||
插件按 DSH 官方插件规范组织(参考 [dsh-external/turtle-ui](https://github.com/dsh-external/turtle-ui) 与 mainline `packages/client/AGENTS.md`),并额外提供 plugin-registry 标准发布面(`dsh.plugin.json`)。**安装通道与渲染方式是两个独立概念**:安装走 profile 清单协议(`dsh plugin` / cordis.yml 行,见[一键安装](#一键安装把提示词发给-dsh))**或** registry 通道(`dsh registry`,见[通过 plugin-registry 安装](#通过-plugin-registry-安装标准)),二者互斥;下面的 portal 条目只描述面板在页面上的渲染方式。
|
||||
|
||||
- **插件形态**:`export const name / inject / Config / apply`,无 default 导出;`tests/plugin-shape.spec.ts` 通过 `Loader.unwrapExports` 守卫该形态
|
||||
- **清单**:`types` + `exports`(`.` / `./invariant` / `./client` / `./src/*` / `./package.json`)、`dshClient`(`platform: 'web'` + 信息性 `inject` 边)、peerDependencies、`engines`、`files` 产物明细、`prepare`(消费者侧 `tsdown`,git 安装可用)
|
||||
- **client 契约**:仅导出 `apply`/`inject`(+ 类型);store 为 `createSidebarStore()` 工厂,实例归 `apply` 所有;`src/invariant.ts` 伴生;client bundle 复刻官方 preset(externals = 平台模块表 + runtime/client 豁免、纯度门、CSS Modules 内联)
|
||||
- **registry 形态(标准发布面)**:仓库根 `dsh.plugin.json`(原生清单:id `dsh-external/dsh-better-sidebar`、`main` = host 构建产物、`client.main` = registry client bundle、`contributes` 空声明);`tests/manifest-consistency.spec.ts` 守卫清单与构建产物一致性(id 严格两段、version 与 package.json 同步、bundle id = 清单 id)
|
||||
- **双 client bundle**:同一 `src/client/index.tsx` 构建出 `lib/client.js`(官方通道,id = 包名 `dsh-better-sidebar`)与 `lib/client-registry.js`(registry 通道,id = 清单 id)——两通道的 bundle-id 契约互不相同,各产出一份、同源不漂移;每个部署二选一(见安装章节「通道互斥」)
|
||||
- **已知偏差**:侧边栏面板经 portal(`document.body` + `createRoot`)挂载而非 slot——官方 slot 系统的 `'root'` 由 ui-layout 独占声明,重复声明 fail loud,外部插件无整面板 slot 可用;与 shell 的集成点(`conversation.chat.turnTail`)走官方 chain-slot 机制
|
||||
|
||||
## 🏗️ 架构
|
||||
@@ -177,7 +269,9 @@ pnpm watch # tsdown --watch(client bundle 热重建)
|
||||
|---|---|---|
|
||||
| host | `src/index.ts` → `lib/index.js` | cordis 插件(`Config` 为 schemastery schema,可调 readLimit/mediaLimit/listLimit/terminalsPerSession/reconnectGraceMs):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket;fs / git / pty 服务 |
|
||||
| invariant | `src/invariant.ts` → `lib/invariant.js` | 包属 invariant 伴生(注册包名,无运行时断言) |
|
||||
| client | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂):portal 侧边栏 + 各视图 + turnTail 拦截 |
|
||||
| client(官方通道) | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂,id = 包名):portal 侧边栏 + 各视图 + turnTail 拦截 |
|
||||
| client(registry 通道) | `src/client/index.tsx` → `lib/client-registry.js` | 同一源码的 registry 版本(id = 清单 id `dsh-external/dsh-better-sidebar`,`dsh.plugin.json` 的 `client.main` 指向它) |
|
||||
| manifest | `dsh.plugin.json`(仓库根;`scripts/package-registry.mjs` 组装 `registry/` 安装根) | registry 发布面:id / version / main / client.main;仅 registry 通道读取 |
|
||||
|
||||
- 所有 API 携带 `sessionId`;cwd 权威值取自会话 header,会话未附加(页面加载竞态)时回退客户端摘要 cwd,再回退进程 cwd;终端按 `${sessionId}:${tabId}` 键控,重连时若权威 cwd 变化则重启 shell 到正确目录
|
||||
- 路由与 `/api` 同款信任围栏(Host 头 loopback / `connection.trustedHosts`,`src/trust-fence.ts`,拷贝自 `@deepseek-ai/dsh-client-connection`,BSD-3-Clause)
|
||||
@@ -195,6 +289,8 @@ pnpm watch # tsdown --watch(client bundle 热重建)
|
||||
- Git 无 push/pull/fetch;无文件 watcher(手动刷新)
|
||||
- 工具行内的文件打开按钮(核心代码)不可拦截,仅"产出文件"行被接管
|
||||
- 终端 Tab **拖拽到另一分栏**时会重挂载(shell 重开);切换 Tab / 切换会话(30 秒内返回)/ 刷新页面不会
|
||||
- `.xlsx` 预览不保留单元格样式(字体/颜色/边框/条件格式/图表)——SheetJS 社区版不解析样式,需保真请下载查看
|
||||
- Office/PPTX 预览组件内联进 client bundle,当前约 23MB(gzip ~4.8MB),首次加载较慢
|
||||
|
||||
## 🖥️ 平台支持
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Word 缩放与 PDF 拖拽修复设计
|
||||
|
||||
> 日期: 2026-08-10
|
||||
> 状态: 已批准
|
||||
|
||||
## Word 缩放
|
||||
|
||||
- 默认 100%,范围 50%–200%,步进 10%。
|
||||
- `Alt + 滚轮` 调整缩放并阻止 viewport 默认滚动。
|
||||
- 底部固定 range slider,同步显示百分比。
|
||||
- 使用 CSS `zoom` 缩放 docx-preview 专属容器,使滚动尺寸同步变化。
|
||||
- 切换 Word 文件时恢复 100%。
|
||||
|
||||
## PDF 拖拽
|
||||
|
||||
- iframe 放入独立 stage。
|
||||
- stage 内常驻透明 drag shield。
|
||||
- Tab 拖拽或面板/分栏调整期间立即启用 shield;结束、取消或窗口失焦时关闭。
|
||||
- shield 捕获 Chromium 内置 PDF viewer 吞掉的 dragover/pointer 流,正常状态不影响 PDF 交互。
|
||||
@@ -0,0 +1,364 @@
|
||||
# Office 三件套预览 — 设计文档
|
||||
|
||||
> 日期:2026-08-10
|
||||
> 状态:已批准(待实现)
|
||||
> 范围:`dsh-better-sidebar` 插件,`EditorView` 文件类型扩展
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前 `EditorView.tsx` 对 `.docx/.xlsx/.pptx` 及旧格式 `.doc/.xls/.ppt` 一律显示 binary 占位(`EditorView.tsx:210`),因为 host 的 `readText` 检测到 NUL 字节就标记 binary(`src/index.ts:108`),而 Office 文件本质是 zip。
|
||||
|
||||
**目标**:为 Office 三件套新增有意义的预览能力,保持插件现有架构纪律(host 零改动、bundle 纯度门、跨平台、失败模式全覆盖)。
|
||||
|
||||
**非目标**:
|
||||
- 不做 Office 文件编辑(只预览)
|
||||
- 不做 `push/pull/fetch` 之类网络 Git 操作(与本次无关,沿用 v1 限制)
|
||||
- 不做文件 watcher
|
||||
|
||||
## 2. 范围与选型
|
||||
|
||||
| 类型 | 方案 | 库 | 加载策略 | host 改动 |
|
||||
|---|---|---|---|---|
|
||||
| `.docx` | 保真渲染(保留样式/页眉页脚/图片/表格) | `docx-preview` | 静态 import(~500KB,小) | 零 |
|
||||
| `.xlsx` | 完整电子表格(公式/图表/条件格式/多 sheet) | `@univerjs/*` 全家桶 + `xlsx`(SheetJS,导入) | `dynamic import()` 懒加载(~5MB,首次打开 .xlsx 才加载) | 零 |
|
||||
| `.pptx` | 降级下载按钮 | — | — | — |
|
||||
| `.doc/.xls/.ppt`(OLE 二进制) | 降级下载按钮 | 纯前端无成熟库 | — | — |
|
||||
|
||||
**决策依据**:
|
||||
- 纯前端路线符合现有 bundle 纯度门(`tsdown.config.ts:128` 的 purity gate 只禁 `@deepseek-ai/*` value import,普通 npm 包内联不受限)与跨平台原则(无需 host 装重依赖)。
|
||||
- `pptx` 纯前端无成熟渲染库( OOXML slides + layouts + masters + shapes 解析极复杂),YAGNI 降级。
|
||||
- 旧 OLE 二进制格式(`.doc/.xls/.ppt`)纯前端基本无解,降级。
|
||||
- Univer 选 dynamic import 懒加载:静态内联会让 client bundle 从 2MB→7MB(gzip 1.5MB→3MB),首屏负担过重。
|
||||
|
||||
## 3. 架构
|
||||
|
||||
### 3.1 host 半:零改动
|
||||
|
||||
复用现有 `/sidebar/file` media route(`src/index.ts:349-392`):
|
||||
- 该路由已支持任意文件字节(不限扩展名,只检查 `isWithin(cwd, path)` + `isFile` + `size <= mediaLimit`)
|
||||
- `MEDIA_TYPES`(`index.ts:44`)对 Office 扩展名 fallback 到 `application/octet-stream`,client 用 `fetch` 拿 ArrayBuffer,不依赖 content-type
|
||||
- `mediaLimit` 默认 20MB,对 Office 文件足够(超限降级下载)
|
||||
|
||||
**不改 `fs.read`**:Office 文件不走文本读取路径(会被 NUL 探测当 binary 拒绝),直接走 media route。
|
||||
|
||||
### 3.2 client 半:EditorView 类型扩展
|
||||
|
||||
`EditorLoad` 类型(`EditorView.tsx:26`)扩展:
|
||||
|
||||
```ts
|
||||
type EditorLoad =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx'; content: string; truncated: boolean }
|
||||
| { status: 'binary' } // 现保留:未知二进制 / pptx / OLE 旧格式 → 下载按钮
|
||||
```
|
||||
|
||||
加载流程(`EditorView.tsx:60` 的 `useEffect`):
|
||||
1. `api.fsRead(scope, path)` 仍调(用于探测 binary/text)— 对 Office 文件会返回 `{kind:'binary'}`,据此分流
|
||||
2. 识别扩展名 ∈ `{.docx, .xlsx}` → `setLoad({ status:'ready', kind:'docx'|'xlsx', content:'', truncated:false })`
|
||||
3. 识别 `.pptx` / `.doc` / `.xls` / `.ppt` / 其他未知 binary → 保持 `{status:'binary'}`,但 binary 占位改为"下载查看"按钮(见 §3.5)
|
||||
4. `IMAGE_EXT` / `MD_EXT` 分支不变
|
||||
|
||||
> 备注:step 1 的 `fsRead` 对 Office 文件是冗余往返(必定返回 binary)。可选优化:client 侧先按扩展名短路,Office 扩展名直接走 media route 不调 `fsRead`。实现时取短路方案,省一次请求。
|
||||
|
||||
### 3.3 文件类型分发(纯函数,易测)
|
||||
|
||||
新增 `src/client/office-types.ts`:
|
||||
|
||||
```ts
|
||||
export const OFFICE_PREVIEWABLE = ['.docx', '.xlsx'] as const
|
||||
export const OFFICE_DOWNLOAD_ONLY = ['.pptx', '.doc', '.xls', '.ppt'] as const
|
||||
|
||||
export type OfficeKind = 'docx' | 'xlsx' | 'download-only' | null
|
||||
|
||||
export function officeKindForExt(ext: string): OfficeKind {
|
||||
if (ext === '.docx') return 'docx'
|
||||
if (ext === '.xlsx') return 'xlsx'
|
||||
if (['.pptx', '.doc', '.xls', '.ppt'].includes(ext)) return 'download-only'
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
`EditorView` 的 `extOfPath` 已有(`EditorView.tsx:235`),复用。
|
||||
|
||||
### 3.4 DocxView / XlsxView 组件
|
||||
|
||||
新增 `src/client/office-view.tsx`,导出 `DocxView` 与 `XlsxView`,各自管理库生命周期。
|
||||
|
||||
#### DocxView
|
||||
|
||||
```tsx
|
||||
export function DocxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const [load, setLoad] = useState<{status:'loading'|'ready'|'error'; message?: string}>({status:'loading'})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = hostRef.current
|
||||
if (container === null) return
|
||||
;(async () => {
|
||||
try {
|
||||
const buf = await fetch(mediaUrl(scope, path)).then(r => r.arrayBuffer())
|
||||
if (cancelled) return
|
||||
const { renderAsync } = await import('docx-preview') // 静态 import 亦可,此处用动态保持一致
|
||||
await renderAsync(buf, container, null, { className: 'docx', inWrapper: true })
|
||||
if (!cancelled) setLoad({status:'ready'})
|
||||
} catch (error) {
|
||||
if (!cancelled) setLoad({status:'error', message: error instanceof Error ? error.message : String(error)})
|
||||
}
|
||||
})()
|
||||
return () => { cancelled = true; container.innerHTML = '' }
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
// 渲染:host div + loading/error 占位
|
||||
}
|
||||
```
|
||||
|
||||
- `docx-preview` 的 `renderAsync(arrayBuffer, container, null, options)` 把 docx 渲染到 container,保留样式/页眉页脚/图片(图片库内部 base64 内联处理)
|
||||
- 卸载清空 `container.innerHTML`(docx-preview 无 dispose API,DOM 清空即可)
|
||||
|
||||
#### XlsxView
|
||||
|
||||
```tsx
|
||||
export function XlsxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const univerRef = useRef<Univer | null>(null)
|
||||
const [load, setLoad] = useState<{status:'loading'|'ready'|'error'; message?: string}>({status:'loading'})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = hostRef.current
|
||||
if (container === null) return
|
||||
;(async () => {
|
||||
try {
|
||||
const buf = await fetch(mediaUrl(scope, path)).then(r => r.arrayBuffer())
|
||||
if (cancelled) return
|
||||
// 懒加载 Univer 全家桶(首次打开 .xlsx 才下载 ~5MB chunk)
|
||||
const { createUniver, defaultLocale } = await import('@univerjs/core')
|
||||
const { UniverSheetsCorePlugin } = await import('@univerjs/sheets-ui')
|
||||
const { UniverSheetsFormulaPlugin } = await import('@univerjs/sheets-formula')
|
||||
const { UniverSheetsConditionalFormattingPlugin } = await import('@univerjs/sheets-conditional-formatting')
|
||||
const { UniverSheetsChartPlugin } = await import('@univerjs/sheets-chart')
|
||||
const { UniverDocsPlugin } = await import('@univerjs/docs-ui')
|
||||
// xlsx 导入:SheetJS 解析后转 Univer workbook data
|
||||
const XLSX = await import('xlsx')
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const workbookData = xlsxWorkbookToUniver(wb) // 转换函数,见 §3.6
|
||||
|
||||
const univer = createUniver({
|
||||
locale: defaultLocale,
|
||||
plugins: [
|
||||
UniverDocsPlugin,
|
||||
UniverSheetsCorePlugin,
|
||||
UniverSheetsFormulaPlugin,
|
||||
UniverSheetsConditionalFormattingPlugin,
|
||||
UniverSheetsChartPlugin,
|
||||
],
|
||||
})
|
||||
univer.createUniverSheet(workbookData)
|
||||
univerRef.current = univer
|
||||
if (!cancelled) setLoad({status:'ready'})
|
||||
} catch (error) {
|
||||
if (!cancelled) setLoad({status:'error', message: ...})
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
univerRef.current?.dispose() // 关键:防 canvas + worker 泄漏
|
||||
univerRef.current = null
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
// 渲染:host div + loading/error 占位
|
||||
}
|
||||
```
|
||||
|
||||
- **dispose 纪律**:卸载必须 `univer.dispose()`,否则 canvas + worker 泄漏(类比 `TerminalView` 的 `term.dispose()` + `observer.disconnect()`)
|
||||
- Univer 的 UI 需要容器有尺寸(类似 xterm),复用 `ResizeObserver` 模式
|
||||
|
||||
### 3.5 binary 占位升级为下载按钮
|
||||
|
||||
`EditorView.tsx:210` 现状:`{t('binary')}` 纯文本占位。
|
||||
|
||||
改为:
|
||||
|
||||
```tsx
|
||||
{load.status === 'binary' && (
|
||||
<div className={css.editorBinary}>
|
||||
<span>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
- 复用现有 `downloadUrl(scope, path)`(`api.ts:140`),走 `/sidebar/file?download=1`
|
||||
- 对所有 binary 统一(不只 Office)— PDF、zip、exe 等都受益
|
||||
|
||||
### 3.6 xlsx → Univer workbook data 转换
|
||||
|
||||
SheetJS 解析的 workbook 结构与 Univer 的 `IWorkbookData` 不同,需转换层 `src/client/xlsx-to-univer.ts`:
|
||||
|
||||
- SheetJS `wb.SheetNames[]` + `wb.Sheets[name]` → Univer `sheets: Record<sheetId, IWorksheetData>`
|
||||
- 单元格:`ws[addr]` → `cellData[row][col]`,映射类型(s/n/b/e)、值、样式(对齐/字体/背景/边框)
|
||||
- 合并单元格:`ws['!merges']` → `mergeData`
|
||||
- 列宽/行高:`ws['!cols']` / `ws['!rows']` → `columnData` / `rowData`
|
||||
- 公式:SheetJS `cell.f` → Univer 公式格式
|
||||
- 图表/条件格式:SheetJS 不解析这些,需 Univer importer 或跳过(首版可只迁数据+样式+合并+公式)
|
||||
|
||||
**首版范围**:数据 + 基本样式(字体/颜色/对齐/边框/合并)+ 公式(文本形式即可,Univer 引擎计算)。图表/条件格式若 Univer importer 直接支持则用,否则跳过。
|
||||
|
||||
> 备选:用 Univer 官方的 `@univerjs/sheets-import-export`(基于 SheetJS 封装,直接产出 `IWorkbookData`),省去自写转换层。实现时优先评估此包,若版本耦合/体积可接受则用,否则自写。
|
||||
|
||||
### 3.7 EditorView 集成
|
||||
|
||||
`EditorView.tsx` 的 `renderTab` / `TabContent` 分发(`Sidebar.tsx:49` 的 switch)无需改 — editor tab 仍走 `EditorView`。
|
||||
|
||||
`EditorView` 内部 `useEffect`(`EditorView.tsx:60`)加 Office 分支:
|
||||
|
||||
```ts
|
||||
const ext = extOfPath(path)
|
||||
const officeKind = officeKindForExt(ext)
|
||||
if (officeKind === 'docx' || officeKind === 'xlsx') {
|
||||
setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false })
|
||||
return // 不再走 fsRead(短路,省一次 binary 往返)
|
||||
}
|
||||
// 原有 IMAGE / MD / text / binary 逻辑不变
|
||||
```
|
||||
|
||||
渲染区(`EditorView.tsx:220` 附近)加:
|
||||
|
||||
```tsx
|
||||
{load.status === 'ready' && load.kind === 'docx' && (
|
||||
<DocxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'xlsx' && (
|
||||
<XlsxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
```
|
||||
|
||||
## 4. 错误处理
|
||||
|
||||
| 场景 | 检测 | 响应 |
|
||||
|---|---|---|
|
||||
| 文件损坏(非有效 zip) | 库抛错 | 显示错误信息 + 下载按钮 |
|
||||
| `>20MB`(超 `mediaLimit`) | fetch 响应 / host 返回 400 | 提示"文件过大" + 下载按钮 |
|
||||
| 加密文件(密码保护) | SheetJS/docx-preview 抛特定错 | "不支持加密文件" + 下载按钮 |
|
||||
| Univer chunk 加载失败 | dynamic import reject | 重试按钮(类比 `TerminalView` 的 `FAILURE_LIMIT`) |
|
||||
| 网络失败(媒体路由 403) | fetch 非 200 | 错误信息(信任围栏拒绝) |
|
||||
| Univer dispose 后异步回调 | cancelled flag | 静默(类比现有 `TerminalView` 模式) |
|
||||
|
||||
**降级统一出口**:所有 Office 错误分支都附带"下载查看"链接,确保用户始终能拿到文件。
|
||||
|
||||
## 5. bundle 与构建
|
||||
|
||||
### 5.1 依赖新增(`package.json`)
|
||||
|
||||
`dependencies`(运行时,内联进 client bundle):
|
||||
- `docx-preview`(~500KB)
|
||||
- `xlsx`(SheetJS,~400KB,xlsx 导入)
|
||||
- `@univerjs/core` + `@univerjs/sheets` + `@univerjs/sheets-ui` + `@univerjs/sheets-formula` + `@univerjs/sheets-conditional-formatting` + `@univerjs/sheets-chart` + `@univerjs/docs-ui` + `@univerjs/design` + `@univerjs/engine-render`(~5MB 合计)
|
||||
|
||||
**不放 `peerDependencies`**:这些是插件自有运行时依赖,不由 web profile 提供(对比现有 `node-pty`/`xterm`/`CodeMirror` 也在 `dependencies`)。
|
||||
|
||||
### 5.2 tsdown.config.ts 调整
|
||||
|
||||
现有 client bundle 配置(`tsdown.config.ts:103` 的 `clientBundle()`):
|
||||
- `format: 'cjs'` + `banner`/`footer` 包成 `__ModuleLoader__.load` 闭包工厂
|
||||
- `noExternal: (id) => (CLIENT_EXTERNALS.includes(id) ? undefined : true)` — 非 externals 全内联
|
||||
|
||||
**dynamic import 兼容性(最大实现风险)**:
|
||||
|
||||
Rollup 在 `format: 'cjs'` + `inlineDynamicImports: false` 时会拆 chunk,但 CJS chunk 在浏览器端无 `require`,动态导入链路可能断裂。需 spike 验证:
|
||||
|
||||
1. **首选**:保持 `format: 'cjs'`,让 rollup 拆 chunk,验证浏览器 `import()` 是否能加载子 chunk(rollup 会输出 `import()` 调用,浏览器原生支持 ESM dynamic import,但 CJS chunk 的 `module.exports` 形态需 wrapper)
|
||||
2. **fallback A**:退回静态内联(放弃懒加载,接受 7MB bundle)
|
||||
3. **fallback B**:Univer 单独打 IIFE bundle `lib/univer-bundle.js`,host 新增静态路由 `/sidebar/univer-bundle.js` serve,client 用 `<script>` 动态注入(破坏 host 零改动,最后手段)
|
||||
|
||||
**spike 必须是实现第一步**,结果决定后续构建配置。
|
||||
|
||||
### 5.3 CSS
|
||||
|
||||
Univer 自带样式,需确保:
|
||||
- Univer 的 CSS 被 `dsh-css-inline` plugin 正确内联(走 `import '@univerjs/.../style.css'`)
|
||||
- 或 Univer 的样式注入机制不与现有 `data-plugin-css` tag 冲突
|
||||
|
||||
docx-preview 的样式由 `className: 'docx'` 选项控制,可能需补 `src/client/office.module.css` 微调。
|
||||
|
||||
## 6. 测试
|
||||
|
||||
### 6.1 Unit(纯函数,加进 `tests/unit.spec.ts`)
|
||||
|
||||
- `officeKindForExt('.docx')` → `'docx'`
|
||||
- `officeKindForExt('.xlsx')` → `'xlsx'`
|
||||
- `officeKindForExt('.pptx')` → `'download-only'`
|
||||
- `officeKindForExt('.doc')` → `'download-only'`
|
||||
- `officeKindForExt('.txt')` → `null`
|
||||
- `officeKindForExt('')` → `null`
|
||||
|
||||
### 6.2 xlsx → Univer 转换(若自写转换层)
|
||||
|
||||
- fixture xlsx → 解析 → 转换 → 断言 `IWorkbookData` 结构(sheet 名/单元格值/合并/公式)
|
||||
- 用 `tests/fixtures/sample.xlsx` 作为输入
|
||||
|
||||
### 6.3 集成测试限制
|
||||
|
||||
- Univer 依赖 canvas API,jsdom 环境无法渲染 → 不测 Univer 渲染产物
|
||||
- 只测加载逻辑:mock `fetch` 返回 ArrayBuffer,验证 loading → ready/error 状态转换
|
||||
- docx-preview 同样依赖 DOM,jsdom 可跑 `renderAsync`(无 canvas 依赖)但渲染产物脆弱 → 只测"不抛错"
|
||||
|
||||
### 6.4 手动冒烟
|
||||
|
||||
- 真实 .docx(含图片/表格/页眉)→ 渲染保真度
|
||||
- 真实 .xlsx(多 sheet/公式/条件格式)→ 渲染 + 公式计算
|
||||
- .pptx → 下载按钮
|
||||
- 加密 docx/xlsx → 错误提示 + 下载
|
||||
- >20MB → 提示 + 下载
|
||||
|
||||
## 7. 国际化
|
||||
|
||||
`src/client/locales.ts` 新增 key:
|
||||
|
||||
- `downloadToView` — "下载查看" / "Download to view"
|
||||
- `binaryNoPreview` — "此文件类型不支持预览" / "This file type cannot be previewed"
|
||||
- `officeTooLarge` — "文件过大,无法预览" / "File too large to preview"
|
||||
- `officeCorrupt` — "文件损坏或格式无效" / "File is corrupt or in an invalid format"
|
||||
- `officeEncrypted` — "不支持加密文件" / "Encrypted files are not supported"
|
||||
- `officeLoadFailed` — "Office 预览组件加载失败" / "Office preview component failed to load"
|
||||
- `retry` — 复用现有 `terminalRetry` 或新增 `retry`
|
||||
|
||||
## 8. 文件改动清单
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/client/EditorView.tsx` | 加 docx/xlsx kind 分支 + 下载按钮 binary 占位 |
|
||||
| `src/client/office-view.tsx`(新) | `DocxView` / `XlsxView` 组件 |
|
||||
| `src/client/office-types.ts`(新) | 扩展名 → kind 纯函数 |
|
||||
| `src/client/xlsx-to-univer.ts`(新,若自写转换) | SheetJS workbook → Univer IWorkbookData |
|
||||
| `src/client/locales.ts` | 新文案 key |
|
||||
| `src/client/sidebar.module.css` | `.editorBinary` / `.editorDownloadLink` 样式 |
|
||||
| `package.json` | 加 docx-preview / @univerjs/* / xlsx 依赖 |
|
||||
| `tsdown.config.ts` | dynamic import 配置(spike 后定) |
|
||||
| `tests/unit.spec.ts` | officeKindForExt 单测 |
|
||||
| `tests/fixtures/`(新) | sample.docx / sample.xlsx 测试夹具 |
|
||||
|
||||
## 9. 风险登记
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解 |
|
||||
|---|---|---|---|
|
||||
| tsdown CJS 闭包不兼容 dynamic import | 中 | 高(阻塞懒加载) | spike 优先;fallback A 静态内联 / fallback B 独立 bundle |
|
||||
| Univer 体积超预期(>5MB) | 低 | 中 | 评估 tree-shaking;裁剪非必要插件(如 chart) |
|
||||
| xlsx → Univer 转换层复杂度爆表 | 中 | 中 | 优先用 `@univerjs/sheets-import-export` 官方包 |
|
||||
| Univer canvas 在某些浏览器渲染异常 | 低 | 中 | 错误边界捕获 + 下载降级 |
|
||||
| docx-preview 图片 base64 内存爆炸(大 docx) | 低 | 低 | 文件 size cap 已 20MB,可接受 |
|
||||
| Univer dispose 不彻底导致内存泄漏 | 中 | 中 | 严格 cleanup + 手动验证 Tab 切换/卸载 |
|
||||
|
||||
## 10. 实现里程碑(粗略,待 writing-plans 细化)
|
||||
|
||||
1. **Spike**:验证 tsdown CJS + dynamic import 兼容性(决定构建策略)
|
||||
2. **基础**:office-types.ts + EditorView 分发 + binary 下载按钮(最小可用,pptx/旧格式先受益)
|
||||
3. **docx**:DocxView + docx-preview 集成 + 错误处理
|
||||
4. **xlsx**:XlsxView + Univer 集成 + xlsx 导入(优先官方 importer)
|
||||
5. **测试**:unit + fixture + 手动冒烟
|
||||
6. **收尾**:CSS 打磨 + 国际化 + 文档更新(README 功能一览)
|
||||
@@ -0,0 +1,31 @@
|
||||
# PDF 原生预览设计
|
||||
|
||||
> 日期: 2026-08-10
|
||||
> 状态: 已批准
|
||||
> 范围: `dsh-better-sidebar` 的 `EditorView` 文件预览扩展
|
||||
|
||||
## 目标
|
||||
|
||||
为 `.pdf` 增加侧边栏内预览,同时保持零新增前端依赖和现有下载降级出口。
|
||||
|
||||
## 方案
|
||||
|
||||
- Client 按扩展名识别 `.pdf`,短路 `fsRead` 的二进制探测。
|
||||
- `PdfView` 通过 `fetch(mediaUrl(scope, path))` 读取字节,创建显式
|
||||
`application/pdf` Blob URL 后交给浏览器原生 `<iframe>`,避免旧 host
|
||||
或代理缓存 `application/octet-stream` 时触发直接下载。
|
||||
- Host media route 为 `.pdf` 返回 `application/pdf`。
|
||||
- PDF 预览工具条始终提供“下载查看”链接。
|
||||
- 不引入 PDF.js,不实现缩略图、文本搜索或自定义翻页工具栏。
|
||||
|
||||
## 错误与限制
|
||||
|
||||
- 是否可渲染取决于浏览器内置 PDF 查看器。
|
||||
- 文件仍受现有 `mediaLimit` 限制。
|
||||
- 浏览器原生 PDF iframe 对 HTTP 错误没有统一可观测事件;下载链接作为稳定降级出口。
|
||||
|
||||
## 测试
|
||||
|
||||
- `.pdf` 扩展名识别为 PDF 预览。
|
||||
- media type helper 对 `.pdf` 返回 `application/pdf`。
|
||||
- Typecheck、bundle purity、manifest/plugin-shape 测试通过。
|
||||
@@ -0,0 +1,37 @@
|
||||
# PPTX 高保真预览设计
|
||||
|
||||
> 日期: 2026-08-10
|
||||
> 状态: 已批准
|
||||
> 范围: `dsh-better-sidebar` 的 `.pptx` 浏览器预览
|
||||
|
||||
## 目标
|
||||
|
||||
使用 `@aiden0z/pptx-renderer` 在侧边栏内预览 `.pptx`,保留图片、表格、
|
||||
图表、SmartArt 与常见 OOXML 样式,同时保持下载降级出口。
|
||||
|
||||
## 架构
|
||||
|
||||
- `.pptx` 从 `download-only` 调整为独立 `pptx` 预览类型。
|
||||
- `PptxView` 从现有 media route 获取 `ArrayBuffer`。
|
||||
- 使用 `PptxViewer.open()` 渲染到库专属 DOM 容器。
|
||||
- 开启 `lazyMedia`、`lazySlides` 与推荐 ZIP 安全限制。
|
||||
- UI 提供上一页、下一页、页码和下载按钮。
|
||||
- 卸载时销毁 viewer 并清空库专属容器。
|
||||
- 旧 `.ppt/.doc/.xls` 继续仅下载。
|
||||
|
||||
## Bundle
|
||||
|
||||
- 先验证普通 bundler 入口不会遗留 Node builtin `require()`。
|
||||
- 若普通入口不纯,切换包提供的 standalone browser ESM 入口。
|
||||
- 不启用可选 PDF.js EMF fallback,避免额外体积。
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 损坏、加密或超限文件显示错误信息和下载链接。
|
||||
- viewer 初始化失败时清理部分创建的实例与 DOM。
|
||||
|
||||
## 测试
|
||||
|
||||
- `.pptx` 类型分发。
|
||||
- bundle 模块表 require guard。
|
||||
- Typecheck、manifest/plugin-shape 与构建通过。
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.1.9",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离",
|
||||
"engines": {
|
||||
"dsh": ">=0.0.1"
|
||||
},
|
||||
"contributes": {
|
||||
"tools": [],
|
||||
"skills": []
|
||||
},
|
||||
"client": {
|
||||
"main": "./lib/client-registry.js"
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.9",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -54,16 +54,19 @@
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aiden0z/pptx-renderer": "^1.2.4",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
@@ -85,11 +88,16 @@
|
||||
"@codemirror/state": "^6.7.1",
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@univerjs/preset-sheets-core": "^0.25.1",
|
||||
"@univerjs/presets": "^0.25.1",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"docx-preview": "^0.4.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
"ws": "^8.18.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -97,11 +105,13 @@
|
||||
"@deepseek-ai/dsh-client-schema-form": "link:/Users/menghuan/.dsh/source/current/packages/client/schema-form",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots",
|
||||
"@deepseek-ai/dsh-client-web-react": "link:/Users/menghuan/.dsh/source/current/packages/client/web-react",
|
||||
"@deepseek-ai/dsh-host-webserver": "link:/Users/menghuan/.dsh/source/current/packages/host/webserver",
|
||||
"@deepseek-ai/dsh-invariants": "link:/Users/menghuan/.dsh/source/current/packages/support/invariants",
|
||||
"@deepseek-ai/dsh-session": "link:/Users/menghuan/.dsh/source/current/packages/core/session",
|
||||
"@deepseek-ai/dsh-settings": "link:/Users/menghuan/.dsh/source/current/packages/settings/settings",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.1",
|
||||
|
||||
Generated
+3427
File diff suppressed because it is too large
Load Diff
@@ -6,3 +6,4 @@ packages:
|
||||
# build scripts unless approved.
|
||||
allowBuilds:
|
||||
node-pty: true
|
||||
protobufjs: true
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Assemble the plugin-registry installable root (`registry/`) from the built
|
||||
* `lib/` output. Run after `pnpm build`:
|
||||
*
|
||||
* pnpm build
|
||||
* node scripts/package-registry.mjs
|
||||
* dsh registry install ./registry
|
||||
* dsh registry enable dsh-external/dsh-better-sidebar
|
||||
*
|
||||
* The registry install copies the whole source directory (`cp -r`), so
|
||||
* installing the repo root would drag in `node_modules/` (machine-local
|
||||
* `link:` devDependency symlinks, node-pty binaries) and `.git/`. This
|
||||
* staging directory carries exactly the files the manifest references plus
|
||||
* the docs — same layout as the dsh-subagent-tree `registry/` pattern. It is
|
||||
* gitignored and rebuilt from scratch on every run.
|
||||
*/
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(fileURLToPath(import.meta.url), '..', '..')
|
||||
const out = join(root, 'registry')
|
||||
|
||||
/** Files copied into registry/, preserving relative paths (lib/ stays lib/). */
|
||||
const files = [
|
||||
'dsh.plugin.json',
|
||||
'lib/index.js',
|
||||
'lib/invariant.js',
|
||||
'lib/client-registry.js',
|
||||
'lib/client-registry.js.map',
|
||||
'README.md',
|
||||
'LICENSE',
|
||||
]
|
||||
|
||||
rmSync(out, { recursive: true, force: true })
|
||||
mkdirSync(out, { recursive: true })
|
||||
for (const file of files) {
|
||||
const source = join(root, file)
|
||||
if (!existsSync(source)) {
|
||||
console.error(`missing ${file} — run \`pnpm build\` first`)
|
||||
process.exit(1)
|
||||
}
|
||||
const target = join(out, file)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
cpSync(source, target)
|
||||
}
|
||||
console.log(`registry/ assembled (${files.length} files)`)
|
||||
console.log('next: dsh registry install ./registry && dsh registry enable dsh-external/dsh-better-sidebar')
|
||||
+53
-10
@@ -13,21 +13,26 @@ import { EditorState } from '@codemirror/state'
|
||||
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { api, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { api, downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { officeKindForExt } from './office-types.ts'
|
||||
import { DocxView, XlsxView } from './office-view.tsx'
|
||||
import { isPdfExt } from './pdf-types.ts'
|
||||
import { PdfView } from './PdfView.tsx'
|
||||
import { PptxView } from './PptxView.tsx'
|
||||
import { isImageExt } from './image-types.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']
|
||||
const MD_EXT = ['.md', '.markdown']
|
||||
|
||||
type EditorLoad =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; kind: 'text' | 'image' | 'md'; content: string; truncated: boolean }
|
||||
| { status: 'binary' }
|
||||
| { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx' | 'pptx' | 'pdf'; content: string; truncated: boolean }
|
||||
| { status: 'binary' } // unknown binary / OLE legacy formats → download button
|
||||
|
||||
/** Previewable files (rendered output vs source editing). */
|
||||
type ViewMode = 'preview' | 'edit'
|
||||
@@ -57,17 +62,36 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
setDraft(null)
|
||||
setDirty(false)
|
||||
setSaveState('idle')
|
||||
// Office preview short-circuit: the host's readText would NUL-probe every
|
||||
// Office file as binary (a wasted round-trip), and .docx/.xlsx have their
|
||||
// own renderers below. Detect by extension and skip fsRead entirely —
|
||||
// the renderer fetches bytes through the media route itself.
|
||||
const ext = extOfPath(path)
|
||||
// Images are binary by nature; short-circuit before fsRead's NUL probe or
|
||||
// every PNG/JPEG would be classified as an unsupported binary file.
|
||||
if (isImageExt(ext)) {
|
||||
setLoad({ status: 'ready', kind: 'image', content: '', truncated: false })
|
||||
return
|
||||
}
|
||||
if (isPdfExt(ext)) {
|
||||
setLoad({ status: 'ready', kind: 'pdf', content: '', truncated: false })
|
||||
return
|
||||
}
|
||||
const officeKind = officeKindForExt(ext)
|
||||
if (officeKind === 'docx' || officeKind === 'xlsx' || officeKind === 'pptx') {
|
||||
setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false })
|
||||
return
|
||||
}
|
||||
if (officeKind === 'download-only') {
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
}
|
||||
api.fsRead(scope, path).then((result) => {
|
||||
if (cancelled) return
|
||||
const ext = extOfPath(path)
|
||||
if (result.kind === 'binary') {
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
}
|
||||
if (IMAGE_EXT.includes(ext)) {
|
||||
setLoad({ status: 'ready', kind: 'image', content: '', truncated: false })
|
||||
return
|
||||
}
|
||||
setLoad({
|
||||
status: 'ready',
|
||||
kind: MD_EXT.includes(ext) ? 'md' : 'text',
|
||||
@@ -207,7 +231,14 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
</div>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'binary' && <div className={css.editorPlaceholder}>{t('binary')}</div>}
|
||||
{load.status === 'binary' && (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
{load.truncated && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||
@@ -222,6 +253,18 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st
|
||||
<img className={css.editorImage} src={mediaUrl(scope, path)} alt={title} />
|
||||
</div>
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'docx' && (
|
||||
<DocxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'xlsx' && (
|
||||
<XlsxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'pptx' && (
|
||||
<PptxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'pdf' && (
|
||||
<PdfView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{previewable && mode === 'preview' && (
|
||||
<div className={css.editorMd}>
|
||||
<MarkdownText text={draft ?? load.content} />
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/** Browser-native PDF preview with an always-available download fallback. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
export function PdfView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const { scope, path, title } = props
|
||||
const [load, setLoad] = useState<
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; url: string }
|
||||
| { status: 'error'; message: string }
|
||||
>({ status: 'loading' })
|
||||
const [interactionBlocked, setInteractionBlocked] = useState(false)
|
||||
const frameRef = useRef<HTMLIFrameElement>(null)
|
||||
const shieldRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
let objectUrl: string | undefined
|
||||
setLoad({ status: 'loading' })
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const bytes = await response.arrayBuffer()
|
||||
if (controller.signal.aborted) return
|
||||
// A direct iframe navigation may download when an old host process or
|
||||
// proxy cached application/octet-stream. The Blob URL owns an explicit
|
||||
// PDF MIME and therefore consistently opens the browser PDF viewer.
|
||||
objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }))
|
||||
setLoad({ status: 'ready', url: objectUrl })
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
controller.abort()
|
||||
if (objectUrl !== undefined) URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
const block = (): void => {
|
||||
setInteractionBlocked(true)
|
||||
if (frameRef.current !== null) frameRef.current.style.pointerEvents = 'none'
|
||||
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'auto'
|
||||
}
|
||||
const unblock = (): void => {
|
||||
setInteractionBlocked(false)
|
||||
if (frameRef.current !== null) frameRef.current.style.pointerEvents = ''
|
||||
if (shieldRef.current !== null) shieldRef.current.style.pointerEvents = 'none'
|
||||
}
|
||||
const blockForResize = (event: PointerEvent): void => {
|
||||
const target = event.target
|
||||
if (target instanceof Element
|
||||
&& target.closest(`.${css.panelResize}, .${css.divider}`) !== null) {
|
||||
block()
|
||||
}
|
||||
}
|
||||
// An iframe is a separate browsing context and otherwise swallows the
|
||||
// dragover/pointer stream as soon as the cursor crosses it. Temporarily
|
||||
// remove it from hit-testing while a tab drag or pane resize is active.
|
||||
document.addEventListener('dragstart', block, true)
|
||||
document.addEventListener('dragend', unblock, true)
|
||||
document.addEventListener('drop', unblock, true)
|
||||
window.addEventListener('pointerdown', blockForResize, true)
|
||||
window.addEventListener('pointerup', unblock, true)
|
||||
window.addEventListener('pointercancel', unblock, true)
|
||||
window.addEventListener('blur', unblock)
|
||||
return () => {
|
||||
document.removeEventListener('dragstart', block, true)
|
||||
document.removeEventListener('dragend', unblock, true)
|
||||
document.removeEventListener('drop', unblock, true)
|
||||
window.removeEventListener('pointerdown', blockForResize, true)
|
||||
window.removeEventListener('pointerup', unblock, true)
|
||||
window.removeEventListener('pointercancel', unblock, true)
|
||||
window.removeEventListener('blur', unblock)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.editorPdf}>
|
||||
<div className={css.editorPdfToolbar}>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={css.editorPdfStage}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'ready' && (
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
className={clsx(css.editorPdfFrame, interactionBlocked && css.editorPdfFrameBlocked)}
|
||||
src={load.url}
|
||||
title={title}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={shieldRef}
|
||||
className={clsx(css.editorPdfDragShield, interactionBlocked && css.editorPdfDragShieldActive)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/** High-fidelity browser-native PPTX preview with slide navigation. */
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
type PptxViewerInstance = import('@aiden0z/pptx-renderer').PptxViewer
|
||||
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready'; count: number }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
export function PptxView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewerRef = useRef<PptxViewerInstance | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [slide, setSlide] = useState(0)
|
||||
const [navigating, setNavigating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
setLoad({ status: 'loading' })
|
||||
setSlide(0)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path), { signal: controller.signal })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const bytes = await response.arrayBuffer()
|
||||
if (controller.signal.aborted) return
|
||||
const { PptxViewer, RECOMMENDED_ZIP_LIMITS } = await import('@aiden0z/pptx-renderer')
|
||||
if (controller.signal.aborted) return
|
||||
const viewer = await PptxViewer.open(bytes, host, {
|
||||
renderMode: 'slide',
|
||||
fitMode: 'contain',
|
||||
lazyMedia: true,
|
||||
lazySlides: true,
|
||||
pdfjs: false,
|
||||
signal: controller.signal,
|
||||
zipLimits: RECOMMENDED_ZIP_LIMITS,
|
||||
onSlideChange: (index) => {
|
||||
if (!controller.signal.aborted) setSlide(index)
|
||||
},
|
||||
})
|
||||
if (controller.signal.aborted) {
|
||||
viewer.destroy()
|
||||
return
|
||||
}
|
||||
viewerRef.current = viewer
|
||||
setSlide(viewer.currentSlideIndex)
|
||||
setLoad({ status: 'ready', count: viewer.slideCount })
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
try {
|
||||
viewerRef.current?.destroy()
|
||||
} catch {
|
||||
// Partially initialized viewer — ignore disposal errors.
|
||||
}
|
||||
viewerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
controller.abort()
|
||||
try {
|
||||
viewerRef.current?.destroy()
|
||||
} catch {
|
||||
// Already destroyed.
|
||||
}
|
||||
viewerRef.current = null
|
||||
host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
const navigate = (target: number): void => {
|
||||
const viewer = viewerRef.current
|
||||
if (viewer === null || load.status !== 'ready' || navigating) return
|
||||
const next = Math.max(0, Math.min(load.count - 1, target))
|
||||
if (next === slide) return
|
||||
setNavigating(true)
|
||||
void viewer.goToSlide(next, { behavior: 'auto' }).then(() => {
|
||||
setSlide(viewer.currentSlideIndex)
|
||||
}).catch((error: unknown) => {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}).finally(() => {
|
||||
setNavigating(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.editorPptx} aria-label={title}>
|
||||
<div className={css.editorPptxToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.editorPptxButton}
|
||||
disabled={load.status !== 'ready' || slide <= 0 || navigating}
|
||||
onClick={() => { navigate(slide - 1) }}
|
||||
>
|
||||
{t('previousSlide')}
|
||||
</button>
|
||||
<span className={css.editorPptxPosition}>
|
||||
{load.status === 'ready' ? `${slide + 1} / ${load.count}` : '– / –'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.editorPptxButton}
|
||||
disabled={load.status !== 'ready' || slide >= load.count - 1 || navigating}
|
||||
onClick={() => { navigate(slide + 1) }}
|
||||
>
|
||||
{t('nextSlide')}
|
||||
</button>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={css.editorPptxStage}>
|
||||
<div className={css.editorPptxHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Side card settings section chrome: the native settings-row recipe (figma
|
||||
* row 16/0 padding, hairline separator, 14px/22px title, 12px/17px desc) with
|
||||
* the native checkbox (16px, brand accent) and the Input atom + "%" suffix.
|
||||
* The shell strips the last row's separator via the trailing-child rule.
|
||||
*/
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Native checkbox: 16px, brand accent, no custom chrome (RiskConfirmation recipe). */
|
||||
.toggle {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.control {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.percentInput {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 0 2px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* "Side card" settings section: the user-facing preferences for the sidebar
|
||||
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
|
||||
* Three rows:
|
||||
* - new conversations open the panel by default (native checkbox),
|
||||
* - the default panel width as a percent of the window (number input + %),
|
||||
* - auto-open the Subagent page when a new subagent appears (native checkbox,
|
||||
* on by default).
|
||||
*
|
||||
* 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
|
||||
* third-party namespaces to configuration clients); the shared SidebarStore
|
||||
* is refreshed on success so the very next brand-new session seeds from the
|
||||
* new values. Any failure reverts the optimistic UI and shows the wire error
|
||||
* inline — a broken settings surface never crashes the shell.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
/** Injected business face: the shared store (prefs cache). */
|
||||
export interface SideCardSectionInjected {
|
||||
store: SidebarStore
|
||||
}
|
||||
|
||||
/** Full section props: the runtime share plus the injected face. */
|
||||
export type SideCardSectionProps = PropsRuntime<'settings.section'> & SideCardSectionInjected
|
||||
|
||||
/** Map one wire failure to the inline message (the conflict gets friendly copy). */
|
||||
function messageOf(error: unknown): string {
|
||||
if (error instanceof Error && 'code' in error && (error as { code?: unknown }).code === 'settings-conflict') {
|
||||
return `${t('settingsSaveFailed')} ${t('settingsConflict')}`
|
||||
}
|
||||
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Side card preferences section.
|
||||
* @param props - composed slot props (runtime share + injected store).
|
||||
* @returns the section element tree.
|
||||
*/
|
||||
export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
|
||||
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// The settings document revision (guards concurrent writes). A ref: commits
|
||||
// read the freshest value at execution time, no re-render needed.
|
||||
const revisionRef = useRef<number | undefined>(undefined)
|
||||
// Whether the user already wrote since mount: the mount read must not
|
||||
// clobber a newer optimistic edit (the window is milliseconds, but a slow
|
||||
// route must never silently revert a just-made change).
|
||||
const dirtyRef = useRef(false)
|
||||
// Serialize commits: a queued write must observe the previous write's
|
||||
// revision; a failed write must not poison the queue for later ones.
|
||||
const inFlightRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||
|
||||
// Sync the persisted document once on mount: the revision and the current
|
||||
// values (another tab may have changed them since the store hydrated).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void api.settingsGet().then((view) => {
|
||||
if (cancelled) return
|
||||
revisionRef.current = view.revision
|
||||
if (dirtyRef.current) return
|
||||
const next = parsePrefs(view.value)
|
||||
setPrefs(next)
|
||||
setWidthDraft(String(next.defaultWidthPercent))
|
||||
}).catch(() => { /* the store's defaults stay authoritative */ })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
/** Persist one patch through the settings route (serialized, revision-guarded). */
|
||||
const commit = (patch: Partial<SidebarPrefs>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
||||
dirtyRef.current = true
|
||||
const run = inFlightRef.current.then(async () => {
|
||||
const view = await api.settingsUpdate(
|
||||
{ ...patch },
|
||||
revisionRef.current,
|
||||
)
|
||||
const next = parsePrefs(view.value)
|
||||
revisionRef.current = view.revision
|
||||
store.setPrefs(next)
|
||||
return next
|
||||
})
|
||||
// A failed commit must not poison the queue: later writes still run.
|
||||
inFlightRef.current = run.then(() => undefined, () => undefined)
|
||||
return run.then(
|
||||
(next) => ({ ok: true, prefs: next }),
|
||||
(caught) => {
|
||||
setError(messageOf(caught))
|
||||
return { ok: false, prefs }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Settle one commit: success adopts the server values, failure reverts. */
|
||||
const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
|
||||
const settled = outcome.ok ? outcome.prefs : previous
|
||||
setPrefs(settled)
|
||||
setWidthDraft(String(settled.defaultWidthPercent))
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, openByDefault: next })
|
||||
setError(null)
|
||||
void commit({ openByDefault: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggleSubagent = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, autoOpenSubagent: next })
|
||||
setError(null)
|
||||
void commit({ autoOpenSubagent: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
const parsed = Number(widthDraft)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setWidthDraft(String(prefs.defaultWidthPercent))
|
||||
return
|
||||
}
|
||||
const clamped = clampWidthPercent(parsed)
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, defaultWidthPercent: clamped })
|
||||
setWidthDraft(String(clamped))
|
||||
setError(null)
|
||||
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.openByDefault}
|
||||
aria-label={t('settingsOpenTitle')}
|
||||
onChange={event => { onToggle(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsSubagentTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsSubagentDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.autoOpenSubagent}
|
||||
aria-label={t('settingsSubagentTitle')}
|
||||
onChange={event => { onToggleSubagent(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+76
-3
@@ -13,7 +13,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context, SidebarConversation } from '../context-types.ts'
|
||||
import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts'
|
||||
import {
|
||||
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
|
||||
resizeSplit, setWidth, toggleExpanded, togglePanel,
|
||||
@@ -28,6 +28,8 @@ import { ExplorerView } from './ExplorerView.tsx'
|
||||
import { EditorView } from './EditorView.tsx'
|
||||
import { TerminalView } from './TerminalView.tsx'
|
||||
import { GitView } from './GitView.tsx'
|
||||
import { SubagentView } from './SubagentView.tsx'
|
||||
import { detectNewDirectSubagent } from './subagent-detect.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { api } from './api.ts'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
@@ -43,8 +45,12 @@ function TabContent(props: {
|
||||
onReferenceFile: (path: string) => void
|
||||
ctx: Context
|
||||
store: SidebarStore
|
||||
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
|
||||
visible: boolean
|
||||
/** Fired before a topology node jumps to its child session (see Sidebar). */
|
||||
onSubagentJump: (childSessionId: string) => void
|
||||
}) {
|
||||
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store } = props
|
||||
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump } = props
|
||||
const scope = { sessionId, cwd }
|
||||
switch (tab.type) {
|
||||
case 'explorer':
|
||||
@@ -64,6 +70,15 @@ function TabContent(props: {
|
||||
return <TerminalView scope={scope} tabId={tab.id} store={store} />
|
||||
case 'editor':
|
||||
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
case 'subagent':
|
||||
return (
|
||||
<SubagentView
|
||||
sessionId={sessionId}
|
||||
ctx={ctx}
|
||||
active={visible}
|
||||
onOpenChild={(address) => { onSubagentJump(address.childSessionId) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +90,7 @@ function buildNewTabOptions(state: SidebarState): NewTabOption[] {
|
||||
return [
|
||||
{ id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) },
|
||||
{ id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) },
|
||||
{ id: 'subagent', label: t('openSubagent'), icon: tabTypeIcon('subagent', 16) },
|
||||
{
|
||||
id: 'terminal',
|
||||
label: terminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
|
||||
@@ -121,6 +137,52 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
}, [sessionId, summaryCwd])
|
||||
const cwd = summaryCwd ?? fetchedCwd
|
||||
|
||||
/**
|
||||
* Subagent auto-activation: the moment the current conversation spawns its
|
||||
* FIRST direct subagent (a 0 → N transition on the list feed) and the
|
||||
* "auto open" pref is on, open the panel (if collapsed) and focus the
|
||||
* Subagent page (single-instance: an existing tab is focused, never
|
||||
* duplicated). Switching to a session that already has subagents never
|
||||
* triggers — its baseline starts at the current count — so a deliberate
|
||||
* layout is never fought.
|
||||
*/
|
||||
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const prev = listBaselineRef.current
|
||||
listBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
|
||||
id: 'subagent',
|
||||
type: 'subagent',
|
||||
title: t('subagent'),
|
||||
}))
|
||||
}, [sessionList, sessionId, store])
|
||||
|
||||
/**
|
||||
* Topology jump-back: clicking a subagent node on the Subagent page calls
|
||||
* the official `openSubagent`, which switches the sidebar to that child
|
||||
* session's OWN layout (a fresh child session defaults to the explorer).
|
||||
* The README contract says the Subagent page must stay open with the jumped
|
||||
* node highlighted — so once the current session becomes the recorded jump
|
||||
* target, re-open the Subagent page on top of the child's layout (expanding
|
||||
* the panel first if it is collapsed). Only this explicit node click arms
|
||||
* the flag, so switching to a subagent session by any other means keeps
|
||||
* that session's own layout untouched.
|
||||
*/
|
||||
const subagentJumpRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const pending = subagentJumpRef.current
|
||||
if (pending === undefined || sessionId !== pending) return
|
||||
subagentJumpRef.current = undefined
|
||||
store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
|
||||
id: 'subagent',
|
||||
type: 'subagent',
|
||||
title: t('subagent'),
|
||||
}))
|
||||
}, [sessionId, store])
|
||||
|
||||
// Panel width drag (left edge strip).
|
||||
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
||||
const [draggingWidth, setDraggingWidth] = useState(false)
|
||||
@@ -231,6 +293,9 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
if (optionId === 'git') {
|
||||
return openTab(s, { id: 'git', type: 'git', title: t('git') })
|
||||
}
|
||||
if (optionId === 'subagent') {
|
||||
return openTab(s, { id: 'subagent', type: 'subagent', title: t('subagent') })
|
||||
}
|
||||
if (optionId === 'terminal') {
|
||||
const count = allLeaves(s.splits).flatMap(leaf => leaf.tabs).filter(tab => tab.type === 'terminal').length
|
||||
if (count >= TERMINAL_LIMIT) return s
|
||||
@@ -251,7 +316,13 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
* ctx and the conversation input service at click time; a missing service
|
||||
* or scope degrades to a logged no-op, never a crash.
|
||||
*/
|
||||
const renderTab = (tab: SidebarTab) => (
|
||||
/**
|
||||
* Render one tab's content. `active` (from the workbench) tells whether
|
||||
* this tab is the active one in its pane; combined with the panel's
|
||||
* open/closed state it gates live views (the Subagent topology pauses its
|
||||
* polling while the page is not actually visible).
|
||||
*/
|
||||
const renderTab = (tab: SidebarTab, active: boolean) => (
|
||||
<TabContent
|
||||
tab={tab}
|
||||
sessionId={sessionId}
|
||||
@@ -261,6 +332,8 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||
onReferenceFile={referenceInChat}
|
||||
ctx={ctx}
|
||||
store={store}
|
||||
visible={state.panelOpen && active}
|
||||
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/* Subagent page: main-agent topology tree. Row/card chrome mirrors the
|
||||
official ui-subagent catalog row (min-height 50, 7/8/11 padding, radius 8,
|
||||
flex-start alignment, tree connector lines via border-left hairlines). */
|
||||
.subagent {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.subagentHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 8px 0 12px;
|
||||
}
|
||||
|
||||
.subagentTitle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The descendant count badge ("3 · 1 运行中") beside the title. */
|
||||
.subagentCount {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.subagentRefresh {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subagentRefresh:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.subagentBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 2px 6px 8px;
|
||||
}
|
||||
|
||||
/* One node card: state dot + label + secondary + (running) live lines. */
|
||||
.subagentRow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
padding: 7px 8px 7px 11px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.subagentRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Keyboard focus (mirror of the official catalog row): a visible fill only
|
||||
when focused via keyboard; the selected state below still wins on top. */
|
||||
.subagentRow:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* The currently-open session, highlighted in place (mirror of the workspace
|
||||
`.sessionRow.selected` — declared after the hover/focus rules so it wins). */
|
||||
.subagentRowActive,
|
||||
.subagentRowActive:hover,
|
||||
.subagentRowActive:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.subagentRowDisabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.subagentRowDisabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Loading placeholders are disabled but keep the default cursor (official
|
||||
`.loadingRow` distinction from `.disabled`). */
|
||||
.subagentRowLoading {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* The state dot aligns to the first text line (official catalog detail). */
|
||||
.subagentDot {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.subagentContent {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.subagentLabel,
|
||||
.subagentSecondary {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subagentLabel {
|
||||
color: inherit;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.subagentSecondary {
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* ── Live lines (running cards only) ──────────────────────────────────── */
|
||||
|
||||
.subagentLive {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subagentLiveTool {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.subagentLiveArgs {
|
||||
min-width: 0;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: var(--dsw-font-xxxs-11-font-size);
|
||||
line-height: var(--dsw-font-xxxs-11-line-height);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subagentLiveText {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* ── Tree connector lines (official catalog recipe) ────────────────────── */
|
||||
|
||||
.subagentNode {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subagentChildren {
|
||||
position: relative;
|
||||
margin-left: 18px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.subagentChildren::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -26px;
|
||||
height: 26px;
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.subagentChildren[aria-busy='true']::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.subagentChildren > .subagentNode::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.subagentChildren > .subagentNode:last-child::before {
|
||||
bottom: auto;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.subagentChildren > .subagentNode > .subagentRow::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: -4px;
|
||||
width: 14px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.subagentEmpty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 16px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.subagentEmptyHint {
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
}
|
||||
|
||||
.subagentError {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.subagentErrorRetry {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subagentErrorRetry:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
/**
|
||||
* Subagent page: the FULL agent topology of the current tree's main session.
|
||||
*
|
||||
* The root is resolved by walking the durable parent chain upward from the
|
||||
* current session to the first non-subagent session — the MAIN session — and
|
||||
* every subagent under it shares this one topology view, no matter how deep
|
||||
* the current selection is (including a subagent transcript opened in the
|
||||
* main view). The main agent renders as the root node card (click it to jump
|
||||
* back to the main session), with its subagents hanging below it in clearly
|
||||
* LAYERED levels: tree connector lines (first level included) and per-level
|
||||
* indentation show the hierarchy, and the currently-open session is
|
||||
* highlighted in place. Every branch is expanded automatically (lazy
|
||||
* catalogs hydrate on demand and consume live membership while visible).
|
||||
*
|
||||
* Each node card carries live status (state dot, durable label, mode and
|
||||
* activity); while a child RUNS, its card additionally shows the LAST text
|
||||
* output and LAST tool call pulled from its history tail, auto-refreshing
|
||||
* every few seconds while the page is visible. Clicking a card jumps
|
||||
* straight into the child transcript (`openSubagent`); the page stays open
|
||||
* and the topology remains rooted at the main session.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconRefreshOutline14, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
Context,
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentAddress,
|
||||
SidebarSubagentCatalog,
|
||||
SidebarSubagentChildEntry,
|
||||
SidebarSubagentDiagnosticEntry,
|
||||
} from '../context-types.ts'
|
||||
import {
|
||||
collectBranchIds,
|
||||
countSubagentDescendants,
|
||||
rootAncestor,
|
||||
} from './subagent-detect.ts'
|
||||
import { lastActivity } from './subagent-activity.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './SubagentView.module.css'
|
||||
|
||||
/** Refresh cadence of the live "last text + tool call" lines while a child runs. */
|
||||
const POLL_MS = 3000
|
||||
/** Preview cap of one tool-call argument line. */
|
||||
const ARGS_PREVIEW = 60
|
||||
|
||||
/** The direct subagent children of one parent (durable `origin` rows). */
|
||||
function directChildren(
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>,
|
||||
parentSessionId: string,
|
||||
): SidebarSessionSummary[] {
|
||||
return Object.values(byId).filter(
|
||||
summary => summary.origin === 'subagent' && summary.parentId === parentSessionId,
|
||||
)
|
||||
}
|
||||
|
||||
/** Human label of one catalog child: durable label, then summary title, then id. */
|
||||
function childLabel(
|
||||
entry: SidebarSubagentChildEntry,
|
||||
summary: SidebarSessionSummary | undefined,
|
||||
): string {
|
||||
return entry.label ?? summary?.displayTitle ?? entry.id
|
||||
}
|
||||
|
||||
function diagnosticReason(entry: SidebarSubagentDiagnosticEntry): string {
|
||||
switch (entry.reason) {
|
||||
case 'corrupt': return t('subagentDiagCorrupt')
|
||||
case 'unsupported': return t('subagentDiagUnsupported')
|
||||
case 'unavailable': return t('subagentDiagUnavailable')
|
||||
}
|
||||
}
|
||||
|
||||
/** The secondary line of one card: title · mode · activity (skips empty parts). */
|
||||
function cardSecondary(
|
||||
summary: SidebarSessionSummary | undefined,
|
||||
entry: SidebarSubagentChildEntry,
|
||||
): string {
|
||||
return [
|
||||
summary?.displayTitle,
|
||||
entry.mode === 'one-shot' ? t('subagentModeOneShot') : t('subagentModeContinuable'),
|
||||
entry.activity === 'running' ? t('subagentRunning') : t('subagentInactive'),
|
||||
].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
/** First `limit` characters with an ellipsis when truncated. */
|
||||
function preview(text: string, limit: number): string {
|
||||
return text.length > limit ? `${text.slice(0, limit)}…` : text
|
||||
}
|
||||
|
||||
/** Collapse whitespace for the single-paragraph live-text preview. */
|
||||
function flatten(text: string): string {
|
||||
return text.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
/** Disabled "loading…" cards backed by the summary mirror while a catalog hydrates. */
|
||||
function CatalogLoadingRows(props: {
|
||||
parentSessionId: string
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>
|
||||
level: number
|
||||
}) {
|
||||
const { parentSessionId, byId, level } = props
|
||||
const children = directChildren(byId, parentSessionId)
|
||||
if (children.length === 0) {
|
||||
return <div className={css.subagentEmpty}>{t('loading')}</div>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{children.map(summary => (
|
||||
<div
|
||||
key={summary.id}
|
||||
role="treeitem"
|
||||
aria-disabled="true"
|
||||
aria-level={level}
|
||||
aria-label={t('loading')}
|
||||
className={`${css.subagentRow} ${css.subagentRowDisabled} ${css.subagentRowLoading}`}
|
||||
>
|
||||
<StateDot state={summary.running === true ? 'ongoing' : 'done'} className={css.subagentDot} />
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{t('loading')}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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…".
|
||||
*/
|
||||
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) {
|
||||
return <span className={css.subagentLive}>{t('subagentThinking')}</span>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{live.tool !== undefined && (
|
||||
<span className={css.subagentLive}>
|
||||
<span className={css.subagentLiveTool}>{live.tool.name}</span>
|
||||
{live.tool.args !== '' && (
|
||||
<span className={css.subagentLiveArgs}>{preview(live.tool.args, ARGS_PREVIEW)}</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{live.text !== undefined && (
|
||||
<span className={css.subagentLiveText}>{flatten(live.text)}</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface RowsProps {
|
||||
parentSessionId: string
|
||||
catalog: SidebarSubagentCatalog | undefined
|
||||
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>
|
||||
byId: Readonly<Record<string, SidebarSessionSummary>>
|
||||
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
|
||||
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,
|
||||
openChild, refresh,
|
||||
}: RowsProps) {
|
||||
const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0
|
||||
return (
|
||||
<>
|
||||
{emptyLoading && (
|
||||
<CatalogLoadingRows parentSessionId={parentSessionId} byId={byId} level={level} />
|
||||
)}
|
||||
{catalog?.state === 'error' && (
|
||||
<div className={css.subagentError}>
|
||||
<span>{catalog.error?.message ?? t('error')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.subagentErrorRetry}
|
||||
onClick={() => { refresh(parentSessionId) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
{t('retry')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(catalog?.entries ?? []).map((entry) => {
|
||||
if (entry.kind === 'diagnostic') {
|
||||
return (
|
||||
<div key={entry.id} className={css.subagentNode}>
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-disabled="true"
|
||||
aria-level={level}
|
||||
className={`${css.subagentRow} ${css.subagentRowDisabled}`}
|
||||
title={diagnosticReason(entry)}
|
||||
>
|
||||
<StateDot state="error" className={css.subagentDot} />
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>{entry.id}</span>
|
||||
<span className={css.subagentSecondary}>{diagnosticReason(entry)}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const childCatalog = catalogs[entry.id]
|
||||
const knownLeaf = !entry.hasChildren
|
||||
const summary = byId[entry.id]
|
||||
const label = childLabel(entry, summary)
|
||||
const secondary = cardSecondary(summary, entry)
|
||||
const childLoading = childCatalog === undefined
|
||||
|| (childCatalog.state === 'loading' && childCatalog.entries.length === 0)
|
||||
const address: SidebarSubagentAddress = {
|
||||
parentSessionId,
|
||||
childSessionId: entry.id,
|
||||
mode: entry.mode,
|
||||
}
|
||||
const current = entry.id === currentSessionId
|
||||
|
||||
return (
|
||||
<div key={entry.id} className={css.subagentNode}>
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={level}
|
||||
aria-label={`${label} ${secondary}`}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
{...knownLeaf ? {} : { 'aria-expanded': true }}
|
||||
className={clsx(css.subagentRow, current && css.subagentRowActive)}
|
||||
onClick={() => { openChild(address) }}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openChild(address)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={entry.activity === 'running' ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{!knownLeaf && (
|
||||
<div role="group" className={css.subagentChildren} aria-busy={childLoading || undefined}>
|
||||
{childCatalog === undefined
|
||||
? (
|
||||
<CatalogLoadingRows
|
||||
parentSessionId={entry.id}
|
||||
byId={byId}
|
||||
level={level + 1}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<CatalogRows
|
||||
parentSessionId={entry.id}
|
||||
catalog={childCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={level + 1}
|
||||
currentSessionId={currentSessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar's Subagent topology page.
|
||||
* @param props - current session id, whether the page is actually visible
|
||||
* (active tab + open panel), the client context, and an optional
|
||||
* jump-notify hook fired right before `openSubagent` (lets the sidebar
|
||||
* shell re-open the Subagent page after the conversation switch lands on
|
||||
* the child session).
|
||||
* @returns the main agent's topology tree, or the empty/error/loading states.
|
||||
*/
|
||||
export function SubagentView(props: {
|
||||
sessionId: string
|
||||
active: boolean
|
||||
ctx: Context
|
||||
onOpenChild?: (address: SidebarSubagentAddress) => void
|
||||
}) {
|
||||
const { sessionId, active, ctx, onOpenChild } = props
|
||||
const sessions = ctx.sessions
|
||||
|
||||
// The same list feed the official catalog consumes (byId lineage + the
|
||||
// lazy per-parent catalogs). Older DSH snapshots without the subagent seam
|
||||
// simply leave these surfaces empty — the page degrades to the empty state.
|
||||
const list = useSyncExternalStore(
|
||||
useMemo(() => (callback: () => void) => sessions.list.subscribe(callback), [sessions]),
|
||||
useCallback(() => sessions.list.getSnapshot(), [sessions]),
|
||||
)
|
||||
const byId = list.byId
|
||||
const catalogs = list.subagentsByParent ?? {}
|
||||
|
||||
// The topology root: the main agent of the current session's tree.
|
||||
const rootId = useMemo(() => rootAncestor(byId, sessionId), [byId, sessionId])
|
||||
const rootCatalog = rootId === undefined ? undefined : catalogs[rootId]
|
||||
const rootSummary = rootId === undefined ? undefined : byId[rootId]
|
||||
|
||||
/** Catalog owners currently consuming live membership updates. */
|
||||
const observedRef = useRef(new Set<string>())
|
||||
|
||||
const observe = useCallback((parentSessionId: string, open: boolean): void => {
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, open)
|
||||
if (open) observedRef.current.add(parentSessionId)
|
||||
else observedRef.current.delete(parentSessionId)
|
||||
}, [sessions])
|
||||
|
||||
// While the page is visible the topology root consumes live membership; a
|
||||
// root change (switching to another main agent's tree) or the page hiding
|
||||
// (tab switched away / panel collapsed) releases everything observed.
|
||||
useEffect(() => {
|
||||
if (rootId === undefined || !active) return
|
||||
observe(rootId, true)
|
||||
return () => {
|
||||
for (const parentSessionId of observedRef.current) {
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
|
||||
}
|
||||
observedRef.current.clear()
|
||||
}
|
||||
}, [rootId, active, observe, sessions])
|
||||
|
||||
// Every branch of the always-expanded topology consumes live membership
|
||||
// (add-only: a branch stays observed until the root changes or the page
|
||||
// hides, which releases the whole set via the root effect's cleanup).
|
||||
const branches = useMemo(() => collectBranchIds(catalogs, rootId), [catalogs, rootId])
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
for (const id of branches) {
|
||||
if (!observedRef.current.has(id)) observe(id, true)
|
||||
}
|
||||
}, [branches, active, observe])
|
||||
|
||||
// Unobserve everything on unmount (the host stops refreshing unused catalogs).
|
||||
useEffect(() => () => {
|
||||
for (const parentSessionId of observedRef.current) {
|
||||
sessions.setSubagentCatalogOpen?.(parentSessionId, false)
|
||||
}
|
||||
observedRef.current.clear()
|
||||
}, [sessions])
|
||||
|
||||
const openChild = useCallback((address: SidebarSubagentAddress): void => {
|
||||
// Notify the shell first: the jump switches the sidebar to the child
|
||||
// session's own layout, and the shell re-opens the Subagent page on top
|
||||
// of it (the topology stays rooted at the main agent with the child
|
||||
// highlighted) — the README "page stays open" contract.
|
||||
onOpenChild?.(address)
|
||||
try {
|
||||
sessions.openSubagent?.(address)
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] openSubagent failed:', error)
|
||||
}
|
||||
}, [sessions, onOpenChild])
|
||||
|
||||
/** Jump back to the main agent (the topology root) from its node. */
|
||||
const openMain = useCallback((): void => {
|
||||
if (rootId === undefined) return
|
||||
try {
|
||||
sessions.open?.(rootId)
|
||||
} catch (error) {
|
||||
console.warn('[dsh-better-sidebar] open session failed:', error)
|
||||
}
|
||||
}, [sessions, rootId])
|
||||
|
||||
const refresh = useCallback((parentSessionId: string): void => {
|
||||
void sessions.refreshSubagents?.(parentSessionId)
|
||||
}, [sessions])
|
||||
|
||||
const totals = useMemo(
|
||||
() => rootId === undefined
|
||||
? { count: 0, runningCount: 0 }
|
||||
: countSubagentDescendants(byId, rootId),
|
||||
[byId, rootId],
|
||||
)
|
||||
// Session summaries can announce membership before the descriptor-backed
|
||||
// catalog catches up (or a catalog that just went ready is still empty).
|
||||
const summaryBackedLoading = rootId !== undefined
|
||||
&& (rootCatalog === undefined || (rootCatalog.state === 'ready' && rootCatalog.entries.length === 0))
|
||||
&& directChildren(byId, rootId).length > 0
|
||||
const readyEmpty = rootCatalog?.state === 'ready'
|
||||
&& rootCatalog.entries.length === 0
|
||||
&& directChildren(byId, rootId ?? '').length === 0
|
||||
const countLabel = totals.count === 0
|
||||
? undefined
|
||||
: totals.runningCount > 0
|
||||
? t('subagentCountRunning', { count: totals.count, running: totals.runningCount })
|
||||
: t('subagentCount', { count: totals.count })
|
||||
|
||||
/** Arrow-key tree navigation over the visible rows (official catalog recipe). */
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const focusAt = useCallback((index: number): void => {
|
||||
const items = bodyRef.current?.querySelectorAll<HTMLElement>(
|
||||
'[role="treeitem"]:not([aria-disabled="true"])',
|
||||
) ?? []
|
||||
if (items.length === 0) return
|
||||
items[(index + items.length) % items.length]?.focus()
|
||||
}, [])
|
||||
const onTreeKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const items = bodyRef.current?.querySelectorAll<HTMLElement>(
|
||||
'[role="treeitem"]:not([aria-disabled="true"])',
|
||||
) ?? []
|
||||
const index = Array.prototype.indexOf.call(items, document.activeElement)
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
focusAt(index + 1)
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
focusAt(index < 0 ? items.length - 1 : index - 1)
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
focusAt(0)
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
focusAt(items.length - 1)
|
||||
}
|
||||
}, [focusAt])
|
||||
|
||||
return (
|
||||
<div className={css.subagent}>
|
||||
<div className={css.subagentHeader}>
|
||||
<span className={css.subagentTitle}>
|
||||
{t('subagent')}
|
||||
{rootSummary?.displayTitle !== undefined && rootSummary.displayTitle !== ''
|
||||
? ` · ${rootSummary.displayTitle}`
|
||||
: ''}
|
||||
</span>
|
||||
{countLabel !== undefined && <span className={css.subagentCount}>{countLabel}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={css.subagentRefresh}
|
||||
aria-label={t('refresh')}
|
||||
title={t('refresh')}
|
||||
disabled={rootId === undefined}
|
||||
onClick={() => { if (rootId !== undefined) refresh(rootId) }}
|
||||
>
|
||||
<IconRefreshOutline14 />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
className={css.subagentBody}
|
||||
role="tree"
|
||||
aria-label={t('subagent')}
|
||||
aria-busy={summaryBackedLoading || undefined}
|
||||
onKeyDown={onTreeKeyDown}
|
||||
>
|
||||
{rootId !== undefined && rootSummary !== undefined && (
|
||||
<div
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-level={0}
|
||||
aria-label={`${rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')} ${t('subagentMainAgent')}`}
|
||||
aria-current={rootId === sessionId ? 'true' : undefined}
|
||||
className={clsx(css.subagentRow, rootId === sessionId && css.subagentRowActive)}
|
||||
onClick={openMain}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
openMain()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<StateDot
|
||||
state={rootSummary.running === true ? 'ongoing' : 'done'}
|
||||
className={css.subagentDot}
|
||||
/>
|
||||
<span className={css.subagentContent}>
|
||||
<span className={css.subagentLabel}>
|
||||
{rootSummary.displayTitle !== '' ? rootSummary.displayTitle : t('subagentMainAgent')}
|
||||
</span>
|
||||
<span className={css.subagentSecondary}>
|
||||
{`${t('subagentMainAgent')} · ${rootSummary.running === true ? t('subagentRunning') : t('subagentInactive')}`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rootId !== undefined && (
|
||||
<div className={css.subagentChildren} role="group" aria-busy={summaryBackedLoading || undefined}>
|
||||
{summaryBackedLoading && (
|
||||
<CatalogLoadingRows parentSessionId={rootId} byId={byId} level={1} />
|
||||
)}
|
||||
{!summaryBackedLoading && (
|
||||
<CatalogRows
|
||||
parentSessionId={rootId}
|
||||
catalog={rootCatalog}
|
||||
catalogs={catalogs}
|
||||
byId={byId}
|
||||
level={1}
|
||||
currentSessionId={sessionId}
|
||||
active={active}
|
||||
ctx={ctx}
|
||||
openChild={openChild}
|
||||
refresh={refresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{readyEmpty && (
|
||||
<div className={css.subagentEmpty}>
|
||||
<div>{t('subagentEmpty')}</div>
|
||||
<div className={css.subagentEmptyHint}>{t('subagentEmptyDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+23
-1
@@ -5,7 +5,7 @@
|
||||
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
|
||||
* dropping on the strip background appends to this pane.
|
||||
*/
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCloseFill14, IconPlusOutline16, Menu,
|
||||
@@ -46,6 +46,12 @@ export function parseDrag(raw: string): TabDragPayload | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** Global tab-drag flag: PDF iframes become non-interactive synchronously. */
|
||||
function setTabDragging(active: boolean): void {
|
||||
if (active) document.body.setAttribute('data-dsh-tab-dragging', '')
|
||||
else document.body.removeAttribute('data-dsh-tab-dragging')
|
||||
}
|
||||
|
||||
export function TabBar(props: {
|
||||
paneId: string
|
||||
tabs: SidebarTab[]
|
||||
@@ -63,6 +69,18 @@ export function TabBar(props: {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const clear = (): void => { setTabDragging(false); setDragOver(false) }
|
||||
window.addEventListener('dragend', clear, true)
|
||||
window.addEventListener('drop', clear, true)
|
||||
window.addEventListener('blur', clear)
|
||||
return () => {
|
||||
window.removeEventListener('dragend', clear, true)
|
||||
window.removeEventListener('drop', clear, true)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
|
||||
@@ -79,6 +97,7 @@ export function TabBar(props: {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setDragOver(false)
|
||||
setTabDragging(false)
|
||||
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
||||
const payload = parseDrag(raw)
|
||||
if (payload !== null) onDropTab(payload, null)
|
||||
@@ -92,13 +111,16 @@ export function TabBar(props: {
|
||||
title={tab.title}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
setTabDragging(true)
|
||||
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}}
|
||||
onDragEnd={() => { setTabDragging(false); setDragOver(false) }}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
setTabDragging(false)
|
||||
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
|
||||
const payload = parseDrag(raw)
|
||||
if (payload !== null) onDropTab(payload, tab.id)
|
||||
|
||||
@@ -119,6 +119,15 @@ export const api = {
|
||||
* this explicit route). */
|
||||
ptyClose: (scope: SessionScope, tab: string) =>
|
||||
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
|
||||
/** Read the side card preferences (plugin-global, no session scope). */
|
||||
settingsGet: () =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.get', {}),
|
||||
/** Merge a patch into the side card preferences (revision-guarded). */
|
||||
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
|
||||
call<{ value?: unknown; revision?: number }>('settings.update', {
|
||||
patch,
|
||||
...(expectedRevision !== undefined ? { expectedRevision } : {}),
|
||||
}),
|
||||
}
|
||||
|
||||
/** Absolute URL of the media route for one path (images only). */
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16,
|
||||
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TabType } from './state.ts'
|
||||
@@ -28,6 +28,7 @@ export function tabTypeIcon(type: TabType, size = 16): ReactNode {
|
||||
case 'explorer': return <IconFolderOpen16 size={size} />
|
||||
case 'git': return <IconBranchOutline16 size={size} />
|
||||
case 'terminal': return <IconTerminalOutline16 size={size} />
|
||||
case 'subagent': return <IconThinkOutline16 size={size} />
|
||||
case 'editor': return <IconCodeOutline16 size={size} />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/** Image extension dispatch, separate from React for unit testing. */
|
||||
export const IMAGE_EXTENSIONS = [
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif',
|
||||
] as readonly string[]
|
||||
|
||||
export function isImageExt(ext: string): boolean {
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
+54
-20
@@ -1,22 +1,28 @@
|
||||
/**
|
||||
* Client half of dsh-better-sidebar: mounts the right sidebar portal (inside
|
||||
* an error boundary so a rendering failure shows an error strip instead of a
|
||||
* blank panel) and registers the turn-tail interception. Requires the
|
||||
* runtime's slots and sessions services; the bundle itself is a module-table
|
||||
* consumer only (react + ui-primitives + xterm, all provided or inlined).
|
||||
* Client half of dsh-better-sidebar: resolves the user's "Side card"
|
||||
* preferences through the plugin's own fenced settings route, mounts the
|
||||
* right sidebar portal (inside an error boundary so a rendering failure
|
||||
* shows an error strip instead of a blank panel), registers the turn-tail
|
||||
* interception, and contributes the Side card settings section to the DSH
|
||||
* Settings shell. Requires the runtime's slots and sessions services; the
|
||||
* bundle itself is a module-table consumer only (react + ui-primitives +
|
||||
* xterm, all provided or inlined).
|
||||
*/
|
||||
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import { createSidebarStore } from './state.ts'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { registerTurnTailInterception } from './intercept.tsx'
|
||||
import { loadPrefs } from './prefs.ts'
|
||||
import { SideCardSection } from './SideCardSection.tsx'
|
||||
import { api } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
import './layout.css'
|
||||
|
||||
/** Services required before mounting (provided by the client runtime). */
|
||||
export const inject = ['slots', 'sessions']
|
||||
export const inject = ['slots', 'sessions', 'connection']
|
||||
|
||||
/**
|
||||
* Error boundary over the sidebar tree: a render error must never blank the
|
||||
@@ -60,7 +66,7 @@ class SidebarBoundary extends Component<{ children: ReactNode }, { error: string
|
||||
export function apply(ctx: Context): void {
|
||||
// One store instance per activation: production code creates it only here,
|
||||
// then hands it to the mounted panel and closes over it in the slot
|
||||
// registration (the official createXXXStore() factory rule — no
|
||||
// registrations (the official createXXXStore() factory rule — no
|
||||
// module-level singleton).
|
||||
const sidebarStore = createSidebarStore()
|
||||
// A failure anywhere in the client lifecycle must never take the app down
|
||||
@@ -81,19 +87,35 @@ export function apply(ctx: Context): void {
|
||||
}
|
||||
try {
|
||||
ctx.effect(() => {
|
||||
try {
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-dsh-better-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
const root = createRoot(host)
|
||||
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
return () => {
|
||||
root.unmount()
|
||||
host.remove()
|
||||
let disposed = false
|
||||
let root: Root | undefined
|
||||
let host: HTMLDivElement | undefined
|
||||
void (async () => {
|
||||
// Resolve the user's side card prefs BEFORE the first session seeds,
|
||||
// so a brand-new conversation opens (or stays closed) at the chosen
|
||||
// width from first paint. A settings route failure falls back to the
|
||||
// schema defaults; the sidebar still mounts (a stalled wire gives up
|
||||
// after the timeout and mounts on the defaults).
|
||||
const prefs = await Promise.race([
|
||||
loadPrefs(api),
|
||||
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
|
||||
])
|
||||
if (prefs !== null) sidebarStore.setPrefs(prefs)
|
||||
if (disposed) return
|
||||
try {
|
||||
host = document.createElement('div')
|
||||
host.setAttribute('data-dsh-better-sidebar', '')
|
||||
document.body.appendChild(host)
|
||||
root = createRoot(host)
|
||||
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||
} catch (error) {
|
||||
fail('mount', error)
|
||||
}
|
||||
} catch (error) {
|
||||
fail('mount', error)
|
||||
return undefined
|
||||
})()
|
||||
return () => {
|
||||
disposed = true
|
||||
root?.unmount()
|
||||
host?.remove()
|
||||
}
|
||||
}, 'dsh-better-sidebar: sidebar mount')
|
||||
|
||||
@@ -108,6 +130,18 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
'dsh-better-sidebar: turn-tail interception',
|
||||
)
|
||||
|
||||
// The "Side card" settings section: appears in the DSH Settings shell
|
||||
// once the shell's declaration is on the ledger (slots.inject waits for
|
||||
// it); the section reads/writes the prefs through the plugin's own
|
||||
// fenced settings route and keeps the shared store in sync.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'better-sidebar',
|
||||
order: 100,
|
||||
label: () => t('settingsNav'),
|
||||
inject: () => ({ store: sidebarStore }),
|
||||
}, SideCardSection))
|
||||
} catch (error) {
|
||||
fail('load', error)
|
||||
}
|
||||
|
||||
+79
-2
@@ -70,6 +70,41 @@ const zh = {
|
||||
copyRelative: '复制相对地址',
|
||||
copyAbsolute: '复制绝对地址',
|
||||
download: '下载',
|
||||
settingsNav: '侧边卡片',
|
||||
settingsOpenTitle: '新会话默认打开',
|
||||
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
|
||||
settingsWidthTitle: '默认宽度占比',
|
||||
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsSaveFailed: '保存失败',
|
||||
settingsConflict: '设置已被其他窗口修改,请重试',
|
||||
binaryNoPreview: '此文件类型不支持预览',
|
||||
downloadToView: '下载查看',
|
||||
officeTooLarge: '文件过大,无法预览',
|
||||
officeCorrupt: '文件损坏或格式无效',
|
||||
officeEncrypted: '不支持加密文件',
|
||||
officeLoadFailed: 'Office 预览组件加载失败',
|
||||
previousSlide: '上一页',
|
||||
nextSlide: '下一页',
|
||||
zoom: '缩放',
|
||||
zoomHint: 'Alt + 滚轮',
|
||||
settingsSubagentTitle: '检测到子代理时自动展开子代理页面',
|
||||
settingsSubagentDesc: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
|
||||
subagent: '子代理',
|
||||
openSubagent: '子代理',
|
||||
subagentMainAgent: '主代理',
|
||||
subagentEmpty: '暂无子代理',
|
||||
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
|
||||
subagentRunning: '运行中',
|
||||
subagentInactive: '空闲',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可续接',
|
||||
subagentCount: '{count} 个子代理',
|
||||
subagentCountRunning: '{count} 个子代理 · {running} 运行中',
|
||||
subagentDiagCorrupt: '目录损坏',
|
||||
subagentDiagUnsupported: '不支持的条目',
|
||||
subagentDiagUnavailable: '不可用',
|
||||
subagentThinking: '思考中…',
|
||||
}
|
||||
|
||||
const en: Record<keyof typeof zh, string> = {
|
||||
@@ -137,15 +172,57 @@ const en: Record<keyof typeof zh, string> = {
|
||||
copyRelative: 'Copy relative path',
|
||||
copyAbsolute: 'Copy absolute path',
|
||||
download: 'Download',
|
||||
settingsNav: 'Side card',
|
||||
settingsOpenTitle: 'Open by default for new conversations',
|
||||
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
|
||||
settingsWidthTitle: 'Default width share',
|
||||
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
|
||||
settingsWidthSuffix: '%',
|
||||
settingsSaveFailed: 'Failed to save',
|
||||
settingsConflict: 'The setting changed in another window — please retry',
|
||||
binaryNoPreview: 'This file type cannot be previewed',
|
||||
downloadToView: 'Download to view',
|
||||
officeTooLarge: 'File too large to preview',
|
||||
officeCorrupt: 'File is corrupt or in an invalid format',
|
||||
officeEncrypted: 'Encrypted files are not supported',
|
||||
officeLoadFailed: 'Office preview component failed to load',
|
||||
previousSlide: 'Previous',
|
||||
nextSlide: 'Next',
|
||||
zoom: 'Zoom',
|
||||
zoomHint: 'Alt + wheel',
|
||||
settingsSubagentTitle: 'Auto-open the Subagent page when a subagent appears',
|
||||
settingsSubagentDesc: 'Expand the side card and open the Subagent page when the current conversation spawns a new subagent; turn off to open it manually',
|
||||
subagent: 'Subagents',
|
||||
openSubagent: 'Subagents',
|
||||
subagentMainAgent: 'Main agent',
|
||||
subagentEmpty: 'No subagents',
|
||||
subagentEmptyDesc: 'Subagents spawned under the main agent will appear here',
|
||||
subagentRunning: 'Running',
|
||||
subagentInactive: 'Inactive',
|
||||
subagentModeOneShot: 'One-shot',
|
||||
subagentModeContinuable: 'Continuable',
|
||||
subagentCount: '{count} subagents',
|
||||
subagentCountRunning: '{count} subagents · {running} running',
|
||||
subagentDiagCorrupt: 'Corrupt',
|
||||
subagentDiagUnsupported: 'Unsupported',
|
||||
subagentDiagUnavailable: 'Unavailable',
|
||||
subagentThinking: 'Thinking…',
|
||||
}
|
||||
|
||||
/** Translate a copy key in the browser's language (zh → zh, else en). */
|
||||
export type CopyKey = keyof typeof zh
|
||||
|
||||
export function t(key: CopyKey): string {
|
||||
/** Translate a copy key; `{name}` placeholders interpolate from `params`. */
|
||||
export function t(key: CopyKey, params?: Record<string, string | number>): string {
|
||||
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en'
|
||||
const dict = lang.toLowerCase().startsWith('zh') ? zh : en
|
||||
return dict[key]
|
||||
let text = dict[key]
|
||||
if (params !== undefined) {
|
||||
for (const [name, value] of Object.entries(params)) {
|
||||
text = text.replaceAll(`{${name}}`, String(value))
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
/** Whether the browser language is Chinese (used for selectors). */
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Office file type dispatch — pure functions mapping file extensions to
|
||||
* preview kinds. Kept separate from {@link EditorView} so the mapping is
|
||||
* unit-testable without mounting React or pulling in docx-preview / Univer.
|
||||
*
|
||||
* The host side never inspects Office extensions: the binary NUL probe in
|
||||
* {@link ../index.ts} (readText) tags every Office file as binary, and the
|
||||
* /sidebar/file media route serves raw bytes for any extension. This module
|
||||
* is the client's sole source of truth for "is this previewable here".
|
||||
*/
|
||||
|
||||
/** Extensions rendered with docx-preview (preserved styles/images/tables). */
|
||||
export const OFFICE_DOCX_EXT = '.docx' as const
|
||||
|
||||
/** Extensions rendered with Univer (sheets: data + formulas + formatting). */
|
||||
export const OFFICE_XLSX_EXT = '.xlsx' as const
|
||||
|
||||
/** Extensions rendered with the browser-native PPTX viewer. */
|
||||
export const OFFICE_PPTX_EXT = '.pptx' as const
|
||||
|
||||
/** Extensions that get a download-only affordance (no client-side renderer). */
|
||||
export const OFFICE_DOWNLOAD_ONLY_EXT = ['.doc', '.xls', '.ppt'] as readonly string[]
|
||||
|
||||
/**
|
||||
* The preview strategy for one path's extension.
|
||||
* - `'docx'` — render via docx-preview.
|
||||
* - `'xlsx'` — render via Univer.
|
||||
* - `'pptx'` — render via pptx-renderer.
|
||||
* - `'download-only'` — show the binary download button (no preview renderer
|
||||
* covers legacy OLE .doc/.xls/.ppt on the client).
|
||||
* - `null` — not an Office file; the caller's existing image / md / text / binary
|
||||
* pipeline handles it.
|
||||
*/
|
||||
export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null
|
||||
|
||||
/**
|
||||
* Map a lowercased extension (with leading dot) to its preview kind.
|
||||
*
|
||||
* @param ext - the path extension from {@link EditorView.extOfPath} (`.docx`,
|
||||
* `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none.
|
||||
*/
|
||||
export function officeKindForExt(ext: string): OfficeKind {
|
||||
if (ext === OFFICE_DOCX_EXT) return 'docx'
|
||||
if (ext === OFFICE_XLSX_EXT) return 'xlsx'
|
||||
if (ext === OFFICE_PPTX_EXT) return 'pptx'
|
||||
if (OFFICE_DOWNLOAD_ONLY_EXT.includes(ext)) return 'download-only'
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Office preview components: {@link DocxView} renders .docx via docx-preview
|
||||
* (preserved styles/images/tables), {@link XlsxView} renders .xlsx via the
|
||||
* Univer sheets preset (data + formulas + formatting). Both load the file
|
||||
* bytes through the existing /sidebar/file media route and own their library
|
||||
* lifecycle (dispose on unmount so canvases/workers don't leak — mirrors the
|
||||
* TerminalView dispose discipline).
|
||||
*
|
||||
* Errors degrade to the same download-button affordance the binary
|
||||
* placeholder uses, so a corrupted / encrypted / oversized file always
|
||||
* leaves the user with a way to get the file.
|
||||
*
|
||||
* Both libraries pull in several MB; tsdown's `codeSplitting: false` inlines
|
||||
* them into the single client bundle (the CJS __ModuleLoader__ wrapper's
|
||||
* `require` cannot load relative chunk URLs in the browser, so dynamic
|
||||
* import() collapses to a synchronous inline — see tsdown.config.ts).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
|
||||
import css from './sidebar.module.css'
|
||||
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
|
||||
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
|
||||
// are present before the first .xlsx opens.
|
||||
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||
|
||||
/** Loading / ready / error state shared by both views. */
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready' }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
/** Shared props. */
|
||||
interface OfficeViewProps {
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a .docx file via docx-preview. The library renders into a container
|
||||
* div (no canvas); images and styles are inlined. Unmounting clears the
|
||||
* container's innerHTML — docx-preview has no dispose API, but tearing down
|
||||
* the DOM is enough.
|
||||
*/
|
||||
export function DocxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = viewportRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (container === null || wrap === null) return
|
||||
setZoom(100)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
// docx-preview ships its own CSS through the className option; the
|
||||
// wrapper div scopes its render output.
|
||||
const { renderAsync } = await import('docx-preview')
|
||||
await renderAsync(buf, wrap, undefined, {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
breakPages: true,
|
||||
experimental: false,
|
||||
})
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Tear down the rendered DOM so a reopen starts clean.
|
||||
if (wrap !== null) wrap.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
if (viewport === null) return
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (!event.altKey) return
|
||||
event.preventDefault()
|
||||
const delta = event.deltaY < 0 ? 10 : -10
|
||||
setZoom(current => Math.max(50, Math.min(200, current + delta)))
|
||||
}
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { viewport.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.editorDocx}>
|
||||
<div className={css.editorDocxViewport} ref={viewportRef}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
{load.status !== 'error' && (
|
||||
<div
|
||||
className={css.editorDocxWrap}
|
||||
ref={wrapRef}
|
||||
aria-label={title}
|
||||
style={{ zoom: zoom / 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={css.editorDocxZoom}>
|
||||
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
|
||||
<input
|
||||
className={css.editorDocxZoomRange}
|
||||
type="range"
|
||||
min={50}
|
||||
max={200}
|
||||
step={10}
|
||||
value={zoom}
|
||||
aria-label={t('zoom')}
|
||||
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
|
||||
/>
|
||||
<span className={css.editorDocxZoomValue}>{zoom}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
|
||||
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
|
||||
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
|
||||
* — without it the canvas, workers, and DOM listeners leak (mirrors the
|
||||
* xterm dispose discipline in TerminalView).
|
||||
*/
|
||||
export function XlsxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const univerRef = useRef<{ dispose: () => void } | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
|
||||
// Dynamic imports — collapsed into the bundle by codeSplitting:false.
|
||||
// The dynamic form keeps the source readable: each lib is only pulled
|
||||
// in when an .xlsx is actually opened (semantically; the bundle still
|
||||
// contains all of them).
|
||||
const XLSX = await import('xlsx')
|
||||
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
|
||||
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
|
||||
// Locales pick the browser language; falls back to en-US.
|
||||
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
|
||||
const localePack = await (isZh
|
||||
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
|
||||
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
|
||||
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
|
||||
|
||||
if (cancelled) return
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale,
|
||||
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
|
||||
presets: [UniverSheetsCorePreset({ container: host })],
|
||||
})
|
||||
univerRef.current = univer
|
||||
univerAPI.createWorkbook(workbookData)
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Partially initialized instance — ignore disposal errors.
|
||||
}
|
||||
univerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Critical: dispose the Univer instance (canvas + workers + listeners).
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Already torn down — ignore.
|
||||
}
|
||||
univerRef.current = null
|
||||
// Clear the host in case dispose left DOM behind.
|
||||
if (host !== null) host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
return (
|
||||
<div className={css.editorXlsx} aria-label={title}>
|
||||
{/* Univer exclusively owns this element's descendants. React only owns
|
||||
the sibling overlay, so Univer cannot remove a React child. */}
|
||||
<div className={css.editorUniverHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared error / fallback affordance: the failure reason plus a download
|
||||
* link, so the user always has a path to the file. Used by both DocxView and
|
||||
* XlsxView, and matches the binary placeholder's download link in EditorView.
|
||||
*/
|
||||
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
|
||||
const { scope, path, message } = props
|
||||
return (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{message}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Pure PDF extension dispatch, separate from React for unit testing. */
|
||||
export function isPdfExt(ext: string): boolean {
|
||||
return ext === '.pdf'
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Client-side read of the user-facing "Side card" preferences. The host owns
|
||||
* the namespace through the settings seam (in-process); the DSH settings RPC
|
||||
* domain only serves allowlisted namespaces to configuration clients, so the
|
||||
* client reads and writes THIS namespace through the plugin's own fenced
|
||||
* /sidebar routes instead (api.settingsGet/settingsUpdate). Any failure
|
||||
* (route rejected, namespace absent, a field of the wrong type, a value out
|
||||
* of the contract range) falls back to the schema defaults — the side card
|
||||
* must keep working exactly as composed when the settings surface is missing.
|
||||
*/
|
||||
import type { api } from './api.ts'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
|
||||
export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent }
|
||||
export type { SidebarPrefs }
|
||||
|
||||
/** The settings wire face the preferences need (a subset of the plugin api). */
|
||||
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
|
||||
|
||||
/**
|
||||
* Validate one raw resolved value into {@link SidebarPrefs}. Used for the
|
||||
* settings.get payload AND the settings.update response (both carry the
|
||||
* layered resolved value); any malformed field falls back to its default.
|
||||
* @param value - the raw resolved section from the settings wire.
|
||||
* @returns validated prefs (always well-formed).
|
||||
*/
|
||||
export function parsePrefs(value: unknown): SidebarPrefs {
|
||||
if (value === null || typeof value !== 'object') return { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
const record = value as Record<string, unknown>
|
||||
return {
|
||||
openByDefault: typeof record.openByDefault === 'boolean'
|
||||
? record.openByDefault
|
||||
: SIDEBAR_PREFS_DEFAULTS.openByDefault,
|
||||
defaultWidthPercent: typeof record.defaultWidthPercent === 'number' && Number.isFinite(record.defaultWidthPercent)
|
||||
? clampWidthPercent(record.defaultWidthPercent)
|
||||
: SIDEBAR_PREFS_DEFAULTS.defaultWidthPercent,
|
||||
autoOpenSubagent: typeof record.autoOpenSubagent === 'boolean'
|
||||
? record.autoOpenSubagent
|
||||
: SIDEBAR_PREFS_DEFAULTS.autoOpenSubagent,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the resolved side card preferences through the plugin's settings route.
|
||||
* @param settings - the settings wire face (the plugin api by default).
|
||||
* @returns validated prefs, or the schema defaults when the route rejects,
|
||||
* the namespace is absent, or a stored value violates the contract.
|
||||
*/
|
||||
export async function loadPrefs(settings: SidebarSettingsClient): Promise<SidebarPrefs> {
|
||||
try {
|
||||
const view = await settings.settingsGet()
|
||||
return parsePrefs(view.value)
|
||||
} catch {
|
||||
// Transport/fence rejection or a malformed response: keep the defaults.
|
||||
return { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
}
|
||||
}
|
||||
+307
-30
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* Sidebar styles. Per the DSH web-styling rules every visual value rides the
|
||||
* theme: --dsw-alias-* semantic tokens for colors, --dsw-font-* typography
|
||||
* roles (size paired with line-height), --dsw-shadow-* for elevation, and
|
||||
* --ds-* for motion. No static palette values, no color literals, no theme
|
||||
* selectors — light/dark adaptation is the theme package's job.
|
||||
* roles (size paired with line-height), and --ds-* for motion. The chrome
|
||||
* mirrors the native DSH sidebar: flat (NO box-shadows anywhere), the
|
||||
* sidebar fill surface, hairline borders, and the 28/36px circular icon
|
||||
* controls with hover fills. No static palette values, no color literals, no
|
||||
* theme selectors — light/dark adaptation is the theme package's job.
|
||||
*/
|
||||
|
||||
/* ── Shell ─────────────────────────────────────────────────────────────── */
|
||||
@@ -16,28 +18,31 @@
|
||||
z-index: 55;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Native rail inset: the 36px control sits 10px off the column edge. */
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
/* The closed-state affordance is the native rail icon control: a plain 36px
|
||||
circle, transparent fill, secondary ink; hover raises the fill. No border,
|
||||
no shadow — the icon alone is the affordance (SidebarRoot .iconButton). */
|
||||
.toggleButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 64px;
|
||||
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
|
||||
border-right: none;
|
||||
border-radius: 10px 0 0 10px;
|
||||
background: var(--dsw-alias-button-floating-fill);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
box-shadow: var(--dsw-shadow-lv1);
|
||||
transition:
|
||||
background var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
color var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.toggleButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -54,9 +59,10 @@
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
/* The native sidebar surface: bluish-50 over white / bluish-900 over dark,
|
||||
exactly like the app's own sidebar column (SidebarRoot .root). */
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
border-left: 1px solid var(--dsw-alias-border-l2);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
/* Expand/collapse slides the panel; fullscreen animates the width. */
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
@@ -67,7 +73,6 @@
|
||||
.panelHidden {
|
||||
transform: translateX(102%);
|
||||
pointer-events: none;
|
||||
box-shadow: none;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
@@ -107,7 +112,7 @@
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -179,9 +184,18 @@
|
||||
}
|
||||
|
||||
.divider {
|
||||
position: relative;
|
||||
flex: none;
|
||||
z-index: 3;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* The hit area stays 7px wide enough to grab, but the visual is a 1px
|
||||
hairline centered in it — a divider line, not a thick band. */
|
||||
.dividerRow::after,
|
||||
.dividerCol::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--dsw-alias-border-l2);
|
||||
transition: background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
@@ -192,14 +206,30 @@
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.dividerRow::after {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.dividerCol {
|
||||
height: 7px;
|
||||
margin: -2px 0;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.divider:hover,
|
||||
.dividerActive {
|
||||
.dividerCol::after {
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
height: 1px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.divider:hover::after,
|
||||
.dividerActive::after {
|
||||
background: var(--dsw-alias-interactive-bg-hover-accent);
|
||||
}
|
||||
|
||||
@@ -371,8 +401,8 @@
|
||||
|
||||
.tabActive {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-bg-base);
|
||||
box-shadow: inset 0 -2px 0 var(--dsw-alias-brand-primary);
|
||||
/* Native selected fill (Rows .sessionRow.selected) — no underline, no shadow. */
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.tabTitle {
|
||||
@@ -449,7 +479,7 @@
|
||||
}
|
||||
|
||||
.explorerRoot {
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -463,9 +493,9 @@
|
||||
padding: 2px 6px 8px;
|
||||
}
|
||||
|
||||
/* One tree row: the app's tree-row cell (34px, radius 8, inset, hover
|
||||
fill, 22px indent step) with a mount fade so expanding a directory
|
||||
reveals its children gently (stable keys keep existing rows from
|
||||
/* One tree row: the app's session-row cell (34px, radius 8, inset, hover
|
||||
fill, 22px indent step, 14px ink) with a mount fade so expanding a
|
||||
directory reveals its children gently (stable keys keep existing rows from
|
||||
replaying it). */
|
||||
.explorerRow {
|
||||
display: flex;
|
||||
@@ -473,10 +503,10 @@
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 6px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
@@ -490,7 +520,7 @@
|
||||
}
|
||||
|
||||
.explorerDir {
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
font: var(--dsw-font-s-strong-14);
|
||||
}
|
||||
|
||||
.explorerHidden {
|
||||
@@ -609,6 +639,46 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Binary / download-only files (pptx, OLE .doc/.xls/.ppt, unknown binaries):
|
||||
a centered notice plus a download link — the user always gets the file. */
|
||||
.editorBinary {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.editorBinaryNotice {
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.editorDownloadLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--ds-transition-duration-slow) var(--ds-ease-in-out),
|
||||
border-color var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.editorDownloadLink:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
border-color: var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.editorError {
|
||||
padding: 12px 16px;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
@@ -699,6 +769,208 @@
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* Word preview: the document scrolls independently while the zoom control
|
||||
stays pinned to the bottom edge. */
|
||||
.editorDocx {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorDocxViewport {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
/* docx-preview renders into a wrapper div with .docx className; the host
|
||||
above scrolls while the wrapper holds the page flow. */
|
||||
.editorDocxWrap {
|
||||
flex: none;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.editorDocxZoom {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 4px 10px;
|
||||
border-top: 1px solid var(--dsw-alias-border-l1);
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
}
|
||||
|
||||
.editorDocxZoomHint,
|
||||
.editorDocxZoomValue {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.editorDocxZoomValue {
|
||||
width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.editorDocxZoomRange {
|
||||
flex: 1;
|
||||
min-width: 72px;
|
||||
accent-color: var(--dsw-alias-brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editorPdf {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorPdfToolbar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.editorPdfStage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.editorPdfFrame {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorPdfFrameBlocked {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editorPdfDragShield {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.editorPdfDragShieldActive {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Tab drag must switch iframe hit-testing before the pointer can enter the
|
||||
Chromium PDF browsing context. The body flag is set synchronously by the
|
||||
drag source, avoiding React render timing entirely. */
|
||||
:global(body[data-dsh-tab-dragging]) .editorPdfFrame {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
:global(body[data-dsh-tab-dragging]) .editorPdfDragShield {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
.editorXlsx {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorUniverHost {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.editorOfficeOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorPptx {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.editorPptxToolbar {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
}
|
||||
|
||||
.editorPptxToolbar .editorDownloadLink {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.editorPptxButton {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxs-strong-12);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editorPptxButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.editorPptxButton:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.editorPptxPosition {
|
||||
min-width: 64px;
|
||||
text-align: center;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.editorPptxStage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editorPptxHost {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ── Terminal ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.terminalWrap {
|
||||
@@ -843,7 +1115,7 @@
|
||||
gap: 6px;
|
||||
min-height: 34px;
|
||||
margin: 0 6px;
|
||||
padding: 0 6px 0 12px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
animation: dsh-row-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
@@ -888,7 +1160,7 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
font: var(--dsw-font-s-14);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -1036,7 +1308,10 @@
|
||||
.gitLink:focus-visible,
|
||||
.gitCommitButton:focus-visible,
|
||||
.terminalRetry:focus-visible,
|
||||
.editorModeButton:focus-visible {
|
||||
.editorModeButton:focus-visible,
|
||||
.editorDownloadLink:focus-visible,
|
||||
.editorPptxButton:focus-visible,
|
||||
.editorDocxZoomRange:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
@@ -1051,7 +1326,9 @@
|
||||
.paneCard,
|
||||
.explorerRow,
|
||||
.gitRow,
|
||||
.divider {
|
||||
.divider,
|
||||
.dividerRow::after,
|
||||
.dividerCol::after {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* that merges the tab into the pane. The tree and all operations live in
|
||||
* state.ts; this file is pure presentation over them.
|
||||
*/
|
||||
import { Fragment, useRef, useState } from 'react'
|
||||
import { Fragment, useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
|
||||
@@ -122,11 +122,24 @@ function LeafView(props: {
|
||||
newTabOptions: NewTabOption[]
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab) => ReactNode
|
||||
renderTab: (tab: SidebarTab, active: boolean) => ReactNode
|
||||
}) {
|
||||
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
const [dropZone, setDropZone] = useState<DropZone | null>(null)
|
||||
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
||||
|
||||
useEffect(() => {
|
||||
const clear = (): void => { setDropZone(null) }
|
||||
window.addEventListener('dragend', clear, true)
|
||||
window.addEventListener('drop', clear, true)
|
||||
window.addEventListener('blur', clear)
|
||||
return () => {
|
||||
window.removeEventListener('dragend', clear, true)
|
||||
window.removeEventListener('drop', clear, true)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
|
||||
@@ -183,7 +196,7 @@ function LeafView(props: {
|
||||
key={tab.id}
|
||||
className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
|
||||
>
|
||||
{renderTab(tab)}
|
||||
{renderTab(tab, tab.id === activeTab?.id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -201,7 +214,7 @@ function NodeView(props: {
|
||||
newTabOptions: NewTabOption[]
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab) => ReactNode
|
||||
renderTab: (tab: SidebarTab, active: boolean) => ReactNode
|
||||
}) {
|
||||
const { node, state, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
if (node.kind === 'leaf') {
|
||||
@@ -251,7 +264,7 @@ export function Workbench(props: {
|
||||
newTabOptions: NewTabOption[]
|
||||
actions: WorkbenchActions
|
||||
onNewTab: (optionId: string) => void
|
||||
renderTab: (tab: SidebarTab) => ReactNode
|
||||
renderTab: (tab: SidebarTab, active: boolean) => ReactNode
|
||||
}) {
|
||||
const { state, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
return (
|
||||
|
||||
+42
-19
@@ -9,7 +9,9 @@
|
||||
* divides the space row- or column-wise with fractional sizes. All tree
|
||||
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
|
||||
*/
|
||||
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal'
|
||||
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
|
||||
|
||||
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent'
|
||||
|
||||
/** One open tab. `path` carries the file (editor) or is absent (explorer/git). */
|
||||
export interface SidebarTab {
|
||||
@@ -54,8 +56,6 @@ export interface SidebarState {
|
||||
export const PANEL_MIN = 280
|
||||
export const PANEL_MAX = 640
|
||||
export const PANEL_DEFAULT = 400
|
||||
/** Approximate default width of the host's left sidebar (columns.ts SIDEBAR_DEFAULT). */
|
||||
export const LEFT_SIDEBAR_DEFAULT = 320
|
||||
export const TAB_MAX_WIDTH = 160
|
||||
export const TERMINAL_LIMIT = 3
|
||||
|
||||
@@ -99,15 +99,16 @@ function maxCounterId(parsed: unknown): number {
|
||||
return max
|
||||
}
|
||||
|
||||
/** A fresh default state: open, one explorer tab in one pane. `width` is
|
||||
* the caller's preferred panel width (default PANEL_DEFAULT); the store
|
||||
* seeds new sessions with half the viewport. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT): SidebarState {
|
||||
/** A fresh default state: one explorer tab in one pane, open per the caller's
|
||||
* preference. `width` is the caller's preferred panel width (default
|
||||
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
|
||||
* true); the store seeds new sessions from the user's side card prefs. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true): SidebarState {
|
||||
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
return {
|
||||
panelOpen: true,
|
||||
panelOpen,
|
||||
width,
|
||||
activePane: leaf.id,
|
||||
nextTerminal: 1,
|
||||
@@ -290,9 +291,9 @@ export function activateTab(state: SidebarState, paneId: string, tabId: string):
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a tab type is single-instance per session (explorer/git). */
|
||||
/** Whether a tab type is single-instance per session (explorer/git/subagent). */
|
||||
function isSingle(type: TabType): boolean {
|
||||
return type === 'explorer' || type === 'git'
|
||||
return type === 'explorer' || type === 'git' || type === 'subagent'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -405,7 +406,14 @@ export interface SidebarSnapshot {
|
||||
state: SidebarState | undefined
|
||||
}
|
||||
|
||||
function loadState(sessionId: string): SidebarState {
|
||||
/** Default panel width for one viewport: the prefs percent of the window,
|
||||
* clamped to the panel floor (a tiny percent must stay usable) and to the
|
||||
* viewport (a large one must never cover the whole window). */
|
||||
export function defaultWidthFor(viewport: number, percent: number): number {
|
||||
return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100)))
|
||||
}
|
||||
|
||||
function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
|
||||
if (raw !== null) {
|
||||
@@ -419,13 +427,15 @@ function loadState(sessionId: string): SidebarState {
|
||||
} catch {
|
||||
// Corrupt or unavailable storage: fall through to the default.
|
||||
}
|
||||
// New sessions open at half the CONTENT area (the viewport minus the
|
||||
// left sidebar's default width), so the panel never swallows half the
|
||||
// whole window; the same default applies to every session.
|
||||
const contentHalf = typeof window !== 'undefined'
|
||||
? Math.max(PANEL_MIN, Math.round((window.innerWidth - LEFT_SIDEBAR_DEFAULT) / 2))
|
||||
: PANEL_DEFAULT
|
||||
return makeDefaultState(contentHalf)
|
||||
// New sessions seed from the user's side card prefs: the width is the
|
||||
// chosen percent of the window (clamped to the panel floor and the
|
||||
// viewport so a huge percent can never crush the app shell), and the
|
||||
// panel starts open only when the preference says so.
|
||||
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
||||
const width = viewport === undefined
|
||||
? PANEL_DEFAULT
|
||||
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
|
||||
return makeDefaultState(width, prefs.openByDefault)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -496,6 +506,7 @@ function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string
|
||||
if (
|
||||
candidate.type !== 'explorer' && candidate.type !== 'git'
|
||||
&& candidate.type !== 'editor' && candidate.type !== 'terminal'
|
||||
&& candidate.type !== 'subagent'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
@@ -537,6 +548,18 @@ export class SidebarStore {
|
||||
private snapshot: SidebarSnapshot = { sessionId: undefined, state: undefined }
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private persistTimer: number | undefined
|
||||
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
|
||||
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
|
||||
/** Replace the side card prefs (the settings RPC result / settings page write). */
|
||||
setPrefs(prefs: SidebarPrefs): void {
|
||||
this.prefs = { ...prefs }
|
||||
}
|
||||
|
||||
/** The current side card prefs (seeds new sessions; persisted states win). */
|
||||
getPrefs(): SidebarPrefs {
|
||||
return { ...this.prefs }
|
||||
}
|
||||
|
||||
/** Select a session (or none); loads its persisted state. */
|
||||
setSession(sessionId: string | undefined): void {
|
||||
@@ -546,7 +569,7 @@ export class SidebarStore {
|
||||
} else {
|
||||
let state = this.bySession.get(sessionId)
|
||||
if (state === undefined) {
|
||||
state = loadState(sessionId)
|
||||
state = loadState(sessionId, this.prefs)
|
||||
this.bySession.set(sessionId, state)
|
||||
} else {
|
||||
// Cache hit: another session's load/ops may have left the uid
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 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 }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Pure subagent-membership helpers over the sessions list feed (structural
|
||||
* mirror world — no runtime imports). Used by the sidebar's auto-activation
|
||||
* effect and the Subagent page:
|
||||
*
|
||||
* - {@link directSubagentCount}: direct durable children of one session,
|
||||
* - {@link detectNewDirectSubagent}: the 0 → N transition that means "a new
|
||||
* subagent just spawned under the current session" (the auto-open trigger),
|
||||
* - {@link countSubagentDescendants}: uninterrupted subagent-origin lineage
|
||||
* totals (mirror of the official `indexSubagentDescendants` over the
|
||||
* plugin's own summary rows).
|
||||
*/
|
||||
import type {
|
||||
SidebarSessionList,
|
||||
SidebarSessionSummary,
|
||||
SidebarSubagentCatalog,
|
||||
} from '../context-types.ts'
|
||||
|
||||
/** Count the direct subagent children of one session (durable `origin` rows). */
|
||||
export function directSubagentCount(
|
||||
byId: SidebarSessionList['byId'],
|
||||
sessionId: string,
|
||||
): number {
|
||||
let count = 0
|
||||
for (const summary of Object.values(byId)) {
|
||||
if (summary.origin === 'subagent' && summary.parentId === sessionId) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* The main agent of the current session's tree: walk the durable parent
|
||||
* chain upward until the first non-subagent session. The Subagent page shows
|
||||
* THIS root's full topology regardless of how deep the current selection is
|
||||
* (a session whose row is still hydrating, or a broken chain, degrades to
|
||||
* the session itself).
|
||||
*/
|
||||
export function rootAncestor(
|
||||
byId: SidebarSessionList['byId'],
|
||||
sessionId: string | undefined,
|
||||
): string | undefined {
|
||||
if (sessionId === undefined) return undefined
|
||||
const seen = new Set<string>()
|
||||
let current: SidebarSessionSummary | undefined = byId[sessionId]
|
||||
while (current !== undefined && current.origin === 'subagent'
|
||||
&& current.parentId !== undefined && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
current = byId[current.parentId]
|
||||
}
|
||||
return current?.id ?? sessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every catalog branch (an entry with `hasChildren`) reachable from
|
||||
* the root — the set of catalogs the always-expanded topology consumes.
|
||||
* Cycles fail soft.
|
||||
*/
|
||||
export function collectBranchIds(
|
||||
catalogs: Readonly<Record<string, SidebarSubagentCatalog>>,
|
||||
rootId: string | undefined,
|
||||
): string[] {
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const visit = (parentId: string): void => {
|
||||
if (seen.has(parentId)) return
|
||||
seen.add(parentId)
|
||||
for (const entry of catalogs[parentId]?.entries ?? []) {
|
||||
if (entry.kind === 'child' && entry.hasChildren) {
|
||||
out.push(entry.id)
|
||||
visit(entry.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rootId !== undefined) visit(rootId)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a new direct subagent appeared under `sessionId` between two
|
||||
* consecutive list snapshots (the count crossed 0 → >0). Switching to a
|
||||
* session that already has subagents yields `false` (its baseline starts at
|
||||
* the current count), so the auto-open never fights an existing layout.
|
||||
*/
|
||||
export function detectNewDirectSubagent(
|
||||
prev: SidebarSessionList,
|
||||
next: SidebarSessionList,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
return directSubagentCount(prev.byId, sessionId) === 0
|
||||
&& directSubagentCount(next.byId, sessionId) > 0
|
||||
}
|
||||
|
||||
/** Descendant totals of one session through an uninterrupted subagent-origin chain. */
|
||||
export interface SubagentDescendantTotals {
|
||||
count: number
|
||||
runningCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Index every subagent descendant under each ancestor it reaches through an
|
||||
* uninterrupted subagent-origin chain (same semantics as the official
|
||||
* `indexSubagentDescendants`; cycles fail soft).
|
||||
*/
|
||||
export function countSubagentDescendants(
|
||||
byId: SidebarSessionList['byId'],
|
||||
sessionId: string,
|
||||
): SubagentDescendantTotals {
|
||||
const totals: SubagentDescendantTotals = { count: 0, runningCount: 0 }
|
||||
for (const descendant of Object.values(byId)) {
|
||||
if (descendant.origin !== 'subagent') continue
|
||||
const seen = new Set<string>()
|
||||
let current: SidebarSessionSummary | undefined = descendant
|
||||
while (current?.origin === 'subagent' && current.parentId !== undefined
|
||||
&& !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
if (current.parentId === sessionId) {
|
||||
totals.count += 1
|
||||
if (descendant.running === true) totals.runningCount += 1
|
||||
break
|
||||
}
|
||||
current = byId[current.parentId]
|
||||
}
|
||||
}
|
||||
return totals
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Convert a SheetJS (xlsx) workbook into Univer's {@link IWorkbookData} so
|
||||
* {@link XlsxView} can render it through the Univer sheets preset.
|
||||
*
|
||||
* v1 scope (matches docs/plans/2026-08-10-office-preview-design.md §3.6):
|
||||
* - Sheet names + order
|
||||
* - Cell values, typed (string / number / boolean / error → force-string)
|
||||
* - Cell formulas (text form; Univer's formula engine computes them)
|
||||
* - Merged cells (!merges → mergeData)
|
||||
* - Column widths (!cols → columnData) and row heights (!rows → rowData)
|
||||
*
|
||||
* Out of scope for v1 (SheetJS community edition does not parse them and the
|
||||
* Pro edition is commercial): cell styles (font/fill/border/alignment),
|
||||
* conditional formatting, charts. Users who need style fidelity get the
|
||||
* download button on the binary placeholder.
|
||||
*
|
||||
* The function is pure (no DOM, no Univer imports) so it is unit-testable
|
||||
* without jsdom or canvas.
|
||||
*/
|
||||
import type * as XLSX from 'xlsx'
|
||||
// Types come from @univerjs/presets (which re-exports @univerjs/core's type
|
||||
// surface). The enum values BooleanNumber / CellValueType are imported as
|
||||
// runtime values from the same module — the bundler inlines them, and the
|
||||
// test environment has @univerjs/presets available as a dependency.
|
||||
import { BooleanNumber, CellValueType, type LocaleType, type ICellData, type IColumnData, type IRowData, type IWorkbookData, type IWorksheetData, type IObjectArrayPrimitiveType, type IObjectMatrixPrimitiveType, type IRange } from '@univerjs/presets'
|
||||
|
||||
/** SheetJS workbook (the slice of the module we use). */
|
||||
type XLSXWorkbook = XLSX.WorkBook
|
||||
|
||||
/**
|
||||
* Build a Univer workbook snapshot from a SheetJS workbook.
|
||||
*
|
||||
* @param wb - the parsed workbook from `XLSX.read(buf, { type: 'array' })`
|
||||
* @param appVersion - the Univer package version, written into `appVersion`
|
||||
* (Univer requires the field but does not gate on it).
|
||||
* @param locale - the locale Univer should render in.
|
||||
*/
|
||||
export function xlsxWorkbookToUniver(
|
||||
wb: XLSXWorkbook,
|
||||
appVersion: string,
|
||||
locale: LocaleType,
|
||||
): IWorkbookData {
|
||||
const sheetOrder: string[] = []
|
||||
const sheets: Record<string, Partial<IWorksheetData>> = {}
|
||||
|
||||
wb.SheetNames.forEach((name, index) => {
|
||||
const sheetId = `sheet-${index}`
|
||||
sheetOrder.push(sheetId)
|
||||
sheets[sheetId] = worksheetToUniver(wb.Sheets[name] ?? {}, name, sheetId)
|
||||
})
|
||||
|
||||
// Univer requires at least one sheet; an empty workbook gets a placeholder.
|
||||
if (sheetOrder.length === 0) {
|
||||
const fallbackId = 'sheet-0'
|
||||
sheetOrder.push(fallbackId)
|
||||
sheets[fallbackId] = {
|
||||
id: fallbackId,
|
||||
name: 'Sheet1',
|
||||
rowCount: 20,
|
||||
columnCount: 26,
|
||||
cellData: {},
|
||||
rowData: {},
|
||||
columnData: {},
|
||||
mergeData: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'workbook',
|
||||
name: wb.Props?.Title || 'Workbook',
|
||||
appVersion,
|
||||
locale,
|
||||
styles: {},
|
||||
sheetOrder,
|
||||
sheets,
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one SheetJS worksheet to a Univer IWorksheetData. */
|
||||
function worksheetToUniver(
|
||||
ws: XLSX.WorkSheet,
|
||||
name: string,
|
||||
sheetId: string,
|
||||
): Partial<IWorksheetData> {
|
||||
const cellData: IObjectMatrixPrimitiveType<ICellData> = {}
|
||||
const merges: IRange[] = []
|
||||
|
||||
// Cell values + formulas: iterate every cell address (skip the !-prefixed
|
||||
// special keys SheetJS uses for sheet-level metadata).
|
||||
for (const addr of Object.keys(ws)) {
|
||||
if (addr.startsWith('!')) continue
|
||||
const cell = ws[addr] as XLSX.CellObject | undefined
|
||||
if (cell === undefined) continue
|
||||
const { r, c } = decodeAddr(addr)
|
||||
const converted = convertCell(cell)
|
||||
if (converted === null) continue
|
||||
let row = cellData[r]
|
||||
if (row === undefined) {
|
||||
row = {}
|
||||
cellData[r] = row
|
||||
}
|
||||
row[c] = converted
|
||||
}
|
||||
|
||||
// Merged cells: SheetJS's Range uses { s: { r, c }, e: { r, c } } (0-indexed),
|
||||
// which is exactly Univer's IRange shape — copy verbatim.
|
||||
if (Array.isArray(ws['!merges'])) {
|
||||
for (const m of ws['!merges']!) {
|
||||
if (m === undefined || m === null) continue
|
||||
merges.push({ startRow: m.s.r, endRow: m.e.r, startColumn: m.s.c, endColumn: m.e.c })
|
||||
}
|
||||
}
|
||||
|
||||
const { rowCount, columnCount } = dimensions(ws, cellData)
|
||||
|
||||
return {
|
||||
id: sheetId,
|
||||
name,
|
||||
tabColor: '',
|
||||
hidden: BooleanNumber.FALSE,
|
||||
freeze: { startRow: -1, startColumn: -1, xSplit: 0, ySplit: 0 },
|
||||
rowCount,
|
||||
columnCount,
|
||||
defaultColumnWidth: 96,
|
||||
defaultRowHeight: 22,
|
||||
mergeData: merges,
|
||||
cellData,
|
||||
rowData: convertRows(ws['!rows']),
|
||||
columnData: convertCols(ws['!cols']),
|
||||
rowHeader: { width: 46 },
|
||||
columnHeader: { height: 20 },
|
||||
showGridlines: BooleanNumber.TRUE,
|
||||
rightToLeft: BooleanNumber.FALSE,
|
||||
}
|
||||
}
|
||||
|
||||
/** Map a SheetJS CellObject onto Univer's ICellData (or null for empty). */
|
||||
function convertCell(cell: XLSX.CellObject): ICellData | null {
|
||||
const out: ICellData = {}
|
||||
// Type mapping: b→BOOLEAN, n→NUMBER, s→STRING, d→STRING, e→FORCE_STRING, z→skip
|
||||
switch (cell.t) {
|
||||
case 'b':
|
||||
out.t = CellValueType.BOOLEAN
|
||||
out.v = cell.v === true
|
||||
break
|
||||
case 'n':
|
||||
out.t = CellValueType.NUMBER
|
||||
out.v = typeof cell.v === 'number' ? cell.v : Number(cell.v)
|
||||
break
|
||||
case 'e':
|
||||
// Excel error strings like #REF! — display verbatim, no formula engine.
|
||||
out.t = CellValueType.FORCE_STRING
|
||||
out.v = String(cell.w ?? cell.v ?? '')
|
||||
break
|
||||
case 's':
|
||||
case 'd':
|
||||
case 'z':
|
||||
default:
|
||||
// String / date / stub. SheetJS renders dates as ISO strings in `w`
|
||||
// when cellDates is on; otherwise `v` is the serial number. Use `w`
|
||||
// (formatted text) when present so users see what Excel showed.
|
||||
out.t = CellValueType.STRING
|
||||
out.v = cell.w ?? (cell.v !== undefined ? String(cell.v) : '')
|
||||
break
|
||||
}
|
||||
// Formula: keep as text; Univer's formula engine computes on load.
|
||||
if (typeof cell.f === 'string' && cell.f !== '') {
|
||||
out.f = cell.f
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Convert SheetJS `!cols` (ColInfo[]) to Univer columnData (sparse, by index). */
|
||||
function convertCols(cols: XLSX.ColInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IColumnData>> {
|
||||
if (!Array.isArray(cols)) return {}
|
||||
const out: IObjectArrayPrimitiveType<Partial<IColumnData>> = {}
|
||||
cols.forEach((info, index) => {
|
||||
if (info === undefined || info === null) return
|
||||
const w = info.wpx ?? (info.width !== undefined ? Math.round(info.width * 7) : undefined)
|
||||
out[index] = {
|
||||
...(w !== undefined ? { w } : {}),
|
||||
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** Convert SheetJS `!rows` (RowInfo[]) to Univer rowData (sparse, by index). */
|
||||
function convertRows(rows: XLSX.RowInfo[] | undefined): IObjectArrayPrimitiveType<Partial<IRowData>> {
|
||||
if (!Array.isArray(rows)) return {}
|
||||
const out: IObjectArrayPrimitiveType<Partial<IRowData>> = {}
|
||||
rows.forEach((info, index) => {
|
||||
if (info === undefined || info === null) return
|
||||
const h = info.hpx ?? info.hpt
|
||||
out[index] = {
|
||||
...(h !== undefined ? { h: Math.round(h) } : {}),
|
||||
...(info.hidden === true ? { hd: BooleanNumber.TRUE } : {}),
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the worksheet dimensions. SheetJS's `!ref` is authoritative when
|
||||
* present; otherwise fall back to the max row/col we actually wrote, with a
|
||||
* small floor so the canvas has somewhere to paint.
|
||||
*/
|
||||
function dimensions(
|
||||
ws: XLSX.WorkSheet,
|
||||
cellData: IObjectMatrixPrimitiveType<ICellData>,
|
||||
): { rowCount: number; columnCount: number } {
|
||||
const ref = ws['!ref']
|
||||
if (typeof ref === 'string' && ref !== '') {
|
||||
const range = decodeRange(ref)
|
||||
return {
|
||||
rowCount: Math.max(range.endRow + 1, 20),
|
||||
columnCount: Math.max(range.endColumn + 1, 10),
|
||||
}
|
||||
}
|
||||
let maxRow = -1
|
||||
let maxCol = -1
|
||||
for (const r of Object.keys(cellData)) {
|
||||
const rowNum = Number(r)
|
||||
if (rowNum > maxRow) maxRow = rowNum
|
||||
const rowObj = cellData[rowNum]!
|
||||
for (const c of Object.keys(rowObj)) {
|
||||
const colNum = Number(c)
|
||||
if (colNum > maxCol) maxCol = colNum
|
||||
}
|
||||
}
|
||||
return {
|
||||
rowCount: Math.max(maxRow + 1, 20),
|
||||
columnCount: Math.max(maxCol + 1, 10),
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode an A1-style address (e.g. "AB12") to 0-indexed {row, col}. */
|
||||
function decodeAddr(addr: string): { r: number; c: number } {
|
||||
let col = 0
|
||||
let i = 0
|
||||
while (i < addr.length) {
|
||||
const ch = addr.charCodeAt(i)
|
||||
if (ch >= 65 && ch <= 90) {
|
||||
col = col * 26 + (ch - 64)
|
||||
i += 1
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
const row = Number(addr.slice(i)) - 1
|
||||
return { r: row, c: col - 1 }
|
||||
}
|
||||
|
||||
/** Decode an A1-style range (e.g. "A1:AB12") to 0-indexed bounds. */
|
||||
function decodeRange(range: string): { startRow: number; endRow: number; startColumn: number; endColumn: number } {
|
||||
const at = range.indexOf(':')
|
||||
const start = decodeAddr(at === -1 ? range : range.slice(0, at))
|
||||
const end = at === -1 ? start : decodeAddr(range.slice(at + 1))
|
||||
return { startRow: start.r, endRow: end.r, startColumn: start.c, endColumn: end.c }
|
||||
}
|
||||
@@ -6,6 +6,23 @@
|
||||
*/
|
||||
|
||||
import z from 'schemastery'
|
||||
import {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from './prefs-shared.ts'
|
||||
|
||||
export {
|
||||
SIDEBAR_PREFS_DEFAULTS,
|
||||
SIDEBAR_PREFS_NS,
|
||||
WIDTH_PERCENT_DEFAULT,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from './prefs-shared.ts'
|
||||
|
||||
/** Tunable sidebar host limits (every field optional; defaults fill in). */
|
||||
export interface SidebarConfig {
|
||||
@@ -54,3 +71,12 @@ export function resolveSidebarConfig(config: SidebarConfig | undefined): Resolve
|
||||
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
|
||||
}
|
||||
}
|
||||
|
||||
// ── User-facing "Side card" preferences ─────────────────────────────────────
|
||||
|
||||
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
|
||||
export const PrefsSchema: z<SidebarPrefs> = z.object({
|
||||
openByDefault: z.boolean().default(true),
|
||||
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
|
||||
autoOpenSubagent: z.boolean().default(true),
|
||||
})
|
||||
|
||||
@@ -80,18 +80,102 @@ export interface SidebarSlotRegisterOptions {
|
||||
/** The client slots service face (register returns the disposer). */
|
||||
export interface SidebarSlotsService {
|
||||
register(options: SidebarSlotRegisterOptions, component: unknown): () => void
|
||||
/**
|
||||
* Run a callback for each declaration lifetime of a slot (the runtime
|
||||
* SlotsService.inject): a no-op while the slot is undeclared, so the
|
||||
* settings section registration waits for the settings shell.
|
||||
*/
|
||||
inject(key: string, callback: () => () => void): () => void
|
||||
}
|
||||
|
||||
/** The client session list row the sidebar reads (cwd for the explorer). */
|
||||
export interface SidebarSessionSummary {
|
||||
id: string
|
||||
cwd?: string
|
||||
displayTitle: string
|
||||
/** Coarse durable origin for navigation filtering (subagent children). */
|
||||
origin?: 'subagent'
|
||||
/** Durable direct parent session id (present on subagent children). */
|
||||
parentId?: string
|
||||
/** Whether the session's agent is currently running. */
|
||||
running?: boolean
|
||||
}
|
||||
|
||||
/** One healthy subagent catalog child row (structural mirror of the runtime). */
|
||||
export interface SidebarSubagentChildEntry {
|
||||
kind: 'child'
|
||||
id: string
|
||||
/** Whether the child Agent driver is running at the Host sampling boundary. */
|
||||
activity: 'running' | 'inactive'
|
||||
/** Whether a direct descendant has durable `origin: 'subagent'`. */
|
||||
hasChildren: boolean
|
||||
mode: 'one-shot' | 'continuable'
|
||||
label?: string
|
||||
}
|
||||
|
||||
/** One unreadable catalog row (corrupt / unsupported / unavailable). */
|
||||
export interface SidebarSubagentDiagnosticEntry {
|
||||
kind: 'diagnostic'
|
||||
id: string
|
||||
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
||||
}
|
||||
|
||||
/** The per-parent lazy catalog delivered through the sessions list feed. */
|
||||
export interface SidebarSubagentCatalog {
|
||||
entries: Array<SidebarSubagentChildEntry | SidebarSubagentDiagnosticEntry>
|
||||
parentAvailable: boolean
|
||||
state: 'loading' | 'ready' | 'error'
|
||||
error: { code?: string; message?: string } | null
|
||||
}
|
||||
|
||||
/** Durable parent/child address that selects subagent transport in the client. */
|
||||
export interface SidebarSubagentAddress {
|
||||
parentSessionId: string
|
||||
childSessionId: string
|
||||
mode: 'one-shot' | 'continuable'
|
||||
}
|
||||
|
||||
/** Minimal structural mirror of one session event (the subagent history tail). */
|
||||
export interface SidebarSessionEvent {
|
||||
type: string
|
||||
seq: number
|
||||
time: number
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** One history row: the durable event plus an optional tool presentation view. */
|
||||
export interface SidebarHistoryEntry {
|
||||
event: SidebarSessionEvent
|
||||
view?: unknown
|
||||
}
|
||||
|
||||
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
|
||||
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
|
||||
|
||||
/** Unary response mirror (`RpcResponse<T>` on the wire). */
|
||||
export interface SidebarRpcResponse<T> {
|
||||
rpcId: unknown
|
||||
result: SidebarRpcResult<T>
|
||||
}
|
||||
|
||||
/** The wire face the Subagent activity summary needs (subset of `ctx.connection`). */
|
||||
export interface SidebarConnectionHandle {
|
||||
api: {
|
||||
subagents: {
|
||||
history(
|
||||
payload: SidebarSubagentAddress & { beforeSeq?: number; maxMessages?: number },
|
||||
signal?: AbortSignal,
|
||||
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The client session list snapshot the sidebar subscribes to. */
|
||||
export interface SidebarSessionList {
|
||||
current: string | undefined
|
||||
byId: Record<string, SidebarSessionSummary>
|
||||
/** Direct durable catalogs keyed by their selected parent address. */
|
||||
subagentsByParent?: Readonly<Record<string, SidebarSubagentCatalog>>
|
||||
}
|
||||
|
||||
/** The client sessions service face (only the list feed is needed). */
|
||||
@@ -100,12 +184,34 @@ export interface SidebarSessionsService {
|
||||
getSnapshot(): SidebarSessionList
|
||||
subscribe(fn: () => void): () => void
|
||||
}
|
||||
/**
|
||||
* Select a listed session as current (mirror of the runtime ISessions.open)
|
||||
* — used to jump back to the main agent from the topology root node.
|
||||
*/
|
||||
open?(id: string): void
|
||||
/**
|
||||
* Resolve an Agent-scoped context view for one session (mirror of the
|
||||
* runtime ISessions.scope) — the ticket `ctx.conversation.input.for`
|
||||
* requires to reach that session's composer.
|
||||
*/
|
||||
scope(id: string): Context | undefined
|
||||
/**
|
||||
* Open a healthy catalog child through its exact direct-parent address
|
||||
* (mirror of the runtime ISessions.openSubagent).
|
||||
*/
|
||||
openSubagent?(address: SidebarSubagentAddress): void
|
||||
/**
|
||||
* Resolve an already discovered direct-parent address without opening it.
|
||||
*/
|
||||
subagentAddress?(id: string): SidebarSubagentAddress | undefined
|
||||
/**
|
||||
* Mark whether a catalog surface is consuming live membership updates.
|
||||
*/
|
||||
setSubagentCatalogOpen?(parentSessionId: string, open: boolean): void
|
||||
/**
|
||||
* Refresh one direct-child catalog.
|
||||
*/
|
||||
refreshSubagents?(parentSessionId: string): Promise<void>
|
||||
}
|
||||
|
||||
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
|
||||
@@ -139,12 +245,43 @@ export interface SidebarInvariantsService {
|
||||
): () => void
|
||||
}
|
||||
|
||||
/** The settings service face (mirror of @deepseek-ai/dsh-settings' Settings). */
|
||||
export interface SidebarSettingsService {
|
||||
/**
|
||||
* Register one namespace schema (the resolved value layers schema defaults,
|
||||
* then the composition base, then the user document).
|
||||
*/
|
||||
register<T>(
|
||||
ns: string,
|
||||
schema: unknown,
|
||||
options?: { base?: Partial<T>; applies?: 'live' | 'restart' },
|
||||
): {
|
||||
get(): T
|
||||
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
|
||||
update(patch: object): Promise<void>
|
||||
replace(section: object): Promise<void>
|
||||
}
|
||||
/** Redacted descriptors of every registered namespace (secrets stripped). */
|
||||
describe(options?: { redactSecrets?: boolean }): Array<{
|
||||
ns: string
|
||||
value?: unknown
|
||||
base?: unknown
|
||||
user?: unknown
|
||||
applies: 'live' | 'restart'
|
||||
revision: number
|
||||
}>
|
||||
/** Service-level merge write with the revision guard (a stale writer is refused). */
|
||||
update(ns: string, patch: object, expectedRevision?: number): Promise<void>
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
httpServer: SidebarHttpServer
|
||||
sessions: SidebarSessionStore & SidebarSessionsService
|
||||
connection: SidebarConnectionHandle
|
||||
loader: SidebarLoader
|
||||
slots: SidebarSlotsService
|
||||
settings: SidebarSettingsService
|
||||
invariants: SidebarInvariantsService
|
||||
/**
|
||||
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
|
||||
|
||||
+87
-3
@@ -18,13 +18,16 @@ import { WebSocket, WebSocketServer } from 'ws'
|
||||
import type { Context } from './context-types.ts'
|
||||
import {
|
||||
Config,
|
||||
PrefsSchema,
|
||||
resolveSidebarConfig,
|
||||
SIDEBAR_PREFS_NS,
|
||||
type ResolvedSidebarConfig,
|
||||
type SidebarConfig,
|
||||
} from './config.ts'
|
||||
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
||||
import { isTrustedApiRequest } from './trust-fence.ts'
|
||||
import * as git from './git.ts'
|
||||
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||
|
||||
@@ -48,6 +51,12 @@ const MEDIA_TYPES: Record<string, string> = {
|
||||
'.bmp': 'image/bmp',
|
||||
'.ico': 'image/x-icon',
|
||||
'.avif': 'image/avif',
|
||||
'.pdf': 'application/pdf',
|
||||
}
|
||||
|
||||
/** Content type served by /sidebar/file (binary-safe fallback for unknowns). */
|
||||
export function mediaTypeForPath(path: string): string {
|
||||
return MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
/** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
|
||||
@@ -112,8 +121,27 @@ async function readText(path: string, readLimit: number): Promise<{ content: str
|
||||
/** One API method dispatch table entry. */
|
||||
type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
|
||||
|
||||
/**
|
||||
* The live face of the side card settings namespace, bound to the settings
|
||||
* service when it is mounted. The DSH settings RPC domain only serves
|
||||
* allowlisted namespaces (api-proxy exposedNamespaces), so the client reads
|
||||
* and writes THIS namespace through the plugin's own fenced /sidebar routes,
|
||||
* which call the seam in-process — no configuration-client gate involved.
|
||||
*/
|
||||
export interface SidebarSettingsFace {
|
||||
/** The current resolved value + revision (undefined while the settings service is absent). */
|
||||
get(): { value?: unknown; revision?: number }
|
||||
/** Merge a patch (revision-guarded) and return the fresh resolved view. */
|
||||
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
|
||||
}
|
||||
|
||||
/** Build the API method table bound to the plugin context, pty manager, and resolved config. */
|
||||
function buildApi(ctx: Context, ptyManager: PtyManager, resolved: ResolvedSidebarConfig): Record<string, ApiMethod> {
|
||||
function buildApi(
|
||||
ctx: Context,
|
||||
ptyManager: PtyManager,
|
||||
resolved: ResolvedSidebarConfig,
|
||||
getSettings: () => SidebarSettingsFace | undefined,
|
||||
): Record<string, ApiMethod> {
|
||||
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
|
||||
const sessionId = requireString(payload, 'sessionId')
|
||||
const record = payload as { cwd?: unknown } | null
|
||||
@@ -212,6 +240,35 @@ function buildApi(ctx: Context, ptyManager: PtyManager, resolved: ResolvedSideba
|
||||
ptyManager.close(`${sessionId}:${tab}`)
|
||||
return { ok: true }
|
||||
},
|
||||
// The side card preferences. The settings service is optional in the
|
||||
// composition; while absent the routes report undefined and the client
|
||||
// keeps the schema defaults. Writes are revision-guarded: a stale editor
|
||||
// is refused with settings-conflict so a concurrent change is never
|
||||
// silently overwritten (mirror of the settings seam's own guard).
|
||||
'settings.get': () => {
|
||||
const settings = getSettings()
|
||||
return settings?.get() ?? { value: undefined, revision: undefined }
|
||||
},
|
||||
'settings.update': async (payload) => {
|
||||
const settings = getSettings()
|
||||
if (settings === undefined) {
|
||||
throw new SidebarError('settings-rejected', 'the settings service is not mounted in this deployment', 503)
|
||||
}
|
||||
const record = payload as { patch?: unknown; expectedRevision?: unknown } | null
|
||||
const patch = record?.patch
|
||||
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
||||
throw new SidebarError('bad-request', 'patch must be a plain object')
|
||||
}
|
||||
const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined
|
||||
try {
|
||||
return await settings.update(patch as Record<string, unknown>, expectedRevision)
|
||||
} catch (error) {
|
||||
if (error instanceof SettingsConflictError) {
|
||||
throw new SidebarError('settings-conflict', error.message, 409)
|
||||
}
|
||||
throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,8 +288,35 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
|
||||
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
|
||||
|
||||
// ── User-facing "Side card" preferences ──────────────────────────────────
|
||||
// Register the namespace with the settings provider so the Settings page
|
||||
// (client half) can render and persist the new-conversation defaults. The
|
||||
// DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to
|
||||
// configuration clients, so the client reaches this namespace through the
|
||||
// plugin's own fenced routes below ('settings.get'/'settings.update'),
|
||||
// which call the seam in-process. Deployments without a settings service
|
||||
// simply never fill the face and the client falls back to the defaults.
|
||||
let settingsFace: SidebarSettingsFace | undefined
|
||||
ctx.inject(['settings'], (sctx) => {
|
||||
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
|
||||
sctx.settings.register(ns, PrefsSchema)
|
||||
const viewOf = (): { value?: unknown; revision?: number } => {
|
||||
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
|
||||
return descriptor === undefined
|
||||
? { value: undefined, revision: undefined }
|
||||
: { value: descriptor.value, revision: descriptor.revision }
|
||||
}
|
||||
settingsFace = {
|
||||
get: viewOf,
|
||||
update: async (patch, expectedRevision) => {
|
||||
await sctx.settings.update(ns, patch, expectedRevision)
|
||||
return viewOf()
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// ── JSON API ────────────────────────────────────────────────────────────
|
||||
const api = buildApi(ctx, ptyManager, resolved)
|
||||
const api = buildApi(ctx, ptyManager, resolved, () => settingsFace)
|
||||
ctx.effect(() => ctx.httpServer.register({
|
||||
kind: 'prefix',
|
||||
path: '/sidebar/api',
|
||||
@@ -297,7 +381,7 @@ export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||
if (!info.isFile() || info.size > resolved.mediaLimit) {
|
||||
throw new SidebarError('fs-error', 'not a file or too large', 400)
|
||||
}
|
||||
const type = MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
|
||||
const type = mediaTypeForPath(path)
|
||||
const body = await readFile(path)
|
||||
// Raw bytes either way (binary-safe); ?download=1 switches the
|
||||
// disposition so the browser saves the file instead of showing it.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shared "Side card" preference vocabulary (types + constants), consumed by
|
||||
* BOTH halves: the host registers the schemastery schema over these values
|
||||
* (config.ts) and the client reads/writes them through the settings RPC
|
||||
* (client/prefs.ts, client/SideCardSection.tsx). Kept free of schemastery so
|
||||
* the browser bundle never pulls the schema runtime in.
|
||||
*/
|
||||
|
||||
/** The user-settings namespace holding the side card preferences. */
|
||||
export const SIDEBAR_PREFS_NS = 'dsh-better-sidebar'
|
||||
|
||||
/** User-facing side card preferences (new-conversation defaults). */
|
||||
export interface SidebarPrefs {
|
||||
/** Whether a brand-new conversation opens the side card by default. */
|
||||
openByDefault: boolean
|
||||
/** Default panel width as a percent of the window width (20–60). */
|
||||
defaultWidthPercent: number
|
||||
/**
|
||||
* Whether the sidebar auto-activates (opens the panel) and expands the
|
||||
* Subagent page when the current conversation spawns a new subagent.
|
||||
*/
|
||||
autoOpenSubagent: boolean
|
||||
}
|
||||
|
||||
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
|
||||
export const WIDTH_PERCENT_MIN = 20
|
||||
export const WIDTH_PERCENT_MAX = 60
|
||||
export const WIDTH_PERCENT_DEFAULT = 30
|
||||
|
||||
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
||||
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
|
||||
openByDefault: true,
|
||||
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
|
||||
autoOpenSubagent: true,
|
||||
}
|
||||
|
||||
/** Clamp one width percent into the contract range (shared by schema and client reads). */
|
||||
export function clampWidthPercent(value: number): number {
|
||||
return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value)))
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export type SidebarErrorCode =
|
||||
| 'fs-error'
|
||||
| 'git-error'
|
||||
| 'pty-error'
|
||||
| 'settings-rejected'
|
||||
| 'settings-conflict'
|
||||
| 'internal'
|
||||
|
||||
/** One API failure with its wire code and HTTP status. */
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Registry manifest consistency spec: guards the plugin-registry release form
|
||||
* (`dsh.plugin.json`) against the build output and package.json, so the repo
|
||||
* cannot drift into an uninstallable / uneable state:
|
||||
* - `id` passes the registry's strict two-segment validation (native form),
|
||||
* - `version` stays in sync with package.json,
|
||||
* - the manifest entry files exist in the build output,
|
||||
* - the registry client bundle registers exactly the manifest id (the
|
||||
* browser-side `arrive()` contract), and the official client bundle keeps
|
||||
* the package-name id (the client-modules compose contract) — the two
|
||||
* bundles must never be swapped.
|
||||
*
|
||||
* Reads the built lib/ output, so run `pnpm build` first (the registry
|
||||
* install validates main/client.main existence the same way).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
||||
|
||||
/** The registry manifest face this spec touches (mirror of the registry's PluginManifest). */
|
||||
interface PluginManifest {
|
||||
id: string
|
||||
version: string
|
||||
main: string
|
||||
description?: string
|
||||
engines?: { dsh?: string }
|
||||
contributes?: { tools?: string[]; skills?: string[] }
|
||||
client?: { main?: string; inject?: string[]; immediately?: boolean }
|
||||
}
|
||||
|
||||
interface PackageJson {
|
||||
name: string
|
||||
version: string
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(resolve(ROOT, 'dsh.plugin.json'), 'utf8')) as PluginManifest
|
||||
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as PackageJson
|
||||
|
||||
/** The registry's strict id validation (manifest.ts / client-modules registerExternal): exactly two lowercase slash-separated segments. */
|
||||
const ID_PATTERN = /^(?!node_modules\/)(?:@[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*|[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*)$/
|
||||
|
||||
/** Literal require() specifiers the frozen browser module table can answer. */
|
||||
const CLIENT_REQUIRE_ALLOWED = new Set([
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
'react-dom',
|
||||
'react-dom/client',
|
||||
'cordis',
|
||||
'@deepseek-ai/dsh-client-ui-slots',
|
||||
'@deepseek-ai/dsh-client-web-react',
|
||||
'@deepseek-ai/dsh-client-ui-primitives',
|
||||
'@deepseek-ai/dsh-client-schema-form',
|
||||
'@deepseek-ai/dsh-client-runtime/client',
|
||||
])
|
||||
|
||||
/** The registered `__ModuleLoader__.load({ id })` value of a built client bundle. */
|
||||
function bundleId(file: string): string {
|
||||
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||||
const match = /load\(\{\s*id:\s*"([^"]+)"/.exec(source)
|
||||
if (match === null) throw new Error(`${file} registers no __ModuleLoader__.load id — run \`pnpm build\` first`)
|
||||
return match[1]!
|
||||
}
|
||||
|
||||
describe('registry manifest consistency (dsh.plugin.json)', () => {
|
||||
it('id is a valid two-segment registry id (native form)', () => {
|
||||
expect(manifest.id).toMatch(ID_PATTERN)
|
||||
})
|
||||
|
||||
it('version matches package.json', () => {
|
||||
expect(manifest.version).toBe(pkg.version)
|
||||
})
|
||||
|
||||
it('main and client.main exist in the build output', () => {
|
||||
expect(existsSync(resolve(ROOT, manifest.main))).toBe(true)
|
||||
expect(manifest.client?.main).toBeDefined()
|
||||
expect(existsSync(resolve(ROOT, manifest.client!.main!))).toBe(true)
|
||||
})
|
||||
|
||||
it('the registry bundle registers exactly the manifest id and the official bundle the package name (no swap)', () => {
|
||||
const registryId = bundleId(manifest.client!.main!)
|
||||
expect(registryId).toBe(manifest.id)
|
||||
expect(bundleId('lib/client.js')).toBe(pkg.name)
|
||||
expect(registryId).not.toBe(pkg.name)
|
||||
})
|
||||
|
||||
it('client bundles require only frozen module-table entries', () => {
|
||||
for (const file of ['lib/client.js', manifest.client!.main!]) {
|
||||
const source = readFileSync(resolve(ROOT, file), 'utf8')
|
||||
// Exclude method calls such as `freeModule.require("util")`; only the
|
||||
// loader factory's lexical require() is constrained by the module table.
|
||||
const required = [...source.matchAll(/(^|[^.$\w])require\("([^"]+)"\)/gm)].map(match => match[2]!)
|
||||
expect([...new Set(required)].filter(id => !CLIENT_REQUIRE_ALLOWED.has(id))).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('contributes declares no tools or skills (the host half registers none)', () => {
|
||||
expect(manifest.contributes?.tools).toEqual([])
|
||||
expect(manifest.contributes?.skills).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -35,4 +35,22 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
expect(resolved.terminalsPerSession).toBe(3)
|
||||
expect(resolved.reconnectGraceMs).toBe(30_000)
|
||||
})
|
||||
|
||||
it('registers the side card preferences schema with the documented defaults', async () => {
|
||||
const { PrefsSchema, SIDEBAR_PREFS_NS } = await import('../src/config.ts')
|
||||
expect(SIDEBAR_PREFS_NS).toBe('dsh-better-sidebar')
|
||||
const resolved = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})(undefined)
|
||||
expect(resolved.openByDefault).toBe(true)
|
||||
expect(resolved.defaultWidthPercent).toBe(30)
|
||||
expect(resolved.autoOpenSubagent).toBe(true)
|
||||
// A stored overridden value resolves through (the range contract is
|
||||
// enforced by the settings service on write); the new pref keeps its
|
||||
// default when the stored document predates it.
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true })
|
||||
})
|
||||
})
|
||||
|
||||
+137
-1
@@ -6,7 +6,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve as resolvePath } from 'node:path'
|
||||
import { apply } from '../src/index.ts'
|
||||
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { apply, mediaTypeForPath } from '../src/index.ts'
|
||||
import * as git from '../src/git.ts'
|
||||
import { listDirectory } from '../src/fs-tree.ts'
|
||||
import { defaultShell, PtyManager } from '../src/pty-manager.ts'
|
||||
@@ -20,9 +21,17 @@ interface FakeContext {
|
||||
}
|
||||
sessions: { get: (id: string) => { header: { cwd?: string } } | undefined }
|
||||
effect: (fn: () => void | (() => void), label?: string) => void
|
||||
/** The settings service never appears in the smoke context: the inject
|
||||
* callback must never run (mirror of cordis' service-less inject). */
|
||||
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void
|
||||
}
|
||||
|
||||
describe('host plugin smoke', () => {
|
||||
it('serves PDF with the browser-native content type', () => {
|
||||
expect(mediaTypeForPath('/work/report.PDF')).toBe('application/pdf')
|
||||
expect(mediaTypeForPath('/work/archive.bin')).toBe('application/octet-stream')
|
||||
})
|
||||
|
||||
it('mounts the fenced routes', () => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
const upgrades: SidebarWebUpgradeRoute[] = []
|
||||
@@ -40,6 +49,9 @@ describe('host plugin smoke', () => {
|
||||
const cleanup = fn()
|
||||
if (typeof cleanup === 'function') effects.push(cleanup)
|
||||
},
|
||||
// No settings service in the smoke context: the registration callback
|
||||
// never runs (cordis' service-less inject behaves the same).
|
||||
inject: () => () => {},
|
||||
}
|
||||
apply(ctx as never)
|
||||
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file'])
|
||||
@@ -167,6 +179,8 @@ describe('session cwd resolution over the API route', () => {
|
||||
sessions: overrides.sessions ?? { get: () => undefined },
|
||||
// The vendored cordis runs registration effects immediately.
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
// No settings service: the namespace registration never runs.
|
||||
inject: () => () => {},
|
||||
}
|
||||
apply(ctx as never)
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
@@ -235,3 +249,125 @@ describe('session cwd resolution over the API route', () => {
|
||||
expect(missing.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('side card settings routes', () => {
|
||||
/** A minimal settings seam: register/describe/update with the revision guard. */
|
||||
const createFakeSettings = () => {
|
||||
const namespaces = new Map<string, {
|
||||
schema: unknown
|
||||
value: Record<string, unknown> | undefined
|
||||
revision: number
|
||||
}>()
|
||||
const resolve = (entry: { schema: unknown; value: Record<string, unknown> | undefined }): unknown => {
|
||||
const schema = entry.schema as (input: unknown) => unknown
|
||||
return entry.value === undefined ? schema(undefined) : schema(entry.value)
|
||||
}
|
||||
return {
|
||||
register(ns: string, schema: unknown) {
|
||||
namespaces.set(ns, { schema, value: undefined, revision: 0 })
|
||||
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
|
||||
},
|
||||
describe() {
|
||||
return [...namespaces.entries()].map(([ns, entry]) => ({
|
||||
ns,
|
||||
value: resolve(entry),
|
||||
applies: 'live' as const,
|
||||
revision: entry.revision,
|
||||
}))
|
||||
},
|
||||
async update(ns: string, patch: Record<string, unknown>, expectedRevision?: number) {
|
||||
const entry = namespaces.get(ns)
|
||||
if (entry === undefined) throw new Error(`settings namespace "${ns}" is not registered`)
|
||||
if (expectedRevision !== undefined && expectedRevision !== entry.revision) {
|
||||
throw new SettingsConflictError(settingsNamespace(ns), expectedRevision, entry.revision)
|
||||
}
|
||||
entry.value = { ...entry.value, ...patch }
|
||||
entry.revision += 1
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const mountWithSettings = (settings?: unknown): SidebarWebRoute => {
|
||||
const routes: SidebarWebRoute[] = []
|
||||
const ctx = {
|
||||
loader: { entries: () => [] },
|
||||
httpServer: {
|
||||
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
|
||||
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
|
||||
},
|
||||
sessions: { get: () => undefined },
|
||||
effect: (fn: () => void | (() => void)) => { fn() },
|
||||
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
|
||||
if (deps.includes('settings') && settings !== undefined) callback({ settings })
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
apply(ctx as never)
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
}
|
||||
|
||||
const invoke = async (route: SidebarWebRoute, method: string, payload: unknown): Promise<{
|
||||
ok: boolean
|
||||
value?: unknown
|
||||
error?: { code?: string; message: string }
|
||||
}> => {
|
||||
const body = Buffer.from(JSON.stringify(payload))
|
||||
const req = {
|
||||
method: 'POST',
|
||||
url: `/sidebar/api/${method}`,
|
||||
headers: { host: '127.0.0.1:3080' },
|
||||
[Symbol.asyncIterator]: async function* () { yield body },
|
||||
} as never
|
||||
const out: { status: number; body: string } = { status: 200, body: '' }
|
||||
const res = {
|
||||
writeHead: (status: number) => { out.status = status },
|
||||
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
|
||||
} as never
|
||||
await route.handler(req, res)
|
||||
return JSON.parse(out.body) as { ok: boolean; value?: unknown; error?: { code?: string; message: string } }
|
||||
}
|
||||
|
||||
it('serves the schema defaults when the settings service is absent', async () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
const result = await invoke(route, 'settings.get', {})
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value).toEqual({ value: undefined, revision: undefined })
|
||||
})
|
||||
|
||||
it('reads the resolved prefs and writes a patch through the seam', async () => {
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
const read = await invoke(route, 'settings.get', {})
|
||||
expect(read.ok).toBe(true)
|
||||
expect(read.value).toEqual({
|
||||
value: { openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true },
|
||||
revision: 0,
|
||||
})
|
||||
|
||||
const written = await invoke(route, 'settings.update', { patch: { openByDefault: false } })
|
||||
expect(written.ok).toBe(true)
|
||||
const view = written.value as { value: { openByDefault: boolean; defaultWidthPercent: number }; revision: number }
|
||||
expect(view.value.openByDefault).toBe(false)
|
||||
expect(view.value.defaultWidthPercent).toBe(30)
|
||||
expect(view.revision).toBe(1)
|
||||
})
|
||||
|
||||
it('refuses a stale write with settings-conflict (409)', async () => {
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
await invoke(route, 'settings.update', { patch: { openByDefault: false } })
|
||||
// The second write carries the pre-write revision: the seam refuses it.
|
||||
const stale = await invoke(route, 'settings.update', {
|
||||
patch: { defaultWidthPercent: 40 },
|
||||
expectedRevision: 0,
|
||||
})
|
||||
expect(stale.ok).toBe(false)
|
||||
expect(stale.error?.code).toBe('settings-conflict')
|
||||
expect(stale.error?.message).toMatch(/changed since it was read/)
|
||||
})
|
||||
|
||||
it('rejects a non-object patch as bad-request', async () => {
|
||||
const route = mountWithSettings(createFakeSettings())
|
||||
const result = await invoke(route, 'settings.update', { patch: 'nope' })
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error?.message).toMatch(/plain object/)
|
||||
})
|
||||
})
|
||||
|
||||
+313
-2
@@ -2,14 +2,24 @@ import { describe, expect, it } from 'vitest'
|
||||
import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
|
||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab, insertLeafAt, makeDefaultState, moveTab, moveTabToEdge,
|
||||
openTab, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
|
||||
moveTab, moveTabToEdge, openTab, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||
type SidebarState, type SplitNode,
|
||||
} from '../src/client/state.ts'
|
||||
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
|
||||
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
|
||||
import { officeKindForExt } from '../src/client/office-types.ts'
|
||||
import { isPdfExt } from '../src/client/pdf-types.ts'
|
||||
import { isImageExt } from '../src/client/image-types.ts'
|
||||
import { relativeTo } from '../src/client/paths.ts'
|
||||
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
|
||||
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
|
||||
import {
|
||||
collectBranchIds, countSubagentDescendants, detectNewDirectSubagent, directSubagentCount, rootAncestor,
|
||||
} from '../src/client/subagent-detect.ts'
|
||||
import { contentText, lastActivity } from '../src/client/subagent-activity.ts'
|
||||
import type { SidebarHistoryEntry, SidebarSessionList, SidebarSubagentCatalog } from '../src/context-types.ts'
|
||||
|
||||
describe('fs-tree', () => {
|
||||
it('sorts directories first, then names case-insensitively', () => {
|
||||
@@ -109,6 +119,17 @@ describe('sidebar state', () => {
|
||||
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1'])
|
||||
})
|
||||
|
||||
it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => {
|
||||
let s = state()
|
||||
s = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
|
||||
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
// Reopening (e.g. the auto-activation effect) focuses the existing tab.
|
||||
const after = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })
|
||||
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
|
||||
const tabs = (after.splits as { tabs: { type: string; id: string }[] }).tabs
|
||||
expect(tabs.filter(tab => tab.type === 'subagent')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('splits panes and moves tabs between them', () => {
|
||||
let s = state()
|
||||
s = splitPane(s, 'row')
|
||||
@@ -324,6 +345,16 @@ describe('persisted state sanitization', () => {
|
||||
expect(clean).toEqual(state)
|
||||
})
|
||||
|
||||
it('accepts a subagent tab as a known type', () => {
|
||||
const raw = JSON.parse(JSON.stringify(makeDefaultState(400)))
|
||||
raw.splits.tabs.push({ id: 'tab:9', type: 'subagent', title: 'Subagents' })
|
||||
raw.splits.active = 'tab:9'
|
||||
const clean = sanitizeState(raw)
|
||||
expect(clean).toBeDefined()
|
||||
const tabs = (clean!.splits as { tabs: { type: string }[] }).tabs
|
||||
expect(tabs.some(tab => tab.type === 'subagent')).toBe(true)
|
||||
})
|
||||
|
||||
it('clamps undersized widths to the panel minimum', () => {
|
||||
const state = { ...makeDefaultState(400), width: 10 }
|
||||
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
|
||||
@@ -450,3 +481,283 @@ describe('editor language mapping', () => {
|
||||
expect(languageKeyForExt('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('office preview kind', () => {
|
||||
it('routes docx/xlsx to their renderers', () => {
|
||||
expect(officeKindForExt('.docx')).toBe('docx')
|
||||
expect(officeKindForExt('.xlsx')).toBe('xlsx')
|
||||
})
|
||||
|
||||
it('routes pptx to its renderer and legacy OLE formats to download-only', () => {
|
||||
expect(officeKindForExt('.pptx')).toBe('pptx')
|
||||
expect(officeKindForExt('.doc')).toBe('download-only')
|
||||
expect(officeKindForExt('.xls')).toBe('download-only')
|
||||
expect(officeKindForExt('.ppt')).toBe('download-only')
|
||||
})
|
||||
|
||||
it('returns null for non-Office extensions and empty input', () => {
|
||||
expect(officeKindForExt('.txt')).toBeNull()
|
||||
expect(officeKindForExt('.md')).toBeNull()
|
||||
expect(officeKindForExt('.png')).toBeNull()
|
||||
expect(officeKindForExt('.pdf')).toBeNull()
|
||||
expect(officeKindForExt('')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pdf preview kind', () => {
|
||||
it('routes only .pdf to the browser-native preview', () => {
|
||||
expect(isPdfExt('.pdf')).toBe(true)
|
||||
expect(isPdfExt('.PDF')).toBe(false)
|
||||
expect(isPdfExt('.docx')).toBe(false)
|
||||
expect(isPdfExt('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('image preview kind', () => {
|
||||
it('routes supported image extensions before binary probing', () => {
|
||||
expect(isImageExt('.png')).toBe(true)
|
||||
expect(isImageExt('.jpg')).toBe(true)
|
||||
expect(isImageExt('.svg')).toBe(true)
|
||||
expect(isImageExt('.avif')).toBe(true)
|
||||
expect(isImageExt('.pdf')).toBe(false)
|
||||
expect(isImageExt('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('side card preferences', () => {
|
||||
/** A fake settings wire face whose settingsGet resolves to one raw value. */
|
||||
const wire = (value: unknown): SidebarSettingsClient => ({
|
||||
settingsGet: async () => ({ value, revision: 1 }),
|
||||
settingsUpdate: async () => ({ value, revision: 2 }),
|
||||
})
|
||||
|
||||
const rejecting = (): SidebarSettingsClient => ({
|
||||
settingsGet: async () => { throw new Error('route rejected') },
|
||||
settingsUpdate: async () => { throw new Error('route rejected') },
|
||||
})
|
||||
|
||||
it('falls back to the defaults when the settings route rejects', async () => {
|
||||
expect(await loadPrefs(rejecting())).toEqual(SIDEBAR_PREFS_DEFAULTS)
|
||||
})
|
||||
|
||||
it('falls back to the defaults when the value is absent or malformed', async () => {
|
||||
expect(await loadPrefs(wire(undefined))).toEqual(SIDEBAR_PREFS_DEFAULTS)
|
||||
expect(await loadPrefs(wire('garbage'))).toEqual(SIDEBAR_PREFS_DEFAULTS)
|
||||
})
|
||||
|
||||
it('parses a valid value and clamps the percent into the contract range', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80, autoOpenSubagent: false })))
|
||||
.toEqual({ openByDefault: false, defaultWidthPercent: 60, autoOpenSubagent: false })
|
||||
})
|
||||
|
||||
it('falls back per-field when a stored field is malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33, autoOpenSubagent: 'no' })))
|
||||
.toEqual({ openByDefault: true, defaultWidthPercent: 33, autoOpenSubagent: true })
|
||||
})
|
||||
|
||||
it('defaults autoOpenSubagent to true when the stored value is absent or malformed', async () => {
|
||||
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 40 })))
|
||||
.toEqual({ openByDefault: false, defaultWidthPercent: 40, autoOpenSubagent: true })
|
||||
expect((await loadPrefs(wire({ openByDefault: true, defaultWidthPercent: 40, autoOpenSubagent: 1 }))).autoOpenSubagent)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
|
||||
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 })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
expect(snapshot.state?.width).toBe(400)
|
||||
// The default prefs keep the panel open.
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setSession('another-fresh')
|
||||
expect(openStore.getSnapshot().state?.panelOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('derives the default width from the window percent with clamps', () => {
|
||||
expect(defaultWidthFor(1440, 30)).toBe(432)
|
||||
expect(defaultWidthFor(800, 30)).toBe(280) // the panel floor
|
||||
expect(defaultWidthFor(1440, 100)).toBe(1440) // the viewport cap
|
||||
})
|
||||
|
||||
it('makeDefaultState honors the open flag', () => {
|
||||
expect(makeDefaultState().panelOpen).toBe(true)
|
||||
expect(makeDefaultState(400, false).panelOpen).toBe(false)
|
||||
expect(makeDefaultState(400, false).width).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('subagent detection over the sessions list feed', () => {
|
||||
/** A list snapshot carrying the given direct subagent children of `parent`. */
|
||||
const list = (
|
||||
parent: string,
|
||||
childIds: string[],
|
||||
running: string[] = [],
|
||||
): SidebarSessionList => {
|
||||
const byId: SidebarSessionList['byId'] = { [parent]: { id: parent, displayTitle: 'Parent' } }
|
||||
for (const id of childIds) {
|
||||
byId[id] = {
|
||||
id,
|
||||
displayTitle: `Child ${id}`,
|
||||
origin: 'subagent',
|
||||
parentId: parent,
|
||||
running: running.includes(id),
|
||||
}
|
||||
}
|
||||
return { current: parent, byId }
|
||||
}
|
||||
|
||||
it('counts only the direct subagent children of the given session', () => {
|
||||
const snapshot = list('p1', ['c1', 'c2'])
|
||||
snapshot.byId['other'] = { id: 'other', displayTitle: 'Other', origin: 'subagent', parentId: 'p2' }
|
||||
expect(directSubagentCount(snapshot.byId, 'p1')).toBe(2)
|
||||
expect(directSubagentCount(snapshot.byId, 'p2')).toBe(1)
|
||||
expect(directSubagentCount(snapshot.byId, 'p1-nobody')).toBe(0)
|
||||
})
|
||||
|
||||
it('fires only on the 0 → N transition of the current session', () => {
|
||||
const empty = list('p1', [])
|
||||
const one = list('p1', ['c1'])
|
||||
const two = list('p1', ['c1', 'c2'])
|
||||
expect(detectNewDirectSubagent(empty, empty, 'p1')).toBe(false)
|
||||
expect(detectNewDirectSubagent(empty, one, 'p1')).toBe(true)
|
||||
// Already-present children never re-trigger (session switch, reload).
|
||||
expect(detectNewDirectSubagent(one, two, 'p1')).toBe(false)
|
||||
expect(detectNewDirectSubagent(two, one, 'p1')).toBe(false)
|
||||
// A child arriving under ANOTHER session does not trigger this one.
|
||||
expect(detectNewDirectSubagent(empty, list('p2', ['x']), 'p1')).toBe(false)
|
||||
})
|
||||
|
||||
it('indexes descendants through uninterrupted subagent lineage', () => {
|
||||
// p1 → c1 (subagent) → g1 (subagent child of c1).
|
||||
const byId: SidebarSessionList['byId'] = {
|
||||
p1: { id: 'p1', displayTitle: 'P1' },
|
||||
c1: { id: 'c1', displayTitle: 'C1', origin: 'subagent', parentId: 'p1', running: true },
|
||||
g1: { id: 'g1', displayTitle: 'G1', origin: 'subagent', parentId: 'c1' },
|
||||
}
|
||||
expect(countSubagentDescendants(byId, 'p1')).toEqual({ count: 2, runningCount: 1 })
|
||||
expect(countSubagentDescendants(byId, 'c1')).toEqual({ count: 1, runningCount: 0 })
|
||||
expect(countSubagentDescendants(byId, 'g1')).toEqual({ count: 0, runningCount: 0 })
|
||||
// An ordinary fork between the parent and the subagent cuts that lineage:
|
||||
// c2 is c1's sibling in origin but its parent `fork` is not a subagent,
|
||||
// so it never reaches p1 (only c1 and g1 count under p1).
|
||||
const forked: SidebarSessionList['byId'] = {
|
||||
...byId,
|
||||
fork: { id: 'fork', displayTitle: 'Fork', parentId: 'p1' },
|
||||
c2: { id: 'c2', displayTitle: 'C2', origin: 'subagent', parentId: 'fork' },
|
||||
}
|
||||
expect(countSubagentDescendants(forked, 'p1')).toEqual({ count: 2, runningCount: 1 })
|
||||
expect(countSubagentDescendants(forked, 'fork')).toEqual({ count: 1, runningCount: 0 })
|
||||
// Cycles terminate (fail soft, never hang); both rows in a 2-cycle reach
|
||||
// the queried node once each.
|
||||
const cyclic: SidebarSessionList['byId'] = {
|
||||
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'b' },
|
||||
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'a' },
|
||||
}
|
||||
expect(countSubagentDescendants(cyclic, 'a')).toEqual({ count: 2, runningCount: 0 })
|
||||
})
|
||||
|
||||
it('resolves the main-agent root of the current session tree', () => {
|
||||
const byId: SidebarSessionList['byId'] = {
|
||||
main: { id: 'main', displayTitle: 'Main' },
|
||||
child: { id: 'child', displayTitle: 'Child', origin: 'subagent', parentId: 'main' },
|
||||
grand: { id: 'grand', displayTitle: 'Grand', origin: 'subagent', parentId: 'child' },
|
||||
}
|
||||
// An ordinary session is its own root; a deep subagent walks up to the main agent.
|
||||
expect(rootAncestor(byId, 'main')).toBe('main')
|
||||
expect(rootAncestor(byId, 'child')).toBe('main')
|
||||
expect(rootAncestor(byId, 'grand')).toBe('main')
|
||||
// A broken chain (parent not in the mirror) degrades to the session itself.
|
||||
expect(rootAncestor(byId, 'orphan')).toBe('orphan')
|
||||
// A hydrating session row degrades to the session itself.
|
||||
expect(rootAncestor(byId, 'not-listed')).toBe('not-listed')
|
||||
expect(rootAncestor(byId, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('collects every catalog branch of the topology, cycle-safe', () => {
|
||||
const child = (id: string, hasChildren: boolean): SidebarSubagentCatalog['entries'][number] => ({
|
||||
kind: 'child', id, activity: 'inactive', hasChildren, mode: 'one-shot',
|
||||
})
|
||||
const catalogs: Record<string, SidebarSubagentCatalog> = {
|
||||
root: { entries: [child('a', true), child('b', false)], parentAvailable: true, state: 'ready', error: null },
|
||||
a: { entries: [child('c', false)], parentAvailable: true, state: 'ready', error: null },
|
||||
}
|
||||
expect(collectBranchIds(catalogs, 'root')).toEqual(['a'])
|
||||
expect(collectBranchIds(catalogs, undefined)).toEqual([])
|
||||
// A cycle terminates (each branch id collected at most once, no hang).
|
||||
const cyclic: Record<string, SidebarSubagentCatalog> = {
|
||||
root: { entries: [child('a', true)], parentAvailable: true, state: 'ready', error: null },
|
||||
a: { entries: [child('root', true)], parentAvailable: true, state: 'ready', error: null },
|
||||
}
|
||||
expect(collectBranchIds(cyclic, 'root')).toEqual(['a', 'root'])
|
||||
})
|
||||
})
|
||||
|
||||
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 },
|
||||
})
|
||||
|
||||
it('extracts text blocks and skips non-text content', () => {
|
||||
// Text blocks join as paragraphs (newline-separated).
|
||||
expect(contentText([{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }])).toBe('hello\n world')
|
||||
expect(contentText([{ type: 'tool_use', name: 'bash' }])).toBeUndefined()
|
||||
expect(contentText(undefined)).toBeUndefined()
|
||||
expect(contentText('nope')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('lastActivity returns the LAST text output and the LAST tool call', () => {
|
||||
const live = lastActivity([
|
||||
entry('turn/start', { turn: 1 }),
|
||||
entry('user/message', { content: [{ type: 'text', text: '请检查代码' }] }),
|
||||
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls -la"}' }),
|
||||
entry('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
message: { content: [{ type: 'text', text: '检查完毕' }] },
|
||||
}),
|
||||
entry('tool/call', { callId: 'c2', name: 'read', arguments: '{"path":"a.ts"}' }),
|
||||
entry('assistant/message', {
|
||||
turn: 1, step: 2,
|
||||
message: { content: [{ type: 'text', text: '再看一眼' }] },
|
||||
}),
|
||||
])
|
||||
expect(live).toEqual({
|
||||
text: '再看一眼',
|
||||
tool: { name: 'read', args: '{"path":"a.ts"}' },
|
||||
})
|
||||
})
|
||||
|
||||
it('lastActivity keeps only the fields the tail actually has', () => {
|
||||
expect(lastActivity([
|
||||
entry('tool/call', { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' }),
|
||||
])).toEqual({ tool: { name: 'bash', args: '{"command":"ls"}' } })
|
||||
expect(lastActivity([
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'ok' }] } }),
|
||||
])).toEqual({ text: 'ok' })
|
||||
})
|
||||
|
||||
it('lastActivity ignores lifecycle events, chunks, and text-less messages', () => {
|
||||
const live = lastActivity([
|
||||
entry('turn/end', { turn: 1, reason: 'success' }),
|
||||
entry('step/start', { turn: 1, step: 1 }),
|
||||
entry('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text', delta: 'x' } }),
|
||||
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'tool_use', name: 'bash' }] } }),
|
||||
])
|
||||
expect(live).toEqual({})
|
||||
expect(lastActivity([])).toEqual({})
|
||||
})
|
||||
|
||||
it('lastActivity defaults a missing tool name and tolerates non-string arguments', () => {
|
||||
const live = lastActivity([
|
||||
entry('tool/call', { callId: 'c1' }),
|
||||
entry('tool/call', { callId: 'c2', name: 'web', arguments: { url: 'x' } }),
|
||||
])
|
||||
expect(live.tool).toEqual({ name: 'web', args: '' })
|
||||
})
|
||||
})
|
||||
|
||||
+105
-29
@@ -1,10 +1,21 @@
|
||||
/**
|
||||
* tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js and
|
||||
* the lib/invariant.js companion, ESM node) plus the browser client bundle
|
||||
* (lib/client.js, CJS closure factory).
|
||||
* the lib/invariant.js companion, ESM node) plus the two browser client
|
||||
* bundles (lib/client.js and lib/client-registry.js, CJS closure factory) —
|
||||
* one per install channel:
|
||||
*
|
||||
* The client bundle replicates the official DSH client-bundle preset
|
||||
* (packages/client/tsdown.client.ts):
|
||||
* - `lib/client.js` serves the official profile channel, registering with
|
||||
* the package-name id `dsh-better-sidebar` (the client-modules compose
|
||||
* keys on the package name; keep it in sync with package.json `name`),
|
||||
* - `lib/client-registry.js` serves the plugin-registry channel
|
||||
* (dsh.plugin.json), registering with the manifest id
|
||||
* `dsh-external/dsh-better-sidebar` (the registry browser-side `arrive()`
|
||||
* check requires bundle id === plugin id).
|
||||
*
|
||||
* Both bundles replicate the official DSH client-bundle preset
|
||||
* (packages/client/tsdown.client.ts) and are compiled from the same
|
||||
* src/client/index.tsx source — only the registered id and the output file
|
||||
* name differ, so they cannot drift:
|
||||
* - externals resolve through the loader module table at runtime (the
|
||||
* PLATFORM_MODULES seed list from apps/web's platform.ts, plus the
|
||||
* runtime/client exemption),
|
||||
@@ -13,21 +24,41 @@
|
||||
* collaboration goes through cordis services, never value imports,
|
||||
* - CSS Modules compile to hashed class maps and inject <style data-plugin>
|
||||
* tags at factory execution,
|
||||
* - the artifact registers itself via window.__ModuleLoader__.load({id,
|
||||
* - each artifact registers itself via window.__ModuleLoader__.load({id,
|
||||
* factory}) with the (require) => exports CJS closure shape.
|
||||
*
|
||||
* Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { builtinModules, createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { UserConfig } from 'tsdown'
|
||||
import { transform } from 'lightningcss'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const PLUGIN_ID = 'dsh-better-sidebar'
|
||||
/** Node builtins must never survive into the browser module-loader factory. */
|
||||
const NODE_BUILTINS = new Set([
|
||||
...builtinModules,
|
||||
...builtinModules.map(id => `node:${id}`),
|
||||
])
|
||||
|
||||
/**
|
||||
* Browser-only standalone entries for dependencies whose package `browser`
|
||||
* remaps Rolldown does not currently honor after CJS lowering. Without these
|
||||
* aliases nanoid/SheetJS/JSZip leave Node builtin require() calls in the
|
||||
* client factory, which the DSH module table correctly refuses.
|
||||
*/
|
||||
const DOCX_PREVIEW_ENTRY = require.resolve('docx-preview')
|
||||
const JSZIP_BROWSER_ENTRY = resolvePath(
|
||||
dirname(require.resolve('jszip/package.json', { paths: [dirname(DOCX_PREVIEW_ENTRY)] })),
|
||||
'dist/jszip.min.js',
|
||||
)
|
||||
const XLSX_BROWSER_ENTRY = resolvePath(
|
||||
dirname(require.resolve('xlsx/package.json')),
|
||||
'dist/xlsx.full.min.js',
|
||||
)
|
||||
|
||||
/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list, plus the runtime/client exemption). */
|
||||
const CLIENT_EXTERNALS = [
|
||||
@@ -58,14 +89,14 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
const REPOSITORY_ROOT = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
/** The style-injection prologue shared by module css and plain css loads. */
|
||||
function injectTag(fileId: string, cssText: string): string {
|
||||
const tagId = `${PLUGIN_ID}/${basename(fileId)}`
|
||||
function injectTag(pluginId: string, fileId: string, cssText: string): string {
|
||||
const tagId = `${pluginId}/${basename(fileId)}`
|
||||
return [
|
||||
`const css = ${JSON.stringify(cssText)};`,
|
||||
`const tagId = ${JSON.stringify(tagId)};`,
|
||||
`if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
|
||||
` const tag = document.createElement('style');`,
|
||||
` tag.dataset.plugin = ${JSON.stringify(PLUGIN_ID)};`,
|
||||
` tag.dataset.plugin = ${JSON.stringify(pluginId)};`,
|
||||
` tag.dataset.pluginCss = tagId;`,
|
||||
` tag.textContent = css;`,
|
||||
` document.head.appendChild(tag);`,
|
||||
@@ -81,21 +112,18 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
|
||||
return `../../../${repositoryPath}`
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' },
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
// clean stays off: the build script removes lib/ wholesale before tsc, so
|
||||
// a tsdown clean here would wipe the lib/types declarations tsc just
|
||||
// emitted (and `watch` must never touch them).
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
/**
|
||||
* One client bundle build for a plugin id. The same src/client/index.tsx is
|
||||
* compiled twice with only the registered id and the output file name
|
||||
* differing: the official channel uses the package name (`dsh-better-sidebar`)
|
||||
* and the registry channel uses the manifest id
|
||||
* (`dsh-external/dsh-better-sidebar`).
|
||||
* @param pluginId - the `__ModuleLoader__.load({ id })` value and the
|
||||
* data-plugin style-tag prefix of this bundle.
|
||||
* @param entryFile - the output file name under lib/.
|
||||
*/
|
||||
function clientBundle(pluginId: string, entryFile: string): UserConfig {
|
||||
return {
|
||||
entry: { client: 'src/client/index.tsx' },
|
||||
outDir: 'lib',
|
||||
format: 'cjs',
|
||||
@@ -108,6 +136,22 @@ export default [
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
|
||||
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
|
||||
// pptx-renderer only uses this to auto-discover its optional PDF.js
|
||||
// fallback. PptxView passes pdfjs:false, so browser CJS has no resolver.
|
||||
'import.meta.resolve': 'undefined',
|
||||
},
|
||||
alias: {
|
||||
jszip: JSZIP_BROWSER_ENTRY,
|
||||
xlsx: XLSX_BROWSER_ENTRY,
|
||||
},
|
||||
// CJS output otherwise makes some transitive packages (notably nanoid
|
||||
// through Univer Core) resolve their Node entry even though this bundle
|
||||
// runs in the browser. Keep browser conditional exports authoritative for
|
||||
// both source import() and generated require() edges.
|
||||
inputOptions: {
|
||||
resolve: {
|
||||
conditionNames: ['browser', 'import', 'require', 'default'],
|
||||
},
|
||||
},
|
||||
// External wins for module-table entries; every other dependency inlines.
|
||||
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
||||
@@ -121,6 +165,12 @@ export default [
|
||||
// imports are erased and never reach this gate.
|
||||
name: 'dsh-client-bundle-purity',
|
||||
resolveId(source: string) {
|
||||
if (NODE_BUILTINS.has(source)) {
|
||||
throw new Error(
|
||||
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
|
||||
+ 'select the dependency browser export or add an explicit browser implementation',
|
||||
)
|
||||
}
|
||||
if (!source.startsWith('@deepseek-ai/')) return null
|
||||
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
|
||||
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
|
||||
@@ -160,22 +210,48 @@ export default [
|
||||
const classMap: Record<string, string> = {}
|
||||
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
|
||||
return [
|
||||
injectTag(fileId, code.toString()),
|
||||
injectTag(pluginId, fileId, code.toString()),
|
||||
`export default ${JSON.stringify(classMap)};`,
|
||||
].join('\n')
|
||||
}
|
||||
return [
|
||||
injectTag(fileId, source.toString('utf8')),
|
||||
injectTag(pluginId, fileId, source.toString('utf8')),
|
||||
'export default "";',
|
||||
].join('\n')
|
||||
},
|
||||
}],
|
||||
outputOptions: {
|
||||
entryFileNames: 'client.js',
|
||||
entryFileNames: entryFile,
|
||||
sourcemapPathTransform: browserSourcePath,
|
||||
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(PLUGIN_ID)}, factory: (require) => {`,
|
||||
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(pluginId)}, factory: (require) => {`,
|
||||
footer: `return module.exports; } });`,
|
||||
intro: 'var module = { exports: {} }; var exports = module.exports;',
|
||||
// The CJS wrapper factory's `require` only resolves module-table entries
|
||||
// (react, cordis, ...); it cannot load relative chunk URLs in the browser.
|
||||
// Disable code splitting so dynamic import() inlines into the single
|
||||
// factory chunk (Office preview libs — docx-preview, Univer — pull in
|
||||
// several MB but only when first opened, the trade-off for wrapper safety).
|
||||
codeSplitting: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' },
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
// clean stays off: the build script removes lib/ wholesale before tsc, so
|
||||
// a tsdown clean here would wipe the lib/types declarations tsc just
|
||||
// emitted (and `watch` must never touch them).
|
||||
clean: false,
|
||||
},
|
||||
// Official profile channel: bundle id = package name (package.json `name`).
|
||||
clientBundle('dsh-better-sidebar', 'client.js'),
|
||||
// Plugin-registry channel: bundle id = manifest id (dsh.plugin.json `id`).
|
||||
clientBundle('dsh-external/dsh-better-sidebar', 'client-registry.js'),
|
||||
] satisfies UserConfig[]
|
||||
|
||||
Reference in New Issue
Block a user