feat(apes-sidebar): Phase 2 — per-tab state(tasks showInactive 持久化)

- useTasksPrefs:ctx.settings.register('apesSidebar.tasks') namespace,
  uSES 观察 + update 写回,applies:'live' 跨 reload 持久化
- tasks 面板加 showInactive 开关:OFF 时只渲染 running 会话
- filterTasksRows 纯函数导出(hook 需渲染,过滤决策单测锁定)
- locales:tasks.showInactive zh/en
- tests/tabs.spec.ts:+4 个 per-tab state 过滤测试(25→29)
- AGENTS.md:Phase 2 更新(per-tab state 落地,scratch 冒烟 +
  apps·msgs·profile feed 延后——better-sidebar 无对应数据源 + 无 dsh CLI)

门禁:typecheck rc=0 · build rc=0 · test 29/29
This commit is contained in:
figmar
2026-09-04 20:11:06 +08:00
parent 856d744139
commit 48e16c73c7
4 changed files with 145 additions and 27 deletions
+9 -6
View File
@@ -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.
+100 -20
View File
@@ -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<TasksPrefs>): Promise<void>
} | undefined
function getTasksPrefsHandle(ctx: Context) {
if (tasksPrefsHandle) return tasksPrefsHandle
if (!ctx.settings?.register) return undefined
tasksPrefsHandle = ctx.settings.register<TasksPrefs>(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<T extends { running?: boolean }>(
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 (
<div data-apes-panel style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 10, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t(titleKey)}</div>
@@ -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 (
<Panel titleKey="tab.tasks.title">
<Empty key="tasks.empty" />
</Panel>
)
}
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 (
<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' } }}
{/* The showInactive toggle — the persisted per-tab state. */}
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 11,
opacity: 0.8,
marginBottom: 6,
cursor: 'pointer',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={prefs.showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
style={{ margin: 0 }}
/>
))}
{t('tasks.showInactive')}
</label>
{rows.length === 0 ? (
<Empty key="tasks.empty" />
) : (
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>
)
}
+2
View File
@@ -25,6 +25,7 @@ const zh: Record<string, string> = {
'tasks.running': '运行中',
'tasks.current': '当前',
'tasks.subagent': '子代理',
'tasks.showInactive': '显示非运行会话',
'agents.empty': '暂无子代理',
'agents.hint': '子代理会话会在此按父会话分组展示。',
'apps.empty': '暂无应用',
@@ -47,6 +48,7 @@ const en: Record<string, string> = {
'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',
+34 -1
View File
@@ -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)
})
})