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.
}
}
}
}