feat(apes-sidebar): Phase 1 — 5 个 apes:* tab 真实注册

- 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 进程)
This commit is contained in:
figmar
2026-09-04 17:42:57 +08:00
parent 0e91e99f49
commit f86c1d82e6
6 changed files with 509 additions and 202 deletions
+1
View File
@@ -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",
+3 -4
View File
@@ -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
+310 -101
View File
@@ -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<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
}
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 (
<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
data-apes-row
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 6px',
fontSize: 12,
borderRadius: 4,
}}
>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title}
</span>
{running && <Chip label={t('tasks.running')} tone="run" />}
{current && <Chip label={t('tasks.current')} tone="cur" />}
{subagent && <Chip label={t('tasks.subagent')} tone="sub" />}
</div>
)
}
/** 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 (
<span
style={{
flexShrink: 0,
fontSize: 10,
lineHeight: '16px',
padding: '0 5px',
borderRadius: 8,
background: bg,
color: fg,
whiteSpace: 'nowrap',
}}
>
{label}
</span>
)
}
/** A panel shell: a title bar + a scrollable body. */
function Panel({ titleKey, children }: { titleKey: string; children: ReactElement | ReactElement[] }): ReactElement {
return (
<div data-apes-panel style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 10, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t(titleKey)}</div>
<div style={{ flex: 1, overflowY: 'auto' }}>{children}</div>
</div>
)
}
/** The empty-state line every panel falls back to. */
function Empty({ key: key }: { key: string }): ReactElement {
return <div style={{ opacity: 0.55, fontSize: 12 }}>{t(key)}</div>
}
/**
* 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 (
<Panel titleKey="tab.tasks.title">
<Empty key="tasks.empty" />
</Panel>
)
}
const open = (id: string) => props.ctx.sessions.open?.(id)
return (
<Panel titleKey="tab.tasks.title">
{rows.map((s) => (
<SessionRow
key={s.id}
title={s.displayTitle}
running={s.running}
current={s.id === list.current}
subagent={s.origin === 'subagent'}
// The row is a button only when a host `open` is available.
{...{ onClick: () => open(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }}
/>
))}
</Panel>
)
}
/**
* 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 (
<Panel titleKey="tab.agents.title">
<Empty key="agents.empty" />
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('agents.hint')}</div>
</Panel>
)
}
// Group by parentId (undefined groups under a synthetic root).
const groups = new Map<string, typeof children>()
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 (
<Panel titleKey="tab.agents.title">
{[...groups.entries()].map(([parentId, rows]) => (
<div key={parentId || 'root'} style={{ marginBottom: 10 }}>
{parentId && (
<div style={{ opacity: 0.5, fontSize: 11, marginBottom: 4 }}>
{list.byId[parentId]?.displayTitle ?? parentId}
</div>
)}
{rows.map((s) => (
<SessionRow
key={s.id}
title={s.displayTitle}
running={s.running}
subagent
{...{ onClick: () => jump(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }}
/>
))}
</div>
))}
</Panel>
)
}
/** apes:apps — placeholder (Phase 2 wires the workspace app feed). */
function AppsView(): ReactElement {
useApesLocale()
return (
<Panel titleKey="tab.apps.title">
<Empty key="apps.empty" />
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('apps.hint')}</div>
</Panel>
)
}
/** apes:messages — placeholder (Phase 2 wires the message/reminder stream). */
function MessagesView(): ReactElement {
useApesLocale()
return (
<Panel titleKey="tab.messages.title">
<Empty key="messages.empty" />
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('messages.hint')}</div>
</Panel>
)
}
/**
* 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 (
<Panel titleKey="tab.profile.title">
<div style={{ fontSize: 12, marginBottom: 8 }}>
<div>
<span style={{ opacity: 0.55 }}>{t('profile.model')}: </span>
<span>{t('profile.unknown')}</span>
</div>
<div>
<span style={{ opacity: 0.55 }}>{t('profile.provider')}: </span>
<span>{t('profile.unknown')}</span>
</div>
</div>
<div style={{ opacity: 0.45, fontSize: 12 }}>{t('profile.hint')}</div>
</Panel>
)
}
/**
* 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) => <TasksView {...props} />,
},
{
id: TABS.agents,
title: () => t('tab.agents.title'),
order: 91,
single: true,
component: (props) => <AgentsView {...props} />,
},
{
id: TABS.apps,
title: () => t('tab.apps.title'),
order: 92,
single: true,
component: () => <AppsView />,
},
{
id: TABS.messages,
title: () => t('tab.messages.title'),
order: 93,
single: true,
component: () => <MessagesView />,
},
{
id: TABS.profile,
title: () => t('tab.profile.title'),
order: 94,
single: true,
component: () => <ProfileView />,
},
]
}
/**
* 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: () => <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)
// 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)
}
}
+18
View File
@@ -22,10 +22,19 @@ const zh: Record<string, string> = {
'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<string, string> = {
@@ -35,10 +44,19 @@ const en: Record<string, string> = {
'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<string, Record<string, string>> = { zh, en }
+21 -97
View File
@@ -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<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). */
/**
* 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<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
}
+156
View File
@@ -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:/)
}
})
})