Compare commits

..
64 Commits
Author SHA1 Message Date
Menghuan1918 8f3bb1b264 chore: release v0.4.3 — README 精简重排 + npm 启动模式实测兼容
- README 341 → 130 行:安装/更新合并为一份提示词 + 手动要点,功能一览
  逐节压缩,registry 通道降为可选小节,开发/架构/限制精简;新增
  「npm 包启动(npx @deepseek-ai/dsh)同样可用,v0.4.3 起实测验证」
- 版本 0.4.1 → 0.4.3;cordis.patch.yml 注释同步
2026-08-11 15:47:16 +08:00
Menghuan1918 80f4406ff2 chore: devDependencies 去硬编码 — @deepseek-ai/* 改从 npm 解析(^0.0.1-rc.1)
- package.json:14 条 link:/Users/menghuan/.dsh/source/current/... 绝对路径
  改为 ^0.0.1-rc.1(npm registry 预发布版,官方已发布 0.0.1-rc.1);
  peerDependencies 同步 ^0.0.1-rc.1 以匹配 rc 系列
- 安装需安装者自己的只读令牌(~/.npmrc 配置 //registry.npmjs.org/:_authToken,
  不写进仓库);或改回指向自己 ~/.dsh/source/current 的 link: —— README 三处说明更新
- .gitignore 加 .npmrc 防令牌误提交;token 全程仅环境变量/临时文件使用,已删除
- vitest.config.ts:npm 版 ui-primitives 的 lib 顶层 import katex css,
  node_modules 外部化会被 Node 原生加载撞上 .css —— inline 该包走 Vite 转换 stub
- pnpm-lock.yaml 由 link: 仓库改为 registry tarball(+2001 行);
  pnpm-workspace.yaml 追加 minimumReleaseAgeExclude(pnpm 11 自动记账)
2026-08-11 14:46:56 +08:00
Menghuan1918 ff94d0c124 chore: release v0.4.1 — 侧边栏设置声明式化 + 小卡片网格 + 二级设置弹窗
- 版本从 0.5.0 定为 0.4.1(patch release,含未打 tag 的 v0.4.0 服务化改造)
- 文档版本标注(AGENTS.md / 设计文档)同步为 v0.4.1+
2026-08-11 14:34:18 +08:00
Menghuan1918 0e20413641 feat(settings): 小卡片响应式网格 + 二级设置原生弹窗
- 清单改为小卡片,置于 repeat(auto-fill, minmax(148px,1fr)) 响应式网格,
  一行自适应排多个、随宽度换行;卡片 = 顶行(图标+标题省略+勾选徽标
  钉在最右端)+ 类型/扩展名描述行(省略号 + title 完整值)
- 声明了 settings.toggles 的卡片右下角渲染齿轮角标(IconSettingsOutline16,
  仅父级启用时显示),点击打开原生 Modal 弹窗:DSH 原生设置行
  (标题/描述 + 品牌强调原生复选框 + hairline 分隔)
- 弹窗行体抽为 FeatureSettingsRows 供直测;Modal 条件挂载
  (常闭挂载在测试 dual-react 分裂下会崩)
- 全部 token 使用 DSH 原生别名(interactive-bg-active/button-primary-fill/
  label-* 等);规格测试改为断言网格卡片与齿轮
- 文档(README/AGENTS.md/设计文档偏差记录 5/6)同步
2026-08-11 14:27:16 +08:00
Menghuan1918 1b0d2085bb feat(settings): 开关改为可点击卡片 — 高亮即启用
- 清单项(侧边栏内容/文件预览)、嵌套相关设置与「新会话默认打开」
  全部从复选框行改为 <button aria-pressed> 卡片:整卡点击切换,
  视觉状态即状态(启用 = 品牌描边 + 交互选中填充 + 勾选徽标;
  禁用 = 中性 hairline + 文字降级),悬停仅在禁用卡上显示
- 嵌套相关设置渲染为更小的缩进卡片(仍仅父级启用时显示);
  宽度输入保持原生设置行(数值非开关)
- 设置导航齿轮为宿主 shell navIcon 硬编码(无插件注入点),
  按用户选择不改宿主、保持现状
- 新增 settingsGeneralTitle 文案;规格测试改为断言 aria-pressed
- 文档(README/AGENTS.md/设计文档偏差记录)同步
2026-08-11 14:19:18 +08:00
Menghuan1918 89ddf8a9e7 feat(settings): 侧边栏设置声明式化 — 注册表驱动的开关清单 + 内置支持插头化
- SidebarPrefs 新增 tabsEnabled / viewersEnabled 开放 map(缺省 = 启用),
  PrefsSchema 用 z.dict 校验,旧文档零迁移
- TabDescriptor/FileViewerDescriptor 新增声明式设置面:viewer 补 title/icon,
  两者补 settings.toggles(子代理声明 autoOpenSubagent、终端声明 agentTerminalTools)
- 服务新增 isTabEnabled/isViewerEnabled;openTab 拒绝禁用类型、matchFileViewer
  跳过禁用 viewer
- SideCardSection 重写为注册表驱动清单:侧边栏内容(图标+类型id+开关+嵌套开关)
  + 文件预览(图标+标题+扩展名+开关),开关持久化 + 乐观提交回滚
- 消费点 gating:+ 菜单过滤、子代理自动展开、agent 终端 reconcile、
  产出文件拦截(editor 关闭时回落默认行)、新会话 explorer 预置
- 内置 viewer 补 6 个手绘 outline 图标;store 快照携带 prefs 使开关变更即时生效
- 测试:service gating / builtins 声明 / parsePrefs map 校验 / schema 断言 /
  新增 SideCardSection renderToString 规格
- 文档:README 设置小节、AGENTS.md 描述符与服务方法、新增设计文档;版本 0.5.0
2026-08-11 14:08:55 +08:00
Atlas Zeng 89d2c65707 Merge pull request #7 from dsh-external/cleanup/drop-dshclient
chore: 移除旧字段 dshClient(只适配新 dsh 的 dsh.client 声明)
2026-08-11 12:03:19 +08:00
Menghuan1918 7c792c2a96 chore: 移除旧字段 dshClient — 只适配新 dsh 版本(dsh.client 声明)
PR #6 修复 client 半未进引导清单时保留了旧字段 dshClient 兼容。
按需精简:新 dsh 的 client-modules 扫描器只读 pkg.dsh.client,旧字段
无任何消费方,删除并同步 README 规范符合性说明。
2026-08-11 12:03:07 +08:00
Atlas Zeng f2d47f1437 Merge pull request #6 from dsh-external/fix/client-modules-declaration
fix: package.json 补 dsh.client 声明(client 半未进引导清单的根因)
2026-08-11 12:00:31 +08:00
Menghuan1918 53a9ced6bf fix(client-modules): package.json 补 dsh.client 声明 — client 半未进引导清单的根因修复
诊断(2026-08-11 重启后侧边栏不显示):host 半正常(/sidebar/api 路由
200),但页面 window.__DSH_BOOT__ 引导清单 30 个 client 插件中没有
dsh-better-sidebar。client-modules 扫描器(packages/client/modules)读
package.json 的 pkg.dsh.client 声明 + exports["./client"] 组合 bundle;
插件只声明了旧字段 dshClient(dsH 新版本约定改为 dsh.client,工作正常的
ui-genui 两者并存)。旧字段缺失 → 负判定缓存 → 无 client 入口 → 浏览器
不加载侧边栏。

修复:package.json 新增 "dsh": { "client": { inject, platform } }
与 dshClient 并存(兼容新旧 dsh);README 规范符合性章节同步字段说明,
并顺带修正上一轮安装章节重排遗留的两个失效锚点链接。
2026-08-11 12:00:15 +08:00
Atlas Zeng 1074dc8678 Merge pull request #5 from dsh-external/docs/readme-install-order
docs: README 安装章节重排 — 官方 profile 安装置顶,registry 降为可选
2026-08-11 11:55:56 +08:00
Menghuan1918 8e6d0d799c docs: 安装章节重排 — 官方 profile 安装置顶(推荐),plugin-registry 降为可选通道
实测(2026-08-11 全新安装验证)发现 registry 通道前置条件(dsh registry
命令)并非默认具备,而官方 profile 安装(link: + cordis.patch.yml 挂载行)
零前置可用。重排 README:

1. 「官方 profile 安装(推荐,无需 plugin-registry)」置顶,含一键提示词与更新流程
2. 「通过 plugin-registry 安装(可选)」降为第二章节:已集成 registry 时
   才可走该通道;通道互斥说明同步更新(不再引用章节位置)
2026-08-11 11:55:40 +08:00
Atlas Zeng 8b53c0a3ab Merge pull request #4 from dsh-external/feat/modular-registry
refactor: 全插件注册化收尾 — 审查修正 + 8/8 viewer 吃狗粮 + 模块化 builtins/
2026-08-11 11:29:49 +08:00
Atlas Zeng 2648c0f8e2 Merge pull request #3 from dsh-external/feat/service-registry
feat: 服务化改造 — ctx.betterSidebar 注册表(registerTab/registerFileViewer)+ 内置迁移到 registry,v0.4.0
2026-08-11 11:29:29 +08:00
Menghuan1918 39455ff18e docs+fix: exports 加 ./client/api 别名;catch-all+detect 纯嗅探规则;设计文档偏差记录
- package.json exports 新增 './client/api' 别名指向 service.d.ts(设计文档
  §7 的公共导入路径,api.ts 已被 fetch 助手占用故不换文件名)
- matchFileViewer 补充 sniff-only 规则:exts: [] + detect 的 descriptor 在
  无 head 时不得盲认领(AGENTS.md 的 magic-sniffer 示例原实现会吞掉
  图片/PDF 等真实 viewer 的文件);有 head 时只认领 detect 命中的。
  新增测试断言三种情形(无 head / magic 命中 / magic miss)
- docs/plans/2026-08-11-service-registry-design.md 追加 §17 实施偏差记录:
  dedupeKey+single 语法糖、available 三参、单趟匹配+嗅探规则、EditorHost/
  TextEditor 拆分、fs.read head 字段、导出别名、+ 菜单 disabled 行、
  保留的 store 级操作、内置清单、测试
- AGENTS.md:available 三参、single 语法糖、viewerId、单趟匹配+head 语义、
  8 viewer 内置清单、openTab seed.title 可选、参考实现指向 builtins/ 目录
- README 服务化章节:6 viewer → 8 viewer,标注全注册表驱动
2026-08-11 11:25:37 +08:00
Menghuan1918 1b24bc8ff2 refactor(builtins): 目录重组为 builtins/{tabs,viewers,index},清理死代码与内联样式
- 新 src/client/builtins/ 目录:tabs.tsx(6 个 tab descriptor +
  TERMINAL_LIMIT/uiTerminalCount,terminal 配额知识离开 state.ts)、
  viewers.tsx(8 个 viewer descriptor)、index.ts(registerBuiltins 聚合
  注册 + 幂等 disposer);删除旧 builtins.tsx
- icons.tsx:删除零调用者的 tabTypeIcon 及多余 import(图标已归
  descriptor.icon,映射随 tab 类型一起注册化)
- OrphanedTab:内联 style → sidebar.module.css 新增 .orphanedType 类
- 新增 tests/builtins.spec.ts(6 tab + 8 viewer 注册断言、code catch-all、
  binary-download NUL detect、office 优先级、disposer 幂等)
- 新增 tests/orphaned-tab.spec.tsx(sanitize 保留未注册类型 tab、
  OrphanedTab 占位渲染);tests/browser-globals.ts 供拉入 xterm/
  CodeMirror 的 spec 首个 import(模块求值期需要 self/document/navigator)
- service.spec 补 openDiffTab 与 diff dedupeKey 一致性用例
2026-08-11 11:22:28 +08:00
Menghuan1918 0467101faf feat(editor): 拆分 EditorHost/TextEditor — code/markdown 成为注册 viewer,fs.read 增加 head 字节
- 新 src/client/EditorHost.tsx:'editor' tab 组件。matchFileViewer →
  按 fetchStrategy 分发(纯逻辑在 editor-load.ts)→ 渲染 viewer.component;
  binary 无匹配时渲染共享 BinaryDownload。标题栏保留在 host
- 新 src/client/TextEditor.tsx:code/markdown 的 viewer 组件(CodeMirror +
  保存/脏点/截断横幅 + md 预览/编辑切换),内容由 host 经 fsRead 传入,
  自身不再 fetch;按 viewerId === 'markdown' 分支(不再解析扩展名)
- 新 src/client/editor-load.ts:planFirstMatch / planFsReadOutcome /
  decodeHead 纯函数,策略分发可单测
- 新 src/client/binary-download.tsx:共享下载兜底组件(viewer 注册与
  host fallback 同一 UI)
- host src/index.ts:fs.read 二进制响应增加 head 字段(base64,前 4KB,
  唯一 host 触碰,设计文档 detect 机制所需);客户端 FsBinaryResult 同步
- builtins:注册 8 个 viewer(新增 markdown exts md/markdown、code
  catch-all -100);binary-download 增加 NUL detect + 复用 BinaryDownload
- 删除 src/client/EditorView.tsx(职责一分为二);陈旧注释同步
- 新增 tests/editor-load.spec.ts(12 用例)
2026-08-11 11:16:36 +08:00
Menghuan1918 db0ba36610 fix(service): 注册表核心修正 — seed.title 优先、single 语法糖、available 三参、单趟匹配、dedupe 委托
- service.openTab 的 seed.title 优先于 descriptor.title(修复编辑器 tab 标题
  从文件名变成 t('editor') 的回归);title 变为可选,descriptor.title 兜底
- TabDescriptor 新增 single?: boolean 语法糖(展开为 dedupeKey: () => id),
  dedupeKey 显式给出时优先
- available 签名升级为 (ctx, scope, state) 三参(设计文档 §5.2 的超集),
  Sidebar.buildNewTabOptions 与 terminal 配额检查适配
- matchFileViewer 从三趟全局遍历改为设计文档 §8.2 的单趟 per-descriptor 算法:
  priority 降序,每个 descriptor 先 detect(head 可用时)再 exts,[] 为 catch-all
- applyDedupe 的落位逻辑委托给 state.openTabInActivePane(消除重复实现),
  同时 id 安全网现在也覆盖 service.openTab 路径(同 id 打开聚焦既有 tab)
- state.ts:删除零引用的 SIDEBAR_BUILTIN_TAB_TYPES;TERMINAL_LIMIT 移入
  builtins.tsx(state 不再关心 terminal);openTab reducer 改名 openTabInActivePane
- Sidebar.tsx 删除 TERMINAL_LIMIT/allLeaves 死 import;openDiffTab 补充
  dedupeKey 一致性注释(其按 id 去重与 diff descriptor 的 dedupeKey 同规则)
- tests/service.spec.ts 重写匹配/去重断言并新增 title/single/available 用例
2026-08-11 11:12:29 +08:00
HuanLinOTO 7687204b43 docs: 服务化接入文档(AGENTS.md)+ README 标注
- AGENTS.md: 完整接入文档(消费插件开发者面向)
  - TabDescriptor / FileViewerDescriptor 全字段
  - fetchStrategy 对照 + matchFileViewer 匹配算法
  - HMR 生命周期 + 平台约束陷阱
  - 完整最小示例(package.json + client half + profile 注册)
- README.md: 加「服务化(v0.4.0+)」章节,链接到 AGENTS.md
2026-08-11 01:44:00 +08:00
HuanLinOTO e40039ceec refactor: 完全解耦内置 tab — dedupe 归 service,图标归 descriptor
- state.openTab 简化为 '添加到 pane + id dedupe',移除 isSingle/editor-path/diff-id 三分支
  (dedupe 策略归 service.openTab 通过 descriptor.dedupeKey 统一)
- intercept.tsx openSidebarFile 改走 service.openTab(editor descriptor 的 dedupeKey 接管 per-path 去重)
- Sidebar.tsx 两处 subagent 触发(auto-activation + jump-back)改走 service.openTab
- service.openTab seed 加可选 id 字段(editor 需要 path-derived id)
- TabBar.tsx 加 getTabIcon 回调,tab 标签图标查 descriptor.icon 替代 tabTypeIcon
- split-pane.tsx 移除 tabTypeIcon 兜底(buildNewTabOptions 已从 descriptor 填 icon)
- Workbench/NodeView/LeafView 透传 getTabIcon
- tests/unit.spec.ts 更新 openTab 测试(id dedupe 替代 type dedupe)
2026-08-11 01:39:20 +08:00
HuanLinOTO 291158a3ba feat: 服务化改造 — ctx.betterSidebar 注册表(registerTab/registerFileViewer),内置 6 tab + 6 viewer 迁移到 registry,v0.4.0
- 新增 src/client/service.ts: BetterSidebarService 接口 + createBetterSidebarService 工厂
  - registerTab/registerFileViewer 返回 disposer(HMR-safe,挂到 cordis fiber)
  - matchFileViewer 三段匹配: priority desc → detect(内容嗅探) → exts
  - openTab 走 descriptor.dedupeKey 统一去重(替代 isSingle/editor-path/diff-id 三分支)
  - createTab 回调支持自定义 tab 创建(terminal 的 nextTerminal 递增)
- 新增 src/client/builtins.tsx: 6 内置 tab + 6 内置 viewer 自我注册(吃狗粮)
  - tab: editor(hidden) / explorer / git / subagent / terminal / diff(hidden)
  - viewer: image / pdf / docx / xlsx / pptx / binary-download
  - code/markdown 保持 EditorView 内联兜底(matchFileViewer 返回 undefined 时走 fsRead+CodeMirror)
- 新增 src/client/OrphanedTab.tsx: 未注册 type 的占位组件(插件未加载时降级)
- 改造 src/client/index.tsx: ctx.provide('betterSidebar') + registerBuiltins
- 改造 src/client/Sidebar.tsx: TabContent/buildNewTabOptions/onNewTab 查表替代 switch
- 改造 src/client/EditorView.tsx: 预览路由查 matchFileViewer 替代 if/else 链
- 改造 src/client/state.ts: TabType 联合字面量→string,sanitizeNode 接受所有 string type
- 改造 src/context-types.ts: declare module 'cordis' 加 betterSidebar 字段
- 改造 src/index.ts: re-export Context + 服务类型(consumer import type 触发 augmentation)
- package.json: 加 ./client/service exports 子路径,version 0.4.0
- tests/service.spec.ts: 12 个新测试覆盖 register/dispose/matchFileViewer/dedupe/createTab
- tests/unit.spec.ts: sanitizeState 测试更新(未知 type 不再被拒绝,渲染 OrphanedTab)
2026-08-11 00:31:23 +08:00
HuanLinOTO fbebcf5138 docs: 服务化改造设计(registerTab/registerFileViewer + 内置迁移到 registry) 2026-08-11 00:09:46 +08:00
Menghuan1918 069c754877 feat: Git 面板真正的 diff 显示 + VSCode 式历史与右键高级操作,v0.3.0
- 真正的统一 diff 显示:hunk 头 + 双行号 + 红绿着色,长行换行无横向滚动,untracked 全新增视图
- 点击变更/历史自动打开独立 diff tab(首次在下半栏新增分栏,同变更去重聚焦,临时页签刷新不恢复)
- 历史行:分支徽章 + 作者/相对时间,懒加载分页(20 条/页 + 加载更多)
- 右键菜单:文件行(打开/暂存/放弃更改/复制路径)、历史行(复制哈希/信息/还原/捡取),破坏性操作确认弹窗
- 修复暂存/未暂存分类错误(' M' 被误标为已暂存导致 diff 恒空)
- 修复 git 相对路径解析(会话在仓库子目录时 diff/discard/show/read 可用)
- host 新增 git.commit-diff / git.discard / git.revert / git.cherry-pick 路由与 log 分页
2026-08-10 22:29:24 +08:00
Atlas Zeng feea87557c Merge pull request #2 from dsh-external/feat/agent-terminal-tools
feat: 为 agent 暴露 8 个终端工具,侧边栏自动同步 agent 终端 tab,v0.2.0
2026-08-10 21:49:58 +08:00
Menghuan1918 8edfbfde44 fix: 修复 agent 终端工具审查问题(terminal_list 输出契约、跨会话归属、UTF-8 截断边界等),工具注入改为设置面板开关(默认关闭) 2026-08-10 21:43:58 +08:00
HuanLinOTO 6f62d19905 feat: 为 agent 暴露 8 个终端工具(terminal_create/list/send/read/wait_for/resize/signal/close),侧边栏自动同步 agent 终端 tab,v0.2.0 2026-08-10 19:08:58 +08:00
Menghuan1918 61b18cbefe feat: Subagent 拓扑页面(主代理根节点 + 分层树 + 实时工具调用 + 自动激活设置),v0.1.9 2026-08-10 17:24:42 +08:00
Atlas Zeng 1064ce954f Merge pull request #1 from dsh-external/feat/office-preview
feat: add Office, PDF, PPTX, and image previews
2026-08-10 16:58:48 +08:00
HuanLinOTO eb5059574e fix: route images before binary detection 2026-08-10 16:54:13 +08:00
HuanLinOTO a6befc8b6b feat: add Office, PDF, and PPTX previews 2026-08-10 16:46:01 +08:00
Menghuan1918 4060bd53e1 fix: 分割线改为 1px 发丝线(保留 7px 拖拽热区),v0.1.3
- 分割线视觉从 7px 实底色块改为居中 1px 发丝线(::after 伪元素)
- 悬停/拖拽时线变色为强调色,不再整条填充
- 拖拽热区保持 7px 不变;reduced-motion 下伪元素过渡一并禁用
- version 同步 0.1.3(package.json + dsh.plugin.json)
2026-08-09 23:53:52 +08:00
Menghuan1918 a1286191d6 feat: plugin-registry 标准发布面(dsh.plugin.json + 双 client bundle + registry 打包),v0.1.2
- dsh.plugin.json 原生清单:id dsh-external/dsh-better-sidebar,version 0.1.2,
  main = lib/index.js,client.main = lib/client-registry.js,contributes 空声明
- tsdown client 构建参数化:lib/client.js(官方通道,id = 包名)+ lib/client-registry.js
  (registry 通道,id = 清单 id),同源不漂移
- scripts/package-registry.mjs 组装 registry/ 安装根(gitignore,不入库)
- tests/manifest-consistency.spec.ts:id 两段校验 / version 同步 / 入口存在 /
  双 bundle id 不互换 / contributes 空
- README:新增「通过 plugin-registry 安装(标准)」章节 + 保留官方 profile 安装
  方案 + 通道互斥说明;架构表补 manifest 与双 client 行
2026-08-09 23:49:58 +08:00
Menghuan1918 9b00135de4 feat: DSH-native flat chrome + Side card settings (plugin-owned settings wire)
- Remove every box-shadow; panel uses the native sidebar fill surface
  (--dsw-specific-sidebar-fill) with a hairline border, the collapse
  control becomes the native 36px circular rail button, the active tab
  uses the native interactive-bg-active fill, and rows/typography follow
  the native 34px/14px session-row recipe.
- New 'Side card' settings section in the DSH Settings shell: open-by-
  default for new conversations (boolean) and default width share as a
  percent of the window (20-60). New sessions seed from these prefs;
  existing per-session layouts stay untouched.
- The settings namespace is read/written through the plugin's own fenced
  /sidebar routes calling the settings seam in-process: the DSH settings
  RPC domain only serves allowlisted namespaces to configuration clients,
  so third-party namespaces cannot use connection.api.settings (reads are
  silently filtered, writes refused with 'not exposed'). The seam still
  persists to the user settings document with revision-guarded writes.
2026-08-09 23:49:58 +08:00
Atlas Zeng 06440d0365 Update README.md 2026-08-09 22:12:53 +08:00
Menghuan1918 c4851dceb7 docs: update prompt for DSH (same one-shot style as install) 2026-08-09 22:11:37 +08:00
Menghuan1918 ed6d86d5ee chore: release 0.1.1 2026-08-09 22:07:03 +08:00
Menghuan1918 260e3b8d6b docs: one-shot install prompt for DSH; platform support notes
- README leads with a self-contained prompt the user pastes into DSH:
  clone URL, build, profile registration, cordis mount row, restart
- manual install/update section kept as the no-prompt alternative
- platform support section documents win32/linux/macOS adaptation and
  the node-pty build-tool requirements
2026-08-09 22:07:03 +08:00
Menghuan1918 81ab0c5312 fix: cross-platform path handling and tests
- fs-tree joins listed paths with the platform separator (Windows rows
  previously leaked mixed separators)
- media route containment check goes through isWithin(): separator-
  tolerant and case-insensitive on win32, so case-mismatched Windows
  paths are not misclassified
- relativeTo compares case-insensitively (Windows / case-insensitive
  macOS volumes) while keeping the caller's own casing in the output
- tests: platform-conditional path assertions, os.tmpdir() instead of
  /tmp, isWithin/relativeTo/resolveSidebarPath win32 coverage
2026-08-09 22:07:03 +08:00
Menghuan1918 9b153844e9 feat: explorer file download via the host route; editor line numbers
- explorer row menu gains a download action (files only); the host route
  serves raw bytes with Content-Disposition: attachment, binary-safe
- editor enables line numbers
2026-08-09 22:07:03 +08:00
Menghuan1918 e2ee427ea3 feat: per-session store + configurable host limits; fix divider drag runaway
- state: side-store per conversation, injected as a prop instead of a module singleton
- host: schemastery Config (read/media/list/terminal limits) resolved from cordis config
- shape: types/exports/peerDependencies for the web profile + plugin-shape spec
- fix: split divider emits incremental deltas so drags track the cursor 1:1
- docs: README refresh
2026-08-09 22:07:03 +08:00
Atlas Zeng a10c9fc77e Update README.md 2026-08-09 11:46:58 +08:00
Menghuan1918 2247f6ecce docs: refresh README — pitch, feature map, shortcut cheat-sheet, state self-healing
Restructures the README for readability: a one-line pitch plus "why use
it" bullets, emoji-grouped feature sections, a keyboard-shortcut table,
and a quick-start flow. Adds the recently shipped capabilities to the
feature map (tab/+ menu icons, the @-reference row button, right-click
copy relative/absolute path, persisted-state self-healing) and documents
the split-id de-duplication under architecture.
2026-08-09 11:45:15 +08:00
Menghuan1918 fec53cef0d fix: split panes duplicate when a file opens — stale uid collisions with persisted ids
The uid counter is module-global and resets on every reload, while the
persisted split tree keeps its old ids (pane:1, split:1, ...). A split
created AFTER a reload minted a fresh "pane:1" beside the persisted
"pane:1"; mapLeaf then visited BOTH leaves, so every open (explorer click,
produced-files chip) landed the tab in both panes of a top-bottom split.

- loadState seeds the counter past the raw persisted ids before sanitize
- setSession re-seeds on cache hits (another session's smaller max must
  not leave the counter below this session's ids)
- sanitize re-ids stale duplicate pane/split ids (heals states already
  corrupted) and follows the activePane rename
- openTab falls back to the first pane when the active pane is gone
  instead of silently dropping the open

Unit tests cover the re-id heal and the stale-activePane fallback;
verified end-to-end in headless Chrome: a state with two "pane:1" leaves
loads healed and a file click lands in exactly one pane.
2026-08-09 00:14:27 +08:00
Menghuan1918 c81de3374f fix: @-reference button no-ops — read conversation via ctx.get, not the ctx property
Cordis only grants property reads for services declared in the plugin's
inject list; every other read throws "cannot get property X without
inject" (the app's own plugins read 'conversation' through ctx.get for
this reason). referenceInChat now resolves the conversation service lazily
via ctx.get('conversation') and no-ops when absent. Verified end-to-end in
headless Chrome: clicking the @file button on root/dir/file rows now
appends the at-relative-path mention to the composer draft with no
console errors.
2026-08-09 00:01:56 +08:00
Menghuan1918 f091ac322b fix: sidebar crash #310 — referenceInChat hook sat behind the no-session early return
The new useCallback was declared after the early return for the no-session
state, so the first render (no session) ran fewer hooks than the follow-up
render once a session arrived — React error #310, boundary strip on load.
Moved the hook above the early return (with a sessionId guard) and verified
headlessly: the error no longer appears and the panel renders fully.
2026-08-08 23:51:42 +08:00
Menghuan1918 6e36a99c88 feat: explorer copy-path & @-reference actions; icons on tabs and + menu
- Tabs, the + menu, and empty-pane cards carry per-type icons (folder /
  branch / terminal / code file); a terminal glyph joins the icon set
- Hovering an explorer row reveals an @文件 button at the far right that
  appends @<relative path> to the composer draft (session-scoped input
  service, space-separated append)
- Right-clicking a row opens a context menu: copy relative / absolute
  path (writeClipboard + transient in-row "copied" label)
- Rows become div role=button (Enter/Space) to host the inner button;
  relativeTo helper unit-tested
2026-08-08 23:46:14 +08:00
Menghuan1918 88b08de957 docs: README — keepalive semantics, theme adaptation, DSH-native chrome 2026-08-08 17:32:23 +08:00
Menghuan1918 439adf3967 ui: align the sidebar with the app's own chrome (rows, buttons, motion)
Explorer rows become the app's tree-row cell — 34px, radius 8, inset by
the scroll padding, 22px indent step, and the session-row mount fade —
and git rows get the same cell plus the proper selected fill
(interactive-bg-active). Icon buttons switch to the app's 28px circle
pattern (ui-workspace), headers settle at the 36px section rhythm, and
keyboard focus gets a visible ring everywhere per the styling policy,
with a prefers-reduced-motion block turning off the slides and fades.
2026-08-08 17:30:45 +08:00
Menghuan1918 7aa24badf0 theme: terminal and editor follow the app's color scheme, re-theming in place
The app resolves light/dark from prefers-color-scheme and the user's
choice (body[data-ds-dark-theme]), but xterm and CodeMirror were pinned to
hardcoded dark themes — a light-mode user got a dark card inside a light
panel. New theme.ts reads the resolved scheme + token values and notifies
on flips (the presenter toggles the body attribute); the terminal now takes
its surface colors from the tokens and a curated one-dark/one-light ANSI
palette, and the editor gets a token-driven surface with the same curated
syntax palettes. Both re-theme in place on a scheme flip: the terminal
re-applies its theme, the editor reconfigures a Compartment holding only
the scheme-dependent extensions, so the document, undo history and scroll
survive. one-dark is dropped (superseded by the curated highlight style).
2026-08-08 17:29:13 +08:00
Menghuan1918 d1cdca3324 terminal: keep the pty alive while its tab is open, whatever the view does
The close frame was sent on every unmount, so switching conversations (or
refreshing) killed the shell even though the tab was still open; coming
back spawned a fresh terminal. Now the unmount close frame only fires when
the tab is truly gone (checked against the per-session store state), so a
bare unmount hands the process to the host's reconnect grace and the next
connect reattaches the SAME shell with its transcript. Closing a tab
releases the quota immediately: the socket-open path keeps the close
frame, and a new pty.close API covers the socket-down (reconnect loop)
case that previously left a 30s zombie holding the quota.

The host also records the spawn cwd and respawns when a reconnect carries
a different authoritative cwd — the page-load hydrate race can attach the
real session cwd after the first connect fell back to the process cwd, and
a shell in the wrong directory must not linger.
2026-08-08 17:24:08 +08:00
Atlas Zeng cef0251aba Update README.md 2026-08-08 16:23:36 +08:00
Menghuan1918 9363ccfd9d docs: rewrite README — features first, then install-from-source
Restructured per the user's request: a full feature walkthrough (explorer,
CodeMirror editor with preview/edit toggle, terminal, git, split workbench,
tab bar, session isolation, interception) followed by step-by-step source
installation (clone → pnpm install/build → web profile registration →
restart). Corrected stale limitations (syntax highlighting now shipped;
the sidebar squeezes the layout instead of floating).
2026-08-08 16:20:48 +08:00
Menghuan1918 f4b86770d0 tabs: keep all tab content mounted (inactive hidden), so switching tabs never reloads a terminal
Switching tabs used to unmount the active view; a terminal's unmount sent
the tab-close frame, killing its pty, and switching back respawned a fresh
shell. Every tab now stays mounted and inactive ones are display:none: a
terminal keeps its WebSocket + pty + scrollback, an editor keeps its
CodeMirror view and unsaved draft, explorer/git keep their data. The close
frame now fires only when a tab is truly closed (close button, middle
click, pane removal). Re-shown terminals re-fit via the existing
ResizeObserver; editors re-measure via CodeMirror's own observer.
2026-08-08 16:19:18 +08:00
Menghuan1918 e80c1f6625 terminal: build the ws URL like the app's own downlinks; show the attempted URL on failure
The served bundle's wsUrl is provably correct, yet one report showed
ws://host/ws//host/sidebar/ws/terminal (a /ws/ prefix no version ever
emitted). Mirror the app's own downlink construction (new URL(path,
location.origin) + protocol swap — the construction the chat's working
event streams use), and surface the exact attempted URL in the failure
banner plus a console.error, so a recurrence identifies the source
(stale page vs environment rewrite) immediately.
2026-08-08 15:23:01 +08:00
Menghuan1918 97e1f60e47 ui: preview/edit toggle for markdown, empty-pane open cards, + flush to the rightmost tab
- markdown tabs open in rendered preview with a 预览/编辑 segmented toggle;
  edit mode uses the CodeMirror source editor (markdown highlighting), the
  preview renders the live unsaved draft, and the editor stays mounted while
  previewing so edits survive the toggle.
- an empty pane shows openable-type cards (explorer / git / terminal, the
  same options as the + menu, terminal dimmed at the cap) instead of the
  bare 'this pane is empty' message; clicking a card opens the tab.
- the + button sits immediately right of the last tab (sticky at the
  scrollport's right edge when the tab row overflows, so it stays
  reachable).
2026-08-08 15:16:09 +08:00
Menghuan1918 47ebffcf9b fix: session-cwd attach race, terminal failure surfacing, white-screen hardening; CodeMirror editor
- cwd resolution falls back (attached header → client summary cwd → process
  cwd) across the JSON API, media route and terminal upgrade, so explorer/
  git/terminal work from first paint instead of 'session ... has no working
  directory' while the web client hydrates the session (refresh-only fix).
- terminal client threads the summary cwd into the WS URL and stops the
  reconnect loop after three unreasoned failures, showing the close code
  with a manual retry; a 1011+reason refusal already showed the reason.
- white screen: persisted state is structurally sanitized on load (malformed
  shapes fall back to default) and the restored width is clamped to the
  viewport; the layout margin is capped at the viewport; the client entry
  wraps every lifecycle step in try/catch and pins a visible diagnostic bar
  on load/mount/interception failure instead of failing silently.
- editor: textarea replaced with CodeMirror 6 — line wrapping, per-extension
  syntax highlighting (js/ts/jsx/tsx, json, python, html, css, xml, yaml,
  sql, java, c/c++, rust, go, php, shell, toml, nginx, dockerfile,
  properties), one-dark theme, dirty dot + Ctrl/Cmd+S save kept.
- tests: +9 (cwd fallback chain over the API route, state sanitization,
  language mapping); 36 pass, typecheck + build + vm materialize clean.
2026-08-08 15:12:56 +08:00
Menghuan1918 8950e2ae69 fix: zombie terminals eating the quota; tab drop double-firing into two panes
- The per-session terminal quota counted exited zombie handles (shells
  closed or dropped on hosts without the close-frame protocol), so enough
  abandoned terminals permanently blocked new ones — the 'disconnected,
  reconnecting' loop. open() now sweeps the session's exited handles before
  the quota check, and the running instance's leftovers disappear on the
  next restart (disposeAll).
- Dragging a tab onto the strip's background fired the drop TWICE: the
  TabBar handled it (merge into this pane) and the event bubbled to the
  pane root, which ran its edge-zone handler again — producing two splits /
  a duplicated instance (up+down). The strip now stops propagation for
  drag-over and drop.
- Tests: +1 real-pty smoke (exited handle swept, new tab spawns under a
  quota of 1).
2026-08-08 14:54:40 +08:00
Menghuan1918 302bd85e62 sidebar: squeeze the conversation layout instead of floating over it
The open panel now occupies layout space: #root (the AppFrame grid) gives
up the panel width on the right via margin-right driven by a
--dsh-sidebar-width CSS variable, and only the center column is flexible,
so the squeeze lands exactly on the agent output and the input bar (VSCode
sidebar behavior). The fixed panel covers the vacated strip; expand/
collapse animates the margin and the slide on the same theme duration,
width drags disable the transition, and prefers-reduced-motion is honored.
2026-08-08 14:51:05 +08:00
Menghuan1918 0621504e1b fix: terminal quota leak and dead-terminal reuse; error boundary against blank panel
- Terminals were never destroyed when their tab closed (socket drop only
  detached listeners), so the per-session quota (3) filled up and new
  terminals failed to mount with 'terminal limit reached'. Now the client
  sends a {type:'close'} control frame on tab teardown and the host releases
  the pty immediately; a bare socket drop (refresh/switch) keeps the process
  for a 30s grace period, and a reconnect within it cancels the pending
  close. open() also respawns a process whose shell already exited instead
  of reusing a dead handle (input sink).
- The sidebar portal renders inside an error boundary: a render failure now
  shows a dismissible error strip with the message (console.error with the
  component stack) instead of a silent blank panel.
- Tests: +2 real-pty smoke specs (close releases quota + respawn after
  exit; reconnect cancels pending close).
2026-08-08 12:31:22 +08:00
Menghuan1918 56a27990d7 sidebar: + menu always reachable on empty panes, unified default width (content-area half), expand/collapse animation
- An empty pane (fresh split, or last tab dragged out) now keeps the tab
  strip with its + menu mounted, so a new tab can always be created or
  dragged in; the placeholder body still explains the state.
- Default width unified: new sessions open at half the CONTENT area
  (viewport minus the left sidebar), so the panel never swallows half the
  whole window and old/new sessions no longer disagree (previously old
  persisted widths ~1/4 vs fresh sessions 1/2). Fullscreen restore targets
  the same content-half width.
- Expand/collapse now animates: the panel stays mounted and slides
  off-screen (translateX + visibility after the slide); fullscreen width
  changes animate too, and drag-resizing disables the transition.
2026-08-08 12:05:25 +08:00
Menghuan1918 c7a7c7e5af terminal: fix posix_spawnp failure (spawn-helper exec bit), half-viewport default, fullscreen toggle, middle-click close
- Root cause of the endless 'disconnected, reconnecting' banner: pnpm
  strips the executable bit from node-pty's prebuilt spawn-helper, so every
  spawn failed with posix_spawnp failed and the client retried forever.
  ensureSpawnHelper() restores 0755 at plugin activation (mirrors
  dsh-pty-local's postinstall; works for link installs too). Verified
  against the live GUI: terminal now connects and echoes.
- TerminalView stops auto-reconnecting on a server refusal (close 1011 with
  a reason): shows the reason plus a manual retry instead of spinning.
- defaultShell() also guards an empty SHELL.
- New sessions open at half the viewport width; width clamping now allows
  filling the window, and a header fullscreen button expands the sidebar to
  the full viewport (toggle restores half).
- Middle-click on a tab closes it (auxclick button 1, autoscroll
  suppressed).
- Tests: +2 (shell fallback, spawn-helper exec bit idempotent).
2026-08-08 11:28:43 +08:00
Menghuan1918 58b4bd71ba sidebar: VSCode-style drag-to-edge splitting, collapse instead of close, theme-compliant tokens
- Split panes by dragging a tab to a pane edge (left/right/up/down zones
  with a drop overlay) or into the center to merge; the toolbar split
  buttons are gone (split-pane.tsx, state.ts moveTabToEdge/insertLeafAt).
- The header close button is now a collapse button; the expand button on
  the right edge stays visible whenever the panel is collapsed, so the
  sidebar can always be reopened (Sidebar.tsx, locales collapse/expand).
- Styles now follow the DSH web-styling rules exactly: --dsw-alias-*
  semantic colors (state-error/warn tokens), --dsw-font-* typography roles
  (size + line-height paired), --dsw-shadow-lv* elevation, no color
  literals or static palette values (sidebar.module.css).
- Tests: +3 for moveTabToEdge (edge split, center merge, self reorder).
2026-08-08 10:35:47 +08:00
Menghuan1918 63e2988ef1 dsh-better-sidebar: VSCode-like right sidebar plugin for the DSH web GUI
Host half (src/index.ts): /sidebar/api JSON API (session cwd, fs tree/read/
write, git status/diff/stage/commit/branch/log/show), /sidebar/file media
route, /sidebar/ws/terminal WebSocket over node-pty; routes share the /api
trust fence (Host-header loopback or connection.trustedHosts).

Client half (src/client): portal-mounted right panel with a per-session
split-pane workbench (recursive row/col splits, draggable dividers, tab
drag-move), tab bar with max-width 160px + plus-menu (explorer/git/terminal
multi-open rules), lazy file explorer rooted at the session cwd, editable
text editor (dirty dot, Ctrl/Cmd+S), image + Markdown preview, xterm
terminal with reconnect, and a VSCode-style git panel. Per-session state
persists to localStorage (dsh-sidebar:v1:<sessionId>). Intercepts the chat
turn-tail produced-files row (priority -1 chain entry) to open files in the
sidebar. UI styled with the dsh --dsw-* theme tokens and ui-primitives.

Build: tsdown emits the host lib plus the __ModuleLoader__ client bundle
(externals = module table words; xterm/css inlined). Tests: 19 unit + smoke
specs (pure state/tree/git parsers + real git/fs integration).
2026-08-08 01:02:24 +08:00
Atlas Zeng 4f4e462f98 Initial commit 2026-08-08 00:04:01 +08:00
94 changed files with 23025 additions and 3845 deletions
+141 -24
View File
@@ -1,31 +1,148 @@
# Build output
lib/
dist/
# Dependencies
node_modules/
# Logs & caches
# Logs
logs
*.log
.vite/
.vitest/
coverage/
.cache/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS / editor
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Local env
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.local
.env.*.local
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp directory
.temp
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# pnpm
pnpm-debug.log*
.pnpm-store
# Unrelated work that landed in this directory (not part of apes-sidebar)
maxkb-integration/
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
lib/
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
registry/
.npmrc
-6
View File
@@ -1,6 +0,0 @@
# apes-sidebar build config
# Allow node-pty (a transitive runtime dep of dsh-better-sidebar's terminal
# panel) to run its native build script. Phase 0 never exercises the terminal
# panel, but pnpm's ignored-builds gate hard-fails the preinstall dep check
# otherwise, which blocks `pnpm build`.
onlyBuiltDependencies=node-pty
+460 -131
View File
@@ -1,157 +1,486 @@
# apes-sidebar — engineering conventions
# dsh-better-sidebar 插件接入文档
A DSH (DeepSeek Harness) **sidebar plugin** that registers five information-layer
tabs into the `dsh-better-sidebar` host: `apes:tasks`, `apes:agents`,
`apes:apps`, `apes:messages`, `apes:profile`.
> 面向**消费插件开发者**:如何让你的插件向 better-sidebar 注册新的侧边栏页面(tab)和文件类型预览器。
This repo is a **from-scratch v2.0.0 rewrite** of the legacy JS-bundle sidebar.
The engineering template is `dsh-better-sidebar` (TS + tsdown + host/client
split). If a convention here conflicts with `dsh-better-sidebar`, follow
better-sidebar and file a note in `docs/`.
better-sidebar 从 v0.4.0 起暴露 `ctx.betterSidebar` 服务(Cordis context 属性),其他插件通过 `registerTab` / `registerFileViewer` 注册扩展点,返回 disposer 由 Cordis fiber 自动管理生命周期(HMR-safe)。
## Stack
---
- **Language**: TypeScript (strict, `noUncheckedIndexedAccess`, ESM, `.ts` extension imports)
- **Build**: `tsdown` (rolldown). Dual output — `lib/index.js` + `lib/invariant.js`
(host, ESM) and `lib/client.js` (client, self-contained bundle with a named
`apply` export)
- **Type check**: `tsc --noEmit -p tsconfig.json`
- **Unit tests**: `vitest` (node environment, no DOM)
- **Package manager**: `pnpm` (Node ≥ 22.13 required — `node:sqlite` + pnpm 11)
- **Peer**: `dsh-better-sidebar` ≥ 0.18.0 (host-provided, never bundled)
- **Dev-only types**: `@deepseek-ai/cordis` (types; host provides at runtime)
## 1. 服务定位
## Commands
- **服务名**:`betterSidebar`(即 `ctx.betterSidebar`)
- **发布侧**:better-sidebar 的 client half(`src/client/index.tsx`,通过 `ctx.provide('betterSidebar', service)` 发布)
- **消费侧**:你的插件的 client half(`inject = ['betterSidebar', ...]`,然后 `ctx.betterSidebar.registerTab(...)`)
- **类型合并**:`declare module 'cordis' { interface Context { betterSidebar: BetterSidebarService } }` 由 `dsh-better-sidebar` 包导出;消费插件 `import type {} from 'dsh-better-sidebar'` 即触发类型合并
```bash
pnpm install # install deps (preinstall re-checks on build)
pnpm typecheck # tsc --noEmit (strict)
pnpm build # rm -rf lib && tsc (d.ts) && tsdown (bundles)
pnpm test # vitest run (unit only; no e2e in Phase 0)
pnpm build && pnpm test && pnpm typecheck # the full gate
> ⚠️ **host 半不发布此服务**:`ctx.betterSidebar` 只在 client 侧存在。如果你的插件 host 半需要读 better-sidebar 状态,走 better-sidebar 自己的 HTTP/WS 路由(`/sidebar/api/*`),不走服务。
---
## 2. 消费插件的最小骨架
### 2.1 `package.json`
```jsonc
{
"name": "my-plugin",
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"dsh-better-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
```
Run the **full gate** before pushing. All three must be green.
- `dsh-better-sidebar` 必须声明为 **peerDependency**(不是 dependency,避免重复实例化)
- 标记 `optional: true` 让你的插件在 better-sidebar 未安装时也能加载(注册代码会因为 `ctx.betterSidebar` 为 undefined 而跳过)
## Layout
### 2.2 client half 入口
```
src/
index.ts # HOST entry: export name/inject/apply (namespace plugin shape)
invariant.ts # invariant surface (assert, semver, peer guard) — dep-free
context-types.ts # service face contracts (ApesContext = CordisContext & {...})
client/
index.tsx # CLIENT entry: named `apply(ctx)` registering apes:* tabs
locales.ts # i18n (zh default, en fallback) + useApesLocale hook
tests/
smoke.spec.ts # host plugin shape + apply + invariant surface
locales.spec.ts # i18n contract against a mock ApesLocaleService
tsdown.config.ts # dual build config (host ESM + client CJS)
vitest.config.ts
cordis.patch.yml # plugin registration into the DSH profile
```ts
// my-plugin/src/client/index.ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar', 'slots'] // 声明服务依赖
export function apply(ctx: Context): void {
// 注册一个 sidebar tab
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
icon: <DbIcon />,
order: 50,
component: ({ ctx, scope, tab }) => <DbView sessionId={scope.sessionId} />,
})
)
// 注册一个文件预览器
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => parseCsv(await fetchCsvBytes(scope, path)),
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
})
)
}
```
## Hard rules
> ⚠️ **构建期纯度门**:client bundle 禁止 value-import 别的插件代码(`tsdown.config.ts` 的纯度门会挡)。`import type {}` 会被擦除,**不触发门禁**——所以类型可以自由共享,运行时符号不行。所有运行时交互必须走 `ctx.betterSidebar` 的方法调用。
1. **Namespace plugin export shape.** `src/index.ts` MUST export
`{ name, inject, apply }` as named exports — **never** a `default` export.
A stray `default` makes the cordis `Loader.unwrapExports` discard
`name`/`inject`/`apply` and the plugin silently fails to mount.
`tests/smoke.spec.ts` pins this.
### 2.3 类型导入
2. **Host / client split is load-bearing.**
- The **host** half (`lib/index.js`) runs in Node. It may use Node builtins.
- The **client** half (`lib/client.js`) runs in the DSH browser shell. Its
entry is a **named `export function apply(ctx: ApesClientContext)`** (not a
default export); tsdown bundles it to a self-contained module whose export
face is `{ apply, ... }`. The host wires it via `ctx.betterSidebar` /
`ctx.effect` (read from `ctx`, not a separately-injected `hostApi`).
React is external (host-provided). Never `import` a Node builtin into
`src/client/**`.
- `src/invariant.ts` is reachable from BOTH halves — it must stay
dependency-free (no Node builtins, no @deepseek-ai imports).
```ts
import type { TabDescriptor, FileViewerDescriptor, BetterSidebarService } from 'dsh-better-sidebar'
```
3. **`dsh-better-sidebar` is a peerDependency, not a bundled dep.** The tsdown
config lists it in `deps.neverBundle`. If a build inlines it, the client
bundle ships a second copy of the sidebar registry — tabs register into a
dead instance and never appear.
类型定义在 `lib/types/client/service.d.ts`,通过 `package.json` 的 `./client/service`(别名 `./client/api`)exports 子路径暴露。
4. **`@deepseek-ai/cordis` is devDependency (types only).** The host provides
the real cordis context at runtime. Do NOT add cordis to `dependencies` or
to `deps.neverBundle` — that would force the client to resolve it.
---
5. **Tab IDs are `apes:*`.** Never `wb:*` (legacy prefix) or any better-sidebar
internal id. The five Phase 1 tabs: `apes:tasks`, `apes:agents`,
`apes:apps`, `apes:messages`, `apes:profile`.
## 3. Tab 注册 API
6. **Scope lock.** This plugin is the **information layer only**:
tasks / agents / apps / messages / profile. Do NOT add file/terminal/browser
panels — those belong to `dsh-better-sidebar` itself. Do NOT add team/room
features — those belong to `dsh-background-agents`.
### 3.1 `TabDescriptor` 完整字段
7. **Bilingual i18n.** Every user-facing string goes through `t(key)` in
`src/client/locales.ts` with BOTH a `zh` and an `en` entry. No raw strings in
JSX.
```ts
interface TabDescriptor {
/** 唯一 id;也是 SidebarTab.type 的值。建议带包前缀:'my-plugin:db'。 */
id: string
/** 标题(i18n 友好:传字符串或返回字符串的函数) */
title: string | (() => string)
/** 图标:ReactNode 或 (size: number) => ReactNode */
icon?: ReactNode | ((size: number) => ReactNode)
/** + 菜单排序(升序);默认 100。内置:explorer=10, git=20, subagent=30, terminal=40 */
order?: number
/** 从 + 菜单隐藏(editor/diff 用:由其他流程触发打开,不在菜单里) */
hidden?: boolean
/** + 菜单禁用判定(如 terminal 配额满)。三参:ctx、会话 scope、当前状态 */
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
/**
* 单实例语法糖:`single: true` ≡ `dedupeKey: () => id`(打开时聚焦既有
* 同类型 tab 而非新开)。显式给出 dedupeKey 时优先于 single。
*/
single?: boolean
/**
* 去重键:openTab 时若已存在 dedupeKey 相同的 tab,则聚焦而非新开。
* 返回 undefined 表示不去重(每次都新开,但同 id 会被 id 安全网聚焦)。
* 内置策略:explorer/git/subagent 用 single: true;editor 用 tab => tab.path;diff 用 tab => tab.id。
*/
dedupeKey?: (tab: SidebarTab) => string | undefined
/**
* 自定义 tab 创建(minting SidebarTab + 状态 patch)。
* 返回 null 拒绝创建。terminal 用它生成 terminal:<n> id 并递增 nextTerminal。
* 省略时用默认 { id, type, title } + seed 里的 path/diff。
*/
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* 声明式设置(v0.4.1+):每个注册的 tab 都会在 Side card 设置页获得一行
* 开关(图标 + 标题 + 类型 id),`settings.toggles` 在其行下追加嵌套开关,
* 绑定 SidebarPrefs 字段。嵌套开关仅父级启用时显示。
*/
settings?: {
toggles?: readonly {
/** SidebarPrefs 字段名(内置键:'autoOpenSubagent' / 'agentTerminalTools') */
key: string
title: string | (() => string)
desc?: string | (() => string)
}[]
}
/** 渲染函数 */
component: (props: TabComponentProps) => ReactNode
}
```
8. **pnpm build-script approval.** `node-pty` (a transitive runtime dep of
better-sidebar's terminal panel) needs its native build approved once:
`pnpm approve-builds` (pick `node-pty`, `y`). Without this, `pnpm build`'s
preinstall dep-check hard-fails. It is approved in the committed
`pnpm-lock.yaml` state — don't reset it.
### 3.2 `TabComponentProps`
## Testing conventions
```ts
interface TabComponentProps {
ctx: Context // client cordis context
store: SidebarStore // better-sidebar 的状态 store(可调 reduce 等)
scope: SessionScope // { sessionId, cwd? }
tab: SidebarTab // 当前 tab 实例(含 id/type/title/path?/diff?)
visible: boolean // 是否是当前激活 tab 且面板打开(不可见时暂停轮询等)
// 以下由内置 tab 使用,外部 tab 可忽略:
expanded?: string[] // explorer 的展开目录集
onToggleDir?: (path: string) => void
onReferenceFile?: (path: string) => void
onOpenFile?: (path: string) => void
onOpenDiff?: (tab: SidebarTab) => void
onSubagentJump?: (childSessionId: string) => void
}
```
- Unit tests run in a **node** vitest environment. No DOM, no jsdom (the
client React components are not rendered in Phase 0 — a full cordis scratch
mount smoke lands in Phase 1).
- The host `apply()` is tested against a **minimal fake context** (see
`tests/smoke.spec.ts`) — never against a live DSH shell in CI.
- The locale contract is tested against a **mock `ApesLocaleService`** (see
`tests/locales.spec.ts`).
- A test that needs the real DSH host is a **Phase 1+** concern (cordis
scratch profile); do not add such tests to Phase 0.
### 3.3 注册示例
## Gotchas (learned, don't rediscover)
**最简 tab**(单实例、+ 菜单可见):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:notes',
title: 'Notes',
icon: <NoteIcon />,
order: 50,
single: true, // ≡ dedupeKey: () => 'my-plugin:notes'
component: ({ scope }) => <NotesView sessionId={scope.sessionId} />,
})
)
```
- `tsdown` 0.22 deprecated `external`/`noExternal` in favour of
`deps.neverBundle`/`deps.alwaysBundle`. **`external` and `deps.neverBundle`
cannot coexist** — using both throws at build. Use only the `deps` API.
- `allowImportingTsExtensions` (for `.ts`-extension imports) is set in
`tsconfig.json`. `tsconfig.build.json` inherits it but emits `d.ts`, which is
compatible (tsdown handles the strip). If `tsc` build complains, the
declaration emit needs `emitDeclarationOnly` + `noEmit: false` — that is the
current `tsconfig.build.json` setup.
- `node-pty` build approval is **per-machine state** in `pnpm-lock.yaml` + the
pnpm store. A fresh `pnpm install` on a clean checkout will re-prompt.
- The workspace path contains spaces and Chinese characters
(`文稿 - figmarLite的MacBook Pro/...`) — always quote it in shell commands.
- **`lib/client.js` is a DSH browser `__ModuleLoader__` bundle, NOT plain
CJS/ESM.** Line 1 is `window.__ModuleLoader__.load({...})` — it is consumed
by the DSH browser shell's module loader and **cannot be `require()`d in
Node** (top-level `window` reference throws `window is not defined`). This is
why the cordis scratch mount smoke cannot run in the unit gate: the client
artifact is browser-only by construction, not just because the `dsh` CLI is
absent. Unit specs test the **source** `src/client/index.tsx` (`apply`
contract), never the built bundle.
**多实例 tab**(每次新开、带自定义 id):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:doc',
title: 'Doc',
icon: <DocIcon />,
order: 60,
// 不设 dedupeKey:每次 openTab 都新开
component: ({ tab, scope }) => <DocView docId={tab.id} sessionId={scope.sessionId} />,
})
)
// 外部触发打开:
ctx.betterSidebar.openTab({ type: 'my-plugin:doc', title: 'Spec.md', id: 'doc:spec' })
```
## Phase status
**条件可见**(仅 git 仓库时显示):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:commits',
title: 'Commits',
icon: <CommitIcon />,
order: 70,
available: (state) => hasGitRepo(state), // 返回 false 时 + 菜单显示为 disabled
dedupeKey: () => 'my-plugin:commits',
component: ({ scope }) => <CommitsView sessionId={scope.sessionId} />,
})
)
```
- **Phase 0 (done)**: engineering skeleton, host/client split, i18n, invariant
surface, build gate green, unit smoke green.
- **Phase 1 (done)**: real tab registration for the five `apes:*` panels
against a mock host registry (type-only real `TabDescriptor` /
`TabComponentProps` / `Context` from `dsh-better-sidebar`), i18n for all
five views, `ctx.effect`-wrapped registration with HMR-safe disposer, and
graceful degrade when `betterSidebar` is absent. Unit smoke: `tests/tabs.spec.ts`
(6 tests). The cordis scratch mount smoke (real DSH host process) is deferred
to Phase 2 — it needs a live host and is outside the unit gate.
- **Phase 2 (partial)**: badge counts + auto-collapse (visible-gated
subscription pause) + per-tab state landed for the information panels.
`apes:tasks` / `apes:agents` show live count pills (running / subagent);
`apes:tasks` persists a `showInactive` preference through the settings
namespace (`apesSidebar.tasks`, `applies: 'live'`). The cordis scratch mount
smoke and wiring `apps` / `messages` / `profile` to real feeds are **deferred**
— better-sidebar 0.18.0 exposes no workspace-apps / message-stream /
model-provider host feeds for those three panels, and a live scratch mount
needs a `dsh` CLI (absent locally) plus a DOM harness the Phase 0 convention
deliberately avoids. See docs/ for the deferral rationale.
### 3.4 内置 tab(不可重复注册)
| id | order | single | hidden | 用途 |
|---|---|---|---|---|
| `editor` | -1 | 否(按 path 去重) | 是 | 文件编辑/预览(由 openSidebarFile 触发) |
| `explorer` | 10 | 是 | 否 | 文件资源管理器 |
| `git` | 20 | 是 | 否 | Git 面板 |
| `subagent` | 30 | 是 | 否 | 子代理拓扑 |
| `terminal` | 40 | 否 | 否 | 终端(nextTerminal 自增) |
| `diff` | -1 | 否(按 id 去重) | 是 | 差异查看(由 GitView 触发) |
你的 `id` 不可与上述重复,否则 `registerTab` 抛 `"tab type \"X\" already registered"`。
---
## 4. FileViewer 注册 API
### 4.1 `FileViewerDescriptor` 完整字段
```ts
interface FileViewerDescriptor {
/** 唯一 id:'image' / 'pdf' / 'my-plugin:csv' */
id: string
/** 设置清单展示名(v0.4.1+,i18n 友好);缺省回退到 id */
title?: string | (() => string)
/** 设置清单图标(v0.4.1+):ReactNode 或 (size: number) => ReactNode */
icon?: ReactNode | ((size: number) => ReactNode)
/** 小写无点的扩展名数组:['png','jpg']。[] = catch-all(仅最低优先级有效) */
exts: readonly string[]
/** 优先级(高优先);默认 0。内置默认 0;catch-all code 用 -100;binary-download 用 -50 */
priority?: number
/** 字节获取策略 */
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
/** 内容嗅探(覆盖 exts):head 字节可用时,第一个 detect 返回 true 的 viewer 命中 */
detect?: (path: string, head: Uint8Array) => boolean
/** fetchStrategy='custom' 时的加载函数 */
load?: (path: string, scope: SessionScope) => Promise<unknown>
/** 声明式设置(v0.4.1+):形状同 TabDescriptor.settings */
settings?: { toggles?: readonly { key: string; title: string | (() => string); desc?: string | (() => string) }[] }
/** 渲染函数 */
component: (props: FileViewerProps) => ReactNode
}
```
### 4.2 `FileViewerProps`
```ts
interface FileViewerProps {
ctx: Context
store: SidebarStore
scope: SessionScope
path: string
title: string
viewerId: string // 命中 viewer 的 id(如 'code' / 'my-plugin:csv')
content?: string // fetchStrategy='fsRead' 时
truncated?: boolean // fetchStrategy='fsRead' 时
mediaUrl?: string // fetchStrategy='mediaUrl' 时
customData?: unknown // fetchStrategy='custom' 时(load() 的返回值)
}
```
### 4.3 `fetchStrategy` 对照
| 策略 | 字节来源 | 传给 component 的字段 | 适用 |
|---|---|---|---|
| `none` | 不需要字节 | (无) | 自渲染(如纯 UI) |
| `fsRead` | `/sidebar/api` 的 `fs.read` | `content`, `truncated` | 文本类(CSV/JSON/XML) |
| `mediaUrl` | `/sidebar/file` 媒体路由 URL | `mediaUrl` | 图片/PDF/Office(viewer 自己 fetch 字节) |
| `custom` | viewer 的 `load()` 函数 | `customData` | 自定义协议(如远程拉取) |
| `binary-download` | 不预览,显示下载按钮 | (无) | 无客户端渲染器的二进制格式 |
### 4.4 匹配算法
`matchFileViewer(path, head?)` **单趟**按 priority 降序(稳定排序,相同 priority 按注册顺序)遍历每个 descriptor:
1. 若 `head` 字节可用且该 descriptor 有 `detect` → 调 `detect(path, head)`,true 则命中;**miss 且是 catch-all(`exts: []`)则本轮放弃**(纯嗅探型不得盲认领)
2. 否则匹配 `exts`(小写无点;`exts: []` 且无 `detect` 是盲 catch-all,直接命中)
即:**priority 高的 descriptor 先获得裁决权**(其 detect 或 exts 任一命中即赢),低 priority 的 detect 不会越过高 priority 的 exts 匹配。`exts: []` + `detect` 的组合是"纯嗅探":无 head 时不认领任何文件(不会吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。全部 miss 返回 `undefined`(编辑器显示下载按钮)。
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
> **内置 viewer**(不可重复注册,全部 8 个):image(0) / pdf(0) / docx(0) / xlsx(0) / pptx(0) / markdown(0, fsRead) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
### 4.5 注册示例
**CSV 预览器**(自定义加载 + 渲染):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => {
const text = await fetchText(scope, path)
return parseCsv(text)
},
component: ({ customData, path }) => <CsvGrid rows={customData as string[][]} path={path} />,
})
)
```
**覆盖内置 image viewer**(如想用自定义的 SVG 优化渲染):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:svg-pro',
exts: ['svg'],
priority: 10, // 高于内置 image 的 0
fetchStrategy: 'mediaUrl',
component: ({ mediaUrl }) => <OptimizedSvg src={mediaUrl} />,
})
)
```
**内容嗅探**(按 magic bytes 路由,忽略扩展名):
```ts
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:magic-parquet',
exts: [], // catch-all,但 priority 高 + detect 精确命中
priority: 100,
fetchStrategy: 'custom',
detect: (_path, head) => head.length >= 4
&& head[0] === 0x50 && head[1] === 0x41
&& head[2] === 0x52 && head[3] === 0x31, // 'PAR1'
load: async (path, scope) => parseParquet(await fetchBytes(scope, path)),
component: ({ customData }) => <ParquetTable data={customData} />,
})
)
```
---
## 5. 服务方法完整清单
```ts
interface BetterSidebarService {
/** 注册 tab 类型;返回 disposer */
registerTab(descriptor: TabDescriptor): () => void
/** 注册文件预览器;返回 disposer */
registerFileViewer(descriptor: FileViewerDescriptor): () => void
/** 当前已注册的 tab 描述符快照(同步,供 useSyncExternalStore 用;含被设置页禁用的类型) */
getTabs(): readonly TabDescriptor[]
/** 当前已注册的 file viewer 描述符快照(含被设置页禁用的 viewer) */
getFileViewers(): readonly FileViewerDescriptor[]
/** 按 id 查 tab 描述符 */
getTab(id: string): TabDescriptor | undefined
/** 某个 tab 类型是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
isTabEnabled(id: string): boolean
/** 某个 file viewer 是否在 Side card 设置中启用(v0.4.1+;缺省 = 启用) */
isViewerEnabled(id: string): boolean
/** 按 path 匹配 file viewer(priority 降序单趟:detect → exts;跳过硬禁用 viewer) */
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
/**
* 打开一个 tab(+ 菜单和外部触发都用它;走 descriptor.dedupeKey 去重)。
* title 可选:给出时优先于 descriptor.title(editor 显示文件名);
* 有 createTab 的 descriptor(terminal)会忽略 title/path/id。
* 被设置禁用的类型是 no-op(console.warn 提示)。
*/
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
/** 关闭一个 tab */
closeTab(tabId: string): void
/** 订阅注册表变化(register/dispose 时触发) */
subscribe(listener: () => void): () => void
}
```
> **声明式设置(v0.4.1+)**:每个注册的 tab/viewer 自动出现在 DSH 设置页「侧边卡片」分区的清单里——响应式网格中的**小卡片**(图标 + 标题 + 类型 id + **高亮 = 启用**,勾选徽标钉在卡片最右端,viewer 卡片还显示扩展名),开关持久化到 `SidebarPrefs.tabsEnabled / viewersEnabled`(开放 map,缺省 = 启用)。关闭语义:tab 从 `+` 菜单消失、`openTab` 拒绝新开、子代理自动展开 / agent 终端自动补 tab 等派生流程停止,**已打开的 tab 保留**;viewer 被 `matchFileViewer` 跳过,文件落到下一个匹配。`settings.toggles` 声明的相关设置(如子代理的 `autoOpenSubagent`)通过卡片右下角的齿轮按钮在**原生弹窗**中编辑(复选框行),父级卡片关闭时齿轮隐藏;**key 必须是宿主 PrefsSchema 的字段**(内置键:`autoOpenSubagent` / `agentTerminalTools`),外部插件的自定义键会被 settings seam 丢弃。
---
## 6. 生命周期与 HMR
- **disposer 必须返回**:`registerTab` / `registerFileViewer` 返回 `() => void`,Cordis fiber 卸载时自动调用。**务必**用 `ctx.effect(() => register(...))` 包裹,否则 fiber 卸载(HMR / 插件禁用)时不会撤销注册,导致下次激活时 `"already registered"` 错误。
- **注册时机**:better-sidebar 在 `apply()` 开头 `ctx.provide('betterSidebar', service)`,所以你的插件 `inject = ['betterSidebar']` 时,better-sidebar 已经就绪。
- **顺序无关**:Cordis 的 `inject` 保证服务就绪后才激活你的插件;你的插件可在 `apply` 内任意时刻注册。
- **持久化降级**:localStorage 里持久化的 tab 若其 type 未注册(你的插件未加载),渲染为 `<OrphanedTab/>` 占位卡(显示 "插件未加载" + 关闭按钮);你的插件加载后下次渲染自动恢复。
---
## 7. 平台约束与陷阱
| 陷阱 | 说明 |
|---|---|
| **构建纯度门** | client bundle 禁止 value-import `@dsh-external/*` 或非白名单的 `@deepseek-ai/*`;类型 `import type {}` 会被擦除,不触发门禁 |
| **双 cordis 实例** | 外部插件解析不到 DSH monorepo 的 cordis augmentation;better-sidebar 自己重述了 `interface Context { betterSidebar: ... }`,你 `import type {}` 即拿到类型 |
| **ModuleLoader 不跨插件** | 运行时 `require()` 虽支持跨 bundle,但被构建门挡;所有交互走 `ctx.betterSidebar` 方法调用 |
| **host 半无此服务** | `ctx.betterSidebar` 只在 client 侧存在;host 半需要 better-sidebar 数据走 `/sidebar/api/*` HTTP 路由 |
| **portal 限制** | 整面板 slot 由 ui-layout 独占,外部 tab 只能进入 better-sidebar 的 portal 内部,无法全屏替换 |
| **id 冲突** | `registerTab` / `registerFileViewer` 对重复 id 抛错;建议用包前缀(`my-plugin:xxx`) |
---
## 8. 完整最小示例
> 假设插件 `my-plugin` 要加一个"Database 浏览器" tab + `.csv` 文件预览器。
**`my-plugin/package.json`**:
```jsonc
{
"name": "my-plugin",
"version": "0.1.0",
"main": "lib/index.js",
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }
},
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"dsh-better-sidebar": "workspace:*",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"react": "^18.2.0"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
```
**`my-plugin/src/client/index.tsx`**:
```tsx
import { createElement } from 'react'
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
import type { Context } from 'cordis'
export const inject = ['betterSidebar']
export function apply(ctx: Context): void {
// Database tab
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
order: 50,
dedupeKey: () => 'my-plugin:db',
component: ({ scope }) => createElement(DbView, { sessionId: scope.sessionId }),
})
)
// CSV viewer
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => {
const res = await fetch('/sidebar/api/fs.read', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: scope.sessionId, path }),
})
const { value } = await res.json()
return parseCsv(value.content)
},
component: ({ customData, path }) =>
createElement(CsvGrid, { rows: customData as string[][], path }),
})
)
}
function DbView(props: { sessionId: string }): React.ReactNode { /* ... */ }
function CsvGrid(props: { rows: string[][]; path: string }): React.ReactNode { /* ... */ }
function parseCsv(text: string): string[][] { /* ... */ }
```
**注册到 profile**:在 `~/.dsh/profiles/web/package.json` 的 `dependencies` 加 `"my-plugin": "link:<你的插件路径>"`,在 `cordis.patch.yml` 加挂载行,`pnpm install`,重启 `dsh web` + 浏览器硬刷新。
---
## 9. 参考实现
better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"):
- **`src/client/builtins/`**:6 个内置 tab(explorer/git/subagent/terminal/editor/diff)+ 8 个内置 viewer(image/pdf/docx/xlsx/pptx/markdown/code/binary-download)的注册代码(tabs.tsx / viewers.tsx / index.ts)
- **`src/client/service.ts`**:`BetterSidebarService` 接口 + `createBetterSidebarService` 工厂实现
- **`src/client/SideCardSection.tsx`**:声明式设置页(注册表驱动清单 + `settings.toggles` 嵌套开关 + 开关持久化)
- **`tests/service.spec.ts`**:注册表生命周期 / 匹配算法 / dedupe / createTab / 启用态 gating 测试
- **`tests/builtins.spec.ts`**:内置注册清单断言(6 tab + 8 viewer + 声明式元数据)
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`**:设计文档(含实施偏差记录)
调试时直接读这些文件即可看到所有 API 的真实用法。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 dsh-external
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+130
View File
@@ -0,0 +1,130 @@
# dsh-better-sidebar
> 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" />
## ✨ 功能一览
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 按钮引用到输入框、右键复制相对/绝对路径
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限),Tab 保活重连并回放转录;可选为模型注入 8 个 `terminal_*` 工具(agent 终端自动同步为 tab)
- **🌿 Git 面板**:真 diff 显示、VSCode 式 diff tab(首次自动下分栏)、懒加载历史、右键暂存/放弃/提交/还原/捡取
- **🧩 Subagent 页面**:主会话完整 agent 拓扑(分层树)、点击直达执行记录、运行中节点实时工具调用轮询、检测到新子代理自动展开
- **🧩 分栏工作台**:拖 Tab 到边缘拆分/中间合并、分隔线调比例、宽度拖拽、折叠 + 全屏
- **🔁 会话隔离**:布局/分栏/Tab 按会话持久化(localStorage),陈旧状态自动净化;拦截对话"产出文件"行改为在侧边栏打开
- **🎨 原生观感**:完全复用 DSH 主题 token 与官方组件,明暗自适应
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表实时渲染功能清单——小卡片网格(高亮 = 启用,勾选徽标钉在最右端),每项可独立开/关(tab 从 `+` 菜单消失、viewer 预览落到下一个匹配);子代理/终端等二级设置经卡片齿轮按钮在原生弹窗中编辑;另有新会话默认打开、默认宽度占比
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(详见 [AGENTS.md](./AGENTS.md))
## ⌨️ 快捷键
| 操作 | 按键 |
|---|---|
| 保存编辑 | `Ctrl/Cmd + S` |
| Git 提交 | `Ctrl + Enter` |
| 关闭 Tab | 鼠标中键 |
| 拆分/合并分栏 | 拖 Tab 到分栏边缘 / 中间 |
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
## 🚀 安装(推荐:官方 profile)
前置:已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10。
把下面提示词**整段**发给 DSH 即可自动完成克隆、构建、注册与安装:
```text
请帮我把 dsh-better-sidebar 插件安装到我的 web profile(插件 = VSCode 风格右侧侧边栏,仓库 https://github.com/dsh-external/DSH-better-sidebar):
1. 克隆并构建:
git clone https://github.com/dsh-external/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
(@deepseek-ai/* 从 npm 解析(0.0.1-rc.? 预发布版);若 401/404,用安装者自己的只读令牌配置 ~/.npmrc,或把 devDependencies 改回指向自己 ~/.dsh/source/current 的 link:,不要继续)
2. 注册到 web profile:
a. ~/.dsh/profiles/web/package.json 的 dependencies 加 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
b. ~/.dsh/profiles/web/cordis.patch.yml 追加:
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
3. 在 ~/.dsh/profiles/web 执行 pnpm install
4. 重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
```
> 安装 = 依赖登记(等价 `dsh plugin --profile web add link:<路径>`)+ 一行挂载行,与面板的渲染方式无关。**DSH 以 npm 包启动(如 `npx -p @deepseek-ai/dsh@0.0.1-rc.1 dsh web`)同样可用**(v0.4.3 起实测验证)。
### 更新
```text
1. cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build
(401/404 处理同上)
2. 核对注册仍有效(缺失才补):profile package.json 的 link: 依赖 + cordis.patch.yml 挂载行
3. 仅 client(src/client/*)→ 硬刷新即可;含 host(src/index.ts、src/config.ts 等)→ 重启 DSH + 硬刷新
```
`link:` 引用下 `pnpm install` 无需重复执行(仓库已装即可)。
## 📦 通过 plugin-registry 安装(可选)
前置:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 可用)。**与官方 profile 安装二选一**——同时启用会双挂载(Node 半挂两次、页面两个侧边栏)。
```sh
git clone https://github.com/dsh-external/DSH-better-sidebar.git && cd DSH-better-sidebar
pnpm install && pnpm build
node scripts/package-registry.mjs # 组装 registry/ 暂存(只含清单 + 产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用)
dsh registry enable dsh-external/dsh-better-sidebar
```
更新:`git pull && pnpm install && pnpm build` → `node scripts/package-registry.mjs` → `dsh registry uninstall/install/enable`(同上三步)。切换通道前先移除另一通道的挂载(profile 的 link: 依赖与挂载行,或 `dsh registry uninstall`)。
## 🔌 服务化:注册 tab 与文件预览器
从 v0.4.0 起暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏页面与文件预览器(内置 6 tab + 8 viewer 也走同一服务,吃自己的狗粮):
```ts
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar']
export function apply(ctx: Context) {
ctx.effect(() => ctx.betterSidebar.registerTab({
id: 'my-plugin:db', title: 'Database', component: ({ scope }) => <DbView sessionId={scope.sessionId} />,
}))
}
```
完整接入文档(`TabDescriptor` / `FileViewerDescriptor` 全字段、匹配算法、HMR 陷阱、声明式设置):见 [`AGENTS.md`](./AGENTS.md)。
## 🛠️ 开发与构建
```sh
pnpm install # @deepseek-ai/* 从 npm 解析(^0.0.1-rc.1),需安装者自己的只读令牌:
# ~/.npmrc 配 //registry.npmjs.org/:_authToken=<自己的令牌>(勿写进仓库),
# 或改 devDependencies 为 link: 指向自己的 ~/.dsh/source/current
pnpm typecheck # tsc --noEmit
pnpm build # → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(含 manifest 一致性守卫,需先 build)
pnpm watch # tsdown --watch
```
运行期不依赖 npm / checkout:`@deepseek-ai/*` 声明在 `peerDependencies`,由 web profile 提供。
**规范符合性**:插件按 DSH 官方插件规范组织(参考 turtle-ui),无 default 导出(`name/inject/Config/apply`);双 client bundle(官方通道 id = 包名 / registry 通道 id = 清单 id,`tests/manifest-consistency.spec.ts` 守卫);已知偏差:侧边栏面板经 portal 挂载(`'root'` slot 由 ui-layout 独占,外部插件无整面板 slot)。
**架构**:单 npm 包、host/client 双半结构——host(`src/index.ts`):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket(fs / git / pty,全部会话级 + 信任围栏);client(`src/client/index.tsx`):portal 侧边栏 + 各视图 + turnTail 拦截;client 状态按会话持久化 localStorage;client bundle externals 仅平台模块表词条,xterm/CodeMirror 等内联。
## 🔐 安全
- 路由受 Host 头信任围栏保护(与 `/api` 一致)
- `fs.write` 原子写入;媒体路由仅限会话 cwd 内文件
- git 操作仅调用 git CLI,绝不设置 git 身份
## ⚠️ 已知限制
- Git 无 push/pull/fetch;无文件 watcher(手动刷新);工具行内文件打开按钮不可拦截
- 终端 Tab 拖到另一分栏会重挂载(shell 重开);切换 Tab / 会话 / 刷新不会
- `.xlsx` 预览不保留单元格样式(SheetJS 社区版限制)
- Office/PPTX 预览内联进 client bundle(约 23MB),首次加载较慢
## 🖥️ 平台支持
Windows / Linux / macOS 三平台适配(macOS 日常验证;其余经单元测试覆盖);`node-pty` 优先预编译二进制,失败需编译工具链(Windows VS Build Tools / Linux make+g+++python3 / macOS Xcode CLT)。
-13
View File
@@ -1,13 +0,0 @@
# apes-sidebar bundle patch: inserts the host plugin row into the profile
# roster. The client half (sidebar tabs: Tasks / Agents / Messages / Apps /
# Profile) loads through the dsh.client.inject declaration in package.json;
# this patch mounts the Node-side host entry only.
#
# Double-mount guard: if another *enabled* entry already mounts the same
# package (e.g. via an aggregate bundle), this row backs off.
- insert:
- id: apes-sidebar
name: apes-sidebar
disabled: !!js >
[...ctx.loader.entries()].some((e) =>
e.options.name === 'apes-sidebar' && e.options.id !== 'apes-sidebar' && !e.disabled)
-362
View File
@@ -1,362 +0,0 @@
# apes-sidebar 重写方案(v2)— 冲突检查通过,从零开始
> 目标:重写现有 `apes-sidebar`(v0.4.0,单文件手写 JS client bundle),
> **代码规范/工程方案学习 [DSH-better-sidebar v0.18.0](https://github.com/omdsh-dev/DSH-better-sidebar)**,
> **需求功能/交互能力学习 [tutti-os/tutti](https://github.com/tutti-os/tutti)**。
>
> 分析基线:DSH-better-sidebar `v0.18.0`(浅克隆 /tmp/DSH-better-sidebar)· Tutti(本地 /Users/figmarlite/Desktop/…/APES/tutti)
> 生成时间:2026-09-03 · 用途:重构开工前对齐,供评审与拆单。
## 0. 冲突检查结论(2026-09-03)
**目标自洽,不冲突,无需推翻重规划。** 两个参考源互补:
- better-sidebar v0.18.0 = 「怎么建」(工程规范/构建/测试/i18n/token/服务契约)
- tutti-os/tutti = 「做什么」(面板模型/角标/agent 可编程驱动)
三处主动调整(借"从零开始"授权做干净化):
1. **Tab id 换前缀**:旧 `wb:*` → 新 `apes:*`(apes:tasks / apes:agents / apes:apps / apes:messages / apes:profile),不留 `wb` 历史包袱。
2. **定位收敛**:只做 **tasks/agents/apps/messages/profile** 五面板信息层;files/terminal/browser/editor 由 better-sidebar 宿主提供,**不重复造轮子**(复用 `openTab/openFile` + 宿主内置 tab 即可)。
3. **放弃团队房间**:tutti 的 rooms/team 依赖 `dsh-background-agents` 0.7.x,与 DSH 基座 loader id 冲突会崩(历史教训),v2 不做。
唯一硬约束:peer 钉 `@deepseek-ai/cordis ^4.0.2` + `dsh-better-sidebar ^0.18.0`(better-sidebar 发 npm `next` dist-tag,随 DSH rc.x 漂移;升级前必须跑挂载冒烟)。
---
## 1. 现状诊断(旧版 v0.4.0 的痛点)
### 1.1 现状结构
```
figmar/apes-sidebar (v0.4.0)
├── package.json # peer: cordis ^4.0.1 / better-sidebar ^0.16.0 / react 18
├── cordis.patch.yml # insert apes-sidebar(双挂载守卫)
├── lib/index.js # host 最小入口(~1KB,只打日志)
└── lib/client.js # 45KB 单文件,window.__ModuleLoader__.load 手写 bundle
# 纯 JS + React.createElement,无 JSX/TS/构建/测试
```
注册 4 个 Tab(全部基于 better-sidebar `ctx.betterSidebar.registerTab`):
| Tab id | 作用 | 参照 |
|---|---|---|
| `wb:tasks` | 工作区+时间分组会话列表、搜索、运行中徽标、运行中任务区 | WorkBuddy |
| `wb:agents` | 子代理/后台 agent 列表、状态、一键跳转 | Tutti multi-agent |
| `wb:apps` | 应用中心(Design/PPT/Video 网格) | Tutti App Center |
| `wb:profile` | Provider/模型信息 + 设置/帮助入口 | WorkBuddy 底部 |
配置:better-sidebar 设置页 `pluginSettings['apes-sidebar']`(`enableTasks`/`enableAgents`/`enableApps`/`enableProfile`/`displayName`/`showTime`/`showUngrouped`/`appsList`)。
### 1.2 主要痛点
1. **单文件 45KB 手写 JS bundle**:无类型、无模块拆分、无构建产物管理;
CSS 以字符串内联注入(`APPS_CSS` 拼接),每次改样式要手工编辑字符串。
2. **零测试**:无 spec,无 CI 挂载冒烟——任何 API 漂移(如 DSH rc 升级)静默崩。
3. **i18n 手写**:本地 zh/en 两字典,不跟随宿主 `ctx.locale`,第三方语言直接缺。
4. **皮肤脆弱**:CSS 硬编码 `#fff` 渐变等(`card-icon` 用 `#3370ff→#7c3aed`),不消费
`--dsw-alias-*` token 链,深色/浅色/品牌换肤会破。
5. **状态模型简单**:轮询/快照读取 sessions/workspaces,无订阅模型、无 memo、无懒加载。
6. **无 host 能力**:只有日志入口。需要 host 路由(读会话日志、子代理拓扑、job 回放)
时只能绕 DSH 网关或放弃。
7. **版本基线落后**:peer 只到 better-sidebar ^0.16.0;而 better-sidebar v0.18.0
已毕业正式版、只支持 DSH 0.1.2-rc.1+(协议、RPC 方言、client store 均大变)。
---
## 2. 参考源 A:DSH-better-sidebar v0.18.0 工程规范(「怎么建」)
### 2.1 包结构(published npm package)
- `name: dsh-better-sidebar` / `version: 0.18.0` / `type: module` / `pnpm@11.8.0`
- `main: lib/index.js`;exports 子路径:`.` / `./invariant` / `./client` / `./client/service` / `./client/api` / `./src/*` / `./package.json`
- `dsh.bundle.patch: ./cordis.patch.yml`(bundle 激活的**硬性资格**)
- `dsh.client.inject`: `[locale, ui-slots, ui-conversation, modules]`,`platform: web`
- `dsh.plugin.json`:市场 manifest 形态(id/version/main/engines.dsh/contributes/client.main)
- `files`: lib + src + cordis.patch.yml + scripts + README
### 2.2 构建与质量
- `build`: `tsc -p tsconfig.build.json && tsdown`(类型声明 + JS 产物)
- `typecheck` / `test`(vitest) / `lint:ts`(oxlint) / `playwright`(e2e)
- **懒加载 chunk**:重依赖(xterm/CodeMirror/mermaid)独立 bundle `lib/client-<name>.js`,
经 `/sidebar/bundle` 按需下发、`globalThis.__dshChunks__` 物化(`src/client/chunk-loader.ts`);
核心 bundle 禁止静态 import chunks。
- **CI 挂载冒烟**(`plugin-mount` job):`pnpm pack` → scratch profile 真实安装 →
`dsh web`(keyless)→ Playwright 断言 `[data-dsh-better-sidebar]` 挂载、无 pageerror、
+ 菜单逐 tab 打开、editor chunk 强制加载。本地:`pnpm build && pnpm pack && pnpm test:mount`。
### 2.3 仓库硬约束(AGENTS.md)
- 禁止修改 DSH 源码(零写入 `~/.dsh/source/current`);改动必须 PR(`feat/*`/`fix/*`)。
- 挂载只走 `cordis.patch.yml` + profile;**不反向侵入 DSH**。
- 市场约束:`dependencies/peerDependencies/optionalDependencies` **一律不得出现 `cordis`**;
`scripts` 不得含 `preinstall/install/postinstall/prepare`(`tests/market-manifest.spec.ts` 守护)。
- 缺能力时用 DSH 现成只读/公开 API 或插件自有路由(如 jobs.output 事件回放),不改 DSH。
### 2.4 架构:host half + client half
**host half(Node 侧,`src/index.ts` ~1300 行)**
- 导出 `export const name = 'dsh-better-sidebar'`、`inject = ['webServer','sessions','webRuntime','tools']`、`export function apply(ctx)`。
- 插件自有路由全走同源 fence:`/sidebar/api/*`(JSON:fs.tree/search/read/write、git.*、session.cwd、
changes.ops…)、`/sidebar/file`(媒体)、`/sidebar/html`(预览)、`/sidebar/bundle`(懒加载)、
`/sidebar/ws/*`(终端 WebSocket upgrade)。
- **信任围栏** `trust-fence.ts`:与 /api 网关同源(Host-header loopback / webRuntime trustedHosts),
逐请求读取;**路径安全** `path-security.ts`:会话 cwd 内解析、workspaceFence 守卫、写路径原子写(tmp+rename)。
- 会话隔离:所有请求带 sessionId,权威 cwd 来自 session store(header → client cwd → persistence → process.cwd 四级回退)。
- PTY 管理:`pty-manager.ts` + `agent-pty.ts`(agent 终端注册表、resize gate、按会话配额)。
- 工具注册:`registerTools`(agent 可调用)、`agent-opens.ts`(agent 主动开 tab 的 registry + open tool)。
**client half(浏览器侧,`src/client/index.tsx` ~446 行)**
- `inject = ['slots','sessions','locale','modules','connection']`
- `attachLocale(ctx.locale)` + `ctx.effect` 注册词典(zh/en 为主,第三语言走 better-locale 可选覆盖)。
- `createSidebarStore()` 单例 per activation → `ctx.provide('betterSidebar', service)`。
- `ctx.effect(() => registerBuiltins(ctx, service))`:内置 7 tab + 6 viewer **自己吃自己的狗粮**。
- 挂载:`document.body` 追加 host div → `createRoot` 渲染 `RenderBoundary > Sidebar`;
bodyObserver 防宿主移除;几何自检防页面 transform 漂移(degraded viewport sync)。
- 拦截层:turn-tail 拦截、open-path 拦截(`remote.session.openWorkspacePath` 遮蔽)、外链拦截
(urlTarget 认领,默认 browser tab)、**IME 守卫**(composition 键防第三方 UI 抢键)。
- 设置面:`ctx.slots.inject('settings.section', …)` 声明「Side card」设置分区。
### 2.5 服务契约(消费插件 API,也是 apes-sidebar 重写后要面向的基座)
`ctx.betterSidebar`(client-only):
```ts
registerTab(descriptor: TabDescriptor): () => void // 返回 disposer,HMR-safe
registerFileViewer(descriptor: FileViewerDescriptor): () => void
getTabs() / getFileViewers() / getTab(id)
isTabEnabled(id) / isViewerEnabled(id)
matchFileViewer(path, head?)
openTab(seed: OpenTabSeed, scope?) / closeTab(tabId, scope?)
openFile(scope, path, title?)
updateTab / activateTab
subscribe(listener) / getSnapshot() / subscribeState(listener)
```
`TabDescriptor` 关键字段(v0.18.0 全量):
```ts
interface TabDescriptor {
id: string
title: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
order?: number // + 菜单排序,默认 100
hidden?: boolean
available?: (ctx, scope, state) => boolean // + 菜单禁用谓词
single?: boolean // 单实例(dedupeKey: () => id 语法糖)
dedupeKey?: (tab) => string | undefined
createTab?: (state) => { tab, patch? } | null // 自定义 mint tab id
urlTarget?: (url: URL) => boolean // 外链认领
settings?: SidebarSettingsDeclaration // toggles / pluginToggles / render
badge?: (ctx, scope, state) => string | number | null | undefined // Tab 角标
onOpen? / onActivate? / onClose? // 生命周期(仅 service 路径触发)
component: (props: TabComponentProps) => ReactNode
}
```
声明式设置:`'switch' | 'text' | 'number' | 'select'`;select 支持 `multi` 与图标选项卡。
`TabComponentProps = { ctx, store, scope, tab, visible, expanded?, revealed?, onToggleDir?/onOpenFile?/… }`。
**接入规范(外部插件视角)**:
- peerDependencies: `@deepseek-ai/cordis` + `dsh-better-sidebar`(optional);
type-only import 触发 `declare module '@deepseek-ai/cordis'` 类型合并;
- 纯浏览器插件走 `import type {} from 'dsh-better-sidebar/client/service'`(零 Node 依赖图);
- client half `inject: ['betterSidebar', …]`,`ctx.effect(() => ctx.betterSidebar.registerTab(...))`;
- 宿主 half 无此服务——需要读状态走 better-sidebar 自己的 HTTP/WS 路由。
### 2.6 i18n / 皮肤 / 无障碍规范
- i18n:词典注册到 DSH `ctx.locale`(LOCALE_NS);新增 zh key 必须同步 ja(否则 ja 回退 en);
渲染 MarkdownText 必须经 `markdownTextProps()`(labels 嵌套契约)。
- 皮肤:**视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌**,无硬编码颜色;
硬编码 hex 只能做 token 链末端 fallback(`color:var(--x,#fff)`),并有 `tests/theme.spec.ts` 守护。
- a11y:role=tablist/tab/separator、aria-selected/valuenow、focus-visible ring、IME guard。
---
## 3. 参考源 B:Tutti 侧边栏能力(「做什么」)
### 3.1 面板模型(packages/agent/gui/workbench/tool-sidebar/model.ts)
```ts
export const agentToolPanelIds = ['files','terminal','browser','tasks','apps','messages','side'] as const
```
- 每面板有 label、可开多实例(resourceId 区分,如 browser tab 带 agent session id)。
- 每面板默认/最小/最大宽度(files 最大不限、side 默认 440 最大 600、其余默认 720/最小 420)。
- `mainContentMinWidth = 280`:主内容区最小宽度保护。
### 3.2 行为层(AgentToolSidebar.tsx + useAgentToolSidebarController)
- **多标签**:TabBar(role=tablist),每个 tab = panel + resourceId;关闭/激活/拖拽。
- **可编程 handle**(AgentToolSidebarHandle,agent/宿主可驱动):
`addPanel / ensurePanel / openPanel / closeTab / close / collapseForContainerConstraint`
- **容器约束自动折叠**:`shouldAutoCollapseAgentToolSidebar({containerWidth, mainContentMinWidth, sidebarWidth})`
→ 窗口变窄时 `collapseForContainerConstraint()`(不关 tab,只收起到 Dock)。
- **宽度记忆 + 动画**:`onLayoutWidthChange`、resize handle(键盘可操作 role=separator)、
width transition 260ms。
- **懒挂载渲染**:`contentReadyTabIds` + `renderLoading`(未就绪 tab 显示 loading,就绪才挂)。
- **空态 Picker**:`isEmptySidebar` → AgentToolSidebarPicker(从 + 菜单选面板)。
- **展开态**:expand 按钮让活动面板撑满(expanded panel 概念)。
### 3.3 信息架构与角标(StandaloneAgentToolSidebar.tsx)
- `panels` 条件组装:`side` 面板仅在有 side conversation identity 时出现(`canAdd: false`)。
- `quickActionPanels`: 有自动化 browser 时把 browser 提到 quick action;常驻 tasks/apps/messages。
- `reminders`(角标计数):
- `browser: automationBrowserCount`(运行中 automation browser tab 数)
- `messages: messageCenterWorkingCount`(工作会话中 working agent 消费者计数,订阅 sessionEngine)
- 对应旧版 apes-sidebar 的 badge 概念:tasks 徽标 = 运行中会话数;agents 徽标 = 运行子代理数。
### 3.4 各面板承载(渲染层职责划分)
| 面板 | 承载 | apes-sidebar 对应/借鉴 |
|---|---|---|
| files | 文件树/编辑器 | (better-sidebar 已有 editor)不重复 |
| terminal | 终端 | (better-sidebar 已有)不重复 |
| browser | 内嵌浏览器 | (better-sidebar 已有)不重复 |
| tasks | Issue Manager/任务编排 | **apes:wb:tasks 升级对象**:会话/任务拓扑 |
| apps | App Center | **apes:wb:apps 对齐对象**:应用网格+调起 |
| messages | 消息中心(审批/通知/后台工作汇总) | **apes 可新增 wb:messages** |
| side | Side Conversation(子会话并排) | 子代理会话展示可借鉴 |
Tutti 其余值得迁移的交互:控制中心(一个视图看所有 agent 对话+待审批+运行任务)、
goal→tasks 自动拆分、@ 深度引用、task 分配给人/agent。
### 3.5 进程/事件模型
- 会话与活动数据经 `sessionEngine`/activity 服务订阅(`subscribe + getSnapshot` 模式,
即 React 19 `useSyncExternalStore` 形状)——ape-sidebar 应把「轮询」升级为订阅 + memo。
- 子代理/后台工作 → `subagent-live-route` / jobs 回放(better-sidebar 已实现:
`jobs-routes.ts` 从会话事件日志回放 job 输出、`subagent-live-route.ts` 批量子代理状态)。
---
## 4. apes-sidebar v2 目标架构
### 4.1 定位
> **apes-sidebar v2 = 构建在 better-sidebar v0.18.0 之上的「工作空间指挥层」**,
> 不重复 files/terminal/browser/editor(better-sidebar 已有),聚焦 Tutti 风格
> **任务 / Agent / 消息 / 应用**四类信息面板 + host 侧路由与 agent 可编程驱动。
仍是一个 **client-half 注册型插件**(注册到 `ctx.betterSidebar`),但工程规范全面对齐 better-sidebar。
### 4.2 目标仓库布局
```
apes-sidebar/
├── package.json # peer: @deepseek-ai/cordis ^4.0.2 / dsh-better-sidebar ^0.18.0 / react ^18
├── cordis.patch.yml # insert apes-sidebar(双挂载守卫,抄 v0.18.0 写法)
├── tsconfig.json / tsconfig.build.json
├── tsdown.config.ts # 多产物:lib/index.js + lib/client.js + lib/client-<chunk>.js
├── vitest.config.ts / playwright.config.ts
├── scripts/e2e-mount.sh # scratch profile 真实挂载冒烟
├── AGENTS.md # 仓库硬约束(参照 better-sidebar)
├── src/
│ ├── index.ts # host half:路由 + 工具(若需要)
│ ├── context-types.ts # declare module '@deepseek-ai/cordis' 类型合并
│ ├── client/
│ │ ├── index.tsx # apply():attachLocale → 注册词典 → registerTabs
│ │ ├── locales.ts # zh/en(跟随 ctx.locale)
│ │ ├── api.ts # 插件自有路由 client
│ │ ├── tabs/
│ │ │ ├── TasksTab.tsx # 会话/任务导航(保留 wb:tasks id 兼容持久化)
│ │ │ ├── AgentsTab.tsx # Agent/子代理拓扑(wb:agents)
│ │ │ ├── AppsTab.tsx # 应用中心(wb:apps)
│ │ │ ├── MessagesTab.tsx # (新)消息中心/审批汇总
│ │ │ └── ProfileTab.tsx # 底部用户区(wb:profile)
│ │ └── builtins.ts # 统一注册(吃自己的狗粮)
│ └── (host routes 需要时) sidechat-routes.ts / subagent-routes.ts
└── tests/
├── tabs.spec.tsx / theme.spec.ts / market-manifest.spec.ts
└── e2e/mount.e2e.ts
```
### 4.3 关键工程决策
1. **TS + tsdown 双产物**:lib/index.js(host)、lib/client.js(client 入口)、
chunks 按需(若引入 mermaid/图表等重依赖);`files` 白名单发布。
2. **client inject 精简**:只声明实际 require 的宿主包
(`@deepseek-ai/dsh-client-ui-primitives` + 服务注入 betterSidebar/sessions/workspaces/locale 经 cordis inject,不塞 dsh.client.inject)。
3. **host half 保持最小**:纯展示可无 host;需要会话日志回放/子代理状态时新增
better-sidebar 风格的自有路由(同源 fence + 会话隔离),不碰 DSH 源码。
4. **Tab id 向后兼容**:`wb:tasks` / `wb:agents` / `wb:apps` / `wb:profile` 保持不变
(避免破坏已持久化 pluginToggles / 用户布局);样式 data 属性带 `apes-sidebar-` 前缀。
5. **配置迁移到声明式 settings**:沿用 `pluginToggles`(switch/select)机制,
在 better-sidebar 设置页渲染;新增 `displayName` select(apes-sidebar/feyOS/自定义)。
6. **i18n 对齐**:词典注册 `ctx.locale`(zh/en);新增 key 同步第三方语言或做 en 回退。
7. **皮肤对齐**:全部视觉消费 `--dsw-alias-*`/`--ds-*` token,硬编码 hex 仅作链尾 fallback。
8. **badge/reminders 对齐 Tutti**:TasksTab 徽标 = 运行中会话/任务数;AgentsTab 徽标 =
运行子代理数(订阅 sessionEngine 而非轮询)。若可经 host 路由读 agent consumer counts,
则消息中心徽标 = working count(对应 Tutti messageCenterWorkingCount)。
9. **懒挂载**:未激活 tab 不渲染内容;首次打开再挂(contentReady + loading),
与 better-sidebar TabContent 行为一致。
10. **测试门禁**:至少 tabs 注册/开关/渲染 smoke + 挂载 e2e + 皮肤 token 守护 +
market manifest 守护(cordis 不得出现在 dependencies)。
---
## 5. 功能清单(Tutti → apes-sidebar 映射)
| # | 能力 | Tutti 参照 | apes-sidebar v2 落地 | 优先级 |
|---|---|---|---|---|
| F1 | 会话/任务导航(工作区+时间分组、搜索、运行徽标) | Tasks | `wb:tasks` 升级:订阅式 + 运行中任务区 + 拓扑 | P0 |
| F2 | Agent 总览(子代理/后台 agent、状态、跳转) | multi-agent | `wb:agents` 升级:订阅式、subagent lineage | P0 |
| F3 | 应用中心(已装应用网格 + 调起) | App Center | `wb:apps`:appsList 可配置、viewId 调起多级降级 | P0 |
| F4 | 消息中心/审批/工作汇总(角标) | messages + messageCenterWorkingCount | `wb:messages`(新) | P1 |
| F5 | 任务自动拆分(goal→tasks) | Tutti goal 编排 | 规划:输入目标→子任务草稿→一键建会话(DSH 能力内简化版) | P2 |
| F6 | 显示名/开关/子设置 | — | 声明式 settings(pluginToggles) | P0 |
| F7 | agent 可编程开 tab | AgentToolSidebarHandle | host route + better-sidebar openTab/openFile 组合 | P1 |
| F8 | 自动折叠/宽度记忆 | shouldAutoCollapseAgentToolSidebar | 交 better-sidebar 容器(若支持)或本地监听 | P2 |
| F9 | Profile 用户区 | — | `wb:profile`:模型/provider/连接状态 | P0 |
| F10 | 深色/浅色/换肤 | skin tokens | 全 token 化 | P0 |
---
## 6. 分阶段实施
### Phase 0:工程骨架(0.5d)
- 建仓、tsconfig/tsdown/vitest/playwright、package.json(peer 钉 cordis ^4.0.2 + better-sidebar ^0.18.0)
- cordis.patch.yml(v0.18.0 守卫写法)、AGENTS.md、最小 host+client 空注册冒烟
- 通过 `pnpm test:mount` 在 scratch profile 验证挂载
### Phase 1:4 Tab 平移 + 质量化(1-1.5d)
- 把旧 client.js 的 4 视图用 TSX 重写,行为不变(保留 wb:* id)
- i18n → ctx.locale、皮肤 → token、样式 → CSS modules
- 配置 → pluginToggles 声明式
- 单测:注册数/开关/渲染/badge
### Phase 2:Tutti 能力增强(2-3d)
- `wb:tasks`:订阅式 sessionEngine 快照 + 运行中任务区(子代理拓扑优先排序)
- `wb:agents`:subagent lineage + 状态订阅
- 新增 `wb:messages`(若 host 有 working count 数据源)
- agent 可编程开 tab(F7)最小实现
### Phase 3:打磨与发布(1d)
- 皮肤 token 全检、a11y、IME guard、错误边界
- e2e 挂载冒烟入 CI、Gitea 发布 + npm 发版脚本
---
## 7. 验证清单(每个 Phase 完成时)
```bash
pnpm typecheck
pnpm test # vitest 单测
pnpm lint:ts
pnpm build && pnpm pack
pnpm test:mount # scratch profile 真实挂载(需 dsh + playwright chromium)
# 真机:dsh plugin --profile desktop add file:<tarball> → 硬刷新 → + 菜单见新 tab
```
---
## 8. 风险与注意
- **DSH rc 漂移**:better-sidebar 是 0.1.2-rc.1 线,DSH 官方 rc.x 发 next dist-tag;
重写期间锁定 devDeps peer,升级前跑 `pnpm peers check`。
- **不要装 dsh-background-agents 0.7.x**:与 DSH 基座 loader 条目 id 冲突 → 启动崩溃
(历史教训),团队房间类能力暂缓。
- **apps 调起无公开一键 API**:维持多级防御(uiConversation.activateView → slots.set → 提示)。
- **client 纯模块表**:运行时只能 require react/react-dom/primitives 等宿主提供模块;
其余依赖必须内联或走懒加载 chunk,否则构建纯度门会拦。
@@ -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,137 @@
# 侧边栏设置声明式化 + 内置支持"插头化"设计
**日期**:2026-08-11
**状态**:已实施(本文档含实施偏差记录)
**目标版本**:v0.4.1
## 1. 目标
1. 把 DSH 设置页的「侧边卡片」分区从硬编码 4 行改为**注册表驱动的声明式清单**:
- 展示当前注册的所有侧边栏内容(tab 类型)与文件查看器(viewer),每项可**直接打开/关闭**;
- 每项声明式元数据:**类型(id)、图标(SVG)、相关设置**(如子代理的"检测到子代理就自动打开")。
2. 内置支持(资源管理 / Git / 终端 / 子代理 / 编辑器 / 差异)与 8 个文件预览器继续以**插头(descriptor)形式**注册,并在描述符上携带上述声明。
## 2. 非目标
- 不改宿主路由 / pty / tools 的 gating 语义:`agentTerminalTools` 仍是独立布尔偏好(宿主 `syncToolsGate` 零改动),只是它的**设置行渲染位置**改由终端 tab 描述符声明。
- 不做"已打开 tab 随禁用即时关闭"(保留式语义:禁用只影响新开,已打开 tab 继续渲染)。
- 不实现外部插件自定义 settings 键的动态 schema(见 §6 限制)。
- 不迁移 bundle 形态、不加依赖、不动 peerDependencies。
## 3. 现状回顾
- `SideCardSection.tsx` 硬编码 4 行:`openByDefault` / `defaultWidthPercent` / `autoOpenSubagent` / `agentTerminalTools`。
- `PrefsSchema`(`config.ts`)为扁平对象,`parsePrefs`(`client/prefs.ts`)逐字段校验。
- 内置 tab/viewer 已通过 `registerBuiltins` 走同一注册表(v0.4.0),但描述符缺少设置面元数据:viewer 无 `title`/`icon`,tab 无相关设置声明。
- 服务无启用态概念:`getTabs`/`matchFileViewer`/`openTab` 不过滤。
## 4. 设计
### 4.1 偏好扩展(`prefs-shared.ts` / `config.ts` / `client/prefs.ts`)
```ts
interface SidebarPrefs {
openByDefault: boolean
defaultWidthPercent: number
autoOpenSubagent: boolean
agentTerminalTools: boolean
tabsEnabled: Record<string, boolean> // 按 tab descriptor id;缺省 = 启用
viewersEnabled: Record<string, boolean> // 按 viewer descriptor id;缺省 = 启用
}
```
- schema:`z.dict(z.boolean()).default({})`(schemastery `dict`);旧文档自动解析为 `{}`(全开),零迁移。
- `parsePrefs`:record-of-boolean 校验(非对象/非布尔值回退 `{}` / 丢弃该键)。
- 设置页开关写入 `tabsEnabled: { ...prefs.tabsEnabled, [id]: next }`(viewers 同理)。
### 4.2 声明式描述符(`client/service.ts`)
```ts
interface SidebarSettingToggle {
key: string // SidebarPrefs 字段名('autoOpenSubagent')
title: string | (() => string)
desc?: string | (() => string)
}
interface SidebarSettingsDeclaration {
toggles?: readonly SidebarSettingToggle[]
}
// TabDescriptor 增加:
settings?: SidebarSettingsDeclaration
// FileViewerDescriptor 增加(设置清单展示用):
title?: string | (() => string) // 缺省回退 id
icon?: ReactNode | ((size) => ReactNode)
settings?: SidebarSettingsDeclaration
```
服务新增:
```ts
isTabEnabled(id: string): boolean // store.getPrefs().tabsEnabled[id] !== false
isViewerEnabled(id: string): boolean
```
行为变化:
- `openTab`:类型被禁用 → no-op(`console.warn` 提示);`matchFileViewer`:跳过禁用 viewer(文件落到下一个匹配或下载按钮)。
- `getTabs()/getFileViewers()` 仍返回**全部**注册项(设置页清单必须看到已禁用的项)。
### 4.3 内置声明(`builtins/tabs.tsx` / `builtins/viewers.tsx`)
| tab | 相关设置声明 |
|---|---|
| subagent | `toggles: [{ key: 'autoOpenSubagent', ... }]` |
| terminal | `toggles: [{ key: 'agentTerminalTools', ... }]` |
| explorer/git/editor/diff | 无(仅开关) |
8 个 viewer 全部补 `title`(locales 新增 `viewerImage`…`viewerBinary`)+ `icon`(`icons.tsx` 新增 6 个手绘 outline SVG:image/pdf/docx/xlsx/pptx/markdown;code/binary-download 复用 `IconCodeOutline16` / `IconDownloadOutline16`)。
### 4.4 Store 快照携带 prefs(`client/state.ts`)
`SidebarSnapshot` 增加 `prefs` 字段;`setPrefs` 更新快照并 `notify()`(`setSession`/`update`/`reduce` 构造快照时带上当前 prefs)。效果:+ 菜单等 `useSyncExternalStore` 消费者在设置变更后**立即**重渲染(禁用 git → + 菜单马上消失)。
`makeDefaultState(width, panelOpen, seedExplorer = true)` 增加第三参;`loadState` 在 `tabsEnabled['explorer'] === false` 时传 `false`——新会话不再预置 explorer tab(与"已存在会话保持布局"语义并列:禁用只影响新布局)。
### 4.5 消费点 gating
| 消费点 | 行为 |
|---|---|
| + 菜单(`buildNewTabOptions`) | `filter(!d.hidden && service.isTabEnabled(d.id))` |
| 子代理自动展开 effect | 追加 `isTabEnabled('subagent') === false` 早退(禁用时不展开面板) |
| agent 终端 reconcile(WS 推送) | `isTabEnabled('terminal') === false` 时忽略推送(不自动补 tab;重新启用后下一次推送自愈) |
| 产出文件拦截(`intercept.tsx` select) | `tabsEnabled['editor'] === false` 时 select 返回 null(回落默认交付行) |
| `openTab` / `matchFileViewer` | 见 §4.2 |
| 新会话默认布局 | explorer 禁用 → 空 pane |
### 4.6 设置页声明式渲染(`SideCardSection.tsx`)
- `inject: () => ({ store, service })`(`index.tsx` 闭包直接捕获 service)。
- 注册表清单:本地 state + `service.subscribe` 同步(注册变化罕见,无需 external-store)。
- 行结构:
- **常规**:`openByDefault`、宽度(既有逻辑)。
- **侧边栏内容**:按 `order` 升序、`hidden` 置后遍历 tabs——图标(16) + 标题 + desc=`tab.id`(类型)+ 开关;`settings.toggles` 渲染为缩进子行(仅父级启用时显示,绑定 `prefs[toggle.key]`)。
- **文件预览**:按 priority 降序遍历 viewers——图标 + `title ?? id` + desc=`exts.join(' · ') || t('settingsViewerCatchAll')` + 开关。
- 提交机制不变:串行化 + revision 守卫 + 失败回滚 + `store.setPrefs` 同步。
## 5. 测试
- `service.spec.ts`:启用态缺省 true;`openTab` 拒绝禁用类型;`matchFileViewer` 跳过禁用 viewer(含兜底 code 被关 → undefined)。
- `builtins.spec.ts`:subagent/terminal 的 toggles 声明;全部 tab 有 icon;全部 viewer 有 title+icon。
- `unit.spec.ts`:`parsePrefs` 的 map 校验(非对象/非布尔回退)+ 新字段默认值;`loadState` explorer 禁用 → 空 pane。
- `plugin-shape.spec.ts` / `smoke.spec.ts`:schema 解析与 settings 路由断言补新字段。
- `side-card-section.spec.tsx`(新增):`renderToString` 断言声明式行(图标/标题/id/exts/开关/嵌套开关隐藏规则)。
## 6. 限制与失败模式
- **外部插件的自定义 settings 键**:`toggles[].key` 必须是宿主 `PrefsSchema` 的字段(内置:`autoOpenSubagent` / `agentTerminalTools`);未知键会被 settings seam 丢弃(schemastery object 非严格模式放行未知键但 schema 不含其校验)。文档注明。
- 设置路由失败 / 文档缺失:沿用既有降级(schema 默认 + 本地 state,开关默认全开)。
- 已打开 tab 的类型被禁用:tab 保留可继续使用;关闭后无法重开。
- 父级(子代理/终端)关闭时嵌套开关隐藏,其值保留在 prefs 不销毁。
## 7. 实施偏差记录
1. **嵌套开关的"父级关闭即隐藏"**:设计初稿曾考虑始终显示嵌套开关;实施决定仅在父级启用时渲染(避免"autoOpenSubagent 已开但子代理页面被关"的无效组合),值保留。
2. **宿主 `syncToolsGate` 不改**:`agentTerminalTools` 仅随自身开关走,不与 `tabsEnabled['terminal']` 联动(模型终端工具是模型面向能力,与侧边栏 UI 开关解耦)。
3. **`renderRow` 统一行渲染**:tab/viewer/嵌套开关共用一行渲染器(`sub` 参数区分缩进),CSS 新增 `.sectionHeading` / `.rowIcon` / `.titleLine` / `.subRow`。
4. **清单排序**:tabs 按 `hidden` 置后 + `order` 升序(editor/diff 殿后);viewers 按 priority 降序(code 兜底最后)。
5. **开关改为卡片点击(v0.4.1 追加)**:清单项与嵌套相关设置、`openByDefault` 全部从"复选框行"改为**可点击卡片**——`<button aria-pressed>`,整卡即开关,视觉状态即状态(启用 = 品牌描边 + 交互选中填充 + `IconCheckOutline16` 勾选徽标;禁用 = 中性 hairline + 文字降级)。嵌套相关设置渲染为更小的缩进卡片;宽度输入保持原生设置行(它是数值不是开关)。设置导航齿轮图标为宿主 shell 硬编码(`navIcon` 按 section id 映射,无插件注入点),用户选择不改宿主,保持现状。
6. **小卡片响应式网格 + 二级设置弹窗(v0.4.1 再追加)**:清单项改为**小卡片**,置于 `repeat(auto-fill, minmax(148px, 1fr))` 响应式网格(一行自适应多个,随宽度换行);卡片结构 = 顶行(图标 + 标题省略 + **勾选徽标钉在最右端**)+ 类型/扩展名描述行(省略号截断,`title` 属性给出完整值)。声明了 `settings.toggles` 的卡片右下角渲染**齿轮角标按钮**(`IconSettingsOutline16`,仅父级启用时显示),点击打开**原生 `Modal` 弹窗**,内含 DSH 原生设置行(标题/描述 + 品牌强调原生复选框 + hairline 分隔,末行去分隔线)——二级设置不再内嵌于网格。实现细节:Modal 采用条件挂载(`settingsFor !== null && <Modal open …>`),因为 Modal 原语无条件调用 hooks,常闭挂载在测试的 dual-react(react 18.2 server / 18.3 bundled)分裂环境下会崩;弹窗行体抽成 `FeatureSettingsRows` 供 renderToString 直测。
@@ -0,0 +1,416 @@
# DSH-better-sidebar 服务化改造设计
**日期**:2026-08-11
**状态**:已批准,待实施
**作者**:opencode + 用户
**当前版本**:v0.3.0(main `069c754`)
**目标版本**:v0.4.0
## 1. 目标
将 `dsh-better-sidebar` 从一个"纯消费方"插件改造为"服务提供者":
1. 允许其他 DSH 插件向 better-sidebar **注册新的侧边栏页面(tab 类型)**
2. 允许其他 DSH 插件向 better-sidebar **注册文件类型预览器**
3. 把现有 5 个内置 tab 和 6 种内置文件预览器**全部迁移到新注册表**(吃自己的狗粮)
## 2. 非目标(Out of Scope)
- **不动 host 半**(`src/index.ts` 的 HTTP 路由 / pty / tools / settings)
- **不迁移到标准 bundle 形态**(不加 `cordis.patch.yml`、不弃用 `dsh.plugin.json`、不放弃 `client-registry.js`)—— 作为独立工作
- 不动 server 端 `ctx.provide`:本次纯 client 端服务
## 3. 现状回顾
### 3.1 Tab 系统硬编码点(7 处)
| 文件:行 | 用途 |
|---|---|
| `src/client/state.ts:14` | `TabType` 联合类型(5 字面量) |
| `src/client/state.ts:295-297` | `isSingle(type)` 单实例判定 |
| `src/client/state.ts:555-561` | `sanitizeNode` 白名单校验 |
| `src/client/Sidebar.tsx:56-83` | `TabContent` switch 渲染 |
| `src/client/Sidebar.tsx:87-102` | `buildNewTabOptions` + 菜单项 |
| `src/client/Sidebar.tsx:341-364` | `onNewTab` 创建分支 |
| `src/client/icons.tsx:26-33` | `tabTypeIcon` 图标 switch |
### 3.2 文件预览硬编码点
- `src/client/EditorView.tsx:58-106`:顺序 if/else 选 `load.kind`
- `src/client/EditorView.tsx:251-272`:if 链按 `load.kind` 渲染组件
- 4 个预览组件(DocxView / XlsxView / PptxView / PdfView)静态 import 在 `EditorView.tsx:22-26`
- 3 个纯函数模块(`office-types.ts` / `image-types.ts` / `pdf-types.ts`)+ `lang.ts` 已半抽象,可作为内置注册器的实现细节复用
### 3.3 服务暴露现状
- `src/index.ts` / `src/client/index.tsx` 全文搜 `ctx.provide` / `Context.service` / `extends Service` —— **零命中**
- `package.json` 的 `exports` 没有暴露任何供外部 import 的注册 API
- README:262 已承认 portal 挂载的限制(整面板 slot 不可用),但未提服务化方案
### 3.4 平台约束
- **构建期纯度门**(`tsdown.config.ts:177-180`):client bundle 禁止 value-import 别的插件代码;**type-only import 会被擦除,不触发门禁**
- **双 cordis 实例**:外部插件解析不到 DSH monorepo 的 cordis augmentation,必须由 better-sidebar 自己导出 `declare module 'cordis'` augmentation
- **ModuleLoader 运行时支持跨 bundle require,但被构建门挡** → 协作只能走 cordis 服务或 slot
## 4. 方案选型
| 方案 | 机制 | 评估 |
|---|---|---|
| **A. `ctx.provide` + 服务对象(采纳)** | client `apply()` 内 `ctx.provide('betterSidebar', service)`,service 暴露 `registerTab/registerFileViewer` 返回 disposer | registry 语义自然;disposer 生命周期贴合 cordis fiber;precedent:`dsh/packages/client/locale/src/client/index.ts:362` |
| B. `ctx.slots.inject` 声明式 slot | 声明 `betterSidebar.tabType` 等 slot | 偏 UI 链替换,对带元数据 + disposer 的 registry 别扭 |
| C. `extends Service` 类形式 | 完整 Service 类 + Config schema | client 半不支持 `extends Service`;本次纯 client,没必要 |
**采纳方案 A**。
## 5. 服务接口设计
### 5.1 主服务接口
```ts
// 新文件 src/client/api.ts
export interface BetterSidebarService {
/** 注册一种 tab 类型;返回 disposer */
registerTab(descriptor: TabDescriptor): () => void
/** 注册一种文件预览器;返回 disposer */
registerFileViewer(descriptor: FileViewerDescriptor): () => void
/** 运行时快照(同步,供 React useSyncExternalStore 用) */
getTabs(): readonly TabDescriptor[]
getFileViewers(): readonly FileViewerDescriptor[]
/** 匹配文件的预览器(按 priority 降序、exts/includes、detect() 三段匹配) */
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
/** 工具方法:让外部 tab 也能开/关 tab */
openTab(tab: { type: string; title: string; path?: string }): void
closeTab(tabId: string): void
/** 订阅 registry 变化(注册/卸载时触发) */
subscribe(listener: () => void): () => void
}
```
### 5.2 TabDescriptor
```ts
export interface TabDescriptor {
id: string // 唯一,如 'explorer' / 'my-plugin:db'
title: string | (() => string) // i18n 友好
icon?: ReactNode | ((size: number) => ReactNode)
order?: number // + 菜单排序,默认 100
single?: boolean // 单实例(explorer/git/subagent)
hidden?: boolean // 不出现在 + 菜单(editor 用)
available?: (ctx: Context, scope: SessionScope) => boolean
component: (props: TabComponentProps) => ReactNode
}
export interface TabComponentProps {
ctx: Context
store: SidebarStore
scope: SessionScope
tab: SidebarTab
}
```
### 5.3 FileViewerDescriptor
```ts
export interface FileViewerDescriptor {
id: string // 'image' / 'pdf' / 'my-plugin:csv'
exts: readonly string[] // 小写无点,如 ['png','jpg']
priority?: number // 高优先;默认 0;内置默认 0;兜底 code 用 -100
fetchStrategy: 'none' | 'fsRead' | 'mediaUrl' | 'custom' | 'binary-download'
detect?: (path: string, head: Uint8Array) => boolean // 内容嗅探(覆盖 exts)
load?: (path: string, scope: SessionScope) => Promise<unknown> // fetchStrategy='custom' 时
component: (props: FileViewerProps) => ReactNode
}
export interface FileViewerProps {
ctx: Context
store: SidebarStore
scope: SessionScope
path: string
title: string
content?: string // fetchStrategy='fsRead' 时
truncated?: boolean
mediaUrl?: string // fetchStrategy='mediaUrl' 时
customData?: unknown // fetchStrategy='custom' 时 load() 的返回值
}
```
## 6. Cordis 类型合并 outlet
```ts
// 新文件 src/types.ts (纯类型)
import type { BetterSidebarService } from './client/api'
declare module 'cordis' {
interface Context {
betterSidebar: BetterSidebarService
}
}
```
通过 `src/index.ts` 的 `export type * from './types.ts'` re-export 到主入口。Consumer 插件 `import type {} from 'dsh-better-sidebar'` 触发合并。
## 7. package.json exports 新增
```jsonc
"exports": {
".": { ... },
"./invariant": { ... },
"./client": { ... },
"./client/api": { // 新增,纯类型导出
"types": "./lib/types/client/api.d.ts",
"default": "./lib/client.js" // 运行时落点回到 client bundle(不会被 value import)
},
"./src/*": "./src/*",
"./package.json": "./package.json"
}
```
`files` 字段相应加 `lib/types/client/api.d.ts`。
## 8. 内置 tab 自我注册(吃狗粮)
`src/client/index.tsx` 的 `apply()` 内:
```ts
const service = createBetterSidebarService(store)
ctx.provide('betterSidebar', service)
registerBuiltins(service) // 5 个内置 tab + 内置 viewer 全部用 service.registerTab/registerFileViewer
```
### 8.1 5 个内置 tab
| id | order | single | hidden | available | 组件 |
|---|---|---|---|---|---|
| `editor` | -1 | false | true | always | `EditorView`(由 open-file 流程触发,不在 + 菜单) |
| `explorer` | 10 | true | false | always | `ExplorerView` |
| `git` | 20 | true | false | 检查 `.git` 存在 | `GitView` |
| `subagent` | 30 | true | false | always | `SubagentView` |
| `terminal` | 40 | false | false | 检查 terminal 配额 | `TerminalView` |
### 8.2 内置 file viewer
| id | exts | priority | fetchStrategy | 组件 |
|---|---|---|---|---|
| `image` | png/jpg/jpeg/gif/webp/svg/bmp/ico/avif | 0 | mediaUrl | `<img>` |
| `pdf` | pdf | 0 | mediaUrl | `PdfView` |
| `docx` | docx | 0 | mediaUrl | `DocxView` |
| `xlsx` | xlsx | 0 | mediaUrl | `XlsxView` |
| `pptx` | pptx | 0 | mediaUrl | `PptxView` |
| `markdown` | md/markdown | 0 | fsRead | `<MarkdownText>` |
| `code` | [](兜底) | -100 | fsRead | CodeMirror |
| `binary-download` | doc/xls/ppt + NUL 探测为 binary | -50 | binary-download | 下载按钮 |
`matchFileViewer(path, head?)` 算法:
1. 按 `priority` 降序遍历
2. 若 `descriptor.detect` 提供且 `head` 可用 → 调 `detect(path, head)`,true 则命中
3. 否则匹配 `exts`(小写无点)
4. 全部 miss → 返回 `undefined`(caller 走兜底 `binary-download`)
## 9. 7 处硬编码 switch 改造点
| 文件:行 | 原状 | 改造后 |
|---|---|---|
| `src/client/state.ts:14` | `TabType` 联合 5 字面量 | `type TabType = string`;保留 `SIDEBAR_BUILTIN_TAB_TYPES` 常量数组供向后兼容 |
| `src/client/state.ts:295-297` `isSingle` | switch 5 字面量 | `(type) => service.getTabs().find(t => t.id === type)?.single ?? false`;store 持有 service 引用 |
| `src/client/state.ts:555-561` `sanitizeNode` | 白名单 5 type | 未注册 type 标记 `orphaned: true` 不丢弃,渲染占位卡 |
| `src/client/Sidebar.tsx:56-83` `TabContent` | switch 5 分支 | 查 `service.getTabs().find(t => t.id === tab.type)`,调 `descriptor.component(props)`;未找到渲染 `<OrphanedTab/>` |
| `src/client/Sidebar.tsx:87-102` `buildNewTabOptions` | 硬编码 4 项 | `service.getTabs().filter(t => !t.hidden && (t.available?.(ctx, scope) ?? true)).sort(byOrder).map(...)` |
| `src/client/Sidebar.tsx:341-364` `onNewTab` | if optionId === 'explorer' 等 | `service.openTab({ type: optionId, title: descriptor.title() })` |
| `src/client/icons.tsx:26-33` `tabTypeIcon` | switch 5 分支 | 废弃;调用方直接读 `descriptor.icon` |
## 10. 文件预览路由改造
### 10.1 `src/client/EditorView.tsx:58-106`(选预览器)
```ts
const viewer = service.matchFileViewer(path, head?)
if (!viewer || viewer.fetchStrategy === 'binary-download') {
setLoad({ status: 'binary' })
return
}
switch (viewer.fetchStrategy) {
case 'mediaUrl': setLoad({ status: 'ready', kind: 'mediaUrl' }); break
case 'fsRead':
api.fsRead(scope, path).then(({ content, truncated }) =>
setLoad({ status: 'ready', kind: 'fsRead', content, truncated })
)
break
case 'custom':
viewer.load(path, scope).then(data =>
setLoad({ status: 'ready', kind: 'custom', customData: data })
)
break
case 'none': setLoad({ status: 'ready', kind: 'none' }); break
}
```
### 10.2 `src/client/EditorView.tsx:251-272`(渲染)
```ts
const viewer = service.matchFileViewer(path)
if (!viewer) return <BinaryDownload ... />
return <viewer.component {...props} />
```
4 个内置预览组件(DocxView/XlsxView/PptxView/PdfView)从 `EditorView.tsx` 静态 import 移到 `registerBuiltins` 内部 import + 注册。
## 11. 持久化与降级
- localStorage 里 `orphaned` tab:渲染 `<OrphanedTab title="..." hint="Plugin not loaded: ${tab.type}"/>` + 关闭按钮
- `sanitizeState` / `sanitizeNode` 不再丢弃未识别 type,改为标记 `orphaned`,保留 `title/path` 以备插件恢复时复用
## 12. Consumer 插件使用范例
### 12.1 Consumer `package.json`
```jsonc
{
"peerDependencies": {
"cordis": "^4.0.0-rc.7",
"dsh-better-sidebar": "workspace:*"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": { "optional": true }
}
}
```
### 12.2 Consumer client half
```ts
// my-plugin/src/client/index.tsx
import type {} from 'dsh-better-sidebar' // 触发 ctx.betterSidebar 类型合并
export const inject = ['betterSidebar', 'slots']
export function apply(ctx: Context): void {
ctx.effect(() =>
ctx.betterSidebar.registerTab({
id: 'my-plugin:db',
title: () => 'Database',
icon: <DbIcon />,
order: 50,
component: ({ scope, store, tab }) => <DbView scope={scope} />,
})
)
ctx.effect(() =>
ctx.betterSidebar.registerFileViewer({
id: 'my-plugin:csv',
exts: ['csv'],
fetchStrategy: 'custom',
load: async (path, scope) => parseCsv(await fetchCsv(scope, path)),
component: ({ customData, path }) => <CsvGrid data={customData} path={path} />,
})
)
}
```
## 13. 测试策略
### 13.1 单元测试
- `tests/service.spec.ts`:`createBetterSidebarService` 的 register/dispose/subscribe 生命周期
- `tests/match-file-viewer.spec.ts`:`matchFileViewer` 的 priority + exts + detect 三段匹配算法
- `tests/builtins.spec.ts`:5 个内置 tab + 8 个内置 viewer 都正确注册
- 现有 `tests/state.spec.ts` / `tests/office-types.spec.ts` / `tests/image-types.spec.ts` 保持通过
### 13.2 集成测试
- `tests/orphaned-tab.spec.ts`:localStorage 里有未注册 type 的 tab → 渲染 `<OrphanedTab/>`,注册后恢复
### 13.3 现有 manifest 守卫
- `tests/manifest-consistency.spec.ts` 的 `contributes.tools/skills` 空数组守卫保持通过
- `CLIENT_EXTERNALS` 列表守卫保持通过(不新增外部 value import)
## 14. 风险与缓解
| 风险 | 缓解 |
|---|---|
| 双 cordis 实例导致 consumer 拿不到 `ctx.betterSidebar` 类型 | 由 dsh-better-sidebar 自己 `declare module 'cordis'`,consumer `import type {}` 触发合并 |
| 注册顺序:consumer 在 better-sidebar 之前激活导致 inject 等待 | cordis `inject` 自带就绪等待,consumer 不会在服务就绪前激活 |
| HMR 时 disposer 漏调导致注册项残留 | `ctx.effect(() => disposer)` 挂到 fiber,卸载时自动调 |
| localStorage 里旧版本的 `TabType` 字面量 tab | `sanitizeNode` 改为标记 `orphaned` 不丢弃,向后兼容 |
| `EditorView` 的 `kind` 字段类型从联合字面量变成 string | 内部 `load` 状态类型加 `kind: string` + `customData?: unknown`,TypeScript 收窄在渲染层做 |
| 兜底 `code` viewer priority -100 与外部 `binary-download` -50 冲突 | `matchFileViewer` 先尝试 `detect`(NUL 探测为 binary),再走 exts;外部 `binary-download` 的 `exts: ['doc','xls','ppt']` 优先于兜底 `code` 的 `exts: []` |
## 15. 版本与发布
- 版本号:`v0.4.0`(破坏性:`TabType` 从联合字面量改 string)
- peerDependencies 不变(不加新依赖)
- `dsh.plugin.json` 的 `version` 同步更新到 `0.4.0`
- 重建 `lib/` + 推送 main + profile 更新
## 16. 实施顺序(高层)
1. 拉取 main v0.3.0 到工作分支
2. 新建 `src/client/api.ts`(类型 + `createBetterSidebarService` 工厂)
3. 新建 `src/types.ts`(`declare module 'cordis'` augmentation)+ `src/index.ts` re-export
4. `src/client/index.tsx` 改造:`ctx.provide('betterSidebar', service)` + `registerBuiltins(service)`
5. 抽 `registerBuiltins.ts`:5 个内置 tab + 8 个内置 viewer 注册
6. 改造 7 处硬编码 switch(state.ts / Sidebar.tsx / icons.tsx)
7. 改造 `EditorView.tsx` 的选预览器 + 渲染两段
8. `package.json` 加 `./client/api` exports + `files` 字段
9. 加 `<OrphanedTab/>` 组件 + sanitize 降级
10. 写测试(service / matchFileViewer / builtins / orphaned)
11. typecheck + test + build 三件套
12. 重建 lib/,提交,推送,更新 profile web
详细实施计划由 writing-plans skill 生成。
---
## 17. 实施偏差记录(2026-08-11,feat/modular-registry)
实现(PR #3 + 本分支)与上文设计有意或被迫不同的点,逐条记录。文档以本节为准。
### 17.1 去重机制:`dedupeKey` 为唯一机制,`single` 是其语法糖
§5.2 的 `single?: boolean` 与实现的 `dedupeKey` 合并为单一机制:
- `dedupeKey?: (tab) => string | undefined` 是唯一去重规则(单实例 `() => id`、按 path、按 id 三种旧策略统一表达);
- `single: true` ≡ `dedupeKey: () => id`(语法糖,显式 dedupeKey 优先)。
### 17.2 `available` 三参签名
§5.2 的 `available?: (ctx, scope) => boolean` 实现为 `(ctx, scope, state) => boolean`(state 用于 terminal 配额等 UI 状态判定,是设计签名的超集)。
### 17.3 匹配算法:单趟 per-descriptor,非三趟全局
§8.2 的算法(priority 降序逐 descriptor,detect 或 exts 二选一)为准;实现确认单趟,并补充一条规则:**`exts: []` + `detect` 是纯嗅探模式**——无 head 时不认领任何文件(避免 magic-sniffer 吞掉图片/PDF 等真实 viewer 的文件),有 head 时只认领 detect 命中的。盲 catch-all(无 detect)行为不变。
### 17.4 编辑器拆分:EditorHost + TextEditor(code/markdown 成为注册 viewer)
§10 的 EditorView 职责一分为二:
- `EditorHost`('editor' tab 组件):标题栏 + `matchFileViewer` → 按 fetchStrategy 分发(纯逻辑在 `editor-load.ts` 的 `planFirstMatch`/`planFsReadOutcome`)→ 渲染 viewer.component;binary 无匹配渲染共享 `BinaryDownload`;
- `TextEditor`(viewer 组件):code/markdown 的 CodeMirror 编辑 + md 预览/编辑切换 + 保存/脏点,内容由 host 经 fsRead 传入,按 `viewerId === 'markdown'` 分支。
- 代码/保存按钮从标题行移到内容区第二行(VSCode 式工具栏,有意 UX 变更)。
### 17.5 `fs.read` 增加 `head` 字段(唯一 host 触碰)
§8.2/§14 的 detect 机制需要 head 字节:host `fs.read` 对二进制响应附加 `head`(base64,前 4KB,常量 `READ_HEAD_LIMIT`)。客户端在 fsRead 结果为二进制时用 head 重匹配一次(无额外 IO),NUL 探测因此成立。向后兼容(新字段)。纯文本文件不触发重匹配(文本嗅探请用 exts 或 custom 策略)。
### 17.6 导出路径:`./client/service` 为主,`./client/api` 为别名
§7 要求 `./client/api`,但 `api.ts` 已被既有的 fetch 助手占用;package.json exports 增加 `"./client/api"` 别名指向 `lib/types/client/service.d.ts`,公共导入路径满足设计,内部文件名不变。
### 17.7 + 菜单:disabled 行而非 filter
§9 的 `filter(!hidden && available)` 实现为"available 返回 false 显示 disabled 行"(与 v0.3.0 旧 UX 一致;终端配额满时用户能看到选项为何不可用)。行为有意保留,文档更新为现状。
### 17.8 保留的 store 级操作(非注册表职责)
- `openDiffTab`(state reducer):diff 的 split 树放置手术(粘性 diff pane、首次拆分下方),保留;其按 id 去重与 diff descriptor 的 `dedupeKey: (tab) => tab.id` 同规则(测试断言两者一致)。
- `reconcileAgentTerminals`:agent 终端列表同步(host 推送驱动),直接落 state(`openTabInActivePane`),不经过 service(terminal 的 createTab 语义不适用)。
- 命名:state.ts 的 `openTab` reducer 改名 `openTabInActivePane`(与 `service.openTab` 区分)。
### 17.9 内置清单
- tab 6 个不变(editor/explorer/git/subagent/terminal/diff),explorer/git/subagent 改用 `single: true`。
- viewer 8 个:image/pdf/docx/xlsx/pptx(mediaUrl)+ markdown(fsRead)+ code(fsRead,catch-all -100)+ binary-download(-50,exts doc/xls/ppt + NUL detect)。
- `SIDEBAR_BUILTIN_TAB_TYPES` 常量删除(零引用);`tabTypeIcon` 删除(图标归 descriptor.icon)。
### 17.10 测试
新增 `tests/builtins.spec.ts`(内置注册清单)、`tests/orphaned-tab.spec.tsx`(sanitize 保留未注册类型 + 占位渲染)、`tests/editor-load.spec.ts`(策略分发纯函数);`tests/browser-globals.ts` 为拉入 xterm/CodeMirror 的 spec 提供模块求值期浏览器全局 mock。`matchFileViewer` 相关断言按 17.3 语义重写。
+16
View File
@@ -0,0 +1,16 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.4.3",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离。暴露服务供其他插件注册侧边栏页面和文件预览器",
"engines": {
"dsh": ">=0.0.1"
},
"contributes": {
"tools": [],
"skills": []
},
"client": {
"main": "./lib/client-registry.js"
}
}
+126 -51
View File
@@ -1,69 +1,144 @@
{
"name": "apes-sidebar",
"version": "2.0.0",
"description": "WorkBuddy/Tutti 风格右侧边栏增强(构建在 dsh-better-sidebar 之上:任务 / Agent / 消息 / 应用 / Profile 五面板信息层)",
"name": "dsh-better-sidebar",
"version": "0.4.3",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session. Exposes a service for other plugins to register sidebar tabs and file viewers.",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": "./lib/index.js",
"./invariant": "./lib/invariant.js",
"./client": "./lib/client.js",
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./client/service": {
"types": "./lib/types/client/service.d.ts",
"default": "./lib/client.js"
},
"./client/api": {
"types": "./lib/types/client/service.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src",
"cordis.patch.yml",
"scripts",
"AGENTS.md",
"README.md"
],
"scripts": {
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"verify:mount": "node scripts/verify-mount.cjs",
"lint:ts": "oxlint src tests tsdown.config.ts vitest.config.ts",
"dev": "tsdown --watch",
"pack:plugin": "pnpm build && pnpm pack --pack-destination dist"
},
"peerDependencies": {
"dsh-better-sidebar": "^0.18.0"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": {
"optional": true
}
},
"devDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"dsh-better-sidebar": "^0.18.0",
"lightningcss": "^1.32.0",
"oxlint": "^0.15.0",
"react": "~18.3.1",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@11.8.0",
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"betterSidebar",
"locale",
"sessions"
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"license": "MIT"
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"src"
],
"scripts": {
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
"typecheck": "tsc --noEmit",
"bundle": "tsdown",
"watch": "tsdown --watch",
"prepare": "tsdown",
"test": "vitest run"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
"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",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/search": "^6.7.1",
"@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": {
"@deepseek-ai/dsh-agent": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1-rc.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1-rc.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1-rc.1",
"@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
"@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
"@deepseek-ai/dsh-session": "^0.0.1-rc.1",
"@deepseek-ai/dsh-settings": "^0.0.1-rc.1",
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"cordis": "^4.0.0-rc.7",
"lightningcss": "^1.32.0",
"react": "^18.2.0",
"react-dom": "18.2.0",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"vitest": "^4.1.8"
}
}
+4861 -1841
View File
File diff suppressed because it is too large Load Diff
+64 -1
View File
@@ -1,4 +1,67 @@
packages:
- .
# node-pty's install script copies its shipped prebuilt binaries into place
# (no source compilation on supported platforms); pnpm 10+ blocks dependency
# build scripts unless approved.
allowBuilds:
node-pty: true
protobufjs: true
minimumReleaseAgeExclude:
- dsh-better-sidebar@0.18.0
- '@deepseek-ai/cordis-plugin-include@1.0.5-rc.1'
- '@deepseek-ai/cordis-plugin-loader@1.0.1-rc.1'
- '@deepseek-ai/cordis@4.0.1-rc.1'
- '@deepseek-ai/cosmokit@1.8.2-rc.1'
- '@deepseek-ai/dsh-agent-default-model@0.0.1-rc.1'
- '@deepseek-ai/dsh-agent-presets@0.0.1-rc.1'
- '@deepseek-ai/dsh-agent@0.0.1-rc.1'
- '@deepseek-ai/dsh-api-remotes@0.0.1-rc.1'
- '@deepseek-ai/dsh-atomic-write@0.0.1-rc.1'
- '@deepseek-ai/dsh-attachment@0.0.1-rc.1'
- '@deepseek-ai/dsh-brand@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-connection@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-locale@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-runtime@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-schema-form@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-conversation@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-primitives@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-settings@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-slash@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-ui-slots@0.0.1-rc.1'
- '@deepseek-ai/dsh-client-web-react@0.0.1-rc.1'
- '@deepseek-ai/dsh-code-runtime@0.0.1-rc.1'
- '@deepseek-ai/dsh-commands@0.0.1-rc.1'
- '@deepseek-ai/dsh-compact@0.0.1-rc.1'
- '@deepseek-ai/dsh-credentials@0.0.1-rc.1'
- '@deepseek-ai/dsh-goal@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-apiproxy@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-directory-picker@0.0.1-rc.1'
- '@deepseek-ai/dsh-host-webserver@0.0.1-rc.1'
- '@deepseek-ai/dsh-invariants@0.0.1-rc.1'
- '@deepseek-ai/dsh-llm-retry@0.0.1-rc.1'
- '@deepseek-ai/dsh-llm@0.0.1-rc.1'
- '@deepseek-ai/dsh-native-command@0.0.1-rc.1'
- '@deepseek-ai/dsh-paths@0.0.1-rc.1'
- '@deepseek-ai/dsh-scope@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-persistence@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-projection-cache@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-projection@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-query@0.0.1-rc.1'
- '@deepseek-ai/dsh-session-title@0.0.1-rc.1'
- '@deepseek-ai/dsh-session@0.0.1-rc.1'
- '@deepseek-ai/dsh-settings@0.0.1-rc.1'
- '@deepseek-ai/dsh-skill@0.0.1-rc.1'
- '@deepseek-ai/dsh-storage-domain@0.0.1-rc.1'
- '@deepseek-ai/dsh-storage@0.0.1-rc.1'
- '@deepseek-ai/dsh-subagent@0.0.1-rc.1'
- '@deepseek-ai/dsh-system-prompt@0.0.1-rc.1'
- '@deepseek-ai/dsh-timeout@0.0.1-rc.1'
- '@deepseek-ai/dsh-token-meter@0.0.1-rc.1'
- '@deepseek-ai/dsh-tools@0.0.1-rc.1'
- '@deepseek-ai/dsh-type-meta@0.0.1-rc.1'
- '@deepseek-ai/dsh-typert-registry@0.0.1-rc.1'
- '@deepseek-ai/dsh-user-approval@0.0.1-rc.1'
- '@deepseek-ai/dsh-user-interaction@0.0.1-rc.1'
- '@deepseek-ai/dsh-workspace@0.0.1-rc.1'
- '@deepseek-ai/schemastery@3.18.1-rc.1'
+49
View File
@@ -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')
-80
View File
@@ -1,80 +0,0 @@
/**
* 验收验证:模拟 DSH host 的 resolveOverlayPackage + bundle 加载
* - 确认 apes-sidebar 在 profile node_modules 可解析
* - 确认 host entry (lib/index.js) 的命名导出 shape { name, inject, apply }
* - 确认 client entry (lib/client.js) 存在且是 __ModuleLoader__ bundle(browser artifact)
* - 确认 invariant surface 可用
*/
const path = require('path')
const fs = require('fs')
const { createRequire } = require('module')
const PROFILE = process.env.HOME + '/.dsh/profiles/desktop'
const req = createRequire(path.join(PROFILE, 'noop.js'))
let pass = 0, fail = 0
const check = (label, cond, detail = '') => {
if (cond) { pass++; console.log(' ✓', label) }
else { fail++; console.log(' ✗', label, detail) }
}
console.log('### apes-sidebar 挂载验收')
// 1. 包可解析
try {
const pkgPath = req.resolve('apes-sidebar/package.json')
const pkg = req('apes-sidebar/package.json')
check('package.json 可解析', true, pkgPath)
check('name=apes-sidebar', pkg.name === 'apes-sidebar')
check('version=2.0.0', pkg.version === '2.0.0')
} catch (e) {
check('package.json 可解析', false, e.message)
}
// 2. host entry 加载 + shape
try {
const host = req('apes-sidebar')
check('host entry 加载成功', true)
check('导出 name (string)', typeof host.name === 'string', 'got ' + typeof host.name)
check('导出 inject (string[])', Array.isArray(host.inject), 'got ' + typeof host.inject)
check('inject === [] (无硬依赖 service)', JSON.stringify(host.inject) === '[]', JSON.stringify(host.inject))
check('导出 apply (function)', typeof host.apply === 'function')
check('无 default export(namespace 规则)', !('default' in host))
console.log(' name =', JSON.stringify(host.name))
} catch (e) {
check('host entry 加载', false, e.message)
}
// 3. invariant surface
try {
const inv = req('apes-sidebar/invariant')
check('invariant 可加载', true)
check('invariant.assert 是函数', typeof inv.assert === 'function')
} catch (e) {
check('invariant 可加载', false, e.message)
}
// 4. client entry 存在 + 是 browser artifact
try {
const clientPath = req.resolve('apes-sidebar/client')
const clientSrc = fs.readFileSync(clientPath, 'utf8')
check('client entry 文件存在', true, clientPath)
check('client 是 __ModuleLoader__ bundle(line 1 window)',
clientSrc.split('\n')[0].includes('window.__ModuleLoader__.load'),
'line1=' + clientSrc.split('\n')[0].slice(0, 50))
} catch (e) {
check('client entry', false, e.message)
}
// 5. peer dep dsh-better-sidebar 版本满足 ^0.18.0
try {
const bs = req('dsh-better-sidebar/package.json')
const v = bs.version
check('dsh-better-sidebar 版本满足 ^0.18.0',
v === '0.18.0', 'got ' + v)
} catch (e) {
check('dsh-better-sidebar 版本', false, e.message)
}
console.log('\n=== 验收结果: ' + pass + ' passed, ' + fail + ' failed ===')
process.exit(fail > 0 ? 1 : 0)
+519
View File
@@ -0,0 +1,519 @@
/**
* Agent-owned terminal registry: a uuid-keyed table of long-lived PTY
* sessions created by the model through the `terminal_create` tool. Each
* handle survives across tool calls (and across WebSocket disconnects from
* the sidebar view) until the model calls `terminal_close` or the user
* closes the corresponding sidebar tab — tmux semantics, scoped per agent
* session.
*
* This is a parallel registry to {@link PtyManager}: UI tabs are keyed by
* `${sessionId}:${tabId}` and capped per session, while agent terminals are
* keyed by uuid and uncapped (the model is trusted to close unused ones).
* Both registries share the same shell resolver and spawn-helper fix.
*/
import { randomUUID } from 'node:crypto'
import * as nodePty from 'node-pty'
import { ensureSpawnHelper } from './pty-manager.ts'
import { SidebarError } from './wire.ts'
/** Per-agent-terminal transcript bound (bytes kept for replay and reads). */
const TRANSCRIPT_LIMIT = 1 << 20
/** POSIX signals the registry forwards to a live pty. */
export const ALLOWED_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGKILL', 'SIGHUP', 'SIGTSTP'] as const
/** Signal name accepted by `signal()`. */
export type AgentTerminalSignal = (typeof ALLOWED_SIGNALS)[number]
/** Default read page size (lines) when the caller omits `count`. */
export const DEFAULT_READ_COUNT = 500
/** Smallest pty dimension the registry accepts (mirrors the tool contract). */
export const TERMINAL_DIM_MIN = 2
/** Largest pty dimension the registry accepts (mirrors the tool contract). */
export const TERMINAL_DIM_MAX = 1024
/** Clamp one cols×rows pair into the supported pty range (flooring decimals). */
export function clampDims(cols: number, rows: number): { cols: number; rows: number } {
const clamp = (value: number): number =>
Math.min(TERMINAL_DIM_MAX, Math.max(TERMINAL_DIM_MIN, Math.floor(value)))
return { cols: clamp(cols), rows: clamp(rows) }
}
/**
* Serializable snapshot of one agent terminal — the shape the model sees
* through `terminal_list` and the sidebar sees through the push endpoint.
* Carries no pty reference and no transcript (those are reached through
* dedicated read/attach paths), and no sessionId: ownership is registry
* internals, scoped by the caller (list filters by session, the push
* endpoint scopes by its query param), never part of the serialized view.
*/
export interface AgentTerminalSnapshot {
/** Stable opaque handle the model passes back to other terminal_* tools. */
uuid: string
/** Display title the model chose at create time. */
title: string
/** The command the model asked to run at create time (verbatim). */
command: string
/** Whether the top-level process has exited. */
exited: boolean
/** Exit code if exited normally; absent until the process exits. */
exitCode?: number | null
/** Exit signal name if the process was killed by a signal; null otherwise. */
exitSignal?: string | null
}
/** Map a POSIX signal number to its conventional name (best-effort). */
const SIGNAL_NAMES: Record<number, string> = {
1: 'SIGHUP', 2: 'SIGINT', 3: 'SIGQUIT', 4: 'SIGILL', 6: 'SIGABRT',
9: 'SIGKILL', 11: 'SIGSEGV', 13: 'SIGPIPE', 14: 'SIGALRM', 15: 'SIGTERM',
17: 'SIGCHLD', 18: 'SIGCONT', 19: 'SIGSTOP', 20: 'SIGTSTP',
}
/** Convert a raw signal number to a name (or null when absent/unknown). */
function signalNameOf(signal: number | null | undefined): string | null {
if (signal === null || signal === undefined) return null
return SIGNAL_NAMES[signal] ?? `signal ${signal}`
}
/** Locate the first occurrence of `needle` in `transcript`, returning its line/column. */
function locateNeedle(transcript: string, needle: string): { line: number; column: number } | undefined {
if (needle === '') return undefined
const idx = transcript.indexOf(needle)
if (idx === -1) return undefined
// Walk the transcript up to the match index, counting newlines to derive
// the 0-based line; the column is the offset within that line.
let line = 0
let lineStart = 0
for (let i = 0; i < idx; i += 1) {
if (transcript.charCodeAt(i) === 0x0a /* \n */) {
line += 1
lineStart = i + 1
}
}
return { line, column: idx - lineStart }
}
/** One live agent terminal. */
export interface AgentTerminalHandle {
/** Stable opaque handle. */
uuid: string
/** Owning conversation id. */
sessionId: string
/** Display title. */
title: string
/** The command written to stdin right after spawn. */
command: string
/** The working directory the process was spawned with. */
cwd: string
/** The live pty process. */
pty: nodePty.IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string
/** Whether the top-level process exited (transcript stays replayable). */
exited: boolean
/** Exit code once known. */
exitCode?: number | null
/** Exit signal number once known (POSIX only; undefined on Windows). */
exitSignal?: number | null
}
/** Read result shape (mirrors the official tool-pty terminal_read contract). */
export interface AgentTerminalReadResult {
/** The slice of transcript text for the requested page. */
text: string
/** Total lines in the retained transcript (the page may be a subset). */
totalLines: number
/** 0-based index of the first line in `text` (inclusive). */
lineBegin: number
/** 0-based index of the last line in `text` (exclusive). */
lineEnd: number
}
/** Outcome of {@link AgentPtyRegistry.waitFor}. */
export type AgentTerminalWaitResult =
| {
/** The needle was found in the transcript. */
kind: 'found'
/** The matched substring. */
needle: string
/** 0-based line index (in the retained transcript) where the needle first appeared. */
line: number
/** 0-based column index within that line where the match starts. */
column: number
/** Elapsed wall-clock milliseconds from the wait start to the match. */
elapsedMs: number
}
| {
/** The needle did not appear before the timeout. */
kind: 'timeout'
/** The needle that was awaited. */
needle: string
/** The configured timeout in milliseconds. */
timeoutMs: number
/** Total lines retained when the timeout fired (call terminal_read to inspect). */
totalLines: number
}
| {
/** The terminal exited before the needle appeared. */
kind: 'exited'
/** The needle that was awaited. */
needle: string
/** The exit code, if known. */
exitCode?: number | null
/** The exit signal name, if the process was killed by a signal. */
exitSignal?: string | null
}
/** Snapshot projection of a handle (drops the pty reference and transcript). */
export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot {
const out: AgentTerminalSnapshot = {
uuid: handle.uuid,
title: handle.title,
command: handle.command,
exited: handle.exited,
}
if (handle.exited) {
out.exitCode = handle.exitCode ?? null
out.exitSignal = signalNameOf(handle.exitSignal)
}
return out
}
/**
* The agent terminal registry. The constructor takes the resolved shell
* binary (the same `defaultShell()` the UI-tab registry uses) and runs the
* spawn-helper chmod fix once at construction so the first agent terminal
* does not race a lazy fixer.
*/
export class AgentPtyRegistry {
private readonly sessions = new Map<string, AgentTerminalHandle>()
private readonly changeListeners = new Set<() => void>()
constructor(private readonly shell: string) {
ensureSpawnHelper()
}
/**
* Spawn one agent terminal: start the shell in `cwd`, then write
* `command + '\n'` to stdin so the command runs in the fresh shell. The
* terminal stays alive after the command exits — the model can send more
* input through `terminal_send` until it calls `terminal_close` or the
* user closes the sidebar tab. An empty `command` spawns a bare shell.
* @returns the new handle's uuid (the model-facing opaque id).
*/
create(
sessionId: string,
title: string,
command: string,
cwd: string,
cols = 80,
rows = 24,
): string {
const uuid = randomUUID()
const dims = clampDims(cols, rows)
const pty = nodePty.spawn(this.shell, [], {
name: 'xterm-256color',
cols: dims.cols,
rows: dims.rows,
cwd,
env: { ...process.env },
})
const handle: AgentTerminalHandle = {
uuid,
sessionId,
title,
command,
cwd,
pty,
transcript: '',
exited: false,
}
pty.onData((data) => {
handle.transcript += data
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
}
})
pty.onExit(({ exitCode, signal }) => {
handle.exited = true
handle.exitCode = exitCode
handle.exitSignal = signal
this.notify()
})
if (command !== '') {
// Write the command + Enter so it runs in the freshly spawned shell.
// Use \r (carriage return) — the actual character a terminal sends for
// the Enter key — not \n (line feed). PowerShell treats a bare \n as a
// soft line break (continuation prompt ">>") rather than a command
// submit; \r is the cross-shell Enter semantics on both POSIX and Windows.
try {
pty.write(`${command}\r`)
} catch {
// A spawn that failed between onData and onExit surfaces its own
// exit; the create call still returns the uuid so the model can
// read the transcript and see the failure.
}
}
this.sessions.set(uuid, handle)
this.notify()
return uuid
}
/** All live agent terminals belonging to one conversation. */
list(sessionId: string): AgentTerminalSnapshot[] {
const out: AgentTerminalSnapshot[] = []
for (const handle of this.sessions.values()) {
if (handle.sessionId === sessionId) out.push(snapshotOf(handle))
}
return out
}
/** Resolve a live handle by uuid, or throw `not-found`. */
private expect(uuid: string): AgentTerminalHandle {
const handle = this.sessions.get(uuid)
if (handle === undefined) {
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
}
return handle
}
/**
* Resolve a live handle that belongs to `sessionId`, or throw `not-found`.
* The model-facing tools call this before every uuid-keyed operation: a
* uuid from another session is indistinguishable from an unknown one, so a
* model can never reach (or probe) a terminal it does not own.
*/
assertOwned(uuid: string, sessionId: string): AgentTerminalHandle {
const handle = this.expect(uuid)
if (handle.sessionId !== sessionId) {
throw new SidebarError('not-found', `agent terminal "${uuid}" not found`, 404)
}
return handle
}
/** Resolve a handle's snapshot, or undefined if it does not exist. */
snapshot(uuid: string): AgentTerminalSnapshot | undefined {
const handle = this.sessions.get(uuid)
return handle === undefined ? undefined : snapshotOf(handle)
}
/** Write raw text to a terminal's stdin (tmux `send-keys` semantics). */
send(uuid: string, text: string): void {
const handle = this.expect(uuid)
if (handle.exited) {
throw new SidebarError('bad-request', `agent terminal "${uuid}" has exited`, 400)
}
handle.pty.write(text)
}
/**
* Read one bounded page of the retained transcript. `offset` is a 0-based
* line index from the start of the retained transcript (default 0);
* `count` caps the page size (default 500). A negative `offset` reads
* from the end (e.g. -50 reads the last 50 lines). Returns `totalLines`
* so the model can paginate.
*/
read(uuid: string, offset?: number, count?: number): AgentTerminalReadResult {
const handle = this.expect(uuid)
const lines = handle.transcript.split('\n')
const totalLines = lines.length
const pageSize = Math.max(1, Math.min(count ?? DEFAULT_READ_COUNT, DEFAULT_READ_COUNT))
let start: number
if (offset === undefined || offset === 0) {
start = 0
} else if (offset < 0) {
// Negative offset: read from the end (e.g. -50 → last 50 lines).
start = Math.max(0, totalLines + offset)
} else {
start = Math.min(offset, totalLines)
}
const end = Math.min(start + pageSize, totalLines)
const slice = lines.slice(start, end).join('\n')
return {
text: slice,
totalLines,
lineBegin: start,
lineEnd: end,
}
}
/**
* Resize a terminal's pty, clamped to the 2..1024 sane range.
* @returns the dimensions actually applied (the caller echoes these, so the
* reported value always matches the pty).
*/
resize(uuid: string, cols: number, rows: number): { cols: number; rows: number } {
const handle = this.expect(uuid)
const dims = clampDims(cols, rows)
if (!handle.exited) handle.pty.resize(dims.cols, dims.rows)
return dims
}
/**
* Wait for `needle` to appear in a terminal's transcript, or for the
* terminal to exit, or for the timeout to elapse — whichever happens
* first. The wait polls the live transcript every ~50ms and short-circuits
* on `signal` abort (re-thrown as the abort reason so the tool layer
* surfaces cancellation).
*
* The match scans the FULL retained transcript on each poll, not just the
* delta since the last poll — a needle that scrolled past the most recent
* chunk but is still within the ~1 MiB bound is still a match. The
* returned line/column locate the FIRST occurrence (oldest), which is what
* a user watching the terminal would have seen first.
*
* The implementation uses polling (not pty onData subscription) because
* node-pty's onData fires before the registry's own onData listener
* updates the transcript (listener order is not guaranteed), and on
* Windows ConPTY output can arrive in bursts with batching delays that
* make event-driven wakeups unreliable. A 50ms poll is fast enough for
* interactive use and simple enough to be obviously correct.
* @param uuid - terminal to watch.
* @param needle - substring to search for (case-sensitive, verbatim).
* @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms.
* @param signal - caller-owned cancellation; aborts the wait re-throwing.
* @returns one of `found` / `timeout` / `exited`.
*/
async waitFor(
uuid: string,
needle: string,
timeoutMs = 10_000,
signal?: AbortSignal,
): Promise<AgentTerminalWaitResult> {
if (needle === '') {
throw new SidebarError('bad-request', 'needle must be a non-empty string', 400)
}
const handle = this.expect(uuid)
const timeout = Math.max(100, Math.floor(timeoutMs))
const start = Date.now()
const deadline = start + timeout
// Fast path: already exited, or the needle is already in the transcript
// (a `terminal_send` may have produced the expected output before this
// call even started).
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
const firstHit = locateNeedle(handle.transcript, needle)
if (firstHit !== undefined) {
return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, elapsedMs: Date.now() - start }
}
// Poll loop: check the transcript every 50ms, exit on match / exit /
// abort / timeout. The handle is read live each iteration (its transcript
// and exited fields mutate as the pty produces output).
while (true) {
if (signal?.aborted) signal.throwIfAborted()
if (handle.exited) {
return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) }
}
const hit = locateNeedle(handle.transcript, needle)
if (hit !== undefined) {
return { kind: 'found', needle, line: hit.line, column: hit.column, elapsedMs: Date.now() - start }
}
if (Date.now() >= deadline) {
return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length }
}
await new Promise(resolve => {
const t = setTimeout(resolve, 50)
// Allow the Node process to exit even if the timer is pending.
if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref()
})
}
}
/**
* Send a POSIX signal to a terminal's foreground process.
*
* Two delivery paths, by signal kind:
* - **Interactive control signals** (SIGINT, SIGTSTP) are delivered by
* writing the corresponding control character to the pty stdin. This is
* how a real terminal sends Ctrl+C / Ctrl+Z: the byte hits the kernel
* line discipline (POSIX ISIG mode) or the ConPTY input pipeline
* (Windows), which translates it into a SIGINT/SIGTSTP for the
* foreground process group. This works on every platform — calling
* `node-pty.kill('SIGINT')` throws on Windows and is fragile on POSIX,
* but writing `\x03` is universally correct.
* - **Termination signals** (SIGKILL, SIGTERM, SIGHUP) use `pty.kill()`,
* which maps to the platform's process-termination path (POSIX
* `kill(2)`, Windows `TerminateProcess`). These cannot be faked with
* control characters.
*/
signal(uuid: string, signal: AgentTerminalSignal): void {
const handle = this.expect(uuid)
if (handle.exited) return
// Ctrl+C → 0x03 (ETX), Ctrl+Z → 0x1A (SUB). Writing these to the pty
// master is the cross-platform way to deliver the signal to the
// foreground process group through the terminal's input pipeline.
if (signal === 'SIGINT' || signal === 'SIGTSTP') {
const ctrlByte = signal === 'SIGINT' ? '\x03' : '\x1a'
try {
handle.pty.write(ctrlByte)
} catch {
// A pty that rejects writes is already tearing down; the next
// onExit will mark it exited. Not an error from the caller's view.
}
return
}
// SIGKILL / SIGTERM / SIGHUP: use the process-termination path.
try {
handle.pty.kill(signal)
} catch {
// node-pty on Windows rejects named signals other than the default;
// fall back to the default kill (TerminateProcess on Windows,
// SIGKILL-equivalent on POSIX) so the signal still takes effect.
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
}
}
/**
* Close a terminal and drop its state. Idempotent: a second close of the
* same uuid is a no-op. Returns true iff a live handle was actually
* dropped.
*/
close(uuid: string): boolean {
const handle = this.sessions.get(uuid)
if (handle === undefined) return false
this.sessions.delete(uuid)
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
this.notify()
return true
}
/** Resolve a live handle by uuid (for the WS attach path). */
get(uuid: string): AgentTerminalHandle | undefined {
return this.sessions.get(uuid)
}
/**
* Subscribe to registry changes (create / close / exit). The sidebar push
* endpoint uses this to forward snapshots to the connected view. Returns
* the unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.changeListeners.add(listener)
return () => { this.changeListeners.delete(listener) }
}
/** Close every agent terminal (plugin teardown). */
disposeAll(): void {
for (const uuid of [...this.sessions.keys()]) this.close(uuid)
}
/** Fire every change listener (callers wrap in try/catch if needed). */
private notify(): void {
for (const listener of [...this.changeListeners]) {
try {
listener()
} catch {
// A listener throwing must not break the others or the registry.
}
}
}
}
+110
View File
@@ -0,0 +1,110 @@
/**
* The diff tab: one change opened from the git panel, like VSCode's diff
* editor. A worktree ref loads the file's unified diff (`git diff`, staged or
* not; untracked files — which git diff never covers — render as a full-file
* addition from their content), a commit ref loads the commit's full patch
* (`git.show`-style). The header carries a refresh button because the tab
* stays mounted while the git panel's staging/discard operations change the
* very content it shows.
*/
import { useCallback, useEffect, useState } from 'react'
import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from './api.ts'
import { api } from './api.ts'
import type { SidebarDiffRef } from './state.ts'
import { DiffView } from './DiffView.tsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** The loaded diff surface (untracked content rendered as a full addition). */
interface DiffData {
diff: string
untracked?: string
}
export function DiffTab(props: { sessionId: string; cwd: string | undefined; diff: SidebarDiffRef }) {
const { sessionId, cwd, diff } = props
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [data, setData] = useState<DiffData | null>(null)
const [tick, setTick] = useState(0)
const refresh = useCallback((): void => { setTick(value => value + 1) }, [])
useEffect(() => {
let cancelled = false
const scope: SessionScope = { sessionId, cwd }
setLoading(true)
setError(null)
setData(null)
const load = async (): Promise<void> => {
try {
if (diff.kind === 'commit') {
const result = await api.gitCommitDiff(scope, diff.hashFull)
if (!cancelled) setData({ diff: result.diff })
return
}
let result = await api.gitDiff(scope, diff.path, diff.staged)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once: the ref
// may predate the staged-flag fix, or the change moved sides (a
// file staged after its tab opened). Both sides empty means the
// file genuinely has no text changes.
const other = await api.gitDiff(scope, diff.path, !diff.staged)
if (other.diff !== '') result = other
}
if (result.diff !== '') {
if (!cancelled) setData({ diff: result.diff })
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition; anything else is a genuine no-text-change.
if (diff.untracked === true && !diff.staged) {
const text = await api.fsRead(scope, diff.path)
if (!cancelled) {
setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' })
}
return
}
if (!cancelled) setData({ diff: '' })
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [sessionId, cwd, diff, tick])
return (
<div className={css.gitDiffTab}>
<div className={css.gitDiffTabHeader}>
<span className={css.gitDiffTabTitle} title={diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}>
{diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`}
</span>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={refresh}
>
<IconRefreshOutline16 />
</button>
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
{!loading && error !== null && <div className={css.gitError}>{t('diffLoadError')}: {error}</div>}
{!loading && error === null && data !== null && (
<>
{data.untracked !== undefined
? <DiffView diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffView diff={data.diff} />}
{data.diff === '' && data.untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
</>
)}
</div>
)
}
+266
View File
@@ -0,0 +1,266 @@
/**
* The real diff surface for the git panel: parses the host's unified diff
* text (`git diff` / `git show`) and renders it VSCode-style — per-file
* sections with hunks (`@@ -a,b +c,d @@` headers), old/new line-number
* gutters, and aligned context / deleted / added rows colored through the
* DSH tokens. Untracked files produce no `git diff` output, so the caller
* can pass the file content to render as a full-file addition instead.
*
* The parser is a pure function (`parseUnifiedDiff`) so the interesting
* cases are unit-tested without a DOM.
*/
import { useMemo, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** One rendered diff line. */
export interface DiffLine {
kind: 'ctx' | 'del' | 'add' | 'meta'
/** The line content without its diff marker ('' for the no-newline marker). */
text: string
/** Old-side line number (null for pure additions / metadata). */
oldNum: number | null
/** New-side line number (null for pure deletions / metadata). */
newNum: number | null
}
/** One parsed hunk. */
export interface DiffHunk {
/** The old-side start line (`-a[,b]`). */
oldStart: number
/** The new-side start line (`+c[,d]`). */
newStart: number
/** The section text after the trailing `@@` (may be empty). */
header: string
lines: DiffLine[]
}
/** One parsed file section of a unified diff. */
export interface DiffFile {
/** The `---` path verbatim ('/dev/null' for a new file). */
oldPath: string
/** The `+++` path verbatim ('/dev/null' for a deleted file). */
newPath: string
/** The file changed with binary content: no hunks to draw. */
binary: boolean
hunks: DiffHunk[]
}
/** The parsed unified diff. */
export interface ParsedDiff {
files: DiffFile[]
}
/** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */
function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line)
if (match === null) return null
return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' }
}
/**
* Parse `git diff --no-color` output into file sections and hunks. Rows
* outside a file section (leading noise) and metadata rows between the
* `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode
* changes, rename/similarity lines) are skipped; a section that never
* reaches a hunk (a mode/rename-only change) stays hunkless so the caller
* can still draw its path.
*/
export function parseUnifiedDiff(text: string): ParsedDiff {
const files: DiffFile[] = []
let current: DiffFile | null = null
let inHunk = false
let hunk: DiffHunk | null = null
let oldNum = 0
let newNum = 0
const flushHunk = (): void => {
if (current !== null && hunk !== null) current.hunks.push(hunk)
hunk = null
inHunk = false
}
for (const raw of text.split('\n')) {
if (raw.startsWith('diff --git ')) {
flushHunk()
current = { oldPath: '', newPath: '', binary: false, hunks: [] }
files.push(current)
continue
}
if (current === null) continue
if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') {
flushHunk()
current.binary = true
continue
}
if (raw.startsWith('--- ')) {
flushHunk()
current.oldPath = raw.slice(4)
continue
}
if (raw.startsWith('+++ ')) {
current.newPath = raw.slice(4)
continue
}
const header = parseHunkHeader(raw)
if (header !== null) {
flushHunk()
hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] }
oldNum = header.oldStart
newNum = header.newStart
inHunk = true
continue
}
if (!inHunk || hunk === null) continue
const marker = raw[0]
if (marker === '\\') {
// `\ No newline at end of file`: metadata attached to the previous row.
hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null })
continue
}
if (marker === ' ') {
hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum })
oldNum += 1
newNum += 1
} else if (marker === '-') {
hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null })
oldNum += 1
} else if (marker === '+') {
hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum })
newNum += 1
} else {
// Not a diff line (a hunk can never contain one): stop the hunk.
flushHunk()
}
}
flushHunk()
return { files }
}
/** Build the untracked-file shape: one file, one hunk of pure additions. */
function untrackedFile(path: string, content: string): DiffFile {
const lines: DiffLine[] = []
const body = content.endsWith('\n') ? content.slice(0, -1) : content
if (body !== '') {
let num = 1
for (const line of body.split('\n')) {
lines.push({ kind: 'add', text: line, oldNum: null, newNum: num })
num += 1
}
}
return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] }
}
/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */
function displayPath(path: string): string {
if (path === '/dev/null') return path
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
return path
}
/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */
function fileTag(file: DiffFile): string | null {
if (file.binary) return t('diffBinary')
if (file.oldPath === '/dev/null') return t('diffAdded')
if (file.newPath === '/dev/null') return t('diffDeleted')
const oldPath = displayPath(file.oldPath)
const newPath = displayPath(file.newPath)
if (oldPath !== newPath) return t('diffRenamed')
return null
}
/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */
const MAX_DIFF_ROWS = 500
export interface DiffViewProps {
/** Unified diff text (`git.diff` or `git.commit-diff` payloads). */
diff: string
/** Untracked-file content: when present, renders as a full-file addition instead of parsing. */
untrackedPath?: string
untrackedContent?: string
}
export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) {
const parsed = useMemo<ParsedDiff>(() => {
if (untrackedPath !== undefined) {
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
}
return parseUnifiedDiff(diff)
}, [diff, untrackedPath, untrackedContent])
const [expanded, setExpanded] = useState(false)
// Flatten into display rows so the cap can slice a single list.
const rows = useMemo(() => {
const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
parsed.files.forEach((file, fileIndex) => {
out.push({ key: `f${fileIndex}`, file, type: 'path' })
if (file.binary) return
file.hunks.forEach((hunk, hunkIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk })
hunk.lines.forEach((line, lineIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line })
})
})
})
return out
}, [parsed])
const hidden = rows.length - MAX_DIFF_ROWS
const capped = hidden > 0 && !expanded
const headLines = Math.ceil(MAX_DIFF_ROWS / 2)
const tailLines = MAX_DIFF_ROWS - headLines
const head = capped ? rows.slice(0, headLines) : rows
const tail = capped ? rows.slice(rows.length - tailLines) : []
if (rows.length === 0) return null
const renderRow = (row: (typeof rows)[number]): ReactNode => {
if (row.type === 'path') {
const tag = fileTag(row.file)
const from = displayPath(row.file.oldPath)
const to = displayPath(row.file.newPath)
return (
<div key={row.key} className={css.gitDiffFile}>
<span className={css.gitDiffFilePath}>{to}</span>
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
</div>
)
}
if (row.type === 'hunk') {
const hunk = row.hunk!
return (
<div key={row.key} className={css.gitDiffHunk}>
<span className={css.gitDiffHunkHeader}>@@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@</span>
{hunk.header !== '' && <span className={css.gitDiffHunkSection}>{hunk.header}</span>}
</div>
)
}
const line = row.line!
const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
return (
<div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
{line.kind === 'meta'
? <span className={css.gitDiffMetaText}>{line.text}</span>
: (
<>
<span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
<span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
<span className={css.gitDiffCode}>{line.text}</span>
</>
)}
</div>
)
}
return (
<div className={css.gitDiff}>
{head.map(renderRow)}
{hidden > 0 && (
<button type="button" className={css.gitDiffExpand} aria-expanded={expanded} onClick={() => { setExpanded(value => !value) }}>
{expanded ? t('diffCollapse') : t('diffExpand', { count: hidden })}
</button>
)}
{tail.map(renderRow)}
</div>
)
}
+101
View File
@@ -0,0 +1,101 @@
/**
* The editor tab host: resolves a file's previewer through the sidebar
* registry (`matchFileViewer`), fetches bytes per the matched viewer's
* fetch strategy, and renders its component — or the shared download pane
* when nothing can render the file. The header shows the file title; the
* editable code/markdown viewers render their own toolbar below it.
*
* The strategy dispatch is pure (planFirstMatch / planFsReadOutcome in
* editor-load.ts); this component only wires it to the host APIs.
*/
import { useEffect, useState } from 'react'
import { createElement } from 'react'
import type { Context } from '../context-types.ts'
import { api, mediaUrl, type SessionScope } from './api.ts'
import { BinaryDownload } from './binary-download.tsx'
import { planFirstMatch, planFsReadOutcome, type EditorLoadAction } from './editor-load.ts'
import { t } from './locales.ts'
import type { FileViewerDescriptor } from './service.ts'
import type { SidebarStore } from './state.ts'
import css from './sidebar.module.css'
type EditorLoad =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
| { status: 'binary' }
export function EditorHost(props: { ctx: Context; store: SidebarStore; scope: SessionScope; path: string; title: string }) {
const { ctx, store, scope, path, title } = props
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
useEffect(() => {
let cancelled = false
setLoad({ status: 'loading' })
const mediaUrlOf = (): string => mediaUrl(scope, path)
const apply = (action: EditorLoadAction): void => {
if (cancelled) return
switch (action.kind) {
case 'binary':
setLoad({ status: 'binary' })
return
case 'render':
setLoad({
status: 'ready',
viewer: action.viewer,
content: action.content,
truncated: action.truncated,
mediaUrl: action.mediaUrl,
customData: action.customData,
})
return
case 'customLoad':
void action.viewer.load?.(path, scope).then((data) => {
if (cancelled) return
setLoad({ status: 'ready', viewer: action.viewer, customData: data })
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return
case 'fetchFsRead':
api.fsRead(scope, path).then((result) => {
if (cancelled) return
// Binary reads carry the head bytes for the detect re-match.
const outcome = planFsReadOutcome(action.viewer, {
binary: result.kind === 'binary',
content: result.kind === 'text' ? result.content : '',
truncated: result.truncated,
head: result.kind === 'binary' ? result.head : undefined,
}, (head) => ctx.betterSidebar?.matchFileViewer(path, head), mediaUrlOf)
apply(outcome)
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return
}
}
apply(planFirstMatch(ctx.betterSidebar?.matchFileViewer(path), mediaUrlOf))
return () => { cancelled = true }
}, [scope.sessionId, scope.cwd, path, ctx])
return (
<div className={css.editor}>
<div className={css.editorHeader}>
<span className={css.editorTitle} title={path}>{title}</span>
</div>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{load.status === 'binary' && <BinaryDownload scope={scope} path={path} />}
{load.status === 'ready' && createElement(load.viewer.component, {
ctx, store, scope, path, title,
viewerId: load.viewer.id,
content: load.content,
truncated: load.truncated,
mediaUrl: load.mediaUrl,
customData: load.customData,
})}
</div>
)
}
+283
View File
@@ -0,0 +1,283 @@
/**
* The file explorer: a lazy VSCode-style tree rooted at the session's
* working directory. Levels load on expansion (one API call per directory),
* directories sort first, hidden entries render dimmed, and the expansion
* set lives in the per-session state. Clicking a file opens an editor tab.
*
* Row actions: hovering a row reveals an @-reference button on the far
* right (appends `@<relative path>` to the composer draft), and right-click
* opens a context menu to copy the relative or absolute path (with a brief
* "copied" label replacing the button after a successful write); file rows
* also offer a download action (the host serves raw bytes, binary-safe).
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
IconRefreshOutline16, Menu, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { api, downloadUrl, type FsEntry } from './api.ts'
import { relativeTo } from './paths.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
interface LevelData {
entries?: FsEntry[]
error?: string
}
/** Root label: the last path segment (mirror of the host rootLabel). */
function baseName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, '')
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return at === -1 ? trimmed : trimmed.slice(at + 1)
}
/** How long the row's "copied" label stays after a successful write. */
const COPIED_MS = 1200
export function ExplorerView(props: {
sessionId: string
cwd: string | undefined
expanded: string[]
onToggle: (path: string) => void
onOpenFile: (path: string) => void
/** Insert `@<relative path>` into the composer draft. */
onReferenceFile: (path: string) => void
}) {
const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props
const [data, setData] = useState<Record<string, LevelData>>({})
const dataRef = useRef(data)
const [refreshTick, setRefreshTick] = useState(0)
/** The row whose path was just copied ("copied" label replaces its button). */
const [copiedPath, setCopiedPath] = useState<string | null>(null)
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
const storeLevel = useCallback((path: string, level: LevelData) => {
dataRef.current = { ...dataRef.current, [path]: level }
setData(dataRef.current)
}, [])
const loadDir = useCallback((dir: string) => {
if (dataRef.current[dir] !== undefined) return
storeLevel(dir, {})
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
storeLevel(dir, { entries: listing.entries })
}).catch((error: unknown) => {
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
})
}, [sessionId, cwd, storeLevel])
useEffect(() => {
// Load the visible set; already-loaded levels (kept in the cache) are
// not refetched. Only the refresh button wipes the cache.
const root = cwd
if (root === undefined) return
loadDir(root)
for (const dir of expanded) loadDir(dir)
}, [cwd, expanded, refreshTick, loadDir])
/** Copy `text`; on success flip the row's copied label for a moment. */
const copyPath = useCallback((text: string, path: string): void => {
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopiedPath(path)
window.setTimeout(() => {
setCopiedPath(current => current === path ? null : current)
}, COPIED_MS)
})
}, [])
/** The row's trailing actions: the @-reference button, or the copied label. */
const rowActions = (entry: FsEntry): ReactNode => {
if (copiedPath === entry.path) {
return <span className={css.explorerCopied}>{t('copied')}</span>
}
return (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(entry.path)
}}
>
{t('referenceFile')}
</button>
)
}
const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => {
event.preventDefault()
event.stopPropagation()
setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
}
/** Download a file through the host route (raw bytes, binary-safe). */
const downloadFile = (path: string): void => {
const url = downloadUrl({ sessionId, cwd }, path)
const anchor = document.createElement('a')
anchor.href = url
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}
const root = cwd
const renderLevel = (dir: string, depth: number): ReactNode => {
const level = data[dir]
if (level === undefined) {
return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
}
if (level.error !== undefined) {
return (
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
{level.error}
</div>
)
}
const entries = level.entries ?? []
return entries.map(entry => {
if (entry.isDir) {
const isOpen = expanded.includes(entry.path)
return (
<div key={entry.path}>
<div
role="button"
tabIndex={0}
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
style={{ paddingLeft: depth * 22 + 6 }}
onClick={() => { onToggle(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onToggle(entry.path)
}
}}
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
>
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
<span className={css.explorerName}>{entry.name}</span>
{rowActions(entry)}
</div>
{isOpen && renderLevel(entry.path, depth + 1)}
</div>
)
}
return (
<div
key={entry.path}
role="button"
tabIndex={0}
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
style={{ paddingLeft: depth * 22 + 6 }}
title={entry.path}
onClick={() => { onOpenFile(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onOpenFile(entry.path)
}
}}
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
>
<IconCodeOutline16 size={14} />
<span className={css.explorerName}>{entry.name}</span>
{rowActions(entry)}
</div>
)
})
}
return (
<div className={css.explorer}>
<div className={css.explorerHeader}>
<span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => {
dataRef.current = {}
setData({})
setRefreshTick(tick => tick + 1)
}}
>
<IconRefreshOutline16 />
</button>
</div>
<div className={css.explorerBody}>
{root === undefined ? (
<div className={css.explorerEmpty}>{t('noSession')}</div>
) : (
<>
<div
className={css.explorerRow}
style={{ paddingLeft: 6 }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<IconFolderOpen16 size={14} />
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>
: (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(root)
}}
>
{t('referenceFile')}
</button>
)}
</div>
{data[root] !== undefined && renderLevel(root, 1)}
</>
)}
</div>
{/*
The one shared context menu, positioned at the right-click cursor
(portal so the explorer's overflow clip cannot crop it).
*/}
<Menu
open={rowMenu !== null}
onClose={() => { setRowMenu(null) }}
items={[
// Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
: []),
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
]}
onSelect={(id) => {
const target = rowMenu
if (target === null) return
setRowMenu(null)
if (id === 'download') {
downloadFile(target.path)
return
}
copyPath(
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
target.path,
)
}}
portal
align="start"
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
anchor={<span />}
/>
</div>
)
}
+540
View File
@@ -0,0 +1,540 @@
/**
* The source-control panel: status list (staged vs unstaged), stage/unstage,
* commit with a message box, branch switch, and a VSCode-like history — rows
* carry branch decorations, author and relative time. Clicking a changed
* file or a history row opens a dedicated diff TAB (see {@link DiffTab}),
* placed below the git pane on first use. File rows and history rows open a
* right-click context menu with advanced operations (open in editor, discard,
* revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/
* focus (no file watcher — KISS).
*/
import { useCallback, useEffect, useState, type MouseEvent, type ReactNode } from 'react'
import {
Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
IconTrashOutline16, Input, Menu, Modal, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts'
import { api } from './api.ts'
import { relativeTo } from './paths.ts'
import { relativeTime, t } from './locales.ts'
import type { SidebarTab } from './state.ts'
import css from './sidebar.module.css'
/** The XY status letters a row badge shows (X = index, Y = worktree). */
function badgeOf(entry: GitStatusEntry): string {
const index = entry.xy[0]
const worktree = entry.xy[1]
if (index !== undefined && index !== ' ' && index !== '?') return index
if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree
return '?'
}
/** Whether the entry carries STAGED (index) changes — the X letter is set. */
function isStagedEntry(entry: GitStatusEntry): boolean {
const index = entry.xy[0]
return index !== undefined && index !== ' ' && index !== '?'
}
/** Whether the entry carries UNSTAGED (worktree) changes — the Y letter is set
* (untracked `??` counts as unstaged: it is a worktree-only change). A file
* with both letters set ('MM') lands in BOTH sections. */
function isUnstagedEntry(entry: GitStatusEntry): boolean {
if (entry.xy === '??') return true
const worktree = entry.xy[1]
return worktree !== undefined && worktree !== ' ' && worktree !== '?'
}
/** Whether the entry is untracked (`??`): git diff never includes it. */
function isUntracked(entry: GitStatusEntry): boolean {
return badgeOf(entry) === '?'
}
/** The last path segment (tab title for a file's diff). */
function baseName(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
/** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
function refNames(refs: string): string[] {
return [...new Set(
refs
.split(',')
.map(ref => ref.trim())
.filter(ref => ref !== '')
.map(ref => (ref.includes(' -> ') ? ref.slice(ref.indexOf(' -> ') + 4) : ref))
.map(ref => (ref.startsWith('tag: ') ? ref.slice(5) : ref)),
)]
}
/** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
interface ConfirmState {
title: string
description: string
confirmLabel: string
onConfirm: () => Promise<unknown>
}
/** History batch size: the log loads lazily in pages so a long history never
* floods the panel at once (the end of the log is reached by paging). */
const LOG_BATCH = 20
export function GitView(props: {
scope: SessionScope
onOpenFile: (path: string) => void
/** Open a diff tab (the shell places it below the git pane on first use). */
onOpenDiff: (tab: SidebarTab) => void
}) {
const { scope, onOpenFile, onOpenDiff } = props
const [status, setStatus] = useState<GitStatusResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [branchNames, setBranchNames] = useState<string[]>([])
const [logEntries, setLogEntries] = useState<GitLogEntry[]>([])
const [commitMsg, setCommitMsg] = useState('')
const [busy, setBusy] = useState(false)
const [commitError, setCommitError] = useState<string | null>(null)
/** Whether the history was fully paged (a batch shorter than LOG_BATCH). */
const [logEnded, setLogEnded] = useState(false)
const [logLoadingMore, setLogLoadingMore] = useState(false)
/** The open file-row context menu (cursor position for the portaled Menu). */
const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null)
/** The open history-row context menu. */
const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
/** The pending destructive action awaiting confirmation. */
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
const refresh = useCallback(async (): Promise<void> => {
setLoading(true)
setError(null)
try {
const [statusResult, branchResult, logResult] = await Promise.all([
api.gitStatus(scope),
api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
// The first history page only; the rest arrives via "load more".
api.gitLog(scope, LOG_BATCH, 0).catch(() => [] as GitLogEntry[]),
])
setStatus(statusResult)
setBranchNames(branchResult.names)
setLogEntries(logResult)
setLogEnded(logResult.length < LOG_BATCH)
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
setLoading(false)
}
}, [scope.sessionId, scope.cwd])
useEffect(() => { void refresh() }, [refresh])
/** Append the next history page (lazy: only when the user asks for more). */
const loadMoreLog = async (): Promise<void> => {
if (logLoadingMore || logEnded) return
setLogLoadingMore(true)
try {
const next = await api.gitLog(scope, LOG_BATCH, logEntries.length)
setLogEntries(entries => [...entries, ...next])
if (next.length < LOG_BATCH) setLogEnded(true)
} catch (reason) {
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
} finally {
setLogLoadingMore(false)
}
}
/** The diff tab for one changed file (one tab per path+side; same id = focused). */
const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => {
onOpenDiff({
id: `diff:w:${staged ? 's' : 'u'}:${entry.path}`,
type: 'diff',
title: baseName(entry.path),
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry) },
})
}
/** The diff tab for one commit (one tab per commit). */
const openCommitDiff = (entry: GitLogEntry): void => {
onOpenDiff({
id: `diff:c:${entry.hashFull}`,
type: 'diff',
title: `${entry.hash} ${entry.subject}`,
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject },
})
}
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(scope, entry.path)
else await api.gitStage(scope, entry.path)
await refresh()
} finally {
setBusy(false)
}
}
const stageAll = async (staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(scope)
else await api.gitStage(scope)
await refresh()
} finally {
setBusy(false)
}
}
const commit = async (): Promise<void> => {
const message = commitMsg.trim()
if (message === '' || busy) return
setBusy(true)
setCommitError(null)
try {
await api.gitCommit(scope, message)
setCommitMsg('')
await refresh()
} catch (reason) {
setCommitError(reason instanceof Error ? reason.message : String(reason))
} finally {
setBusy(false)
}
}
const checkout = async (branch: string): Promise<void> => {
if (branch === status?.branch || busy) return
setBusy(true)
setCommitError(null)
try {
await api.gitCheckout(scope, branch)
await refresh()
} catch (reason) {
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
} finally {
setBusy(false)
}
}
/** Run one destructive operation after the confirm modal, then refresh. */
const runConfirmed = (confirmState: ConfirmState): void => {
setConfirm({ ...confirmState, onConfirm: async () => {
setBusy(true)
setCommitError(null)
try {
await confirmState.onConfirm()
await refresh()
} catch (reason) {
setCommitError(reason instanceof Error ? reason.message : String(reason))
} finally {
setBusy(false)
}
} })
}
/** Copy `text` to the clipboard (best-effort; no visual feedback needed — the menu closes). */
const copy = (text: string): void => {
void writeClipboard(text)
}
const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => {
event.preventDefault()
event.stopPropagation()
setFileMenu({ entry, staged, x: event.clientX, y: event.clientY })
}
const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
event.preventDefault()
event.stopPropagation()
setHistoryMenu({ entry, x: event.clientX, y: event.clientY })
}
const stagedEntries = (status?.entries ?? []).filter(isStagedEntry)
const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry)
const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
return (
<div key={`${staged ? 's' : 'u'}:${entry.path}`} className={css.gitRow}>
<button
type="button"
className={css.gitRowMain}
title={entry.path}
onClick={() => { openWorktreeDiff(entry, staged) }}
onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
>
<span className={css.gitBadge}>{badgeOf(entry)}</span>
<span className={css.gitName}>{entry.path}</span>
</button>
<button
type="button"
className={css.iconButton}
aria-label={staged ? t('unstage') : t('stage')}
title={staged ? t('unstage') : t('stage')}
disabled={busy}
onClick={() => { void stageEntry(entry, staged) }}
>
{staged ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
</button>
</div>
)
}
return (
<div className={css.git}>
<div className={css.gitHeader}>
<select
className={css.gitBranchSelect}
value={status?.branch ?? ''}
onChange={(event) => { void checkout(event.target.value) }}
disabled={busy || (status !== null && !status.isRepo)}
>
{(status?.branch ?? '') !== '' && <option value={status!.branch}>{status!.branch}</option>}
{branchNames.filter(name => name !== status?.branch).map(name => <option key={name} value={name}>{name}</option>)}
</select>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { void refresh() }}
>
<IconRefreshOutline16 />
</button>
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
{!loading && error !== null && <div className={css.gitError}>{error}</div>}
{!loading && status !== null && !status.isRepo && (
<div className={css.gitPlaceholder}>{t('notRepo')}</div>
)}
{status !== null && status.isRepo && (
<>
<div className={css.gitSection}>
<div className={css.gitSectionHeader}>
<span>{t('staged')} ({stagedEntries.length})</span>
{stagedEntries.length > 0 && (
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(true) }}>
{t('unstageAll')}
</button>
)}
</div>
{stagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
{stagedEntries.map(entry => renderEntry(entry, true))}
</div>
<div className={css.gitSection}>
<div className={css.gitSectionHeader}>
<span>{t('unstaged')} ({unstagedEntries.length})</span>
{unstagedEntries.length > 0 && (
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(false) }}>
{t('stageAll')}
</button>
)}
</div>
{unstagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
{unstagedEntries.map(entry => renderEntry(entry, false))}
</div>
<div className={css.gitCommit}>
<Input
className={css.gitCommitInput}
placeholder={t('commitPlaceholder')}
value={commitMsg}
disabled={busy}
onChange={(event) => { setCommitMsg(event.target.value); setCommitError(null) }}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') void commit()
}}
/>
<button
type="button"
className={css.gitCommitButton}
disabled={busy || commitMsg.trim() === '' || stagedEntries.length === 0}
onClick={() => { void commit() }}
>
{t('commit')}
</button>
</div>
{commitError !== null && <div className={css.gitError}>{commitError}</div>}
<div className={css.gitSection}>
<div className={css.gitSectionHeader}><span>{t('history')}</span></div>
{logEntries.map(entry => (
<div
key={entry.hashFull}
role="button"
tabIndex={0}
className={css.gitLogRow}
title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
onClick={() => { openCommitDiff(entry) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openCommitDiff(entry)
}
}}
onContextMenu={(event) => { openHistoryMenu(event, entry) }}
>
<span className={css.gitLogLine1}>
<span className={css.gitLogHash}>{entry.hash}</span>
<span className={css.gitLogSubject}>{entry.subject}</span>
</span>
<span className={css.gitLogLine2}>
{refNames(entry.refs).map(ref => (
<span key={ref} className={css.gitLogRef}>{ref}</span>
))}
<span className={css.gitLogMeta}>{entry.author} · {relativeTime(entry.date)}</span>
</span>
</div>
))}
{!logEnded && (
<button
type="button"
className={css.gitLogMore}
disabled={logLoadingMore || busy}
onClick={() => { void loadMoreLog() }}
>
{logLoadingMore ? t('loading') : t('loadMore')}
</button>
)}
</div>
{/*
The one shared file-row context menu, positioned at the right-click
cursor (portal so the panel's overflow clip cannot crop it).
*/}
<Menu
open={fileMenu !== null}
onClose={() => { setFileMenu(null) }}
items={[
{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> },
fileMenu?.staged === true
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
: { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
...(fileMenu !== null && !isUntracked(fileMenu.entry)
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
: []),
{ type: 'separator', id: 'sep1' },
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
]}
onSelect={(id) => {
const target = fileMenu
if (target === null) return
setFileMenu(null)
if (id === 'open') {
onOpenFile(target.entry.path)
return
}
if (id === 'stage') {
void stageEntry(target.entry, target.staged)
return
}
if (id === 'discard') {
runConfirmed({
title: t('discardTitle'),
description: t('discardDesc', { path: target.entry.path }),
confirmLabel: t('discard'),
onConfirm: () => api.gitDiscard(scope, target.entry.path),
})
return
}
if (id === 'relative') {
copy(relativeTo(scope.cwd ?? '', target.entry.path))
return
}
if (id === 'absolute') copy(target.entry.path)
}}
portal
align="start"
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
anchor={<span />}
/>
{/* The shared history-row context menu. */}
<Menu
open={historyMenu !== null}
onClose={() => { setHistoryMenu(null) }}
items={[
{ id: 'view', label: t('viewCommitDiff') },
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
{ type: 'separator', id: 'sep2' },
{ id: 'revert', label: t('revertCommit'), danger: true },
{ id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
]}
onSelect={(id) => {
const target = historyMenu
if (target === null) return
setHistoryMenu(null)
if (id === 'view') {
openCommitDiff(target.entry)
return
}
if (id === 'copyShort') {
copy(target.entry.hash)
return
}
if (id === 'copyFull') {
copy(target.entry.hashFull)
return
}
if (id === 'copySubject') {
copy(target.entry.subject)
return
}
if (id === 'revert') {
runConfirmed({
title: t('revertTitle'),
description: t('revertDesc', { subject: target.entry.subject }),
confirmLabel: t('revertCommit'),
onConfirm: () => api.gitRevert(scope, target.entry.hashFull),
})
return
}
if (id === 'cherryPick') {
runConfirmed({
title: t('cherryPickTitle'),
description: t('cherryPickDesc', { subject: target.entry.subject }),
confirmLabel: t('cherryPickCommit'),
onConfirm: () => api.gitCherryPick(scope, target.entry.hashFull),
})
}
}}
portal
align="start"
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
anchor={<span />}
/>
{/* Destructive actions land here first: Cancel / Confirm. */}
<Modal
open={confirm !== null}
onClose={() => { setConfirm(null) }}
title={confirm?.title ?? ''}
closeLabel={t('cancel')}
footer={(
<>
<Button variant="outline" onClick={() => { setConfirm(null) }}>{t('cancel')}</Button>
<Button
variant="primary"
disabled={busy}
onClick={() => {
const pending = confirm
if (pending === null) return
setConfirm(null)
void pending.onConfirm()
}}
>
{confirm?.confirmLabel ?? ''}
</Button>
</>
)}
>
<p className={css.gitConfirmDesc}>{confirm?.description}</p>
</Modal>
</>
)}
</div>
)
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Placeholder shown when a persisted tab's type is not registered (the
* contributing plugin is not installed / not yet loaded). The tab is kept
* in state so it can recover if the plugin loads later; the user can close
* it through the tab bar's X button as usual. This is the graceful-
* degradation path for the open tab-type set: {@link Sidebar} renders this
* instead of crashing on an unknown type.
*/
import type { ReactNode } from 'react'
import { t } from './locales.ts'
import css from './sidebar.module.css'
import type { TabComponentProps } from './service.ts'
export function OrphanedTab(props: TabComponentProps): ReactNode {
const { tab } = props
return (
<div className={css.editor}>
<div className={css.editorHeader}>
<span className={css.editorTitle} title={tab.type}>{tab.title}</span>
</div>
<div className={css.editorPlaceholder}>
<span>{t('pluginNotLoaded')}</span>
<code className={css.orphanedType}>{tab.type}</code>
</div>
</div>
)
}
+110
View File
@@ -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>
)
}
+131
View File
@@ -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>
)
}
+265
View File
@@ -0,0 +1,265 @@
/**
* Side card settings section chrome (DSH-native tokens throughout):
* - the feature inventories are responsive GRIDS of small toggle cards
* (auto-fill columns — several cards per row, wrapping);
* - a card's on/off state IS its visual state: enabled = brand border +
* tinted fill + a check badge pinned to the far right of the top row,
* disabled = neutral hairline + dimmed text;
* - features that declare related settings carry a gear corner button
* opening a native Modal whose rows are the DSH settings-row recipe
* (title/desc left + native accent checkbox, hairline separators);
* - the width input stays a native settings row (a value, not a switch).
*/
.section {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.section > :last-child {
border-bottom: none;
}
/* Responsive inventory: as many small cards per row as the width allows. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 8px;
}
/* One small toggle card (visual chrome; the interactive part is .cardMain). */
.card {
position: relative;
display: flex;
flex-direction: column;
border-radius: 8px;
border: 1px solid var(--dsw-alias-border-l2);
background: transparent;
font: inherit;
color: inherit;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
/* Hover affordance on disabled cards only (enabled cards are already lit). */
.card:not(.cardOn):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Enabled = highlighted: brand border + tinted fill. */
.cardOn {
border-color: var(--dsw-alias-button-primary-fill);
background: var(--dsw-alias-interactive-bg-active);
}
/* The card's switch: the whole main area (top row + desc) flips the feature. */
.cardMain {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 10px 12px;
border: 0;
border-radius: inherit;
background: transparent;
font: inherit;
color: inherit;
text-align: left;
cursor: pointer;
}
.cardMain:focus-visible,
.cardGear:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
.cardTop {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.cardIcon {
display: inline-flex;
align-items: center;
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.cardOn .cardIcon {
color: var(--dsw-alias-button-primary-fill);
}
.cardTitle {
flex: 1;
min-width: 0;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cardOn .cardTitle {
color: var(--dsw-alias-label-primary);
}
/* The check badge: pinned to the FAR RIGHT of the top row (enabled only). */
.cardCheck {
display: inline-flex;
align-items: center;
flex: none;
color: var(--dsw-alias-button-primary-fill);
}
.cardDesc {
font-size: 11px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cardOn .cardDesc {
color: var(--dsw-alias-label-secondary);
}
/* The gear corner button: bottom-right of cards that declare related
settings; the desc gets right padding so the ellipsis never runs under it. */
.cardWithGear .cardDesc {
padding-right: 18px;
}
.cardGear {
position: absolute;
right: 6px;
bottom: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
.cardGear:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
/* Inventory section heading (常规 / 侧边栏内容 / 文件预览). */
.sectionHeading {
padding: 14px 0 0;
font-size: 12px;
line-height: 17px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--dsw-alias-label-secondary);
}
/* Native settings row recipe (the width input row + popup rows). */
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
.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 (the settings-row 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;
}
/* Secondary settings popup body: stacked native rows, trailing separator
stripped by the trailing-child rule. */
.popupRows {
display: flex;
flex-direction: column;
width: 100%;
}
.popupRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
cursor: pointer;
}
.popupRows > :last-child {
border-bottom: none;
}
.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) {
.card {
transition: none;
}
}
+418
View File
@@ -0,0 +1,418 @@
/**
* "Side card" settings section: the user-facing preferences for the sidebar
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
*
* The section is DECLARATIVE — it renders the enable/disable inventory from
* the sidebar service's registries instead of hardcoding rows:
* - 常规: new conversations open the panel by default (a toggle card), and
* the default panel width as a percent of the window (number input row).
* - 侧边栏内容: one SMALL CARD per REGISTERED tab type (built-ins and
* external plugins alike), laid out in a responsive grid that wraps
* several cards per row — icon + title + type id, clicked to toggle the
* switch persisted in `prefs.tabsEnabled[id]`.
* - 文件预览: one SMALL CARD per REGISTERED file viewer — icon + title +
* the extensions it covers, clicked to toggle `prefs.viewersEnabled[id]`.
*
* A card's on/off state is its VISUAL STATE: enabled = highlighted (brand
* border + tinted fill + a check badge pinned to the card's far right),
* disabled = neutral and dimmed. Features that declare `settings.toggles`
* carry a gear corner button that opens a native Modal with the related
* settings as native checkbox rows (e.g. the Subagent page's "auto-open
* when a subagent appears", the Terminal page's model terminal tools).
*
* 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 and the sidebar's consumption points (the + menu, derived
* flows) re-render immediately. Any failure reverts the optimistic UI and
* shows the wire error inline — a broken settings surface never crashes the
* shell.
*/
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
import {
IconCheckOutline16,
IconPanelLeftOutline16,
IconSettingsOutline16,
Input,
Modal,
} from '@deepseek-ai/dsh-client-ui-primitives'
import clsx from 'clsx'
// 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 type {
BetterSidebarService,
FileViewerDescriptor,
SidebarSettingToggle,
TabDescriptor,
} from './service.ts'
import css from './SideCardSection.module.css'
/** Injected business face: the shared store (prefs cache) + the sidebar service (registries). */
export interface SideCardSectionInjected {
store: SidebarStore
service: BetterSidebarService
}
/** 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)}`
}
/** Resolve an i18n-friendly string-or-function value. */
function textOf(value: string | (() => string) | undefined): string {
if (value === undefined) return ''
return typeof value === 'function' ? value() : value
}
/** Resolve a descriptor icon (ReactNode or size function). */
function iconOf(icon: ReactNode | ((size: number) => ReactNode) | undefined, size: number): ReactNode {
if (icon === undefined) return null
return typeof icon === 'function' ? icon(size) : icon
}
/** Tab inventory order: hidden types (editor/diff) last, then + menu order. */
function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
if (a.hidden !== b.hidden) return a.hidden === true ? 1 : -1
return (a.order ?? 100) - (b.order ?? 100)
}
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
return (b.priority ?? 0) - (a.priority ?? 0)
}
/** Read one boolean pref by declarative key (missing = false). */
function prefBool(prefs: SidebarPrefs, key: string): boolean {
return (prefs as unknown as Record<string, boolean>)[key] === true
}
/**
* The body of a feature's secondary settings popup: one native checkbox row
* per declared toggle. Extracted so the rows are testable without opening
* the Modal (the Modal portal renders only while open).
*/
export function FeatureSettingsRows(props: {
toggles: readonly SidebarSettingToggle[]
prefs: SidebarPrefs
onToggle: (toggle: SidebarSettingToggle, next: boolean) => void
}) {
const { toggles, prefs, onToggle } = props
return (
<div className={css.popupRows}>
{toggles.map(toggle => {
const title = textOf(toggle.title)
return (
<label key={toggle.key} className={css.popupRow}>
<span className={css.rowText}>
<span className={css.title}>{title}</span>
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
</span>
<input
type="checkbox"
className={css.toggle}
checked={prefBool(prefs, toggle.key)}
aria-label={title}
onChange={event => { onToggle(toggle, event.currentTarget.checked) }}
/>
</label>
)
})}
</div>
)
}
/**
* Render the Side card preferences section.
* @param props - composed slot props (runtime share + injected store/service).
* @returns the section element tree.
*/
export function SideCardSection({ store, service }: SideCardSectionProps) {
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
const [error, setError] = useState<string | null>(null)
// Which feature's secondary settings popup is open (null = closed).
const [settingsFor, setSettingsFor] = useState<TabDescriptor | null>(null)
// The declarative inventory: the registered tab types and file viewers.
// Local state + service.subscribe (registry changes are rare — plugin
// load/unload — so a plain effect is enough; no external-store ceremony).
const [tabs, setTabs] = useState<TabDescriptor[]>(() => [...service.getTabs()].sort(tabOrder))
const [viewers, setViewers] = useState<FileViewerDescriptor[]>(() => [...service.getFileViewers()].sort(viewerOrder))
useEffect(() => service.subscribe(() => {
setTabs([...service.getTabs()].sort(tabOrder))
setViewers([...service.getFileViewers()].sort(viewerOrder))
}), [service])
// 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: Record<string, unknown>): 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))
}
/** Optimistically flip one boolean pref, then commit (revert on failure). */
const togglePref = (patch: Record<string, unknown>): void => {
const previous = prefs
setPrefs({ ...previous, ...patch } as SidebarPrefs)
setError(null)
void commit(patch).then(outcome => applyOutcome(previous, outcome))
}
const onToggle = (next: boolean): void => {
togglePref({ openByDefault: next })
}
/** Flip one per-tab enable switch (merge into the tabsEnabled map). */
const onToggleTab = (id: string, next: boolean): void => {
togglePref({ tabsEnabled: { ...prefs.tabsEnabled, [id]: next } })
}
/** Flip one per-viewer enable switch (merge into the viewersEnabled map). */
const onToggleViewer = (id: string, next: boolean): void => {
togglePref({ viewersEnabled: { ...prefs.viewersEnabled, [id]: next } })
}
/** Flip one declaratively-declared toggle (a SidebarPrefs boolean field). */
const onToggleSetting = (toggle: SidebarSettingToggle, next: boolean): void => {
togglePref({ [toggle.key]: next })
}
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))
}
/**
* One SMALL toggle card for the responsive inventory grid: the card's main
* area is the switch (click to flips, visual state IS the state), the
* check badge sits at the far right, and a feature that declares related
* settings carries a gear corner button opening its settings popup.
*/
const renderCard = (props: {
title: string
desc: string
icon?: ReactNode
enabled: boolean
onToggle: (next: boolean) => void
/** A feature with declared related settings shows the gear corner button. */
onOpenSettings?: () => void
}) => {
const hasSettings = props.onOpenSettings !== undefined
return (
<div
className={clsx(css.card, props.enabled && css.cardOn, hasSettings && css.cardWithGear)}
>
<button
type="button"
className={css.cardMain}
aria-pressed={props.enabled}
title={props.desc}
onClick={() => { props.onToggle(!props.enabled) }}
>
<span className={css.cardTop}>
{props.icon !== null && props.icon !== undefined && (
<span className={css.cardIcon}>{props.icon}</span>
)}
<span className={css.cardTitle}>{props.title}</span>
{props.enabled && (
<span className={css.cardCheck}>
<IconCheckOutline16 size={14} />
</span>
)}
</span>
<span className={css.cardDesc}>{props.desc}</span>
</button>
{hasSettings && (
<button
type="button"
className={css.cardGear}
aria-label={`${props.title} ${t('settingsPopup')}`}
title={t('settingsPopup')}
onClick={props.onOpenSettings}
>
<IconSettingsOutline16 size={14} />
</button>
)}
</div>
)
}
return (
<div className={css.section}>
<div className={css.sectionHeading}>{t('settingsGeneralTitle')}</div>
{renderCard({
title: t('settingsOpenTitle'),
desc: t('settingsOpenDesc'),
icon: <IconPanelLeftOutline16 size={16} />,
enabled: prefs.openByDefault,
onToggle,
})}
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsWidthTitle')}</span>
<span className={css.desc}>{t('settingsWidthDesc')}</span>
</span>
<span className={css.control}>
<Input
type="number"
className={css.percentInput}
value={widthDraft}
min={WIDTH_PERCENT_MIN}
max={WIDTH_PERCENT_MAX}
step={1}
aria-label={t('settingsWidthTitle')}
onChange={event => { setWidthDraft(event.currentTarget.value) }}
onBlur={commitWidth}
onKeyDown={event => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
/>
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
{/* 侧边栏内容: one small card per registered tab type in a responsive
grid; features declaring `settings.toggles` open their settings in
the popup (gear corner button) instead of nested inline rows. */}
<div className={css.sectionHeading}>{t('settingsTabsTitle')}</div>
<div className={css.grid}>
{tabs.map(tab => (
<Fragment key={tab.id}>
{renderCard({
title: textOf(tab.title),
desc: tab.id,
icon: iconOf(tab.icon, 16),
enabled: prefs.tabsEnabled[tab.id] !== false,
onToggle: (next) => { onToggleTab(tab.id, next) },
// The settings gear only while the feature is enabled: its
// related settings are dormant while the feature is off.
onOpenSettings: prefs.tabsEnabled[tab.id] !== false
&& (tab.settings?.toggles?.length ?? 0) > 0
? () => { setSettingsFor(tab) }
: undefined,
})}
</Fragment>
))}
</div>
{/* 文件预览: one small card per registered file viewer. */}
<div className={css.sectionHeading}>{t('settingsViewersTitle')}</div>
<div className={css.grid}>
{viewers.map(viewer => (
<Fragment key={viewer.id}>
{renderCard({
title: textOf(viewer.title) || viewer.id,
desc: viewer.exts.length === 0 ? t('settingsViewerCatchAll') : viewer.exts.join(' · '),
icon: iconOf(viewer.icon, 16),
enabled: prefs.viewersEnabled[viewer.id] !== false,
onToggle: (next) => { onToggleViewer(viewer.id, next) },
})}
</Fragment>
))}
</div>
{/* The secondary settings popup: a feature's declared related settings
as native checkbox rows (Modal chrome is the app's own). Mounted
only while a feature is open — the Modal primitive runs hooks
unconditionally, so a closed-but-mounted Modal would break SSR
(and the renderToString spec) under the test dual-react split. */}
{settingsFor !== null && (
<Modal
open
onClose={() => { setSettingsFor(null) }}
title={textOf(settingsFor.title)}
closeLabel={t('close')}
>
<FeatureSettingsRows
toggles={settingsFor.settings?.toggles ?? []}
prefs={prefs}
onToggle={onToggleSetting}
/>
</Modal>
)}
{error !== null && (
<div className={css.error} role="alert">
{error}
</div>
)}
</div>
)
}
+458
View File
@@ -0,0 +1,458 @@
/**
* The sidebar shell: a fixed-position right panel portalled onto
* document.body (the core AppFrame owns the left sidebar / center / details
* columns and has no right-side hole for plugins), plus the edge toggle
* button. The panel width drags from its left edge; the whole layout lives
* in the per-session store, so switching conversations swaps the sidebar.
*
* The shell binds the workbench actions to the store and dispatches tab
* content to the four views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer).
*/
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, SidebarSessionList } from '../context-types.ts'
import {
agentUuidOf, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab,
reconcileAgentTerminals,
resizeSplit, setWidth, toggleExpanded, togglePanel,
type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { relativeTo } from './paths.ts'
import { OrphanedTab } from './OrphanedTab.tsx'
import { detectNewDirectSubagent } from './subagent-detect.ts'
import { t } from './locales.ts'
import { api, type SessionScope } from './api.ts'
import css from './sidebar.module.css'
/** How many consecutive reconnect failures stop the agent-terminals push loop
* (mirror of the terminal view's own cap; the loop restarts on session switch). */
const FAILURE_LIMIT = 3
/** Render the content of one tab (dispatched by type). */
function TabContent(props: {
tab: SidebarTab
sessionId: string
cwd: string | undefined
expanded: string[]
onToggleDir: (path: string) => void
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
/** Open a diff tab from the git panel (placement handled by the store). */
onOpenDiff: (tab: SidebarTab) => void
}) {
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props
const scope = { sessionId, cwd }
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) {
return <OrphanedTab ctx={ctx} store={store} scope={scope} tab={tab} visible={visible} />
}
return descriptor.component({
ctx, store, scope, tab, visible, expanded,
onToggleDir, onReferenceFile, onOpenDiff, onSubagentJump,
})
}
/** The + menu options for the current state, driven by the tab registry.
* Hidden tabs (editor/diff) never show; `available` returning false shows
* a disabled row (e.g. terminal at capacity) instead of hiding the option.
* Tabs the user disabled in the side card settings are filtered out
* entirely — re-enabling them is the settings page's job. */
function buildNewTabOptions(state: SidebarState, ctx: Context, scope: SessionScope): NewTabOption[] {
const service = ctx.betterSidebar
if (service === undefined) return []
return service.getTabs()
.filter(d => !d.hidden && service.isTabEnabled(d.id))
.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
.map(d => ({
id: d.id,
label: typeof d.title === 'function' ? d.title() : d.title,
disabled: !(d.available?.(ctx, scope, state) ?? true),
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
}))
}
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { ctx, store } = props
// Current conversation (the sessions list feed).
const sessionList = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
)
const current = sessionList.current
// Per-session sidebar state.
const snapshot = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot(), [store]),
)
useEffect(() => { store.setSession(current) }, [current, store])
const state = snapshot.state
const sessionId = snapshot.sessionId
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
// While the session's header is still hydrating (or the session is blank),
// the list summary may carry no cwd; ask the host once (it falls back to
// the process cwd) so the explorer root and terminal cwd are real from
// first paint instead of showing "no session".
const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
useEffect(() => {
setFetchedCwd(undefined)
if (sessionId === undefined || summaryCwd !== undefined) return
let cancelled = false
api.sessionCwd({ sessionId })
.then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
.catch(() => { /* the explorer/git rows surface their own errors */ })
return () => { cancelled = true }
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
/**
* Agent terminals push: subscribe to the host's live list of agent-owned
* terminals for this session (created by the model through the
* `terminal_create` tool). The host pushes a JSON array on every
* create / close / exit; the sidebar reconciles the list into tabs
* (id `agent:<uuid>`, title from the agent). A disconnected socket
* retries with a short backoff so a refresh or transient drop reattaches
* the same shell without losing the agent's work — capped like the
* terminal view's own reconnect loop, so a refused endpoint never spins
* forever (the next session switch restarts the loop).
* While the terminal tab type is disabled in settings, pushes are
* ignored (no auto-added tabs); re-enabling makes the next push converge.
*/
useEffect(() => {
if (sessionId === undefined) return
let socket: WebSocket | null = null
let retry: number | undefined
let closed = false
let failures = 0
const connect = (): void => {
if (closed) return
const url = new URL('/sidebar/ws/agent-terminals', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = new URLSearchParams({ sessionId }).toString()
socket = new WebSocket(url.toString())
socket.onmessage = (event) => {
if (typeof event.data !== 'string') return
try {
const list = JSON.parse(event.data) as Array<{ uuid: string; title: string; command: string; exited: boolean }>
if (!Array.isArray(list)) return
store.reduce(s => ctx.betterSidebar?.isTabEnabled('terminal') === false
? s
: reconcileAgentTerminals(s, list))
} catch {
// Malformed push: ignore (the next push will reconcile).
}
}
socket.onclose = () => {
if (closed) return
failures += 1
if (failures >= FAILURE_LIMIT) {
console.error('[dsh-better-sidebar] agent-terminals connection failed; stopping reconnect loop', sessionId)
return
}
retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => { socket?.close() }
}
connect()
return () => {
closed = true
window.clearTimeout(retry)
socket?.close()
}
}, [sessionId, store])
/**
* Subagent auto-activation: the moment the current conversation spawns its
* FIRST direct subagent (a 0 → N transition on the list feed), the "auto
* open" pref is on, and the Subagent tab type is enabled in settings,
* 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
if (ctx.betterSidebar?.isTabEnabled('subagent') === false) return
store.reduce(s => s.panelOpen ? s : togglePanel(s))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionList, sessionId, store, ctx])
/**
* Topology jump-back: clicking a subagent node on the Subagent page calls
* the official `openSubagent`, which switches the sidebar to that child
* 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 => s.panelOpen ? s : togglePanel(s))
ctx.betterSidebar?.openTab({ type: 'subagent', title: t('subagent') })
}, [sessionId, store, ctx])
// Panel width drag (left edge strip).
const widthDrag = useRef({ startX: 0, startWidth: 0 })
const [draggingWidth, setDraggingWidth] = useState(false)
// Layout push: the app shell gives up the panel's width while it is open
// (0 while collapsed), so the conversation and input bar are squeezed
// instead of covered. The margin is capped at the viewport so a stale
// persisted width (e.g. fullscreen on a bigger window) can never crush
// the app shell to zero. Dragging disables the layout transition.
useEffect(() => {
const width = snapshot.state?.panelOpen === true
? Math.min(snapshot.state.width, window.innerWidth)
: 0
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
}, [snapshot.state?.panelOpen, snapshot.state?.width])
useEffect(() => {
if (draggingWidth) document.body.setAttribute('data-dsh-sidebar-dragging', '')
else document.body.removeAttribute('data-dsh-sidebar-dragging')
}, [draggingWidth])
// Whether the panel currently fills the viewport (fullscreen expansion).
const fullscreen = state !== undefined && window.innerWidth - state.width < 8
const actions: WorkbenchActions = useMemo(() => ({
closeTab: (paneId, tabId) => {
// A closed terminal releases its pty immediately — including when its
// socket is mid-reconnect, where the unmount close frame never reaches
// the host and the process would hold the quota until the grace ends.
// Agent terminals (tabId `agent:<uuid>`) close through a different
// host route: the WS close frame is the primary path (sent by
// TerminalView on unmount), and the agent-pty.close HTTP route is the
// fallback when the WS is down.
const current = store.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
store.reduce(s => closeTab(s, paneId, tabId))
if (tab?.type === 'terminal') {
if (isAgentTabId(tabId)) {
const uuid = agentUuidOf(tabId)
void api.agentPtyClose(uuid).catch(() => { /* the host may already have released it */ })
} else if (sessionId !== undefined) {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
}
}
},
activateTab: (paneId, tabId) => {
store.reduce(s => ({
...s,
activePane: paneId,
splits: mapLeaf(s.splits, paneId, (leaf) => {
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
}),
}))
},
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
},
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
store.reduce((s) => {
let index = -1
const source = leafWithTab(s.splits, beforeTabId)
if (source !== undefined && source.id === toPane) {
index = source.tabs.findIndex(tab => tab.id === beforeTabId)
}
return moveTab(s, payload.paneId, payload.tabId, toPane, index)
})
},
resizeSplit: (splitId, index, deltaFrac) => {
store.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) }))
},
}), [store, sessionId, cwd])
/**
* The explorer's @-reference button: append `@<relative path>` to the
* session's composer draft (space-separated). The conversation service is
* resolved lazily through `ctx.get` (the inject-free read — the app's own
* plugins read 'conversation' the same way); a missing service or scope
* degrades to a logged no-op, never a crash. Defined above the no-session
* early return — a hook must never sit behind a conditional return
* (React counts hooks per render).
*/
const referenceInChat = useCallback((path: string): void => {
if (sessionId === undefined) return
try {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) return
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) return
const input = conversation.input.for(actx)
const mention = `@${relativeTo(cwd ?? '', path)}`
const draft = input.state.getSnapshot().draft
input.setDraft(draft.trim() === '' ? mention : `${draft} ${mention}`)
} catch (error) {
console.warn('[dsh-better-sidebar] reference insert failed:', error)
}
}, [ctx, sessionId, cwd])
if (state === undefined || sessionId === undefined) {
return (
<div className={css.toggleRail}>
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelLeftOutline16 size={16} />
</button>
</Tooltip>
</div>
)
}
const onNewTab = (optionId: string): void => {
const service = ctx.betterSidebar
const descriptor = service?.getTab(optionId)
if (descriptor === undefined) return
const title = typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title
service.openTab({ type: optionId, title })
}
/**
* The explorer's @-reference button: append `@<relative path>` to the
* session's composer draft (space-separated). Resolves the session-scope
* ctx and the conversation input service at click time; a missing service
* or scope degrades to a logged no-op, never a crash.
*/
/**
* 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). The pane id travels
* with the tab so diff tabs can split below their source pane.
*/
const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => (
<TabContent
tab={tab}
sessionId={sessionId}
cwd={cwd}
expanded={state.expanded}
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
visible={state.panelOpen && active}
onSubagentJump={(childSessionId) => { subagentJumpRef.current = childSessionId }}
onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }}
/>
)
return (
<>
{!state.panelOpen && (
<div className={css.toggleRail}>
<Tooltip label={t('expand')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={t('expand')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconPanelLeftOutline16 size={16} />
</button>
</Tooltip>
</div>
)}
{/*
The panel stays mounted while collapsed (hidden off-screen) so the
slide in/out can animate; visibility hides it after the slide settles.
*/}
<div
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: Math.min(state.width, window.innerWidth) }}
data-dragging={draggingWidth || undefined}
>
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
widthDrag.current = { startX: event.clientX, startWidth: state.width }
setDraggingWidth(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
setDraggingWidth(false)
}}
/>
<div className={css.panelHeader}>
<button
type="button"
className={css.iconButton}
aria-label={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
onClick={() => {
const viewport = window.innerWidth
store.reduce(s => setWidth(s, fullscreen
? Math.max(280, Math.round((viewport - 320) / 2))
: viewport))
}}
>
<IconFullscreenOutline16 />
</button>
<span className={css.panelTitle}>
{t('explorer')}
{cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''}
</span>
<button
type="button"
className={css.iconButton}
aria-label={t('collapse')}
title={t('collapse')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconChevronRightOutline14 />
</button>
</div>
<div className={css.panelBody}>
<Workbench
state={state}
newTabOptions={buildNewTabOptions(state, ctx, { sessionId, cwd })}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={(tab) => {
const descriptor = ctx.betterSidebar?.getTab(tab.type)
if (descriptor === undefined) return null
return typeof descriptor.icon === 'function' ? descriptor.icon(14) : descriptor.icon
}}
/>
</div>
</div>
</>
)
}
+275
View File
@@ -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);
}
+584
View File
@@ -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>
)
}
+188
View File
@@ -0,0 +1,188 @@
/**
* The tab strip of one pane: tabs capped at TAB_MAX_WIDTH (ellipsized),
* overflow scrolls horizontally, a close button per tab, a four-way split
* button cluster, and the + menu that opens new tabs (explorer / git /
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* dropping on the strip background appends to this pane.
*/
import { useEffect, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconCloseFill14, IconPlusOutline16, Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTab } from './state.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** One + menu option. */
export interface NewTabOption {
id: string
label: string
disabled?: boolean
/** Leading icon (Menu row). */
icon?: ReactNode
}
/** Drag payload for tab moves (HTML5 DnD dataTransfer). */
export const TAB_DRAG_TYPE = 'application/x-dsh-tab'
export interface TabDragPayload {
tabId: string
paneId: string
}
export function serializeDrag(payload: TabDragPayload): string {
return JSON.stringify(payload)
}
export function parseDrag(raw: string): TabDragPayload | null {
try {
const parsed = JSON.parse(raw) as TabDragPayload
if (typeof parsed.tabId === 'string' && typeof parsed.paneId === 'string') return parsed
return null
} catch {
return 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[]
active: string | null
onActivate: (tabId: string) => void
onClose: (tabId: string) => void
onNewTab: (optionId: string) => void
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
/** Icon resolver for tab labels (reads from the tab descriptor registry). */
getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab, getTabIcon,
} = 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)}
onDragOver={(event) => {
// The strip owns drops on itself (merge into this pane); stopping
// propagation keeps the pane root from also running its edge-zone
// handler on the same drop.
event.preventDefault()
event.stopPropagation()
setDragOver(true)
}}
onDragLeave={() => { setDragOver(false) }}
onDrop={(event) => {
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)
}}
>
<div className={css.tabList}>
{tabs.map(tab => (
<div
key={tab.id}
className={clsx(css.tab, active === tab.id && css.tabActive)}
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)
}}
onClick={() => { onActivate(tab.id) }}
onAuxClick={(event) => {
// Middle-click closes the tab (and suppresses autoscroll).
if (event.button === 1) {
event.preventDefault()
onClose(tab.id)
}
}}
>
{getTabIcon?.(tab) ?? null}
<span className={css.tabTitle}>{tab.title}</span>
<button
type="button"
className={css.tabClose}
aria-label={t('close')}
onClick={(event) => {
event.stopPropagation()
onClose(tab.id)
}}
>
<IconCloseFill14 />
</button>
</div>
))}
{/*
The + sits immediately after the rightmost tab (sticky at the
right edge of the scrollport when the tabs overflow, so it stays
reachable no matter how many tabs are open).
*/}
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={newTabOptions.map(option => ({
id: option.id,
label: option.label,
...(option.disabled === true ? { disabled: true } : {}),
...(option.icon !== undefined ? { icon: option.icon } : {}),
}))}
onSelect={(id) => {
onNewTab(id)
setMenuOpen(false)
}}
portal
align="end"
anchor={(
<button
type="button"
className={css.tabBarPlus}
aria-label={t('newTab')}
title={t('newTab')}
onClick={() => { setMenuOpen(v => !v) }}
>
<IconPlusOutline16 />
</button>
)}
/>
</div>
</div>
)
}
+233
View File
@@ -0,0 +1,233 @@
/**
* The interactive terminal: xterm.js over a WebSocket to the host pty.
* The host replays the session's transcript on connect, then streams live
* output; input frames are raw text, resize frames are JSON with
* type:"resize". Transient disconnects (page refresh, host restart) reconnect
* automatically; a server-side refusal (close code 1011 with a reason, e.g.
* a failed pty spawn) stops the loop and shows the reason with a manual
* retry, and repeated unreasoned failures surface the close code after three
* attempts, so the banner never spins forever.
*
* Two attach modes share one upgrade endpoint:
* - `tabId` starting with `agent:` is an agent-owned terminal (created by
* the `terminal_create` tool). The uuid is the suffix after `agent:`; the
* view connects with `?uuid=...`. A close frame kills the pty (the agent's
* terminal closes when the user closes the tab); a bare socket drop
* leaves the pty alive (the agent owns the lifetime).
* - Any other `tabId` is a UI-tab terminal (the user created it from the +
* menu). The view connects with `?tab=...&sessionId=...&cwd=...`. A close
* frame schedules a 0-ms close; a bare socket drop gets the host's
* reconnect grace.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from 'xterm'
import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import { agentUuidOf, isAgentTabId, type SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */
const FAILURE_LIMIT = 3
/**
* Curated ANSI palettes for the terminal. The surface colors (background,
* foreground, cursor, selection) ride the theme tokens so the terminal
* blends with the panel in both schemes; the 16 ANSI colors are the same
* designed palettes the app's code surfaces use (one-dark family for dark,
* one-light family for light), read live so a scheme flip re-themes in
* place.
*/
const ANSI_DARK: Record<string, string> = {
black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
brightCyan: '#56b6c2', brightWhite: '#ffffff',
}
const ANSI_LIGHT: Record<string, string> = {
black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
brightCyan: '#0997b3', brightWhite: '#fafafa',
}
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
function xtermTheme(): ITheme {
const dark = isDarkScheme()
const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
return {
background,
foreground,
cursor: foreground,
cursorAccent: background,
selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
...(dark ? ANSI_DARK : ANSI_LIGHT),
}
}
export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
const { scope, tabId, store } = props
const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false)
const [fatal, setFatal] = useState<string | null>(null)
const [lastUrl, setLastUrl] = useState<string | null>(null)
const connectRef = useRef<(() => void) | null>(null)
useEffect(() => {
const host = hostRef.current
if (host === null) return
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
allowTransparency: true,
convertEol: false,
scrollback: 4000,
theme: xtermTheme(),
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
fit.fit()
// Re-theme in place when the app's scheme flips (tokens + palette).
const applyTheme = (): void => {
term.options.theme = xtermTheme()
term.refresh(0, term.rows - 1)
}
const schemeSub = subscribeColorScheme(applyTheme)
let socket: WebSocket | null = null
let closed = false
let retry: number | undefined
let failures = 0
const wsUrl = (): string => {
const url = new URL('/sidebar/ws/terminal', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
// Agent terminals attach by uuid (the host looks them up in the agent
// pty registry); UI-tab terminals attach by sessionId+tab (the host
// uses the UI-tab pty manager). Same upgrade endpoint, different query.
if (isAgentTabId(tabId)) {
url.search = new URLSearchParams({ uuid: agentUuidOf(tabId) }).toString()
} else {
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
url.search = params.toString()
}
// Same construction the app's own downlink WebSockets use (new URL
// over location.origin + protocol swap): whatever the environment
// does to the app's websockets applies identically here.
return url.toString()
}
const sendResize = (): void => {
if (socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
}
const connect = (): void => {
if (closed) return
const url = wsUrl()
setLastUrl(url)
socket = new WebSocket(url)
socket.onopen = () => {
failures = 0
setConnected(true)
setFatal(null)
sendResize()
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') term.write(event.data)
}
socket.onclose = (event) => {
setConnected(false)
// A server-side refusal carries a close code + reason; retrying it
// forever would only spin the banner, so surface it with a retry.
if (event.code === 1011 && event.reason !== '') {
setFatal(event.reason)
return
}
// Unreasoned drops (upgrade rejected, host down, mid-handshake
// refusal) normally recover on the next attempt; after a few
// consecutive failures stop spinning and show the close code.
failures += 1
if (failures >= FAILURE_LIMIT) {
const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
console.error('[dsh-better-sidebar] terminal connection failed:', event.code, event.reason, url)
setFatal(`${t('terminalConnectFailed')}${detail}`)
return
}
if (!closed) retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => {
socket?.close()
}
}
connectRef.current = connect
const inputSub = term.onData((data) => {
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
})
const observer = new ResizeObserver(() => {
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
})
observer.observe(host)
connect()
return () => {
closed = true
window.clearTimeout(retry)
observer.disconnect()
schemeSub()
inputSub.dispose()
// The close frame tells the host the owning tab is GONE (immediate
// pty release). A bare unmount — conversation switch, re-render,
// page unload — leaves the tab open, so the socket drop alone hands
// the process to the host's reconnect grace: switching back or
// refreshing reattaches the SAME shell instead of respawning one.
// (The host respawns on its own when the authoritative cwd changed.)
// Agent terminals follow the same rule: a close frame kills the pty
// (the user closed the sidebar tab); a bare socket drop leaves it
// alive (the agent owns the lifetime).
if (!store.tabOpen(scope.sessionId, tabId)
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'close' }))
}
socket?.close()
term.dispose()
connectRef.current = null
}
}, [scope.sessionId, scope.cwd, tabId, store])
return (
<div className={css.terminalWrap}>
{fatal !== null && (
<div className={css.terminalBanner}>
{t('terminalError')}: {fatal}
{lastUrl !== null && <div className={css.terminalBannerUrl}>{lastUrl}</div>}
<button
type="button"
className={css.terminalRetry}
onClick={() => { setFatal(null); connectRef.current?.() }}
>
{t('terminalRetry')}
</button>
</div>
)}
{fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
<div ref={hostRef} className={css.terminal} />
</div>
)
}
+193
View File
@@ -0,0 +1,193 @@
/**
* The code/markdown file viewer: a CodeMirror 6 editor with line wrapping,
* syntax highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S
* save, and a preview/edit toggle for markdown files. Registered as the
* `code` (catch-all) and `markdown` built-in viewers; the editor tab host
* fetches the content through the fsRead strategy and passes it in props,
* so this component never fetches or dispatches — it only edits.
*
* The toolbar (mode toggle / dirty dot / save / status) renders as its own
* row below the host's title bar, VSCode-style.
*/
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
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 } 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 type { FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
export function TextEditor(props: FileViewerProps) {
const { scope, path, viewerId, content, truncated } = props
const [mode, setMode] = useState<ViewMode>('preview')
/** The editor's current text (null while clean); preview renders this. */
const [draft, setDraft] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
const hostRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<CodeMirrorView | null>(null)
const savingRef = useRef(false)
/** The theme compartment of the current view (reconfigured on scheme flip). */
const themeCompRef = useRef<CmThemeCompartment | null>(null)
/** The app's resolved color scheme; the editor re-themes in place on flips. */
const [dark, setDark] = useState(() => isDarkScheme())
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
// A new file (tab switch) starts clean: fresh preview mode, no draft.
useEffect(() => {
setMode('preview')
setDraft(null)
setDirty(false)
setSaveState('idle')
}, [content])
// Create the CodeMirror editor once the content is loaded. The view owns
// the document; React only tracks dirty/draft state through the update
// listener. For markdown the view stays mounted while previewing (hidden),
// so unsaved edits survive the preview/edit toggle. The theme + syntax
// colors live in a compartment so a scheme flip reconfigures only that
// part — the document, undo history and scroll position survive.
useEffect(() => {
if (content === undefined) return
const host = hostRef.current
if (host === null) return
const language = languageForPath(path)
const themeComp = new CmThemeCompartment()
themeCompRef.current = themeComp
const state = EditorState.create({
doc: content,
extensions: [
CodeMirrorView.lineWrapping,
lineNumbers(),
history(),
EditorState.tabSize.of(2),
CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
cmSurfaceTheme,
themeComp.of(dark),
...(language !== null ? [language] : []),
CodeMirrorView.updateListener.of((update) => {
if (update.docChanged) {
setDraft(update.state.doc.toString())
setDirty(true)
}
}),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => { save(); return true },
},
...defaultKeymap,
...historyKeymap,
]),
],
})
const view = new CodeMirrorView({ state, parent: host })
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
themeCompRef.current = null
}
// The keymap's save() reads live refs; scope/path are stable for a
// tab's lifetime, and the dark flip is handled by the reconfigure
// effect below (recreating the view here would drop the draft).
}, [content, path])
// Scheme flip: re-theme in place (the compartment holds only the
// scheme-dependent extensions; everything else is untouched).
useEffect(() => {
const view = viewRef.current
const themeComp = themeCompRef.current
if (view === null || themeComp === null) return
view.dispatch({ effects: themeComp.reconfigure(dark) })
}, [dark])
// The editor may have been display:none while previewing; re-measure when
// it becomes visible again (CodeMirror sizes itself on reveal).
useEffect(() => {
if (mode === 'edit') viewRef.current?.requestMeasure()
}, [mode])
const save = (): void => {
const view = viewRef.current
if (view === null || savingRef.current) return
savingRef.current = true
setSaveState('saving')
api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
savingRef.current = false
setDraft(null)
setDirty(false)
setSaveState('saved')
}).catch(() => {
savingRef.current = false
setSaveState('failed')
})
}
const markdown = viewerId === 'markdown'
const editable = content !== undefined
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
return (
<>
<div className={css.editorHeader}>
{markdown && (
<div className={css.editorModeToggle}>
<button
type="button"
className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
onClick={() => { setMode('preview') }}
>
{t('preview')}
</button>
<button
type="button"
className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
onClick={() => { setMode('edit') }}
>
{t('edit')}
</button>
</div>
)}
{dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
{editable && (
<button
type="button"
className={css.iconButton}
aria-label={t('save')}
title={`${t('save')} (Ctrl/Cmd+S)`}
onClick={save}
>
<IconCheckOutline16 />
</button>
)}
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
</div>
{editable && (
<>
{truncated === true && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
<div
className={clsx(css.editorCm, markdown && mode === 'preview' && css.editorCmHidden)}
ref={hostRef}
/>
</>
)}
{markdown && mode === 'preview' && (
<div className={css.editorMd}>
<MarkdownText text={draft ?? content ?? ''} />
</div>
)}
</>
)
}
+174
View File
@@ -0,0 +1,174 @@
/**
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
* `/sidebar/api/<method>` with the sessionId and — when known — the session's
* cwd from the client's own list summary. The host prefers its attached
* session header and uses the summary cwd only while the session is still
* hydrating at page load (a detached session would otherwise fail the
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
/** One wire failure. */
export class SidebarApiError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message)
}
}
/** Explorer row (host fs-tree shape). */
export interface FsEntry {
name: string
path: string
isDir: boolean
hidden: boolean
}
/** Git status entry (host git shape). */
export interface GitStatusEntry {
path: string
xy: string
}
/** Git status snapshot. */
export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
}
/** One git log row. */
export interface GitLogEntry {
/** Short hash (7+ chars, display). */
hash: string
/** Full 40-char hash (advanced operations). */
hashFull: string
subject: string
author: string
/** ISO 8601 author date (`%ai`). */
date: string
/** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
refs: string
}
/** Text read result. */
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
/** Binary read result (no content; images load through the media route).
* `head` carries the first bytes (base64) for viewer detect sniffing. */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response
try {
response = await fetch(`/sidebar/api/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal,
})
} catch (error) {
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
}
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
= await response.json().catch(() => null)
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
throw new SidebarApiError(
parsed?.error?.code ?? 'http',
parsed?.error?.message ?? `HTTP ${response.status}`,
)
}
return parsed.value as T
}
/** One request's session scope: the conversation id plus its cwd when known. */
export interface SessionScope {
sessionId: string
/** The session's working directory from the client list summary (optional). */
cwd?: string
}
/** Fold a scope into a JSON payload ({cwd} only when present). */
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
}
/** The sidebar API surface (session scope threaded through every call). */
export const api = {
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) =>
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
gitStage: (scope: SessionScope, path?: string) =>
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
gitUnstage: (scope: SessionScope, path?: string) =>
call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
gitCommit: (scope: SessionScope, message: string) =>
call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
gitCheckout: (scope: SessionScope, branch: string) =>
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
/** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', scopePayload(scope, {
...(count !== undefined ? { count } : {}),
...(skip !== undefined ? { skip } : {}),
}), signal),
/** Full patch text of one commit (diff display for the history rows). */
gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) =>
call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal),
/** Discard the worktree changes of one file (the index is untouched). */
gitDiscard: (scope: SessionScope, path: string) =>
call<{ ok: true }>('git.discard', scopePayload(scope, { path })),
/** Revert one commit onto the current branch. */
gitRevert: (scope: SessionScope, hash: string) =>
call<{ ok: true }>('git.revert', scopePayload(scope, { hash })),
/** Cherry-pick one commit onto the current branch. */
gitCherryPick: (scope: SessionScope, hash: string) =>
call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })),
/** Release a terminal's process immediately (tab closed; the WS close frame
* may be unreachable while the socket is down, so the host also accepts
* this explicit route). */
ptyClose: (scope: SessionScope, tab: string) =>
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
/** Release an agent terminal by uuid (tab closed while WS was down). */
agentPtyClose: (uuid: string) =>
call<{ ok: true }>('agent-pty.close', { uuid }),
/** 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). */
export function mediaUrl(scope: SessionScope, path: string): string {
return fileUrl(scope, path, false)
}
/** Absolute URL of the download route: serves raw bytes (binary-safe) with
* `Content-Disposition: attachment`, so the browser saves the file. */
export function downloadUrl(scope: SessionScope, path: string): string {
return fileUrl(scope, path, true)
}
/** Shared URL builder for the /sidebar/file route (media vs download). */
function fileUrl(scope: SessionScope, path: string, download: boolean): string {
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
if (download) params.set('download', '1')
return `/sidebar/file?${params.toString()}`
}
+23
View File
@@ -0,0 +1,23 @@
/**
* The "no preview — download instead" pane: shown by the editor host when no
* viewer can render a file (binary without a registered renderer, or the
* `binary-download` strategy) and registered as the `binary-download` viewer
* component so the declarative route and the host fallback share one UI.
*/
import type { ReactNode } from 'react'
import type { SessionScope } from './api.ts'
import { downloadUrl } from './api.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
export function BinaryDownload(props: { scope: SessionScope; path: string }): ReactNode {
const { scope, path } = props
return (
<div className={css.editorBinary}>
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Built-in registration: the plugin registers its own tab pages and file
* previewers through the same {@link BetterSidebarService} external plugins
* use — eating its own dogfood. The descriptors live next to their feature
* modules (tabs.tsx / viewers.tsx); this module only aggregates them and
* owns the disposer lifecycle (cordis auto-invokes it on fiber disposal,
* HMR-safe).
*/
import type { Context } from '../../context-types.ts'
import type { BetterSidebarService } from '../service.ts'
import { builtinTabs } from './tabs.tsx'
import { builtinViewers } from './viewers.tsx'
/**
* Register all built-in tabs and viewers with the service. Returns a
* disposer that unregisters everything (cordis auto-invokes it on fiber
* disposal). The `ctx` is threaded into tab descriptors that need it
* (EditorHost reads `ctx.betterSidebar` for file-viewer matching).
*/
export function registerBuiltins(ctx: Context, service: BetterSidebarService): () => void {
const disposers: (() => void)[] = []
for (const tab of builtinTabs(ctx)) {
disposers.push(service.registerTab(tab))
}
for (const viewer of builtinViewers()) {
disposers.push(service.registerFileViewer(viewer))
}
return () => {
for (const d of disposers) {
try { d() } catch { /* already disposed */ }
}
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* The 6 built-in tab descriptors: the plugin registers its own pages
* (explorer / git / terminal / subagent / editor / diff) through the same
* {@link BetterSidebarService} external plugins use — eating its own
* dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`) and
* mints `terminal:<n>` ids through `createTab`.
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
import { openSidebarFile } from '../intercept.tsx'
import { ExplorerView } from '../ExplorerView.tsx'
import { EditorHost } from '../EditorHost.tsx'
import { TerminalView } from '../TerminalView.tsx'
import { GitView } from '../GitView.tsx'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { IconTerminalOutline16, IconDiffOutline16 } from '../icons.tsx'
import type { TabDescriptor } from '../service.ts'
/** How many UI-owned terminals may be open at once (agent-owned ones are uncapped). */
export const TERMINAL_LIMIT = 3
/** Count UI-owned terminals (agent:` tabs excluded — they are the model's). */
function uiTerminalCount(state: SidebarState): number {
return allLeaves(state.splits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
}
/** The 6 built-in tab descriptors. */
export function builtinTabs(ctx: Context): readonly TabDescriptor[] {
return [
{
id: 'editor',
title: () => t('editor'),
icon: (size: number) => <IconCodeOutline16 size={size} />,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.path,
component: ({ ctx, store, scope, tab }) => (
<EditorHost ctx={ctx} store={store} scope={scope} path={tab.path ?? ''} title={tab.title} />
),
},
{
id: 'explorer',
title: () => t('explorer'),
icon: (size: number) => <IconFolderOpen16 size={size} />,
order: 10,
single: true,
component: ({ ctx, store, scope, expanded, onToggleDir, onReferenceFile }) => (
<ExplorerView
sessionId={scope.sessionId}
cwd={scope.cwd}
expanded={expanded ?? []}
onToggle={onToggleDir ?? (() => { /* no-op */ })}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onReferenceFile={onReferenceFile ?? (() => { /* no-op */ })}
/>
),
},
{
id: 'git',
title: () => t('git'),
icon: (size: number) => <IconBranchOutline16 size={size} />,
order: 20,
single: true,
component: ({ ctx, store, scope, onOpenDiff }) => (
<GitView
scope={scope}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
/>
),
},
{
id: 'subagent',
title: () => t('subagent'),
icon: (size: number) => <IconThinkOutline16 size={size} />,
order: 30,
single: true,
// Declarative settings: the auto-open switch renders under this row in
// the Side card settings page (the Subagent page's own related setting).
settings: {
toggles: [{
key: 'autoOpenSubagent',
title: () => t('settingsSubagentTitle'),
desc: () => t('settingsSubagentDesc'),
}],
},
component: ({ ctx, scope, visible, onSubagentJump }) => (
<SubagentView
sessionId={scope.sessionId}
ctx={ctx}
active={visible}
onOpenChild={(address) => { onSubagentJump?.(address.childSessionId) }}
/>
),
},
{
id: 'terminal',
title: () => t('terminal'),
icon: (size: number) => <IconTerminalOutline16 size={size} />,
order: 40,
available: (_ctx, _scope, state) => uiTerminalCount(state) < TERMINAL_LIMIT,
// Declarative settings: the model-facing terminal tools switch renders
// under this row in the Side card settings page (the Terminal page's
// own related setting; the host gates the toolset on it independently).
settings: {
toggles: [{
key: 'agentTerminalTools',
title: () => t('settingsToolsTitle'),
desc: () => t('settingsToolsDesc'),
}],
},
createTab: (state) => {
const count = uiTerminalCount(state)
if (count >= TERMINAL_LIMIT) return null
return {
tab: {
id: `terminal:${state.nextTerminal}`,
type: 'terminal',
title: `${t('terminal')} ${state.nextTerminal}`,
},
patch: { nextTerminal: state.nextTerminal + 1 },
}
},
component: ({ scope, tab, store }) => (
<TerminalView scope={scope} tabId={tab.id} store={store} />
),
},
{
id: 'diff',
title: () => t('git'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
order: -1,
hidden: true,
dedupeKey: (tab) => tab.id,
component: ({ scope, tab }) => (
tab.diff === undefined ? null
: <DiffTab sessionId={scope.sessionId} cwd={scope.cwd} diff={tab.diff} />
),
},
]
}
+116
View File
@@ -0,0 +1,116 @@
/**
* The 8 built-in file viewer descriptors: every preview surface is a
* registered viewer (image / pdf / docx / xlsx / pptx / markdown / code /
* binary-download), exactly like external plugins register theirs. The
* `binary-download` viewer sniffs NUL bytes via `detect` for unknown
* binaries and serves doc/xls/ppt by extension; `code` is the catch-all
* (`exts: []`, lowest priority) that claims any file no other viewer did.
*
* Every viewer carries the declarative settings-surface fields — `title`
* and `icon` — so the Side card settings page can render the enable/disable
* inventory without hardcoding (eating our own dogfood).
*/
import { IconCodeOutline16, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { DocxView, XlsxView } from '../office-view.tsx'
import { PdfView } from '../PdfView.tsx'
import { PptxView } from '../PptxView.tsx'
import { TextEditor } from '../TextEditor.tsx'
import { BinaryDownload } from '../binary-download.tsx'
import {
IconImageOutline16,
IconMarkdownOutline16,
IconPdfOutline16,
IconDocxOutline16,
IconXlsxOutline16,
IconPptxOutline16,
} from '../icons.tsx'
import type { FileViewerDescriptor } from '../service.ts'
import { t } from '../locales.ts'
import css from '../sidebar.module.css'
/** The 8 built-in file viewer descriptors. */
export function builtinViewers(): readonly FileViewerDescriptor[] {
return [
{
id: 'image',
title: () => t('viewerImage'),
icon: (size: number) => <IconImageOutline16 size={size} />,
exts: ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico', 'avif'],
fetchStrategy: 'mediaUrl',
component: ({ mediaUrl: url, title }) => (
<div className={css.editorImageWrap}>
<img className={css.editorImage} src={url} alt={title} />
</div>
),
},
{
id: 'pdf',
title: () => t('viewerPdf'),
icon: (size: number) => <IconPdfOutline16 size={size} />,
exts: ['pdf'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<PdfView scope={scope} path={path} title={title} />
),
},
{
id: 'docx',
title: () => t('viewerDocx'),
icon: (size: number) => <IconDocxOutline16 size={size} />,
exts: ['docx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<DocxView scope={scope} path={path} title={title} />
),
},
{
id: 'xlsx',
title: () => t('viewerXlsx'),
icon: (size: number) => <IconXlsxOutline16 size={size} />,
exts: ['xlsx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<XlsxView scope={scope} path={path} title={title} />
),
},
{
id: 'pptx',
title: () => t('viewerPptx'),
icon: (size: number) => <IconPptxOutline16 size={size} />,
exts: ['pptx'],
fetchStrategy: 'mediaUrl',
component: ({ scope, path, title }) => (
<PptxView scope={scope} path={path} title={title} />
),
},
{
id: 'markdown',
title: () => t('viewerMarkdown'),
icon: (size: number) => <IconMarkdownOutline16 size={size} />,
exts: ['md', 'markdown'],
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
},
{
id: 'code',
title: () => t('viewerCode'),
icon: (size: number) => <IconCodeOutline16 size={size} />,
exts: [],
priority: -100,
fetchStrategy: 'fsRead',
component: (props) => <TextEditor {...props} />,
},
{
id: 'binary-download',
title: () => t('viewerBinary'),
icon: (size: number) => <IconDownloadOutline16 size={size} />,
exts: ['doc', 'xls', 'ppt'],
priority: -50,
fetchStrategy: 'binary-download',
// NUL probe: a file whose head bytes contain a NUL is binary — claimed
// before the catch-all code viewer on the head re-match.
detect: (_path, head) => head.includes(0),
component: ({ scope, path }) => <BinaryDownload scope={scope} path={path} />,
},
]
}
+129
View File
@@ -0,0 +1,129 @@
/**
* CodeMirror 6 theme pieces for the sidebar editor. The editor surface
* (background, caret, gutter) rides the DSH theme tokens so it blends with
* the panel in both schemes; only the syntax token colors need concrete
* values, and those come from the same designed palettes the app's code
* surfaces use — the one-dark family for dark, the one-light family for
* light. The scheme flip reconfigures these via a compartment (see
* TextEditor), so the document, undo history and scroll survive re-theming.
*/
import { Compartment } from '@codemirror/state'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags, type Tag } from '@lezer/highlight'
import { EditorView } from '@codemirror/view'
/** Token-driven surface shared by both schemes (pure CSS values). */
export const cmSurfaceTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '13px',
backgroundColor: 'transparent',
color: 'var(--dsw-alias-label-primary)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'var(--ds-font-family-code)',
},
'.cm-content': {
caretColor: 'var(--dsw-alias-label-primary)',
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--dsw-alias-label-tertiary)',
border: 'none',
},
})
/** Scheme-specific surface tints (selection, active line). */
function cmSurfaceTint(dark: boolean): ReturnType<typeof EditorView.theme> {
return EditorView.theme({
'.cm-selectionBackground, .cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
},
'.cm-activeLine, .cm-activeLineGutter': {
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)',
},
})
}
/** One syntax rule: a tag (or tag set) mapped to a concrete color/style. */
interface HighlightRule {
tag: Tag | readonly Tag[]
color?: string
fontStyle?: string
}
/** one-dark syntax palette (mirrors @codemirror/theme-one-dark). */
const HIGHLIGHTS_DARK: HighlightRule[] = [
{ tag: tags.comment, color: '#5c6370', fontStyle: 'italic' },
{ tag: tags.keyword, color: '#c678dd' },
{ tag: tags.string, color: '#98c379' },
{ tag: tags.number, color: '#d19a66' },
{ tag: tags.bool, color: '#d19a66' },
{ tag: tags.atom, color: '#d19a66' },
{ tag: tags.typeName, color: '#e5c07b' },
{ tag: tags.className, color: '#e5c07b' },
{ tag: tags.propertyName, color: '#e06c75' },
{ tag: tags.function(tags.variableName), color: '#61afef' },
{ tag: tags.variableName, color: '#e06c75' },
{ tag: tags.operator, color: '#56b6c2' },
{ tag: tags.tagName, color: '#e06c75' },
{ tag: tags.attributeName, color: '#d19a66' },
{ tag: tags.heading, color: '#e06c75', fontStyle: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontStyle: 'bold' },
{ tag: tags.link, color: '#61afef', fontStyle: 'underline' },
{ tag: tags.meta, color: '#e5c07b' },
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
]
/** one-light syntax palette (the light counterpart of one-dark). */
const HIGHLIGHTS_LIGHT: HighlightRule[] = [
{ tag: tags.comment, color: '#a0a1a7', fontStyle: 'italic' },
{ tag: tags.keyword, color: '#a626a4' },
{ tag: tags.string, color: '#50a14f' },
{ tag: tags.number, color: '#986801' },
{ tag: tags.bool, color: '#0184bc' },
{ tag: tags.atom, color: '#0184bc' },
{ tag: tags.typeName, color: '#c18401' },
{ tag: tags.className, color: '#c18401' },
{ tag: tags.propertyName, color: '#e45649' },
{ tag: tags.function(tags.variableName), color: '#c18401' },
{ tag: tags.variableName, color: '#e45649' },
{ tag: tags.operator, color: '#383a42' },
{ tag: tags.tagName, color: '#e45649' },
{ tag: tags.attributeName, color: '#986801' },
{ tag: tags.heading, color: '#e45649', fontStyle: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontStyle: 'bold' },
{ tag: tags.link, color: '#4078f2', fontStyle: 'underline' },
{ tag: tags.meta, color: '#c18401' },
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
]
/** The scheme-dependent extension pair (surface tint + syntax highlight). */
function cmThemeExtensions(dark: boolean): Array<ReturnType<typeof EditorView.theme>> {
return [
cmSurfaceTint(dark),
syntaxHighlighting(HighlightStyle.define(dark ? HIGHLIGHTS_DARK : HIGHLIGHTS_LIGHT)),
]
}
/**
* A Compartment holding the two scheme-dependent extensions. Created once
* per editor view; a scheme flip dispatches `reconfigure(dark)` on it, so
* the document, undo history, scroll and keymaps survive re-theming.
*/
export class CmThemeCompartment {
private readonly compartment = new Compartment()
/** `of(...)` payload for EditorState.create. */
of(dark: boolean): ReturnType<Compartment['of']> {
return this.compartment.of(cmThemeExtensions(dark))
}
/** Reconfigure for a new scheme. */
reconfigure(dark: boolean): ReturnType<Compartment['reconfigure']> {
return this.compartment.reconfigure(cmThemeExtensions(dark))
}
}
+5
View File
@@ -0,0 +1,5 @@
/** CSS Modules class-map declaration (tsdown compiles .module.css at build). */
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Pure editor-load planning: the decision logic the editor host needs to
* turn a matched file viewer + a host fs.read result into a render action.
* Kept dependency-free (no React, no fetch) so the strategy dispatch is
* unit-testable and the wire contract (head bytes, binary flag) is pinned.
*
* The host flow this module drives:
* 1. `matchFileViewer(path)` picks a viewer by extension/priority.
* 2. `planFirstMatch` dispatches its fetchStrategy.
* 3. An fsRead viewer fetches through the host; `planFsReadOutcome`
* decides what to do with the result — including the head-based
* re-match that lets a `detect` viewer claim a binary the extension
* match could not see (the builtin NUL probe on `binary-download`).
*/
import type { FileViewerDescriptor } from './service.ts'
/** One host fs.read result (mirror of the wire; `head` present when binary). */
export interface FsReadOutcome {
binary: boolean
content: string
truncated: boolean
/** base64 of the first bytes (present on binary reads; sniffing material). */
head?: string
}
/** What the editor host should do next. */
export type EditorLoadAction =
/** No renderer: show the download UI. */
| { kind: 'binary' }
/** Render `viewer`'s component with the carried payload. */
| { kind: 'render'; viewer: FileViewerDescriptor; content?: string; truncated?: boolean; mediaUrl?: string; customData?: unknown }
/** Fetch the file through the host (fsRead strategy). */
| { kind: 'fetchFsRead'; viewer: FileViewerDescriptor }
/** Call the viewer's load() and render with its return value. */
| { kind: 'customLoad'; viewer: FileViewerDescriptor }
/** Decode the host's base64 head bytes into the sniffing buffer. */
export function decodeHead(headBase64: string): Uint8Array {
const binary = atob(headBase64)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
return bytes
}
/**
* Dispatch one matched viewer's fetchStrategy. A missing viewer or a
* `binary-download` strategy both mean "no client-side renderer" → the
* download UI. `mediaUrlOf` builds the media URL for `mediaUrl`/`none`
* strategies (pure, but scope-bound — injected by the host).
*/
export function planFirstMatch(
viewer: FileViewerDescriptor | undefined,
mediaUrlOf: () => string,
): EditorLoadAction {
if (viewer === undefined || viewer.fetchStrategy === 'binary-download') return { kind: 'binary' }
switch (viewer.fetchStrategy) {
case 'mediaUrl':
case 'none':
return { kind: 'render', viewer, mediaUrl: mediaUrlOf() }
case 'custom':
return { kind: 'customLoad', viewer }
case 'fsRead':
return { kind: 'fetchFsRead', viewer }
}
}
/**
* Decide what an fsRead result means for the editor.
* - Text: the first match stands (content is valid for any fsRead viewer).
* - Binary: the host head bytes enable a re-match — a `detect` viewer (e.g.
* a plugin sniffing a binary format) may claim the file. `custom` viewers
* load their own bytes; `mediaUrl`/`none` viewers render the media route;
* an fsRead viewer or nothing cannot render binary → download UI.
*/
export function planFsReadOutcome(
viewer: FileViewerDescriptor,
result: FsReadOutcome,
rematch: (head: Uint8Array) => FileViewerDescriptor | undefined,
mediaUrlOf: () => string,
): EditorLoadAction {
if (!result.binary) {
return { kind: 'render', viewer, content: result.content, truncated: result.truncated }
}
const claimed = result.head === undefined ? undefined : rematch(decodeHead(result.head))
if (claimed !== undefined && claimed.fetchStrategy === 'custom') {
return { kind: 'customLoad', viewer: claimed }
}
if (claimed !== undefined && (claimed.fetchStrategy === 'mediaUrl' || claimed.fetchStrategy === 'none')) {
return { kind: 'render', viewer: claimed, mediaUrl: mediaUrlOf() }
}
return { kind: 'binary' }
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
* icon library has none) and a diff glyph. Per-tab icons live on the tab
* descriptors (`descriptor.icon`), not in a type-keyed switch — the icon
* mapping was registry-ized with the tab types.
*/
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
/**
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
* a rounded frame with a prompt chevron and underscore cursor.
*/
export const IconTerminalOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M4.5 6.25 6.75 8 4.5 9.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.5 10.4h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
/** Diff glyph in the app's outline style: a file frame with a plus and a minus row. */
export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="1.5" width="13" height="13" rx="2.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 5h3M5.5 3.5v3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<path d="M9.5 12.5h2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
// ── File-viewer inventory glyphs (Side card settings page) ────────────────
/** Image viewer glyph: a picture frame with a sun and a mountain. */
export const IconImageOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="5.5" cy="6" r="1.2" stroke="currentColor" strokeWidth="1.5" />
<path d="m3.5 12 3-3 2.25 2.25L11.5 8.5 13 10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
/** PDF viewer glyph: a document frame with the "PDF" label. */
export const IconPdfOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M5 13.5v-3h1.4c.75 0 1.1.32 1.1.85 0 .54-.35.85-1.1.85H5.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.3 13.5v-3h1.05c.8 0 1.35.5 1.35 1.5s-.55 1.5-1.35 1.5z" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11.6 13.5v-3h1.3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg>
)
/** Word viewer glyph: a document frame with a "W". */
export const IconDocxOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 1.5h6.5L13.5 5v9.5h-10z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M9.5 1.5V5h4" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
<path d="M6.2 13.4 7.4 10l1.2 3.4M7.4 10.6l-.35-1.1c-.2-.62.2-1.25.85-1.25h.2c.65 0 1.05.63.85 1.25l-.35 1.1" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.75 10.6 9.2 9.4" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg>
)
/** Excel viewer glyph: a spreadsheet grid. */
export const IconXlsxOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2" width="13" height="12" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M1.5 6h13M1.5 9.5h13M6 6v8M10.5 6v8" stroke="currentColor" strokeWidth="1.25" />
<path d="m3.8 13.2 2-3M5.8 13.2l-2-3" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg>
)
/** PowerPoint viewer glyph: a chart with rising bars. */
export const IconPptxOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 12.5v-3M7 12.5V7M10 12.5V4.5M13 12.5v-1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
/** Markdown viewer glyph: the classic "M with a down arrow" badge. */
export const IconMarkdownOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 10.5V5.5l2 2.5 2-2.5v5M9.5 10.5v-5l2 2.5 2-2.5v5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
+8
View File
@@ -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)
}
+147 -454
View File
@@ -1,471 +1,164 @@
/**
* apes-sidebar client-half entry (Phase 1: the five real `apes:*` tabs).
*
* Loaded through the package.json `dsh.client.inject` declaration (web
* platform). The entry registers the five apes-sidebar tab descriptors —
* `apes:tasks`, `apes:agents`, `apes:apps`, `apes:messages`, `apes:profile` —
* into the better-sidebar host's `ctx.betterSidebar` registry, then disposes
* cleanly on fiber teardown (HMR-safe).
*
* Registration is wrapped in a single `ctx.effect(...)`: the effect body
* registers the i18n dictionaries plus all five tabs and returns the combined
* disposer, which cordis invokes on activation teardown. A missing
* `betterSidebar` (bare DSH, no better-sidebar installed) degrades silently —
* a missing peer must never break the shell.
*
* Type base: the descriptors and the component props are the REAL
* better-sidebar `TabDescriptor` / `TabComponentProps` (type-only import —
* erased at build, never bundled, and it triggers the `declare module
* '@deepseek-ai/cordis'` augmentation so `ctx.betterSidebar` type-checks).
*
* inject list (client-only):
* - betterSidebar: the better-sidebar registry service (peer, optional)
* - locale: the DSH locale runtime (dictionaries register under apesSidebar)
* - sessions: the client session list feed (drives tasks + agents)
* 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 { useSyncExternalStore, type ReactElement, type ReactNode } from 'react'
import type { TabDescriptor, TabComponentProps, Context } from 'dsh-better-sidebar'
import { t, registerLocales, useApesLocale } from './locales.ts'
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { createSidebarStore } from './state.ts'
import { createBetterSidebarService } from './service.ts'
import { registerBuiltins } from './builtins/index.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', 'connection']
/**
* The five tab ids, in + menu order (ascending). Phase 2 adds badge counts
* and auto-collapse on top of these same descriptors.
* Error boundary over the sidebar tree: a render error must never blank the
* whole panel silently — it shows a dismissible error strip and logs the
* stack for diagnosis.
*/
const TABS = {
tasks: 'apes:tasks',
agents: 'apes:agents',
apps: 'apes:apps',
messages: 'apes:messages',
profile: 'apes:profile',
} as const
class SidebarBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
state = { error: null as string | null }
/**
* The cordis Context face this entry resolves against: the better-sidebar
* `Context` (a `CordisContext & SidebarContextShape` intersection that
* carries `sessions`, `locale`, and `betterSidebar`). `betterSidebar` is
* typed required by the peer, but a bare DSH profile lacks it at runtime —
* the apply() guard handles the absence, so we keep the peer's type as-is
* and guard on the value.
*/
type ApesClientContext = Context
/**
* Read the client session list uSES-safely, with `visible`-gated pausing
* (Phase 2 auto-collapse). `useSyncExternalStore` re-subscribes whenever the
* `subscribe` identity changes, so passing a no-op subscribe while the tab is
* not the active-open one stops live session-list churn from re-rendering a
* hidden panel — exactly better-sidebar's "live views pause otherwise"
* contract. The snapshot itself is always read (cheap), so a hidden tab still
* shows its last-known data when re-shown.
*/
const NOOP_SUBSCRIBE = () => () => {}
function useSessionList(ctx: Context, visible: boolean) {
const list = ctx.sessions.list
return useSyncExternalStore(
visible ? list.subscribe : NOOP_SUBSCRIBE,
list.getSnapshot,
list.getSnapshot,
)
}
/** Count of sessions whose agent is currently running (badge source). */
function runningCount(ctx: Context): number {
const { byId } = ctx.sessions.list.getSnapshot()
let n = 0
for (const id in byId) {
if (byId[id]?.running) n++
static getDerivedStateFromError(error: unknown): { error: string } {
return { error: error instanceof Error ? error.message : String(error) }
}
return n
}
/** Count of subagent-origin sessions (badge source for apes:agents). */
function subagentCount(ctx: Context): number {
const { byId } = ctx.sessions.list.getSnapshot()
let n = 0
for (const id in byId) {
if (byId[id]?.origin === 'subagent') n++
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
}
return n
}
/**
* Per-tab state (Phase 2): the apes:tasks panel's `showInactive` preference,
* persisted across reloads through better-sidebar's settings service
* (`ctx.settings.register`). The namespace handle is registered ONCE at
* module scope (registering per render would re-stack schemas); the hook
* reads it uSES-safely and writes back via `update`.
*
* Degrades to the default (`showInactive: true`) when `ctx.settings` is
* absent — a bare DSH profile or a unit-test fake that omits the service.
*/
const TASKS_NS = 'apesSidebar.tasks'
interface TasksPrefs {
showInactive: boolean
}
let tasksPrefsHandle: {
get(): TasksPrefs
watch(cb: (next: TasksPrefs, prev: TasksPrefs) => void): () => void
update(patch: Partial<TasksPrefs>): Promise<void>
} | undefined
function getTasksPrefsHandle(ctx: Context) {
if (tasksPrefsHandle) return tasksPrefsHandle
if (!ctx.settings?.register) return undefined
tasksPrefsHandle = ctx.settings.register<TasksPrefs>(TASKS_NS, null, {
base: { showInactive: true },
applies: 'live',
})
return tasksPrefsHandle
}
const DEFAULT_TASKS_PREFS: TasksPrefs = { showInactive: true }
/**
* Pure row filter for the tasks list: when `showInactive` is false, only
* running sessions render. Exported for unit testing (the hook itself needs
* a render, but this decision is a plain function).
*/
export function filterTasksRows<T extends { running?: boolean }>(
all: T[],
showInactive: boolean,
): T[] {
return showInactive ? all : all.filter((s) => s.running)
}
function useTasksPrefs(ctx: Context): { prefs: TasksPrefs; setShowInactive: (v: boolean) => void } {
const handle = getTasksPrefsHandle(ctx)
const prefs = useSyncExternalStore(
handle ? (cb) => handle.watch(cb) : NOOP_SUBSCRIBE,
handle ? handle.get : () => DEFAULT_TASKS_PREFS,
handle ? handle.get : () => DEFAULT_TASKS_PREFS,
)
return {
prefs,
setShowInactive: (v: boolean) => {
void handle?.update({ showInactive: v })
},
}
}
/** A row in a list view: id, title, and optional flag chips. */
function SessionRow({
title,
running,
current,
subagent,
}: {
title: string
running?: boolean
current?: boolean
subagent?: boolean
}): ReactElement {
return (
<div
data-apes-row
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 6px',
fontSize: 12,
borderRadius: 4,
}}
>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title}
</span>
{running && <Chip label={t('tasks.running')} tone="run" />}
{current && <Chip label={t('tasks.current')} tone="cur" />}
{subagent && <Chip label={t('tasks.subagent')} tone="sub" />}
</div>
)
}
/** A small status pill. */
function Chip({ label, tone }: { label: string; tone: 'run' | 'cur' | 'sub' }): ReactElement {
const bg = tone === 'run' ? '#2ea04333' : tone === 'cur' ? '#1f6feb33' : '#6e40c933'
const fg = tone === 'run' ? '#2ea043' : tone === 'cur' ? '#1f6feb' : '#6e40c9'
return (
<span
style={{
flexShrink: 0,
fontSize: 10,
lineHeight: '16px',
padding: '0 5px',
borderRadius: 8,
background: bg,
color: fg,
whiteSpace: 'nowrap',
}}
>
{label}
</span>
)
}
/** A panel shell: a title bar + a scrollable body. */
function Panel({ titleKey, children }: { titleKey: string; children: ReactNode }): ReactElement {
return (
<div data-apes-panel style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 10, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t(titleKey)}</div>
<div style={{ flex: 1, overflowY: 'auto' }}>{children}</div>
</div>
)
}
/** The empty-state line every panel falls back to. */
function Empty({ key: key }: { key: string }): ReactElement {
return <div style={{ opacity: 0.55, fontSize: 12 }}>{t(key)}</div>
}
/**
* apes:tasks — the session list. Running/current/subagent rows are flagged.
* Clicking a row selects it as current (when the host exposes `open`).
*/
function TasksView(props: TabComponentProps): ReactElement {
useApesLocale()
const list = useSessionList(props.ctx, props.visible)
const { prefs, setShowInactive } = useTasksPrefs(props.ctx)
// Per-tab state: when showInactive is off, only running sessions render.
const all = Object.values(list.byId)
const rows = prefs.showInactive ? all : all.filter((s) => s.running)
const open = (id: string) => props.ctx.sessions.open?.(id)
return (
<Panel titleKey="tab.tasks.title">
{/* The showInactive toggle — the persisted per-tab state. */}
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 11,
opacity: 0.8,
marginBottom: 6,
cursor: 'pointer',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={prefs.showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
style={{ margin: 0 }}
/>
{t('tasks.showInactive')}
</label>
{rows.length === 0 ? (
<Empty key="tasks.empty" />
) : (
rows.map((s) => (
<SessionRow
key={s.id}
title={s.displayTitle}
running={s.running}
current={s.id === list.current}
subagent={s.origin === 'subagent'}
// The row is a button only when a host `open` is available.
{...{ onClick: () => open(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }}
/>
))
)}
</Panel>
)
}
/**
* apes:agents — the subagent children, grouped by their durable parent
* session. Reads the same sessions feed and filters `origin === 'subagent'`.
*/
function AgentsView(props: TabComponentProps): ReactElement {
useApesLocale()
const list = useSessionList(props.ctx, props.visible)
const children = Object.values(list.byId).filter((s) => s.origin === 'subagent')
if (children.length === 0) {
return (
<Panel titleKey="tab.agents.title">
<Empty key="agents.empty" />
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('agents.hint')}</div>
</Panel>
)
}
// Group by parentId (undefined groups under a synthetic root).
const groups = new Map<string, typeof children>()
for (const s of children) {
const key = s.parentId ?? ''
const arr = groups.get(key) ?? []
arr.push(s)
groups.set(key, arr)
}
const jump = (childSessionId: string) => props.onSubagentJump?.(childSessionId)
return (
<Panel titleKey="tab.agents.title">
{[...groups.entries()].map(([parentId, rows]) => (
<div key={parentId || 'root'} style={{ marginBottom: 10 }}>
{parentId && (
<div style={{ opacity: 0.5, fontSize: 11, marginBottom: 4 }}>
{list.byId[parentId]?.displayTitle ?? parentId}
</div>
)}
{rows.map((s) => (
<SessionRow
key={s.id}
title={s.displayTitle}
running={s.running}
subagent
{...{ onClick: () => jump(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }}
/>
))}
render(): ReactNode {
if (this.state.error !== null) {
return (
<div className={css.boundaryError}>
<span>dsh-better-sidebar: {this.state.error}</span>
<button
type="button"
className={css.terminalRetry}
onClick={() => { this.setState({ error: null }) }}
>
{t('terminalRetry')}
</button>
</div>
))}
</Panel>
)
}
/** apes:apps — placeholder (Phase 2 wires the workspace app feed). */
function AppsView(): ReactElement {
useApesLocale()
return (
<Panel titleKey="tab.apps.title">
<Empty key="apps.empty" />
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('apps.hint')}</div>
</Panel>
)
}
/** apes:messages — placeholder (Phase 2 wires the message/reminder stream). */
function MessagesView(): ReactElement {
useApesLocale()
return (
<Panel titleKey="tab.messages.title">
<Empty key="messages.empty" />
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('messages.hint')}</div>
</Panel>
)
}
/**
* apes:profile — account/runtime overview. Phase 1 shows the shell + a hint;
* Phase 2 reads model/provider from a host route (none exposed yet).
*/
function ProfileView(): ReactElement {
useApesLocale()
return (
<Panel titleKey="tab.profile.title">
<div style={{ fontSize: 12, marginBottom: 8 }}>
<div>
<span style={{ opacity: 0.55 }}>{t('profile.model')}: </span>
<span>{t('profile.unknown')}</span>
</div>
<div>
<span style={{ opacity: 0.55 }}>{t('profile.provider')}: </span>
<span>{t('profile.unknown')}</span>
</div>
</div>
<div style={{ opacity: 0.45, fontSize: 12 }}>{t('profile.hint')}</div>
</Panel>
)
}
/**
* Build the five tab descriptors. Each `component` receives the real
* better-sidebar `TabComponentProps` (ctx / store / scope / tab / visible).
*
* Phase 2 additions:
* - `badge` on tasks/agents: a cheap count pill (running sessions / subagent
* count). Called on every tab-bar render, so it reads the live sessions
* snapshot synchronously and returns `null` when the count is 0 (hidden).
* - `visible` is now honored by the live views: `useSessionList` swaps to a
* no-op subscribe when the tab is not the active-open one (auto-collapse —
* hidden panels stop re-rendering on session-list churn).
*/
function buildDescriptors(): TabDescriptor[] {
return [
{
id: TABS.tasks,
title: () => t('tab.tasks.title'),
order: 90,
single: true,
// Running-session count pill (99+ capped by the host; 0 → hidden).
badge: (ctx) => {
const n = runningCount(ctx)
return n === 0 ? null : n
},
component: (props) => <TasksView {...props} />,
},
{
id: TABS.agents,
title: () => t('tab.agents.title'),
order: 91,
single: true,
// Active-subagent count pill (0 → hidden).
badge: (ctx) => {
const n = subagentCount(ctx)
return n === 0 ? null : n
},
component: (props) => <AgentsView {...props} />,
},
{
id: TABS.apps,
title: () => t('tab.apps.title'),
order: 92,
single: true,
component: () => <AppsView />,
},
{
id: TABS.messages,
title: () => t('tab.messages.title'),
order: 93,
single: true,
component: () => <MessagesView />,
},
{
id: TABS.profile,
title: () => t('tab.profile.title'),
order: 94,
single: true,
component: () => <ProfileView />,
},
]
}
/**
* Register the i18n dictionaries plus all five tabs; returns the combined
* disposer (locale registrations + the five tab disposers). Safe to call
* from inside a cordis effect.
*/
function registerAll(ctx: ApesClientContext): () => void {
const better = ctx.betterSidebar
const disposers: (() => void)[] = [registerLocales(ctx.locale)]
for (const descriptor of buildDescriptors()) {
disposers.push(better.registerTab(descriptor))
)
}
return this.props.children
}
return () => {
for (const d of disposers) {
try {
d()
} catch {
// Already disposed (double-dispose on HMR) — ignore.
}
}
/**
* Client plugin body.
* @param ctx - the client cordis context (slots, sessions).
*/
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
// registrations (the official createXXXStore() factory rule — no
// module-level singleton).
const sidebarStore = createSidebarStore()
// The sidebar registry service: external plugins register tab types and
// file previewers through `ctx.betterSidebar.registerTab/registerFileViewer`.
// Published before the panel mounts so consumers injecting 'betterSidebar'
// are ready by the time the sidebar renders.
const service = createBetterSidebarService(sidebarStore)
ctx.provide('betterSidebar', service)
// Register the plugin's own built-in tabs and viewers through the same
// service (eating our own dogfood). The disposer unregisters them on
// fiber disposal (HMR-safe).
ctx.effect(
() => registerBuiltins(ctx, service),
'dsh-better-sidebar: register built-in tabs and viewers',
)
// A failure anywhere in the client lifecycle must never take the app down
// silently: log with the plugin prefix and pin a visible diagnostic strip
// to the page so a blank panel is never the only symptom.
const fail = (phase: string, error: unknown): void => {
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
try {
const bar = document.createElement('div')
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
document.body.appendChild(bar)
} catch {
// Nothing left to report with.
}
}
}
try {
ctx.effect(() => {
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)
}
})()
return () => {
disposed = true
root?.unmount()
host?.remove()
}
}, 'dsh-better-sidebar: sidebar mount')
/** The cordis client entry. */
export function apply(ctx: ApesClientContext): void {
if (!ctx.betterSidebar) {
// better-sidebar not present in this profile: the tabs cannot register.
// Expected on a bare DSH; the host half still mounts (logs only).
// Do NOT throw — a missing peer must degrade, never break the shell.
return
}
if (typeof ctx.effect === 'function') {
// cordis effect takes a zero-arg thunk; capture ctx in the closure.
// The body returns the combined disposer; cordis runs it on activation
// teardown (HMR-safe).
ctx.effect(() => registerAll(ctx))
} else {
// No effect API (non-cordis harness / unit test): register synchronously;
// disposal rides process teardown.
registerAll(ctx)
ctx.effect(
() => {
try {
return registerTurnTailInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return undefined
}
},
'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, keeps the shared store in sync, and renders the
// declarative enable/disable inventory from the tab/viewer registry.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'better-sidebar',
order: 100,
label: () => t('settingsNav'),
inject: () => ({ store: sidebarStore, service }),
}, SideCardSection))
} catch (error) {
fail('load', error)
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Interception of the chat's produced-files row: the turn-tail chain entry
* that replaces ui-deliverables' row when the closing turn produced files.
* The takeover looks identical (same chip row); the chips open the file in
* the sidebar instead of the host OS. Priority -1 runs before the default-0
* deliverables entry; when nothing was produced the selector returns null
* and the original row renders unchanged.
*/
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import type { SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import css from './sidebar.module.css'
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: string, path: string): void {
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
const absolute = resolveSidebarPath(summary?.cwd, path)
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
const title = at === -1 ? absolute : absolute.slice(at + 1)
// Route through the sidebar service so the editor descriptor's dedupeKey
// (per-path) applies; the id is path-derived so multiple editors coexist.
ctx.betterSidebar?.openTab({ type: 'editor', title, path: absolute, id: `editor:${absolute}` })
}
/** The intercepted produced-files row (visual twin of the deliverables chips). */
export function SidebarProducedFiles(props: {
matched: readonly string[]
openInSidebar: (path: string) => void
}) {
const { matched, openInSidebar } = props
const shown = matched.slice(0, 6)
const hidden = matched.length - shown.length
return (
<div className={css.producedRow}>
<span className={css.producedLabel}>{t('produced')}</span>
{shown.map(path => {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
const name = at === -1 ? path : path.slice(at + 1)
return (
<button
key={path}
type="button"
className={css.producedChip}
title={path}
onClick={() => { openInSidebar(path) }}
>
<IconCodeOutline16 size={12} />
<span>{name}</span>
</button>
)
})}
{hidden > 0 && <span className={css.producedMore}>+{hidden}</span>}
</div>
)
}
/** Register the turn-tail interception (returns the disposer). */
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
return ctx.slots.register({
name: 'conversation.chat.turnTail',
// Decline the takeover while the editor tab type is disabled in the side
// card settings: the produced-files row falls back to the default
// deliverables behavior instead of offering chips that cannot open.
select: (owner) => {
if (store.getPrefs().tabsEnabled['editor'] === false) return null
return selectProducedFiles(owner)
},
priority: -1,
registrant: 'dsh-better-sidebar',
inject: (sessionId: string) => ({
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
}),
}, SidebarProducedFiles)
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Syntax highlighting for the file editor: extension → CodeMirror language
* mapping. The key derivation is pure and unit-tested; the factories pull in
* the CodeMirror language packages (bundled into the client).
*/
import { Language, LanguageSupport, StreamLanguage } from '@codemirror/language'
import { javascript } from '@codemirror/lang-javascript'
import { json } from '@codemirror/lang-json'
import { markdown } from '@codemirror/lang-markdown'
import { python } from '@codemirror/lang-python'
import { html } from '@codemirror/lang-html'
import { css } from '@codemirror/lang-css'
import { xml } from '@codemirror/lang-xml'
import { yaml } from '@codemirror/lang-yaml'
import { sql } from '@codemirror/lang-sql'
import { java } from '@codemirror/lang-java'
import { cpp } from '@codemirror/lang-cpp'
import { rust } from '@codemirror/lang-rust'
import { go } from '@codemirror/lang-go'
import { php } from '@codemirror/lang-php'
import { shell } from '@codemirror/legacy-modes/mode/shell'
import { toml } from '@codemirror/legacy-modes/mode/toml'
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
import { properties } from '@codemirror/legacy-modes/mode/properties'
/** The lowercased file extension of a path ('' when none). */
export function extOf(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at + 1).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
/** Language key for an extension, or null for plain text. Pure (tested). */
export function languageKeyForExt(ext: string): string | null {
switch (ext) {
case 'js': case 'mjs': case 'cjs': return 'js'
case 'jsx': return 'jsx'
case 'ts': case 'mts': case 'cts': return 'ts'
case 'tsx': return 'tsx'
case 'json': case 'jsonc': return 'json'
case 'md': case 'markdown': return 'md'
case 'py': case 'pyw': return 'python'
case 'html': case 'htm': return 'html'
case 'css': return 'css'
case 'xml': case 'xsl': return 'xml'
case 'yaml': case 'yml': return 'yaml'
case 'sql': return 'sql'
case 'java': return 'java'
case 'c': case 'h': return 'c'
case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp'
case 'rs': return 'rust'
case 'go': return 'go'
case 'php': return 'php'
case 'sh': case 'bash': case 'zsh': return 'shell'
case 'toml': return 'toml'
case 'nginx': case 'conf': return 'nginx'
case 'dockerfile': case 'docker': return 'dockerfile'
case 'properties': case 'env': return 'properties'
default: return null
}
}
const FACTORIES: Record<string, () => Language | LanguageSupport> = {
js: () => javascript({ jsx: true }),
jsx: () => javascript({ jsx: true }),
ts: () => javascript({ typescript: true }),
tsx: () => javascript({ typescript: true, jsx: true }),
json: () => json(),
md: () => markdown(),
python: () => python(),
html: () => html(),
css: () => css(),
xml: () => xml(),
yaml: () => yaml(),
sql: () => sql(),
java: () => java(),
c: () => cpp(),
cpp: () => cpp(),
rust: () => rust(),
go: () => go(),
php: () => php(),
shell: () => StreamLanguage.define(shell),
toml: () => StreamLanguage.define(toml),
nginx: () => StreamLanguage.define(nginx),
dockerfile: () => StreamLanguage.define(dockerFile),
properties: () => StreamLanguage.define(properties),
}
/** The CodeMirror language support for a path, or null for plain text. */
export function languageForPath(path: string): Language | LanguageSupport | null {
const key = languageKeyForExt(extOf(path))
return key === null ? null : FACTORIES[key]!()
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Layout push: when the sidebar is open it OCCUPIES the layout instead of
* floating over it — the app shell (#root, the AppFrame three-column grid)
* gives up the panel's width on the right. Only the center column is
* flexible (1fr), so the squeeze lands exactly on the conversation output
* and the input bar, like a VSCode sidebar. The fixed panel covers the
* vacated strip, which looks seamless because #root's background is the
* theme base.
*
* The width rides a CSS variable updated by the Sidebar shell (0 while
* collapsed); expand/collapse animates both the margin and the panel slide
* on the same theme duration. Width drags disable the transition so the
* layout tracks the pointer.
*/
#root {
margin-right: var(--dsh-sidebar-width, 0px);
transition: margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
body[data-dsh-sidebar-dragging] #root {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
#root {
transition: none;
}
}
+322 -125
View File
@@ -1,136 +1,333 @@
/**
* apes-sidebar i18n dictionaries, registered into the DSH locale runtime
* under the `apesSidebar` namespace (follows the DSH i18n system: the
* active locale is the Host-backed preference, not the raw browser language).
*
* zh + en are primary. A third language falls back to en (the better-sidebar
* convention: every new zh key must also have an en entry).
*
* The module tracks the active locale in a small external store so `t()`
* always resolves against the current locale, and React components re-render
* on locale change via {@link useApesLocale} (useSyncExternalStore).
* Minimal zh/en copy for the sidebar. The plugin renders outside the slot
* system's locale seat, so it reads the browser language directly (the DSH
* locale preference and the browser language agree in practice; both default
* from navigator.language anyway).
*/
import { useSyncExternalStore } from 'react'
import type { ApesLocaleService } from '../context-types.ts'
const NS = 'apesSidebar'
const zh: Record<string, string> = {
'tab.tasks.title': '任务',
'tab.agents.title': 'Agent',
'tab.messages.title': '消息',
'tab.apps.title': '应用',
'tab.profile.title': '账户',
'tasks.empty': '暂无会话',
'tasks.running': '运行中',
'tasks.current': '当前',
'tasks.subagent': '子代理',
'tasks.showInactive': '显示非运行会话',
'agents.empty': '暂无子代理',
'agents.hint': '子代理会话会在此按父会话分组展示。',
'apps.empty': '暂无应用',
'apps.hint': '工作区应用与外部入口将在此展示。',
'messages.empty': '暂无消息',
'messages.hint': '会话消息流与提醒将在此汇总。',
'profile.model': '模型',
'profile.provider': '提供商',
'profile.unknown': '未知',
'profile.hint': '账户与运行配置概览。',
const zh = {
explorer: '资源管理器',
git: '源代码管理',
terminal: '终端',
editor: '编辑器',
newTab: '新建标签页',
openExplorer: '资源管理器',
openGit: 'Git 面板',
newTerminal: '新终端',
terminalLimit: '终端数量已达上限 (3)',
close: '关闭',
collapse: '折叠侧边栏',
expand: '展开侧边栏',
expandFullscreen: '展开为全屏',
restoreFullscreen: '还原宽度',
terminalError: '终端连接失败',
terminalConnectFailed: '终端多次连接失败',
terminalRetry: '重试',
preview: '预览',
edit: '编辑',
refresh: '刷新',
save: '保存',
saved: '已保存',
unsaved: '未保存',
saveFailed: '保存失败',
truncation: '文件过大,仅显示前 512KB',
binary: '二进制文件,无法预览',
loading: '加载中…',
error: '加载失败',
retry: '重试',
splitLeft: '向左分栏',
splitRight: '向右分栏',
splitUp: '向上分栏',
splitDown: '向下分栏',
notRepo: '当前目录不是 git 仓库',
noChanges: '没有变更',
stage: '暂存',
unstage: '取消暂存',
stageAll: '全部暂存',
unstageAll: '全部取消暂存',
commitPlaceholder: '提交信息 (Ctrl+Enter)',
commit: '提交',
commitError: '提交失败',
branch: '分支',
checkoutError: '切换分支失败',
history: '历史',
changes: '变更',
staged: '已暂存',
unstaged: '未暂存',
cancel: '取消',
diffEmpty: '没有文本差异',
diffLoadError: '加载差异失败',
diffBinary: '二进制',
diffAdded: '新增',
diffDeleted: '删除',
diffRenamed: '重命名',
diffExpand: '展开其余 {count} 行',
diffCollapse: '收起',
discard: '放弃更改',
discardTitle: '放弃更改',
discardDesc: '将丢弃「{path}」的工作区修改(不可恢复)。',
viewCommitDiff: '查看提交差异',
copyShortHash: '复制短哈希',
copyFullHash: '复制完整哈希',
copySubject: '复制提交信息',
revertCommit: '还原此提交',
revertTitle: '还原此提交',
revertDesc: '将在当前分支创建一个反转「{subject}」的新提交。',
cherryPickCommit: '捡取此提交',
cherryPickTitle: '捡取此提交',
cherryPickDesc: '将「{subject}」的更改应用到当前分支。',
timeJustNow: '刚刚',
timeMinutesAgo: '{n} 分钟前',
timeHoursAgo: '{n} 小时前',
timeYesterday: '昨天',
loadMore: '加载更多',
historyLoadError: '加载更多历史失败',
produced: '本次产出',
producedOpen: '在侧边栏中打开',
disconnected: '终端连接断开,重连中…',
exited: '终端进程已退出',
noSession: '选择一个会话以使用侧边栏',
pluginNotLoaded: '插件未加载,标签页暂不可用:',
hiddenFiles: '隐藏文件',
parent: '上级目录',
copied: '已复制',
copy: '复制',
newFile: '新文件',
openEditor: '打开编辑器',
gitDetail: '查看变更详情',
referenceFile: '@文件',
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: '当前会话产生新的子代理时,自动展开侧边栏并打开子代理页面;关闭后需手动打开',
settingsToolsTitle: '为模型注入终端工具',
settingsToolsDesc: '开启后,模型可通过 terminal_create 等 8 个工具创建并操作侧边栏终端(默认关闭)',
settingsTabsTitle: '侧边栏内容',
settingsViewersTitle: '文件预览',
settingsGeneralTitle: '常规',
settingsPopup: '功能设置',
settingsViewerCatchAll: '兜底:任意文件',
viewerImage: '图片',
viewerPdf: 'PDF',
viewerDocx: 'Word 文档',
viewerXlsx: 'Excel 表格',
viewerPptx: 'PPT 演示',
viewerMarkdown: 'Markdown',
viewerCode: '代码',
viewerBinary: '二进制下载',
subagent: '子代理',
openSubagent: '子代理',
subagentMainAgent: '主代理',
subagentEmpty: '暂无子代理',
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
subagentRunning: '运行中',
subagentInactive: '空闲',
subagentModeOneShot: '一次性',
subagentModeContinuable: '可续接',
subagentCount: '{count} 个子代理',
subagentCountRunning: '{count} 个子代理 · {running} 运行中',
subagentDiagCorrupt: '目录损坏',
subagentDiagUnsupported: '不支持的条目',
subagentDiagUnavailable: '不可用',
subagentThinking: '思考中…',
}
const en: Record<string, string> = {
'tab.tasks.title': 'Tasks',
'tab.agents.title': 'Agents',
'tab.messages.title': 'Messages',
'tab.apps.title': 'Apps',
'tab.profile.title': 'Profile',
'tasks.empty': 'No sessions yet',
'tasks.running': 'running',
'tasks.current': 'current',
'tasks.subagent': 'subagent',
'tasks.showInactive': 'Show inactive sessions',
'agents.empty': 'No subagents yet',
'agents.hint': 'Subagent sessions appear here, grouped by parent.',
'apps.empty': 'No apps yet',
'apps.hint': 'Workspace apps and external entry points appear here.',
'messages.empty': 'No messages yet',
'messages.hint': 'Session message flow and reminders are aggregated here.',
'profile.model': 'Model',
'profile.provider': 'Provider',
'profile.unknown': 'Unknown',
'profile.hint': 'Account and runtime configuration overview.',
const en: Record<keyof typeof zh, string> = {
explorer: 'Explorer',
git: 'Source Control',
terminal: 'Terminal',
editor: 'Editor',
newTab: 'New tab',
openExplorer: 'Explorer',
openGit: 'Git panel',
newTerminal: 'New terminal',
terminalLimit: 'Terminal limit reached (3)',
close: 'Close',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
expandFullscreen: 'Expand to full width',
restoreFullscreen: 'Restore width',
terminalError: 'Terminal connection failed',
terminalConnectFailed: 'Terminal failed to connect repeatedly',
terminalRetry: 'Retry',
preview: 'Preview',
edit: 'Edit',
refresh: 'Refresh',
save: 'Save',
saved: 'Saved',
unsaved: 'Unsaved',
saveFailed: 'Save failed',
truncation: 'File too large — showing the first 512KB',
binary: 'Binary file, preview unavailable',
loading: 'Loading…',
error: 'Failed to load',
retry: 'Retry',
splitLeft: 'Split left',
splitRight: 'Split right',
splitUp: 'Split up',
splitDown: 'Split down',
notRepo: 'This directory is not a git repository',
noChanges: 'No changes',
stage: 'Stage',
unstage: 'Unstage',
stageAll: 'Stage all',
unstageAll: 'Unstage all',
commitPlaceholder: 'Commit message (Ctrl+Enter)',
commit: 'Commit',
commitError: 'Commit failed',
branch: 'Branch',
checkoutError: 'Branch switch failed',
history: 'History',
changes: 'Changes',
staged: 'Staged',
unstaged: 'Unstaged',
cancel: 'Cancel',
diffEmpty: 'No text changes',
diffLoadError: 'Failed to load diff',
diffBinary: 'Binary',
diffAdded: 'Added',
diffDeleted: 'Deleted',
diffRenamed: 'Renamed',
diffExpand: 'Expand {count} more rows',
diffCollapse: 'Collapse',
discard: 'Discard changes',
discardTitle: 'Discard changes',
discardDesc: 'This discards the worktree changes of "{path}" (not recoverable).',
viewCommitDiff: 'View commit diff',
copyShortHash: 'Copy short hash',
copyFullHash: 'Copy full hash',
copySubject: 'Copy subject',
revertCommit: 'Revert commit',
revertTitle: 'Revert commit',
revertDesc: 'Create a new commit on the current branch that reverts "{subject}".',
cherryPickCommit: 'Cherry-pick commit',
cherryPickTitle: 'Cherry-pick commit',
cherryPickDesc: 'Apply the changes of "{subject}" to the current branch.',
timeJustNow: 'just now',
timeMinutesAgo: '{n} min ago',
timeHoursAgo: '{n} h ago',
timeYesterday: 'yesterday',
loadMore: 'Load more',
historyLoadError: 'Failed to load more history',
produced: 'Produced',
producedOpen: 'Open in sidebar',
disconnected: 'Terminal disconnected, reconnecting…',
exited: 'Terminal process exited',
noSession: 'Select a conversation to use the sidebar',
pluginNotLoaded: 'Plugin not loaded; tab unavailable:',
hiddenFiles: 'Hidden files',
parent: 'Parent directory',
copied: 'Copied',
copy: 'Copy',
newFile: 'New file',
openEditor: 'Open editor',
gitDetail: 'View change details',
referenceFile: '@file',
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',
settingsToolsTitle: 'Inject terminal tools for the model',
settingsToolsDesc: 'When enabled, the model can create and drive sidebar terminals through the 8 terminal_* tools (off by default)',
settingsTabsTitle: 'Sidebar content',
settingsViewersTitle: 'File viewers',
settingsGeneralTitle: 'General',
settingsPopup: 'Feature settings',
settingsViewerCatchAll: 'Catch-all: any file',
viewerImage: 'Image',
viewerPdf: 'PDF',
viewerDocx: 'Word',
viewerXlsx: 'Excel',
viewerPptx: 'PowerPoint',
viewerMarkdown: 'Markdown',
viewerCode: 'Code',
viewerBinary: 'Binary download',
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…',
}
const DICTS: Record<string, Record<string, string>> = { zh, en }
/** Translate a copy key in the browser's language (zh → zh, else en). */
export type CopyKey = keyof typeof zh
/** The currently resolved active locale code ('zh' default). */
let activeLocale = 'zh'
const listeners = new Set<() => void>()
function notify(): void {
for (const fn of listeners) fn()
}
/** The active locale snapshot (uSES-safe: returns the same code string). */
export function getActiveLocale(): string {
return activeLocale
}
export function subscribeLocale(fn: () => void): () => void {
listeners.add(fn)
return () => {
listeners.delete(fn)
}
}
/**
* Re-render the calling component whenever the active locale changes.
* Returns the active locale code; components re-resolve `t(key)` against it.
*/
export function useApesLocale(): string {
return useSyncExternalStore(subscribeLocale, getActiveLocale, getActiveLocale)
}
/** Translate a key in the active locale; falls back to en, then the key. */
export function t(key: string): string {
const dict = DICTS[activeLocale] ?? en
return dict[key] ?? en[key] ?? key
}
/**
* Register the dictionaries into the locale runtime AND subscribe to its
* active-locale changes, keeping {@link activeLocale} in sync. Returns the
* combined disposer (locale registrations + the snapshot subscription).
*/
export function registerLocales(locale: ApesLocaleService | undefined): () => void {
if (!locale) return () => undefined
const disposes = [
locale.register(NS, 'zh', zh),
locale.register(NS, 'en', en),
]
let unsub: (() => void) | undefined
try {
// Seed the active locale from the runtime snapshot (uSES-safe read).
activeLocale = locale.getSnapshot().active || 'zh'
unsub = locale.subscribe(() => {
const next = locale.getSnapshot().active || 'en'
if (next !== activeLocale) {
activeLocale = next
notify()
}
})
} catch {
// Locale runtime unavailable (very early boot): stay on the default.
}
return () => {
unsub?.()
for (const d of disposes) {
try {
d()
} catch {
// Disposer already run (double-dispose) — ignore.
}
/** 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
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). */
export function isZh(): boolean {
return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
}
/** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */
export function relativeTime(iso: string): string {
const then = Date.parse(iso)
if (Number.isNaN(then)) return iso
const seconds = Math.floor((Date.now() - then) / 1000)
if (seconds < 60) return t('timeJustNow')
if (seconds < 3600) return t('timeMinutesAgo', { n: Math.floor(seconds / 60) })
if (seconds < 86400) return t('timeHoursAgo', { n: Math.floor(seconds / 3600) })
if (seconds < 172800) return t('timeYesterday')
const date = new Date(then)
const pad = (value: number): string => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Office file type dispatch — pure functions mapping file extensions to
* preview kinds. Kept separate from the editor host 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 (with leading dot) (`.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
}
+249
View File
@@ -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 download link.
*/
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
const { scope, path, message } = props
return (
<div className={css.editorBinary}>
<span className={css.editorBinaryNotice}>{message}</span>
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
{t('downloadToView')}
</a>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Path projection helpers shared by the explorer rows: a path relative to
* the session cwd (for the @-reference button and "copy relative path").
* The fs-tree joins with '/' even on Windows, so both separators normalize
* to '/' before comparison.
*/
/**
* The path relative to the session's working directory.
* @param cwd - the explorer root (absolute).
* @param path - an absolute entry path from the fs-tree.
* @returns the relative path with '/' separators ('.' for the cwd itself),
* or `path` unchanged when it lies outside the cwd.
*
* The prefix test is case-insensitive: Windows paths (and macOS's
* case-insensitive volumes) may arrive with different casing than the cwd
* row, and the containment decision must not depend on it. The returned
* relative text keeps the caller's own casing.
*/
export function relativeTo(cwd: string, path: string): string {
const base = cwd.replace(/[\\/]+$/, '')
const norm = (value: string): string => value.replace(/\\/g, '/')
const nBase = norm(base)
const nPath = norm(path)
if (nPath === nBase) return '.'
if (nPath.toLowerCase().startsWith(`${nBase.toLowerCase()}/`)) return nPath.slice(nBase.length + 1)
return path
}
+4
View File
@@ -0,0 +1,4 @@
/** Pure PDF extension dispatch, separate from React for unit testing. */
export function isPdfExt(ext: string): boolean {
return ext === '.pdf'
}
+79
View File
@@ -0,0 +1,79 @@
/**
* 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,
agentTerminalTools: typeof record.agentTerminalTools === 'boolean'
? record.agentTerminalTools
: SIDEBAR_PREFS_DEFAULTS.agentTerminalTools,
tabsEnabled: booleanMapOf(record.tabsEnabled),
viewersEnabled: booleanMapOf(record.viewersEnabled),
}
}
/**
* Validate one enable-switch map (per-tab / per-viewer). Only boolean values
* survive; a non-object or a non-boolean entry falls back to the empty map /
* drops the entry — an absent key means the feature stays enabled.
*/
function booleanMapOf(value: unknown): Record<string, boolean> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
const out: Record<string, boolean> = {}
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
if (typeof item === 'boolean') out[key] = item
}
return out
}
/**
* 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 }
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Pure derivation of one turn's produced files from finalized conversation
* nodes — a structural replica of ui-deliverables' `producedForClosing`
* (the mutation tools' follow-along `locations`, by render intent: a diff
* card or a generic edit card; reads/deletes/failures produce nothing).
* Kept dependency-free so the takeover logic is unit-testable and the
* replica is easy to diff against upstream when it drifts.
*/
/** Paths a tool-result view reports as produced, by render intent. */
export function producedPaths(view: unknown): readonly string[] {
if (view === null || typeof view !== 'object') return []
const record = view as { card?: unknown; kind?: unknown; locations?: unknown }
const isEdit = record.card === 'diff' || (record.card === 'generic' && record.kind === 'edit')
if (!isEdit) return []
if (!Array.isArray(record.locations)) return []
const paths: string[] = []
for (const location of record.locations) {
if (location !== null && typeof location === 'object' && typeof (location as { path?: unknown }).path === 'string') {
paths.push((location as { path: string }).path)
}
}
return paths
}
/**
* Files produced by the turn the assistant at `seq` closes. Accumulation
* resets on turn boundaries (a user message, or a node reporting a different
* turn number); paths keep first-seen order and appear once.
* @param nodes - snapshot nodes in surface order (structural, unknown-safe).
* @param seq - the closing assistant's seq (the render site's anchor).
* @returns produced paths; empty when the turn wrote nothing.
*/
export function producedForClosing(nodes: readonly unknown[], seq: number): readonly string[] {
let pending: string[] = []
let seen = new Set<string>()
let turn: number | undefined
for (const node of nodes) {
if (node === null || typeof node !== 'object') continue
const record = node as { kind?: unknown; isError?: unknown; callView?: unknown; turn?: unknown; seq?: unknown }
if (record.kind === 'tool-result') {
if (record.isError === true) continue
for (const path of producedPaths(record.callView)) {
if (seen.has(path)) continue
seen.add(path)
pending.push(path)
}
continue
}
if (record.kind === 'user') {
turn = undefined
pending = []
seen = new Set()
} else if (typeof record.turn === 'number') {
if (turn !== undefined && record.turn !== turn) {
pending = []
seen = new Set()
}
turn = record.turn
}
if (record.kind === 'assistant' && record.seq === seq) return pending
}
return []
}
/**
* Claim the turn-tail chain only when the closing turn produced files.
* @param owner - the turn-tail owner currency ({nodes, seq}).
* @returns produced paths as the matched value, or null to decline.
*/
export function selectProducedFiles(owner: unknown): readonly string[] | null {
const record = owner as { nodes?: unknown; seq?: unknown } | null
if (record === null || typeof record !== 'object') return null
if (!Array.isArray(record.nodes) || typeof record.seq !== 'number') return null
const paths = producedForClosing(record.nodes, record.seq)
return paths.length === 0 ? null : paths
}
/** Resolve a (possibly relative) path against the session cwd for the sidebar. */
export function resolveSidebarPath(cwd: string | undefined, path: string): string {
const absolute = path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
if (absolute) return path
const base = cwd ?? ''
if (base === '') return path
const separator = base.includes('\\') ? '\\' : '/'
return `${base.replace(/[\\/]+$/, '')}${separator}${path}`
}
+379
View File
@@ -0,0 +1,379 @@
/**
* The BetterSidebar client service: a registry that external plugins use
* to contribute sidebar tab types and file previewers. The service is
* published to the cordis context as `ctx.betterSidebar` (see
* {@link ../context-types.ts}); consumers declare it in `inject` and call
* `registerTab` / `registerFileViewer`, both returning a disposer that
* cordis auto-invokes on fiber disposal (HMR-safe).
*
* Design notes:
* - The registry is synchronous-snapshot (Map + listener set) so React
* can read it through `useSyncExternalStore` without tearing.
* - `dedupeKey` unifies the three open-tab strategies the builtins used to
* hardcode: single-instance (`() => type`), per-path (`tab => tab.path`),
* and per-id (`tab => tab.id` for diff tabs whose id is change-derived).
* `single: true` is sugar for `dedupeKey: () => id`.
* - `createTab` lets a descriptor own tab instantiation (the terminal
* builtin uses it to mint `terminal:<n>` ids and bump `nextTerminal`).
* - `matchFileViewer` walks descriptors in priority order (desc, stable):
* per descriptor it tries `detect` first (when `head` bytes are given),
* then `exts`; `exts: []` is a catch-all that matches any path.
*/
import type { ReactNode } from 'react'
import type { Context } from '../context-types.ts'
import {
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane,
type SidebarState, type SidebarStore, type SidebarTab,
} from './state.ts'
import type { SessionScope } from './api.ts'
/** One declarative boolean setting of a tab/viewer, rendered as a nested
* switch row in the Side card settings page (e.g. the Subagent page's
* "auto-open when a subagent appears"). */
export interface SidebarSettingToggle {
/** The SidebarPrefs field this toggle reads and writes ('autoOpenSubagent'). */
key: string
/** Row title (i18n friendly: string or () => string). */
title: string | (() => string)
/** Row description (i18n friendly). */
desc?: string | (() => string)
}
/** Declarative settings of one registered tab or file viewer. */
export interface SidebarSettingsDeclaration {
/**
* Extra boolean toggles rendered under the feature's own row in the
* settings page (only while the feature is enabled). Keys must be fields
* of the host's PrefsSchema (built-ins: 'autoOpenSubagent',
* 'agentTerminalTools'); unknown keys are dropped by the settings seam.
*/
toggles?: readonly SidebarSettingToggle[]
}
/** Props every tab component receives (builtins and external alike). */
export interface TabComponentProps {
ctx: Context
store: SidebarStore
scope: SessionScope
tab: SidebarTab
/** Whether this tab is the active one AND the panel is open (live views pause otherwise). */
visible: boolean
/** The explorer's expanded directory set (ExplorerView). */
expanded?: string[]
onToggleDir?: (path: string) => void
onReferenceFile?: (path: string) => void
onOpenFile?: (path: string) => void
onOpenDiff?: (tab: SidebarTab) => void
onSubagentJump?: (childSessionId: string) => void
}
/** Describes one kind of sidebar tab (builtins register themselves too). */
export interface TabDescriptor {
/** Unique id; also the `SidebarTab.type` value (`'explorer'`, `'my-plugin:db'`). */
id: string
title: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
/** + menu sort order (ascending); default 100. */
order?: number
/** Hide from the + menu (the editor tab is opened by file-open, not by the menu). */
hidden?: boolean
/**
* + menu disabled predicate (e.g. terminal at capacity). Receives the
* session scope and the live sidebar state (counts, expansions).
*/
available?: (ctx: Context, scope: SessionScope, state: SidebarState) => boolean
/**
* Single-instance sugar: `true` is shorthand for `dedupeKey: () => id`
* (opening the tab focuses an existing one of the same type instead of
* creating a duplicate). An explicit `dedupeKey` always wins when both
* are given. Builtins: explorer/git/subagent use `single: true`.
*/
single?: boolean
/**
* If provided, opening a tab whose `dedupeKey(tab)` matches an existing
* tab's key focuses the existing one instead of creating a new one.
* Returning `undefined` means "no dedup — always open a new tab".
* Builtins: editor uses `tab => tab.path`; diff uses `tab => tab.id`
* (openDiffTab mints change-derived ids).
*/
dedupeKey?: (tab: SidebarTab) => string | undefined
/**
* Custom tab creation (minting the `SidebarTab` and any state patches).
* Return `null` to refuse creation. The terminal builtin uses this to
* mint `terminal:<n>` ids and bump `nextTerminal`.
* When omitted, a default `{ id, type, title }` tab is created.
*/
createTab?: (state: SidebarState) => { tab: SidebarTab; patch?: Partial<SidebarState> } | null
/**
* Declarative settings shown in the Side card settings page: every
* registered tab gets an enable/disable switch (icon + title + id), and
* `settings.toggles` adds nested switches tied to SidebarPrefs fields
* (e.g. the subagent tab's 'autoOpenSubagent').
*/
settings?: SidebarSettingsDeclaration
component: (props: TabComponentProps) => ReactNode
}
/** How the host loads a file's bytes for one viewer. */
export type FileFetchStrategy =
| 'none' // no bytes needed (image/pdf/office fetch through mediaUrl themselves)
| 'fsRead' // text read through /sidebar/api fs.read
| 'mediaUrl' // the viewer gets a media URL string
| 'custom' // the viewer's load() fetches its own bytes
| 'binary-download' // show a download button (no client-side renderer)
/** Props every file viewer component receives. */
export interface FileViewerProps {
ctx: Context
store: SidebarStore
scope: SessionScope
path: string
title: string
/** The matching descriptor's id (`'code'`, `'my-plugin:csv'`). */
viewerId: string
/** fsRead text content (fetchStrategy='fsRead'). */
content?: string
truncated?: boolean
/** mediaUrl for the path (fetchStrategy='mediaUrl'). */
mediaUrl?: string
/** custom load() return value (fetchStrategy='custom'). */
customData?: unknown
}
/** Describes one file previewer (builtins register themselves too). */
export interface FileViewerDescriptor {
/** Unique id (`'image'`, `'pdf'`, `'my-plugin:csv'`). */
id: string
/** Display name for the settings inventory (falls back to `id` when absent). */
title?: string | (() => string)
/** Icon shown in the settings inventory. */
icon?: ReactNode | ((size: number) => ReactNode)
/** Lowercase extensions without leading dot (`['png','jpg']`). `[]` = match any (catch-all). */
exts: readonly string[]
/** Higher wins; default 0. Builtins use 0; the catch-all `code` viewer uses -100. */
priority?: number
fetchStrategy: FileFetchStrategy
/**
* Content sniff: when `head` bytes are available the descriptor's `detect`
* is consulted before its `exts` (per-descriptor, in priority order).
*/
detect?: (path: string, head: Uint8Array) => boolean
/** fetchStrategy='custom' loader. */
load?: (path: string, scope: SessionScope) => Promise<unknown>
/**
* Declarative settings shown in the Side card settings page: every
* registered viewer gets an enable/disable switch (icon + title + exts).
*/
settings?: SidebarSettingsDeclaration
component: (props: FileViewerProps) => ReactNode
}
/** The registry service published as `ctx.betterSidebar`. */
export interface BetterSidebarService {
registerTab(descriptor: TabDescriptor): () => void
registerFileViewer(descriptor: FileViewerDescriptor): () => void
getTabs(): readonly TabDescriptor[]
getFileViewers(): readonly FileViewerDescriptor[]
/** Find a tab descriptor by id (undefined if not registered). */
getTab(id: string): TabDescriptor | undefined
/**
* Whether a tab type is enabled in the side card prefs. An absent
* `tabsEnabled[id]` entry means enabled — only an explicit `false`
* disables the type (hidden from the + menu, `openTab` refuses, and
* derived flows gate on it).
*/
isTabEnabled(id: string): boolean
/** Whether a file viewer is enabled (absent `viewersEnabled[id]` = enabled). */
isViewerEnabled(id: string): boolean
/**
* Find a file viewer for a path (priority desc; detect first, then exts).
* Disabled viewers are skipped, so files fall through to the next match.
*/
matchFileViewer(path: string, head?: Uint8Array): FileViewerDescriptor | undefined
/**
* Open a tab (used by external tabs and the + menu). `title` overrides
* the descriptor's title when given (the editor tab shows the file name);
* when the descriptor provides `createTab` it mints the tab itself and
* `title`/`path`/`id` are ignored. A disabled tab type is a no-op.
*/
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void
/** Close a tab by id. */
closeTab(tabId: string): void
/** Subscribe to registry changes (register/dispose). */
subscribe(listener: () => void): () => void
}
/** Extract the lowercase extension without leading dot from a path. */
function extOfPath(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at + 1).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
/**
* Create one BetterSidebar service bound to a store. The service owns the
* tab/viewer registries (Map + listener set) and proxies openTab/closeTab
* to the store's reducer. One instance per client plugin activation.
*/
export function createBetterSidebarService(store: SidebarStore): BetterSidebarService {
const tabs = new Map<string, TabDescriptor>()
const viewers = new Map<string, FileViewerDescriptor>()
const listeners = new Set<() => void>()
const notify = (): void => {
for (const fn of [...listeners]) fn()
}
const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
const registerTab = (descriptor: TabDescriptor): (() => void) => {
if (tabs.has(descriptor.id)) {
throw new Error(`[dsh-better-sidebar] tab type "${descriptor.id}" already registered`)
}
tabs.set(descriptor.id, descriptor)
notify()
return () => {
if (tabs.get(descriptor.id) === descriptor) {
tabs.delete(descriptor.id)
notify()
}
}
}
const registerFileViewer = (descriptor: FileViewerDescriptor): (() => void) => {
if (viewers.has(descriptor.id)) {
throw new Error(`[dsh-better-sidebar] file viewer "${descriptor.id}" already registered`)
}
viewers.set(descriptor.id, descriptor)
notify()
return () => {
if (viewers.get(descriptor.id) === descriptor) {
viewers.delete(descriptor.id)
notify()
}
}
}
const getTabs = (): readonly TabDescriptor[] => Array.from(tabs.values())
const getFileViewers = (): readonly FileViewerDescriptor[] => Array.from(viewers.values())
const getTab = (id: string): TabDescriptor | undefined => tabs.get(id)
// The enable switches come from the user's side card prefs (the shared
// store the service is bound to): an absent key means enabled.
const isTabEnabled = (id: string): boolean => store.getPrefs().tabsEnabled[id] !== false
const isViewerEnabled = (id: string): boolean => store.getPrefs().viewersEnabled[id] !== false
const matchFileViewer = (path: string, head?: Uint8Array): FileViewerDescriptor | undefined => {
const ext = extOfPath(path)
// Single pass in priority order (descending; stable for equal
// priorities — insertion order). Each descriptor gets first refusal in
// its own turn: `detect` (when head bytes are available) beats its own
// `exts`, and `exts: []` is a catch-all matching any path — so the
// catch-all `code` viewer (-100) only sees paths no higher-priority
// descriptor claimed. Disabled viewers are skipped entirely.
for (const v of Array.from(viewers.values()).sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
)) {
if (!isViewerEnabled(v.id)) continue
// Content sniff first (only when head bytes are available).
if (head !== undefined && v.detect !== undefined) {
if (v.detect(path, head)) return v
// A catch-all with detect is SNIFF-ONLY: it must not blind-claim
// paths it never sniffed (a magic-number viewer must not swallow
// every file before the real viewers get their turn).
if (v.exts.length === 0) continue
} else if (v.exts.length === 0) {
// Blind catch-all (no detect) claims anything; a sniff-only
// catch-all (detect defined, no head yet) yields this round.
if (v.detect === undefined) return v
continue
}
if (v.exts.includes(ext)) return v
}
return undefined
}
const openTab = (seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string }): void => {
// A type the user disabled in settings never opens — neither from the
// + menu nor from derived flows (file opens, subagent auto-open,
// external plugins). Already-open tabs keep rendering.
if (!isTabEnabled(seed.type)) {
console.warn(`[dsh-better-sidebar] tab type "${seed.type}" is disabled in the side card settings`)
return
}
store.reduce((state) => {
const descriptor = tabs.get(seed.type)
if (descriptor === undefined) return state
// Let the descriptor mint the tab (terminal's nextTerminal bump, etc.).
if (descriptor.createTab !== undefined) {
const result = descriptor.createTab(state)
if (result === null) return state
const next = applyDedupe(state, result.tab, descriptor)
return result.patch !== undefined ? { ...next, ...result.patch } : next
}
const tab: SidebarTab = {
id: seed.id ?? seed.type,
type: seed.type,
// A caller-provided title wins (the editor shows the file name);
// otherwise the descriptor's (possibly i18n) title is the default.
title: seed.title ?? (typeof descriptor.title === 'function' ? descriptor.title() : descriptor.title),
...(seed.path !== undefined ? { path: seed.path } : {}),
...(seed.diff !== undefined ? { diff: seed.diff } : {}),
}
return applyDedupe(state, tab, descriptor)
})
}
const closeTab = (tabId: string): void => {
store.reduce((state) => {
const paneId = findPaneIdOf(state, tabId)
if (paneId === '') return state
return closeTabReducer(state, paneId, tabId)
})
}
return {
registerTab,
registerFileViewer,
getTabs,
getFileViewers,
getTab,
isTabEnabled,
isViewerEnabled,
matchFileViewer,
openTab,
closeTab,
subscribe,
}
}
/**
* Apply dedup: if a tab whose `dedupeKey` matches an existing tab of the
* same type exists, focus it; otherwise land the tab through
* `openTabInActivePane` (the id safety net + active-pane landing are that
* reducer's job — not re-implemented here).
* `single: true` resolves to the id-key sugar when no explicit key is given.
*/
function applyDedupe(state: SidebarState, tab: SidebarTab, descriptor: TabDescriptor): SidebarState {
const dedupeKey = descriptor.dedupeKey ?? (descriptor.single === true ? () => descriptor.id : undefined)
const key = dedupeKey?.(tab)
if (key !== undefined) {
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(t => t.type === tab.type && dedupeKey!(t) === key)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
}
return openTabInActivePane(state, tab)
}
/** Find which pane hosts a tab id ('' if none). */
function findPaneIdOf(state: SidebarState, tabId: string): string {
for (const leaf of allLeaves(state.splits)) {
if (leaf.tabs.some(t => t.id === tabId)) return leaf.id
}
return state.activePane ?? ''
}
File diff suppressed because it is too large Load Diff
+288
View File
@@ -0,0 +1,288 @@
/**
* The split-pane workbench: renders the recursive split tree. A split lays
* children out row- or column-wise with draggable dividers (fractional
* sizes); a leaf renders its tab strip plus the active tab's content.
*
* Splitting is VSCode-style DRAG-TO-EDGE, not buttons: while dragging a tab
* over a pane, a drop overlay shows five zones — four edges (left/right/up/
* down) that split the pane with the tab in a fresh leaf, and the center
* 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, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
import type { DropZone } from './state.ts'
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
import css from './sidebar.module.css'
/** Actions the workbench needs (bound to the store by the sidebar shell). */
export interface WorkbenchActions {
closeTab: (paneId: string, tabId: string) => void
activateTab: (paneId: string, tabId: string) => void
/** Make a pane the target of newly opened tabs (click focus). */
focusPane: (paneId: string) => void
/** VSCode drag gesture: edge → split the target pane, center → merge. */
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => void
/** Reorder within a pane (drop onto another tab inserts before it). */
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
}
/** One divider: pointer-capture drag translating px deltas into fractions.
* Deltas are incremental — each move reports the displacement since the
* previous move — because the store adds every reported delta to the pane
* sizes; a cumulative (since-pointer-down) delta would be re-added on each
* move and the divider would run away from the cursor. */
function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
const { dir, onResize } = props
// `last` is the previous pointer position while dragging; `size` is the
// container size at pointer-down (constant during the drag), used to
// normalize the px delta into a fraction.
const last = useRef({ x: 0, y: 0, size: 0 })
const [dragging, setDragging] = useState(false)
return (
<div
className={clsx(css.divider, dir === 'row' ? css.dividerRow : css.dividerCol, dragging && css.dividerActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
const box = event.currentTarget.parentElement?.getBoundingClientRect()
last.current = {
x: event.clientX,
y: event.clientY,
size: box === undefined ? 1 : (dir === 'row' ? box.width : box.height),
}
setDragging(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
onResize(delta / Math.max(1, last.current.size))
last.current.x = event.clientX
last.current.y = event.clientY
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
setDragging(false)
}}
/>
)
}
/** Map a pointer position inside a pane to the VSCode drop zone (25% edges). */
function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone {
const rect = pane.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return 'center'
const x = (event.clientX - rect.left) / rect.width
const y = (event.clientY - rect.top) / rect.height
if (x < 0.25) return 'left'
if (x > 0.75) return 'right'
if (y < 0.25) return 'up'
if (y > 0.75) return 'down'
return 'center'
}
/** The icon of one openable type card (mirror of the + menu options). */
/**
* An empty pane's welcome cards: the openable types as cards, clicked to
* open (instead of a bare "this pane is empty" message).
*/
function PaneEmptyCards(props: {
newTabOptions: NewTabOption[]
onNewTab: (optionId: string) => void
}) {
const { newTabOptions, onNewTab } = props
return (
<div className={css.paneEmptyCards}>
{newTabOptions.map(option => (
<button
key={option.id}
type="button"
className={css.paneCard}
disabled={option.disabled === true}
title={option.label}
onClick={() => { onNewTab(option.id) }}
>
{option.icon ?? null}
<span>{option.label}</span>
</button>
))}
</div>
)
}
/** A leaf: tab strip + active content + VSCode-style drop target for tabs. */
function LeafView(props: {
leaf: { id: string; tabs: SidebarTab[]; active: string | null }
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
const { leaf, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = 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)}
onPointerDown={() => { actions.focusPane(leaf.id) }}
onDragOver={(event) => {
event.preventDefault()
const zone = zoneAt(event, event.currentTarget)
setDropZone(zone)
}}
onDragLeave={(event) => {
// Only clear when the pointer left the pane entirely (not onto a child).
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setDropZone(null)
}
}}
onDrop={(event) => {
event.preventDefault()
const zone = dropZone ?? zoneAt(event, event.currentTarget)
setDropZone(null)
const payload = parseDrag(event.dataTransfer.getData('application/x-dsh-tab'))
if (payload !== null) actions.moveTabToEdge(payload, leaf.id, zone)
}}
>
{dropZone !== null && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />}
{/*
The tab strip renders even for an empty pane: the + menu must stay
reachable when the pane has no tabs (fresh split, or the last tab was
dragged out), so a new tab can always be created or dragged in.
*/}
<TabBar
paneId={leaf.id}
tabs={leaf.tabs}
active={leaf.active}
onActivate={(tabId) => { actions.activateTab(leaf.id, tabId) }}
onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
onNewTab={onNewTab}
newTabOptions={newTabOptions}
getTabIcon={getTabIcon}
onDropTab={(payload, before) => {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before)
}}
/>
{leaf.tabs.length > 0 ? (
/*
Every tab stays MOUNTED (inactive ones hidden), so switching tabs
never tears down the content: a terminal keeps its pty connection
and scrollback, an editor keeps its CodeMirror view and unsaved
draft, explorer/git keep their loaded data. The unmount (and the
terminal's close frame) happens only when a tab is truly closed.
*/
<div className={css.paneContent}>
{leaf.tabs.map(tab => (
<div
key={tab.id}
className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
>
{renderTab(tab, tab.id === activeTab?.id, leaf.id)}
</div>
))}
</div>
) : (
<PaneEmptyCards newTabOptions={newTabOptions} onNewTab={onNewTab} />
)}
</div>
)
}
/** Recursive node renderer. */
function NodeView(props: {
node: SplitNode
state: SidebarState
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
const { node, state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
if (node.kind === 'leaf') {
return (
<LeafView
leaf={node}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
/>
)
}
const isRow = node.dir === 'row'
return (
<div className={clsx(css.split, isRow ? css.splitRow : css.splitCol)}>
{node.children.map((child, index) => (
<Fragment key={child.id}>
{index > 0 && (
<Divider
dir={node.dir}
onResize={(deltaFrac) => { actions.resizeSplit(node.id, index - 1, deltaFrac) }}
/>
)}
<div
className={css.splitChild}
style={{ flexGrow: node.sizes[index], flexBasis: 0, minWidth: 0, minHeight: 0 }}
>
<NodeView
node={child}
state={state}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
/>
</div>
</Fragment>
))}
</div>
)
}
/** The workbench: the split tree filling the sidebar body. */
export function Workbench(props: {
state: SidebarState
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode
getTabIcon?: (tab: SidebarTab) => ReactNode
}) {
const { state, newTabOptions, actions, onNewTab, renderTab, getTabIcon } = props
return (
<div className={css.workbench}>
<NodeView
node={state.splits}
state={state}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
getTabIcon={getTabIcon}
/>
</div>
)
}
+796
View File
@@ -0,0 +1,796 @@
/**
* Per-session sidebar state: the panel geometry, the split-pane workbench
* tree, open tabs, and the explorer expansion set. One state instance per
* conversation id, persisted to localStorage under `dsh-sidebar:v1:<id>` so
* a reload restores the exact layout of the session it belongs to — switching
* conversations swaps the whole state (memory + isolation).
*
* The split tree is a recursive structure: a leaf holds a tab group, a split
* 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.
*/
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
/**
* Tab type identifier. Builtins register their ids (explorer / git / editor
* / terminal / subagent / diff) through the sidebar service; external
* plugins register their own (e.g. `'my-plugin:db'`). Kept as `string` so
* the registry stays open.
*/
export type TabType = string
/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */
export type SidebarDiffRef =
| { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean }
| { kind: 'commit'; hash: string; hashFull: string; subject: string }
/** One open tab. `path` carries the file (editor) or is absent (explorer/git);
* `diff` carries the change a diff tab shows. */
export interface SidebarTab {
id: string
type: TabType
title: string
path?: string
diff?: SidebarDiffRef
}
/** A tab group. */
export interface SidebarLeaf {
kind: 'leaf'
id: string
tabs: SidebarTab[]
active: string | null
}
/** A recursive split between child panes (fractional sizes summing to 1). */
export interface SidebarSplit {
kind: 'split'
id: string
dir: 'row' | 'col'
sizes: number[]
children: SplitNode[]
}
export type SplitNode = SidebarLeaf | SidebarSplit
/** The full per-session state. */
export interface SidebarState {
panelOpen: boolean
width: number
/** The pane receiving newly opened tabs (last pane the user touched). */
activePane: string | null
/** Monotonic terminal tab counter (ids survive reloads). */
nextTerminal: number
/** Explorer expansion set (absolute directory paths). */
expanded: string[]
splits: SplitNode
}
export const PANEL_MIN = 280
export const PANEL_MAX = 640
export const PANEL_DEFAULT = 400
export const TAB_MAX_WIDTH = 160
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
function uid(prefix: string): string {
nextIdCounter += 1
return `${prefix}:${nextIdCounter}`
}
/**
* The largest numeric suffix across a raw persisted state's counter ids
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
* resets on every reload, so a split minted AFTER a reload would collide
* with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
* mapLeaf would then visit BOTH leaves and every open would land in both
* panes of the split. Seeding the counter past the persisted ids keeps
* fresh ids disjoint.
*/
function maxCounterId(parsed: unknown): number {
let max = 0
const consider = (id: unknown): void => {
if (typeof id !== 'string') return
const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
if (match !== null) max = Math.max(max, Number(match[1]))
}
const walk = (node: unknown): void => {
if (node === null || typeof node !== 'object') return
const record = node as Record<string, unknown>
consider(record.id)
if (Array.isArray(record.tabs)) {
for (const tab of record.tabs) {
if (tab !== null && typeof tab === 'object') consider((tab as Record<string, unknown>).id)
}
}
if (Array.isArray(record.children)) {
for (const child of record.children) walk(child)
}
}
walk((parsed as Record<string, unknown> | null)?.splits)
return max
}
/** 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.
* `seedExplorer` places the default explorer tab — the store passes false
* when the user disabled the explorer tab type in settings, so a fresh
* session starts with an empty pane instead of a tab they turned off. */
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true, seedExplorer = true): SidebarState {
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
if (seedExplorer) {
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
leaf.active = leaf.tabs[0]!.id
}
return {
panelOpen,
width,
activePane: leaf.id,
nextTerminal: 1,
expanded: [],
splits: leaf,
}
}
/** Walk the tree and apply `visit` to the leaf with the given id. */
export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode {
if (node.kind === 'leaf') {
if (node.id === paneId) {
const copy: SidebarLeaf = { ...node, tabs: [...node.tabs] }
visit(copy)
return copy
}
return node
}
const split = node
return {
...split,
sizes: [...split.sizes],
children: split.children.map(child => mapLeaf(child, paneId, visit)),
}
}
/** The first leaf of the tree (fallback pane when activePane is gone). */
export function firstLeaf(node: SplitNode): SidebarLeaf {
if (node.kind === 'leaf') return node
return firstLeaf(node.children[0]!)
}
/** Find the leaf containing a tab id, if any. */
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
if (node.kind === 'leaf') {
return node.tabs.some(tab => tab.id === tabId) ? node : undefined
}
for (const child of node.children) {
const found = leafWithTab(child, tabId)
if (found !== undefined) return found
}
return undefined
}
/** All leaves of the tree, depth-first. */
export function allLeaves(node: SplitNode): SidebarLeaf[] {
if (node.kind === 'leaf') return [node]
return node.children.flatMap(allLeaves)
}
/** Whether a tab exists anywhere in a state tree (any pane). */
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
}
/** Replace a leaf with a split of it plus a fresh empty leaf. */
export function splitLeafAt(node: SplitNode, paneId: string, dir: 'row' | 'col'): SplitNode {
const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
return mapLeaf(node, paneId, (leaf) => {
const target: SidebarLeaf = { ...leaf }
const split: SidebarSplit = {
kind: 'split',
id: uid('split'),
dir,
sizes: [0.5, 0.5],
children: [target, fresh],
}
Object.assign(leaf, split)
})
}
/**
* Split a leaf by inserting a fresh leaf holding `tab` beside it — the
* VSCode drag-to-edge gesture. `dir` is the split direction ('row' for
* left/right, 'col' for up/down); `front` places the new leaf first (left/
* up) or second (right/down).
* @returns the new tree plus the fresh leaf's id (the drop's active pane).
*/
export function insertLeafAt(
node: SplitNode,
paneId: string,
dir: 'row' | 'col',
tab: SidebarTab,
front: boolean,
): { node: SplitNode; leafId: string } {
const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [tab], active: tab.id }
const leafId = fresh.id
const next = mapLeaf(node, paneId, (leaf) => {
const target: SidebarLeaf = { ...leaf }
const split: SidebarSplit = {
kind: 'split',
id: uid('split'),
dir,
sizes: [0.5, 0.5],
children: front ? [fresh, target] : [target, fresh],
}
Object.assign(leaf, split)
})
return { node: next, leafId }
}
/** Where a tab drop lands on a pane: an edge creates a split, center merges. */
export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center'
/**
* The VSCode drag gesture: move a tab out of its pane and either merge it
* into the target pane (center) or split the target pane with the tab in a
* fresh leaf (edge). The source pane collapses when it empties.
*/
export function moveTabToEdge(
state: SidebarState,
fromPane: string,
tabId: string,
toPane: string,
zone: DropZone,
): SidebarState {
if (fromPane === toPane && zone === 'center') {
// Dropped back onto its own pane's center: reorder to the end.
return moveTab(state, fromPane, tabId, toPane, -1)
}
const source = leafWithTab(state.splits, tabId)
if (source === undefined) return state
const tab = source.tabs.find(candidate => candidate.id === tabId)!
let emptied = false
let splits = mapLeaf(state.splits, source.id, (leaf) => {
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
if (emptied) splits = removeLeafAt(splits, source.id)
if (zone === 'center') {
splits = mapLeaf(splits, toPane, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
})
return { ...state, splits, activePane: toPane }
}
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
return { ...state, splits: result.node, activePane: result.leafId }
}
/**
* Remove a leaf from the tree. A split left with one child promotes that
* child; removing the last leaf yields an empty leaf.
*/
export function removeLeafAt(node: SplitNode, paneId: string): SplitNode {
if (node.kind === 'leaf') return node.id === paneId ? { ...node, tabs: [], active: null } : node
const children = node.children.filter(child => !(child.kind === 'leaf' && child.id === paneId))
if (children.length === node.children.length) {
return {
...node,
sizes: [...node.sizes],
children: node.children.map(child => removeLeafAt(child, paneId)),
}
}
if (children.length === 1) return children[0]!
return { ...node, sizes: [...node.sizes], children }
}
/** Close a tab; an emptied leaf is removed (unless it is the only pane). */
export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
let emptied = false
const splits = mapLeaf(state.splits, paneId, (leaf) => {
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
return { ...state, splits: emptied ? removeLeafAt(splits, paneId) : splits }
}
/** Activate a tab in its pane. */
export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
return {
...state,
activePane: paneId,
splits: mapLeaf(state.splits, paneId, (leaf) => {
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
}),
}
}
/**
* Land a tab in the active pane (or focus its existing instance by id).
* Dedup strategies (single-instance, per-path, per-change) are owned by the
* tab descriptor through {@link BetterSidebarService.openTab} / `dedupeKey`;
* this reducer only handles the id-based safety net (reconcile and
* openDiffTab already check existence before calling) and the landing
* itself — the service's dedupe path delegates here after its dedupeKey
* check misses.
*/
export function openTabInActivePane(state: SidebarState, tab: SidebarTab): SidebarState {
let targetId = state.activePane ?? firstLeaf(state.splits).id
// A stale activePane (its pane was closed since) must not swallow the
// open: fall back to the first pane instead of dropping the tab.
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
targetId = firstLeaf(state.splits).id
}
// Id-based safety net: if a tab with the same id exists, focus it.
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(candidate => candidate.id === tab.id)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
return {
...state,
activePane: targetId,
splits: mapLeaf(state.splits, targetId, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
}
}
/** Move a tab from one pane to another (insert at index; -1 appends). */
export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState {
let moved: SidebarTab | undefined
let emptied = false
let splits = mapLeaf(state.splits, fromPane, (leaf) => {
const found = leaf.tabs.find(tab => tab.id === tabId)
if (found === undefined) return
moved = found
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
if (moved === undefined) return state
if (emptied) splits = removeLeafAt(splits, fromPane)
splits = mapLeaf(splits, toPane, (leaf) => {
const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
leaf.active = moved!.id
})
return { ...state, splits, activePane: toPane }
}
/** Split the active pane (or the pane containing the active tab). */
export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState {
const paneId = state.activePane ?? firstLeaf(state.splits).id
return { ...state, splits: splitLeafAt(state.splits, paneId, dir) }
}
/**
* Open a diff tab the VSCode way: an existing instance of the same change is
* focused wherever it lives; otherwise the tab joins the first pane that
* already holds diff tabs (diff panes are sticky — repeated clicks stack
* there); on the FIRST diff of a layout the source pane splits vertically so
* the diff lands in a fresh pane below it ("默认在下半栏新增一个").
*
* This is split-tree placement surgery, not registry dispatch: the diff tab
* descriptor's `dedupeKey` is `(tab) => tab.id`, and the existing-instance
* check below is exactly that rule — the two agree by construction (asserted
* in tests). Diff tabs minted by the Git view carry change-derived ids, so
* the id check is the per-change dedupe.
* @returns the new state, with the diff pane active.
*/
export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState {
const existingLeaf = leafWithTab(state.splits, tab.id)
if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id)
const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff'))
if (diffLeaf !== undefined) {
return {
...state,
activePane: diffLeaf.id,
splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
}
}
// First diff: split the source pane, the diff tab in the new LOWER leaf.
// (A stale sourcePaneId — its pane closed meanwhile — degrades to the
// regular open path instead of dropping the tab into an orphaned leaf.)
if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) {
return openTabInActivePane(state, tab)
}
const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false)
return { ...state, splits: result.node, activePane: result.leafId }
}
/** Toggle the panel open/closed (opening restores the previous layout). */
export function togglePanel(state: SidebarState): SidebarState {
return { ...state, panelOpen: !state.panelOpen }
}
/** Set the panel width (clamped to the contract range; the upper bound is
* the viewport so the fullscreen expansion can fill the window). */
export function setWidth(state: SidebarState, width: number): SidebarState {
const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) }
}
/** Toggle a directory in the explorer expansion set. */
export function toggleExpanded(state: SidebarState, path: string): SidebarState {
const expanded = state.expanded.includes(path)
? state.expanded.filter(item => item !== path)
: [...state.expanded, path]
return { ...state, expanded }
}
/** Adjust one split divider: `i` is the left/top child index, delta in fractions. */
export function resizeSplit(node: SplitNode, splitId: string, index: number, delta: number): SplitNode {
if (node.kind === 'leaf') return node
if (node.id === splitId) {
const sizes = [...node.sizes]
const left = Math.min(0.92, Math.max(0.08, sizes[index]! + delta))
const right = Math.min(0.92, Math.max(0.08, sizes[index + 1]! - delta))
sizes[index] = left
sizes[index + 1] = right
return { ...node, sizes }
}
return {
...node,
sizes: [...node.sizes],
children: node.children.map(child => resizeSplit(child, splitId, index, delta)),
}
}
/** Prefix marking a tab id as an agent-owned terminal (suffix is the uuid). */
export const AGENT_TAB_PREFIX = 'agent:'
/** Whether a tab id refers to an agent-owned terminal. */
export function isAgentTabId(tabId: string): boolean {
return tabId.startsWith(AGENT_TAB_PREFIX)
}
/** Extract the agent terminal uuid from an `agent:<uuid>` tab id. */
export function agentUuidOf(tabId: string): string {
return tabId.slice(AGENT_TAB_PREFIX.length)
}
/** Build the sidebar tab id for one agent terminal uuid. */
export function agentTabId(uuid: string): string {
return `${AGENT_TAB_PREFIX}${uuid}`
}
/**
* Reconcile the sidebar's agent-terminal tabs with the host's live list.
* The host pushes the current list of agent terminals (created by the model
* through the `terminal_create` tool) over a dedicated WebSocket; this
* reducer mirrors that list into tabs: new uuids get a tab, vanished uuids
* lose theirs. The agent owns the lifetime — the user closing a tab sends a
* WS close frame that kills the pty, which fires a change, which converges
* the view. Idempotent: a no-op when the lists already match.
* @param state - the current per-session sidebar state.
* @param agentTerminals - the live agent terminal snapshots from the host.
* @returns the next state (or the same reference if no change was needed).
*/
export function reconcileAgentTerminals(
state: SidebarState,
agentTerminals: ReadonlyArray<{ uuid: string; title: string }>,
): SidebarState {
const existingTabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
const existingAgentTabs = existingTabs.filter(tab => isAgentTabId(tab.id))
const existingUuids = new Set(existingAgentTabs.map(tab => agentUuidOf(tab.id)))
const serverUuids = new Set(agentTerminals.map(t => t.uuid))
const toAdd = agentTerminals.filter(t => !existingUuids.has(t.uuid))
const toRemove = existingAgentTabs.filter(tab => !serverUuids.has(agentUuidOf(tab.id)))
if (toAdd.length === 0 && toRemove.length === 0) return state
// Remove tabs whose uuids vanished from the server list (the agent closed
// them, or the pty exited and was reaped). Reuse closeTab's leaf cleanup.
let splits = state.splits
for (const tab of toRemove) {
const leaf = leafWithTab(splits, tab.id)
if (leaf !== undefined) {
splits = closeTab({ ...state, splits }, leaf.id, tab.id).splits
}
}
// Add tabs for new uuids (the agent created a terminal). They land in the
// active pane via openTabInActivePane; the next reconcile is a no-op for them.
let next: SidebarState = { ...state, splits }
for (const terminal of toAdd) {
const tab: SidebarTab = {
id: agentTabId(terminal.uuid),
type: 'terminal',
title: terminal.title,
}
next = openTabInActivePane(next, tab)
}
return next
}
// ── The per-session store ──────────────────────────────────────────────────
const STORAGE_PREFIX = 'dsh-sidebar:v1'
/** Immutable snapshot handed to React (replaced only on real changes). */
export interface SidebarSnapshot {
sessionId: string | undefined
state: SidebarState | undefined
/**
* The current side card prefs. Carried IN the snapshot (not a separate
* subscription) so prefs changes re-render the consumers that gate on
* them — the + menu hides a tab type the moment its switch flips.
*/
prefs: SidebarPrefs
}
/** 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) {
const parsed = JSON.parse(raw) as unknown
// Seed the uid counter past the persisted ids (it resets on reload);
// sanitize re-ids any duplicates the pre-seeding counter left behind.
nextIdCounter = maxCounterId(parsed)
const sanitized = sanitizeState(parsed)
if (sanitized !== undefined) return sanitized
}
} catch {
// Corrupt or unavailable storage: fall through to the default.
}
// 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), the panel
// starts open only when the preference says so, and the default explorer
// tab is skipped when the user disabled the explorer tab type.
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
const width = viewport === undefined
? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
return makeDefaultState(width, prefs.openByDefault, prefs.tabsEnabled['explorer'] !== false)
}
/**
* Structural validation of one persisted state. A malformed or stale shape
* (older layouts, hand-edited storage) must fall back to the default instead
* of crashing the panel on every reload; the restored width is also clamped
* to the current viewport so a stale fullscreen width can never crush the
* app shell (margin-right larger than the window) or cover the whole screen.
* @returns a clean state, or undefined to fall back to the default.
*/
export function sanitizeState(parsed: unknown): SidebarState | undefined {
if (parsed === null || typeof parsed !== 'object') return undefined
const record = parsed as Record<string, unknown>
if (typeof record.panelOpen !== 'boolean') return undefined
if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
return undefined
}
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
const reid = new Map<string, string>()
const splits = sanitizeNode(record.splits, new Set(), reid)
if (splits === undefined) return undefined
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
return {
panelOpen: record.panelOpen,
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
// A stale duplicate pane id may have been re-ided; follow the rename so
// new tabs still land in the pane the user was using.
activePane: typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null,
nextTerminal: record.nextTerminal,
expanded: record.expanded as string[],
splits,
}
}
/**
* One tree node id, deduplicated against the ids already seen in this
* state. Duplicates are exactly the pre-seeding counter-reset corruption
* (a "pane:1"/"split:1" minted after a reload beside the persisted ones):
* keeping both would make mapLeaf visit two leaves at once and every open
* would land in both panes, so the repeat gets a fresh id.
* @returns the id to use (the original, or a fresh uid for repeats).
*/
function uniqueNodeId(id: string, seen: Set<string>, reid: Map<string, string>): string {
if (!seen.has(id)) {
seen.add(id)
return id
}
const prefix = /^split:\d+$/.test(id) ? 'split' : 'pane'
const fresh = uid(prefix)
seen.add(fresh)
reid.set(id, fresh)
return fresh
}
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string>): SplitNode | undefined {
if (node === null || typeof node !== 'object') return undefined
const record = node as Record<string, unknown>
if (record.kind === 'leaf') {
if (typeof record.id !== 'string' || !Array.isArray(record.tabs)) return undefined
const tabs: SidebarTab[] = []
let droppedDiff = false
for (const tab of record.tabs) {
if (tab === null || typeof tab !== 'object') return undefined
const candidate = tab as Record<string, unknown>
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
// Diff tabs are ephemeral by nature (like VSCode's diff editors):
// they never survive a reload, so a stale ref (e.g. a wrong staged
// side persisted by an older build) cannot resurface as a dead tab
// showing "no text changes" after every refresh.
if (candidate.type === 'diff') {
droppedDiff = true
continue
}
// Tab types are an open set (external plugins register their own);
// accept any string type here — an unregistered type renders an
// <OrphanedTab/> at view time and recovers if its plugin loads later.
if (typeof candidate.type !== 'string') return undefined
tabs.push({
id: candidate.id,
type: candidate.type,
title: candidate.title,
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
})
}
const active = typeof record.active === 'string' ? record.active : null
// An active pointer into a dropped diff tab is expected after the drop;
// any other missing active is structural corruption → reset the state.
if (active !== null && !tabs.some(tab => tab.id === active) && !droppedDiff) return undefined
return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active: active !== null && tabs.some(tab => tab.id === active) ? active : null }
}
if (record.kind === 'split') {
if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
if (!Array.isArray(record.children) || !Array.isArray(record.sizes)) return undefined
const children: SplitNode[] = []
for (const child of record.children) {
const clean = sanitizeNode(child, seen, reid)
if (clean === undefined) return undefined
children.push(clean)
}
if (children.length < 2) return undefined
if (
record.sizes.length !== children.length
|| record.sizes.some(size => typeof size !== 'number' || !Number.isFinite(size) || size <= 0)
) {
return undefined
}
return { kind: 'split', id: uniqueNodeId(record.id, seen, reid), dir: record.dir, sizes: record.sizes as number[], children }
}
return undefined
}
/** The session-scoped store: one state per conversation, localStorage-backed. */
export class SidebarStore {
private readonly bySession = new Map<string, SidebarState>()
private snapshot: SidebarSnapshot = {
sessionId: undefined,
state: undefined,
prefs: { ...SIDEBAR_PREFS_DEFAULTS },
}
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). Notifies like any store change: the snapshot carries the prefs,
* so consumers that gate on enable switches (the + menu, derived flows)
* re-render with the new values immediately.
*/
setPrefs(prefs: SidebarPrefs): void {
this.prefs = { ...prefs }
this.snapshot = { ...this.snapshot, prefs: this.prefs }
this.notify()
}
/** 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 {
if (this.snapshot.sessionId === sessionId) return
if (sessionId === undefined) {
this.snapshot = { sessionId: undefined, state: undefined, prefs: this.prefs }
} else {
let state = this.bySession.get(sessionId)
if (state === undefined) {
state = loadState(sessionId, this.prefs)
this.bySession.set(sessionId, state)
} else {
// Cache hit: another session's load/ops may have left the uid
// counter below THIS session's persisted ids — re-seed so fresh
// pane/split ids can never collide with its tree.
nextIdCounter = maxCounterId(state)
}
this.snapshot = { sessionId, state, prefs: this.prefs }
}
this.notify()
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
getSnapshot(): SidebarSnapshot {
return this.snapshot
}
/** Mutate the current session's state (no-op without a session). */
update(mutator: (draft: SidebarState) => void): void {
const sessionId = this.snapshot.sessionId
const state = this.snapshot.state
if (sessionId === undefined || state === undefined) return
const draft = structuredClone(state)
mutator(draft)
this.bySession.set(sessionId, draft)
this.snapshot = { sessionId, state: draft, prefs: this.prefs }
this.schedulePersist(sessionId, draft)
this.notify()
}
/**
* Whether a tab still exists in its session's state. Views use this on
* unmount to tell "the tab was closed" (release the terminal now) from
* "the tree re-rendered / the conversation switched" (the tab is still
* open — keep the terminal alive through the host's reconnect grace).
* Checks the session's own map entry (the current snapshot may already
* point at another session when a conversation switch unmounts the old
* one's tabs).
*/
tabOpen(sessionId: string, tabId: string): boolean {
const state = this.bySession.get(sessionId)
?? (this.snapshot.sessionId === sessionId ? this.snapshot.state : undefined)
return state !== undefined && tabOpenIn(state, tabId)
}
/** Apply a pure reducer (returns the next state). */
reduce(reducer: (state: SidebarState) => SidebarState): void {
const sessionId = this.snapshot.sessionId
const state = this.snapshot.state
if (sessionId === undefined || state === undefined) return
const next = reducer(state)
this.bySession.set(sessionId, next)
this.snapshot = { sessionId, state: next, prefs: this.prefs }
this.schedulePersist(sessionId, next)
this.notify()
}
private schedulePersist(sessionId: string, state: SidebarState): void {
window.clearTimeout(this.persistTimer)
this.persistTimer = window.setTimeout(() => {
try {
localStorage.setItem(`${STORAGE_PREFIX}:${sessionId}`, JSON.stringify(state))
} catch {
// Storage full or unavailable: layout memory is best-effort.
}
}, 200)
}
private notify(): void {
for (const listener of [...this.listeners]) listener()
}
}
/**
* Create one sidebar store instance. Production code calls this only from
* the client plugin's `apply` (the instance is handed to components as a
* prop); tests call it directly. No module-level singleton: the store's
* lifetime belongs to the plugin activation, exactly like the official
* `createXXXStore()` factory rule.
*/
export function createSidebarStore(): SidebarStore {
return new SidebarStore()
}
+67
View File
@@ -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 }),
}
}
+125
View File
@@ -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
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Live theme access for surfaces that cannot consume the token colors
* directly — xterm's palette and CodeMirror's theme extensions need concrete
* values, but the app's scheme flips at runtime (ui-layout's ThemePresenter
* projects prefers-color-scheme and the user's choice onto
* body[data-ds-dark-theme] and html { color-scheme }). This module reads the
* resolved scheme and token values, and notifies subscribers on flips, so
* the terminal and the editor re-theme in place instead of freezing in the
* scheme they happened to be created under.
*/
/** Whether the app shell resolved to the dark scheme.
*
* The presenter sets `html { color-scheme }` together with the body palette
* attribute, so a set color-scheme means the decision is authoritative (an
* absent attribute is then LIGHT even when the OS prefers dark — the user
* chose light). Before the presenter has run, fall back to the OS media
* query as the best guess.
*/
export function isDarkScheme(): boolean {
if (typeof document === 'undefined') return true
const decided = document.documentElement.style.colorScheme !== ''
if (decided) return document.body.hasAttribute('data-ds-dark-theme')
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches
}
/** One token's computed value on <body> ('' while the theme has not applied). */
export function tokenValue(name: string): string {
if (typeof document === 'undefined') return ''
return getComputedStyle(document.body).getPropertyValue(name).trim()
}
/**
* Subscribe to color-scheme flips (the presenter toggles the body
* attribute). The callback fires after the attribute changed; re-read the
* scheme inside it.
* @returns the disposer.
*/
export function subscribeColorScheme(callback: () => void): () => void {
if (typeof document === 'undefined') return () => {}
const observer = new MutationObserver(() => { callback() })
observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
return () => { observer.disconnect() }
}
+260
View File
@@ -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 }
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Serializable configuration and defaults for the sidebar host half. Loader
* schema validation normally fills defaults; {@link resolveSidebarConfig}
* applies the same defaults for direct callers that bypass the Loader.
* @module dsh-better-sidebar/config
*/
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 {
/** Read cap of one text file (bytes); larger files return truncated. */
readLimit?: number
/** Media route cap (bytes); larger binaries are refused. */
mediaLimit?: number
/** Explorer row bound of one level. */
listLimit?: number
/** Terminals per session. */
terminalsPerSession?: number
/** How long a disconnected terminal process survives awaiting a reconnect. */
reconnectGraceMs?: number
}
/** Schemastery schema for the plugin configuration. */
export const Config: z<SidebarConfig> = z.object({
readLimit: z.number().step(1).min(1).default(512 * 1024),
mediaLimit: z.number().step(1).min(1).default(20 * 1024 * 1024),
listLimit: z.number().step(1).min(1).default(1000),
terminalsPerSession: z.number().step(1).min(1).default(3),
reconnectGraceMs: z.number().step(1).min(0).default(30_000),
})
/** Fully defaulted sidebar host settings. */
export interface ResolvedSidebarConfig {
readLimit: number
mediaLimit: number
listLimit: number
terminalsPerSession: number
reconnectGraceMs: number
}
/**
* Apply direct-call defaults after Loader schema validation has normally run.
*
* @param config - Deployment-provided sidebar host settings.
* @returns Complete settings consumed by the host half.
*/
export function resolveSidebarConfig(config: SidebarConfig | undefined): ResolvedSidebarConfig {
return {
readLimit: config?.readLimit ?? 512 * 1024,
mediaLimit: config?.mediaLimit ?? 20 * 1024 * 1024,
listLimit: config?.listLimit ?? 1000,
terminalsPerSession: config?.terminalsPerSession ?? 3,
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),
agentTerminalTools: z.boolean().default(false),
// Per-feature enable switches are OPEN maps (any tab/viewer id, built-in or
// external): an absent key means enabled, so old documents resolve to {}
// (everything on) with no migration. Non-boolean values fail validation.
tabsEnabled: z.dict(z.boolean()).default({}),
viewersEnabled: z.dict(z.boolean()).default({}),
})
+321 -27
View File
@@ -1,33 +1,327 @@
/**
* Structural types for the cordis services apes-sidebar consumes.
*
* PHASE 1: the sidebar tab + session types now come from the REAL
* `dsh-better-sidebar` package (type-only imports in `src/client/index.tsx`):
* `TabDescriptor` / `TabComponentProps` / `BetterSidebarService` /
* `SidebarSessionSummary` / `SidebarSessionList`. The Phase 0 placeholder
* mirrors (`ApesSidebarTabDescriptor`, `ApesSidebarService`,
* `ApesSessionSummary`, `ApesSessionList`, `ApesSessionsService`, and the
* `ApesContext` intersection) were deleted — they are superseded by the
* peer's authoritative types and would drift.
*
* What remains here is the ONE service face apes-sidebar still defines
* itself: the locale runtime mirror (`ApesLocaleService`). `dsh-better-sidebar`
* publishes its own `SidebarLocaleService`, but locales.ts pre-dates the type
* import and stays on this local mirror to keep the i18n module independent
* of the peer (it must compile even when the peer is absent).
*
* This file must stay FREE of Node.js types: it is part of the
* CLIENT-reachable declaration graph, so a Node import here would leak into
* browser-only consumer builds.
* Structural types for the cordis services this plugin consumes, plus the
* Context augmentation both halves share. A third-party plugin resolves
* outside the DSH monorepo's single cordis instance, so the upstream
* `declare module 'cordis'` augmentations do not reach this Context — and
* the npm cordis package does not declare the DSH-vendored runtime members
* (`ctx.effect`, service properties). The members below mirror the actual
* runtime shapes this plugin touches:
* - httpServer: @deepseek-ai/dsh-host-webserver
* - sessions: host side @deepseek-ai/dsh-session (SessionStore), client
* side the runtime ISessions list feed
* - conversation: client side ui-conversation's IConversation (composer
* draft), read lazily through `ctx.get` — cross-plugin service reads need
* an inject declaration, so the direct property is never typed here
* - loader: @cordisjs/plugin-loader (entry options)
* - slots: the client runtime SlotsService
* - effect: the DSH-vendored cordis lifecycle helper
* Drift from upstream is contained to this file.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Duplex } from 'node:stream'
import type { Context } from 'cordis'
import type { BetterSidebarService } from './client/service.ts'
/** One named webserver route (mirror of the host-webserver WebRoute). */
export interface SidebarWebRoute {
kind: 'exact' | 'prefix'
path: string
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
}
/** One exact-path HTTP upgrade registration (mirror of WebUpgradeRoute). */
export interface SidebarWebUpgradeRoute {
path: string
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
}
/** The httpServer service face this plugin uses. */
export interface SidebarHttpServer {
register(route: SidebarWebRoute): () => void
registerUpgrade(route: SidebarWebUpgradeRoute): () => void
}
/** A published session's header slice the sidebar reads (authoritative cwd). */
export interface SidebarSessionHeader {
cwd?: string
}
/** The host session store face (`ctx.sessions.get(id)` returns the live session). */
export interface SidebarSessionStore {
get(id: string): { header: SidebarSessionHeader } | undefined
}
/** One loader entry's options slice (the connection row's resolved config). */
export interface SidebarLoaderEntry {
options: { name: string; config?: unknown }
}
/** The loader face used to read the connection row's trustedHosts config. */
export interface SidebarLoader {
entries(): Iterable<SidebarLoaderEntry>
}
/** Registration options the sidebar passes to `ctx.slots.register` (subset of the real options). */
export interface SidebarSlotRegisterOptions {
name: string
key?: string
id?: string
order?: number
label?: string | (() => string)
/** Chain routing selector (returns the matched value, or null to pass on). */
select?: (owner: unknown) => unknown
priority?: number
locale?: string
registrant?: string
/** Business-face factory; args depend on the slot scope. */
inject?: (...args: any[]) => Record<string, unknown>
children?: Record<string, unknown>
}
/** 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). */
export interface SidebarSessionsService {
list: {
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`. */
export interface SidebarSessionInput {
/** The live input store (draft read for append). */
state: {
getSnapshot(): { draft: string }
}
/** Replace the draft text (the input machine's single public write path). */
setDraft(text: string): void
}
/** The composer draft face the sidebar reaches through `ctx.get('conversation')`. */
export interface SidebarConversation {
input: {
for(actx: Context): SidebarSessionInput
}
}
/**
* The client locale service face (mirror of dsh-client-locale's LocaleRuntime).
* Only the slices apes-sidebar's i18n module touches: snapshot read, snapshot
* subscription, and per-namespace dictionary registration.
* The invariant service face (mirror of @deepseek-ai/dsh-invariants'
* InvariantService). The upstream augmentation does not reach this Context
* (dual-cordis-instance resolution), so the register signature is restated
* structurally, exactly like the other service faces above.
*/
export interface ApesLocaleService {
getSnapshot(): { active: string }
subscribe(fn: () => void): () => void
register(ns: string, locale: string, dict: Record<string, string>): () => void
export interface SidebarInvariantsService {
/** Reserve one package's checks and install them in the service's child fiber. */
register(
packageName: string,
installer: (ctx: Context, fail: (message: string) => never) => void | Promise<void>,
): () => 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>
}
/**
* The tools service face (mirror of @deepseek-ai/dsh-tools' ToolRegistry).
* The host half registers model-facing tools here; the registry attaches the
* returned disposer to the contributing fiber so unloading unregisters them.
*/
export interface SidebarToolsService {
/** Register one tool definition (raw JSON-Schema or defineTool-sugar form). */
register(tool: unknown): () => void
}
/**
* The agent face a tool sees on `exec.agent` (mirror of @deepseek-ai/dsh-agent's
* Agent). Only the slices the terminal tools touch are restated: the live
* session identity and its header cwd, both readonly.
*/
export interface SidebarAgent {
/** The live session identity shared with the session log. */
readonly id: string
/** The live session this agent drives. */
readonly session: {
/** The session's header (validated cwd, lineage metadata). */
readonly header: { readonly cwd?: string }
}
}
declare module 'cordis' {
interface Context {
httpServer: SidebarHttpServer
sessions: SidebarSessionStore & SidebarSessionsService
connection: SidebarConnectionHandle
loader: SidebarLoader
slots: SidebarSlotsService
settings: SidebarSettingsService
invariants: SidebarInvariantsService
tools: SidebarToolsService
/**
* The client-side sidebar registry: external plugins register tab types
* and file previewers here. Provided by the client half (see
* {@link ./client/index.tsx}); undefined on the host side.
*/
betterSidebar: BetterSidebarService
/**
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
* activation; its returned cleanup runs at disposal.
*/
effect(fn: () => void | (() => void), label?: string): void
}
}
export type { Context }
+115
View File
@@ -0,0 +1,115 @@
/**
* Single-level directory listing for the sidebar explorer. Streams the level
* with opendir, sorts directories first then names (case-insensitive), and
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
* reported as files without probing their target — the explorer shows what
* dirent says, keeping the read cheap for arbitrarily large levels.
*/
import { opendir } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import { SidebarError } from './wire.ts'
/** One explorer row. */
export interface SidebarFsEntry {
name: string
path: string
isDir: boolean
hidden: boolean
}
/** One listed level. */
export interface SidebarFsListing {
path: string
entries: SidebarFsEntry[]
truncated: boolean
}
/** Directory-first, case-insensitive name ordering (VSCode explorer order). */
export function compareEntries(a: SidebarFsEntry, b: SidebarFsEntry): number {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
}
/**
* List one directory level.
* @param path - absolute directory path.
* @param maxEntries - row bound of one level (extra rows flag `truncated`).
* @returns the sorted listing.
* @throws {SidebarError} fs-error when the level is unreadable or not a directory.
*/
export async function listDirectory(path: string, maxEntries = 1000): Promise<SidebarFsListing> {
let level
try {
level = await opendir(path)
} catch (error) {
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
}
const rows: SidebarFsEntry[] = []
let overflow = 0
try {
for await (const dirent of level) {
if (rows.length >= maxEntries) {
overflow += 1
continue
}
rows.push({
name: dirent.name,
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
// would leak mixed separators into every row's path.
path: join(path, dirent.name),
isDir: dirent.isDirectory(),
hidden: dirent.name.startsWith('.'),
})
}
} catch (error) {
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
}
rows.sort(compareEntries)
return { path, entries: rows, truncated: overflow > 0 }
}
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
export function rootLabel(path: string): string {
const base = basename(path)
return base !== '' ? base : path
}
/** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */
export function parentOf(path: string): string | undefined {
const parent = dirname(path)
return parent === path ? undefined : parent
}
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
export function requireAbsolute(path: string): string {
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
}
return resolve(path)
}
/**
* Whether `target` lies under `base` (or equals it), tolerant of separator
* style and — on Windows, where the filesystem is case-insensitive — of
* letter case. The media route uses this instead of a raw `startsWith` so a
* case-mismatched or mixed-separator path can never be misclassified
* (e.g. `C:\Users\Me` vs `c:/users/me/file.png`).
* @param platform - filesystem semantics; injectable so both branches are
* unit-testable on any host.
*/
export function isWithin(base: string, target: string, platform: NodeJS.Platform = process.platform): boolean {
const norm = (value: string): string => value.replace(/[\\/]+/g, '/').replace(/\/$/, '')
const b = norm(base)
const t = norm(target)
if (platform === 'win32') {
const lb = b.toLowerCase()
const lt = t.toLowerCase()
return lt === lb || lt.startsWith(`${lb}/`)
}
return t === b || t.startsWith(`${b}/`)
}
/** Message text of an unknown thrown value. */
export function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
+236
View File
@@ -0,0 +1,236 @@
/**
* Git operations for the sidebar source-control panel. Everything goes
* through the system `git` binary spawned per request (no library, no state),
* with porcelain-parseable output formats (`-z` NUL framing, unit separators)
* so parsing never depends on locale or color config. All commands run with
* `-C <cwd>` on the session's working directory and `--no-pager` /
* `-c color.ui=false` so output stays machine-readable.
*
* Commits use the user's git global identity untouched (never sets
* user.name/user.email).
*/
import { spawn } from 'node:child_process'
/** A parsed `git status --porcelain=v1 -z` entry. */
export interface GitStatusEntry {
path: string
/** Two-letter index/worktree status (X Y), e.g. 'M ', ' M', 'A ', '??'. */
xy: string
}
/** The source-control panel snapshot. */
export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
}
/** One `git log` row. */
export interface GitLogEntry {
/** Short hash (7+ chars, display). */
hash: string
/** Full 40-char hash (advanced operations: revert / cherry-pick). */
hashFull: string
subject: string
author: string
/** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */
date: string
/** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
refs: string
}
/** One git failure (stderr text as the message). */
export class GitCommandError extends Error {
constructor(
message: string,
readonly code = 'git-error',
readonly command: string,
) {
super(message)
}
}
/** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
export function parsePorcelainZ(output: string): GitStatusEntry[] {
const tokens = output.split('\0')
const entries: GitStatusEntry[] = []
let index = 0
while (index < tokens.length) {
const token = tokens[index]!
index += 1
if (token === '') continue
const xy = token.slice(0, 2)
const rest = token.slice(3)
entries.push({ path: rest, xy })
// Rename/copy entries carry the ORIGIN path as the next NUL field; the
// new path (the file as it exists now) is the display path.
if ((xy[0] === 'R' || xy[0] === 'C') && tokens[index] !== undefined && tokens[index] !== '') {
index += 1
}
}
return entries
}
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */
export function parseLogLines(output: string): GitLogEntry[] {
const rows: GitLogEntry[] = []
for (const line of output.split('\n')) {
if (line === '') continue
const [hash, subject, author, date, hashFull, refs] = line.split('\x1f')
if (hash === undefined || subject === undefined) continue
rows.push({
hash,
subject,
author: author ?? '',
date: date ?? '',
hashFull: hashFull ?? hash,
refs: refs ?? '',
})
}
return rows
}
/** Run one git command; resolves with stdout, rejects with GitCommandError. */
function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string> {
const full = ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args]
return new Promise<string>((resolvePromise, reject) => {
const child = spawn('git', full, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
})
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new GitCommandError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'git-error', args.join(' ')))
}, timeoutMs)
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
child.on('error', (error) => {
clearTimeout(timer)
reject(new GitCommandError(`cannot run git: ${error.message}`, 'git-error', args.join(' ')))
})
child.on('close', (code) => {
clearTimeout(timer)
if (code === 0) {
resolvePromise(stdout)
} else {
reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, 'git-error', args.join(' ')))
}
})
})
}
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
export async function isGitRepo(cwd: string): Promise<boolean> {
try {
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'])
return out.trim() === 'true'
} catch {
return false
}
}
/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */
export async function repoRoot(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--show-toplevel'])
return out.trim()
}
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
export async function currentBranch(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
return out.trim()
}
/** Working-tree status (untracked included). */
export async function status(cwd: string): Promise<GitStatusResult> {
const repo = await isGitRepo(cwd)
if (!repo) return { isRepo: false, entries: [] }
const [branch, raw] = await Promise.all([
currentBranch(cwd).catch(() => 'HEAD'),
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']),
])
return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
}
/** Diff text of the worktree (unstaged) or the index (staged). */
export async function diff(cwd: string, path: string | undefined, staged: boolean): Promise<string> {
const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
if (staged) args.push('--cached')
if (path !== undefined) args.push('--', path)
return runGit(cwd, args)
}
/** Stage paths (all when path is undefined). */
export async function stage(cwd: string, path: string | undefined): Promise<void> {
await runGit(cwd, ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
}
/** Unstage paths (all when path is undefined). */
export async function unstage(cwd: string, path: string | undefined): Promise<void> {
await runGit(cwd, ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
}
/** Commit the staged changes with a message (global identity untouched). */
export async function commit(cwd: string, message: string): Promise<void> {
await runGit(cwd, ['commit', '-m', message])
}
/** Branch names (current first). */
export async function branches(cwd: string): Promise<{ current: string; names: string[] }> {
const [current, raw] = await Promise.all([
currentBranch(cwd).catch(() => 'HEAD'),
runGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
])
const names = raw.split('\n').filter(line => line !== '')
return { current, names: names.includes(current) ? names : [current, ...names] }
}
/** Switch to an existing branch. */
export async function checkout(cwd: string, branch: string): Promise<void> {
await runGit(cwd, ['checkout', branch])
}
/** Recent commit history (newest first), lazily pageable via skip/count. */
export async function log(cwd: string, count = 30, skip = 0): Promise<GitLogEntry[]> {
const raw = await runGit(cwd, [
'log', '-n', String(count), '--skip', String(skip), '--decorate=short',
'--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D',
])
return parseLogLines(raw)
}
/**
* Content of a file at a revision (`git show <rev>:<path>`), or null when the
* revision has no such path (a new/untracked file has no HEAD side).
*/
export async function show(cwd: string, rev: string, path: string): Promise<string | null> {
try {
return await runGit(cwd, ['show', `${rev}:${path}`])
} catch {
return null
}
}
/** Full patch text of one commit (`git show` with the commit header suppressed).
* Merge commits show their diff against the first parent (`-m --first-parent`
* is a no-op for regular commits), so a history click always has content. */
export async function commitDiff(cwd: string, hash: string): Promise<string> {
return runGit(cwd, ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash])
}
/** Discard the worktree changes of one path (`git checkout -- <path>`; the index is untouched). */
export async function discard(cwd: string, path: string): Promise<void> {
await runGit(cwd, ['checkout', '--', path])
}
/** Revert one commit onto the current branch with an auto-generated message. */
export async function revert(cwd: string, hash: string): Promise<void> {
await runGit(cwd, ['revert', '--no-edit', hash])
}
/** Cherry-pick one commit onto the current branch. */
export async function cherryPick(cwd: string, hash: string): Promise<void> {
await runGit(cwd, ['cherry-pick', hash])
}
+748 -51
View File
@@ -1,60 +1,757 @@
/**
* apes-sidebar host-half entry.
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
* read/write, git), the /sidebar/file media route (images), and the terminal
* WebSocket upgrade. Every route passes the same browser-trust fence as the
* /api gateway — Host-header loopback or the connection row's `trustedHosts`
* (the `dsh web` launcher derives LAN IP literals per boot) — with the
* trustedHosts read live from the connection loader row so the fence never
* drifts from the deployment's.
*
* The host half is intentionally MINIMAL for Phase 0: it mounts a logger
* line so the cordis patch's `insert` row is exercised end-to-end (the
* scratch-profile mount smoke asserts this package's fiber activates).
*
* Phase 2 (Tutti capability layer) will add the host routes the tabs need:
* - subagent topology / job output replay (mirror of better-sidebar's
* subagent-live-route.ts + jobs-routes.ts)
* - agent-programmable open-tab registry (mirror of better-sidebar's
* agent-opens.ts)
* All host routes follow the better-sidebar trust-fence + session-isolation
* model (same-origin loopback fence, per-request cwd from the session store).
*
* The client half (the actual sidebar tabs) lives in src/client/index.tsx and
* is loaded through the package.json `dsh.client.inject` declaration; it
* registers tabs into `ctx.betterSidebar` (better-sidebar's registry).
*
* inject list: Phase 0 needs only `logger` (always present). Phase 2 will
* add `webServer` + `sessions` when the host routes land.
* All operations are conversation-scoped: requests carry a sessionId, the
* session's authoritative cwd comes from the session store, and terminal
* processes are keyed by session.
*/
export const name = 'apes-sidebar'
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, extname, isAbsolute, join } from 'node:path'
import type { IncomingMessage } from 'node:http'
import { WebSocket, WebSocketServer } from 'ws'
import type { Context } from './context-types.ts'
import {
Config,
PrefsSchema,
resolveSidebarConfig,
SIDEBAR_PREFS_NS,
type ResolvedSidebarConfig,
type SidebarConfig,
type SidebarPrefs,
} 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 { AgentPtyRegistry, clampDims, type AgentTerminalHandle } from './agent-pty.ts'
import { registerTools } from './tools.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
/**
* The cordis services the host half injects.
*
* MUST stay empty: the host half uses `ctx.logger` only via optional-chaining
* (`log?.info?.()` inside try/catch) — it is a soft convenience, not a hard
* dependency. Declaring it in `inject` would make the entry wait on a
* `logger` service that no DSH host provider registers, leaving the entry
* `pending` forever and failing the plugin tree load (recovery mode).
* Compare dsh-better-sidebar, whose inject list is services the host
* actually provides (`webServer`/`sessions`/`webRuntime`/`tools`).
*/
export const inject = [] as const
export { Config }
export type { SidebarConfig, ResolvedSidebarConfig }
// Re-export the Context augmentation (declare module 'cordis') so consumers
// `import type {} from 'dsh-better-sidebar'` and gain `ctx.betterSidebar`.
// Also re-export the service descriptor types so consumers can type their
// registerTab / registerFileViewer arguments without reaching into /client.
export type { Context } from './context-types.ts'
export type {
BetterSidebarService,
TabDescriptor,
TabComponentProps,
FileViewerDescriptor,
FileViewerProps,
FileFetchStrategy,
} from './client/service.ts'
/**
* The cordis effect entry: runs once per activation on the Node half.
* Phase 0 — log a mount marker and validate the better-sidebar peer
* version (warn-only, never blocks mount).
*/
export function apply(ctx: { logger?: { info: (msg: string) => void }; effect?: (fn: () => void | (() => void)) => () => void }): void {
const log = ctx.logger
const mount = () => {
try {
log?.info?.('[apes-sidebar] host half mounted (Phase 0 skeleton)')
} catch {
// Never let a logging failure abort the fiber.
/** Plugin identity for cordis.yml rows. */
export const name = 'dsh-better-sidebar'
/** Services required before mounting: the webserver routes, the session store, the loader's connection row, and the tool registry. */
export const inject = ['httpServer', 'sessions', 'loader', 'tools']
/** Content types for the media route, by extension. */
const MEDIA_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.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). */
function trustedHostsOf(ctx: Context): string[] {
for (const entry of ctx.loader.entries()) {
if (entry.options.name === 'connection') {
const config = entry.options.config as { trustedHosts?: string[] } | undefined
return config?.trustedHosts ?? []
}
}
if (typeof ctx.effect === 'function') {
ctx.effect(() => {
mount()
return () => undefined
})
} else {
mount()
return []
}
/**
* Resolve a session's authoritative working directory. The attached session
* header wins; while the session is still hydrating from persistence (the
* web client attaches the current conversation a moment after page load, so
* the very first sidebar requests can arrive detached) the caller's own
* list-summary cwd is used; the process cwd is the last resort (blank
* sessions have no cwd anywhere yet). Never throws for a missing cwd, so
* explorer/git/terminal work from first paint instead of surfacing
* "session ... has no working directory".
*/
function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): string {
const session = ctx.sessions.get(sessionId)
const headerCwd = session?.header.cwd
if (headerCwd !== undefined && headerCwd !== '') return headerCwd
if (clientCwd !== undefined && clientCwd !== '') {
try {
return requireAbsolute(clientCwd)
} catch {
throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
}
}
return process.cwd()
}
/**
* Resolve a path that a git command reported — `git status`/`git diff`
* print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the
* session cwd (a session inside a subdirectory of a repository). Absolute
* paths pass through; relative ones join the repo root (falling back to the
* cwd when the root cannot be resolved, e.g. a bare directory).
*/
async function resolveGitPath(cwd: string, raw: string): Promise<string> {
if (isAbsolute(raw)) return requireAbsolute(raw)
const root = await git.repoRoot(cwd).catch(() => cwd)
return requireAbsolute(join(root, raw))
}
/** How many leading bytes a binary read returns for client-side detect sniffing. */
const READ_HEAD_LIMIT = 4096
/** Text read of a file with the size cap; binary detection via NUL probe.
* Binary reads also return the first {@link READ_HEAD_LIMIT} bytes (base64)
* so the client can re-match viewers by content (`detect`). */
async function readText(path: string, readLimit: number): Promise<{
content: string
truncated: boolean
binary: boolean
size: number
head?: string
}> {
const info = await stat(path).catch((error: unknown) => {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
})
if (info.isDirectory()) {
throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
}
const size = info.size
const truncated = size > readLimit
const handle = await open(path, 'r').catch((error: unknown) => {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
})
try {
const buffer = Buffer.alloc(Math.min(size, readLimit))
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
const slice = buffer.subarray(0, bytesRead)
const binary = slice.includes(0)
const head = binary
? slice.subarray(0, Math.min(slice.length, READ_HEAD_LIMIT)).toString('base64')
: undefined
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size, head }
} finally {
await handle.close()
}
}
/** 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, agent pty registry, and resolved config. */
function buildApi(
ctx: Context,
ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry,
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
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
}
return {
'session.cwd': (payload) => {
const { sessionId, cwd } = cwdOf(payload)
return { sessionId, cwd, root: rootLabel(cwd), parent: parentOf(cwd) ?? null }
},
'fs.tree': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown }
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
return listDirectory(target, resolved.listLimit)
},
'fs.read': async (payload) => {
const { cwd } = cwdOf(payload)
// Relative paths are git-derived (status/diff report repo-root-relative
// names; the untracked diff view reads the file through this route).
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
const { content, truncated, binary, size, head } = await readText(path, resolved.readLimit)
if (binary) return { kind: 'binary', size, truncated, head }
return { kind: 'text', content, truncated }
},
'fs.write': async (payload) => {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
const content = requireString(payload, 'content')
const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`
try {
await mkdir(dirname(path), { recursive: true })
await writeFile(tmp, content, 'utf8')
await rename(tmp, path)
} catch (error) {
await rm(tmp, { force: true }).catch(() => {})
throw new SidebarError('fs-error', `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
return { ok: true }
},
'git.status': async (payload) => {
const { cwd } = cwdOf(payload)
return git.status(cwd)
},
'git.diff': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown; staged?: unknown }
const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path'))
return { diff: await git.diff(cwd, path, record.staged === true) }
},
'git.stage': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown }
const path = record.path === undefined ? undefined : requireString(payload, 'path')
await git.stage(cwd, path)
return { ok: true }
},
'git.unstage': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown }
const path = record.path === undefined ? undefined : requireString(payload, 'path')
await git.unstage(cwd, path)
return { ok: true }
},
'git.commit': async (payload) => {
const { cwd } = cwdOf(payload)
const message = requireString(payload, 'message')
await git.commit(cwd, message)
return { ok: true }
},
'git.branch': async (payload) => {
const { cwd } = cwdOf(payload)
return git.branches(cwd)
},
'git.checkout': async (payload) => {
const { cwd } = cwdOf(payload)
await git.checkout(cwd, requireString(payload, 'branch'))
return { ok: true }
},
'git.log': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { count?: unknown; skip?: unknown }
const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0
? record.count
: undefined
const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0
? record.skip
: undefined
return git.log(cwd, count, skip)
},
'git.commit-diff': async (payload) => {
const { cwd } = cwdOf(payload)
return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) }
},
'git.discard': async (payload) => {
const { cwd } = cwdOf(payload)
await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path')))
return { ok: true }
},
'git.revert': async (payload) => {
const { cwd } = cwdOf(payload)
await git.revert(cwd, requireString(payload, 'hash'))
return { ok: true }
},
'git.cherry-pick': async (payload) => {
const { cwd } = cwdOf(payload)
await git.cherryPick(cwd, requireString(payload, 'hash'))
return { ok: true }
},
'git.show': async (payload) => {
const { cwd } = cwdOf(payload)
const path = await resolveGitPath(cwd, requireString(payload, 'path'))
const rev = requireString(payload, 'rev')
return { content: await git.show(cwd, rev, path) }
},
// Release a terminal immediately. The WebSocket close frame already does
// this while the socket is open; this route covers the tab-close that
// happens while the socket is down (reconnect loop), so a closed tab can
// never hold the per-session quota until the reconnect grace expires.
'pty.close': (payload) => {
const sessionId = requireString(payload, 'sessionId')
const tab = requireString(payload, 'tab')
ptyManager.close(`${sessionId}:${tab}`)
return { ok: true }
},
// Release an agent terminal by uuid. The WS close frame already does
// this while the socket is open; this route covers the tab-close that
// happens while the socket is down (reconnect loop) so a closed agent
// tab never leaves a zombie pty behind. Idempotent.
'agent-pty.close': (payload) => {
const uuid = requireString(payload, 'uuid')
agentPtyRegistry.close(uuid)
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)
}
},
}
}
/**
* Plugin body: mount the fenced routes and the pty lifecycle.
* @param ctx - host plugin context (httpServer, sessions, loader).
* @param config - deployment-provided limits; the Loader validates against
* {@link Config} and fills defaults, direct callers get them from
* {@link resolveSidebarConfig}.
*/
export function apply(ctx: Context, config?: SidebarConfig): void {
// pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
// restore it before any terminal can spawn (idempotent).
ensureSpawnHelper()
const resolved = resolveSidebarConfig(config)
const trustedHosts = trustedHostsOf(ctx)
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
// The agent-owned terminal registry: parallel to the UI-tab ptyManager,
// keyed by uuid (the model's opaque handle) instead of `${sessionId}:${tabId}`,
// uncapped, and torn down with the plugin. The model creates terminals here
// through the terminal_create tool; the sidebar view attaches through the
// same /sidebar/ws/terminal upgrade with ?uuid=... instead of ?tab=...
const agentPtyRegistry = new AgentPtyRegistry(defaultShell())
// ── 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
// The model-facing terminal tools are gated on the side-card setting
// `agentTerminalTools` (default off): nothing is injected until the user
// turns the feature on, and turning it off mid-session unregisters the
// tools and releases the agent terminals they created.
let toolsDisposers: (() => void) | null = null
const syncToolsGate = (scope: { get(): SidebarPrefs }): void => {
if (scope.get().agentTerminalTools) {
if (toolsDisposers === null) {
toolsDisposers = registerTools(ctx, agentPtyRegistry, (sessionId) => sessionCwdOf(ctx, sessionId))
}
} else if (toolsDisposers !== null) {
toolsDisposers()
toolsDisposers = null
// The feature is off: release every agent terminal the model created
// while it was on (they are only reachable through the tools). The
// registry change fires the push, so the sidebar reconciles them away.
agentPtyRegistry.disposeAll()
}
}
ctx.inject(['settings'], (sctx) => {
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
// The structural settings mirror types `schema` as unknown, so the
// generic is not inferred here; the real service resolves it from the
// schemastery schema (PrefsSchema) — narrow the owner scope explicitly.
const scope = sctx.settings.register(ns, PrefsSchema) as {
get(): SidebarPrefs
watch(callback: (next: SidebarPrefs, prev: SidebarPrefs) => void): () => void
}
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()
},
}
// Register (or unregister) the terminal tools from the current setting,
// and keep them in sync with every settings commit.
syncToolsGate(scope)
scope.watch(() => { syncToolsGate(scope) })
})
// ── JSON API ────────────────────────────────────────────────────────────
const api = buildApi(ctx, ptyManager, agentPtyRegistry, resolved, () => settingsFace)
ctx.effect(() => ctx.httpServer.register({
kind: 'prefix',
path: '/sidebar/api',
handler: async (req, res) => {
if (!fence(req)) {
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
return
}
if (req.method !== 'POST') {
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
return
}
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
const method = pathname.startsWith('/sidebar/api/') ? pathname.slice('/sidebar/api/'.length) : undefined
if (method === undefined || method.includes('/')) {
writeError(res, new SidebarError('not-found', 'unknown sidebar API method', 404))
return
}
try {
const payload = await readJsonBody(req)
const handler = api[method]
if (handler === undefined) {
throw new SidebarError('not-found', `unknown sidebar API method "${method}"`, 404)
}
writeOk(res, await handler(payload))
} catch (error) {
writeError(res, error)
}
},
}), 'dsh-better-sidebar: /sidebar/api routes')
// ── Media route (images for the editor) ─────────────────────────────────
ctx.effect(() => ctx.httpServer.register({
kind: 'prefix',
path: '/sidebar/file',
handler: async (req, res) => {
if (!fence(req)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET') {
res.writeHead(405)
res.end()
return
}
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const sessionId = url.searchParams.get('sessionId')
const raw = url.searchParams.get('path')
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const path = requireAbsolute(raw)
if (!isWithin(cwd, path)) {
// Only files under the session cwd are served as media (the editor
// opens images from the explorer; produced files go through read).
// isWithin (not a raw startsWith) so case-mismatched Windows paths
// and mixed separators cannot be misclassified.
throw new SidebarError('fs-error', 'media path outside the session working directory', 403)
}
const info = await stat(path)
if (!info.isFile() || info.size > resolved.mediaLimit) {
throw new SidebarError('fs-error', 'not a file or too large', 400)
}
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.
const headers: Record<string, string> = { 'content-type': type, 'cache-control': 'no-cache' }
if (url.searchParams.get('download') === '1') {
headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`
}
res.writeHead(200, headers)
res.end(body)
} catch (error) {
writeError(res, error)
}
},
}), 'dsh-better-sidebar: /sidebar/file media route')
// ── Terminal WebSocket ──────────────────────────────────────────────────
// One upgrade endpoint serves both UI-tab terminals (?tab=...) and
// agent-owned terminals (?uuid=...). The two paths attach to different
// registries but share the wire protocol: input frames are raw text,
// resize frames are JSON `{type:'resize',cols,rows}`, and a close frame
// `{type:'close'}` releases the underlying pty (immediate for agent
// terminals, scheduled-0 for UI tabs which keep the same reconnect grace
// contract the host has always had).
const wss = new WebSocketServer({ noServer: true })
ctx.effect(() => ctx.httpServer.registerUpgrade({
path: '/sidebar/ws/terminal',
handler: (req, socket, head) => {
if (!fence(req)) {
socket.destroy()
return
}
wss.handleUpgrade(req, socket, head, (ws) => {
void attachTerminal(ctx, ptyManager, agentPtyRegistry, ws, req, resolved)
})
},
}), 'dsh-better-sidebar: terminal WebSocket')
// ── Agent terminals push WebSocket ──────────────────────────────────────
// Pushes the live list of agent terminals for one session to the sidebar
// view: the client mirrors the list into tabs (id `agent:<uuid>`,
// title from the agent's `terminal_create` call). The host fires on every
// create / close / exit; the client reconciles by adding tabs for new
// uuids and dropping tabs whose uuids disappeared (the user closing a tab
// sends `{type:'close'}` on the terminal WS, which kills the pty, which
// fires a change here, which converges the view).
const agentListWss = new WebSocketServer({ noServer: true })
ctx.effect(() => ctx.httpServer.registerUpgrade({
path: '/sidebar/ws/agent-terminals',
handler: (req, socket, head) => {
if (!fence(req)) {
socket.destroy()
return
}
agentListWss.handleUpgrade(req, socket, head, (ws) => {
void attachAgentList(agentPtyRegistry, ws, req)
})
},
}), 'dsh-better-sidebar: agent-terminals push WebSocket')
ctx.effect(() => () => {
toolsDisposers?.()
ptyManager.disposeAll()
agentPtyRegistry.disposeAll()
wss.close()
agentListWss.close()
}, 'dsh-better-sidebar: teardown')
}
/** Push the live agent-terminal list for one session to a connected sidebar view. */
async function attachAgentList(
registry: AgentPtyRegistry,
ws: WebSocket,
req: IncomingMessage,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const sessionId = url.searchParams.get('sessionId')
if (sessionId === null) {
ws.close(1008, 'sessionId is required')
return
}
const send = (): void => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(registry.list(sessionId)))
}
}
send()
const unsubscribe = registry.subscribe(send)
ws.on('close', () => { unsubscribe() })
ws.on('error', () => { unsubscribe() })
} catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error))
}
}
/**
* Wire one terminal socket to its pty: replay transcript, pump both ways.
* Two attach modes share the wire protocol:
* - `?uuid=...` attaches to an agent-owned terminal (created by the
* `terminal_create` tool). The close frame kills the pty immediately
* (the agent's terminal closes when the user closes the sidebar tab); a
* bare socket drop (refresh, tab switch) leaves the pty alive for the
* reconnect grace, exactly like UI-tab terminals.
* - `?tab=...&sessionId=...` attaches to a UI-tab terminal (the user
* created it from the + menu). The close frame schedules a 0-ms close
* (the host's reconnect grace keeps the shell alive across a refresh).
*/
async function attachTerminal(
ctx: Context,
ptyManager: PtyManager,
agentPtyRegistry: AgentPtyRegistry,
ws: WebSocket,
req: IncomingMessage,
resolved: ResolvedSidebarConfig,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const uuid = url.searchParams.get('uuid')
if (uuid !== null) {
const handle = agentPtyRegistry.get(uuid)
if (handle === undefined) {
ws.close(1011, `agent terminal "${uuid}" not found`)
return
}
pumpAgentTerminal(agentPtyRegistry, handle, ws)
return
}
const sessionId = url.searchParams.get('sessionId')
const tabId = url.searchParams.get('tab')
if (sessionId === null || tabId === null) {
ws.close(1008, 'either ?uuid or ?sessionId+?tab are required')
return
}
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
// Replay the transcript, then follow live output.
if (handle.transcript !== '') ws.send(handle.transcript)
const onData = (data: string): void => {
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
ws.send(data)
}
}
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
}
const dataSub = handle.pty.onData(onData)
const exitSub = handle.pty.onExit(onExit)
ws.on('message', (data) => {
const text = data.toString('utf8')
// Control frames are JSON with a known shape; anything else (including
// JSON that is not a recognized control) is terminal input, verbatim.
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
try {
const parsed: unknown = JSON.parse(text)
if (parsed !== null && typeof parsed === 'object') {
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
}
} catch {
// Not JSON: terminal input.
}
if (control !== null && control.type === 'close') {
// The owning tab was closed: release the quota immediately.
ptyManager.scheduleClose(handle.key, 0)
return
}
if (handle.exited) return
if (
control !== null
&& control.type === 'resize'
&& typeof control.cols === 'number' && typeof control.rows === 'number'
) {
const dims = clampDims(control.cols, control.rows)
handle.pty.resize(dims.cols, dims.rows)
} else {
handle.pty.write(text)
}
})
ws.on('close', () => {
dataSub.dispose()
exitSub.dispose()
// A bare socket drop (refresh, tab switch) leaves the process alive
// for a grace period so a quick reconnect keeps it; the reconnect's
// open() cancels the pending close.
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
})
} catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error))
}
}
/**
* Pump one agent terminal's pty to a connected view. The close frame kills
* the pty immediately (the agent's terminal closes when the user closes the
* sidebar tab); a bare socket drop leaves the pty alive — the agent owns
* the lifetime, and only `terminal_close`, a `{type:'close'}` frame, or
* plugin teardown kills it.
*/
function pumpAgentTerminal(
registry: AgentPtyRegistry,
handle: AgentTerminalHandle,
ws: WebSocket,
): void {
if (handle.transcript !== '') ws.send(handle.transcript)
const onData = (data: string): void => {
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
ws.send(data)
}
}
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
}
const dataSub = handle.pty.onData(onData)
const exitSub = handle.pty.onExit(onExit)
ws.on('message', (data) => {
if (handle.exited) return
const text = data.toString('utf8')
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
try {
const parsed: unknown = JSON.parse(text)
if (parsed !== null && typeof parsed === 'object') {
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
}
} catch {
// Not JSON: terminal input.
}
if (control !== null && control.type === 'close') {
// The user closed the sidebar tab: kill the pty immediately. The
// agent's next terminal_list / terminal_send will see it gone.
registry.close(handle.uuid)
return
}
if (
control !== null
&& control.type === 'resize'
&& typeof control.cols === 'number' && typeof control.rows === 'number'
) {
const dims = clampDims(control.cols, control.rows)
handle.pty.resize(dims.cols, dims.rows)
} else if (control === null) {
// Raw text input (a JSON-looking string the pty would have received
// verbatim is reachable in theory but is exotic for an agent terminal;
// preserve the UI-tab semantics and forward as input).
handle.pty.write(text)
}
// An unrecognized JSON control frame is dropped (the UI-tab path also
// treats non-resize JSON controls as input, but for an agent terminal
// there is no realistic input that is also valid JSON).
})
ws.on('close', () => {
dataSub.dispose()
exitSub.dispose()
// A bare socket drop (refresh, tab switch) leaves the agent's pty alive.
// The agent owns the lifetime: only `terminal_close`, a `{type:'close'}`
// frame, or plugin teardown kills it. A reconnecting view reattaches the
// same shell and gets the full transcript replayed.
})
}
+22 -79
View File
@@ -1,89 +1,32 @@
/**
* The apes-sidebar invariant surface: a minimal self-check companion module
* (mirror of better-sidebar's `lib/invariant.js`). Shipped as a separate
* entry so consumers can `import { assert } from 'apes-sidebar/invariant'`
* without pulling the host-half apply() graph.
*
* Kept dependency-free: this module must never import Node builtins or
* other @deepseek-ai packages, because it sits in the client-reachable
* declaration graph.
* Package-owned invariant companion for `dsh-better-sidebar`.
* @module dsh-better-sidebar/invariant
*/
/** Thrown by {@link assert} when the condition is falsy. */
export class InvariantError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvariantError'
}
}
/* jscpd:ignore-start */
import type { Context } from './context-types.ts'
/** Assert a condition; throw an {@link InvariantError} with `message` when falsy. */
export function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new InvariantError(message)
}
const PACKAGE_NAME = 'dsh-better-sidebar'
/** Cordis companion plugin name. */
export const name = 'dsh-better-sidebar-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* The invariant installer signature: register one package's checks in the
* service's child fiber. `fail` throws (never returns) to abort the mount.
* No runtime invariant: the sidebar owns no service state or event protocol
* of its own — every route is mounted under the host's httpServer fence, the
* pty lifecycle is exercised by the smoke spec, and the panel's store is a
* plain snapshot registry asserted by the behavior specs. The route fence,
* pty quota, and store semantics are each observed through their seams.
*/
export type InvariantInstaller = (
ctx: unknown,
fail: (message: string) => never,
) => void | Promise<void>
const install: () => void = () => {}
/**
* Guard the better-sidebar peer dependency contract at host startup:
* apes-sidebar requires `dsh-better-sidebar` >= 0.18.0. The check is a
* no-op in the client bundle (it only runs on the Node half) and a warning
* (not a hard fail) so a fresh DSH without the peer still mounts — the
* tabs simply won't appear until the peer is present.
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export function assertBetterSidebarPeer(
logger: { warn: (msg: string) => void } | undefined,
): void {
let pkg: { version?: string } | undefined
try {
// Dynamic import so the client bundle never statically pulls a Node
// resolution path; the import is guarded and never awaited here.
pkg = (globalThis as { __apesPeer__?: unknown }).__apesPeer__ as { version?: string } | undefined
} catch {
pkg = undefined
}
const version = pkg?.version
if (version && compareSemver(version, '0.18.0') < 0) {
logger?.warn(
`[apes-sidebar] dsh-better-sidebar ${version} is older than the required 0.18.0 — `
+ 'sidebar tabs will not register. Upgrade dsh-better-sidebar.',
)
}
}
/** Compare two semver strings (numeric, prerelease-aware enough for rc tags). */
export function compareSemver(a: string, b: string): number {
const pa = parseSemver(a)
const pb = parseSemver(b)
if (pa.num[0] !== pb.num[0]) return pa.num[0] - pb.num[0]
if (pa.num[1] !== pb.num[1]) return pa.num[1] - pb.num[1]
if (pa.num[2] !== pb.num[2]) return pa.num[2] - pb.num[2]
// Both release, or both prerelease: lexicographic fallback on the tag.
const ta = pa.pre
const tb = pb.pre
if (ta === tb) return 0
if (ta === '') return 1
if (tb === '') return -1
return ta < tb ? -1 : ta > tb ? 1 : 0
}
interface ParsedSemver {
num: [number, number, number]
pre: string
}
function parseSemver(version: string): ParsedSemver {
const m = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
if (!m) return { num: [0, 0, 0], pre: '' }
return {
num: [Number(m[1]), Number(m[2]), Number(m[3])],
pre: m[4] ?? '',
}
}
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+67
View File
@@ -0,0 +1,67 @@
/**
* 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
/**
* Whether the model-facing agent terminal tools (terminal_create / list /
* send / read / wait_for / resize / signal / close) are injected into the
* model's toolset. Off by default: the feature stays dormant until the
* user explicitly enables it in the side card settings.
*/
agentTerminalTools: boolean
/**
* Per-tab enable switches, keyed by tab descriptor id (`'explorer'`,
* `'my-plugin:db'`). An ABSENT key means enabled — only an explicit
* `false` disables a tab type (hidden from the + menu, `openTab` refuses,
* and derived flows like subagent auto-open / agent-terminal tabs stop).
* Already-open tabs of a disabled type keep rendering (closing one
* prevents reopening), matching the "existing conversations keep their
* own layouts" rule.
*/
tabsEnabled: Record<string, boolean>
/**
* Per-viewer enable switches, keyed by file viewer descriptor id
* (`'image'`, `'my-plugin:csv'`). An ABSENT key means enabled; a disabled
* viewer is skipped by `matchFileViewer` so files fall through to the
* next matching viewer (or the download button when none match).
*/
viewersEnabled: Record<string, 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,
agentTerminalTools: false,
tabsEnabled: {},
viewersEnabled: {},
}
/** 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)))
}
+199
View File
@@ -0,0 +1,199 @@
/**
* PTY session table for the sidebar terminals. One node-pty process per
* `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
* (page refresh, tab switch) and reconnect to the same process by key.
* Output is mirrored into a bounded transcript ring (capped bytes) so a new
* connection replays history before live data. Sessions die only when the
* tab is closed or the plugin tears down.
*/
import { chmodSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { createRequire } from 'node:module'
import * as nodePty from 'node-pty'
import { SidebarError } from './wire.ts'
/** Per-terminal transcript bound (bytes kept for replay). */
const TRANSCRIPT_LIMIT = 1 << 20
/**
* Restore the executable bit pnpm strips from node-pty's prebuilt
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
* @deepseek-ai/dsh-pty-local's ensure-spawn-helper postinstall, run at
* plugin activation so link-installed deployments get the fix too.
*/
export function ensureSpawnHelper(): void {
if (process.platform === 'win32') return
try {
const require = createRequire(import.meta.url)
const entry = require.resolve('node-pty')
const packageRoot = dirname(dirname(entry))
const candidates = [
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
join(packageRoot, 'build', 'Release', 'spawn-helper'),
]
for (const helper of candidates) {
if (existsSync(helper)) chmodSync(helper, 0o755)
}
} catch {
// Resolution or chmod failure: the terminal surfaces its own spawn error.
}
}
/** One live terminal. */
export interface SidebarPty {
/** `${sessionId}:${tabId}` registry key. */
key: string
sessionId: string
tabId: string
/** The working directory the process was SPAWNED with (a reconnect that
* resolves a different authoritative cwd respawns instead of reusing —
* the page-load hydrate race can attach the real cwd after the first
* connect, and a shell in the wrong directory must not linger). */
cwd: string
pty: nodePty.IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string
/** Whether the top-level process exited (transcript stays replayable). */
exited: boolean
exitCode?: number | null
}
/**
* The terminal registry. `maxPerSession` bounds concurrent processes per
* conversation (the client caps tabs at the same number).
*/
export class PtyManager {
private readonly sessions = new Map<string, SidebarPty>()
private readonly pendingCloses = new Map<string, ReturnType<typeof setTimeout>>()
constructor(
private readonly shell: string,
private readonly maxPerSession: number,
) {}
/** All live terminal keys of one session. */
keysOf(sessionId: string): string[] {
const keys: string[] = []
for (const handle of this.sessions.values()) {
if (handle.sessionId === sessionId) keys.push(handle.key)
}
return keys
}
/**
* Open (or reuse) the terminal for a session/tab key. A handle whose
* process already exited is replaced with a fresh spawn (reconnecting a
* dead terminal must yield a live shell, not an input sink), and so is a
* live handle whose spawn cwd differs from the now-authoritative one (the
* first connect of a page load can arrive before the session hydrates, so
* it fell back to the process cwd — reconnecting with the real cwd must
* restart the shell in the right directory). Reopening also cancels any
* pending scheduled close (a reconnect within the grace window keeps the
* process alive).
* @param sessionId - conversation id.
* @param tabId - client tab id.
* @param cwd - initial working directory (the session's cwd).
* @param cols - initial terminal width.
* @param rows - initial terminal height.
* @returns the live handle.
* @throws {SidebarError} pty-error when the per-session cap is reached.
*/
open(sessionId: string, tabId: string, cwd: string, cols: number, rows: number): SidebarPty {
const key = `${sessionId}:${tabId}`
this.cancelClose(key)
const existing = this.sessions.get(key)
if (existing !== undefined && !existing.exited && existing.cwd === cwd) return existing
if (existing !== undefined) this.close(key)
// Zombie cleanup: a session's exited handles (shell closed, tab dropped
// on an old host without the close frame) must not eat the quota.
for (const [candidate, handle] of [...this.sessions]) {
if (handle.sessionId === sessionId && handle.exited) this.close(candidate)
}
if (this.keysOf(sessionId).length >= this.maxPerSession) {
throw new SidebarError('pty-error', `terminal limit reached (${this.maxPerSession}) for this session`, 400)
}
const handle: SidebarPty = {
key,
sessionId,
tabId,
cwd,
pty: nodePty.spawn(this.shell, [], {
name: 'xterm-256color',
cols: Math.max(2, Math.floor(cols)),
rows: Math.max(2, Math.floor(rows)),
cwd,
env: { ...process.env },
}),
transcript: '',
exited: false,
}
handle.pty.onData((data) => {
handle.transcript += data
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
}
})
handle.pty.onExit(({ exitCode }) => {
handle.exited = true
handle.exitCode = exitCode
})
this.sessions.set(key, handle)
return handle
}
/**
* Schedule the terminal's destruction after `delayMs`. A tab close sends
* delay 0 (release the quota immediately); a bare socket drop (refresh,
* crash) uses the grace period so a quick reconnect keeps the process.
* `open()` cancels any pending close.
*/
scheduleClose(key: string, delayMs: number): void {
const handle = this.sessions.get(key)
if (handle === undefined) return
this.cancelClose(key)
const timer = setTimeout(() => { this.close(key) }, delayMs)
this.pendingCloses.set(key, timer)
}
/** Cancel a pending scheduled close (the terminal is being reopened). */
cancelClose(key: string): void {
const timer = this.pendingCloses.get(key)
if (timer !== undefined) {
clearTimeout(timer)
this.pendingCloses.delete(key)
}
}
/** Resolve a live handle by key, or undefined. */
get(key: string): SidebarPty | undefined {
return this.sessions.get(key)
}
/** Close a terminal and drop its state (the owning tab was closed). */
close(key: string): void {
this.cancelClose(key)
const handle = this.sessions.get(key)
if (handle === undefined) return
this.sessions.delete(key)
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
}
/** Close every terminal (plugin teardown). */
disposeAll(): void {
for (const timer of this.pendingCloses.values()) clearTimeout(timer)
this.pendingCloses.clear()
for (const key of [...this.sessions.keys()]) this.close(key)
}
}
/** The interactive shell for this platform (empty SHELL falls back). */
export function defaultShell(): string {
if (process.platform === 'win32') return 'powershell.exe'
const shell = process.env.SHELL
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash'
}
+477
View File
@@ -0,0 +1,477 @@
/**
* Eight model-facing tools for the agent-owned sidebar terminals (tmux
* semantics: spawn-and-detach, send-keys, read, wait-for, resize, signal,
* close, list). Each tool binds to the calling agent's session through
* `exec.agent.session.id`, so the model never passes a sessionId — the
* agent identity is the scope.
*
* Conventions (per plugin-development-guide.md §3):
* C1 — parameters schema-validated before `execute` runs.
* C4 — `execute` returns one canonical JSON value; `render` is a separate
* pure text projection.
* C6 — `exec.signal.throwIfAborted()` before any spawn.
* C10 — no UI/transport vocabulary in the canonical value.
*/
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Context } from './context-types.ts'
import {
AgentPtyRegistry,
ALLOWED_SIGNALS,
type AgentTerminalSignal,
type AgentTerminalSnapshot,
} from './agent-pty.ts'
/** Maximum UTF-8 bytes of one `terminal_read` result text. */
const READ_BYTE_LIMIT = 256 * 1024
/**
* Bound a string to a byte limit, marking truncation. Truncation never
* splits a multi-byte UTF-8 sequence: when the byte cap lands inside one,
* the walk-back retreats to the sequence's leading byte so the retained
* prefix decodes cleanly (a split would decode to U+FFFD).
* @internal exported for the unit tests, like {@link snapshotOf}.
*/
export function boundBytes(text: string, maxBytes: number): { text: string; truncated: boolean } {
const buf = Buffer.from(text, 'utf8')
if (buf.byteLength <= maxBytes) return { text, truncated: false }
// The byte at `end` is a continuation byte (10xxxxxx) exactly when the
// character that started before `end` spills past the cap. Stop at the
// first non-continuation byte — the retained prefix is then intact.
let end = maxBytes
while (end > 0 && ((buf[end] ?? 0) & 0xc0) === 0x80) end -= 1
return { text: buf.subarray(0, end).toString('utf8'), truncated: true }
}
/** Pure text projection helper (the canonical value is already structured). */
function textRender<T>(fn: (value: T) => string): (_args: unknown, value: unknown) => ContentBlock[] {
return (_args, value) => [{ type: 'text', text: fn(value as T) }]
}
/** Extract the calling agent or throw the canonical "no agent" error. */
function requireAgent(agent: Agent | undefined): Agent {
if (agent === undefined) {
throw new Error('sidebar terminal tools require an initiating agent')
}
return agent
}
/** Resolve the calling agent's session id (the registry scope + ownership key). */
function sessionIdOf(exec: ToolRunContext): string {
return requireAgent(exec.agent).session.id
}
/**
* Register the eight terminal tools against the host tool registry. The
* `resolveCwd` callback threads the live session cwd (authoritative from the
* session store, falling back to the process cwd) so a freshly-created
* terminal lands in the right directory without the model passing it.
* Every uuid-keyed tool first asserts the terminal belongs to the calling
* session (`registry.assertOwned`), so one agent can never reach another
* session's terminals.
* @param ctx - host plugin context (carries the tools service).
* @param registry - the agent-owned terminal registry.
* @param resolveCwd - live cwd resolver for one session id.
* @returns a disposer that unregisters all eight tools (the caller gates
* registration on the side-card setting and calls this to turn them off).
*/
export function registerTools(
ctx: Context,
registry: AgentPtyRegistry,
resolveCwd: (sessionId: string) => string,
): () => void {
const disposers: Array<() => void> = []
const register = (tool: ReturnType<typeof defineTool>): void => {
disposers.push(ctx.tools.register(tool))
}
register(defineTool({
name: 'terminal_create',
description:
'Open a persistent terminal in the sidebar and run a command in it. '
+ 'Spawns an interactive shell, writes the command + Enter to its stdin, and returns a uuid handle. '
+ 'The terminal stays alive after the command exits — send more input with terminal_send (set submit=true to run a command), '
+ 'read output with terminal_read, send Ctrl+C with terminal_signal(signal="SIGINT"), '
+ 'and close it with terminal_close when done. '
+ 'Use this for interactive shells, REPLs, long-running dev servers, '
+ 'or any work that needs persistent terminal state across tool calls. '
+ 'The terminal appears as a new tab in the right sidebar (titled with the `title` you provide) so the user can watch and interact with it.',
parameters: {
title: {
type: 'string',
required: true,
description: 'Short human-readable label for the terminal tab (e.g. "dev server", "python repl").',
},
command: {
type: 'string',
required: true,
description: 'Shell command to run in the freshly spawned shell. The host appends an Enter key automatically — do NOT include a trailing newline. Pass "" to open a bare shell with no command.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true, description: 'Opaque handle for the new terminal. Pass to terminal_send / terminal_read / terminal_resize / terminal_signal / terminal_close.' },
title: { type: 'string', required: true, description: 'The title you provided (echoed for confirmation).' },
},
},
render: textRender((v: { uuid: string; title: string }) =>
`Opened terminal "${v.title}" (uuid: ${v.uuid}). The sidebar tab appears automatically; use terminal_read to see output and terminal_send (with submit=true) to run more commands.`,
),
},
execute: (args: { title: string; command: string }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
const cwd = resolveCwd(sessionId)
const uuid = registry.create(sessionId, args.title, args.command, cwd, 80, 24)
return Promise.resolve({ uuid, title: args.title })
},
}))
register(defineTool({
name: 'terminal_list',
description:
'List every terminal the current agent has opened in this session. Returns each terminal\'s uuid, title, '
+ 'the command it was started with, and whether the top-level process has exited (with exit code/signal if so). '
+ 'Use this to recover state after a long sequence of tool calls or to find a terminal you forgot to close.',
parameters: {},
output: {
schema: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
title: { type: 'string', required: true },
command: { type: 'string', required: true },
exited: { type: 'boolean', required: true },
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }] },
exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }] },
},
},
},
render: (_args, value) => {
const list = value as AgentTerminalSnapshot[]
if (list.length === 0) return [{ type: 'text', text: 'No agent terminals open in this session.' }]
const lines = list.map((t) => {
const status = t.exited
? `exited (code ${t.exitCode ?? '?'}, signal ${t.exitSignal ?? 'none'})`
: 'running'
return ` ${t.uuid} "${t.title}" [${status}] $ ${t.command}`
})
return [{ type: 'text', text: `Agent terminals in this session:\n${lines.join('\n')}` }]
},
},
execute: (_args, exec) => {
const sessionId = sessionIdOf(exec)
return Promise.resolve(registry.list(sessionId))
},
}))
register(defineTool({
name: 'terminal_send',
description:
'Send raw text (keystrokes) to a terminal opened with terminal_create — tmux send-keys semantics. '
+ 'The text is written verbatim to the pty stdin. '
+ 'To submit a command, set submit=true (appends an Enter key); do NOT put "\\n" or "\\r" in the text yourself. '
+ 'To send Ctrl+C (interrupt the running command), use the terminal_signal tool with signal="SIGINT" — do NOT try to send the control character "\\u0003" as text. '
+ 'Use terminal_signal with signal="SIGTSTP" for Ctrl+Z (suspend) as well. '
+ 'This tool does NOT wait for the command to finish or for output to settle — pair with terminal_read to observe the result. '
+ 'Throws if the terminal has exited.',
parameters: {
uuid: {
type: 'string',
required: true,
description: 'Terminal uuid from terminal_create or terminal_list.',
},
text: {
type: 'string',
required: true,
description: 'UTF-8 text to write to the terminal stdin (verbatim, no shell escaping). Do not include trailing newlines — use the submit flag instead.',
},
submit: {
type: 'boolean',
description: 'Append an Enter key (carriage return) after the text to submit a command. Default: false. Set to true when sending a command to run; leave false for partial input or control sequences.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
bytes: { type: 'integer', required: true, description: 'Number of UTF-8 bytes written (including the Enter key if submit was true).' },
},
},
render: textRender((v: { uuid: string; bytes: number }) =>
`Sent ${v.bytes} byte(s) to terminal ${v.uuid}.`,
),
},
execute: (args: { uuid: string; text: string; submit?: boolean }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const payload = args.submit === true ? `${args.text}\r` : args.text
registry.send(args.uuid, payload)
return Promise.resolve({ uuid: args.uuid, bytes: Buffer.byteLength(payload, 'utf8') })
},
}))
register(defineTool({
name: 'terminal_read',
description:
'Read a bounded page of retained output from an agent terminal without sending input. '
+ 'The host keeps up to ~1 MiB of scrollback; this tool returns up to 500 lines per call. '
+ 'Use `offset` to paginate forward ( 0-based from the start of the retained transcript ) or backward ( negative reads from the end, e.g. -50 reads the last 50 lines ). '
+ 'Returns `totalLines` so you know how much scrollback remains. '
+ 'Output is bounded to 256 KiB per call; longer pages are truncated with the `truncated` flag.',
parameters: {
uuid: {
type: 'string',
required: true,
description: 'Terminal uuid from terminal_create or terminal_list.',
},
offset: {
type: 'number',
description: '0-based line offset from the start of the retained transcript (default 0). Negative reads from the end (e.g. -50 = last 50 lines).',
},
count: {
type: 'number',
description: 'Maximum lines to return (default 500, hard cap 500).',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
text: { type: 'string', required: true, description: 'The slice of transcript for the requested page.' },
totalLines: { type: 'integer', required: true, description: 'Total lines in the retained transcript.' },
lineBegin: { type: 'integer', required: true, description: '0-based index of the first line in `text` (inclusive).' },
lineEnd: { type: 'integer', required: true, description: '0-based index of the last line in `text` (exclusive).' },
truncated: { type: 'boolean', required: true, description: 'Whether `text` was truncated to fit the 256 KiB read cap.' },
},
},
render: (_args, value) => {
const v = value as { text: string; totalLines: number; lineBegin: number; lineEnd: number; truncated: boolean }
const head = `[lines ${v.lineBegin}..${v.lineEnd} of ${v.totalLines}${v.truncated ? '; truncated to 256KiB' : ''}]`
return [{ type: 'text', text: `${head}\n${v.text}` }]
},
},
execute: (args: { uuid: string; offset?: number; count?: number }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const result = registry.read(args.uuid, args.offset, args.count)
const bounded = boundBytes(result.text, READ_BYTE_LIMIT)
return Promise.resolve({
text: bounded.text,
totalLines: result.totalLines,
lineBegin: result.lineBegin,
lineEnd: result.lineEnd,
truncated: bounded.truncated,
})
},
}))
register(defineTool({
name: 'terminal_wait_for',
description:
'Block until a substring appears in a terminal\'s retained transcript, or until the timeout elapses, or until the terminal exits — whichever happens first. '
+ 'Use this to synchronize on command completion cues ( e.g. a shell prompt, "done", "Listening on", "Build successful" ) '
+ 'without busy-polling terminal_read. '
+ 'The wait scans the FULL retained transcript (up to ~1 MiB) on every poll, so a needle that scrolled past the most recent chunk is still a match. '
+ 'Returns `found` with the line/column of the first occurrence, `timeout` if the needle did not appear in time, or `exited` if the terminal process died before the needle appeared. '
+ 'Default timeout is 10 seconds; raise it for long-running commands ( dev servers, test suites ). '
+ 'The wait is cooperative: a tool-call cancel ( or agent turn end ) aborts it immediately.',
parameters: {
uuid: {
type: 'string',
required: true,
description: 'Terminal uuid from terminal_create or terminal_list.',
},
needle: {
type: 'string',
required: true,
description: 'Substring to wait for (case-sensitive, verbatim). Must be non-empty.',
},
timeout_ms: {
type: 'number',
description: 'Maximum wait in milliseconds (default 10000, i.e. 10s). Clamped to a minimum of 100ms.',
},
},
output: {
schema: {
oneOf: [
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'found' },
needle: { type: 'string', required: true },
line: { type: 'integer', required: true, description: '0-based line index in the retained transcript where the needle first appeared.' },
column: { type: 'integer', required: true, description: '0-based column index within that line where the match starts.' },
elapsedMs: { type: 'integer', required: true, description: 'Wall-clock milliseconds from wait start to match.' },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'timeout' },
needle: { type: 'string', required: true },
timeoutMs: { type: 'integer', required: true, description: 'The configured timeout that elapsed.' },
totalLines: { type: 'integer', required: true, description: 'Total lines retained when the timeout fired. Call terminal_read to inspect the tail.' },
},
},
{
type: 'object',
additionalProperties: false,
properties: {
kind: { type: 'string', required: true, const: 'exited' },
needle: { type: 'string', required: true },
exitCode: { oneOf: [{ type: 'integer' }, { type: 'null' }], description: 'Exit code, if known.' },
exitSignal: { oneOf: [{ type: 'string' }, { type: 'null' }], description: 'Exit signal name, if killed by a signal.' },
},
},
],
},
render: (_args, value) => {
const v = value as { kind: 'found' | 'timeout' | 'exited'; needle: string; elapsedMs?: number; timeoutMs?: number; line?: number; column?: number; exitCode?: number | null; exitSignal?: string | null }
if (v.kind === 'found') {
return [{ type: 'text', text: `Found "${v.needle}" at line ${v.line}, column ${v.column} (after ${v.elapsedMs}ms).` }]
}
if (v.kind === 'timeout') {
return [{ type: 'text', text: `Timed out after ${v.timeoutMs}ms waiting for "${v.needle}". Call terminal_read to inspect the transcript.` }]
}
const exitInfo = v.exitCode !== undefined && v.exitCode !== null ? ` (exit code ${v.exitCode})` : ''
return [{ type: 'text', text: `Terminal exited before "${v.needle}" appeared${exitInfo}.` }]
},
},
async execute(args: { uuid: string; needle: string; timeout_ms?: number }, exec) {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
// The registry validates the needle (empty → bad-request) and returns
// the camelCase result the schema above declares directly — no
// field-by-field projection.
const timeoutMs = args.timeout_ms ?? 10_000
return await registry.waitFor(args.uuid, args.needle, timeoutMs, exec.signal)
},
}))
register(defineTool({
name: 'terminal_resize',
description:
'Resize an agent terminal\'s pty ( cols × rows ). The host clamps both to a 2..1024 sane range. '
+ 'Most shells redraw their prompt and any full-screen TUI on the next output frame. '
+ 'No-op if the terminal has exited. Returns the dimensions actually applied.',
parameters: {
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
cols: { type: 'integer', required: true, description: 'New column count ( clamped to 2..1024 ).' },
rows: { type: 'integer', required: true, description: 'New row count ( clamped to 2..1024 ).' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
cols: { type: 'integer', required: true },
rows: { type: 'integer', required: true },
},
},
render: textRender((v: { uuid: string; cols: number; rows: number }) =>
`Resized terminal ${v.uuid} to ${v.cols}×${v.rows}.`,
),
},
execute: (args: { uuid: string; cols: number; rows: number }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const dims = registry.resize(args.uuid, args.cols, args.rows)
return Promise.resolve({ uuid: args.uuid, ...dims })
},
}))
register(defineTool({
name: 'terminal_signal',
description:
'Send a POSIX signal to an agent terminal\'s foreground process — this is how you send Ctrl+C, Ctrl+Z, etc. '
+ 'Use signal="SIGINT" for Ctrl+C (interrupt the running command), signal="SIGTERM" to request termination, '
+ 'signal="SIGKILL" to force-kill the pty, signal="SIGHUP" to hang up (many shells exit), signal="SIGTSTP" for Ctrl+Z (suspend). '
+ 'Do NOT try to send control characters (like "\\u0003") through terminal_send — use this tool instead. '
+ 'On Windows, only SIGKILL and SIGTERM are effective — others are accepted but may no-op. '
+ 'No-op if the terminal has already exited. Use terminal_close to dispose of the terminal entirely.',
parameters: {
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
signal: {
type: 'string',
required: true,
enum: ALLOWED_SIGNALS as readonly string[],
description: 'Signal to deliver: SIGINT (Ctrl+C) | SIGTERM | SIGKILL | SIGHUP | SIGTSTP (Ctrl+Z).',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
signal: { type: 'string', required: true },
},
},
render: textRender((v: { uuid: string; signal: AgentTerminalSignal }) =>
`Sent ${v.signal} to terminal ${v.uuid}.`,
),
},
execute: (args: { uuid: string; signal: AgentTerminalSignal }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
registry.signal(args.uuid, args.signal)
return Promise.resolve({ uuid: args.uuid, signal: args.signal })
},
}))
register(defineTool({
name: 'terminal_close',
description:
'Close an agent terminal and release its process. The uuid becomes invalid for all subsequent tool calls. '
+ 'Idempotent: closing an already-closed uuid is a no-op. '
+ 'The corresponding sidebar tab is removed automatically when the host pushes the updated terminal list. '
+ 'Always close terminals you no longer need — the host keeps the pty alive until you do.',
parameters: {
uuid: { type: 'string', required: true, description: 'Terminal uuid from terminal_create or terminal_list.' },
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
uuid: { type: 'string', required: true },
closed: { type: 'boolean', required: true, description: 'Whether a live terminal was actually dropped (false if the uuid was already gone).' },
},
},
render: textRender((v: { uuid: string; closed: boolean }) =>
v.closed ? `Closed terminal ${v.uuid}.` : `Terminal ${v.uuid} was already closed.`,
),
},
execute: (args: { uuid: string }, exec) => {
exec.signal.throwIfAborted()
const sessionId = sessionIdOf(exec)
registry.assertOwned(args.uuid, sessionId)
const closed = registry.close(args.uuid)
return Promise.resolve({ uuid: args.uuid, closed })
},
}))
return () => {
for (const dispose of disposers) dispose()
}
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Browser-trust fence for the sidebar routes, behaviorally identical to the
* /api gateway's fence in @deepseek-ai/dsh-client-connection
* (src/api-request-trust.ts + src/loopback-hostname.ts, BSD-3-Clause,
* copied here because the package does not export these helpers and the
* plugin must not depend on its internals). Host-header loopback or a
* configured trusted authority passes; cross-site browser markers refuse.
* This is a DNS-rebinding / cross-site defense, not authentication.
*/
import type { IncomingHttpHeaders } from 'node:http'
/** The request facts the fence reads (structural subset of IncomingMessage). */
interface ApiTrustRequest {
headers: IncomingHttpHeaders
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
/** Normalized URL of a Host-header authority, or undefined when unparsable. */
function parseAuthority(authority: string): URL | undefined {
try {
return new URL(`http://${authority}`)
} catch {
return undefined
}
}
/** Whether a normalized URL hostname names the local loopback authority. */
export function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/** Canonical authority form: hostname, or hostname:port when a port was written. */
function canonicalAuthority(entry: string, entryUrl: URL): string {
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
}
/** Whether the request authority matches a trustedHosts entry (exact or port-less). */
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
return trustedHosts.some((entry) => {
const entryUrl = parseAuthority(entry)
if (entryUrl === undefined) return false
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
? entryUrl.hostname === hostUrl.hostname
: entryUrl.host === hostUrl.host
})
}
/**
* Decide whether one sidebar request may reach the plugin routes.
* @param request - node HTTP request facts (headers).
* @param trustedHosts - non-loopback authorities this deployment serves.
* @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
*/
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
const host = header(request.headers, 'host')
if (host === undefined) return false
const hostUrl = parseAuthority(host)
if (hostUrl === undefined) return false
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
const origin = header(request.headers, 'origin')
if (origin === undefined) return true
try {
return new URL(origin).host === hostUrl.host
} catch {
return false
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Wire helpers for the /sidebar JSON API: bounded body reading, response
* writing, and the shared error envelope. Every API method returns
* `{ok: true, value}` on success and `{ok: false, error: {code, message}}`
* (HTTP 4xx/5xx matching the code) on failure.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
/** Machine-readable error codes of the sidebar API. */
export type SidebarErrorCode =
| 'bad-request'
| 'not-found'
| 'forbidden'
| 'method-error'
| 'fs-error'
| 'git-error'
| 'pty-error'
| 'settings-rejected'
| 'settings-conflict'
| 'internal'
/** One API failure with its wire code and HTTP status. */
export class SidebarError extends Error {
constructor(
readonly code: SidebarErrorCode,
message: string,
readonly status = 400,
) {
super(message)
}
}
/** Body size bound of one JSON request (defense against unbounded reads). */
const MAX_BODY_BYTES = 1 << 20
/** Success envelope of one API method. */
export interface SidebarOk<T> { ok: true; value: T }
/** Failure envelope of one API method. */
export interface SidebarErr { ok: false; error: { code: SidebarErrorCode; message: string } }
/** Read and parse the JSON request body (bounded; malformed → bad-request). */
export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = []
let total = 0
for await (const chunk of req) {
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
total += buffer.length
if (total > MAX_BODY_BYTES) {
throw new SidebarError('bad-request', 'request body too large')
}
chunks.push(buffer)
}
const text = Buffer.concat(chunks).toString('utf8')
if (text.trim() === '') return {}
try {
return JSON.parse(text) as unknown
} catch {
throw new SidebarError('bad-request', 'request body is not valid JSON')
}
}
/** Write a JSON response with the given status. */
export function writeJson(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body)
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
res.end(payload)
}
/** Write the success envelope. */
export function writeOk(res: ServerResponse, value: unknown): void {
writeJson(res, 200, { ok: true, value })
}
/** Write the failure envelope for any thrown value (unknown → internal 500). */
export function writeError(res: ServerResponse, error: unknown): void {
if (error instanceof SidebarError) {
writeJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
return
}
const message = error instanceof Error ? error.message : String(error)
writeJson(res, 500, { ok: false, error: { code: 'internal', message } })
}
/** Narrow an unknown payload value to a string, else throw bad-request. */
export function requireString(payload: unknown, key: string): string {
const record = payload as Record<string, unknown> | null
const value = record?.[key]
if (typeof value !== 'string' || value === '') {
throw new SidebarError('bad-request', `missing or invalid "${key}"`)
}
return value
}
+340
View File
@@ -0,0 +1,340 @@
/**
* AgentPtyRegistry unit tests: spawn real ptys (mirroring the pty-manager
* smoke tests) and verify uuid-keyed CRUD, transcript bounding, exit
* tracking, and change-event subscription. The "AttachConsole failed"
* console noise from node-pty's ConPTY module on Windows is expected and
* does not affect the assertions.
*/
import { describe, expect, it } from 'vitest'
import { AgentPtyRegistry, ALLOWED_SIGNALS, snapshotOf, type AgentTerminalSnapshot } from '../src/agent-pty.ts'
/**
* Resolve a shell binary for tests: on Windows use PowerShell (available on
* every modern Windows host), on POSIX use /bin/sh (universally available).
*/
function testShell(): string {
return process.platform === 'win32' ? 'powershell.exe' : '/bin/sh'
}
/** Wait for a terminal's transcript to contain a substring (or timeout). */
async function waitForTranscript(
registry: AgentPtyRegistry,
uuid: string,
needle: string,
timeoutMs = 5000,
): Promise<string> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const handle = registry.get(uuid)
if (handle !== undefined && handle.transcript.includes(needle)) return handle.transcript
await new Promise(resolve => setTimeout(resolve, 50))
}
const handle = registry.get(uuid)
return handle?.transcript ?? ''
}
describe('AgentPtyRegistry', () => {
it('creates a terminal with a uuid, writes the command to stdin, and lists it', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'echo test', 'echo hello-agent-pty', process.cwd(), 80, 24)
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
const list = registry.list('s1')
expect(list).toHaveLength(1)
expect(list[0]!.uuid).toBe(uuid)
expect(list[0]!.title).toBe('echo test')
expect(list[0]!.command).toBe('echo hello-agent-pty')
expect(list[0]!.exited).toBe(false)
// The command was written to stdin; wait for the output.
const transcript = await waitForTranscript(registry, uuid, 'hello-agent-pty')
expect(transcript).toContain('hello-agent-pty')
} finally {
registry.disposeAll()
}
})
it('spawns a bare shell when command is empty', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'bare', '', process.cwd(), 80, 24)
const handle = registry.get(uuid)
expect(handle).toBeDefined()
expect(handle!.command).toBe('')
expect(handle!.exited).toBe(false)
// The pty process exists; its transcript may take a moment to
// accumulate on Windows ConPTY. The handle is alive and listed.
expect(registry.list('s1')).toHaveLength(1)
} finally {
registry.disposeAll()
}
})
it('sends raw text to stdin (send-keys semantics)', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'sender', '', process.cwd(), 80, 24)
// Wait for the shell prompt, then send a command.
await waitForTranscript(registry, uuid, '', 1000)
// The registry's send() writes verbatim; the caller (tool layer) is
// responsible for appending \r when submit=true. Here we test the
// registry directly: send text + \r to submit.
registry.send(uuid, 'echo sent-via-send\r')
const transcript = await waitForTranscript(registry, uuid, 'sent-via-send')
expect(transcript).toContain('sent-via-send')
} finally {
registry.disposeAll()
}
})
it('reads a bounded page of the transcript', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'reader', 'echo line1\necho line2\necho line3', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'line3')
const page = registry.read(uuid)
expect(page.totalLines).toBeGreaterThan(0)
expect(page.text).toContain('line1')
expect(page.text).toContain('line3')
// Negative offset reads from the end.
const tail = registry.read(uuid, -2)
expect(tail.lineBegin).toBeGreaterThanOrEqual(0)
expect(tail.lineEnd).toBe(tail.lineBegin + tail.text.split('\n').length)
} finally {
registry.disposeAll()
}
})
it('resizes without throwing', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'resizer', '', process.cwd(), 80, 24)
expect(() => registry.resize(uuid, 120, 40)).not.toThrow()
} finally {
registry.disposeAll()
}
})
it('resize clamps to the 2..1024 range and returns the applied dimensions', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'clamp', '', process.cwd(), 80, 24)
// Oversized / undersized requests report the clamped values, so the
// echoed result always matches the pty (the tool contract).
expect(registry.resize(uuid, 5000, 1)).toEqual({ cols: 1024, rows: 2 })
expect(registry.resize(uuid, -3, 80.9)).toEqual({ cols: 2, rows: 80 })
expect(registry.resize(uuid, 120, 40)).toEqual({ cols: 120, rows: 40 })
// create clamps through the same helper.
const other = registry.create('s1', 'clamped-create', '', process.cwd(), 1, 9999)
expect(registry.get(other)!.pty.cols).toBe(2)
expect(registry.get(other)!.pty.rows).toBe(1024)
} finally {
registry.disposeAll()
}
})
it('assertOwned rejects a uuid owned by another session', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const mine = registry.create('s1', 'mine', '', process.cwd(), 80, 24)
const theirs = registry.create('s2', 'theirs', '', process.cwd(), 80, 24)
expect(() => registry.assertOwned(mine, 's1')).not.toThrow()
// A foreign uuid is indistinguishable from an unknown one.
expect(() => registry.assertOwned(theirs, 's1')).toThrow(/not found/)
expect(() => registry.assertOwned('nope', 's1')).toThrow(/not found/)
} finally {
registry.disposeAll()
}
})
it('closes a terminal and removes it from the list', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'closer', '', process.cwd(), 80, 24)
expect(registry.list('s1')).toHaveLength(1)
const closed = registry.close(uuid)
expect(closed).toBe(true)
expect(registry.list('s1')).toHaveLength(0)
// Closing again is a no-op.
const closedAgain = registry.close(uuid)
expect(closedAgain).toBe(false)
} finally {
registry.disposeAll()
}
})
it('scopes list by session id', () => {
const registry = new AgentPtyRegistry(testShell())
try {
registry.create('s1', 'a', '', process.cwd(), 80, 24)
registry.create('s1', 'b', '', process.cwd(), 80, 24)
registry.create('s2', 'c', '', process.cwd(), 80, 24)
expect(registry.list('s1')).toHaveLength(2)
expect(registry.list('s2')).toHaveLength(1)
expect(registry.list('s3')).toHaveLength(0)
} finally {
registry.disposeAll()
}
})
it('fires change listeners on create, close, and exit', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
let changeCount = 0
registry.subscribe(() => { changeCount += 1 })
const uuid = registry.create('s1', 'watched', '', process.cwd(), 80, 24)
// create fires one change.
expect(changeCount).toBeGreaterThanOrEqual(1)
registry.close(uuid)
// close fires another change.
expect(changeCount).toBeGreaterThanOrEqual(2)
} finally {
registry.disposeAll()
}
})
it('disposeAll closes every terminal', () => {
const registry = new AgentPtyRegistry(testShell())
registry.create('s1', 'a', '', process.cwd(), 80, 24)
registry.create('s2', 'b', '', process.cwd(), 80, 24)
registry.disposeAll()
expect(registry.list('s1')).toHaveLength(0)
expect(registry.list('s2')).toHaveLength(0)
})
it('snapshotOf drops the pty reference and transcript', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'snap', '', process.cwd(), 80, 24)
const handle = registry.get(uuid)!
const snap = snapshotOf(handle)
expect(snap.uuid).toBe(uuid)
expect(snap.title).toBe('snap')
expect(snap.exited).toBe(false)
// No pty or transcript fields on the snapshot.
expect('pty' in snap).toBe(false)
expect('transcript' in snap).toBe(false)
// sessionId is registry-internal ownership, never serialized (it would
// violate the terminal_list output schema's additionalProperties:false).
expect('sessionId' in snap).toBe(false)
expect('sessionId' in registry.list('s1')[0]!).toBe(false)
} finally {
registry.disposeAll()
}
})
it('ALLOWED_SIGNALS includes SIGINT, SIGTERM, SIGKILL, SIGHUP, SIGTSTP', () => {
expect(ALLOWED_SIGNALS).toContain('SIGINT')
expect(ALLOWED_SIGNALS).toContain('SIGTERM')
expect(ALLOWED_SIGNALS).toContain('SIGKILL')
expect(ALLOWED_SIGNALS).toContain('SIGHUP')
expect(ALLOWED_SIGNALS).toContain('SIGTSTP')
})
it('waitFor returns found when the needle is already in the transcript', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'echo-test', 'echo wait-for-fast', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, 'wait-for-fast')
// The needle is already present; waitFor should return immediately.
const result = await registry.waitFor(uuid, 'wait-for-fast', 2000)
expect(result.kind).toBe('found')
if (result.kind === 'found') {
expect(result.needle).toBe('wait-for-fast')
expect(result.elapsedMs).toBeLessThan(500)
}
} finally {
registry.disposeAll()
}
})
it('waitFor returns found after the needle appears (async output)', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
// Spawn a shell WITH a command (so output is guaranteed), but start
// the wait for a DIFFERENT needle that only appears after we send
// a follow-up command. This exercises the subscribe/poll path: the
// wait must notice output arriving AFTER it started, not just find
// pre-existing content.
const uuid = registry.create('s1', 'async-echo', 'echo shell-ready', process.cwd(), 80, 24)
// Wait for the initial command's output so we know the shell is live.
await waitForTranscript(registry, uuid, 'shell-ready', 10_000)
// Start the wait BEFORE sending the follow-up command. The needle is
// NOT in the transcript yet. ConPTY under test concurrency can be slow,
// so the timeout is generous.
const waitPromise = registry.waitFor(uuid, 'UNIQUE_NEEDLE_42', 30_000)
// Give the wait a moment to subscribe, then send the command.
await new Promise(resolve => setTimeout(resolve, 200))
registry.send(uuid, 'echo UNIQUE_NEEDLE_42\r')
const result = await waitPromise
expect(result.kind).toBe('found')
if (result.kind === 'found') {
expect(result.needle).toBe('UNIQUE_NEEDLE_42')
}
} finally {
registry.disposeAll()
}
}, 40_000)
it('waitFor returns timeout when the needle never appears', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'silent', '', process.cwd(), 80, 24)
await waitForTranscript(registry, uuid, '', 1000)
// Wait for a needle that will never appear; use a short timeout to
// keep the test fast.
const result = await registry.waitFor(uuid, 'this-needle-will-never-appear', 500)
expect(result.kind).toBe('timeout')
if (result.kind === 'timeout') {
expect(result.timeoutMs).toBe(500)
expect(result.totalLines).toBeGreaterThan(0)
}
} finally {
registry.disposeAll()
}
})
it('waitFor throws on an empty needle', async () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'empty-needle', '', process.cwd(), 80, 24)
await expect(registry.waitFor(uuid, '', 500)).rejects.toThrow()
} finally {
registry.disposeAll()
}
})
it('waitFor throws on an unknown uuid', async () => {
const registry = new AgentPtyRegistry(testShell())
await expect(registry.waitFor('nonexistent-uuid', 'foo', 500)).rejects.toThrow()
})
it('delivers SIGINT and SIGTSTP by writing control characters (cross-platform)', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'signal-test', '', process.cwd(), 80, 24)
// SIGINT and SIGTSTP are delivered by writing \x03 / \x1a to the pty
// stdin — the cross-platform way terminals send Ctrl+C / Ctrl+Z.
// These must NOT throw (the old kill(SIGINT) path threw on Windows).
expect(() => registry.signal(uuid, 'SIGINT')).not.toThrow()
expect(() => registry.signal(uuid, 'SIGTSTP')).not.toThrow()
// The control byte was written to the pty; the shell received it.
// On POSIX the line discipline generates the signal; on Windows
// ConPTY translates it. Either way the write path is exercised.
} finally {
registry.disposeAll()
}
})
it('delivers SIGKILL via the process-termination path', () => {
const registry = new AgentPtyRegistry(testShell())
try {
const uuid = registry.create('s1', 'kill-test', '', process.cwd(), 80, 24)
// SIGKILL uses pty.kill() (TerminateProcess on Windows). Must not
// throw even when the pty's kill() rejects named signals — the
// fallback to the default kill ensures the signal takes effect.
expect(() => registry.signal(uuid, 'SIGKILL')).not.toThrow()
} finally {
registry.disposeAll()
}
})
})
+59
View File
@@ -0,0 +1,59 @@
/**
* Browser globals for specs that pull browser-bound modules. Import this
* FIRST in a spec file: unlike statements after imports (which vitest
* hoists above the module body), an import statement evaluates in order,
* so this runs before any later import's module graph (xterm's UMD wrapper
* checks `self` and its color defaults probe `document` at evaluation time;
* window/localStorage are only touched at runtime by the store).
*/
const g = globalThis as Record<string, unknown>
if (g.self === undefined) g.self = globalThis
const elementStub = (): Record<string, unknown> => ({
style: {},
classList: { add: () => {}, remove: () => {}, contains: () => false },
setAttribute: () => {},
getAttribute: () => null,
appendChild: () => {},
addEventListener: () => {},
removeEventListener: () => {},
getBoundingClientRect: () => ({ left: 0, top: 0, width: 0, height: 0 }),
// xterm's color palette probe measures a canvas context at module load.
getContext: () => null,
})
if (g.document === undefined) {
g.document = {
createElement: () => elementStub(),
createDocumentFragment: () => elementStub(),
addEventListener: () => {},
removeEventListener: () => {},
defaultView: globalThis,
body: elementStub(),
// CodeMirror's view module probes the UA and injects styles at load.
documentElement: elementStub(),
head: elementStub(),
styleSheets: [],
}
}
if (g.navigator === undefined) {
g.navigator = { userAgent: 'node', platform: 'node', vendor: '', maxTouchPoints: 0 }
}
if (g.window === undefined) {
g.window = {
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
innerWidth: 1024,
getComputedStyle: () => ({ getPropertyValue: () => '' }),
}
}
if (g.localStorage === undefined) {
g.localStorage = {
getItem: () => null,
setItem: () => {},
}
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Built-in registration tests: the plugin registers 6 tabs and 8 file
* viewers through the same service external plugins use (dogfooding);
* the catch-all `code` viewer and the NUL-sniffing `binary-download`
* viewer pin the registry's matching behavior.
*/
import { describe, expect, it } from 'vitest'
// First import: browser globals before the xterm-carrying builtin graph loads.
import './browser-globals.ts'
import type { Context } from '../src/context-types.ts'
import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
import { registerBuiltins } from '../src/client/builtins/index.ts'
function setup(): { service: ReturnType<typeof createBetterSidebarService>; dispose: () => void } {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const dispose = registerBuiltins({} as Context, service)
return { service, dispose }
}
describe('built-in tab registrations', () => {
it('registers the 6 built-in tabs', () => {
const { service } = setup()
expect(service.getTabs().map(t => t.id).sort()).toEqual(
['diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
)
})
it('editor and diff are hidden from the + menu (opened by file-open / git view)', () => {
const { service } = setup()
expect(service.getTabs().filter(t => t.hidden).map(t => t.id).sort()).toEqual(['diff', 'editor'])
})
it('single-instance tabs use the single sugar', () => {
const { service } = setup()
for (const id of ['explorer', 'git', 'subagent']) {
expect(service.getTab(id)?.single).toBe(true)
}
})
it('the subagent tab declares its auto-open related setting', () => {
const { service } = setup()
const toggles = service.getTab('subagent')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['autoOpenSubagent'])
})
it('the terminal tab declares the model terminal-tools related setting', () => {
const { service } = setup()
const toggles = service.getTab('terminal')?.settings?.toggles ?? []
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools'])
})
it('every built-in tab carries the settings-surface icon', () => {
const { service } = setup()
for (const tab of service.getTabs()) {
expect(tab.icon, tab.id).toBeDefined()
}
})
})
describe('built-in file viewer registrations', () => {
it('registers the 8 built-in file viewers', () => {
const { service } = setup()
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
['binary-download', 'code', 'docx', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
)
})
it('code is the catch-all at the lowest priority', () => {
const { service } = setup()
const code = service.getFileViewers().find(v => v.id === 'code')
expect(code?.exts).toEqual([])
expect(code?.priority).toBe(-100)
expect(code?.fetchStrategy).toBe('fsRead')
expect(service.matchFileViewer('anything.zzz')?.id).toBe('code')
})
it('markdown claims md/markdown before the catch-all', () => {
const { service } = setup()
expect(service.matchFileViewer('readme.md')?.id).toBe('markdown')
expect(service.matchFileViewer('readme.markdown')?.id).toBe('markdown')
expect(service.matchFileViewer('readme.md', new Uint8Array([0x61]))?.id).toBe('markdown')
})
it('binary-download claims legacy office by extension', () => {
const { service } = setup()
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download')
expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download')
})
it('binary-download NUL detect claims unknown-extension binaries over code', () => {
const { service } = setup()
// First match (no head) falls to the catch-all code viewer...
expect(service.matchFileViewer('blob.zzz')?.id).toBe('code')
// ...but the head re-match (NUL probe) routes it to binary-download.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('binary-download')
// A NUL-free blob stays with code.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
})
it('office mediaUrl viewers beat the binary sniffers for their own extensions', () => {
const { service } = setup()
// A .docx is a zip (no NUL in its head): the docx viewer (priority 0)
// claims it before binary-download (-50) is consulted.
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('docx')
})
it('every built-in viewer carries the declarative settings surface (title + icon)', () => {
const { service } = setup()
for (const viewer of service.getFileViewers()) {
expect(viewer.title, viewer.id).toBeDefined()
expect(viewer.icon, viewer.id).toBeDefined()
}
})
})
describe('built-in disposer', () => {
it('unregisters everything (HMR-safe)', () => {
const { service, dispose } = setup()
dispose()
expect(service.getTabs()).toHaveLength(0)
expect(service.getFileViewers()).toHaveLength(0)
// The disposer is idempotent.
dispose()
})
})
+100
View File
@@ -0,0 +1,100 @@
/**
* Tests for the editor-load planning: the pure strategy dispatch the
* editor host runs (planFirstMatch / planFsReadOutcome / decodeHead).
*/
import { describe, expect, it } from 'vitest'
import { decodeHead, planFirstMatch, planFsReadOutcome } from '../src/client/editor-load.ts'
import type { FileViewerDescriptor } from '../src/client/service.ts'
const viewer = (over: Partial<FileViewerDescriptor>): FileViewerDescriptor => ({
id: 'v',
exts: [],
fetchStrategy: 'fsRead',
component: () => null,
...over,
})
describe('planFirstMatch', () => {
it('no viewer → binary (download UI)', () => {
expect(planFirstMatch(undefined, () => '/m')).toEqual({ kind: 'binary' })
})
it('binary-download strategy → binary', () => {
const v = viewer({ id: 'dl', fetchStrategy: 'binary-download' })
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'binary' })
})
it('mediaUrl / none → render with the media URL', () => {
const v = viewer({ id: 'img', fetchStrategy: 'mediaUrl' })
expect(planFirstMatch(v, () => '/media')).toEqual({ kind: 'render', viewer: v, mediaUrl: '/media' })
expect(planFirstMatch(viewer({ id: 'none', fetchStrategy: 'none' }), () => '/m').kind).toBe('render')
})
it('custom → customLoad (the viewer loads its own bytes)', () => {
const v = viewer({ id: 'csv', fetchStrategy: 'custom', load: async () => [] })
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'customLoad', viewer: v })
})
it('fsRead → fetchFsRead', () => {
const v = viewer({ id: 'code', fetchStrategy: 'fsRead' })
expect(planFirstMatch(v, () => '/m')).toEqual({ kind: 'fetchFsRead', viewer: v })
})
})
describe('planFsReadOutcome', () => {
const rematch = (head: Uint8Array): FileViewerDescriptor | undefined => {
// The builtin binary-download NUL probe shape.
if (head.includes(0)) return viewer({ id: 'binary-download', fetchStrategy: 'binary-download' })
return undefined
}
it('text result renders with content, same viewer', () => {
const v = viewer({ id: 'code' })
const action = planFsReadOutcome(v, { binary: false, content: 'hello', truncated: true }, rematch, () => '/m')
expect(action).toEqual({ kind: 'render', viewer: v, content: 'hello', truncated: true })
})
it('binary without head bytes → binary', () => {
expect(planFsReadOutcome(viewer({}), { binary: true, content: '', truncated: false }, rematch, () => '/m')).toEqual({ kind: 'binary' })
})
it('binary whose NUL head re-matches nothing renderable → binary', () => {
const action = planFsReadOutcome(
viewer({}),
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' }, // 0x00 bytes
rematch,
() => '/m',
)
// binary-download claims it — still the download UI.
expect(action).toEqual({ kind: 'binary' })
})
it('binary re-matched to a custom viewer → customLoad (external sniffer)', () => {
const sniffer = viewer({ id: 'my:raw', fetchStrategy: 'custom', load: async () => ({}) })
const action = planFsReadOutcome(
viewer({}),
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' },
() => sniffer,
() => '/m',
)
expect(action).toEqual({ kind: 'customLoad', viewer: sniffer })
})
it('binary re-matched to a mediaUrl viewer → render with the media URL', () => {
const media = viewer({ id: 'my:binimg', fetchStrategy: 'mediaUrl' })
const action = planFsReadOutcome(
viewer({}),
{ binary: true, content: '', truncated: false, head: 'AAAAAA==' },
() => media,
() => '/m?x=1',
)
expect(action).toEqual({ kind: 'render', viewer: media, mediaUrl: '/m?x=1' })
})
})
describe('decodeHead', () => {
it('decodes base64 to the exact bytes', () => {
const head = decodeHead('AP//AA==')
expect(Array.from(head)).toEqual([0x00, 0xff, 0xff, 0x00])
})
})
-128
View File
@@ -1,128 +0,0 @@
/**
* Locale contract for the client half. The apes:* tabs are bilingual
* (zh default, en fallback) — the same shape tutti-os ships. This spec pins
* the i18n surface against the REAL locale module API:
* - t() resolves the active locale, falling back en → raw key
* - registerLocales() syncs activeLocale from a mock ApesLocaleService and
* keeps it in sync as the runtime emits new snapshots
* - subscribeLocale() re-render hook + clean detach
* The React hook (useApesLocale) needs a renderer; it is a thin
* useSyncExternalStore wrapper over the same getSnapshot/subscribe pair and
* is exercised indirectly here.
*/
import { describe, expect, it } from 'vitest'
import {
t,
getActiveLocale,
subscribeLocale,
registerLocales,
} from '../src/client/locales.ts'
import type { ApesLocaleService } from '../src/context-types.ts'
/** Build a mock ApesLocaleService whose active locale we can flip. */
function makeLocaleService(initialActive = 'zh'): {
service: ApesLocaleService
setActive: (loc: string) => void
registered: Array<{ ns: string; locale: string }>
} {
let active = initialActive
const subs = new Set<() => void>()
const registered: Array<{ ns: string; locale: string }> = []
const service: ApesLocaleService = {
getSnapshot: () => ({ active }),
subscribe: (fn) => {
subs.add(fn)
return () => subs.delete(fn)
},
register: (ns, locale) => {
registered.push({ ns, locale })
return () => {}
},
}
return {
service,
setActive: (loc) => {
active = loc
for (const fn of [...subs]) fn()
},
registered,
}
}
describe('locales module (real API)', () => {
it('resolves keys in the default zh locale', () => {
// Before any registerLocales call, activeLocale is the module default.
const active = getActiveLocale()
expect(['zh', 'en']).toContain(active)
// Every registered key resolves to a non-empty string.
expect(t('tab.tasks.title').length).toBeGreaterThan(0)
expect(t('tab.agents.title').length).toBeGreaterThan(0)
// A key in neither locale falls back to the raw key (flag the gap).
expect(t('does.not.exist')).toBe('does.not.exist')
})
it('registerLocales registers zh+en under the apesSidebar namespace', () => {
const { service, registered } = makeLocaleService('zh')
const dispose = registerLocales(service)
expect(registered.map((r) => r.locale).sort()).toEqual(['en', 'zh'])
expect(registered.every((r) => r.ns === 'apesSidebar')).toBe(true)
dispose() // clean disposer, no throw on double-run
expect(() => dispose()).not.toThrow()
})
it('registerLocales seeds activeLocale from the runtime snapshot', () => {
const { service } = makeLocaleService('en')
const dispose = registerLocales(service)
expect(getActiveLocale()).toBe('en')
expect(t('tab.tasks.title')).toBe('Tasks')
dispose()
})
it('keeps activeLocale in sync as the runtime emits a new active locale', () => {
const { service, setActive } = makeLocaleService('zh')
const dispose = registerLocales(service)
expect(getActiveLocale()).toBe('zh')
const before = t('tab.tasks.title')
setActive('en')
expect(getActiveLocale()).toBe('en')
expect(t('tab.tasks.title')).toBe('Tasks')
// The value actually changed.
expect(before).not.toBe(t('tab.tasks.title'))
dispose()
})
it('subscribeLocale notifies on locale change and detaches cleanly', () => {
const { service, setActive } = makeLocaleService('zh')
const dispose = registerLocales(service)
const seen: string[] = []
const unsub = subscribeLocale(() => seen.push(getActiveLocale()))
setActive('en')
expect(seen).toEqual(['en'])
unsub()
setActive('zh')
// After detach, no further observation.
expect(seen).toEqual(['en'])
dispose()
})
it('is a no-op (clean disposer) when the locale runtime is absent', () => {
const dispose = registerLocales(undefined)
expect(typeof dispose).toBe('function')
expect(() => dispose()).not.toThrow()
})
it('tolerates a runtime whose subscribe throws (early boot guard)', () => {
const service: ApesLocaleService = {
getSnapshot: () => ({ active: 'en' }),
subscribe: () => {
throw new Error('runtime not ready')
},
register: () => () => {},
}
// The try/catch in registerLocales swallows the subscribe failure and
// still returns a working disposer.
const dispose = registerLocales(service)
expect(typeof dispose).toBe('function')
expect(() => dispose()).not.toThrow()
})
})
+103
View File
@@ -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([])
})
})
+87
View File
@@ -0,0 +1,87 @@
/**
* Orphaned-tab tests: a persisted tab whose type is not registered must
* survive sanitize (title/path kept for recovery) and render the graceful
* placeholder instead of crashing — the degradation path of the open
* tab-type set (a contributing plugin not installed / not yet loaded).
*/
import { describe, expect, it } from 'vitest'
import { renderToString } from 'react-dom/server'
import { OrphanedTab } from '../src/client/OrphanedTab.tsx'
import { sanitizeState } from '../src/client/state.ts'
/** Collect every tab type in a sanitized tree (leaf/split recursive). */
function typesOf(node: unknown): string[] {
if (node === null || typeof node !== 'object') return []
const record = node as { kind?: unknown; tabs?: unknown; children?: unknown }
if (record.kind === 'leaf' && Array.isArray(record.tabs)) {
return (record.tabs as Array<{ type: string }>).map(tab => tab.type)
}
if (Array.isArray(record.children)) return record.children.flatMap(typesOf)
return []
}
describe('sanitize keeps unregistered tab types', () => {
it('preserves a persisted tab of an unknown type with its title/path', () => {
const raw = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: null,
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
tabs: [
{ id: 'tab:1', type: 'my-plugin:db', title: 'Database', path: '/data.sqlite' },
{ id: 'tab:2', type: 'explorer', title: 'Explorer' },
],
active: 'tab:1',
},
}
const clean = sanitizeState(raw)
expect(clean).toBeDefined()
expect(typesOf(clean!.splits)).toContain('my-plugin:db')
// The kept tab keeps its payload so a later registration can reuse it.
const leaf = clean!.splits as unknown as { kind: 'leaf'; tabs: Array<Record<string, unknown>> }
const kept = leaf.tabs.find(tab => tab.type === 'my-plugin:db')
expect(kept).toMatchObject({ title: 'Database', path: '/data.sqlite' })
})
it('an unknown type alone in a pane still sanitizes cleanly', () => {
const raw = {
panelOpen: true,
width: 400,
nextTerminal: 1,
activePane: null,
expanded: [],
splits: {
kind: 'leaf',
id: 'pane:1',
tabs: [{ id: 'tab:1', type: 'some-plugin:page', title: 'Page' }],
active: 'tab:1',
},
}
const clean = sanitizeState(raw)
expect(clean).toBeDefined()
expect(typesOf(clean!.splits)).toEqual(['some-plugin:page'])
})
})
describe('OrphanedTab placeholder', () => {
const tab = { id: 'tab:1', type: 'my-plugin:db', title: 'Database' }
it('renders the unregistered type and keeps the title', () => {
const html = renderToString((
<OrphanedTab
ctx={undefined as never}
store={undefined as never}
scope={{ sessionId: 's1', cwd: '/p' }}
tab={tab}
visible={false}
/>
))
expect(html).toContain('my-plugin:db')
expect(html).toContain('Database')
expect(html).not.toContain('undefined')
})
})
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import Loader from '@cordisjs/plugin-loader'
import * as sidebar from '../src/index.ts'
/**
* Run the real namespace export through `Loader.unwrapExports`; a stray
* default would discard `name`, `inject`, `Config`, and `apply`. Same guard
* the official plugin repos ship (dsh-external/turtle-ui,
* packages/ui/jsonrpc).
*/
describe('dsh-better-sidebar plugin export shape', () => {
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
expect('default' in sidebar).toBe(false)
expect(typeof sidebar.apply).toBe('function')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown>
expect(unwrapped).toBe(sidebar)
expect(unwrapped.name).toBe('dsh-better-sidebar')
expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader', 'tools'])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
it('exports the schemastery Config with the documented tunable fields', () => {
const schema = sidebar.Config
expect(schema).toBeDefined()
// The resolved defaults mirror the pre-config constants.
const resolved = (schema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})(undefined)
expect(resolved.readLimit).toBe(512 * 1024)
expect(resolved.mediaLimit).toBe(20 * 1024 * 1024)
expect(resolved.listLimit).toBe(1000)
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)
// The terminal tools default OFF (the feature is dormant until the user
// enables it in the side card settings).
expect(resolved.agentTerminalTools).toBe(false)
// The enable-switch maps resolve to {} (everything on) for old documents.
expect(resolved.tabsEnabled).toEqual({})
expect(resolved.viewersEnabled).toEqual({})
// 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, agentTerminalTools: false, tabsEnabled: {}, viewersEnabled: {} })
})
})
+394
View File
@@ -0,0 +1,394 @@
/**
* Tests for the BetterSidebar service registry: register/dispose lifecycle,
* matchFileViewer priority/exts/detect algorithm, and openTab dedupe.
*/
import { describe, it, expect } from 'vitest'
// Mock browser globals (SidebarStore.reduce → schedulePersist uses window.setTimeout)
const g = globalThis as Record<string, unknown>
if (g.window === undefined) {
g.window = {
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
innerWidth: 1024,
}
}
if (g.localStorage === undefined) {
g.localStorage = {
getItem: () => null,
setItem: () => {},
}
}
import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore, allLeaves, openDiffTab } from '../src/client/state.ts'
describe('BetterSidebar service', () => {
it('registerTab adds to the registry and dispose removes it', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
expect(service.getTabs()).toHaveLength(0)
const dispose = service.registerTab({
id: 'test:tab',
title: 'Test',
component: () => null,
})
expect(service.getTabs()).toHaveLength(1)
expect(service.getTab('test:tab')?.id).toBe('test:tab')
dispose()
expect(service.getTabs()).toHaveLength(0)
expect(service.getTab('test:tab')).toBeUndefined()
})
it('registerTab throws on duplicate id', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'dup', title: 'A', component: () => null })
expect(() => service.registerTab({ id: 'dup', title: 'B', component: () => null })).toThrow()
})
it('registerFileViewer adds and dispose removes', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
expect(service.getFileViewers()).toHaveLength(0)
const dispose = service.registerFileViewer({
id: 'csv',
exts: ['csv'],
fetchStrategy: 'custom',
component: () => null,
})
expect(service.getFileViewers()).toHaveLength(1)
dispose()
expect(service.getFileViewers()).toHaveLength(0)
})
it('subscribe fires on register and dispose', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
let calls = 0
const unsub = service.subscribe(() => { calls++ })
const dispose = service.registerTab({ id: 'x', title: 'X', component: () => null })
expect(calls).toBe(1)
dispose()
expect(calls).toBe(2)
unsub()
service.registerTab({ id: 'y', title: 'Y', component: () => null })
expect(calls).toBe(2)
})
})
describe('enable switches (declarative settings)', () => {
/** A fresh store + service with one tab and one viewer registered. */
const setup = () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
service.registerFileViewer({ id: 'image', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null })
return { store, service }
}
it('an absent map key means enabled (default state)', () => {
const { service } = setup()
expect(service.isTabEnabled('explorer')).toBe(true)
expect(service.isViewerEnabled('image')).toBe(true)
// Unknown ids are enabled too (nothing registered → the check is open).
expect(service.isTabEnabled('whatever')).toBe(true)
})
it('only an explicit false disables a tab type', () => {
const { store, service } = setup()
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
expect(service.isTabEnabled('explorer')).toBe(false)
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: true } })
expect(service.isTabEnabled('explorer')).toBe(true)
})
it('openTab refuses a disabled tab type (no tab lands, no createTab)', () => {
const { store, service } = setup()
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { explorer: false } })
store.setSession('s1')
service.openTab({ type: 'explorer', title: 'Explorer' })
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(l => l.tabs)
expect(tabs).toHaveLength(0)
})
it('matchFileViewer skips a disabled viewer (files fall through)', () => {
const { store, service } = setup()
service.registerFileViewer({ id: 'code', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null })
// image claims .png; disabling it lets the catch-all take over.
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false } })
expect(service.matchFileViewer('photo.png')?.id).toBe('code')
// Disabling the catch-all too → no viewer at all.
store.setPrefs({ ...store.getPrefs(), viewersEnabled: { image: false, code: false } })
expect(service.matchFileViewer('photo.png')).toBeUndefined()
// Re-enabling restores the image viewer.
store.setPrefs({ ...store.getPrefs(), viewersEnabled: {} })
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
})
})
describe('matchFileViewer', () => {
it('matches by extension', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'img', exts: ['png', 'jpg'], fetchStrategy: 'mediaUrl', component: () => null })
expect(service.matchFileViewer('photo.png')?.id).toBe('img')
expect(service.matchFileViewer('photo.JPG')?.id).toBe('img')
expect(service.matchFileViewer('doc.txt')).toBeUndefined()
})
it('higher priority wins on extension conflict', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'basic', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
service.registerFileViewer({ id: 'advanced', exts: ['png'], priority: 10, fetchStrategy: 'custom', component: () => null })
expect(service.matchFileViewer('x.png')?.id).toBe('advanced')
})
it('catch-all (exts: []) matches anything at lowest priority', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'catchall', exts: [], priority: -100, fetchStrategy: 'fsRead', component: () => null })
service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
expect(service.matchFileViewer('x.png')?.id).toBe('img')
expect(service.matchFileViewer('x.txt')?.id).toBe('catchall')
})
it('detect claims files the viewer would otherwise miss, at its priority', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
// by-magic does not match 'bin' by extension; only its detect (PNG magic)
// can claim it — and only when head bytes are available.
service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 5, fetchStrategy: 'fsRead', component: () => null })
service.registerFileViewer({
id: 'by-magic',
exts: ['mag'],
priority: 10,
fetchStrategy: 'fsRead',
detect: (_path, head) => head[0] === 0x89,
component: () => null,
})
// No head: by-magic's exts miss, by-ext claims .bin.
expect(service.matchFileViewer('file.bin')?.id).toBe('by-ext')
// Head with PNG magic: by-magic's detect fires at priority 10 before
// by-ext (5) is consulted.
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-magic')
// Head without the magic: detect misses, by-ext claims .bin again.
expect(service.matchFileViewer('file.bin', new Uint8Array([0x00, 0x50]))?.id).toBe('by-ext')
})
it('priority decides first: a higher-priority exts match beats a lower-priority detect', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'by-ext', exts: ['bin'], priority: 10, fetchStrategy: 'fsRead', component: () => null })
service.registerFileViewer({
id: 'by-magic',
exts: ['mag'],
priority: 5,
fetchStrategy: 'fsRead',
detect: (_path, head) => head[0] === 0x89,
component: () => null,
})
// Per-descriptor walk: by-ext (priority 10) claims .bin before by-magic's
// detect (priority 5) is ever consulted — the design's priority-first rule.
expect(service.matchFileViewer('file.bin', new Uint8Array([0x89, 0x50]))?.id).toBe('by-ext')
})
it('a catch-all with detect is sniff-only: it never blind-claims without head', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'img', exts: ['png'], priority: 0, fetchStrategy: 'mediaUrl', component: () => null })
service.registerFileViewer({
id: 'magic-sniffer',
exts: [],
priority: 100,
fetchStrategy: 'custom',
detect: (_path, head) => head[0] === 0x89,
component: () => null,
})
// No head: the sniff-only catch-all yields — the real png viewer wins.
expect(service.matchFileViewer('photo.png')?.id).toBe('img')
// Head with the magic: the sniffer claims it (detect at priority 100).
expect(service.matchFileViewer('photo.png', new Uint8Array([0x89, 0x50]))?.id).toBe('magic-sniffer')
// Head without the magic: the sniffer yields again, img claims it.
expect(service.matchFileViewer('photo.png', new Uint8Array([0x00, 0x50]))?.id).toBe('img')
})
it('returns undefined when no viewer matches and no catch-all', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerFileViewer({ id: 'img', exts: ['png'], fetchStrategy: 'mediaUrl', component: () => null })
expect(service.matchFileViewer('doc.txt')).toBeUndefined()
})
})
describe('service.openTab dedupe', () => {
it('dedupeKey focuses existing tab instead of duplicating', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'singleton',
title: 'Singleton',
dedupeKey: () => 'singleton',
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'singleton', title: 'Singleton' })
service.openTab({ type: 'singleton', title: 'Singleton' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
expect(tabs.filter(t => t.type === 'singleton')).toHaveLength(1)
})
it('no dedupeKey opens a new tab for each distinct id', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'multi',
title: 'Multi',
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:2' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(2)
})
it('reopening with the same id focuses the existing tab (id safety net)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'multi',
title: 'Multi',
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
service.openTab({ type: 'multi', title: 'Multi', id: 'multi:1' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs)
expect(tabs.filter(t => t.type === 'multi')).toHaveLength(1)
})
it('createTab mints custom ids and patches state', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'counter',
title: 'Counter',
createTab: (state) => ({
tab: { id: `counter:${state.nextTerminal}`, type: 'counter', title: `C${state.nextTerminal}` },
patch: { nextTerminal: state.nextTerminal + 1 },
}),
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'counter', title: 'Counter' })
service.openTab({ type: 'counter', title: 'Counter' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'counter')
expect(tabs).toHaveLength(2)
expect(tabs[0]!.id).toBe('counter:1')
expect(tabs[1]!.id).toBe('counter:2')
expect(state.nextTerminal).toBe(3)
})
it('a caller-provided title wins over the descriptor title (editor shows the file name)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: () => 'Editor', component: () => null })
store.setSession('s1')
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'editor')
expect(tab?.title).toBe('main.ts')
})
it('the descriptor title is the default when no title is given', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'plain', title: () => 'Plain', component: () => null })
store.setSession('s1')
service.openTab({ type: 'plain' })
const state = store.getSnapshot().state!
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'plain')
expect(tab?.title).toBe('Plain')
})
it('single: true dedupes like dedupeKey: () => id (sugar)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'singleton', title: 'Singleton', single: true, component: () => null })
store.setSession('s1')
service.openTab({ type: 'singleton' })
service.openTab({ type: 'singleton', id: 'singleton:extra' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'singleton')
expect(tabs).toHaveLength(1)
})
it('an explicit dedupeKey wins over single: true', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'multi',
title: 'Multi',
single: true,
dedupeKey: (tab) => tab.id, // per-id, not per-type: two tabs coexist
component: () => null,
})
store.setSession('s1')
service.openTab({ type: 'multi', id: 'multi:1' })
service.openTab({ type: 'multi', id: 'multi:2' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
expect(tabs).toHaveLength(2)
service.openTab({ type: 'multi', id: 'multi:1' })
const state2 = store.getSnapshot().state!
const tabs2 = allLeaves(state2.splits).flatMap(l => l.tabs).filter(t => t.type === 'multi')
expect(tabs2).toHaveLength(2)
})
it('available receives ctx, scope and the live state (superset signature)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const seen: unknown[] = []
service.registerTab({
id: 'gated',
title: 'Gated',
available: (ctx, scope, state) => {
seen.push([ctx, scope, state])
return true
},
component: () => null,
})
store.setSession('s1')
expect(service.getTabs()[0]!.available?.({} as never, { sessionId: 's1', cwd: '/p' }, store.getSnapshot().state!)).toBe(true)
expect(seen).toHaveLength(1)
})
it('openDiffTab and the service dedupeKey agree on diff identity (per-change id rule)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'diff',
title: 'Diff',
dedupeKey: (tab) => tab.id,
component: () => null,
})
store.setSession('s1')
const seed = { kind: 'worktree' as const, path: '/p/a.ts', staged: false }
const paneId = store.getSnapshot().state!.activePane!
// The git view's placement path (split surgery) opens the diff tab...
store.reduce(s => openDiffTab(s, paneId, { id: 'diff:1', type: 'diff', title: 'a.ts', diff: seed }))
// ...and a service open of the same change focuses the existing tab
// (the descriptor's dedupeKey is the same per-change id rule).
service.openTab({ type: 'diff', title: 'a.ts', diff: seed, id: 'diff:1' })
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'diff')
expect(tabs).toHaveLength(1)
})
})
+156
View File
@@ -0,0 +1,156 @@
/**
* Side card settings section render tests: the section is DECLARATIVE —
* every small card (icon, title, type id, extensions, on/off state) derives
* from the sidebar service's tab/viewer registries instead of hardcoded
* copy. The toggles are CARDS in a responsive grid: the card's main area is
* the switch, the visual state IS the state (highlighted = enabled),
* announced via `aria-pressed`, and the check badge sits at the far right.
* Features that declare related settings carry a gear corner button whose
* popup rows (native checkboxes) are tested through the extracted
* FeatureSettingsRows component (the Modal portal renders only while open).
*
* Rendered with renderToString (mount effects — the settings RPC sync — do
* not run in SSR; the initial store prefs are the render input).
*/
import { describe, expect, it } from 'vitest'
import { renderToString } from 'react-dom/server'
import { createElement } from 'react'
import { createSidebarStore, type SidebarStore } from '../src/client/state.ts'
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
import { FeatureSettingsRows, SideCardSection, type SideCardSectionProps } from '../src/client/SideCardSection.tsx'
/** One tab + one viewer + the subagent-style nested toggle under a tab. */
function mount(): { store: SidebarStore; service: BetterSidebarService } {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({
id: 'explorer',
title: () => 'Explorer',
icon: () => createElement('svg', { 'data-icon': 'explorer' }),
order: 10,
component: () => null,
})
service.registerTab({
id: 'subagent',
title: () => 'Subagents',
icon: () => createElement('svg', { 'data-icon': 'subagent' }),
order: 30,
settings: {
toggles: [{
key: 'autoOpenSubagent',
title: () => 'Auto-open Subagents',
desc: () => 'Expand on new subagent',
}],
},
component: () => null,
})
service.registerFileViewer({
id: 'image',
title: () => 'Image',
icon: () => createElement('svg', { 'data-icon': 'image' }),
exts: ['png', 'jpg'],
fetchStrategy: 'mediaUrl',
component: () => null,
})
return { store, service }
}
function renderSection(store: SidebarStore, service: BetterSidebarService): string {
return renderToString(createElement(
SideCardSection,
{ store, service } as unknown as SideCardSectionProps,
))
}
/** Count `aria-pressed` occurrences of one value in the rendered HTML. */
function pressedCount(html: string, value: string): number {
return html.match(new RegExp(`aria-pressed="${value}"`, 'g'))?.length ?? 0
}
describe('SideCardSection declarative inventory', () => {
it('renders one small card per registered tab: icon + title + type id + pressed state', () => {
const { store, service } = mount()
const html = renderSection(store, service)
expect(html).toContain('data-icon="explorer"')
expect(html).toContain('>Explorer<')
// The type id is the card's desc (the declarative "type" surface).
expect(html).toContain('>explorer<')
expect(html).toContain('data-icon="subagent"')
expect(html).toContain('>Subagents<')
// Default prefs: openByDefault + both tabs + the image viewer are all
// enabled → 4 cards pressed, none pressed=false. The nested auto-open
// toggle is NOT an inline card (it lives in the popup).
expect(pressedCount(html, 'true')).toBe(4)
expect(pressedCount(html, 'false')).toBe(0)
expect(html).not.toContain('Auto-open Subagents')
})
it('renders one small card per registered viewer: icon + title + exts', () => {
const { store, service } = mount()
const html = renderSection(store, service)
expect(html).toContain('data-icon="image"')
expect(html).toContain('>Image<')
// The covered extensions are the card's desc.
expect(html).toContain('png · jpg')
})
it('renders the gear corner button on features that declare related settings', () => {
const { store, service } = mount()
const html = renderSection(store, service)
// Subagents declares a toggle → its card carries the settings gear
// (aria-label = "<title> Feature settings"); Explorer and Image declare
// none → no gear.
expect(html.match(/aria-label="[^"]*Feature settings"/g)?.length).toBe(1)
})
it('a disabled feature renders pressed=false', () => {
const { store, service } = mount()
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false }, viewersEnabled: { image: false } })
const html = renderSection(store, service)
expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<')
expect(pressedCount(html, 'false')).toBe(2)
expect(pressedCount(html, 'true')).toBe(2)
})
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
const { store, service } = mount()
store.setPrefs({ ...store.getPrefs(), tabsEnabled: { subagent: false } })
const html = renderSection(store, service)
expect(html).not.toContain('Feature settings')
})
})
describe('FeatureSettingsRows (the secondary settings popup body)', () => {
const prefs: typeof SIDEBAR_PREFS_DEFAULTS = {
...SIDEBAR_PREFS_DEFAULTS,
autoOpenSubagent: false,
}
const toggles = [{
key: 'autoOpenSubagent',
title: () => 'Auto-open Subagents',
desc: () => 'Expand on new subagent',
}]
it('renders one native checkbox row per declared toggle with its current value', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles,
prefs,
onToggle: () => {},
}))
expect(html).toContain('Auto-open Subagents')
expect(html).toContain('Expand on new subagent')
// The checkbox reflects the prefs value (false here → unchecked).
expect(html).not.toContain('checked=""')
})
it('checks the row when the pref is on', () => {
const html = renderToString(createElement(FeatureSettingsRows, {
toggles,
prefs: { ...prefs, autoOpenSubagent: true },
onToggle: () => {},
}))
expect(html).toContain('checked=""')
})
})
+586 -97
View File
@@ -1,114 +1,603 @@
/**
* Phase 0 smoke spec: mounts the host plugin against a minimal fake context,
* verifies the plugin export shape (no stray default, so the cordis Loader
* keeps name/inject/apply), and exercises the invariant surface. Runs with
* `pnpm test`.
*
* The client half (lib/client.js closure factory) is a browser artifact — it
* registers tabs into the better-sidebar host registry at runtime, which
* needs a live DSH shell. The client registration contract is therefore
* covered by a focused unit spec (locales) + the build's purity gate; a full
* mount smoke (cordis scratch profile) lands in Phase 1 with the real tabs.
* Smoke spec: mounts the host plugin against a minimal fake context and
* exercises the real integrations — route registration, git against the
* actual repository, and a real directory listing. Runs with `pnpm test`.
*/
import { describe, expect, it } from 'vitest'
import * as host from '../src/index.ts'
import {
InvariantError,
assert,
assertBetterSidebarPeer,
compareSemver,
} from '../src/invariant.ts'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve as resolvePath } from 'node:path'
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'
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
interface FakeContext {
loader: { entries: () => never[] }
httpServer: {
register: (route: SidebarWebRoute) => () => void
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
}
sessions: { get: (id: string) => { header: { cwd?: string } } | undefined }
tools: { register: (tool: unknown) => () => void }
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[] = []
const effects: Array<() => void | (() => void)> = []
const ctx: FakeContext = {
loader: { entries: () => [] },
httpServer: {
register: (route) => { routes.push(route); return () => {} },
registerUpgrade: (route) => { upgrades.push(route); return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => () => {} },
// The DSH-vendored cordis runs the registration effect immediately and
// keeps its cleanup for disposal.
effect: (fn) => {
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'])
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
// Teardown runs without throwing (pty manager has nothing open).
for (const cleanup of effects) cleanup()
})
it('runs git status/log/branches against this repository', async () => {
const cwd = process.cwd()
const status = await git.status(cwd)
expect(status.isRepo).toBe(true)
expect(typeof status.branch).toBe('string')
expect(Array.isArray(status.entries)).toBe(true)
const log = await git.log(cwd)
expect(log.length).toBeGreaterThan(0)
expect(log[0]!.hash).toMatch(/^[0-9a-f]{7,}$/)
const branches = await git.branches(cwd)
expect(branches.names).toContain(branches.current)
})
it('enriches the log (full hash + refs) and renders commit diffs', async () => {
const cwd = process.cwd()
const log = await git.log(cwd)
const first = log[0]!
expect(first.hashFull).toMatch(/^[0-9a-f]{40}$/)
expect(typeof first.refs).toBe('string')
const patch = await git.commitDiff(cwd, first.hashFull)
expect(patch).toContain('diff --git')
})
it('pages the log lazily with skip/count', async () => {
const cwd = process.cwd()
const first = await git.log(cwd, 5, 0)
expect(first).toHaveLength(5)
const second = await git.log(cwd, 5, 5)
expect(second).toHaveLength(5)
// The pages are disjoint windows over the same ordered history.
expect(first[0]!.hashFull).not.toBe(second[0]!.hashFull)
const all = await git.log(cwd, 10, 0)
expect(all.slice(0, 5)).toEqual(first)
expect(all.slice(5)).toEqual(second)
// A skip past the end returns an empty page (the lazy loader's stop sign).
expect(await git.log(cwd, 5, 10_000)).toEqual([])
})
it('pty manager releases the quota on close and respawns after exit', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {
const first = manager.open('s1', 't1', process.cwd(), 80, 24)
expect(manager.keysOf('s1')).toHaveLength(1)
// Tab-close semantics (close frame): quota released immediately.
manager.scheduleClose(first.key, 0)
await new Promise(resolve => setTimeout(resolve, 50))
expect(manager.keysOf('s1')).toHaveLength(0)
// Reopen spawns a fresh process.
const second = manager.open('s1', 't1', process.cwd(), 80, 24)
expect(second).not.toBe(first)
expect(manager.keysOf('s1')).toHaveLength(1)
// After the shell exits, a reconnect respawns instead of reusing the dead handle.
second.pty.write('exit\r')
const deadline = Date.now() + 5000
while (!second.exited && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 100))
}
expect(second.exited).toBe(true)
const third = manager.open('s1', 't1', process.cwd(), 80, 24)
expect(third.exited).toBe(false)
expect(third).not.toBe(second)
} finally {
manager.disposeAll()
}
})
it('pty manager: exited zombie handles do not consume the quota', async () => {
const manager = new PtyManager(defaultShell(), 1)
try {
const first = manager.open('s3', 't1', process.cwd(), 80, 24)
first.pty.write('exit\r')
const deadline = Date.now() + 5000
while (!first.exited && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 100))
}
expect(first.exited).toBe(true)
// Quota is 1; the exited handle is swept, so a NEW tab can still spawn.
const second = manager.open('s3', 't2', process.cwd(), 80, 24)
expect(second.exited).toBe(false)
expect(manager.keysOf('s3')).toHaveLength(1)
} finally {
manager.disposeAll()
}
})
it('pty manager: a reconnect within the grace period cancels the pending close', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {
const handle = manager.open('s2', 't1', process.cwd(), 80, 24)
manager.scheduleClose(handle.key, 200)
manager.open('s2', 't1', process.cwd(), 80, 24)
await new Promise(resolve => setTimeout(resolve, 400))
expect(manager.get(handle.key)).toBeDefined()
} finally {
manager.disposeAll()
}
})
it('pty manager: reopening with a different cwd respawns in the new directory', async () => {
const manager = new PtyManager(defaultShell(), 3)
// A real second directory: os.tmpdir() exists on every platform ('/tmp'
// does not exist on Windows).
const other = tmpdir()
try {
const first = manager.open('s4', 't1', process.cwd(), 80, 24)
// The hydrate race: the first connect fell back to the process cwd,
// the reconnect carries the session's real cwd — the shell must move.
const second = manager.open('s4', 't1', other, 80, 24)
expect(second).not.toBe(first)
expect(second.cwd).toBe(other)
expect(manager.keysOf('s4')).toHaveLength(1)
// A same-cwd reconnect reattaches without respawning.
const third = manager.open('s4', 't1', other, 80, 24)
expect(third).toBe(second)
expect(manager.keysOf('s4')).toHaveLength(1)
} finally {
manager.disposeAll()
}
})
it('lists the repository root level', async () => {
const listing = await listDirectory(process.cwd(), 1000)
expect(listing.entries.some(entry => entry.name === 'src' && entry.isDir)).toBe(true)
expect(listing.entries.some(entry => entry.name === 'package.json' && !entry.isDir)).toBe(true)
expect(listing.truncated).toBe(false)
})
})
/**
* Run the real namespace export through `Loader.unwrapExports`; a stray
* default would discard `name`, `inject`, and `apply`. Same guard the
* official plugin repos ship.
* Destructive git operations (discard / revert / cherry-pick) run against a
* throwaway repository under the OS temp dir — never the plugin repo. The
* fixture's commit identity comes from the GIT_AUTHOR / GIT_COMMITTER
* environment variables, confined to the fixture process: no git config is
* touched anywhere (the plugin never sets an identity, and neither does its
* test fixture).
*/
describe('apes-sidebar host plugin shape', () => {
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in host).toBe(false)
expect(typeof host.apply).toBe('function')
expect(host.name).toBe('apes-sidebar')
// inject MUST be empty: `logger` is used only via optional-chaining in
// apply(ctx) — declaring it as an injected service would make the entry
// wait on a provider DSH host does not register, blocking activation
// (recovery mode). See src/index.ts comment.
expect(host.inject).toEqual([])
describe('git destructive operations (scratch repository)', () => {
const FIXTURE_IDENTITY = {
GIT_AUTHOR_NAME: 'dsh-better-sidebar-test',
GIT_AUTHOR_EMAIL: 'test@dsh.invalid',
GIT_COMMITTER_NAME: 'dsh-better-sidebar-test',
GIT_COMMITTER_EMAIL: 'test@dsh.invalid',
}
const gitRun = (cwd: string, args: string[]): string => {
const result = spawnSync('git', ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args], {
encoding: 'utf8',
env: { ...process.env, ...FIXTURE_IDENTITY },
})
if (result.status !== 0) {
throw new Error(result.stderr || `git ${args[0] ?? ''} exited with ${String(result.status)}`)
}
return result.stdout
}
/** A fresh repo on branch `main` with one committed file `a.txt`. */
const makeScratchRepo = (): string => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-'))
gitRun(dir, ['init', '-q'])
gitRun(dir, ['checkout', '-q', '-b', 'main'])
writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'base'])
return dir
}
it('discard restores the worktree file from the index (staged changes kept)', async () => {
const dir = makeScratchRepo()
try {
// Unstaged-only changes: fully reverts to the committed content.
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
await git.discard(dir, 'a.txt')
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
// Staged changes: the worktree snaps back to the STAGED content and
// the index is untouched (`git checkout -- <path>` restores from the
// index — VSCode's "Discard Changes" semantics).
writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n')
gitRun(dir, ['add', '-A'])
await git.discard(dir, 'a.txt')
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\nCHANGED\nthree\n')
const staged = await git.diff(dir, 'a.txt', true)
expect(staged).toContain('-two')
expect(staged).toContain('+CHANGED')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('apply mounts a logger line through the effect (Phase 0 minimal)', () => {
const logs: string[] = []
let effects = 0
it('revert creates a revert commit', async () => {
const dir = makeScratchRepo()
try {
writeFileSync(join(dir, 'a.txt'), 'one\nTWO\nthree\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'change'])
const featureHash = (await git.log(dir))[0]!.hashFull
await git.revert(dir, featureHash)
expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n')
expect((await git.log(dir))[0]!.subject).toBe('Revert "change"')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('cherry-pick applies a commit from another branch', async () => {
const dir = makeScratchRepo()
try {
gitRun(dir, ['checkout', '-q', '-b', 'feature'])
writeFileSync(join(dir, 'b.txt'), 'feature work\n')
gitRun(dir, ['add', '-A'])
gitRun(dir, ['commit', '-q', '-m', 'feature work'])
const featureHash = (await git.log(dir))[0]!.hashFull
gitRun(dir, ['checkout', '-q', 'main'])
await git.cherryPick(dir, featureHash)
expect(readFileSync(join(dir, 'b.txt'), 'utf8')).toBe('feature work\n')
expect((await git.log(dir))[0]!.subject).toBe('feature work')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('reports a failing destructive operation as a GitCommandError', async () => {
const dir = makeScratchRepo()
try {
// An unknown revision fails before touching anything.
await expect(git.revert(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
await expect(git.cherryPick(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})
describe('session cwd resolution over the API route', () => {
interface CtxOverrides {
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
}
const mount = (overrides: CtxOverrides = {}): SidebarWebRoute => {
const routes: SidebarWebRoute[] = []
const ctx = {
logger: { info: (msg: string) => logs.push(msg) },
effect: (fn: () => void | (() => void)) => {
effects += 1
const cleanup = fn()
return () => {
if (typeof cleanup === 'function') cleanup()
loader: { entries: () => [] },
httpServer: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: overrides.sessions ?? { get: () => undefined },
tools: { register: () => () => {} },
// 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')!
}
const invoke = async (
route: SidebarWebRoute,
method: string,
payload: unknown,
): Promise<{ ok: boolean; value?: { cwd: string }; error?: { 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?: { cwd: string }; error?: { message: string } }
}
it('uses the client summary cwd while the session is detached', async () => {
const route = mount()
const result = await invoke(route, 'session.cwd', { sessionId: 's-detached', cwd: '/tmp/summary-cwd' })
expect(result.ok).toBe(true)
// The summary cwd passes through requireAbsolute (platform resolve), so
// the expectation follows the platform's own normalization.
expect(result.value?.cwd).toBe(resolvePath('/tmp/summary-cwd'))
})
it('falls back to the process cwd with no summary cwd', async () => {
const route = mount()
const result = await invoke(route, 'session.cwd', { sessionId: 's-unknown' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe(process.cwd())
})
it('prefers the attached session header over the client summary', async () => {
const route = mount({
sessions: {
get: (id) => id === 's-attached' ? { header: { cwd: '/attached-cwd' } } : undefined,
},
})
const result = await invoke(route, 'session.cwd', { sessionId: 's-attached', cwd: '/tmp/summary-cwd' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe('/attached-cwd')
})
it('rejects a non-absolute client cwd', async () => {
const route = mount()
const result = await invoke(route, 'session.cwd', { sessionId: 's-detached', cwd: 'relative/path' })
expect(result.ok).toBe(false)
expect(result.error?.message).toMatch(/invalid working directory/)
})
it('pty.close releases a terminal key (and rejects a missing tab)', async () => {
const route = mount()
const result = await invoke(route, 'pty.close', { sessionId: 's-pty', tab: 't1' })
expect(result.ok).toBe(true)
const missing = await invoke(route, 'pty.close', { sessionId: 's-pty' })
expect(missing.ok).toBe(false)
})
it('git.diff resolves repo-relative paths (session in a subdirectory)', async () => {
// The plugin repo's status paths are relative to the repo top level
// (e.g. `src/git.ts`); a session whose cwd sits inside the repo must
// still load per-file diffs instead of failing with "not an absolute
// path". The session header points INTO the repository.
const route = mount({
sessions: {
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
},
})
const result = await invoke(route, 'git.diff', { sessionId: 's-sub', path: 'src/git.ts', staged: false })
expect(result.ok).toBe(true)
const value = result as unknown as { ok: boolean; value?: { diff: string } }
expect(typeof value.value?.diff).toBe('string')
})
it('fs.read resolves repo-relative paths (untracked diff fallback)', async () => {
const route = mount({
sessions: {
get: () => ({ header: { cwd: join(process.cwd(), 'src') } }),
},
})
const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'src/git.ts' })
expect(result.ok).toBe(true)
const value = result as unknown as { ok: boolean; value?: { kind: string; content: string } }
expect(value.value?.kind).toBe('text')
expect(value.value?.content).toContain('runGit')
})
})
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 },
tools: { register: () => () => {} },
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,
agentTerminalTools: false,
// The enable-switch maps default to {} (everything on).
tabsEnabled: {},
viewersEnabled: {},
},
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/)
})
})
describe('agent terminal tool gating', () => {
it('injects the eight tools only when the side-card setting is enabled (default off)', () => {
let registered = 0
let disposed = 0
// The tools currently registered (registered minus disposed).
const live = (): number => registered - disposed
// A ref container: the watch callback is only assigned inside a closure,
// which TypeScript's control-flow analysis ignores (the bare variable
// would narrow to null and refuse the optional call).
const watcherRef: { current: (() => void) | null } = { current: null }
let enabled = false
const settings = {
register() {
return {
get: () => ({ agentTerminalTools: enabled }),
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
update: async () => {},
replace: async () => {},
}
},
describe: () => [],
async update() {},
}
host.apply(ctx as never)
expect(effects).toBe(1)
expect(logs).toEqual(['[apes-sidebar] host half mounted (Phase 0 skeleton)'])
})
it('apply survives a missing logger (degrades, never throws)', () => {
let effects = 0
const ctx = {
effect: (fn: () => void | (() => void)) => {
effects += 1
fn()
loader: { entries: () => [] },
httpServer: {
register: (route: SidebarWebRoute) => { void route; return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => { registered += 1; return () => { disposed += 1 } } },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: readonly string[], callback: (sctx: { settings: unknown }) => void) => {
if (deps.includes('settings')) callback({ settings })
return () => {}
},
}
expect(() => host.apply(ctx as never)).not.toThrow()
expect(effects).toBe(1)
})
it('apply without an effect API still runs once synchronously', () => {
const logs: string[] = []
const ctx = { logger: { info: (msg: string) => logs.push(msg) } }
host.apply(ctx as never)
expect(logs).toEqual(['[apes-sidebar] host half mounted (Phase 0 skeleton)'])
})
})
describe('apes-sidebar invariant surface', () => {
it('assert throws an InvariantError on a falsy condition', () => {
expect(() => assert(false, 'boom')).toThrow(InvariantError)
expect(() => assert(false, 'boom')).toThrow('boom')
// A truthy condition passes silently.
expect(() => assert(true, 'ok')).not.toThrow()
expect(() => assert(1, 'ok')).not.toThrow()
})
it('compareSemver orders versions (numeric + rc prerelease)', () => {
expect(compareSemver('0.17.9', '0.18.0')).toBeLessThan(0)
expect(compareSemver('0.18.0', '0.18.0')).toBe(0)
expect(compareSemver('0.19.0', '0.18.0')).toBeGreaterThan(0)
expect(compareSemver('1.0.0', '0.99.99')).toBeGreaterThan(0)
// Prerelease sorts below the release it qualifies.
expect(compareSemver('0.18.0-rc.1', '0.18.0')).toBeLessThan(0)
expect(compareSemver('0.18.0-rc.1', '0.18.0-rc.2')).toBeLessThan(0)
})
it('assertBetterSidebarPeer warns on an old peer (no logger → no throw)', () => {
const warnings: string[] = []
const warn = (msg: string) => warnings.push(msg)
// An old peer (0.17.x) triggers a warn.
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.17.9' }
assertBetterSidebarPeer({ warn })
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain('0.17.9')
// A new peer (0.18.x) does not.
warnings.length = 0
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.18.0' }
assertBetterSidebarPeer({ warn })
expect(warnings).toHaveLength(0)
// No logger at all: still no throw.
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.17.0' }
expect(() => assertBetterSidebarPeer(undefined)).not.toThrow()
delete (globalThis as Record<string, unknown>).__apesPeer__
apply(ctx as never)
// Default off: no tools are registered even though the settings service is mounted.
expect(live()).toBe(0)
// Flipping the setting on registers all eight tools.
enabled = true
watcherRef.current?.()
expect(live()).toBe(8)
expect(disposed).toBe(0)
// Flipping it back off unregisters them (and releases any agent terminals).
enabled = false
watcherRef.current?.()
expect(live()).toBe(0)
expect(disposed).toBe(8)
// And a redundant toggle registers them fresh (no double-registration per
// flip: the guard only skips when the tools are already live).
enabled = true
watcherRef.current?.()
expect(live()).toBe(8)
expect(registered).toBe(16)
})
})
-267
View File
@@ -1,267 +0,0 @@
/**
* Phase 1 tab-registration spec: mounts the CLIENT half against a mock
* better-sidebar registry and verifies the five `apes:*` tabs register with
* the right descriptors, the disposer tears them down (HMR-safe), and a
* missing `betterSidebar` peer degrades without throwing.
*
* The React components are NOT rendered here (Phase 0 testing convention:
* node vitest environment, no DOM). The descriptors' `component` field is
* asserted to be a function; the JSX it returns is only evaluated when the
* host actually mounts the tab, which a live DSH shell does (Phase 1+ mount
* smoke). What this spec pins is the REGISTRATION CONTRACT: the ids, the
* title/order/single shape, and the disposer lifecycle.
*
* The mock registry records every registered descriptor and returns a
* disposer that removes it — mirroring the real better-sidebar
* `registerTab` (returns `() => void`, invoked by cordis on fiber teardown).
*/
import { describe, expect, it } from 'vitest'
import { apply, filterTasksRows } from '../src/client/index.tsx'
/** The five tab ids this plugin owns (the + menu contract). */
const EXPECTED_IDS = ['apes:tasks', 'apes:agents', 'apes:apps', 'apes:messages', 'apes:profile']
/** One registered descriptor shape the mock records (the fields we assert). */
interface Registered {
id: string
title: string | (() => string)
order?: number
single?: boolean
component: unknown
badge?: (ctx: unknown, scope: unknown, state: unknown) => string | number | null | undefined
}
/**
* A mock better-sidebar registry: `registerTab` records the descriptor and
* returns a disposer that removes it (the real service's contract).
*/
function makeMockRegistry() {
const registered: Registered[] = []
return {
registry: {
registerTab: (descriptor: Registered) => {
registered.push(descriptor)
return () => {
const i = registered.findIndex((r) => r.id === descriptor.id)
if (i !== -1) registered.splice(i, 1)
}
},
getTabs: () => registered,
subscribe: () => () => undefined,
},
registered,
}
}
/** A no-op locale service (registerLocales only needs register/getSnapshot/subscribe). */
function makeMockLocale() {
return {
getSnapshot: () => ({ active: 'zh' }),
subscribe: () => () => undefined,
register: () => () => undefined,
}
}
/**
* Drive a cordis effect: invoke the effect body, capture its returned
* disposer, and expose `teardown()` so a test can simulate fiber unmount.
*/
function makeEffectHarness() {
let disposer: (() => void) | undefined
return {
effect: (fn: () => void | (() => void)) => {
const result = fn()
if (typeof result === 'function') disposer = result
return () => disposer?.()
},
teardown: () => disposer?.(),
}
}
describe('apes-sidebar client tab registration (Phase 1)', () => {
it('registers all five apes:* tabs into the better-sidebar registry', () => {
const { registry, registered } = makeMockRegistry()
const eff = makeEffectHarness()
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never)
expect(registered.map((r) => r.id).sort()).toEqual([...EXPECTED_IDS].sort())
expect(registry.getTabs()).toHaveLength(5)
})
it('gives every tab a title function, an ascending order, single-instance, and a component fn', () => {
const { registered } = makeMockRegistry()
const eff = makeEffectHarness()
apply({ betterSidebar: makeMockRegistry().registry, locale: makeMockLocale(), ...eff } as never)
// Re-run on the registry whose descriptors we captured.
const { registry, registered: reg2 } = makeMockRegistry()
const eff2 = makeEffectHarness()
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff2 } as never)
for (const d of reg2) {
expect(EXPECTED_IDS).toContain(d.id)
// title is a function (i18n-friendly) and resolves to a non-empty string.
expect(typeof d.title === 'function' ? typeof d.title() : typeof d.title).toBe('string')
if (typeof d.title === 'function') expect(d.title().length).toBeGreaterThan(0)
// order is present and numeric.
expect(typeof d.order).toBe('number')
// single-instance sugar (each apes:* tab is a singleton panel).
expect(d.single).toBe(true)
// component is a render function.
expect(typeof d.component).toBe('function')
}
// orders are strictly ascending in registration order.
const orders = reg2.map((d) => d.order as number)
for (let i = 1; i < orders.length; i++) {
expect(orders[i]!).toBeGreaterThan(orders[i - 1]!)
}
})
it('the combined disposer removes all five tabs (HMR-safe teardown)', () => {
const { registry } = makeMockRegistry()
const eff = makeEffectHarness()
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never)
expect(registry.getTabs()).toHaveLength(5)
// Simulate fiber unmount: cordis invokes the effect's returned disposer.
eff.teardown()
expect(registry.getTabs()).toHaveLength(0)
// A second teardown is a safe no-op (double-dispose is swallowed).
expect(() => eff.teardown()).not.toThrow()
})
it('does not register when betterSidebar is absent (bare DSH degrades)', () => {
const eff = makeEffectHarness()
expect(() => apply({ locale: makeMockLocale(), ...eff } as never)).not.toThrow()
// No effect was run (the guard returns before ctx.effect).
// The effect harness captured nothing.
expect(eff.effect).toBeTypeOf('function')
})
it('registers synchronously when no effect API is present (non-cordis harness)', () => {
const { registry } = makeMockRegistry()
// No `effect` on the context → apply registers synchronously.
apply({ betterSidebar: registry, locale: makeMockLocale() } as never)
expect(registry.getTabs()).toHaveLength(5)
})
it('tab ids follow the apes:* namespace (never wb:* or builtin ids)', () => {
const { registered } = makeMockRegistry()
const eff = makeEffectHarness()
apply({ betterSidebar: makeMockRegistry().registry, locale: makeMockLocale(), ...eff } as never)
for (const d of registered) {
expect(d.id.startsWith('apes:')).toBe(true)
expect(d.id).not.toMatch(/^wb:/)
}
})
})
/**
* Build a mock `ctx.sessions.list.getSnapshot()` feed for badge assertions.
* `byId` maps session id → a summary with optional `running` / `origin`.
*/
function makeMockSessionsCtx(byId: Record<string, { running?: boolean; origin?: string }>) {
const snapshot = { current: undefined as string | undefined, byId }
return {
sessions: {
list: {
getSnapshot: () => snapshot,
subscribe: () => () => undefined,
},
},
}
}
/**
* Phase 2 badge contract: `apes:tasks` shows the running-session count,
* `apes:agents` shows the subagent count, and BOTH hide (return null) when
* their count is 0. The badge is a pure `(ctx) => …` function, so it is
* exercised directly against a mock sessions feed — no DOM, no rendering.
*/
describe('apes-sidebar tab badges (Phase 2)', () => {
/** Register into a fresh mock registry and return the captured descriptors. */
function registeredDescriptors() {
const { registry, registered } = makeMockRegistry()
const eff = makeEffectHarness()
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never)
return registered
}
function badgeOf(registered: Registered[], id: string) {
return registered.find((d) => d.id === id)?.badge
}
it('apes:tasks badge is the running-session count (2 running → 2)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:tasks')
expect(badge).toBeTypeOf('function')
const ctx = makeMockSessionsCtx({
a: { running: true },
b: { running: true },
c: { running: false },
})
expect(badge!(ctx as never, {} as never, {} as never)).toBe(2)
})
it('apes:tasks badge is null when nothing is running (hidden pill)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:tasks')
const ctx = makeMockSessionsCtx({ a: { running: false }, b: {} })
expect(badge!(ctx as never, {} as never, {} as never)).toBeNull()
})
it('apes:agents badge is the subagent count (1 subagent → 1)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:agents')
expect(badge).toBeTypeOf('function')
const ctx = makeMockSessionsCtx({
parent: {},
child: { origin: 'subagent' },
other: { running: true }, // running but NOT a subagent → not counted
})
expect(badge!(ctx as never, {} as never, {} as never)).toBe(1)
})
it('apes:agents badge is null when there are no subagents (hidden pill)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:agents')
const ctx = makeMockSessionsCtx({ a: {}, b: { running: true } })
expect(badge!(ctx as never, {} as never, {} as never)).toBeNull()
})
it('apps/messages/profile have NO badge (information placeholders, no count)', () => {
const registered = registeredDescriptors()
for (const id of ['apes:apps', 'apes:messages', 'apes:profile']) {
expect(badgeOf(registered, id)).toBeUndefined()
}
})
})
/**
* Phase 2 per-tab state: the apes:tasks `showInactive` filter. When the user
* toggles the preference off, only running sessions render. This is a pure
* decision function (the hook's uSES↔settings wiring needs a render, but the
* filter itself is plain and unit-testable).
*/
describe('apes:tasks showInactive per-tab state filter (Phase 2)', () => {
const sessions = [
{ id: 'a', running: true },
{ id: 'b', running: false },
{ id: 'c', running: true },
{ id: 'd' }, // running undefined → treated as not running
]
it('showInactive=true renders every session', () => {
expect(filterTasksRows(sessions, true)).toHaveLength(4)
})
it('showInactive=false renders only running sessions (2 of 4)', () => {
const rows = filterTasksRows(sessions, false)
expect(rows).toHaveLength(2)
expect(rows.map((s) => s.id)).toEqual(['a', 'c'])
})
it('showInactive=false on an all-running list keeps everything', () => {
expect(filterTasksRows([{ running: true }, { running: true }], false)).toHaveLength(2)
})
it('showInactive=false on an all-inactive list yields empty', () => {
expect(filterTasksRows([{ running: false }, {}], false)).toHaveLength(0)
})
})
+223
View File
@@ -0,0 +1,223 @@
/**
* Tool-layer tests for the eight agent-terminal tools: registration shape,
* the combined disposer, canonical-output contract (validated against each
* tool's declared output schema exactly like the registry's
* `createSuccessResult` does), per-session ownership, and the UTF-8
* truncation boundary. The pty registry is stubbed (no real spawn): the
* wiring of the tools themselves is the subject here — pty behavior is
* covered by agent-pty.spec.ts.
*/
import { describe, expect, it } from 'vitest'
import { validateJsonSchemaValue, type ToolDefinition, type ToolRunContext } from '@deepseek-ai/dsh-tools'
import { boundBytes, registerTools } from '../src/tools.ts'
import type { AgentPtyRegistry } from '../src/agent-pty.ts'
import type { Context } from '../src/context-types.ts'
/** One registration captured by the fake tools service. */
interface CapturedTool {
definition: ToolDefinition
}
/** Stub registry: uuid-keyed rows without a real pty. */
class FakeRegistry {
readonly terminals = new Map<string, { sessionId: string; title: string; command: string; exited: boolean }>()
create(sessionId: string, title: string, command: string): string {
const uuid = `uuid-${this.terminals.size + 1}`
this.terminals.set(uuid, { sessionId, title, command, exited: false })
return uuid
}
list(sessionId: string): Array<{ uuid: string; title: string; command: string; exited: boolean }> {
return [...this.terminals.entries()]
.filter(([, row]) => row.sessionId === sessionId)
.map(([uuid, row]) => ({ uuid, title: row.title, command: row.command, exited: row.exited }))
}
assertOwned(uuid: string, sessionId: string): void {
const row = this.terminals.get(uuid)
if (row === undefined || row.sessionId !== sessionId) {
throw new Error(`agent terminal "${uuid}" not found`)
}
}
send(): void {}
read(): { text: string; totalLines: number; lineBegin: number; lineEnd: number } {
// A page larger than the tool's 256 KiB read cap, to exercise boundBytes.
return { text: 'x'.repeat(300 * 1024), totalLines: 1, lineBegin: 0, lineEnd: 1 }
}
resize(_uuid: string, cols: number, rows: number): { cols: number; rows: number } {
return { cols, rows }
}
signal(): void {}
close(): boolean {
return true
}
waitFor(_uuid: string, needle: string): Promise<{ kind: 'found'; needle: string; line: number; column: number; elapsedMs: number }> {
// Mirrors the registry's empty-needle rejection (the tool layer no
// longer duplicates this check).
if (needle === '') return Promise.reject(new Error('needle must be a non-empty string'))
return Promise.resolve({ kind: 'found', needle, line: 0, column: 0, elapsedMs: 1 })
}
}
/** A minimal ToolRunContext for one calling session (only the slices the tools touch). */
function exec(sessionId: string): ToolRunContext {
return {
signal: { throwIfAborted: () => {}, aborted: false },
agent: { session: { id: sessionId } },
} as unknown as ToolRunContext
}
/** Register the eight tools against a fake service; the tools bind to `registry`. */
function mount(): { captured: CapturedTool[]; registry: FakeRegistry; dispose: () => number } {
const captured: CapturedTool[] = []
let disposeCount = 0
const ctx = {
tools: {
register: (tool: unknown): (() => void) => {
captured.push({ definition: tool as ToolDefinition })
return () => { disposeCount += 1 }
},
},
} as unknown as Context
const registry = new FakeRegistry()
const dispose = registerTools(ctx, registry as unknown as AgentPtyRegistry, () => '/cwd')
return { captured, registry, dispose: () => { dispose(); return disposeCount } }
}
/** The captured definition for one tool name. */
function toolOf(captured: CapturedTool[], name: string): ToolDefinition {
const found = captured.find(candidate => candidate.definition.name === name)
if (found === undefined) throw new Error(`tool "${name}" was not registered`)
return found.definition
}
describe('agent terminal tools', () => {
it('registers all eight tools and the combined disposer unregisters them all', () => {
const { captured, dispose } = mount()
expect(captured.map(candidate => candidate.definition.name)).toEqual([
'terminal_create',
'terminal_list',
'terminal_send',
'terminal_read',
'terminal_wait_for',
'terminal_resize',
'terminal_signal',
'terminal_close',
])
expect(dispose()).toBe(8)
// Disposing twice must not throw (each registry disposer is idempotent).
expect(() => mount().dispose()).not.toThrow()
})
it('terminal_create returns the uuid and echoed title (schema-valid)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_create')
const value = await tool.execute({ title: 'dev server', command: 'npm run dev' }, exec('s1'))
expect(value).toMatchObject({ title: 'dev server' })
expect(typeof (value as { uuid: unknown }).uuid).toBe('string')
expect(registry.terminals.size).toBe(1)
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_list output matches the declared schema — no sessionId leaks', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_list')
const uuid = registry.create('s1', 'dev server', 'npm run dev')
registry.create('s2', 'other session', 'top secret')
const value = await tool.execute({}, exec('s1'))
const list = value as Array<Record<string, unknown>>
expect(list).toHaveLength(1)
expect(list[0]!.uuid).toBe(uuid)
// The session id is registry-internal ownership, never serialized.
expect('sessionId' in list[0]!).toBe(false)
// And the whole value validates against the declared output schema —
// this is the contract the registry enforces at runtime (additionalProperties: false).
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('rejects a uuid owned by another session (send/read/resize/signal/close/wait_for)', async () => {
const { captured, registry } = mount()
const mine = registry.create('s1', 'mine', '')
const foreign = registry.create('s2', 'theirs', '')
for (const name of ['terminal_send', 'terminal_read', 'terminal_resize', 'terminal_signal', 'terminal_close', 'terminal_wait_for']) {
const tool = toolOf(captured, name)
const args = name === 'terminal_send' ? { uuid: foreign, text: 'ls' }
: name === 'terminal_read' ? { uuid: foreign }
: name === 'terminal_resize' ? { uuid: foreign, cols: 80, rows: 24 }
: name === 'terminal_signal' ? { uuid: foreign, signal: 'SIGINT' }
: name === 'terminal_close' ? { uuid: foreign }
: { uuid: foreign, needle: 'x', timeout_ms: 100 }
// A foreign uuid is indistinguishable from an unknown one.
await expect(tool.execute(args, exec('s1'))).rejects.toThrow(/not found/)
}
// Own-session uuids still work.
const sendTool = toolOf(captured, 'terminal_send')
await expect(sendTool.execute({ uuid: mine, text: 'ls' }, exec('s1'))).resolves.toBeDefined()
const closeTool = toolOf(captured, 'terminal_close')
await expect(closeTool.execute({ uuid: mine }, exec('s1'))).resolves.toMatchObject({ closed: true })
})
it('terminal_read truncates at the byte cap and reports it (schema-valid)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_read')
const uuid = registry.create('s1', 'reader', '')
const value = await tool.execute({ uuid }, exec('s1'))
const result = value as { text: string; truncated: boolean }
expect(result.truncated).toBe(true)
expect(Buffer.byteLength(result.text, 'utf8')).toBeLessThanOrEqual(256 * 1024)
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_resize echoes the registry-applied dimensions (schema-valid)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_resize')
const uuid = registry.create('s1', 'resizer', '')
const value = await tool.execute({ uuid, cols: 120, rows: 40 }, exec('s1'))
expect(value).toEqual({ uuid, cols: 120, rows: 40 })
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_wait_for returns the registry result in camelCase (schema-valid, no projection)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_wait_for')
const uuid = registry.create('s1', 'waiter', '')
const value = await tool.execute({ uuid, needle: 'done' }, exec('s1'))
expect(value).toEqual({ kind: 'found', needle: 'done', line: 0, column: 0, elapsedMs: 1 })
expect(validateJsonSchemaValue(tool.output.schema, value, 'value')).toEqual([])
})
it('terminal_wait_for rejects an empty needle through the registry (no duplicate check)', async () => {
const { captured, registry } = mount()
const tool = toolOf(captured, 'terminal_wait_for')
const uuid = registry.create('s1', 'waiter', '')
await expect(tool.execute({ uuid, needle: '' }, exec('s1'))).rejects.toThrow(/non-empty/)
})
})
describe('boundBytes', () => {
it('never splits a multi-byte UTF-8 sequence at the cap', () => {
// '€' is 3 bytes (E2 82 AC); the cap must retreat to a sequence boundary.
expect(boundBytes('a€b', 2)).toEqual({ text: 'a', truncated: true })
expect(boundBytes('a€b', 3)).toEqual({ text: 'a', truncated: true })
expect(boundBytes('a€b', 4)).toEqual({ text: 'a€', truncated: true })
expect(boundBytes('a€b', 5)).toEqual({ text: 'a€b', truncated: false })
expect(boundBytes('€', 1)).toEqual({ text: '', truncated: true })
// ASCII pages truncate at the byte cap directly.
expect(boundBytes('hello', 3)).toEqual({ text: 'hel', truncated: true })
})
it('every retained prefix decodes without U+FFFD', () => {
for (let n = 1; n <= 18; n += 1) {
const { text } = boundBytes('中文字符串测试 123', n)
expect(text.includes('\uFFFD')).toBe(false)
expect(Buffer.byteLength(text, 'utf8')).toBeLessThanOrEqual(n)
}
})
})
+1105
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,5 +1,5 @@
{
"//": "Declaration build for the shipped types surface: emits lib/types/*.d.ts from src. The typecheck gate stays `tsc --noEmit` on tsconfig.json; tsdown bundles the JS. Consumers resolve declarations through the package exports' types subpaths.",
"//": "Declaration build for the shipped types surface: emits lib/types/*.d.ts from src (the typecheck gate stays `tsc --noEmit` on tsconfig.json; tsdown bundles the JS). Consumers resolve declarations through the package exports' types subpaths.",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
+2 -2
View File
@@ -4,8 +4,6 @@
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
@@ -13,6 +11,8 @@
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["node"]
},
+146 -92
View File
@@ -1,33 +1,66 @@
/**
* tsdown build for apes-sidebar:
* - host-half lib (lib/index.js + lib/invariant.js, ESM node)
* - the browser client bundle (lib/client.js, CJS closure factory) registered
* under the package-name id `apes-sidebar` (official profile channel).
* tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js and
* 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): externals resolve through the loader
* module table at runtime; everything else inlines; the purity gate rejects
* any other @deepseek-ai value import (cross-plugin collaboration goes
* through cordis services, never value imports); CSS compiles to hashed
* class maps and injects <style data-plugin> tags at factory execution;
* the artifact registers itself via window.__ModuleLoader__.load.
* - `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),
* - everything else is inlined into the bundle (xterm, clsx, ...),
* - the purity gate rejects any other @deepseek-ai value import: cross-plugin
* collaboration goes through cordis services, never value imports,
* - CSS Modules compile to hashed class maps and inject <style data-plugin>
* tags at factory execution,
* - 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, join, relative, resolve as resolvePath, sep } from 'node:path'
import { builtinModules } from 'node:module'
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
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)
/** Node builtins must never survive into the browser module-loader factory. */
const NODE_BUILTINS = new Set([
...builtinModules,
...builtinModules.map(id => `node:${id}`),
])
/** Module specifiers the web shell shares into the frozen module table. */
/**
* 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 = [
'react',
'react/jsx-runtime',
@@ -35,18 +68,22 @@ const CLIENT_EXTERNALS = [
'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',
]
/**
* Wire/type layers a client bundle may inline (mirror of the official
* INLINE_SAFE list): browser-safe contract surfaces with no runtime identity
* to share.
* to share. Everything else under @deepseek-ai/* is either a module-table
* entry (external) or a leak the purity gate rejects.
*/
const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
/** Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline. */
const CSS_VIRTUAL_PREFIX = '\0apes-css:'
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
const CSS_VIRTUAL_SUFFIX = '.mjs'
const REPOSITORY_ROOT = fileURLToPath(new URL('.', import.meta.url))
@@ -77,8 +114,10 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
/**
* One client bundle build for a plugin id. The same src/client/index.tsx is
* compiled once per install channel; only the registered id and the output
* file name differ, so they cannot drift.
* 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/.
@@ -92,101 +131,111 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
dts: false,
sourcemap: true,
clean: false,
deps: {
neverBundle: [...CLIENT_EXTERNALS],
alwaysBundle: (id: string) => !CLIENT_EXTERNALS.includes(id),
},
external: [...CLIENT_EXTERNALS],
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
// 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'],
},
},
plugins: [purityGatePlugin(), makeCssPlugin(pluginId)],
// External wins for module-table entries; every other dependency inlines.
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
plugins: [{
// Bundle purity gate (mirror of the official preset): platform seed
// entries stay external, inline-safe wire layers inline, and every
// other @deepseek-ai value import is a build error — a cross-plugin
// value import either inlines a duplicate runtime instance or requires
// a specifier the frozen module table cannot answer. Cross-plugin
// collaboration goes through cordis services instead. Type-only
// imports are erased and never reach this gate.
name: 'dsh-client-bundle-purity',
resolveId(source: string) {
if (NODE_BUILTINS.has(source)) {
throw new Error(
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
+ 'select the dependency browser export or add an explicit browser implementation',
)
}
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
throw new Error(
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
)
},
}, {
name: 'dsh-css-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.css')) return null
// Relative/absolute paths resolve against the importer; bare
// specifiers (e.g. 'xterm/css/xterm.css') resolve from the package.
let abs: string
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
} else {
abs = require.resolve(source)
}
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
// CSS Modules (x.module.css) become hashed class maps; plain css
// (xterm's stylesheet) is inlined verbatim.
if (fileId.endsWith('.module.css')) {
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: `[hash]_[local]` },
minify: true,
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
return [
injectTag(pluginId, fileId, code.toString()),
`export default ${JSON.stringify(classMap)};`,
].join('\n')
}
return [
injectTag(pluginId, fileId, source.toString('utf8')),
'export default "";',
].join('\n')
},
}],
outputOptions: {
entryFileNames: entryFile,
sourcemapPathTransform: browserSourcePath,
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,
},
}
}
/** A rolldown plugin as tsdown's config accepts it (contextual `this` for load/resolveId). */
type BuildPlugin = NonNullable<UserConfig['plugins']>
/** The shared client-bundle purity gate. */
function purityGatePlugin(): BuildPlugin {
return {
name: 'apes-client-bundle-purity',
resolveId(source: string) {
if (NODE_BUILTINS.has(source)) {
throw new Error(
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
+ 'select the dependency browser export or add an explicit browser implementation',
)
}
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
throw new Error(
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
)
},
}
}
/** The shared CSS-inline virtual-module plugin (one <style data-plugin> per file). */
function makeCssPlugin(pluginId: string): BuildPlugin {
return {
name: 'apes-css-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.css')) return null
let abs: string
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
} else {
// Bare specifiers resolve from the package.
const { createRequire } = require('node:module')
abs = createRequire(import.meta.url).resolve(source)
}
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
if (fileId.endsWith('.module.css')) {
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: `[hash]_[local]` },
minify: true,
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
return [
injectTag(pluginId, fileId, code.toString()),
`export default ${JSON.stringify(classMap)};`,
].join('\n')
}
return [
injectTag(pluginId, fileId, source.toString('utf8')),
'export default "";',
].join('\n')
},
}
}
export default [
{
entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' },
@@ -196,8 +245,13 @@ export default [
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('apes-sidebar', 'client.js'),
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[]
+9 -13
View File
@@ -1,9 +1,14 @@
/**
* Vitest config: inline the npm-published `@deepseek-ai/*` packages whose
* BUILT lib bundles css side-effect imports. Installed from the npm registry
* these packages live under `node_modules/.pnpm` and are externalized by
* vitest — Node then chokes on the `.css` import. Inlining routes them
* through Vite's transform, which stubs css imports (the default `css: false`).
* BUILT lib bundles css side-effect imports (e.g. `dsh-client-ui-primitives`
* imports `katex/dist/katex.min.css` at the top of its `lib/index.js`).
*
* Installed from the npm registry (the default since v0.4.1) these packages
* live under `node_modules/.pnpm` and are externalized by vitest — Node then
* chokes on the `.css` import. Inlining routes them through Vite's transform,
* which stubs css imports (the default `css: false`). The previous
* `link:`-to-source-checkout install needed no such config: linked files sit
* outside `node_modules` and are transformed by default.
*/
import { defineConfig } from 'vitest/config'
@@ -14,14 +19,5 @@ export default defineConfig({
inline: [/@deepseek-ai\/dsh-client-ui-primitives/],
},
},
exclude: [
'tests/e2e/**',
'**/.worktrees/**',
'**/node_modules/**',
'**/dist/**',
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
],
},
})