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:
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user