feat(apes-sidebar): Phase 2 — badge 计数 + auto-collapse(visible 门控暂停)

- apes:tasks badge = running 会话数;apes:agents badge = subagent 数
  (纯 (ctx)=>number|null 函数,0→null 隐藏,host 封顶 99+)
- useSessionList 接受 visible:非活动 tab 切 no-op subscribe,
  停止 live session-list churn 重渲染隐藏面板(better-sidebar
  'live views pause otherwise' 契约 = auto-collapse)
- tests/tabs.spec.ts:新增 5 个 badge 契约测试(6→11),mock
  sessions feed 直测 badge 纯函数
- AGENTS.md:Phase 2 标 partial(per-tab state / scratch 冒烟 /
  apps·messages·profile feed 延后,better-sidebar 0.18.0 无相应 API/数据源)

门禁:typecheck rc=0 · build rc=0(client.js 15.74→20K)· test 25/25
This commit is contained in:
figmar
2026-09-04 20:04:47 +08:00
parent e4dcd1118e
commit 856d744139
3 changed files with 141 additions and 10 deletions
+7 -3
View File
@@ -132,6 +132,10 @@ cordis.patch.yml # plugin registration into the DSH profile
graceful degrade when `betterSidebar` is absent. Unit smoke: `tests/tabs.spec.ts`
(6 tests). The cordis scratch mount smoke (real DSH host process) is deferred
to Phase 2 — it needs a live host and is outside the unit gate.
- **Phase 2 (later)**: badge/reminder counts, auto-collapse, per-tab state,
the cordis scratch mount smoke (live DSH host), and wiring `apps` /
`messages` / `profile` to real host feeds (currently placeholders).
- **Phase 2 (partial)**: badge counts + auto-collapse (visible-gated
subscription pause) landed for `apes:tasks` / `apes:agents`. Per-tab state,
the cordis scratch mount smoke, and wiring `apps` / `messages` / `profile`
to real feeds are **deferred** — better-sidebar 0.18.0 exposes no
meta-write API for plugin tab state, no workspace-apps / message-stream /
model-provider host feeds for those three panels, and no local `dsh` CLI to
drive a live scratch mount. See docs/ for the deferral rationale.
+56 -7
View File
@@ -49,10 +49,43 @@ const TABS = {
*/
type ApesClientContext = Context
/** Read one client session list snapshot uSES-safely. */
function useSessionList(ctx: Context) {
/**
* Read the client session list uSES-safely, with `visible`-gated pausing
* (Phase 2 auto-collapse). `useSyncExternalStore` re-subscribes whenever the
* `subscribe` identity changes, so passing a no-op subscribe while the tab is
* not the active-open one stops live session-list churn from re-rendering a
* hidden panel — exactly better-sidebar's "live views pause otherwise"
* contract. The snapshot itself is always read (cheap), so a hidden tab still
* shows its last-known data when re-shown.
*/
const NOOP_SUBSCRIBE = () => () => {}
function useSessionList(ctx: Context, visible: boolean) {
const list = ctx.sessions.list
return useSyncExternalStore(list.subscribe, list.getSnapshot, list.getSnapshot)
return useSyncExternalStore(
visible ? list.subscribe : NOOP_SUBSCRIBE,
list.getSnapshot,
list.getSnapshot,
)
}
/** Count of sessions whose agent is currently running (badge source). */
function runningCount(ctx: Context): number {
const { byId } = ctx.sessions.list.getSnapshot()
let n = 0
for (const id in byId) {
if (byId[id]?.running) n++
}
return n
}
/** Count of subagent-origin sessions (badge source for apes:agents). */
function subagentCount(ctx: Context): number {
const { byId } = ctx.sessions.list.getSnapshot()
let n = 0
for (const id in byId) {
if (byId[id]?.origin === 'subagent') n++
}
return n
}
/** A row in a list view: id, title, and optional flag chips. */
@@ -132,7 +165,7 @@ function Empty({ key: key }: { key: string }): ReactElement {
*/
function TasksView(props: TabComponentProps): ReactElement {
useApesLocale()
const list = useSessionList(props.ctx)
const list = useSessionList(props.ctx, props.visible)
const rows = Object.values(list.byId)
if (rows.length === 0) {
return (
@@ -165,7 +198,7 @@ function TasksView(props: TabComponentProps): ReactElement {
*/
function AgentsView(props: TabComponentProps): ReactElement {
useApesLocale()
const list = useSessionList(props.ctx)
const list = useSessionList(props.ctx, props.visible)
const children = Object.values(list.byId).filter((s) => s.origin === 'subagent')
if (children.length === 0) {
return (
@@ -256,8 +289,14 @@ function ProfileView(): ReactElement {
/**
* 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.
*
* Phase 2 additions:
* - `badge` on tasks/agents: a cheap count pill (running sessions / subagent
* count). Called on every tab-bar render, so it reads the live sessions
* snapshot synchronously and returns `null` when the count is 0 (hidden).
* - `visible` is now honored by the live views: `useSessionList` swaps to a
* no-op subscribe when the tab is not the active-open one (auto-collapse —
* hidden panels stop re-rendering on session-list churn).
*/
function buildDescriptors(): TabDescriptor[] {
return [
@@ -266,6 +305,11 @@ function buildDescriptors(): TabDescriptor[] {
title: () => t('tab.tasks.title'),
order: 90,
single: true,
// Running-session count pill (99+ capped by the host; 0 → hidden).
badge: (ctx) => {
const n = runningCount(ctx)
return n === 0 ? null : n
},
component: (props) => <TasksView {...props} />,
},
{
@@ -273,6 +317,11 @@ function buildDescriptors(): TabDescriptor[] {
title: () => t('tab.agents.title'),
order: 91,
single: true,
// Active-subagent count pill (0 → hidden).
badge: (ctx) => {
const n = subagentCount(ctx)
return n === 0 ? null : n
},
component: (props) => <AgentsView {...props} />,
},
{
+78
View File
@@ -28,6 +28,7 @@ interface Registered {
order?: number
single?: boolean
component: unknown
badge?: (ctx: unknown, scope: unknown, state: unknown) => string | number | null | undefined
}
/**
@@ -154,3 +155,80 @@ describe('apes-sidebar client tab registration (Phase 1)', () => {
}
})
})
/**
* Build a mock `ctx.sessions.list.getSnapshot()` feed for badge assertions.
* `byId` maps session id → a summary with optional `running` / `origin`.
*/
function makeMockSessionsCtx(byId: Record<string, { running?: boolean; origin?: string }>) {
const snapshot = { current: undefined as string | undefined, byId }
return {
sessions: {
list: {
getSnapshot: () => snapshot,
subscribe: () => () => undefined,
},
},
}
}
/**
* Phase 2 badge contract: `apes:tasks` shows the running-session count,
* `apes:agents` shows the subagent count, and BOTH hide (return null) when
* their count is 0. The badge is a pure `(ctx) => …` function, so it is
* exercised directly against a mock sessions feed — no DOM, no rendering.
*/
describe('apes-sidebar tab badges (Phase 2)', () => {
/** Register into a fresh mock registry and return the captured descriptors. */
function registeredDescriptors() {
const { registry, registered } = makeMockRegistry()
const eff = makeEffectHarness()
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never)
return registered
}
function badgeOf(registered: Registered[], id: string) {
return registered.find((d) => d.id === id)?.badge
}
it('apes:tasks badge is the running-session count (2 running → 2)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:tasks')
expect(badge).toBeTypeOf('function')
const ctx = makeMockSessionsCtx({
a: { running: true },
b: { running: true },
c: { running: false },
})
expect(badge!(ctx as never, {} as never, {} as never)).toBe(2)
})
it('apes:tasks badge is null when nothing is running (hidden pill)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:tasks')
const ctx = makeMockSessionsCtx({ a: { running: false }, b: {} })
expect(badge!(ctx as never, {} as never, {} as never)).toBeNull()
})
it('apes:agents badge is the subagent count (1 subagent → 1)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:agents')
expect(badge).toBeTypeOf('function')
const ctx = makeMockSessionsCtx({
parent: {},
child: { origin: 'subagent' },
other: { running: true }, // running but NOT a subagent → not counted
})
expect(badge!(ctx as never, {} as never, {} as never)).toBe(1)
})
it('apes:agents badge is null when there are no subagents (hidden pill)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:agents')
const ctx = makeMockSessionsCtx({ a: {}, b: { running: true } })
expect(badge!(ctx as never, {} as never, {} as never)).toBeNull()
})
it('apps/messages/profile have NO badge (information placeholders, no count)', () => {
const registered = registeredDescriptors()
for (const id of ['apes:apps', 'apes:messages', 'apes:profile']) {
expect(badgeOf(registered, id)).toBeUndefined()
}
})
})