diff --git a/src/client/SubagentView.module.css b/src/client/SubagentView.module.css index 14ddd9b3..de5892e6 100644 --- a/src/client/SubagentView.module.css +++ b/src/client/SubagentView.module.css @@ -545,3 +545,30 @@ .jobsPaneError { color: var(--dsw-alias-state-error-primary); } + +/* "Active only" toolbar row under the main agent card: collapses idle + topology branches so busy trees surface just the running subagents. */ +.subagentToolbar { + padding: 2px 10px 4px; +} + +.subagentToggle { + border: none; + border-radius: 999px; + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + font: var(--dsw-font-xxxs-11); + padding: 2px 10px; +} + +.subagentToggle:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.subagentToggleOn, +.subagentToggleOn:hover { + background: var(--dsw-alias-interactive-bg-active); + color: var(--dsw-alias-label-primary); +} diff --git a/src/client/SubagentView.tsx b/src/client/SubagentView.tsx index 5ea2f744..2f681474 100644 --- a/src/client/SubagentView.tsx +++ b/src/client/SubagentView.tsx @@ -40,6 +40,7 @@ import { countSubagentDescendants, isSideThreadSummary, rootAncestor, + runningVisibilitySet, } from './subagent-detect.ts' import { type LastActivity } from '../subagent-activity.ts' import { SIDE_LABEL_PREFIX } from '../sidechat-core.ts' @@ -65,6 +66,26 @@ const ARGS_PREVIEW = 60 const JOB_POLL_MS = 2000 /** How long the kill button stays armed before it needs re-confirming. */ const JOB_KILL_ARM_MS = 3000 +/** localStorage key of the "active only" toggle (a pure view preference). */ +const ACTIVE_ONLY_KEY = 'dsh-better-sidebar.subagent.activeOnly' + +/** Read the persisted "active only" preference; failures fall back to off. */ +function readActiveOnlyPref(): boolean { + try { + return globalThis.localStorage?.getItem(ACTIVE_ONLY_KEY) === '1' + } catch { + return false + } +} + +/** Persist the "active only" preference; storage failures are non-fatal. */ +function writeActiveOnlyPref(value: boolean): void { + try { + globalThis.localStorage?.setItem(ACTIVE_ONLY_KEY, value ? '1' : '0') + } catch { + // Private mode / quota errors must never break the page. + } +} /** The direct subagent children of one parent (durable `origin` rows; * Side Chat threads ride the same origin but are tab-strip conversations, @@ -240,6 +261,13 @@ interface RowsProps { currentSessionId: string /** The batch live-preview map (child id → latest activity). */ live: Readonly> + /** + * Active-only filter (`null` = off): session ids worth showing — running + * nodes plus ancestors with running descendants. Entries outside the set + * (and diagnostics) are hidden; `entry.activity === 'running'` always + * passes so a catalog row never lags its own summary flag. + */ + activeKeepSet: ReadonlySet | null openChild: (address: SidebarSubagentAddress) => void refresh: (parentSessionId: string) => void } @@ -247,6 +275,7 @@ interface RowsProps { /** Render one topology level; branches are always expanded (lazy catalogs). */ function CatalogRows({ parentSessionId, catalog, catalogs, byId, level, currentSessionId, live, + activeKeepSet, openChild, refresh, }: RowsProps) { const emptyLoading = catalog?.state === 'loading' && catalog.entries.length === 0 @@ -255,6 +284,10 @@ function CatalogRows({ // strip owns them). Legacy threads created before the descriptor fix still // arrive as corrupt diagnostics; they are recognized by summary title. const visibleEntries = (catalog?.entries ?? []).filter((entry) => { + if (activeKeepSet !== null && !(activeKeepSet.has(entry.id) + || (entry.kind === 'child' && entry.activity === 'running'))) { + return false + } if (entry.kind === 'child') return !(entry.label?.startsWith(SIDE_LABEL_PREFIX) ?? false) return !(byId[entry.id]?.displayTitle.startsWith(SIDE_LABEL_PREFIX) ?? false) }) @@ -361,6 +394,7 @@ function CatalogRows({ level={level + 1} currentSessionId={currentSessionId} live={live} + activeKeepSet={activeKeepSet} openChild={openChild} refresh={refresh} /> @@ -664,6 +698,22 @@ export function SubagentView(props: { const rootSummary = rootId === undefined ? undefined : byId[rootId] const live = useSubagentLive(rootId, active) + // "Active only" view mode: hide idle topology branches so a busy tree + // surfaces just the running subagents (and the ancestors that own them). + // A pure view preference — persisted client-side, defaulting to off. + const [activeOnly, setActiveOnly] = useState(readActiveOnlyPref) + const toggleActiveOnly = useCallback(() => { + setActiveOnly(value => { + const next = !value + writeActiveOnlyPref(next) + return next + }) + }, []) + const activeKeepSet = useMemo( + () => (rootId === undefined || !activeOnly ? null : runningVisibilitySet(byId, rootId)), + [byId, rootId, activeOnly], + ) + /** Catalog owners currently consuming live membership updates. */ const observedRef = useRef(new Set()) @@ -844,6 +894,19 @@ export function SubagentView(props: { )} + {rootId !== undefined && totals.count > 0 && ( +
+ +
+ )} {rootId !== undefined && (
{summaryBackedLoading && ( @@ -858,6 +921,7 @@ export function SubagentView(props: { level={1} currentSessionId={sessionId} live={live} + activeKeepSet={activeKeepSet} openChild={openChild} refresh={refresh} /> diff --git a/src/client/locales-ar.ts b/src/client/locales-ar.ts index 34310111..5c5e919c 100644 --- a/src/client/locales-ar.ts +++ b/src/client/locales-ar.ts @@ -277,6 +277,8 @@ export const ar: Record = { subagent: 'المهام', openSubagent: 'المهام', subagentMainAgent: 'الوكيل الرئيسي', + subagentActiveOnly: "للنشطين فقط", + subagentActiveOnlyTitle: "طيّ الوكلاء الفرعيين الخاملين؛ إظهار الوكلاء قيد التشغيل وسلسلة آبائهم فقط", subagentEmpty: 'لا وكلاء فرعيون', subagentEmptyDesc: 'الوكلاء الفرعيون المُنتَجون تحت الوكيل الرئيسي سيظهرون هنا', subagentRunning: 'قيد التشغيل', diff --git a/src/client/locales-de.ts b/src/client/locales-de.ts index a73124e6..b66b455a 100644 --- a/src/client/locales-de.ts +++ b/src/client/locales-de.ts @@ -262,6 +262,8 @@ export const de: Record = { subagent: 'Aufgaben', openSubagent: 'Aufgaben', subagentMainAgent: 'Hauptagent', + subagentActiveOnly: "Nur aktive", + subagentActiveOnlyTitle: "Untätige Subagenten einklappen; nur laufende und deren Vorfahren anzeigen", subagentEmpty: 'Keine Subagenten', subagentEmptyDesc: 'Subagenten, die unter dem Hauptagenten erzeugt werden, erscheinen hier', subagentRunning: 'Läuft', diff --git a/src/client/locales-fr.ts b/src/client/locales-fr.ts index eb645a69..3fc241d4 100644 --- a/src/client/locales-fr.ts +++ b/src/client/locales-fr.ts @@ -269,6 +269,8 @@ export const fr: Record = { subagent: 'Gestion des tâches', openSubagent: 'Gestion des tâches', subagentMainAgent: 'Agent principal', + subagentActiveOnly: "Actifs uniquement", + subagentActiveOnlyTitle: "Replier les sous-agents inactifs ; garder les actifs et leurs ancêtres", subagentEmpty: 'Aucun sous-agent pour l’instant', subagentEmptyDesc: 'Les sous-agents dérivés de l’agent principal actuel s’afficheront ici', subagentRunning: 'En cours', diff --git a/src/client/locales-hi.ts b/src/client/locales-hi.ts index cf98156a..207d077d 100644 --- a/src/client/locales-hi.ts +++ b/src/client/locales-hi.ts @@ -276,6 +276,8 @@ export const hi: Record = { subagent: 'कार्य', openSubagent: 'कार्य', subagentMainAgent: 'मुख्य एजेंट', + subagentActiveOnly: "केवल सक्रिय", + subagentActiveOnlyTitle: "निष्क्रिय सब-एजेंट समेटें; केवल चल रहे और उनके पूर्वज दिखाएँ", subagentEmpty: 'कोई सबएजेंट नहीं', subagentEmptyDesc: 'मुख्य एजेंट के अंतर्गत बने सबएजेंट यहाँ दिखेंगे', subagentRunning: 'चल रहा', diff --git a/src/client/locales-id.ts b/src/client/locales-id.ts index e14502f5..16e406ab 100644 --- a/src/client/locales-id.ts +++ b/src/client/locales-id.ts @@ -274,6 +274,8 @@ export const id: Record = { subagent: 'Tasks', openSubagent: 'Tasks', subagentMainAgent: 'Agen utama', + subagentActiveOnly: "Hanya aktif", + subagentActiveOnlyTitle: "Ciutkan subagen menganggur; tampilkan yang sedang berjalan dan leluhurnya", subagentEmpty: 'Tidak ada subagen', subagentEmptyDesc: 'Subagen yang dibangkitkan di bawah agen utama akan muncul di sini', subagentRunning: 'Berjalan', diff --git a/src/client/locales-it.ts b/src/client/locales-it.ts index dcff13f0..f15729ff 100644 --- a/src/client/locales-it.ts +++ b/src/client/locales-it.ts @@ -267,6 +267,8 @@ export const it: Record = { subagent: 'Attività', openSubagent: 'Attività', subagentMainAgent: 'Agente principale', + subagentActiveOnly: "Solo attivi", + subagentActiveOnlyTitle: "Comprimi i sotto-agenti inattivi; mostra solo quelli attivi e i loro antenati", subagentEmpty: 'Nessun sottoagente', subagentEmptyDesc: 'I sottoagenti generati dall’agente principale compariranno qui', subagentRunning: 'In esecuzione', diff --git a/src/client/locales-ja.ts b/src/client/locales-ja.ts index f984147f..b9081d03 100644 --- a/src/client/locales-ja.ts +++ b/src/client/locales-ja.ts @@ -276,6 +276,8 @@ export const ja: Record = { subagent: 'タスク管理', openSubagent: 'タスク管理', subagentMainAgent: 'メインエージェント', + subagentActiveOnly: 'アクティブのみ', + subagentActiveOnlyTitle: 'アイドル中のサブエージェントを折りたたみ、実行中のものとその親チェーンのみを表示', subagentEmpty: 'サブエージェントなし', subagentEmptyDesc: 'メインエージェントが派生したサブエージェントはここに表示されます', subagentRunning: '実行中', diff --git a/src/client/locales-ko.ts b/src/client/locales-ko.ts index 9b8d241f..36751f3a 100644 --- a/src/client/locales-ko.ts +++ b/src/client/locales-ko.ts @@ -268,6 +268,8 @@ export const ko: Record = { subagent: '작업 관리', openSubagent: '작업 관리', subagentMainAgent: '주 에이전트', + subagentActiveOnly: "활성만", + subagentActiveOnlyTitle: "유휴 상태의 하위 에이전트는 접고, 실행 중인 것과 그 조상 체인만 표시", subagentEmpty: '서브 에이전트 없음', subagentEmptyDesc: '현재 주 에이전트에서 파생된 서브 에이전트가 여기에 표시됩니다', subagentRunning: '실행 중', diff --git a/src/client/locales-nl.ts b/src/client/locales-nl.ts index dd5854fa..14a7f9f1 100644 --- a/src/client/locales-nl.ts +++ b/src/client/locales-nl.ts @@ -274,6 +274,8 @@ export const nl: Record = { subagent: 'Taken', openSubagent: 'Taken', subagentMainAgent: 'Hoofdagent', + subagentActiveOnly: "Alleen actief", + subagentActiveOnlyTitle: "Inactieve subagenten inklappen; alleen draaiende en hun voorouders tonen", subagentEmpty: 'Geen subagents', subagentEmptyDesc: 'Subagents voortgebracht door de hoofdagent verschijnen hier', subagentRunning: 'Actief', diff --git a/src/client/locales-pl.ts b/src/client/locales-pl.ts index 0b2fe4f8..4b250b1f 100644 --- a/src/client/locales-pl.ts +++ b/src/client/locales-pl.ts @@ -278,6 +278,8 @@ export const pl: Record = { subagent: 'Zadania', openSubagent: 'Zadania', subagentMainAgent: 'Agent główny', + subagentActiveOnly: "Tylko aktywne", + subagentActiveOnlyTitle: "Zwiń bezczynne podagenty; pokazuj tylko działające i ich przodków", subagentEmpty: 'Brak podagentów', subagentEmptyDesc: 'Podagenci powołani przez agenta głównego pojawią się tutaj', subagentRunning: 'Działa', diff --git a/src/client/locales-pt.ts b/src/client/locales-pt.ts index 88044502..757f4c24 100644 --- a/src/client/locales-pt.ts +++ b/src/client/locales-pt.ts @@ -259,6 +259,8 @@ export const pt: Record = { subagent: 'Tarefas', openSubagent: 'Tarefas', subagentMainAgent: 'Agente principal', + subagentActiveOnly: "Somente ativos", + subagentActiveOnlyTitle: "Recolher subagentes ociosos; mostrar apenas os ativos e seus ancestrais", subagentEmpty: 'Nenhum subagente', subagentEmptyDesc: 'Subagentes gerados sob o agente principal aparecerão aqui', subagentRunning: 'Executando', diff --git a/src/client/locales-ru.ts b/src/client/locales-ru.ts index 5afceb96..1e45a87a 100644 --- a/src/client/locales-ru.ts +++ b/src/client/locales-ru.ts @@ -274,6 +274,8 @@ export const ru: Record = { subagent: 'Задачи', openSubagent: 'Задачи', subagentMainAgent: 'Главный агент', + subagentActiveOnly: "Только активные", + subagentActiveOnlyTitle: "Свернуть простаивающие субагенты; показывать только выполняющиеся и их предков", subagentEmpty: 'Нет субагентов', subagentEmptyDesc: 'Субагенты, порождённые главным агентом, появятся здесь', subagentRunning: 'Выполняется', diff --git a/src/client/locales-sv.ts b/src/client/locales-sv.ts index cdb7824b..5f7343e4 100644 --- a/src/client/locales-sv.ts +++ b/src/client/locales-sv.ts @@ -259,6 +259,8 @@ export const sv: Record = { subagent: 'Uppgifter', openSubagent: 'Uppgifter', subagentMainAgent: 'Huvudagent', + subagentActiveOnly: "Endast aktiva", + subagentActiveOnlyTitle: "Fäll ihop inaktiva underagenter; visa endast körande och deras förfäder", subagentEmpty: 'Inga subagenter', subagentEmptyDesc: 'Subagenter skapade under huvudagenten visas här', subagentRunning: 'Kör', diff --git a/src/client/locales-th.ts b/src/client/locales-th.ts index 937d557d..0d009a19 100644 --- a/src/client/locales-th.ts +++ b/src/client/locales-th.ts @@ -276,6 +276,8 @@ export const th: Record = { subagent: 'งาน', openSubagent: 'งาน', subagentMainAgent: 'ตัวแทนหลัก', + subagentActiveOnly: "เฉพาะที่กำลังทำงาน", + subagentActiveOnlyTitle: "ย่อซับเอเจนต์ที่ว่างงาน แสดงเฉพาะตัวที่กำลังทำงานและบรรพบุรุษของมัน", subagentEmpty: 'ไม่มีตัวแทนย่อย', subagentEmptyDesc: 'ตัวแทนย่อยที่สร้างภายใต้ตัวแทนหลักจะปรากฏที่นี่', subagentRunning: 'กำลังทำงาน', diff --git a/src/client/locales-tr.ts b/src/client/locales-tr.ts index 45010eb2..c1d9f81b 100644 --- a/src/client/locales-tr.ts +++ b/src/client/locales-tr.ts @@ -276,6 +276,8 @@ export const tr: Record = { subagent: 'Görevler', openSubagent: 'Görevler', subagentMainAgent: 'Ana aracı', + subagentActiveOnly: "Sadece aktif", + subagentActiveOnlyTitle: "Boşta bekleyen alt ajanları daralt; yalnızca çalışanlar ve üst zincirlerini göster", subagentEmpty: 'Alt aracı yok', subagentEmptyDesc: 'Ana aracının altında doğan alt aracılar burada görünür', subagentRunning: 'Çalışıyor', diff --git a/src/client/locales-vi.ts b/src/client/locales-vi.ts index 94c8e6ab..daff3db0 100644 --- a/src/client/locales-vi.ts +++ b/src/client/locales-vi.ts @@ -276,6 +276,8 @@ export const vi: Record = { subagent: 'Quản lý tác vụ', openSubagent: 'Quản lý tác vụ', subagentMainAgent: 'Tác nhân chính', + subagentActiveOnly: "Chỉ mục đang chạy", + subagentActiveOnlyTitle: "Thu gọn subagent nhàn rỗi; chỉ hiện mục đang chạy và chuỗi cha của nó", subagentEmpty: 'Không có tác nhân con', subagentEmptyDesc: 'Tác nhân con do tác nhân chính tạo ra sẽ hiển thị ở đây', subagentRunning: 'Đang chạy', diff --git a/src/client/locales-zh-HK.ts b/src/client/locales-zh-HK.ts index 7e8cb941..0094a5ae 100644 --- a/src/client/locales-zh-HK.ts +++ b/src/client/locales-zh-HK.ts @@ -291,6 +291,8 @@ export const zhHK: Record = { subagent: '任務管理', openSubagent: '任務管理', subagentMainAgent: '主代理', + subagentActiveOnly: "只看活躍", + subagentActiveOnlyTitle: "收起空閒子代理,僅顯示執行中的子代理及其父鏈", subagentEmpty: '暫無子代理', subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡', subagentRunning: '執行中', diff --git a/src/client/locales-zh-MO.ts b/src/client/locales-zh-MO.ts index e21f71b6..2d9d6484 100644 --- a/src/client/locales-zh-MO.ts +++ b/src/client/locales-zh-MO.ts @@ -291,6 +291,8 @@ export const zhMO: Record = { subagent: '任務管理', openSubagent: '任務管理', subagentMainAgent: '主代理', + subagentActiveOnly: "只看活躍", + subagentActiveOnlyTitle: "收起空閒子代理,僅顯示執行中的子代理及其父鏈", subagentEmpty: '暫無子代理', subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡', subagentRunning: '執行中', diff --git a/src/client/locales-zh-TW.ts b/src/client/locales-zh-TW.ts index 2d81e3ff..3a235aca 100644 --- a/src/client/locales-zh-TW.ts +++ b/src/client/locales-zh-TW.ts @@ -291,6 +291,8 @@ export const zhTW: Record = { subagent: '任務管理', openSubagent: '任務管理', subagentMainAgent: '主代理', + subagentActiveOnly: "只看活躍", + subagentActiveOnlyTitle: "摺疊閒置子代理,僅顯示執行中的子代理及其父鏈", subagentEmpty: '暫無子代理', subagentEmptyDesc: '目前主代理派生的子代理將顯示在這裡', subagentRunning: '執行中', diff --git a/src/client/locales.ts b/src/client/locales.ts index 23ee8f77..0ecf7078 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -283,6 +283,8 @@ export const zh = { subagent: '任务管理', openSubagent: '任务管理', subagentMainAgent: '主代理', + subagentActiveOnly: '只看活跃', + subagentActiveOnlyTitle: '收起空闲子代理,仅显示运行中的子代理及其父链', subagentEmpty: '暂无子代理', subagentEmptyDesc: '当前主代理派生的子代理将显示在这里', subagentRunning: '运行中', @@ -627,6 +629,8 @@ export const en: Record = { subagent: 'Tasks', openSubagent: 'Tasks', subagentMainAgent: 'Main agent', + subagentActiveOnly: 'Active only', + subagentActiveOnlyTitle: 'Collapse idle subagents; keep running ones and their ancestors', subagentEmpty: 'No subagents', subagentEmptyDesc: 'Subagents spawned under the main agent will appear here', subagentRunning: 'Running', diff --git a/src/client/subagent-detect.ts b/src/client/subagent-detect.ts index f45b3ad5..8d6cfb31 100644 --- a/src/client/subagent-detect.ts +++ b/src/client/subagent-detect.ts @@ -135,3 +135,44 @@ export function countSubagentDescendants( } return totals } + +/** + * The session ids worth showing under "active only" mode: every running + * subagent plus each ancestor whose subtree contains one (hiding an idle + * parent must never orphan its running descendants). The root itself joins + * only when its subtree runs — the page renders it separately regardless. + * Cycles fail soft (same discipline as {@link countSubagentDescendants}). + */ +export function runningVisibilitySet( + byId: SidebarSessionList['byId'], + rootId: string, +): Set { + const keep = new Set() + const childrenOf = new Map() + for (const summary of Object.values(byId)) { + if (summary.origin !== 'subagent' || isSideThreadSummary(summary) + || summary.parentId === undefined) continue + const list = childrenOf.get(summary.parentId) + if (list) list.push(summary.id) + else childrenOf.set(summary.parentId, [summary.id]) + } + const visit = (id: string): boolean => { + const seen = new Set() + const walk = (current: string): boolean => { + if (seen.has(current)) return false + seen.add(current) + let subtreeRuns = false + for (const childId of childrenOf.get(current) ?? []) { + if (walk(childId)) subtreeRuns = true + } + if (subtreeRuns || byId[current]?.running === true) { + keep.add(current) + return true + } + return false + } + return walk(id) + } + visit(rootId) + return keep +} diff --git a/tests/subagent-detect.spec.ts b/tests/subagent-detect.spec.ts index 2fe4e069..1eadac3d 100644 --- a/tests/subagent-detect.spec.ts +++ b/tests/subagent-detect.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { collectBranchIds, countSubagentDescendants, detectNewDirectSubagent, - directSubagentCount, rootAncestor, + directSubagentCount, rootAncestor, runningVisibilitySet, } from '../src/client/subagent-detect.ts' import type { SidebarSessionList, SidebarSubagentCatalog } from '../src/context-types.ts' @@ -159,4 +159,62 @@ describe('subagent detection over the sessions list feed', () => { } expect(collectBranchIds(cyclic, 'root')).toEqual(['a', 'root']) }) + + describe('runningVisibilitySet (the "active only" view filter)', () => { + it('returns an empty set when every subagent is idle', () => { + const byId = list('p1', ['c1', 'c2']).byId + const keep = runningVisibilitySet(byId, 'p1') + // The root row is rendered separately (never through this filter), so + // an all-idle tree yields an empty set: every catalog entry hides. + expect(keep.size).toBe(0) + expect(keep.has('c1')).toBe(false) + expect(keep.has('c2')).toBe(false) + }) + + it('keeps a running child and the ancestors that own it', () => { + const byId = list('p1', ['c1', 'c2', 'c3'], ['c2']).byId + const keep = runningVisibilitySet(byId, 'p1') + expect(keep.has('p1')).toBe(true) // ancestor of the running child + expect(keep.has('c2')).toBe(true) // running + expect(keep.has('c1')).toBe(false) // idle sibling + expect(keep.has('c3')).toBe(false) + }) + + it('keeps an idle parent only when a deeper descendant runs', () => { + const byId: SidebarSessionList['byId'] = { + p1: { id: 'p1', displayTitle: 'P1' }, + c1: { id: 'c1', displayTitle: 'C1', origin: 'subagent', parentId: 'p1' }, + g1: { id: 'g1', displayTitle: 'G1', origin: 'subagent', parentId: 'c1', running: true }, + c2: { id: 'c2', displayTitle: 'C2', origin: 'subagent', parentId: 'p1' }, + } + const keep = runningVisibilitySet(byId, 'p1') + expect(keep.has('c1')).toBe(true) // idle itself, owns the running g1 + expect(keep.has('g1')).toBe(true) + expect(keep.has('p1')).toBe(true) + expect(keep.has('c2')).toBe(false) + }) + + it('ignores Side Chat threads when deciding what stays visible', () => { + const byId: SidebarSessionList['byId'] = { + p1: { id: 'p1', displayTitle: 'P1' }, + c1: { id: 'c1', displayTitle: 'C1', origin: 'subagent', parentId: 'p1' }, + s1: { + id: 's1', displayTitle: 'Side: refactor plan', origin: 'subagent', + parentId: 'p1', running: true, + }, + } + const keep = runningVisibilitySet(byId, 'p1') + expect(keep.has('s1')).toBe(false) + expect(keep.has('c1')).toBe(false) + }) + + it('terminates on lineage cycles (fail soft)', () => { + const cyclic: SidebarSessionList['byId'] = { + a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'b', running: true }, + b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'a' }, + } + expect(() => runningVisibilitySet(cyclic, 'a')).not.toThrow() + expect(runningVisibilitySet(cyclic, 'a').has('a')).toBe(true) + }) + }) })