diff --git a/AGENTS.md b/AGENTS.md index 682850b..467a7c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/src/client/index.tsx b/src/client/index.tsx index 2dec064..d9182a2 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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) => , }, { @@ -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) => , }, { diff --git a/tests/tabs.spec.ts b/tests/tabs.spec.ts index b452f8c..995fabc 100644 --- a/tests/tabs.spec.ts +++ b/tests/tabs.spec.ts @@ -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) { + 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() + } + }) +})