diff --git a/AGENTS.md b/AGENTS.md index 467a7c9..efb32ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,9 +133,12 @@ cordis.patch.yml # plugin registration into the DSH profile (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 (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. + subscription pause) + per-tab state landed for the information panels. + `apes:tasks` / `apes:agents` show live count pills (running / subagent); + `apes:tasks` persists a `showInactive` preference through the settings + namespace (`apesSidebar.tasks`, `applies: 'live'`). The cordis scratch mount + smoke and wiring `apps` / `messages` / `profile` to real feeds are **deferred** + — better-sidebar 0.18.0 exposes no workspace-apps / message-stream / + model-provider host feeds for those three panels, and a live scratch mount + needs a `dsh` CLI (absent locally) plus a DOM harness the Phase 0 convention + deliberately avoids. See docs/ for the deferral rationale. diff --git a/src/client/index.tsx b/src/client/index.tsx index d9182a2..77121e7 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -23,7 +23,7 @@ * - locale: the DSH locale runtime (dictionaries register under apesSidebar) * - sessions: the client session list feed (drives tasks + agents) */ -import { useSyncExternalStore, type ReactElement } from 'react' +import { useSyncExternalStore, type ReactElement, type ReactNode } from 'react' import type { TabDescriptor, TabComponentProps, Context } from 'dsh-better-sidebar' import { t, registerLocales, useApesLocale } from './locales.ts' @@ -88,6 +88,65 @@ function subagentCount(ctx: Context): number { return n } +/** + * Per-tab state (Phase 2): the apes:tasks panel's `showInactive` preference, + * persisted across reloads through better-sidebar's settings service + * (`ctx.settings.register`). The namespace handle is registered ONCE at + * module scope (registering per render would re-stack schemas); the hook + * reads it uSES-safely and writes back via `update`. + * + * Degrades to the default (`showInactive: true`) when `ctx.settings` is + * absent — a bare DSH profile or a unit-test fake that omits the service. + */ +const TASKS_NS = 'apesSidebar.tasks' +interface TasksPrefs { + showInactive: boolean +} +let tasksPrefsHandle: { + get(): TasksPrefs + watch(cb: (next: TasksPrefs, prev: TasksPrefs) => void): () => void + update(patch: Partial): Promise +} | undefined + +function getTasksPrefsHandle(ctx: Context) { + if (tasksPrefsHandle) return tasksPrefsHandle + if (!ctx.settings?.register) return undefined + tasksPrefsHandle = ctx.settings.register(TASKS_NS, null, { + base: { showInactive: true }, + applies: 'live', + }) + return tasksPrefsHandle +} + +const DEFAULT_TASKS_PREFS: TasksPrefs = { showInactive: true } + +/** + * Pure row filter for the tasks list: when `showInactive` is false, only + * running sessions render. Exported for unit testing (the hook itself needs + * a render, but this decision is a plain function). + */ +export function filterTasksRows( + all: T[], + showInactive: boolean, +): T[] { + return showInactive ? all : all.filter((s) => s.running) +} + +function useTasksPrefs(ctx: Context): { prefs: TasksPrefs; setShowInactive: (v: boolean) => void } { + const handle = getTasksPrefsHandle(ctx) + const prefs = useSyncExternalStore( + handle ? (cb) => handle.watch(cb) : NOOP_SUBSCRIBE, + handle ? handle.get : () => DEFAULT_TASKS_PREFS, + handle ? handle.get : () => DEFAULT_TASKS_PREFS, + ) + return { + prefs, + setShowInactive: (v: boolean) => { + void handle?.update({ showInactive: v }) + }, + } +} + /** A row in a list view: id, title, and optional flag chips. */ function SessionRow({ title, @@ -145,7 +204,7 @@ function Chip({ label, tone }: { label: string; tone: 'run' | 'cur' | 'sub' }): } /** A panel shell: a title bar + a scrollable body. */ -function Panel({ titleKey, children }: { titleKey: string; children: ReactElement | ReactElement[] }): ReactElement { +function Panel({ titleKey, children }: { titleKey: string; children: ReactNode }): ReactElement { return (
{t(titleKey)}
@@ -166,28 +225,49 @@ function Empty({ key: key }: { key: string }): ReactElement { function TasksView(props: TabComponentProps): ReactElement { useApesLocale() const list = useSessionList(props.ctx, props.visible) - const rows = Object.values(list.byId) - if (rows.length === 0) { - return ( - - - - ) - } + const { prefs, setShowInactive } = useTasksPrefs(props.ctx) + // Per-tab state: when showInactive is off, only running sessions render. + const all = Object.values(list.byId) + const rows = prefs.showInactive ? all : all.filter((s) => s.running) const open = (id: string) => props.ctx.sessions.open?.(id) return ( - {rows.map((s) => ( - open(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }} + {/* The showInactive toggle — the persisted per-tab state. */} + + {rows.length === 0 ? ( + + ) : ( + rows.map((s) => ( + open(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }} + /> + )) + )} ) } diff --git a/src/client/locales.ts b/src/client/locales.ts index a201f76..82e007c 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -25,6 +25,7 @@ const zh: Record = { 'tasks.running': '运行中', 'tasks.current': '当前', 'tasks.subagent': '子代理', + 'tasks.showInactive': '显示非运行会话', 'agents.empty': '暂无子代理', 'agents.hint': '子代理会话会在此按父会话分组展示。', 'apps.empty': '暂无应用', @@ -47,6 +48,7 @@ const en: Record = { 'tasks.running': 'running', 'tasks.current': 'current', 'tasks.subagent': 'subagent', + 'tasks.showInactive': 'Show inactive sessions', 'agents.empty': 'No subagents yet', 'agents.hint': 'Subagent sessions appear here, grouped by parent.', 'apps.empty': 'No apps yet', diff --git a/tests/tabs.spec.ts b/tests/tabs.spec.ts index 995fabc..1016dd9 100644 --- a/tests/tabs.spec.ts +++ b/tests/tabs.spec.ts @@ -16,7 +16,7 @@ * `registerTab` (returns `() => void`, invoked by cordis on fiber teardown). */ import { describe, expect, it } from 'vitest' -import { apply } from '../src/client/index.tsx' +import { apply, filterTasksRows } 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'] @@ -232,3 +232,36 @@ describe('apes-sidebar tab badges (Phase 2)', () => { } }) }) + +/** + * Phase 2 per-tab state: the apes:tasks `showInactive` filter. When the user + * toggles the preference off, only running sessions render. This is a pure + * decision function (the hook's uSES↔settings wiring needs a render, but the + * filter itself is plain and unit-testable). + */ +describe('apes:tasks showInactive per-tab state filter (Phase 2)', () => { + const sessions = [ + { id: 'a', running: true }, + { id: 'b', running: false }, + { id: 'c', running: true }, + { id: 'd' }, // running undefined → treated as not running + ] + + it('showInactive=true renders every session', () => { + expect(filterTasksRows(sessions, true)).toHaveLength(4) + }) + + it('showInactive=false renders only running sessions (2 of 4)', () => { + const rows = filterTasksRows(sessions, false) + expect(rows).toHaveLength(2) + expect(rows.map((s) => s.id)).toEqual(['a', 'c']) + }) + + it('showInactive=false on an all-running list keeps everything', () => { + expect(filterTasksRows([{ running: true }, { running: true }], false)).toHaveLength(2) + }) + + it('showInactive=false on an all-inactive list yields empty', () => { + expect(filterTasksRows([{ running: false }, {}], false)).toHaveLength(0) + }) +})