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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user