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
+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',