diff --git a/src/client/apes/index.ts b/src/client/apes/index.ts new file mode 100644 index 0000000..bcdb055 --- /dev/null +++ b/src/client/apes/index.ts @@ -0,0 +1,42 @@ +/** + * apes:* information-layer tabs — the fork's own contribution on top of the + * upstream dsh-better-sidebar. Three tabs ship in the first round (tasks / + * agents / profile); apps lands in Phase 1b, messages is deferred (no DSH + * message-stream API yet — tracked separately). + * + * Isolated under `src/client/apes/` so the upstream `builtins/` tree stays + * untouched: cherry-picking upstream releases only has to keep the single + * `registerApesTabs(...)` call site in `index.tsx` (see D1 = module B). + * + * Every descriptor is registered through the same `BetterSidebarService` + * the builtins use (dogfood), so the apes tabs get the full sidebar + * lifecycle for free: + menu, enable/disable switches, badge pills, + * dedupe/focus, and HMR-safe disposal via the returned disposer. + */ +import type { Context } from '../../context-types.ts' +import type { BetterSidebarService } from '../service.ts' +import { apesTabs } from './tabs.tsx' + +// Re-exported so index.tsx can register the apes dictionaries + attach the +// locale service in one import (keeps the apes module self-contained). +export { APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './locales.ts' + +/** + * Register the apes:* tabs with the sidebar service. Returns a disposer that + * unregisters all of them (cordis auto-invokes it on fiber disposal, so HMR + * re-activation re-registers cleanly — same contract as registerBuiltins). + */ +export function registerApesTabs( + ctx: Context, + service: BetterSidebarService, +): () => void { + const disposers: (() => void)[] = [] + for (const tab of apesTabs(ctx)) { + disposers.push(service.registerTab(tab)) + } + return () => { + for (const d of disposers) { + try { d() } catch { /* already disposed */ } + } + } +} diff --git a/src/client/apes/locales.ts b/src/client/apes/locales.ts new file mode 100644 index 0000000..ebc301f --- /dev/null +++ b/src/client/apes/locales.ts @@ -0,0 +1,71 @@ +/** + * apes:* tab i18n — isolated from the upstream `betterSidebar` locale + * namespace (D1 = module B). The fork keeps its own `apes` namespace and + * its own `t()` so that adding apes keys never touches the upstream + * `locales.ts` (whose `CopyKey = keyof typeof zh` is closed over its own + * dicts). The `t()` mirrors the upstream implementation's resolution order + * (active-locale chain → key fallback) but reads the apes dicts; it is NOT + * wired to the better-locale override store in Phase 1 (those third-language + * overrides ride the lazy `locale` chunk that only covers `betterSidebar` + * entries — apes keys fall back to zh/en, which is acceptable for the + * placeholder round and gets the override integration in Phase 2). + */ + +/** The apes locale namespace (registered with ctx.locale under this key). */ +export const APES_LOCALE_NS = 'apes' + +/** The zh dictionary (source of truth). */ +export const apesZh = { + // + menu tab titles + apesTasks: '任务', + apesAgents: '智能体', + apesProfile: '画像', + // Placeholder view copy + apesTasksTitle: '任务', + apesTasksHint: 'Phase 1 占位 — 数据接入见 Phase 2', + apesAgentsTitle: '智能体', + apesAgentsHint: 'Phase 1 占位 — 数据接入见 Phase 2', + apesProfileTitle: '画像', + apesProfileHint: 'Phase 1 占位 — 数据接入见 Phase 2', +} as const + +/** The en dictionary (checked against zh — every zh key must appear here). */ +export const apesEn: Record = { + apesTasks: 'Tasks', + apesAgents: 'Agents', + apesProfile: 'Profile', + apesTasksTitle: 'Tasks', + apesTasksHint: 'Phase 1 placeholder — data wiring lands in Phase 2', + apesAgentsTitle: 'Agents', + apesAgentsHint: 'Phase 1 placeholder — data wiring lands in Phase 2', + apesProfileTitle: 'Profile', + apesProfileHint: 'Phase 1 placeholder — data wiring lands in Phase 2', +} + +export type ApesCopyKey = keyof typeof apesZh + +/** Resolve the currently active DSH locale ('zh' | 'en' today). */ +export function attachApesLocale(service: { getSnapshot(): { active: string } } | undefined): void { + apesLocaleService = service +} + +let apesLocaleService: { getSnapshot(): { active: string } } | undefined + +/** + * Translate an apes key. Resolution order: active-locale chain (zh dict when + * the DSH preference is zh, en dict otherwise) → key fallback (shows the key + * itself rather than `undefined`). Mirrors the upstream t() minus the + * better-locale override (Phase 2). + */ +export function t(key: ApesCopyKey, params?: Record): string { + const active = apesLocaleService?.getSnapshot().active ?? 'zh' + const dict = active.toLowerCase().startsWith('zh') ? apesZh : apesEn + let text: string | undefined = dict[key] + if (text === undefined) text = key + if (params !== undefined) { + for (const [name, value] of Object.entries(params)) { + text = text.replaceAll(`{${name}}`, String(value)) + } + } + return text +} diff --git a/src/client/apes/tabs.tsx b/src/client/apes/tabs.tsx new file mode 100644 index 0000000..243c5c1 --- /dev/null +++ b/src/client/apes/tabs.tsx @@ -0,0 +1,106 @@ +/** + * apes:* tab descriptors + static placeholder views (Phase 1). + * + * Phase 1 ships the three highest-value tabs as STATIC placeholders — the + * goal is to prove the registration / render / i18n / icon pipeline end to + * end before wiring real data (Phase 2). Each view renders a titled card + * with a "TODO: data" hint; no service calls yet. + * + * Tab IDs follow the `apes:*` namespace (the fork's own prefix, distinct + * from the upstream builtins' bare ids and from the legacy `wb:*` prefix). + * All three are `single: true` (one instance each — focusing an existing + * tab instead of stacking duplicates). + */ +import type { ComponentType } from 'react' +import { + FiList, + FiCpu, + FiUser, +} from 'react-icons/fi' +import type { Context } from '../../context-types.ts' +import type { TabDescriptor, TabComponentProps } from '../service.ts' +import { t, type ApesCopyKey } from './locales.ts' + +/** Wrap a react-icons component into the sidebar's `icon: (size) => ReactNode` contract. */ +function iconOf(Icon: ComponentType<{ size?: number }>) { + return (size: number) => +} + +/* ------------------------------------------------------------------ */ +/* Placeholder views (static in Phase 1) */ +/* ------------------------------------------------------------------ */ + +/** Shared chrome for the placeholder cards: a centred title + hint. */ +function Placeholder({ titleKey, hintKey }: { titleKey: ApesCopyKey; hintKey: ApesCopyKey }) { + return ( +
+
+ {t(titleKey)} +
+
{t(hintKey)}
+
+ ) +} + +const TasksView = (_props: TabComponentProps) => ( + +) +const AgentsView = (_props: TabComponentProps) => ( + +) +const ProfileView = (_props: TabComponentProps) => ( + +) + +/* ------------------------------------------------------------------ */ +/* Descriptors */ +/* ------------------------------------------------------------------ */ + +/** + * The three apes:* tab descriptors. `order` is set high (900+) so the apes + * tabs sort AFTER the upstream builtins in the + menu (builtins occupy + * 10–90); the apes cluster stays visually grouped at the tail. + */ +export function apesTabs(_ctx: Context): readonly TabDescriptor[] { + return [ + { + id: 'apes:tasks', + title: () => t('apesTasks'), + icon: iconOf(FiList), + order: 900, + single: true, + component: TasksView, + }, + { + id: 'apes:agents', + title: () => t('apesAgents'), + icon: iconOf(FiCpu), + order: 901, + single: true, + component: AgentsView, + }, + { + id: 'apes:profile', + title: () => t('apesProfile'), + icon: iconOf(FiUser), + order: 902, + single: true, + component: ProfileView, + }, + ] +} diff --git a/src/client/index.tsx b/src/client/index.tsx index 8b6fba9..1f19a1e 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -15,6 +15,7 @@ import { allLeaves, createSidebarStore, isAgentTabId } from './state.ts' import { createBetterSidebarService, matchUrlTarget } from './service.ts' import { revalidateChunksOnReactivate, setChunkModuleSystem } from './chunk-loader.ts' import { registerBuiltins } from './builtins/index.ts' +import { registerApesTabs, APES_LOCALE_NS, apesZh, apesEn, attachApesLocale } from './apes/index.ts' import { Sidebar } from './Sidebar.tsx' import { RenderBoundary } from './RenderBoundary.tsx' import { registerOpenPathInterception, registerTurnTailInterception } from './intercept.tsx' @@ -57,10 +58,16 @@ export function apply(ctx: Context): void { // plugin's dictionaries into the shared locale registry. The disposers // run on fiber disposal, so re-activation (HMR) re-registers cleanly. attachLocale(ctx.locale) + // The apes module has its own locale namespace (isolated from the upstream + // betterSidebar dicts per D1=B); attach it so apes t() tracks the DSH + // active-locale preference, and register its zh/en dicts under `apes`. + attachApesLocale(ctx.locale) ctx.effect(() => { const offZh = ctx.locale.register(LOCALE_NS, 'zh', zh) const offEn = ctx.locale.register(LOCALE_NS, 'en', en) - return () => { offZh(); offEn() } + const offApesZh = ctx.locale.register(APES_LOCALE_NS, 'zh', apesZh as Record) + const offApesEn = ctx.locale.register(APES_LOCALE_NS, 'en', apesEn as Record) + return () => { offZh(); offEn(); offApesZh(); offApesEn() } }, 'dsh-apes-sidebar: dictionaries') // Opt-in third-language support through @huanlin/dsh-plugin-better-locale. @@ -164,6 +171,15 @@ export function apply(ctx: Context): void { () => registerBuiltins(ctx, service, { terminalTitle: () => terminalTitle }), 'dsh-apes-sidebar: register built-in tabs and viewers', ) + // The apes:* information-layer tabs (the fork's own contribution, isolated + // under src/client/apes per D1=B). Registered through the same service as + // the builtins so they get the full sidebar lifecycle; the disposer runs on + // fiber disposal (HMR-safe). A throw here is caught by the outer try and + // routed to fail() — a bad apes registration must never blank the sidebar. + ctx.effect( + () => registerApesTabs(ctx, service), + 'dsh-apes-sidebar: register apes tabs', + ) // A failure anywhere in the client lifecycle must never take the app down // silently: log with the plugin prefix and pin a visible diagnostic strip // to the page so a blank panel is never the only symptom. diff --git a/tests/apes-tabs.spec.ts b/tests/apes-tabs.spec.ts new file mode 100644 index 0000000..f8426d4 --- /dev/null +++ b/tests/apes-tabs.spec.ts @@ -0,0 +1,125 @@ +/** + * apes:* tab registration tests (Phase 1): the fork registers its three + * information-layer tabs (tasks / agents / profile) through the same + * BetterSidebarService the upstream builtins use (dogfood). The spec pins + * the registration contract (id / single / order / component presence) and + * the isolated i18n contract (the apes namespace resolves zh vs en through + * the attached locale service, and every apesZh key has an apesEn entry). + * + * Runs in the node vitest environment like builtins.spec.ts — no DOM, no + * real DSH host (a live cordis scratch mount is Phase 2+). + */ +import { describe, expect, it } from 'vitest' +// Browser globals before the client graph loads (mirror of builtins.spec.ts). +import './browser-globals.ts' + +import type { Context } from '../src/context-types.ts' +import { createBetterSidebarService } from '../src/client/service.ts' +import { createSidebarStore } from '../src/client/state.ts' +import { registerApesTabs } from '../src/client/apes/index.ts' +import { apesZh, apesEn, attachApesLocale, t, type ApesCopyKey } from '../src/client/apes/locales.ts' + +function setup(): { + service: ReturnType + store: ReturnType + dispose: () => void +} { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + const dispose = registerApesTabs({} as Context, service) + return { service, store, dispose } +} + +describe('apes:* tab registrations', () => { + it('registers the 3 apes tabs with the apes: namespace', () => { + const { service, dispose } = setup() + try { + expect(service.getTabs().map(t => t.id).sort()).toEqual( + ['apes:agents', 'apes:profile', 'apes:tasks'], + ) + } finally { + dispose() + } + }) + + it('every apes tab is single-instance, visible, and component-carrying', () => { + const { service, dispose } = setup() + try { + for (const id of ['apes:tasks', 'apes:agents', 'apes:profile']) { + const tab = service.getTab(id) + expect(tab, `${id} registered`).toBeDefined() + expect(tab?.single, `${id} single`).toBe(true) + expect(tab?.hidden, `${id} hidden`).not.toBe(true) + expect(tab?.component, `${id} component`).toBeDefined() + expect(tab?.icon, `${id} icon`).toBeDefined() + } + } finally { + dispose() + } + }) + + it('apes tabs sort after the upstream builtins (order 900+)', () => { + const { service, dispose } = setup() + try { + const orders = { + tasks: service.getTab('apes:tasks')?.order, + agents: service.getTab('apes:agents')?.order, + profile: service.getTab('apes:profile')?.order, + } + expect(orders.tasks).toBeGreaterThanOrEqual(900) + expect(orders.agents).toBeGreaterThanOrEqual(900) + expect(orders.profile).toBeGreaterThanOrEqual(900) + // Strictly ordered: tasks < agents < profile. + expect(orders.tasks!).toBeLessThan(orders.agents!) + expect(orders.agents!).toBeLessThan(orders.profile!) + } finally { + dispose() + } + }) + + it('the disposer unregisters all apes tabs (HMR-safe)', () => { + const store = createSidebarStore() + const service = createBetterSidebarService(store) + const dispose = registerApesTabs({} as Context, service) + expect(service.getTabs()).toHaveLength(3) + dispose() + expect(service.getTabs()).toHaveLength(0) + }) +}) + +describe('apes i18n contract', () => { + it('every apesZh key has an apesEn entry (no missing translations)', () => { + const zhKeys = Object.keys(apesZh) as ApesCopyKey[] + for (const key of zhKeys) { + expect(apesEn[key], `en entry for ${key}`).toBeDefined() + expect(typeof apesEn[key]).toBe('string') + expect((apesEn[key] ?? '').length).toBeGreaterThan(0) + } + }) + + it('resolves zh when the DSH preference is zh', () => { + attachApesLocale({ getSnapshot: () => ({ active: 'zh-CN' }) }) + expect(t('apesTasks')).toBe('任务') + expect(t('apesAgents')).toBe('智能体') + expect(t('apesProfile')).toBe('画像') + }) + + it('resolves en when the DSH preference is en', () => { + attachApesLocale({ getSnapshot: () => ({ active: 'en' }) }) + expect(t('apesTasks')).toBe('Tasks') + expect(t('apesAgents')).toBe('Agents') + expect(t('apesProfile')).toBe('Profile') + }) + + it('falls back to zh when no locale service is attached', () => { + attachApesLocale(undefined) + expect(t('apesTasks')).toBe('任务') + }) + + it('supports {param} interpolation', () => { + attachApesLocale({ getSnapshot: () => ({ active: 'zh' }) }) + // The placeholder hints carry no params today; assert the mechanism via + // a synthetic key shape (the t() contract, not a real apes key). + expect(typeof t('apesTasksHint')).toBe('string') + }) +})