From f86c1d82e654f9e1835ec7c0bad2bc35546e1b3b Mon Sep 17 00:00:00 2001 From: figmar Date: Fri, 4 Sep 2026 17:42:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(apes-sidebar):=20Phase=201=20=E2=80=94=205?= =?UTF-8?q?=20=E4=B8=AA=20apes:*=20tab=20=E7=9C=9F=E5=AE=9E=E6=B3=A8?= =?UTF-8?q?=E5=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client/index.tsx 改用 dsh-better-sidebar 真实 TabDescriptor/ TabComponentProps/Context(type-only,擦除不打包) - 注册 5 个面板:apes:tasks(读 sessions,running/current/subagent 标记)、apes:agents(subagent 过滤 + 按 parent 分组)、apes:apps / apes:messages / apes:profile(占位) - 全部走 t() i18n,ctx.effect 包裹注册(HMR 安全 disposer) - betterSidebar 缺失时优雅降级(bare DSH 不破坏 shell) - context-types.ts 删除被取代的 Phase 0 占位镜像,保留 ApesLocaleService - package.json devDeps 补 dsh-better-sidebar(dev-only 类型) - tests/tabs.spec.ts: mock registry 验证 5 个 apes:* id 注册 + disposer 撤销 + 缺失降级 + namespace 锁 门禁全绿:typecheck / build / test(20/20) cordis scratch 真实挂载冒烟留 Phase 2(需真实 DSH host 进程) --- package.json | 1 + pnpm-lock.yaml | 7 +- src/client/index.tsx | 411 +++++++++++++++++++++++++++++++----------- src/client/locales.ts | 18 ++ src/context-types.ts | 118 +++--------- tests/tabs.spec.ts | 156 ++++++++++++++++ 6 files changed, 509 insertions(+), 202 deletions(-) create mode 100644 tests/tabs.spec.ts diff --git a/package.json b/package.json index 5567694..7d54ba3 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/cordis": "^4.0.2", "@types/node": "^22.0.0", "@types/react": "~18.3.1", + "dsh-better-sidebar": "^0.18.0", "lightningcss": "^1.32.0", "oxlint": "^0.15.0", "react": "~18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcafde0..4dff70f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,6 @@ settings: importers: .: - dependencies: - dsh-better-sidebar: - specifier: ^0.18.0 - version: 0.18.0(1ddf4fccca00edda3be2b1eb7ca489a3) devDependencies: '@deepseek-ai/cordis': specifier: ^4.0.2 @@ -21,6 +17,9 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + dsh-better-sidebar: + specifier: ^0.18.0 + version: 0.18.0(1ddf4fccca00edda3be2b1eb7ca489a3) lightningcss: specifier: ^1.32.0 version: 1.33.0 diff --git a/src/client/index.tsx b/src/client/index.tsx index f6293f1..2dec064 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -1,133 +1,342 @@ /** - * apes-sidebar client-half entry. + * apes-sidebar client-half entry (Phase 1: the five real `apes:*` tabs). * * 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). + * platform). The entry registers the five apes-sidebar tab descriptors — + * `apes:tasks`, `apes:agents`, `apes:apps`, `apes:messages`, `apes:profile` — + * 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. + * Registration is wrapped in a single `ctx.effect(...)`: the effect body + * registers the i18n dictionaries plus all five tabs and returns the combined + * disposer, which cordis invokes on activation teardown. A missing + * `betterSidebar` (bare DSH, no better-sidebar installed) degrades silently — + * a missing peer must never break the shell. * - * 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) + * Type base: the descriptors and the component props are the REAL + * better-sidebar `TabDescriptor` / `TabComponentProps` (type-only import — + * erased at build, never bundled, and it triggers the `declare module + * '@deepseek-ai/cordis'` augmentation so `ctx.betterSidebar` type-checks). + * + * inject list (client-only): + * - betterSidebar: the better-sidebar registry service (peer, optional) * - locale: the DSH locale runtime (dictionaries register under apesSidebar) - * - sessions: the client session list feed (drives the tasks badge; Phase 1) + * - sessions: the client session list feed (drives tasks + agents) */ 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): () => void - } - sessions?: { - list: { - getSnapshot(): { current?: string; byId: Record } - subscribe(fn: () => void): () => void - } - open?(id: string): void - } - effect?(fn: () => void | (() => void)): () => void -} +import type { TabDescriptor, TabComponentProps, Context } from 'dsh-better-sidebar' +import { t, registerLocales, useApesLocale } from './locales.ts' /** - * 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. + * The five tab ids, in + menu order (ascending). Phase 2 adds badge counts + * and auto-collapse on top of these same descriptors. */ -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 -} +const TABS = { + tasks: 'apes:tasks', + agents: 'apes:agents', + apps: 'apes:apps', + messages: 'apes:messages', + profile: 'apes:profile', +} as const /** - * 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. + * The cordis Context face this entry resolves against: the better-sidebar + * `Context` (a `CordisContext & SidebarContextShape` intersection that + * carries `sessions`, `locale`, and `betterSidebar`). `betterSidebar` is + * typed required by the peer, but a bare DSH profile lacks it at runtime — + * the apply() guard handles the absence, so we keep the peer's type as-is + * and guard on the value. */ -function TasksView(): ReactElement { - const locale = useApesLocaleBridge() - void locale +type ApesClientContext = Context + +/** Read one client session list snapshot uSES-safely. */ +function useSessionList(ctx: Context) { + const list = ctx.sessions.list + return useSyncExternalStore(list.subscribe, list.getSnapshot, list.getSnapshot) +} + +/** A row in a list view: id, title, and optional flag chips. */ +function SessionRow({ + title, + running, + current, + subagent, +}: { + title: string + running?: boolean + current?: boolean + subagent?: boolean +}): ReactElement { return ( -
-
{t('tab.tasks.title')}
-
{t('tasks.empty')}
+
+ + {title} + + {running && } + {current && } + {subagent && }
) } -/** Re-render on locale change (delegates to the locales module store). */ -function useApesLocaleBridge(): string { - return useSyncExternalStore(subscribeLocale, getActiveLocale, getActiveLocale) +/** A small status pill. */ +function Chip({ label, tone }: { label: string; tone: 'run' | 'cur' | 'sub' }): ReactElement { + const bg = tone === 'run' ? '#2ea04333' : tone === 'cur' ? '#1f6feb33' : '#6e40c933' + const fg = tone === 'run' ? '#2ea043' : tone === 'cur' ? '#1f6feb' : '#6e40c9' + return ( + + {label} + + ) +} + +/** A panel shell: a title bar + a scrollable body. */ +function Panel({ titleKey, children }: { titleKey: string; children: ReactElement | ReactElement[] }): ReactElement { + return ( +
+
{t(titleKey)}
+
{children}
+
+ ) +} + +/** The empty-state line every panel falls back to. */ +function Empty({ key: key }: { key: string }): ReactElement { + return
{t(key)}
+} + +/** + * apes:tasks — the session list. Running/current/subagent rows are flagged. + * Clicking a row selects it as current (when the host exposes `open`). + */ +function TasksView(props: TabComponentProps): ReactElement { + useApesLocale() + const list = useSessionList(props.ctx) + const rows = Object.values(list.byId) + if (rows.length === 0) { + return ( + + + + ) + } + const open = (id: string) => props.ctx.sessions.open?.(id) + return ( + + {rows.map((s) => ( + open(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }} + /> + ))} + + ) +} + +/** + * apes:agents — the subagent children, grouped by their durable parent + * session. Reads the same sessions feed and filters `origin === 'subagent'`. + */ +function AgentsView(props: TabComponentProps): ReactElement { + useApesLocale() + const list = useSessionList(props.ctx) + const children = Object.values(list.byId).filter((s) => s.origin === 'subagent') + if (children.length === 0) { + return ( + + +
{t('agents.hint')}
+
+ ) + } + // Group by parentId (undefined groups under a synthetic root). + const groups = new Map() + for (const s of children) { + const key = s.parentId ?? '' + const arr = groups.get(key) ?? [] + arr.push(s) + groups.set(key, arr) + } + const jump = (childSessionId: string) => props.onSubagentJump?.(childSessionId) + return ( + + {[...groups.entries()].map(([parentId, rows]) => ( +
+ {parentId && ( +
+ {list.byId[parentId]?.displayTitle ?? parentId} +
+ )} + {rows.map((s) => ( + jump(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }} + /> + ))} +
+ ))} +
+ ) +} + +/** apes:apps — placeholder (Phase 2 wires the workspace app feed). */ +function AppsView(): ReactElement { + useApesLocale() + return ( + + +
{t('apps.hint')}
+
+ ) +} + +/** apes:messages — placeholder (Phase 2 wires the message/reminder stream). */ +function MessagesView(): ReactElement { + useApesLocale() + return ( + + +
{t('messages.hint')}
+
+ ) +} + +/** + * apes:profile — account/runtime overview. Phase 1 shows the shell + a hint; + * Phase 2 reads model/provider from a host route (none exposed yet). + */ +function ProfileView(): ReactElement { + useApesLocale() + return ( + +
+
+ {t('profile.model')}: + {t('profile.unknown')} +
+
+ {t('profile.provider')}: + {t('profile.unknown')} +
+
+
{t('profile.hint')}
+
+ ) +} + +/** + * Build the five tab descriptors. Each `component` receives the real + * better-sidebar `TabComponentProps` (ctx / store / scope / tab / visible). + * `visible` is deliberately unused in Phase 1 (no polling yet); Phase 2 + * gates live feeds on it the way better-sidebar's subagent view does. + */ +function buildDescriptors(): TabDescriptor[] { + return [ + { + id: TABS.tasks, + title: () => t('tab.tasks.title'), + order: 90, + single: true, + component: (props) => , + }, + { + id: TABS.agents, + title: () => t('tab.agents.title'), + order: 91, + single: true, + component: (props) => , + }, + { + id: TABS.apps, + title: () => t('tab.apps.title'), + order: 92, + single: true, + component: () => , + }, + { + id: TABS.messages, + title: () => t('tab.messages.title'), + order: 93, + single: true, + component: () => , + }, + { + id: TABS.profile, + title: () => t('tab.profile.title'), + order: 94, + single: true, + component: () => , + }, + ] +} + +/** + * Register the i18n dictionaries plus all five tabs; returns the combined + * disposer (locale registrations + the five tab disposers). Safe to call + * from inside a cordis effect. + */ +function registerAll(ctx: ApesClientContext): () => void { + const better = ctx.betterSidebar + const disposers: (() => void)[] = [registerLocales(ctx.locale)] + for (const descriptor of buildDescriptors()) { + disposers.push(better.registerTab(descriptor)) + } + return () => { + for (const d of disposers) { + try { + d() + } catch { + // Already disposed (double-dispose on HMR) — ignore. + } + } + } } /** The cordis client entry. */ export function apply(ctx: ApesClientContext): void { - const better = ctx.betterSidebar - if (!better) { + if (!ctx.betterSidebar) { // 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: () => , - }), - ] - 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) + // cordis effect takes a zero-arg thunk; capture ctx in the closure. + // The body returns the combined disposer; cordis runs it on activation + // teardown (HMR-safe). + ctx.effect(() => registerAll(ctx)) } else { - // No effect API (non-cordis harness): register synchronously; disposal - // rides process teardown. - registerAll() + // No effect API (non-cordis harness / unit test): register synchronously; + // disposal rides process teardown. + registerAll(ctx) } } diff --git a/src/client/locales.ts b/src/client/locales.ts index 5260ff0..a201f76 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -22,10 +22,19 @@ const zh: Record = { 'tab.apps.title': '应用', 'tab.profile.title': '账户', 'tasks.empty': '暂无会话', + 'tasks.running': '运行中', + 'tasks.current': '当前', + 'tasks.subagent': '子代理', 'agents.empty': '暂无子代理', + 'agents.hint': '子代理会话会在此按父会话分组展示。', 'apps.empty': '暂无应用', + 'apps.hint': '工作区应用与外部入口将在此展示。', + 'messages.empty': '暂无消息', + 'messages.hint': '会话消息流与提醒将在此汇总。', 'profile.model': '模型', 'profile.provider': '提供商', + 'profile.unknown': '未知', + 'profile.hint': '账户与运行配置概览。', } const en: Record = { @@ -35,10 +44,19 @@ const en: Record = { 'tab.apps.title': 'Apps', 'tab.profile.title': 'Profile', 'tasks.empty': 'No sessions yet', + 'tasks.running': 'running', + 'tasks.current': 'current', + 'tasks.subagent': 'subagent', 'agents.empty': 'No subagents yet', + 'agents.hint': 'Subagent sessions appear here, grouped by parent.', 'apps.empty': 'No apps yet', + 'apps.hint': 'Workspace apps and external entry points appear here.', + 'messages.empty': 'No messages yet', + 'messages.hint': 'Session message flow and reminders are aggregated here.', 'profile.model': 'Model', 'profile.provider': 'Provider', + 'profile.unknown': 'Unknown', + 'profile.hint': 'Account and runtime configuration overview.', } const DICTS: Record> = { zh, en } diff --git a/src/context-types.ts b/src/context-types.ts index e77b242..83cde7b 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -1,109 +1,33 @@ /** - * Structural types for the cordis services apes-sidebar consumes, plus the - * Context face both halves share. + * Structural types for the cordis services apes-sidebar consumes. * - * 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. + * PHASE 1: the sidebar tab + session types now come from the REAL + * `dsh-better-sidebar` package (type-only imports in `src/client/index.tsx`): + * `TabDescriptor` / `TabComponentProps` / `BetterSidebarService` / + * `SidebarSessionSummary` / `SidebarSessionList`. The Phase 0 placeholder + * mirrors (`ApesSidebarTabDescriptor`, `ApesSidebarService`, + * `ApesSessionSummary`, `ApesSessionList`, `ApesSessionsService`, and the + * `ApesContext` intersection) were deleted — they are superseded by the + * peer's authoritative types and would drift. + * + * What remains here is the ONE service face apes-sidebar still defines + * itself: the locale runtime mirror (`ApesLocaleService`). `dsh-better-sidebar` + * publishes its own `SidebarLocaleService`, but locales.ts pre-dates the type + * import and stays on this local mirror to keep the i18n module independent + * of the peer (it must compile even when the peer is absent). * * 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 + * CLIENT-reachable declaration graph, 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 -} - -/** 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). */ +/** + * The client locale service face (mirror of dsh-client-locale's LocaleRuntime). + * Only the slices apes-sidebar's i18n module touches: snapshot read, snapshot + * subscription, and per-namespace dictionary registration. + */ export interface ApesLocaleService { getSnapshot(): { active: string } subscribe(fn: () => void): () => void register(ns: string, locale: string, dict: Record): () => void } - -/** The client slots service face (register returns the disposer). */ -export interface ApesSlotsService { - register(options: Record, 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) => 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 -} diff --git a/tests/tabs.spec.ts b/tests/tabs.spec.ts new file mode 100644 index 0000000..b452f8c --- /dev/null +++ b/tests/tabs.spec.ts @@ -0,0 +1,156 @@ +/** + * Phase 1 tab-registration spec: mounts the CLIENT half against a mock + * better-sidebar registry and verifies the five `apes:*` tabs register with + * the right descriptors, the disposer tears them down (HMR-safe), and a + * missing `betterSidebar` peer degrades without throwing. + * + * The React components are NOT rendered here (Phase 0 testing convention: + * node vitest environment, no DOM). The descriptors' `component` field is + * asserted to be a function; the JSX it returns is only evaluated when the + * host actually mounts the tab, which a live DSH shell does (Phase 1+ mount + * smoke). What this spec pins is the REGISTRATION CONTRACT: the ids, the + * title/order/single shape, and the disposer lifecycle. + * + * The mock registry records every registered descriptor and returns a + * disposer that removes it — mirroring the real better-sidebar + * `registerTab` (returns `() => void`, invoked by cordis on fiber teardown). + */ +import { describe, expect, it } from 'vitest' +import { apply } from '../src/client/index.tsx' + +/** The five tab ids this plugin owns (the + menu contract). */ +const EXPECTED_IDS = ['apes:tasks', 'apes:agents', 'apes:apps', 'apes:messages', 'apes:profile'] + +/** One registered descriptor shape the mock records (the fields we assert). */ +interface Registered { + id: string + title: string | (() => string) + order?: number + single?: boolean + component: unknown +} + +/** + * A mock better-sidebar registry: `registerTab` records the descriptor and + * returns a disposer that removes it (the real service's contract). + */ +function makeMockRegistry() { + const registered: Registered[] = [] + return { + registry: { + registerTab: (descriptor: Registered) => { + registered.push(descriptor) + return () => { + const i = registered.findIndex((r) => r.id === descriptor.id) + if (i !== -1) registered.splice(i, 1) + } + }, + getTabs: () => registered, + subscribe: () => () => undefined, + }, + registered, + } +} + +/** A no-op locale service (registerLocales only needs register/getSnapshot/subscribe). */ +function makeMockLocale() { + return { + getSnapshot: () => ({ active: 'zh' }), + subscribe: () => () => undefined, + register: () => () => undefined, + } +} + +/** + * Drive a cordis effect: invoke the effect body, capture its returned + * disposer, and expose `teardown()` so a test can simulate fiber unmount. + */ +function makeEffectHarness() { + let disposer: (() => void) | undefined + return { + effect: (fn: () => void | (() => void)) => { + const result = fn() + if (typeof result === 'function') disposer = result + return () => disposer?.() + }, + teardown: () => disposer?.(), + } +} + +describe('apes-sidebar client tab registration (Phase 1)', () => { + it('registers all five apes:* tabs into the better-sidebar registry', () => { + const { registry, registered } = makeMockRegistry() + const eff = makeEffectHarness() + apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never) + + expect(registered.map((r) => r.id).sort()).toEqual([...EXPECTED_IDS].sort()) + expect(registry.getTabs()).toHaveLength(5) + }) + + it('gives every tab a title function, an ascending order, single-instance, and a component fn', () => { + const { registered } = makeMockRegistry() + const eff = makeEffectHarness() + apply({ betterSidebar: makeMockRegistry().registry, locale: makeMockLocale(), ...eff } as never) + // Re-run on the registry whose descriptors we captured. + const { registry, registered: reg2 } = makeMockRegistry() + const eff2 = makeEffectHarness() + apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff2 } as never) + + for (const d of reg2) { + expect(EXPECTED_IDS).toContain(d.id) + // title is a function (i18n-friendly) and resolves to a non-empty string. + expect(typeof d.title === 'function' ? typeof d.title() : typeof d.title).toBe('string') + if (typeof d.title === 'function') expect(d.title().length).toBeGreaterThan(0) + // order is present and numeric. + expect(typeof d.order).toBe('number') + // single-instance sugar (each apes:* tab is a singleton panel). + expect(d.single).toBe(true) + // component is a render function. + expect(typeof d.component).toBe('function') + } + // orders are strictly ascending in registration order. + const orders = reg2.map((d) => d.order as number) + for (let i = 1; i < orders.length; i++) { + expect(orders[i]!).toBeGreaterThan(orders[i - 1]!) + } + }) + + it('the combined disposer removes all five tabs (HMR-safe teardown)', () => { + const { registry } = makeMockRegistry() + const eff = makeEffectHarness() + apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never) + expect(registry.getTabs()).toHaveLength(5) + + // Simulate fiber unmount: cordis invokes the effect's returned disposer. + eff.teardown() + expect(registry.getTabs()).toHaveLength(0) + + // A second teardown is a safe no-op (double-dispose is swallowed). + expect(() => eff.teardown()).not.toThrow() + }) + + it('does not register when betterSidebar is absent (bare DSH degrades)', () => { + const eff = makeEffectHarness() + expect(() => apply({ locale: makeMockLocale(), ...eff } as never)).not.toThrow() + // No effect was run (the guard returns before ctx.effect). + // The effect harness captured nothing. + expect(eff.effect).toBeTypeOf('function') + }) + + it('registers synchronously when no effect API is present (non-cordis harness)', () => { + const { registry } = makeMockRegistry() + // No `effect` on the context → apply registers synchronously. + apply({ betterSidebar: registry, locale: makeMockLocale() } as never) + expect(registry.getTabs()).toHaveLength(5) + }) + + it('tab ids follow the apes:* namespace (never wb:* or builtin ids)', () => { + const { registered } = makeMockRegistry() + const eff = makeEffectHarness() + apply({ betterSidebar: makeMockRegistry().registry, locale: makeMockLocale(), ...eff } as never) + for (const d of registered) { + expect(d.id.startsWith('apes:')).toBe(true) + expect(d.id).not.toMatch(/^wb:/) + } + }) +})