Compare commits
10
Commits
v0.3.0
..
0b79269b2c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b79269b2c | ||
|
|
cfc9d3c884 | ||
|
|
4e60dde18d | ||
|
|
a94c43343d | ||
|
|
48e16c73c7 | ||
|
|
856d744139 | ||
|
|
e4dcd1118e | ||
|
|
f86c1d82e6 | ||
|
|
0e91e99f49 | ||
|
|
c319fb106e |
+22
-138
@@ -1,147 +1,31 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
# Build output
|
||||
lib/
|
||||
dist/
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# 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
|
||||
# Dependencies
|
||||
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.*
|
||||
!.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
|
||||
# Logs & caches
|
||||
*.log
|
||||
.vite/
|
||||
.vitest/
|
||||
coverage/
|
||||
.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
|
||||
# OS / editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
# 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
|
||||
# Local env
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# pnpm
|
||||
.pnpm-store
|
||||
pnpm-debug.log*
|
||||
|
||||
# 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/
|
||||
# Unrelated work that landed in this directory (not part of apes-sidebar)
|
||||
maxkb-integration/
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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
|
||||
@@ -0,0 +1,157 @@
|
||||
# apes-sidebar — engineering conventions
|
||||
|
||||
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`.
|
||||
|
||||
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/`.
|
||||
|
||||
## 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)
|
||||
|
||||
## Commands
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Run the **full gate** before pushing. All three must be green.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
## Hard rules
|
||||
|
||||
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. **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).
|
||||
|
||||
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.
|
||||
|
||||
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`.
|
||||
|
||||
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`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## Testing conventions
|
||||
|
||||
- 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.
|
||||
|
||||
## Gotchas (learned, don't rediscover)
|
||||
|
||||
- `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.
|
||||
|
||||
## Phase status
|
||||
|
||||
- **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.
|
||||
@@ -1,21 +0,0 @@
|
||||
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.
|
||||
@@ -1,310 +0,0 @@
|
||||
# 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" />
|
||||
<img width="1000" height="1186" alt="image" src="https://github.com/user-attachments/assets/9dadffe0-0738-4b6d-b929-f452f51768a2" />
|
||||
|
||||
## 🚀 通过 plugin-registry 安装(标准)
|
||||
|
||||
前置条件:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 命令可用;集成步骤见其 README)。
|
||||
|
||||
```sh
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git
|
||||
cd DSH-better-sidebar
|
||||
pnpm install && pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types
|
||||
node scripts/package-registry.mjs # 组装 registry/ 暂存目录(只含清单 + 构建产物 + README,不入库)
|
||||
dsh registry install ./registry # 安装(默认禁用——信任边界)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar # 启用
|
||||
```
|
||||
|
||||
> **通道互斥**:registry 安装与下方「不依赖 plugin-registry 的官方 profile 安装」是同一插件的两种安装方式,**每个部署二选一**——同时启用会双挂载(Node 半挂两次、页面渲染两个侧边栏)。已用官方方式装过时,先移除 `~/.dsh/profiles/web/cordis.patch.yml` 里的 better-sidebar 挂载行与 `package.json` 的 `link:` 依赖,再走 registry。
|
||||
|
||||
### 更新(registry 通道)
|
||||
|
||||
```sh
|
||||
cd DSH-better-sidebar && git pull && pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs
|
||||
dsh registry uninstall dsh-external/dsh-better-sidebar
|
||||
dsh registry install ./registry
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
## 🚀 一键安装(把提示词发给 DSH)
|
||||
|
||||
把下面提示词**整段**发给 DSH,它会自动完成克隆、构建、注册与安装(前置条件:已安装 DSH 且 `dsh web` 可运行,Node.js ≥ 20、pnpm ≥ 10):
|
||||
|
||||
```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
|
||||
(若 pnpm install 因 @deepseek-ai/* 的 link: 依赖解析失败,说明 DSH 源码 checkout 不在 ~/.dsh/source/current —— 停下来告诉我,不要继续)
|
||||
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)验证
|
||||
```
|
||||
|
||||
> 安装 = 把插件登记进 web profile 的依赖清单(等价于 `dsh plugin --profile web add link:<路径>`)+ 一行 cordis 挂载行,**与 portal 无关**——portal 只指侧边栏面板在页面上的渲染方式(见下文[规范符合性](#规范符合性))。
|
||||
|
||||
### 更新(同样把提示词发给 DSH)
|
||||
|
||||
已安装过(`link:` 引用)时,把下面提示词**整段**发给 DSH 即可更新到最新版:
|
||||
|
||||
```text
|
||||
请帮我更新 dsh-better-sidebar 插件(仓库在 ~/Code/DSH-better-sidebar,已通过 link: 安装到我的 web profile):
|
||||
|
||||
1. 拉取最新代码并重新构建:
|
||||
cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build
|
||||
(若 pnpm install 因 @deepseek-ai/* 的 link: 依赖解析失败,说明 DSH 源码 checkout 不在 ~/.dsh/source/current —— 停下来告诉我,不要继续)
|
||||
2. 核对注册仍然有效(缺失才需要补):
|
||||
a. ~/.dsh/profiles/web/package.json 的 dependencies 中含 "dsh-better-sidebar": "link:<仓库目录的绝对路径>"
|
||||
b. ~/.dsh/profiles/web/cordis.patch.yml 中含挂载行(id: better-sidebar, name: 'dsh-better-sidebar')
|
||||
c. 若有缺失,补齐后在该 profile 目录执行 pnpm install
|
||||
3. 完成后告诉我本次改动涉及 client 还是 host:
|
||||
- 仅 client(src/client/*)→ 我硬刷新(Cmd/Ctrl+Shift+R)即可
|
||||
- 含 host(src/index.ts、src/config.ts 等)→ 我需要重启 DSH 再硬刷新
|
||||
```
|
||||
|
||||
## 为什么用它
|
||||
|
||||
- **⚡ 工作流不断档**:编辑文件、跑终端、看 Git 状态、拖 Tab 分栏对比,全在对话旁边完成,无需切窗口
|
||||
- **🧠 按会话记忆与隔离**:每个 conversation 拥有独立的布局、分栏、Tab 与目录展开状态,切换会话即切换整套工作台
|
||||
- **🎨 与 GUI 浑然一体**:完全复用 DSH 主题变量与官方组件,明暗主题自适应,观感就是原生的
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
### 🗂️ 文件资源管理器
|
||||
|
||||
- 懒加载目录树,根目录 = 当前会话工作目录(cwd);目录优先、隐藏文件变暗、每层 1000 项上限
|
||||
- 点击文件在侧边栏打开编辑器;刷新按钮重载
|
||||
- **悬浮行尾 `@文件` 按钮**:一键把 `@相对路径` 追加到输入框草稿(如 `@src/main.ts`),引用文件不再手打路径
|
||||
- **右键行 → 复制相对地址 / 复制绝对地址**,复制成功行内短暂显示"已复制"
|
||||
|
||||
### 📝 文件编辑与预览
|
||||
|
||||
- **CodeMirror 6**:自动换行 + 按扩展名语法高亮(js/ts/jsx/tsx、json、python、html、css、xml、yaml、sql、java、c/c++、rust、go、php、shell、toml、nginx、dockerfile、properties…)
|
||||
- 脏点标记 + **Ctrl/Cmd+S 保存**(原子写入);512KB 截断横幅、二进制文件提示
|
||||
- 图片直接预览;**Markdown 预览/编辑切换**,预览实时渲染未保存草稿
|
||||
- **PDF 原生预览**:使用浏览器内置 PDF 查看器,预览区始终保留下载入口
|
||||
- **Office 三件套预览**:
|
||||
- `.docx` — 保真渲染(保留样式 / 页眉页脚 / 图片 / 表格),基于 [docx-preview](https://github.com/VolodymyrBaydalka/docxjs)
|
||||
- `.xlsx` — 完整电子表格(多 sheet / 公式计算 / 列宽行高 / 合并单元格),基于 [Univer](https://univer.ai) + [SheetJS](https://sheetjs.com)
|
||||
- `.pptx` — 高保真幻灯片预览(图片 / 表格 / 图表 / SmartArt)+ 上一页 / 下一页导航,基于 [pptx-renderer](https://github.com/aiden0z/pptx-renderer)
|
||||
- `.doc` / `.xls` / `.ppt` 及其他二进制 — 下载按钮(旧 OLE 格式纯前端无成熟渲染库)
|
||||
- 切换 Tab 不卸载:编辑器保留草稿与撤销栈
|
||||
|
||||
### 💻 终端
|
||||
|
||||
- xterm.js + node-pty **真实 shell**,每会话最多 3 个(仅 UI 手动新建的 tab 计入上限)
|
||||
- **Tab 打开期间一直保活**:切换 Tab/分栏、切换会话(30 秒内返回)、刷新页面都重连到同一个 shell 进程并回放转录
|
||||
- 真正关闭 Tab 立即释放配额;连续失败停止重连并显示原因与重试按钮
|
||||
- **Agent 终端(默认关闭)**:设置页开启「为模型注入终端工具」后,模型可通过 `terminal_create` / `list` / `send` / `read` / `wait_for` / `resize` / `signal` / `close` 8 个工具创建持久终端,自动同步为侧边栏 tab(id `agent:<uuid>`,不占用 3 个 UI 上限)。终端由 agent 拥有——`terminal_close` 或用户关闭 tab 才销毁,WS 断开(刷新/切会话)不杀进程;`terminal_signal(SIGINT)` 通过写入 `\x03` 控制字符实现,Windows ConPTY 与 POSIX 通用
|
||||
|
||||
### 🌿 Git 面板
|
||||
|
||||
- **真正的 diff 显示**:解析 `git diff` 统一格式——`@@` hunk 头、旧/新双行号对齐、上下文/删除/新增行红绿着色(DSH 语义色 token),长行自动换行、无横向滚动;二进制文件显示提示、超长 diff 折叠展开、untracked 文件渲染为全新增视图
|
||||
- **点击即开 diff tab(VSCode 式)**:点击变更文件行或历史提交行,自动打开独立 diff tab——首次打开时当前栏纵向一分为二、diff 默认落在**下半栏**,之后新 diff 堆叠进同一栏;同一变更(路径+暂存侧 / 提交)只存在一个实例,重复点击聚焦已有 tab;diff tab 为临时页签(刷新后不恢复,同 VSCode diff 编辑器),带手动刷新按钮
|
||||
- **VSCode 式历史**:提交行 = 短哈希 + 主题,第二行 = 分支/标签徽章 + 作者 · 相对时间;**懒加载分页**(每页 20 条,底部「加载更多」,避免长历史挤爆面板)
|
||||
- **右键高级操作**:
|
||||
- 文件行:在编辑器中打开 / 暂存 / 取消暂存 / 放弃更改(确认弹窗)/ 复制相对·绝对路径
|
||||
- 历史行:查看提交差异 / 复制短哈希 / 复制完整哈希 / 复制提交信息 / 还原此提交(revert)/ 捡取此提交(cherry-pick),破坏性操作均有确认弹窗
|
||||
- 暂存/未暂存按 git XY 状态精确分组(`MM` 双区显示)、stage/unstage(单个与全部)、commit(Ctrl+Enter)、分支切换
|
||||
|
||||
### 🧩 Subagent 页面
|
||||
|
||||
- **主会话 agent 拓扑(分层显示)**:`+` 菜单(或空分栏卡片)打开 Subagent 标签页,显示**主会话(主代理)的完整 agent 拓扑**——主代理作为根节点卡片置顶(**点击它直接回到主代理会话**),其**所有层级的子代理**(含子代理再派生的后代)从它下面按层级展开,树连接线 + 逐级缩进区分层级;无论当前选中/跳转到多深的子代理,所有子代理都共享这一个主会话拓扑视图
|
||||
- **点击直达执行记录**:点击子代理节点直接跳转到该子代理执行记录(官方 `openSubagent`);**跳转后 Subagent 页面保持打开**,主会话拓扑不变,当前所在节点**高亮**(`interactive-bg-active` 选中填充,同官方会话行)
|
||||
- **运行中实时状态**:**仅运行中的节点**显示最后 Text 输出 + 最后工具调用(工具名 + 参数摘要,等宽字体),页面可见时每 3 秒自动刷新;暂无输出时显示「思考中…」;空闲节点保持安静;页面不可见(切标签/折叠面板)时暂停轮询
|
||||
- **自动激活**:设置项「检测到子代理时自动展开子代理页面」默认开启——当前会话**新增**第一个子代理时,侧边栏自动展开并聚焦 Subagent 页面;关闭后需手动打开(切换会话/刷新已有子代理不会打扰)
|
||||
|
||||
### 🧩 VSCode 式分栏工作台
|
||||
|
||||
- **拖 Tab 到分栏边缘**(左/右/上/下)即拆分新分栏,拖到中间合并,拖到另一个 Tab 上插入其前
|
||||
- 分隔线拖拽调整比例;面板宽度从左侧缘拖拽;折叠滑出隐藏 + 全屏展开
|
||||
|
||||
### 📑 Tab 栏
|
||||
|
||||
- **Tab 与 `+` 菜单均带类型图标**(文件夹 / 分支 / 终端 / 代码文件)
|
||||
- `+` 菜单新建 Explorer / Git / Subagent / 终端(终端受 3 个上限约束);**中键点击关闭 Tab**
|
||||
- 空分栏显示可打开类型卡片,点击即开
|
||||
|
||||
### 🔁 会话隔离与状态自愈
|
||||
|
||||
- 布局/分栏/Tab/目录展开按会话持久化(localStorage),切换会话即切换整套状态
|
||||
- **陈旧状态自动净化**:结构校验、宽度钳制视口、损坏的重复 id 自动重新编号——刷新后分栏与新生成的 id 永不冲突,不会出现"文件开进两个分栏"
|
||||
- 侧边栏**占用布局**(挤窄对话列而非悬浮遮挡,动画过渡)
|
||||
- 拦截对话"产出文件"行:点击改为在侧边栏打开,而不是系统默认程序
|
||||
|
||||
### 🎨 界面风格
|
||||
|
||||
- 完全对齐 DSH 原生 chrome:**无阴影**的扁平面板(`--dsw-specific-sidebar-fill` 表面 + hairline 边框)、原生 rail 圆形图标按钮、`interactive-bg-active` 选中填充、34px/14px 原生行规格
|
||||
- 键盘焦点可见(focus-visible 环)、`prefers-reduced-motion` 下关闭全部动画
|
||||
|
||||
### ⚙️ Side card 设置(DSH 设置页)
|
||||
|
||||
- 设置页新增 **Side card(侧边卡片)** 分区,持久化到 DSH 用户设置文档:
|
||||
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
|
||||
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
|
||||
- **检测到子代理时自动展开子代理页面**(默认开启):当前会话产生新的子代理时,自动展开侧边栏并打开 Subagent 页面
|
||||
- **为模型注入终端工具**(默认关闭):开启后模型可通过 8 个 `terminal_*` 工具创建并操作侧边栏终端;关闭会注销工具并释放已创建的 agent 终端
|
||||
|
||||
## ⌨️ 快捷键速查
|
||||
|
||||
| 操作 | 按键 |
|
||||
|---|---|
|
||||
| 保存编辑 | `Ctrl/Cmd + S` |
|
||||
| Git 提交 | `Ctrl + Enter` |
|
||||
| 关闭 Tab | 鼠标中键 |
|
||||
| 拆分分栏 | 拖 Tab 到分栏边缘 |
|
||||
| 合并分栏 | 拖 Tab 到分栏中间 |
|
||||
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
|
||||
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
|
||||
|
||||
---
|
||||
|
||||
## 🧰 手动安装与更新
|
||||
|
||||
不想用提示词时,也可以手动完成(等价于上面提示词的步骤):
|
||||
|
||||
### 前置条件
|
||||
|
||||
- 已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10
|
||||
- **构建/类型检查**需要 DSH 源码 checkout 位于 `~/.dsh/source/current`(`devDependencies` 中的 `@deepseek-ai/*` 以 `link:` 指向它;若 checkout 在其他路径,修改 `package.json` 中对应的 `link:` 路径)。**运行期不依赖该 checkout**——`@deepseek-ai/*` 声明在 `peerDependencies`,由 web profile 提供
|
||||
|
||||
### 1. 克隆、安装、构建
|
||||
|
||||
```sh
|
||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git
|
||||
cd DSH-better-sidebar
|
||||
pnpm install
|
||||
pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types/*.d.ts
|
||||
```
|
||||
|
||||
> 运行期依赖(cordis、react、`@deepseek-ai/dsh-client-*` 等)按官方插件清单规范声明在 `peerDependencies`——web profile 已内置全部 peer 依赖,安装本插件无需额外装包;`dependencies` 只保留插件自有的运行时依赖(node-pty、ws、xterm、CodeMirror、schemastery 等)。
|
||||
|
||||
### 2. 注册到 web profile
|
||||
|
||||
```sh
|
||||
dsh plugin --profile web add link:/绝对路径/DSH-better-sidebar
|
||||
```
|
||||
|
||||
(等价于手动把 `"dsh-better-sidebar": "link:<绝对路径>"` 写入 `~/.dsh/profiles/web/package.json` 的 `dependencies` 并 `pnpm install`。)
|
||||
|
||||
然后编辑 `~/.dsh/profiles/web/cordis.patch.yml`,追加插件挂载行(`dsh plugin` 只管理依赖清单,不写挂载行):
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: better-sidebar
|
||||
name: 'dsh-better-sidebar'
|
||||
```
|
||||
|
||||
### 3. 重启 DSH 并刷新页面
|
||||
|
||||
重启 DSH(你的 `dsh web` 启动方式),浏览器**硬刷新**(Cmd/Ctrl+Shift+R)后,右侧出现侧边栏即安装成功。
|
||||
|
||||
### 更新
|
||||
|
||||
不想用提示词时手动更新(等价于[更新提示词](#更新同样把提示词发给-dsh)):
|
||||
|
||||
```sh
|
||||
git pull && pnpm install && pnpm build # 在插件仓库目录执行
|
||||
```
|
||||
|
||||
- 只改了 **client** 代码(`src/client/*`)→ 硬刷新页面即可(bundle 由服务器按请求读取)
|
||||
- 改了 **host** 代码(`src/index.ts`、`src/config.ts` 等)→ 重启 DSH + 硬刷新
|
||||
|
||||
`link:` 引用无需重新 install。
|
||||
|
||||
### 通过 registry 安装(手动,与上面的官方方案二选一)
|
||||
|
||||
前置条件:`dsh registry` 命令可用(DSH 已集成 plugin-registry)。安装源是 `scripts/package-registry.mjs` 组装的 `registry/` 暂存目录——只含清单 + 构建产物 + README,避免把 `node_modules/`(本机 `link:` 符号链接、node-pty 二进制)与 `.git/` 一并装进 `<dshHome>/plugins`。
|
||||
|
||||
```sh
|
||||
pnpm build # 先构建(产物见上)
|
||||
node scripts/package-registry.mjs # 组装 registry/(重新运行会整体重建)
|
||||
dsh registry install ./registry # 安装(默认禁用)
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
更新:
|
||||
|
||||
```sh
|
||||
git pull && pnpm install && pnpm build
|
||||
node scripts/package-registry.mjs
|
||||
dsh registry uninstall dsh-external/dsh-better-sidebar
|
||||
dsh registry install ./registry
|
||||
dsh registry enable dsh-external/dsh-better-sidebar
|
||||
```
|
||||
|
||||
- 只改了 **client** 代码 → 硬刷新页面即可(registry 通道的 bundle 由服务器按请求读取)
|
||||
- 改了 **host** 代码 → 重启 DSH + 硬刷新
|
||||
- 与官方方案的互斥见上文「通道互斥」说明
|
||||
|
||||
## 🛠️ 开发
|
||||
|
||||
```sh
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm build # rm -rf lib && tsc(声明) + tsdown → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
|
||||
pnpm test # vitest(单元 + 冒烟 + 插件形态 guard:真实 git/fs/node-pty 交互;manifest 一致性测试读取 lib/,需先 build)
|
||||
pnpm watch # tsdown --watch(client bundle 热重建)
|
||||
```
|
||||
|
||||
### 规范符合性
|
||||
|
||||
插件按 DSH 官方插件规范组织(参考 [dsh-external/turtle-ui](https://github.com/dsh-external/turtle-ui) 与 mainline `packages/client/AGENTS.md`),并额外提供 plugin-registry 标准发布面(`dsh.plugin.json`)。**安装通道与渲染方式是两个独立概念**:安装走 profile 清单协议(`dsh plugin` / cordis.yml 行,见[一键安装](#一键安装把提示词发给-dsh))**或** registry 通道(`dsh registry`,见[通过 plugin-registry 安装](#通过-plugin-registry-安装标准)),二者互斥;下面的 portal 条目只描述面板在页面上的渲染方式。
|
||||
|
||||
- **插件形态**:`export const name / inject / Config / apply`,无 default 导出;`tests/plugin-shape.spec.ts` 通过 `Loader.unwrapExports` 守卫该形态
|
||||
- **清单**:`types` + `exports`(`.` / `./invariant` / `./client` / `./src/*` / `./package.json`)、`dshClient`(`platform: 'web'` + 信息性 `inject` 边)、peerDependencies、`engines`、`files` 产物明细、`prepare`(消费者侧 `tsdown`,git 安装可用)
|
||||
- **client 契约**:仅导出 `apply`/`inject`(+ 类型);store 为 `createSidebarStore()` 工厂,实例归 `apply` 所有;`src/invariant.ts` 伴生;client bundle 复刻官方 preset(externals = 平台模块表 + runtime/client 豁免、纯度门、CSS Modules 内联)
|
||||
- **registry 形态(标准发布面)**:仓库根 `dsh.plugin.json`(原生清单:id `dsh-external/dsh-better-sidebar`、`main` = host 构建产物、`client.main` = registry client bundle、`contributes` 空声明);`tests/manifest-consistency.spec.ts` 守卫清单与构建产物一致性(id 严格两段、version 与 package.json 同步、bundle id = 清单 id)
|
||||
- **双 client bundle**:同一 `src/client/index.tsx` 构建出 `lib/client.js`(官方通道,id = 包名 `dsh-better-sidebar`)与 `lib/client-registry.js`(registry 通道,id = 清单 id)——两通道的 bundle-id 契约互不相同,各产出一份、同源不漂移;每个部署二选一(见安装章节「通道互斥」)
|
||||
- **已知偏差**:侧边栏面板经 portal(`document.body` + `createRoot`)挂载而非 slot——官方 slot 系统的 `'root'` 由 ui-layout 独占声明,重复声明 fail loud,外部插件无整面板 slot 可用;与 shell 的集成点(`conversation.chat.turnTail`)走官方 chain-slot 机制
|
||||
|
||||
## 🏗️ 架构
|
||||
|
||||
一个 npm 包,host/client 双半结构(与 DSH monorepo 内 client 包同构):
|
||||
|
||||
| 半 | 入口 | 职责 |
|
||||
|---|---|---|
|
||||
| host | `src/index.ts` → `lib/index.js` | cordis 插件(`Config` 为 schemastery schema,可调 readLimit/mediaLimit/listLimit/terminalsPerSession/reconnectGraceMs):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket;fs / git / pty 服务 |
|
||||
| invariant | `src/invariant.ts` → `lib/invariant.js` | 包属 invariant 伴生(注册包名,无运行时断言) |
|
||||
| client(官方通道) | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂,id = 包名):portal 侧边栏 + 各视图 + turnTail 拦截 |
|
||||
| client(registry 通道) | `src/client/index.tsx` → `lib/client-registry.js` | 同一源码的 registry 版本(id = 清单 id `dsh-external/dsh-better-sidebar`,`dsh.plugin.json` 的 `client.main` 指向它) |
|
||||
| manifest | `dsh.plugin.json`(仓库根;`scripts/package-registry.mjs` 组装 `registry/` 安装根) | registry 发布面:id / version / main / client.main;仅 registry 通道读取 |
|
||||
|
||||
- 所有 API 携带 `sessionId`;cwd 权威值取自会话 header,会话未附加(页面加载竞态)时回退客户端摘要 cwd,再回退进程 cwd;终端按 `${sessionId}:${tabId}` 键控,重连时若权威 cwd 变化则重启 shell 到正确目录
|
||||
- 路由与 `/api` 同款信任围栏(Host 头 loopback / `connection.trustedHosts`,`src/trust-fence.ts`,拷贝自 `@deepseek-ai/dsh-client-connection`,BSD-3-Clause)
|
||||
- client 状态按会话持久化到 `localStorage`(`dsh-sidebar:v1:<sessionId>`),读取时结构校验 + 宽度钳制视口 + 重复 id 重新编号
|
||||
- client bundle externals 仅模块表词条(react/cordis/ui-primitives 等),xterm/CodeMirror 等全部内联
|
||||
|
||||
## 🔐 安全边界
|
||||
|
||||
- 路由受 Host 头信任围栏保护(与 `/api` 一致;`0.0.0.0` 部署时由 `dsh web` 启动器动态派生的 LAN IP 列表生效)
|
||||
- `fs.write` 仅文本写入、原子替换(临时文件 + rename);`/sidebar/file` 仅限会话 cwd 内的媒体文件
|
||||
- 提交/分支操作仅调用 git CLI,绝不设置 git 身份
|
||||
|
||||
## ⚠️ 已知限制(v1 非目标)
|
||||
|
||||
- Git 无 push/pull/fetch;无文件 watcher(手动刷新)
|
||||
- 工具行内的文件打开按钮(核心代码)不可拦截,仅"产出文件"行被接管
|
||||
- 终端 Tab **拖拽到另一分栏**时会重挂载(shell 重开);切换 Tab / 切换会话(30 秒内返回)/ 刷新页面不会
|
||||
- `.xlsx` 预览不保留单元格样式(字体/颜色/边框/条件格式/图表)——SheetJS 社区版不解析样式,需保真请下载查看
|
||||
- Office/PPTX 预览组件内联进 client bundle,当前约 23MB(gzip ~4.8MB),首次加载较慢
|
||||
|
||||
## 🖥️ 平台支持
|
||||
|
||||
代码按 **Windows / Linux / macOS** 三平台适配(macOS 为日常验证平台;Windows/Linux 的平台分支经单元测试覆盖,建议真机冒烟):
|
||||
|
||||
- 路径处理全部走 `node:path` 平台 API(列表拼接、`resolve`);媒体路由的前缀检查与相对路径投影容忍混合分隔符,Windows 上大小写不敏感
|
||||
- Windows 上终端 shell 为 `powershell.exe`;node-pty 的 spawn-helper 修复(pnpm 剥执行位)自动跳过 Windows
|
||||
- `node-pty` 安装优先下载预编译二进制(`prebuild.js`,需网络),失败则源码编译——Windows 需 Visual Studio Build Tools,Linux 需 `make`/`g++`/`python3`,macOS 需 Xcode Command Line Tools;`pnpm-workspace.yaml` 已声明 `allowBuilds: node-pty: true`
|
||||
- git 操作仅依赖 `git` 在 PATH(Windows 需 Git for Windows)
|
||||
@@ -0,0 +1,13 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,362 @@
|
||||
# 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,否则构建纯度门会拦。
|
||||
@@ -1,19 +0,0 @@
|
||||
# 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 交互。
|
||||
@@ -1,364 +0,0 @@
|
||||
# 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 功能一览)
|
||||
@@ -1,31 +0,0 @@
|
||||
# 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 测试通过。
|
||||
@@ -1,37 +0,0 @@
|
||||
# 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 与构建通过。
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"id": "dsh-external/dsh-better-sidebar",
|
||||
"version": "0.3.0",
|
||||
"main": "./lib/index.js",
|
||||
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离",
|
||||
"engines": {
|
||||
"dsh": ">=0.0.1"
|
||||
},
|
||||
"contributes": {
|
||||
"tools": [],
|
||||
"skills": []
|
||||
},
|
||||
"client": {
|
||||
"main": "./lib/client-registry.js"
|
||||
}
|
||||
}
|
||||
+57
-122
@@ -1,134 +1,69 @@
|
||||
{
|
||||
"name": "dsh-better-sidebar",
|
||||
"version": "0.3.0",
|
||||
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.",
|
||||
"private": true,
|
||||
"name": "apes-sidebar",
|
||||
"version": "2.0.0",
|
||||
"description": "WorkBuddy/Tutti 风格右侧边栏增强(构建在 dsh-better-sidebar 之上:任务 / Agent / 消息 / 应用 / Profile 五面板信息层)",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"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"
|
||||
},
|
||||
".": "./lib/index.js",
|
||||
"./invariant": "./lib/invariant.js",
|
||||
"./client": "./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"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-slots",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
},
|
||||
"client": {
|
||||
"inject": [
|
||||
"betterSidebar",
|
||||
"locale",
|
||||
"sessions"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
|
||||
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
|
||||
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-settings": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aiden0z/pptx-renderer": "^1.2.4",
|
||||
"@codemirror/commands": "^6.10.4",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@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": "link:/Users/menghuan/.dsh/source/current/packages/core/agent",
|
||||
"@deepseek-ai/dsh-client-runtime": "link:/Users/menghuan/.dsh/source/current/packages/client/runtime",
|
||||
"@deepseek-ai/dsh-client-schema-form": "link:/Users/menghuan/.dsh/source/current/packages/client/schema-form",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives",
|
||||
"@deepseek-ai/dsh-client-ui-settings": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots",
|
||||
"@deepseek-ai/dsh-client-web-react": "link:/Users/menghuan/.dsh/source/current/packages/client/web-react",
|
||||
"@deepseek-ai/dsh-host-webserver": "link:/Users/menghuan/.dsh/source/current/packages/host/webserver",
|
||||
"@deepseek-ai/dsh-invariants": "link:/Users/menghuan/.dsh/source/current/packages/support/invariants",
|
||||
"@deepseek-ai/dsh-llm": "link:/Users/menghuan/.dsh/source/current/packages/llm/llm",
|
||||
"@deepseek-ai/dsh-session": "link:/Users/menghuan/.dsh/source/current/packages/core/session",
|
||||
"@deepseek-ai/dsh-settings": "link:/Users/menghuan/.dsh/source/current/packages/settings/settings",
|
||||
"@deepseek-ai/dsh-tools": "link:/Users/menghuan/.dsh/source/current/packages/core/tools",
|
||||
"@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"
|
||||
}
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
Generated
+2752
-3799
File diff suppressed because it is too large
Load Diff
+2
-7
@@ -1,9 +1,4 @@
|
||||
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
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/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')
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 验收验证:模拟 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)
|
||||
@@ -1,519 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* The file editor tab: a CodeMirror 6 editor with line wrapping and syntax
|
||||
* highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S save,
|
||||
* image viewing through the media route, and a plain notice for binary
|
||||
* files. Markdown files open in rendered preview with a preview/edit toggle:
|
||||
* edit mode shows the source in the editor, preview renders the live draft,
|
||||
* and unsaved edits survive the toggle. Reads cap at the host's 512KB bound
|
||||
* (a banner marks truncation).
|
||||
*/
|
||||
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, downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { languageForPath } from './lang.ts'
|
||||
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { officeKindForExt } from './office-types.ts'
|
||||
import { DocxView, XlsxView } from './office-view.tsx'
|
||||
import { isPdfExt } from './pdf-types.ts'
|
||||
import { PdfView } from './PdfView.tsx'
|
||||
import { PptxView } from './PptxView.tsx'
|
||||
import { isImageExt } from './image-types.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
const MD_EXT = ['.md', '.markdown']
|
||||
|
||||
type EditorLoad =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; kind: 'text' | 'image' | 'md' | 'docx' | 'xlsx' | 'pptx' | 'pdf'; content: string; truncated: boolean }
|
||||
| { status: 'binary' } // unknown binary / OLE legacy formats → download button
|
||||
|
||||
/** Previewable files (rendered output vs source editing). */
|
||||
type ViewMode = 'preview' | 'edit'
|
||||
|
||||
export function EditorView(props: { scope: SessionScope; path: string; title: string }) {
|
||||
const { scope, path, title } = props
|
||||
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
|
||||
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()) }), [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoad({ status: 'loading' })
|
||||
setMode('preview')
|
||||
setDraft(null)
|
||||
setDirty(false)
|
||||
setSaveState('idle')
|
||||
// Office preview short-circuit: the host's readText would NUL-probe every
|
||||
// Office file as binary (a wasted round-trip), and .docx/.xlsx have their
|
||||
// own renderers below. Detect by extension and skip fsRead entirely —
|
||||
// the renderer fetches bytes through the media route itself.
|
||||
const ext = extOfPath(path)
|
||||
// Images are binary by nature; short-circuit before fsRead's NUL probe or
|
||||
// every PNG/JPEG would be classified as an unsupported binary file.
|
||||
if (isImageExt(ext)) {
|
||||
setLoad({ status: 'ready', kind: 'image', content: '', truncated: false })
|
||||
return
|
||||
}
|
||||
if (isPdfExt(ext)) {
|
||||
setLoad({ status: 'ready', kind: 'pdf', content: '', truncated: false })
|
||||
return
|
||||
}
|
||||
const officeKind = officeKindForExt(ext)
|
||||
if (officeKind === 'docx' || officeKind === 'xlsx' || officeKind === 'pptx') {
|
||||
setLoad({ status: 'ready', kind: officeKind, content: '', truncated: false })
|
||||
return
|
||||
}
|
||||
if (officeKind === 'download-only') {
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
}
|
||||
api.fsRead(scope, path).then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.kind === 'binary') {
|
||||
setLoad({ status: 'binary' })
|
||||
return
|
||||
}
|
||||
setLoad({
|
||||
status: 'ready',
|
||||
kind: MD_EXT.includes(ext) ? 'md' : 'text',
|
||||
content: result.content,
|
||||
truncated: result.truncated,
|
||||
})
|
||||
}).catch((error: unknown) => {
|
||||
if (cancelled) return
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
// Create the CodeMirror editor once the file 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 (load.status !== 'ready' || (load.kind !== 'text' && load.kind !== 'md')) 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: load.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).
|
||||
}, [load])
|
||||
|
||||
// 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 previewable = load.status === 'ready' && load.kind === 'md'
|
||||
const editable = load.status === 'ready' && (load.kind === 'text' || load.kind === 'md')
|
||||
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
|
||||
|
||||
return (
|
||||
<div className={css.editor}>
|
||||
<div className={css.editorHeader}>
|
||||
<span className={css.editorTitle} title={path}>{title}</span>
|
||||
{previewable && (
|
||||
<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>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
|
||||
{load.status === 'binary' && (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{t('binaryNoPreview')}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{editable && (
|
||||
<>
|
||||
{load.truncated && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
|
||||
<div
|
||||
className={clsx(css.editorCm, previewable && mode === 'preview' && css.editorCmHidden)}
|
||||
ref={hostRef}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'image' && (
|
||||
<div className={css.editorImageWrap}>
|
||||
<img className={css.editorImage} src={mediaUrl(scope, path)} alt={title} />
|
||||
</div>
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'docx' && (
|
||||
<DocxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'xlsx' && (
|
||||
<XlsxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'pptx' && (
|
||||
<PptxView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{load.status === 'ready' && load.kind === 'pdf' && (
|
||||
<PdfView scope={scope} path={path} title={title} />
|
||||
)}
|
||||
{previewable && mode === 'preview' && (
|
||||
<div className={css.editorMd}>
|
||||
<MarkdownText text={draft ?? load.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The lowercased file extension of a path ('' when none). */
|
||||
function extOfPath(path: string): string {
|
||||
const at = path.lastIndexOf('.')
|
||||
if (at === -1) return ''
|
||||
const base = path.slice(at).toLowerCase()
|
||||
return base.includes('/') || base.includes('\\') ? '' : base
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,540 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
/** 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>
|
||||
)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
/** 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>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Side card settings section chrome: the native settings-row recipe (figma
|
||||
* row 16/0 padding, hairline separator, 14px/22px title, 12px/17px desc) with
|
||||
* the native checkbox (16px, brand accent) and the Input atom + "%" suffix.
|
||||
* The shell strips the last row's separator via the trailing-child rule.
|
||||
*/
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section > :last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rowText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Native checkbox: 16px, brand accent, no custom chrome (RiskConfirmation recipe). */
|
||||
.toggle {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--dsw-alias-button-primary-fill);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-border-l4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.control {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.percentInput {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 10px 0 2px;
|
||||
font-size: 12px;
|
||||
line-height: 17px;
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* "Side card" settings section: the user-facing preferences for the sidebar
|
||||
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
|
||||
* Three rows:
|
||||
* - new conversations open the panel by default (native checkbox),
|
||||
* - the default panel width as a percent of the window (number input + %),
|
||||
* - auto-open the Subagent page when a new subagent appears (native checkbox,
|
||||
* on by default).
|
||||
*
|
||||
* Writes ride the plugin's own fenced settings route (the host calls the
|
||||
* settings seam in-process — the DSH settings RPC domain does not serve
|
||||
* third-party namespaces to configuration clients); the shared SidebarStore
|
||||
* is refreshed on success so the very next brand-new session seeds from the
|
||||
* new values. Any failure reverts the optimistic UI and shows the wire error
|
||||
* inline — a broken settings surface never crashes the shell.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
clampWidthPercent,
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import css from './SideCardSection.module.css'
|
||||
|
||||
/** Injected business face: the shared store (prefs cache). */
|
||||
export interface SideCardSectionInjected {
|
||||
store: SidebarStore
|
||||
}
|
||||
|
||||
/** Full section props: the runtime share plus the injected face. */
|
||||
export type SideCardSectionProps = PropsRuntime<'settings.section'> & SideCardSectionInjected
|
||||
|
||||
/** Map one wire failure to the inline message (the conflict gets friendly copy). */
|
||||
function messageOf(error: unknown): string {
|
||||
if (error instanceof Error && 'code' in error && (error as { code?: unknown }).code === 'settings-conflict') {
|
||||
return `${t('settingsSaveFailed')} ${t('settingsConflict')}`
|
||||
}
|
||||
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Side card preferences section.
|
||||
* @param props - composed slot props (runtime share + injected store).
|
||||
* @returns the section element tree.
|
||||
*/
|
||||
export function SideCardSection({ store }: SideCardSectionProps) {
|
||||
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
|
||||
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// The settings document revision (guards concurrent writes). A ref: commits
|
||||
// read the freshest value at execution time, no re-render needed.
|
||||
const revisionRef = useRef<number | undefined>(undefined)
|
||||
// Whether the user already wrote since mount: the mount read must not
|
||||
// clobber a newer optimistic edit (the window is milliseconds, but a slow
|
||||
// route must never silently revert a just-made change).
|
||||
const dirtyRef = useRef(false)
|
||||
// Serialize commits: a queued write must observe the previous write's
|
||||
// revision; a failed write must not poison the queue for later ones.
|
||||
const inFlightRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||
|
||||
// Sync the persisted document once on mount: the revision and the current
|
||||
// values (another tab may have changed them since the store hydrated).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
void api.settingsGet().then((view) => {
|
||||
if (cancelled) return
|
||||
revisionRef.current = view.revision
|
||||
if (dirtyRef.current) return
|
||||
const next = parsePrefs(view.value)
|
||||
setPrefs(next)
|
||||
setWidthDraft(String(next.defaultWidthPercent))
|
||||
}).catch(() => { /* the store's defaults stay authoritative */ })
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
/** Persist one patch through the settings route (serialized, revision-guarded). */
|
||||
const commit = (patch: Partial<SidebarPrefs>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
|
||||
dirtyRef.current = true
|
||||
const run = inFlightRef.current.then(async () => {
|
||||
const view = await api.settingsUpdate(
|
||||
{ ...patch },
|
||||
revisionRef.current,
|
||||
)
|
||||
const next = parsePrefs(view.value)
|
||||
revisionRef.current = view.revision
|
||||
store.setPrefs(next)
|
||||
return next
|
||||
})
|
||||
// A failed commit must not poison the queue: later writes still run.
|
||||
inFlightRef.current = run.then(() => undefined, () => undefined)
|
||||
return run.then(
|
||||
(next) => ({ ok: true, prefs: next }),
|
||||
(caught) => {
|
||||
setError(messageOf(caught))
|
||||
return { ok: false, prefs }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Settle one commit: success adopts the server values, failure reverts. */
|
||||
const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
|
||||
const settled = outcome.ok ? outcome.prefs : previous
|
||||
setPrefs(settled)
|
||||
setWidthDraft(String(settled.defaultWidthPercent))
|
||||
}
|
||||
|
||||
const onToggle = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, openByDefault: next })
|
||||
setError(null)
|
||||
void commit({ openByDefault: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggleSubagent = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, autoOpenSubagent: next })
|
||||
setError(null)
|
||||
void commit({ autoOpenSubagent: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const onToggleTools = (next: boolean): void => {
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, agentTerminalTools: next })
|
||||
setError(null)
|
||||
void commit({ agentTerminalTools: next }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
const commitWidth = (): void => {
|
||||
const parsed = Number(widthDraft)
|
||||
if (!Number.isFinite(parsed)) {
|
||||
setWidthDraft(String(prefs.defaultWidthPercent))
|
||||
return
|
||||
}
|
||||
const clamped = clampWidthPercent(parsed)
|
||||
const previous = prefs
|
||||
setPrefs({ ...previous, defaultWidthPercent: clamped })
|
||||
setWidthDraft(String(clamped))
|
||||
setError(null)
|
||||
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsOpenTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsOpenDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.openByDefault}
|
||||
aria-label={t('settingsOpenTitle')}
|
||||
onChange={event => { onToggle(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
<div className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsWidthTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsWidthDesc')}</span>
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Input
|
||||
type="number"
|
||||
className={css.percentInput}
|
||||
value={widthDraft}
|
||||
min={WIDTH_PERCENT_MIN}
|
||||
max={WIDTH_PERCENT_MAX}
|
||||
step={1}
|
||||
aria-label={t('settingsWidthTitle')}
|
||||
onChange={event => { setWidthDraft(event.currentTarget.value) }}
|
||||
onBlur={commitWidth}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
|
||||
</span>
|
||||
</div>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsSubagentTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsSubagentDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.autoOpenSubagent}
|
||||
aria-label={t('settingsSubagentTitle')}
|
||||
onChange={event => { onToggleSubagent(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
<label className={css.row}>
|
||||
<span className={css.rowText}>
|
||||
<span className={css.title}>{t('settingsToolsTitle')}</span>
|
||||
<span className={css.desc}>{t('settingsToolsDesc')}</span>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.toggle}
|
||||
checked={prefs.agentTerminalTools}
|
||||
aria-label={t('settingsToolsTitle')}
|
||||
onChange={event => { onToggleTools(event.currentTarget.checked) }}
|
||||
/>
|
||||
</label>
|
||||
{error !== null && (
|
||||
<div className={css.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
/**
|
||||
* 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, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, openTab,
|
||||
reconcileAgentTerminals,
|
||||
resizeSplit, setWidth, toggleExpanded, togglePanel,
|
||||
TERMINAL_LIMIT, 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 { tabTypeIcon } from './icons.tsx'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { ExplorerView } from './ExplorerView.tsx'
|
||||
import { EditorView } from './EditorView.tsx'
|
||||
import { TerminalView } from './TerminalView.tsx'
|
||||
import { GitView } from './GitView.tsx'
|
||||
import { DiffTab } from './DiffTab.tsx'
|
||||
import { SubagentView } from './SubagentView.tsx'
|
||||
import { detectNewDirectSubagent } from './subagent-detect.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { api } from './api.ts'
|
||||
import { openSidebarFile } from './intercept.tsx'
|
||||
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 }
|
||||
switch (tab.type) {
|
||||
case 'explorer':
|
||||
return (
|
||||
<ExplorerView
|
||||
sessionId={sessionId}
|
||||
cwd={cwd}
|
||||
expanded={expanded}
|
||||
onToggle={onToggleDir}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }}
|
||||
onReferenceFile={onReferenceFile}
|
||||
/>
|
||||
)
|
||||
case 'git':
|
||||
return (
|
||||
<GitView
|
||||
scope={scope}
|
||||
onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }}
|
||||
onOpenDiff={onOpenDiff}
|
||||
/>
|
||||
)
|
||||
case 'diff':
|
||||
return tab.diff === undefined
|
||||
? null
|
||||
: <DiffTab sessionId={sessionId} cwd={cwd} diff={tab.diff} />
|
||||
case 'terminal':
|
||||
return <TerminalView scope={scope} tabId={tab.id} store={store} />
|
||||
case 'editor':
|
||||
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||
case 'subagent':
|
||||
return (
|
||||
<SubagentView
|
||||
sessionId={sessionId}
|
||||
ctx={ctx}
|
||||
active={visible}
|
||||
onOpenChild={(address) => { onSubagentJump(address.childSessionId) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The + menu options for the current state (UI-tab terminal cap applied).
|
||||
* Agent-owned terminals (`agent:` tabs) are the model's, not the user's:
|
||||
* they never count toward the per-session UI cap. */
|
||||
function buildNewTabOptions(state: SidebarState): NewTabOption[] {
|
||||
const uiTerminalCount = allLeaves(state.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
return [
|
||||
{ id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) },
|
||||
{ id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) },
|
||||
{ id: 'subagent', label: t('openSubagent'), icon: tabTypeIcon('subagent', 16) },
|
||||
{
|
||||
id: 'terminal',
|
||||
label: uiTerminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
|
||||
disabled: uiTerminalCount >= TERMINAL_LIMIT,
|
||||
icon: tabTypeIcon('terminal', 16),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
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).
|
||||
*/
|
||||
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 => 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) and the
|
||||
* "auto open" pref is on, open the panel (if collapsed) and focus the
|
||||
* Subagent page (single-instance: an existing tab is focused, never
|
||||
* duplicated). Switching to a session that already has subagents never
|
||||
* triggers — its baseline starts at the current count — so a deliberate
|
||||
* layout is never fought.
|
||||
*/
|
||||
const listBaselineRef = useRef<SidebarSessionList | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const prev = listBaselineRef.current
|
||||
listBaselineRef.current = sessionList
|
||||
if (sessionId === undefined || prev === undefined) return
|
||||
if (!detectNewDirectSubagent(prev, sessionList, sessionId)) return
|
||||
if (!store.getPrefs().autoOpenSubagent) return
|
||||
store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
|
||||
id: 'subagent',
|
||||
type: 'subagent',
|
||||
title: t('subagent'),
|
||||
}))
|
||||
}, [sessionList, sessionId, store])
|
||||
|
||||
/**
|
||||
* Topology jump-back: clicking a subagent node on the Subagent page calls
|
||||
* the official `openSubagent`, which switches the sidebar to that child
|
||||
* session's OWN layout (a fresh child session defaults to the explorer).
|
||||
* The README contract says the Subagent page must stay open with the jumped
|
||||
* node highlighted — so once the current session becomes the recorded jump
|
||||
* target, re-open the Subagent page on top of the child's layout (expanding
|
||||
* the panel first if it is collapsed). Only this explicit node click arms
|
||||
* the flag, so switching to a subagent session by any other means keeps
|
||||
* that session's own layout untouched.
|
||||
*/
|
||||
const subagentJumpRef = useRef<string | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
const pending = subagentJumpRef.current
|
||||
if (pending === undefined || sessionId !== pending) return
|
||||
subagentJumpRef.current = undefined
|
||||
store.reduce(s => openTab(s.panelOpen ? s : togglePanel(s), {
|
||||
id: 'subagent',
|
||||
type: 'subagent',
|
||||
title: t('subagent'),
|
||||
}))
|
||||
}, [sessionId, store])
|
||||
|
||||
// Panel width drag (left edge strip).
|
||||
const widthDrag = useRef({ startX: 0, startWidth: 0 })
|
||||
const [draggingWidth, setDraggingWidth] = useState(false)
|
||||
|
||||
// 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 => {
|
||||
store.reduce((s) => {
|
||||
if (optionId === 'explorer') {
|
||||
return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') })
|
||||
}
|
||||
if (optionId === 'git') {
|
||||
return openTab(s, { id: 'git', type: 'git', title: t('git') })
|
||||
}
|
||||
if (optionId === 'subagent') {
|
||||
return openTab(s, { id: 'subagent', type: 'subagent', title: t('subagent') })
|
||||
}
|
||||
if (optionId === 'terminal') {
|
||||
const count = allLeaves(s.splits)
|
||||
.flatMap(leaf => leaf.tabs)
|
||||
.filter(tab => tab.type === 'terminal' && !isAgentTabId(tab.id)).length
|
||||
if (count >= TERMINAL_LIMIT) return s
|
||||
const tab: SidebarTab = {
|
||||
id: `terminal:${s.nextTerminal}`,
|
||||
type: 'terminal',
|
||||
title: `${t('terminal')} ${s.nextTerminal}`,
|
||||
}
|
||||
return { ...openTab(s, tab), nextTerminal: s.nextTerminal + 1 }
|
||||
}
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
@@ -1,584 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
/**
|
||||
* 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 { tabTypeIcon } from './icons.tsx'
|
||||
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
|
||||
}) {
|
||||
const {
|
||||
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab,
|
||||
} = 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)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{tabTypeIcon(tab.type, 14)}
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* 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). */
|
||||
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean }
|
||||
|
||||
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()}`
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
* EditorView), 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))
|
||||
}
|
||||
}
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
/** CSS Modules class-map declaration (tsdown compiles .module.css at build). */
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
|
||||
* icon library has none) and the per-tab-type icon mapping shared by the
|
||||
* tab strip, the + menu, and the empty-pane cards.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconThinkOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TabType } from './state.ts'
|
||||
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
|
||||
/** The one icon per tab type (shared by tabs, the + menu, and pane cards). */
|
||||
export function tabTypeIcon(type: TabType, size = 16): ReactNode {
|
||||
switch (type) {
|
||||
case 'explorer': return <IconFolderOpen16 size={size} />
|
||||
case 'git': return <IconBranchOutline16 size={size} />
|
||||
case 'terminal': return <IconTerminalOutline16 size={size} />
|
||||
case 'subagent': return <IconThinkOutline16 size={size} />
|
||||
case 'editor': return <IconCodeOutline16 size={size} />
|
||||
case 'diff': return <IconDiffOutline16 size={size} />
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/** 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)
|
||||
}
|
||||
+453
-130
@@ -1,148 +1,471 @@
|
||||
/**
|
||||
* 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).
|
||||
* 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)
|
||||
*/
|
||||
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 { 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']
|
||||
import { useSyncExternalStore, type ReactElement, type ReactNode } from 'react'
|
||||
import type { TabDescriptor, TabComponentProps, Context } from 'dsh-better-sidebar'
|
||||
import { t, registerLocales, useApesLocale } from './locales.ts'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The five tab ids, in + menu order (ascending). Phase 2 adds badge counts
|
||||
* and auto-collapse on top of these same descriptors.
|
||||
*/
|
||||
class SidebarBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
|
||||
state = { error: null as string | null }
|
||||
const TABS = {
|
||||
tasks: 'apes:tasks',
|
||||
agents: 'apes:agents',
|
||||
apps: 'apes:apps',
|
||||
messages: 'apes:messages',
|
||||
profile: 'apes:profile',
|
||||
} as const
|
||||
|
||||
static getDerivedStateFromError(error: unknown): { error: string } {
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
/**
|
||||
* 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
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
|
||||
}
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
/** 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++
|
||||
}
|
||||
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++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body.
|
||||
* @param ctx - the client cordis context (slots, sessions).
|
||||
* 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.
|
||||
*/
|
||||
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()
|
||||
// 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')
|
||||
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
|
||||
|
||||
ctx.effect(
|
||||
() => {
|
||||
try {
|
||||
return registerTurnTailInterception(ctx, sidebarStore)
|
||||
} catch (error) {
|
||||
fail('interception', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
'dsh-better-sidebar: turn-tail interception',
|
||||
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' } }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
// The "Side card" settings section: appears in the DSH Settings shell
|
||||
// once the shell's declaration is on the ledger (slots.inject waits for
|
||||
// it); the section reads/writes the prefs through the plugin's own
|
||||
// fenced settings route and keeps the shared store in sync.
|
||||
ctx.slots.inject('settings.section', () => ctx.slots.register({
|
||||
name: 'settings.section',
|
||||
id: 'better-sidebar',
|
||||
order: 100,
|
||||
label: () => t('settingsNav'),
|
||||
inject: () => ({ store: sidebarStore }),
|
||||
}, SideCardSection))
|
||||
} catch (error) {
|
||||
fail('load', error)
|
||||
/** 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 () => {
|
||||
for (const d of disposers) {
|
||||
try {
|
||||
d()
|
||||
} catch {
|
||||
// Already disposed (double-dispose on HMR) — ignore.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* 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 { openTab, 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)
|
||||
store.reduce(state => openTab(state, {
|
||||
id: `editor:${absolute}`,
|
||||
type: 'editor',
|
||||
title,
|
||||
path: 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',
|
||||
select: selectProducedFiles,
|
||||
priority: -1,
|
||||
registrant: 'dsh-better-sidebar',
|
||||
inject: (sessionId: string) => ({
|
||||
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||
}),
|
||||
}, SidebarProducedFiles)
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* 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]!()
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
+125
-294
@@ -1,305 +1,136 @@
|
||||
/**
|
||||
* 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).
|
||||
* 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).
|
||||
*/
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { ApesLocaleService } from '../context-types.ts'
|
||||
|
||||
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: '选择一个会话以使用侧边栏',
|
||||
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 个工具创建并操作侧边栏终端(默认关闭)',
|
||||
subagent: '子代理',
|
||||
openSubagent: '子代理',
|
||||
subagentMainAgent: '主代理',
|
||||
subagentEmpty: '暂无子代理',
|
||||
subagentEmptyDesc: '当前主代理派生的子代理将显示在这里',
|
||||
subagentRunning: '运行中',
|
||||
subagentInactive: '空闲',
|
||||
subagentModeOneShot: '一次性',
|
||||
subagentModeContinuable: '可续接',
|
||||
subagentCount: '{count} 个子代理',
|
||||
subagentCountRunning: '{count} 个子代理 · {running} 运行中',
|
||||
subagentDiagCorrupt: '目录损坏',
|
||||
subagentDiagUnsupported: '不支持的条目',
|
||||
subagentDiagUnavailable: '不可用',
|
||||
subagentThinking: '思考中…',
|
||||
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 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',
|
||||
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)',
|
||||
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 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.',
|
||||
}
|
||||
|
||||
/** Translate a copy key in the browser's language (zh → zh, else en). */
|
||||
export type CopyKey = keyof typeof zh
|
||||
const DICTS: Record<string, Record<string, string>> = { zh, en }
|
||||
|
||||
/** 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))
|
||||
/** 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
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())}`
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Office file type dispatch — pure functions mapping file extensions to
|
||||
* preview kinds. Kept separate from {@link EditorView} so the mapping is
|
||||
* unit-testable without mounting React or pulling in docx-preview / Univer.
|
||||
*
|
||||
* The host side never inspects Office extensions: the binary NUL probe in
|
||||
* {@link ../index.ts} (readText) tags every Office file as binary, and the
|
||||
* /sidebar/file media route serves raw bytes for any extension. This module
|
||||
* is the client's sole source of truth for "is this previewable here".
|
||||
*/
|
||||
|
||||
/** Extensions rendered with docx-preview (preserved styles/images/tables). */
|
||||
export const OFFICE_DOCX_EXT = '.docx' as const
|
||||
|
||||
/** Extensions rendered with Univer (sheets: data + formulas + formatting). */
|
||||
export const OFFICE_XLSX_EXT = '.xlsx' as const
|
||||
|
||||
/** Extensions rendered with the browser-native PPTX viewer. */
|
||||
export const OFFICE_PPTX_EXT = '.pptx' as const
|
||||
|
||||
/** Extensions that get a download-only affordance (no client-side renderer). */
|
||||
export const OFFICE_DOWNLOAD_ONLY_EXT = ['.doc', '.xls', '.ppt'] as readonly string[]
|
||||
|
||||
/**
|
||||
* The preview strategy for one path's extension.
|
||||
* - `'docx'` — render via docx-preview.
|
||||
* - `'xlsx'` — render via Univer.
|
||||
* - `'pptx'` — render via pptx-renderer.
|
||||
* - `'download-only'` — show the binary download button (no preview renderer
|
||||
* covers legacy OLE .doc/.xls/.ppt on the client).
|
||||
* - `null` — not an Office file; the caller's existing image / md / text / binary
|
||||
* pipeline handles it.
|
||||
*/
|
||||
export type OfficeKind = 'docx' | 'xlsx' | 'pptx' | 'download-only' | null
|
||||
|
||||
/**
|
||||
* Map a lowercased extension (with leading dot) to its preview kind.
|
||||
*
|
||||
* @param ext - the path extension from {@link EditorView.extOfPath} (`.docx`,
|
||||
* `.xlsx`, `.pptx`, `.doc`, `.xls`, `.ppt`, …); `''` when the path has none.
|
||||
*/
|
||||
export function officeKindForExt(ext: string): OfficeKind {
|
||||
if (ext === OFFICE_DOCX_EXT) return 'docx'
|
||||
if (ext === OFFICE_XLSX_EXT) return 'xlsx'
|
||||
if (ext === OFFICE_PPTX_EXT) return 'pptx'
|
||||
if (OFFICE_DOWNLOAD_ONLY_EXT.includes(ext)) return 'download-only'
|
||||
return null
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
/**
|
||||
* Office preview components: {@link DocxView} renders .docx via docx-preview
|
||||
* (preserved styles/images/tables), {@link XlsxView} renders .xlsx via the
|
||||
* Univer sheets preset (data + formulas + formatting). Both load the file
|
||||
* bytes through the existing /sidebar/file media route and own their library
|
||||
* lifecycle (dispose on unmount so canvases/workers don't leak — mirrors the
|
||||
* TerminalView dispose discipline).
|
||||
*
|
||||
* Errors degrade to the same download-button affordance the binary
|
||||
* placeholder uses, so a corrupted / encrypted / oversized file always
|
||||
* leaves the user with a way to get the file.
|
||||
*
|
||||
* Both libraries pull in several MB; tsdown's `codeSplitting: false` inlines
|
||||
* them into the single client bundle (the CJS __ModuleLoader__ wrapper's
|
||||
* `require` cannot load relative chunk URLs in the browser, so dynamic
|
||||
* import() collapses to a synchronous inline — see tsdown.config.ts).
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { downloadUrl, mediaUrl, type SessionScope } from './api.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { xlsxWorkbookToUniver } from './xlsx-to-univer.ts'
|
||||
import css from './sidebar.module.css'
|
||||
// Univer's stylesheets ride the same dsh-css-inline pipeline as xterm's CSS
|
||||
// (one <style data-plugin-css> tag, idempotent). Static import so the styles
|
||||
// are present before the first .xlsx opens.
|
||||
import '@univerjs/preset-sheets-core/lib/index.css'
|
||||
|
||||
/** Loading / ready / error state shared by both views. */
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'ready' }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
/** Shared props. */
|
||||
interface OfficeViewProps {
|
||||
scope: SessionScope
|
||||
path: string
|
||||
title: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a .docx file via docx-preview. The library renders into a container
|
||||
* div (no canvas); images and styles are inlined. Unmounting clears the
|
||||
* container's innerHTML — docx-preview has no dispose API, but tearing down
|
||||
* the DOM is enough.
|
||||
*/
|
||||
export function DocxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const viewportRef = useRef<HTMLDivElement>(null)
|
||||
const wrapRef = useRef<HTMLDivElement>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
const [zoom, setZoom] = useState(100)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const container = viewportRef.current
|
||||
const wrap = wrapRef.current
|
||||
if (container === null || wrap === null) return
|
||||
setZoom(100)
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
// docx-preview ships its own CSS through the className option; the
|
||||
// wrapper div scopes its render output.
|
||||
const { renderAsync } = await import('docx-preview')
|
||||
await renderAsync(buf, wrap, undefined, {
|
||||
className: 'docx',
|
||||
inWrapper: true,
|
||||
ignoreWidth: false,
|
||||
ignoreHeight: false,
|
||||
breakPages: true,
|
||||
experimental: false,
|
||||
})
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Tear down the rendered DOM so a reopen starts clean.
|
||||
if (wrap !== null) wrap.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current
|
||||
if (viewport === null) return
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (!event.altKey) return
|
||||
event.preventDefault()
|
||||
const delta = event.deltaY < 0 ? 10 : -10
|
||||
setZoom(current => Math.max(50, Math.min(200, current + delta)))
|
||||
}
|
||||
viewport.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { viewport.removeEventListener('wheel', onWheel) }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.editorDocx}>
|
||||
<div className={css.editorDocxViewport} ref={viewportRef}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
{load.status !== 'error' && (
|
||||
<div
|
||||
className={css.editorDocxWrap}
|
||||
ref={wrapRef}
|
||||
aria-label={title}
|
||||
style={{ zoom: zoom / 100 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={css.editorDocxZoom}>
|
||||
<span className={css.editorDocxZoomHint}>{t('zoomHint')}</span>
|
||||
<input
|
||||
className={css.editorDocxZoomRange}
|
||||
type="range"
|
||||
min={50}
|
||||
max={200}
|
||||
step={10}
|
||||
value={zoom}
|
||||
aria-label={t('zoom')}
|
||||
onChange={(event) => { setZoom(Number(event.currentTarget.value)) }}
|
||||
/>
|
||||
<span className={css.editorDocxZoomValue}>{zoom}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a .xlsx file via Univer. The sheets preset creates a canvas-based
|
||||
* spreadsheet (formula bar, sheet tabs, formula engine) sized to its
|
||||
* container, so the host fills the pane. Unmounting calls `univer.dispose()`
|
||||
* — without it the canvas, workers, and DOM listeners leak (mirrors the
|
||||
* xterm dispose discipline in TerminalView).
|
||||
*/
|
||||
export function XlsxView(props: OfficeViewProps): JSX.Element {
|
||||
const { scope, path, title } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const univerRef = useRef<{ dispose: () => void } | null>(null)
|
||||
const [load, setLoad] = useState<LoadState>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const host = hostRef.current
|
||||
if (host === null) return
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch(mediaUrl(scope, path))
|
||||
if (cancelled) return
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const buf = await response.arrayBuffer()
|
||||
if (cancelled) return
|
||||
|
||||
// Dynamic imports — collapsed into the bundle by codeSplitting:false.
|
||||
// The dynamic form keeps the source readable: each lib is only pulled
|
||||
// in when an .xlsx is actually opened (semantically; the bundle still
|
||||
// contains all of them).
|
||||
const XLSX = await import('xlsx')
|
||||
const { createUniver, LocaleType, mergeLocales } = await import('@univerjs/presets')
|
||||
const { UniverSheetsCorePreset } = await import('@univerjs/preset-sheets-core')
|
||||
// Locales pick the browser language; falls back to en-US.
|
||||
const isZh = typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
|
||||
const localePack = await (isZh
|
||||
? import('@univerjs/preset-sheets-core/locales/zh-CN').then(m => m.default).catch(() => null)
|
||||
: import('@univerjs/preset-sheets-core/locales/en-US').then(m => m.default).catch(() => null))
|
||||
|
||||
const wb = XLSX.read(buf, { type: 'array' })
|
||||
const locale = isZh ? LocaleType.ZH_CN : LocaleType.EN_US
|
||||
const workbookData = xlsxWorkbookToUniver(wb, '0.25.1', locale)
|
||||
|
||||
if (cancelled) return
|
||||
const { univer, univerAPI } = createUniver({
|
||||
locale,
|
||||
locales: localePack !== null ? { [locale]: mergeLocales(localePack) } : {},
|
||||
presets: [UniverSheetsCorePreset({ container: host })],
|
||||
})
|
||||
univerRef.current = univer
|
||||
univerAPI.createWorkbook(workbookData)
|
||||
if (!cancelled) setLoad({ status: 'ready' })
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Partially initialized instance — ignore disposal errors.
|
||||
}
|
||||
univerRef.current = null
|
||||
host.innerHTML = ''
|
||||
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
// Critical: dispose the Univer instance (canvas + workers + listeners).
|
||||
try {
|
||||
univerRef.current?.dispose()
|
||||
} catch {
|
||||
// Already torn down — ignore.
|
||||
}
|
||||
univerRef.current = null
|
||||
// Clear the host in case dispose left DOM behind.
|
||||
if (host !== null) host.innerHTML = ''
|
||||
}
|
||||
}, [scope.sessionId, scope.cwd, path])
|
||||
|
||||
return (
|
||||
<div className={css.editorXlsx} aria-label={title}>
|
||||
{/* Univer exclusively owns this element's descendants. React only owns
|
||||
the sibling overlay, so Univer cannot remove a React child. */}
|
||||
<div className={css.editorUniverHost} ref={hostRef} />
|
||||
{load.status !== 'ready' && (
|
||||
<div className={css.editorOfficeOverlay}>
|
||||
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
|
||||
{load.status === 'error' && <BinaryFallback scope={scope} path={path} message={load.message} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared error / fallback affordance: the failure reason plus a download
|
||||
* link, so the user always has a path to the file. Used by both DocxView and
|
||||
* XlsxView, and matches the binary placeholder's download link in EditorView.
|
||||
*/
|
||||
function BinaryFallback(props: { scope: SessionScope; path: string; message: string }): JSX.Element {
|
||||
const { scope, path, message } = props
|
||||
return (
|
||||
<div className={css.editorBinary}>
|
||||
<span className={css.editorBinaryNotice}>{message}</span>
|
||||
<a className={css.editorDownloadLink} href={downloadUrl(scope, path)} download>
|
||||
{t('downloadToView')}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
/** Pure PDF extension dispatch, separate from React for unit testing. */
|
||||
export function isPdfExt(ext: string): boolean {
|
||||
return ext === '.pdf'
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* 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}`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,282 +0,0 @@
|
||||
/**
|
||||
* 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 { tabTypeIcon } from './icons.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 ?? tabTypeIcon(option.id as SidebarTab['type'], 16)}
|
||||
<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
|
||||
}) {
|
||||
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
const [dropZone, setDropZone] = useState<DropZone | null>(null)
|
||||
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
|
||||
|
||||
useEffect(() => {
|
||||
const clear = (): void => { setDropZone(null) }
|
||||
window.addEventListener('dragend', clear, true)
|
||||
window.addEventListener('drop', clear, true)
|
||||
window.addEventListener('blur', clear)
|
||||
return () => {
|
||||
window.removeEventListener('dragend', clear, true)
|
||||
window.removeEventListener('drop', clear, true)
|
||||
window.removeEventListener('blur', clear)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
|
||||
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}
|
||||
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
|
||||
}) {
|
||||
const { node, state, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
if (node.kind === 'leaf') {
|
||||
return (
|
||||
<LeafView
|
||||
leaf={node}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
/>
|
||||
)
|
||||
}
|
||||
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}
|
||||
/>
|
||||
</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
|
||||
}) {
|
||||
const { state, newTabOptions, actions, onNewTab, renderTab } = props
|
||||
return (
|
||||
<div className={css.workbench}>
|
||||
<NodeView
|
||||
node={state.splits}
|
||||
state={state}
|
||||
newTabOptions={newTabOptions}
|
||||
actions={actions}
|
||||
onNewTab={onNewTab}
|
||||
renderTab={renderTab}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,780 +0,0 @@
|
||||
/**
|
||||
* 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'
|
||||
|
||||
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent' | 'diff'
|
||||
|
||||
/** 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
|
||||
export const TERMINAL_LIMIT = 3
|
||||
|
||||
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. */
|
||||
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true): SidebarState {
|
||||
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
|
||||
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
|
||||
leaf.active = leaf.tabs[0]!.id
|
||||
return {
|
||||
panelOpen,
|
||||
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
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a tab type is single-instance per session (explorer/git/subagent). */
|
||||
function isSingle(type: TabType): boolean {
|
||||
return type === 'explorer' || type === 'git' || type === 'subagent'
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a tab (or focus its existing instance). Single-instance types focus
|
||||
* the existing tab wherever it lives; editors dedupe by path. The tab lands
|
||||
* in the active pane (or the first pane when the active one is gone).
|
||||
*/
|
||||
export function openTab(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
|
||||
}
|
||||
if (isSingle(tab.type)) {
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.type === tab.type)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
if (tab.type === 'editor' && tab.path !== undefined) {
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.type === 'editor' && candidate.path === tab.path)
|
||||
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
|
||||
}
|
||||
}
|
||||
if (tab.type === 'diff') {
|
||||
// One tab per change: the same path+side (or commit) focuses its instance.
|
||||
for (const leaf of allLeaves(state.splits)) {
|
||||
const existing = leaf.tabs.find(candidate => candidate.type === 'diff' && 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 ("默认在下半栏新增一个").
|
||||
* @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 openTab(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 openTab; 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 = openTab(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
|
||||
}
|
||||
|
||||
/** 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), and the
|
||||
// panel starts open only when the preference says so.
|
||||
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
|
||||
const width = viewport === undefined
|
||||
? PANEL_DEFAULT
|
||||
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
|
||||
return makeDefaultState(width, prefs.openByDefault)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
if (
|
||||
candidate.type !== 'explorer' && candidate.type !== 'git'
|
||||
&& candidate.type !== 'editor' && candidate.type !== 'terminal'
|
||||
&& candidate.type !== 'subagent'
|
||||
) {
|
||||
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 }
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private persistTimer: number | undefined
|
||||
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
|
||||
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
|
||||
|
||||
/** Replace the side card prefs (the settings RPC result / settings page write). */
|
||||
setPrefs(prefs: SidebarPrefs): void {
|
||||
this.prefs = { ...prefs }
|
||||
}
|
||||
|
||||
/** The current side card prefs (seeds new sessions; persisted states win). */
|
||||
getPrefs(): SidebarPrefs {
|
||||
return { ...this.prefs }
|
||||
}
|
||||
|
||||
/** Select a session (or none); loads its persisted state. */
|
||||
setSession(sessionId: string | undefined): void {
|
||||
if (this.snapshot.sessionId === sessionId) return
|
||||
if (sessionId === undefined) {
|
||||
this.snapshot = { sessionId: undefined, state: undefined }
|
||||
} 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 }
|
||||
}
|
||||
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 }
|
||||
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 }
|
||||
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()
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Pure derivation of the compact LIVE line shown on a running subagent card:
|
||||
* the last text output and the last tool call of the child's history tail
|
||||
* (`subagent.history` events, oldest → newest). Renders nothing itself — the
|
||||
* SubagentLiveLine component turns this into the card's status lines.
|
||||
* Kept framework-free so the parser is unit-testable in the node environment.
|
||||
*/
|
||||
import type { SidebarHistoryEntry } from '../context-types.ts'
|
||||
|
||||
/**
|
||||
* Extract the concatenated plain text of a content-block list (the durable
|
||||
* `ContentBlock[]` shape, structurally: blocks with `type: 'text'` carry
|
||||
* `text`; anything else — tool_use, image, … — contributes nothing).
|
||||
* @param content - the raw `content` field of a message event.
|
||||
* @returns the joined text, or undefined when the message carries no text.
|
||||
*/
|
||||
export function contentText(content: unknown): string | undefined {
|
||||
if (!Array.isArray(content)) return undefined
|
||||
const parts: string[] = []
|
||||
for (const block of content) {
|
||||
if (block === null || typeof block !== 'object') continue
|
||||
const candidate = block as { type?: unknown; text?: unknown }
|
||||
if (candidate.type === 'text' && typeof candidate.text === 'string') {
|
||||
parts.push(candidate.text)
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined
|
||||
}
|
||||
|
||||
/** The live status of one subagent card (both fields optional). */
|
||||
export interface LastActivity {
|
||||
/** The latest assembled assistant text output in the tail. */
|
||||
text?: string
|
||||
/** The latest tool call in the tail. */
|
||||
tool?: { name: string; args: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a history tail into the last text output + last tool call (each is
|
||||
* the LAST occurrence in event order). Lifecycle events and raw
|
||||
* `assistant/chunk` rows are ignored — the card shows what the subagent is
|
||||
* doing right now, not its plumbing.
|
||||
* @param entries - the tail page from `subagent.history` (oldest → newest).
|
||||
* @returns the last text and/or tool call; an empty object when the tail has neither.
|
||||
*/
|
||||
export function lastActivity(entries: SidebarHistoryEntry[]): LastActivity {
|
||||
let text: string | undefined
|
||||
let tool: { name: string; args: string } | undefined
|
||||
for (const entry of entries) {
|
||||
const { type, data } = entry.event
|
||||
if (type === 'assistant/message') {
|
||||
const message = data.message as { content?: unknown } | undefined
|
||||
const extracted = contentText(message?.content)
|
||||
if (extracted !== undefined) text = extracted
|
||||
} else if (type === 'tool/call') {
|
||||
tool = {
|
||||
name: typeof data.name === 'string' ? data.name : 'tool',
|
||||
args: typeof data.arguments === 'string' ? data.arguments : '',
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text === undefined && tool === undefined) return {}
|
||||
return {
|
||||
...(text === undefined ? {} : { text }),
|
||||
...(tool === undefined ? {} : { tool }),
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* 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() }
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* 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),
|
||||
})
|
||||
+27
-314
@@ -1,320 +1,33 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { Duplex } from 'node:stream'
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
/** 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 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.
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
export interface ApesLocaleService {
|
||||
getSnapshot(): { active: string }
|
||||
subscribe(fn: () => void): () => void
|
||||
register(ns: string, locale: string, dict: Record<string, string>): () => 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
|
||||
/**
|
||||
* 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
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* 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
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* 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])
|
||||
}
|
||||
+47
-717
@@ -1,730 +1,60 @@
|
||||
/**
|
||||
* 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.
|
||||
* apes-sidebar host-half entry.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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'
|
||||
|
||||
export { Config }
|
||||
export type { SidebarConfig, ResolvedSidebarConfig }
|
||||
|
||||
/** 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 ?? []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
export const name = 'apes-sidebar'
|
||||
|
||||
/**
|
||||
* 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".
|
||||
* 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`).
|
||||
*/
|
||||
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 !== '') {
|
||||
export const inject = [] as const
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return requireAbsolute(clientCwd)
|
||||
log?.info?.('[apes-sidebar] host half mounted (Phase 0 skeleton)')
|
||||
} catch {
|
||||
throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
|
||||
// Never let a logging failure abort the fiber.
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
|
||||
/** Text read of a file with the size cap; binary detection via NUL probe. */
|
||||
async function readText(path: string, readLimit: number): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> {
|
||||
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)
|
||||
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size }
|
||||
} 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 } = await readText(path, resolved.readLimit)
|
||||
if (binary) return { kind: 'binary', size, truncated }
|
||||
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)
|
||||
}
|
||||
if (typeof ctx.effect === 'function') {
|
||||
ctx.effect(() => {
|
||||
mount()
|
||||
return () => undefined
|
||||
})
|
||||
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))
|
||||
} else {
|
||||
mount()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
})
|
||||
}
|
||||
|
||||
+79
-22
@@ -1,32 +1,89 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `dsh-better-sidebar`.
|
||||
* @module dsh-better-sidebar/invariant
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from './context-types.ts'
|
||||
/** Thrown by {@link assert} when the condition is falsy. */
|
||||
export class InvariantError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'InvariantError'
|
||||
}
|
||||
}
|
||||
|
||||
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']
|
||||
/** 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* The invariant installer signature: register one package's checks in the
|
||||
* service's child fiber. `fail` throws (never returns) to abort the mount.
|
||||
*/
|
||||
const install: () => void = () => {}
|
||||
export type InvariantInstaller = (
|
||||
ctx: unknown,
|
||||
fail: (message: string) => never,
|
||||
) => void | Promise<void>
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
* 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.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
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] ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/** 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,
|
||||
}
|
||||
|
||||
/** 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)))
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* 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
@@ -1,477 +0,0 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* 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
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 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()
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 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([])
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
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)
|
||||
// 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 })
|
||||
})
|
||||
})
|
||||
+97
-578
@@ -1,595 +1,114 @@
|
||||
/**
|
||||
* 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`.
|
||||
* 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.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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)
|
||||
})
|
||||
})
|
||||
import * as host from '../src/index.ts'
|
||||
import {
|
||||
InvariantError,
|
||||
assert,
|
||||
assertBetterSidebarPeer,
|
||||
compareSemver,
|
||||
} from '../src/invariant.ts'
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* Run the real namespace export through `Loader.unwrapExports`; a stray
|
||||
* default would discard `name`, `inject`, and `apply`. Same guard the
|
||||
* official plugin repos ship.
|
||||
*/
|
||||
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 })
|
||||
}
|
||||
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([])
|
||||
})
|
||||
|
||||
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[] = []
|
||||
it('apply mounts a logger line through the effect (Phase 0 minimal)', () => {
|
||||
const logs: string[] = []
|
||||
let effects = 0
|
||||
const ctx = {
|
||||
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 },
|
||||
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 () => {},
|
||||
logger: { info: (msg: string) => logs.push(msg) },
|
||||
effect: (fn: () => void | (() => void)) => {
|
||||
effects += 1
|
||||
const cleanup = fn()
|
||||
return () => {
|
||||
if (typeof cleanup === 'function') cleanup()
|
||||
}
|
||||
},
|
||||
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 = {
|
||||
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 () => {}
|
||||
effect: (fn: () => void | (() => void)) => {
|
||||
effects += 1
|
||||
fn()
|
||||
},
|
||||
}
|
||||
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)
|
||||
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__
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
})
|
||||
@@ -1,223 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
-1043
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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,
|
||||
|
||||
+3
-3
@@ -4,6 +4,8 @@
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
@@ -11,10 +13,8 @@
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", "tests", "tsdown.config.ts"]
|
||||
"include": ["src", "tests", "tsdown.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
||||
+92
-146
@@ -1,66 +1,33 @@
|
||||
/**
|
||||
* 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:
|
||||
* 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).
|
||||
*
|
||||
* - `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.
|
||||
* 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.
|
||||
*
|
||||
* Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { builtinModules, createRequire } from 'node:module'
|
||||
import { basename, dirname, join, relative, resolve as resolvePath, sep } from 'node:path'
|
||||
import { builtinModules } 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}`),
|
||||
])
|
||||
|
||||
/**
|
||||
* 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). */
|
||||
/** Module specifiers the web shell shares into the frozen module table. */
|
||||
const CLIENT_EXTERNALS = [
|
||||
'react',
|
||||
'react/jsx-runtime',
|
||||
@@ -68,22 +35,18 @@ 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. Everything else under @deepseek-ai/* is either a module-table
|
||||
* entry (external) or a leak the purity gate rejects.
|
||||
* to share.
|
||||
*/
|
||||
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 = '\0dsh-css:'
|
||||
const CSS_VIRTUAL_PREFIX = '\0apes-css:'
|
||||
const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
|
||||
const REPOSITORY_ROOT = fileURLToPath(new URL('.', import.meta.url))
|
||||
@@ -114,10 +77,8 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
|
||||
|
||||
/**
|
||||
* One client bundle build for a plugin id. The same src/client/index.tsx is
|
||||
* compiled twice with only the registered id and the output file name
|
||||
* differing: the official channel uses the package name (`dsh-better-sidebar`)
|
||||
* and the registry channel uses the manifest id
|
||||
* (`dsh-external/dsh-better-sidebar`).
|
||||
* compiled once per install channel; only the registered id and the output
|
||||
* file name differ, so they cannot drift.
|
||||
* @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/.
|
||||
@@ -131,111 +92,101 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
|
||||
dts: false,
|
||||
sourcemap: true,
|
||||
clean: false,
|
||||
external: [...CLIENT_EXTERNALS],
|
||||
deps: {
|
||||
neverBundle: [...CLIENT_EXTERNALS],
|
||||
alwaysBundle: (id: string) => !CLIENT_EXTERNALS.includes(id),
|
||||
},
|
||||
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'],
|
||||
},
|
||||
},
|
||||
// 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')
|
||||
},
|
||||
}],
|
||||
plugins: [purityGatePlugin(), makeCssPlugin(pluginId)],
|
||||
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' },
|
||||
@@ -245,13 +196,8 @@ 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('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'),
|
||||
clientBundle('apes-sidebar', 'client.js'),
|
||||
] satisfies UserConfig[]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 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`).
|
||||
*/
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
server: {
|
||||
deps: {
|
||||
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.*',
|
||||
],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user