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:
+31
@@ -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/
|
||||
@@ -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,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.
|
||||
@@ -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,否则构建纯度门会拦。
|
||||
@@ -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"
|
||||
}
|
||||
Generated
+4235
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
node-pty: true
|
||||
minimumReleaseAgeExclude:
|
||||
- dsh-better-sidebar@0.18.0
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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] ?? '',
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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__
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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[]
|
||||
@@ -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