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()
+ }
+ })
+})