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:
@@ -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",
|
||||
|
||||
Generated
+3
-4
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:/)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user