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
+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] ?? '',
}
}