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:
@@ -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] ?? '',
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user