feat(apes-sidebar): Phase 0 工程骨架 — host/client 分裂 + i18n + 不变量面

从 0 重写的 v2.0.0 DSH 侧边栏插件第一阶段:
- 工程骨架:tsdown 双构建(host ESM + client CJS closure)、vitest、strict TS
- host 半:namespace 插件形状(name/inject/apply,无 default),minimal apply
- client 半:closure factory 注册 5 个 apes:* tab 占位 + zh/en i18n
- 不变量面:assert/semver/peer guard(dsh-better-sidebar >= 0.18.0)
- 依赖策略:dsh-better-sidebar=peerDependency(不打包),cordis=devDependency(仅类型)
- 门禁:pnpm typecheck + build + test 全绿(14 单测通过)
- AGENTS.md 工程约定 + .gitignore(排除 lib/node_modules/maxkb-integration)

模板:dsh-better-sidebar。作用域锁定信息层(tasks/agents/apps/messages/profile),
不含 file/terminal/browser(归 better-sidebar)、不含 team/room(归 background-agents)。
This commit is contained in:
figmar
2026-09-04 17:14:22 +08:00
parent c319fb106e
commit 0e91e99f49
19 changed files with 5838 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Build output
lib/
dist/
# Dependencies
node_modules/
# Logs & caches
*.log
.vite/
.vitest/
coverage/
.cache/
# OS / editor
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
# Local env
.env
.env.local
.env.*.local
# pnpm
pnpm-debug.log*
# Unrelated work that landed in this directory (not part of apes-sidebar)
maxkb-integration/
+6
View File
@@ -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
+130
View File
@@ -0,0 +1,130 @@
# 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, CJS closure factory)
- **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: closure factory 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. It is
a **CJS closure factory** — `module.exports = (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.
## Phase status
- **Phase 0 (done)**: engineering skeleton, host/client split, i18n, invariant
surface, build gate green, unit smoke green.
- **Phase 1 (next)**: real tab registration for the five `apes:*` panels against
a mock host registry, plus a cordis scratch mount smoke.
- **Phase 2 (later)**: badge/reminder counts, auto-collapse, per-tab state.
+13
View File
@@ -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)
+362
View File
@@ -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,否则构建纯度门会拦。
+61
View File
@@ -0,0 +1,61 @@
{
"name": "apes-sidebar",
"version": "2.0.0",
"description": "WorkBuddy/Tutti 风格右侧边栏增强(构建在 dsh-better-sidebar 之上:任务 / Agent / 消息 / 应用 / Profile 五面板信息层)",
"type": "module",
"main": "lib/index.js",
"exports": {
".": "./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",
"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",
"lightningcss": "^1.32.0",
"oxlint": "^0.15.0",
"react": "~18.3.1",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@11.8.0",
"dsh.bundle.patch": "./cordis.patch.yml",
"dsh.client.inject": [
"betterSidebar",
"locale",
"sessions"
],
"dsh.client.platform": "web",
"license": "MIT"
}
+4235
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
allowBuilds:
node-pty: true
minimumReleaseAgeExclude:
- dsh-better-sidebar@0.18.0
+133
View File
@@ -0,0 +1,133 @@
/**
* apes-sidebar client-half entry.
*
* Loaded through the package.json `dsh.client.inject` declaration (web
* platform). The entry registers the apes-sidebar tab descriptors into the
* better-sidebar host's `ctx.betterSidebar` registry, then disposes cleanly
* on fiber teardown (HMR-safe).
*
* PHASE 0 SCOPE: registers ONE placeholder Tasks tab to prove the
* `ctx.betterSidebar.registerTab` path end-to-end (the scratch-profile mount
* smoke asserts the tab id `apes:tasks` appears in `getTabs()` and renders
* its empty-state copy). Phase 1 replaces the placeholder with the five
* Tutti-derived panels (tasks / agents / messages / apps / profile) built on
* the real better-sidebar `TabDescriptor` contract.
*
* inject list (client-only, Phase 0):
* - betterSidebar: the better-sidebar registry service (type-only in the
* source; the runtime resolves it from the host context)
* - locale: the DSH locale runtime (dictionaries register under apesSidebar)
* - sessions: the client session list feed (drives the tasks badge; Phase 1)
*/
import { useSyncExternalStore, type ReactElement } from 'react'
import { t, registerLocales, subscribeLocale, getActiveLocale } from './locales.ts'
/** The cordis Context face this entry resolves against. */
interface ApesClientContext {
/** The better-sidebar registry (the host publishes it under this key). */
betterSidebar?: BetterSidebarLike
locale?: {
getSnapshot(): { active: string }
subscribe(fn: () => void): () => void
register(ns: string, locale: string, dict: Record<string, string>): () => void
}
sessions?: {
list: {
getSnapshot(): { current?: string; byId: Record<string, { id: string; running?: boolean }> }
subscribe(fn: () => void): () => void
}
open?(id: string): void
}
effect?(fn: () => void | (() => void)): () => void
}
/**
* Structural mirror of the better-sidebar `BetterSidebarService` surface this
* entry uses (Phase 0: only registerTab/getTabs/subscribe). Phase 1 swaps
* this for a type-only `import type { BetterSidebarService } from
* 'dsh-better-sidebar/client/service'` — type-only imports are erased and
* never reach the bundle purity gate.
*/
interface BetterSidebarLike {
registerTab(descriptor: ApesTabDescriptor): () => void
getTabs(): readonly ApesTabDescriptor[]
subscribe(listener: () => void): () => void
}
/** Phase 0 structural TabDescriptor (Phase 1: the real better-sidebar type). */
interface ApesTabDescriptor {
id: string
title: string | (() => string)
icon?: (size: number) => unknown
order?: number
single?: boolean
badge?: (ctx: unknown, state: unknown) => string | number | null | undefined
component: (props: { ctx: unknown }) => ReactElement
}
/**
* The placeholder Tasks view: renders the empty-state copy. Phase 1 builds
* the real session/task navigation (workspace + time grouping, running badge,
* subagent topology) from the sessions feed via useSyncExternalStore.
*/
function TasksView(): ReactElement {
const locale = useApesLocaleBridge()
void locale
return (
<div data-apes-sidebar="tasks" style={{ padding: 12, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t('tab.tasks.title')}</div>
<div style={{ opacity: 0.6 }}>{t('tasks.empty')}</div>
</div>
)
}
/** Re-render on locale change (delegates to the locales module store). */
function useApesLocaleBridge(): string {
return useSyncExternalStore(subscribeLocale, getActiveLocale, getActiveLocale)
}
/** The cordis client entry. */
export function apply(ctx: ApesClientContext): void {
const better = ctx.betterSidebar
if (!better) {
// 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
}
const registerAll = (): (() => void) => {
// Phase 0: i18n registration + the single placeholder tab proving the
// registry path. The combined disposer runs on activation teardown.
const disposeLocale = registerLocales(ctx.locale)
const disposers: (() => void)[] = [
disposeLocale,
better.registerTab({
id: 'apes:tasks',
title: () => t('tab.tasks.title'),
order: 90,
single: true,
component: () => <TasksView />,
}),
]
return () => {
for (const d of disposers) {
try {
d()
} catch {
// Already disposed — ignore.
}
}
}
}
if (typeof ctx.effect === 'function') {
// The effect body returns the combined disposer; cordis runs it on
// activation teardown (HMR-safe).
ctx.effect(registerAll)
} else {
// No effect API (non-cordis harness): register synchronously; disposal
// rides process teardown.
registerAll()
}
}
+116
View File
@@ -0,0 +1,116 @@
/**
* 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 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': '暂无会话',
'agents.empty': '暂无子代理',
'apps.empty': '暂无应用',
'profile.model': '模型',
'profile.provider': '提供商',
}
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',
'agents.empty': 'No subagents yet',
'apps.empty': 'No apps yet',
'profile.model': 'Model',
'profile.provider': 'Provider',
}
const DICTS: Record<string, Record<string, string>> = { zh, en }
/** 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.
}
}
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Structural types for the cordis services apes-sidebar consumes, plus the
* Context face both halves share.
*
* The type base is the `@deepseek-ai/cordis` Context; the service members
* this plugin touches are restated below as structural mirrors and combined
* with the base by INTERSECTION. Intersection (not `declare module`
* augmentation) is deliberate: DSH's own packages already augment
* `@deepseek-ai/cordis` and the host and client packages declare *different*
* types for the same member — intersecting keeps every face available.
*
* This file must stay FREE of Node.js types: it is part of the
* CLIENT-reachable declaration graph (the `Context` in TabComponentProps and
* the `apesSidebar` augmentation), so a Node import here would leak into
* browser-only consumer builds.
*/
import type { Context as CordisContext } from '@deepseek-ai/cordis'
/** One session row the sidebar reads (client sessions list feed). */
export interface ApesSessionSummary {
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
}
/** The client session list snapshot the sidebar subscribes to. */
export interface ApesSessionList {
current: string | undefined
byId: Record<string, ApesSessionSummary>
}
/** The client sessions service face (only the list feed + open are needed). */
export interface ApesSessionsService {
list: {
getSnapshot(): ApesSessionList
subscribe(fn: () => void): () => void
}
/** Select a listed session as current. */
open?(id: string): void
}
/** The client locale service face (mirror of dsh-client-locale's LocaleRuntime). */
export interface ApesLocaleService {
getSnapshot(): { active: string }
subscribe(fn: () => void): () => void
register(ns: string, locale: string, dict: Record<string, string>): () => void
}
/** The client slots service face (register returns the disposer). */
export interface ApesSlotsService {
register(options: Record<string, unknown>, component: unknown): () => void
inject(key: string, callback: () => () => void): () => void
}
/**
* The `ctx.apesSidebar` service face: a registry that lets apes-sidebar
* (and future sibling plugins) contribute sidebar tab descriptors to the
* better-sidebar host. For Phase 0 this is a minimal self-registry; in
* Phase 1 it is re-exported from the better-sidebar `BetterSidebarService`
* (type-only import through `dsh-better-sidebar/client/service`).
*
* NOTE: this is a PLACEHOLDER contract for the skeleton. Phase 1 replaces
* it with the real better-sidebar `BetterSidebarService` + `TabDescriptor`
* types (type-only imports, which are erased and never reach the bundle
* purity gate).
*/
export interface ApesSidebarTabDescriptor {
/** Unique id; also the `SidebarTab.type` value (`'apes:tasks'`, …). */
id: string
title: string | (() => string)
icon?: unknown
/** + menu sort order (ascending); default 100. */
order?: number
hidden?: boolean
/** Single-instance sugar. */
single?: boolean
/** Tab-strip badge: a number renders as a count, a string as-is. */
badge?: (ctx: ApesContext, state: unknown) => string | number | null | undefined
component: (props: Record<string, unknown>) => unknown
}
/** The `ctx.apesSidebar` service face (Phase 0 placeholder). */
export interface ApesSidebarService {
registerTab(descriptor: ApesSidebarTabDescriptor): () => void
getTabs(): readonly ApesSidebarTabDescriptor[]
subscribe(listener: () => void): () => void
}
/**
* The Context face this plugin's code resolves against. Intersecting the
* cordis base with the structural service mirrors keeps every face
* available without module-level conflicts.
*/
export type ApesContext = CordisContext & {
/** The client-side registry (client half only). */
apesSidebar?: ApesSidebarService
/** The client sessions feed (client half only). */
sessions?: ApesSessionsService
/** The client locale runtime (client half only). */
locale?: ApesLocaleService
/** The client slots service (client half only). */
slots?: ApesSlotsService
}
+50
View File
@@ -0,0 +1,50 @@
/**
* apes-sidebar host-half entry.
*
* 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.
*/
export const name = 'apes-sidebar'
/** The cordis services the host half injects (Phase 0 minimal set). */
export const inject = ['logger'] 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 {
log?.info?.('[apes-sidebar] host half mounted (Phase 0 skeleton)')
} catch {
// Never let a logging failure abort the fiber.
}
}
if (typeof ctx.effect === 'function') {
ctx.effect(() => {
mount()
return () => undefined
})
} else {
mount()
}
}
+89
View File
@@ -0,0 +1,89 @@
/**
* 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.
*/
/** Thrown by {@link assert} when the condition is falsy. */
export class InvariantError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvariantError'
}
}
/** 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)
}
/**
* The invariant installer signature: register one package's checks in the
* service's child fiber. `fail` throws (never returns) to abort the mount.
*/
export type InvariantInstaller = (
ctx: unknown,
fail: (message: string) => never,
) => void | Promise<void>
/**
* Guard the better-sidebar peer dependency contract at host startup:
* apes-sidebar requires `dsh-better-sidebar` >= 0.18.0. The check is a
* no-op in the client bundle (it only runs on the Node half) and a warning
* (not a hard fail) so a fresh DSH without the peer still mounts — the
* tabs simply won't appear until the peer is present.
*/
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] ?? '',
}
}
+128
View File
@@ -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()
})
})
+110
View File
@@ -0,0 +1,110 @@
/**
* 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 * as host from '../src/index.ts'
import {
InvariantError,
assert,
assertBetterSidebarPeer,
compareSemver,
} from '../src/invariant.ts'
/**
* 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('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')
expect(host.inject).toEqual(['logger'])
})
it('apply mounts a logger line through the effect (Phase 0 minimal)', () => {
const logs: string[] = []
let effects = 0
const ctx = {
logger: { info: (msg: string) => logs.push(msg) },
effect: (fn: () => void | (() => void)) => {
effects += 1
const cleanup = fn()
return () => {
if (typeof cleanup === 'function') cleanup()
}
},
}
host.apply(ctx as never)
expect(effects).toBe(1)
expect(logs).toEqual(['[apes-sidebar] host half mounted (Phase 0 skeleton)'])
})
it('apply survives a missing logger (degrades, never throws)', () => {
let effects = 0
const ctx = {
effect: (fn: () => void | (() => void)) => {
effects += 1
fn()
},
}
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__
})
})
+11
View File
@@ -0,0 +1,11 @@
{
"//": "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,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "lib/types"
},
"include": ["src"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": false,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "tests", "tsdown.config.ts", "vitest.config.ts"]
}
+203
View File
@@ -0,0 +1,203 @@
/**
* 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).
*
* 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, 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'
/** Node builtins must never survive into the browser module-loader factory. */
const NODE_BUILTINS = new Set([
...builtinModules,
...builtinModules.map(id => `node:${id}`),
])
/** Module specifiers the web shell shares into the frozen module table. */
const CLIENT_EXTERNALS = [
'react',
'react/jsx-runtime',
'react-dom',
'react-dom/client',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-ui-primitives',
]
/**
* 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.
*/
const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
/** Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline. */
const CSS_VIRTUAL_PREFIX = '\0apes-css:'
const CSS_VIRTUAL_SUFFIX = '.mjs'
const REPOSITORY_ROOT = fileURLToPath(new URL('.', import.meta.url))
/** The style-injection prologue shared by module css and plain css loads. */
function injectTag(pluginId: string, fileId: string, cssText: string): string {
const tagId = `${pluginId}/${basename(fileId)}`
return [
`const css = ${JSON.stringify(cssText)};`,
`const tagId = ${JSON.stringify(tagId)};`,
`if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {`,
` const tag = document.createElement('style');`,
` tag.dataset.plugin = ${JSON.stringify(pluginId)};`,
` tag.dataset.pluginCss = tagId;`,
` tag.textContent = css;`,
` document.head.appendChild(tag);`,
`}`,
].join('\n')
}
/** Rebase a physical lib-relative source onto the repository-shaped URL tree. */
function browserSourcePath(source: string, sourcemapPath: string): string {
if (!source.startsWith('.')) return source
const physicalSource = resolvePath(dirname(sourcemapPath), source)
const repositoryPath = relative(REPOSITORY_ROOT, physicalSource).split(sep).join('/')
return `../../../${repositoryPath}`
}
/**
* One client bundle build for a plugin id. The same src/client/index.tsx is
* compiled once per install channel; only the registered id and the output
* file name differ, so they cannot drift.
* @param pluginId - the `__ModuleLoader__.load({ id })` value and the
* data-plugin style-tag prefix of this bundle.
* @param entryFile - the output file name under lib/.
*/
function clientBundle(pluginId: string, entryFile: string): UserConfig {
return {
entry: { client: 'src/client/index.tsx' },
outDir: 'lib',
format: 'cjs',
platform: 'browser',
dts: false,
sourcemap: true,
clean: false,
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' }),
'import.meta.resolve': 'undefined',
},
inputOptions: {
resolve: {
conditionNames: ['browser', 'import', 'require', 'default'],
},
},
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;',
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' },
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
// Official profile channel: bundle id = package name (package.json `name`).
clientBundle('apes-sidebar', 'client.js'),
] satisfies UserConfig[]
+27
View File
@@ -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.*',
],
},
})