diff --git a/.ai/contexts/subagent-observability.md b/.ai/contexts/subagent-observability.md index 16ad5f83..9cf905a8 100644 --- a/.ai/contexts/subagent-observability.md +++ b/.ai/contexts/subagent-observability.md @@ -274,9 +274,72 @@ cases the guard protects are untouched — an empty project directory still renders (`subagentIndex.size === 0`), a filtered-out project still hides, and `_projectMatchedOnly` still short-circuits ahead of it. +## Capped subagent lists + +Both subagent lists used to build every row on every render, and both then +hid most of what they had just built: + +- `appendSubagentChildren` filled `.sidebar-subagents-container` with all of a + parent's children and set `display:none` on the container unless the caret + was expanded; +- the project-level orphan bucket in `buildSessionsList` appended every orphan + and let `.sidebar-orphan-subagents.collapsed > :not(.sidebar-orphan-label)` + hide them in CSS. + +`buildSubagentItem` produces 7 elements per row, so a project with 1300 +children and 1300 orphans built 18 200 elements per render — measured on the +jsdom harness (`test/dom-setup.js`), 18 275 elements total in the sidebar, +of which 18 200 were subagent rows nobody was looking at. Neither `+ N older` +nor the collapsed-group CSS helps here: they save screen space, not +construction. + +Each list now renders, by default, the **union** of + +- every subagent for which `isSubagentActive(parentSessionId, agentId)` holds, and +- the `SUBAGENT_PREVIEW_COUNT` (10) most recent by `modified` + +(`splitSubagentsForPreview`), and the remainder is **not built at all** — only +a `+ N more` toggle is emitted (`appendSubagentRestToggle`). Same fixture after +the change: 217 elements in the sidebar, 20 subagent rows. The union is +deliberate, not a truncated sort: a long-running subagent whose transcript +stopped growing hours ago must keep its `.running` dot, which a +recency-only cut would silently extinguish — the exact class of bug PR #130 +was about. Row order inside each list is unchanged; only membership is. + +Clicking the toggle builds the remainder there and then, removes the toggle, +and records the list in the `expandedSubagentRest` localStorage set +(`p:` for a parent's children, `o:` for a +project's orphan bucket). The next render reads that set *before* splitting +and builds the whole list, so what the user expanded survives morphdom — +same mechanism as `expandedSubagents` and `orphanExpanded:`, +including the one-shot GC that drops `p:` entries whose session is gone from +`sessionMap`. + +Two details that are easy to get wrong: + +- **The toggle's payload cannot live in its closure.** morphdom keeps the + *old* element whenever ids match (`getNodeKey`), so a listener bound at + build time keeps forever the data of the render that created it. The + remainder is therefore held in the module-level `pendingSubagentRest` map, + keyed by the toggle's id, cleared at the top of `renderProjects` and + repopulated by each build — the kept listener always reads current data. +- **An active search bypasses the cap** (`searchMatchIds !== null`), for the + same reason the orphan bucket auto-expands during a search: `refreshSidebar` + has already narrowed `project.sessions` to the matches, so every remaining + row is a hit and hiding one behind a click would lose it. + +Not addressed, and worth knowing: a *collapsed* caret still builds its 10 +preview rows, and the "active" criterion is `isSubagentActive` alone. +`attentionSessions` / `responseReadySessions` / `sessionBusyState` are keyed +by PTY-owning sessions and never hold a subagent id on this fork (subagent +click opens a read-only transcript, fork PR #9), so no other indicator can be +capped out of view — but that is a property of the current wiring, not an +invariant enforced anywhere. + ## If you change this, also check - `eslint.config.js` `rendererCrossFileGlobals` — must list any new renderer-global functions (e.g. `showSubagentTranscript`, `drainViewerWatches`) or lint fails on `no-undef` +- `test/dom-subagent-list-cap.test.js` — pins the union criterion, the "+ N more" lazy build, and the survival of an expanded remainder across a re-render - `test/dom-subagent-transcript.test.js` — 4 tests covering the routing branch + transcript render - `test/dom-sidebar.test.js` — covers orphan group rendering - `test/dom-project-archive-all.test.js` — pins the project archive-all filter diff --git a/public/sidebar.js b/public/sidebar.js index 6938f3fb..f2e96e0d 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -33,6 +33,13 @@ function _gcExpandedSubagentsOnce() { localStorage.setItem('expandedSubagents', JSON.stringify([...pruned])); } } catch {} // eslint: allowEmptyCatch + try { + const raw = new Set(JSON.parse(localStorage.getItem('expandedSubagentRest') || '[]')); + const pruned = new Set([...raw].filter(k => !k.startsWith('p:') || sessionMap.has(k.slice(2)))); + if (pruned.size !== raw.size) { + localStorage.setItem('expandedSubagentRest', JSON.stringify([...pruned])); + } + } catch {} // eslint: allowEmptyCatch } function getExpandedSubagents() { @@ -48,6 +55,22 @@ function saveExpandedSubagents(set) { } catch (e) {} } +// see .ai/contexts/subagent-observability.md (capped subagent lists) +const SUBAGENT_PREVIEW_COUNT = 10; + +function getExpandedSubagentRest() { + _gcExpandedSubagentsOnce(); + try { + return new Set(JSON.parse(localStorage.getItem('expandedSubagentRest') || '[]')); + } catch { return new Set(); } // eslint: allowEmptyCatch +} + +function saveExpandedSubagentRest(set) { + try { + localStorage.setItem('expandedSubagentRest', JSON.stringify([...set])); + } catch {} // eslint: allowEmptyCatch +} + // Subagent type → accent color (background / border) const SUBAGENT_TYPE_COLORS = { explore: { bg: 'rgba(62,207,130,0.18)', border: '#3ecf82' }, @@ -226,6 +249,50 @@ function buildSubagentItem(session) { return item; } +// see .ai/contexts/subagent-observability.md (capped subagent lists) +function splitSubagentsForPreview(list) { + if (list.length <= SUBAGENT_PREVIEW_COUNT) return { shown: list, rest: [] }; + const recent = new Set( + [...list] + .sort((a, b) => new Date(b.modified || 0) - new Date(a.modified || 0)) + .slice(0, SUBAGENT_PREVIEW_COUNT) + .map(s => s.sessionId) + ); + const shown = []; + const rest = []; + for (const s of list) { + if (recent.has(s.sessionId) || isSubagentActive(s.parentSessionId, s.agentId)) shown.push(s); + else rest.push(s); + } + return { shown, rest }; +} + +// see .ai/contexts/subagent-observability.md (capped subagent lists) +const pendingSubagentRest = new Map(); + +function appendSubagentRestToggle(container, domKey, stateKey, rest) { + if (rest.length === 0) return; + const toggle = document.createElement('div'); + toggle.className = 'subagents-more-toggle js-stateful'; + toggle.id = 'submore-' + domKey; + toggle.textContent = `+ ${rest.length} more`; + pendingSubagentRest.set(toggle.id, { stateKey, rest }); + toggle.addEventListener('click', (e) => { + e.stopPropagation(); + const pending = pendingSubagentRest.get(toggle.id); + if (!pending) return; + pendingSubagentRest.delete(toggle.id); + const frag = document.createDocumentFragment(); + for (const s of pending.rest) frag.appendChild(buildSubagentItem(s)); + toggle.parentNode.insertBefore(frag, toggle); + toggle.remove(); + const set = getExpandedSubagentRest(); + set.add(pending.stateKey); + saveExpandedSubagentRest(set); + }); + container.appendChild(toggle); +} + // Shared by buildSessionsList and buildSlugGroup — see .ai/contexts/subagent-observability.md function appendSubagentChildren(parentEl, parentSessionId, subagentIndex) { const children = subagentIndex && subagentIndex.get(parentSessionId); @@ -242,14 +309,22 @@ function appendSubagentChildren(parentEl, parentSessionId, subagentIndex) { if (parentHasActiveSubagent(parentSessionId)) caret.classList.add('has-running-child'); caret.innerHTML = ` ${children.length} subagent${children.length !== 1 ? 's' : ''}`; + const domKey = parentSessionId.replace(/[^a-zA-Z0-9_-]/g, '_'); const childrenContainer = document.createElement('div'); childrenContainer.className = 'sidebar-subagents-container js-stateful'; - childrenContainer.id = 'subc-' + parentSessionId.replace(/[^a-zA-Z0-9_-]/g, '_'); + childrenContainer.id = 'subc-' + domKey; childrenContainer.style.display = isExpanded ? '' : 'none'; - for (const child of children) { + const restKey = 'p:' + parentSessionId; + const restExpanded = searchMatchIds !== null || getExpandedSubagentRest().has(restKey); + const { shown, rest } = restExpanded + ? { shown: children, rest: [] } + : splitSubagentsForPreview(children); + + for (const child of shown) { childrenContainer.appendChild(buildSubagentItem(child)); } + appendSubagentRestToggle(childrenContainer, domKey, restKey, rest); caret.addEventListener('click', (e) => { e.stopPropagation(); @@ -376,6 +451,7 @@ function buildSlugGroup(slug, sessions, subagentIndex) { function renderProjects(projects, resort) { pruneStaleSubagents(); + pendingSubagentRest.clear(); const newSidebar = document.createElement('div'); // Sort project groups using sortedOrder as source of truth @@ -585,9 +661,16 @@ function renderProjects(projects, resort) { }); orphanGroup.appendChild(orphanLabel); - for (const orphan of orphans) { + const restKey = 'o:' + projectPath; + const restExpanded = searchMatchIds !== null || getExpandedSubagentRest().has(restKey); + const { shown, rest } = restExpanded + ? { shown: orphans, rest: [] } + : splitSubagentsForPreview(orphans); + + for (const orphan of shown) { orphanGroup.appendChild(buildSubagentItem(orphan)); } + appendSubagentRestToggle(orphanGroup, fId, restKey, rest); sessionsList.appendChild(orphanGroup); } } diff --git a/public/style.css b/public/style.css index 05fb2fb1..5c1bd075 100644 --- a/public/style.css +++ b/public/style.css @@ -740,6 +740,25 @@ body { display: flex; flex-direction: column; } background: rgba(120,130,255,0.08); } +.subagents-more-toggle { + padding: 3px 10px; + margin: 2px 12px 4px 52px; + font-size: 10.5px; + font-weight: 500; + color: #7a7a90; + cursor: pointer; + user-select: none; + transition: all 0.15s; + border-radius: 5px; + display: inline-block; + background: rgba(255,255,255,0.03); +} + +.subagents-more-toggle:hover { + color: #8088ff; + background: rgba(120,130,255,0.08); +} + /* ---- Slug groups ---- */ .slug-group { } .slug-group-header { diff --git a/test/dom-subagent-list-cap.test.js b/test/dom-subagent-list-cap.test.js new file mode 100644 index 00000000..5195b8c7 --- /dev/null +++ b/test/dom-subagent-list-cap.test.js @@ -0,0 +1,283 @@ +// Coverage for the capped subagent lists — see +// .ai/contexts/subagent-observability.md (capped subagent lists). +// +// Both subagent lists (a parent's children under the "N subagents" caret, and +// the project-level orphan bucket) render, by default, the union of the active +// subagents and the SUBAGENT_PREVIEW_COUNT (10) most recent ones. The rest is +// not merely hidden: its DOM is never built until the user clicks +// "+ N more". The load-bearing assertions below are the ones checking that a +// capped-out item has NO element in the document at all. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom } = require('./dom-setup'); + +const BASE = Date.parse('2026-05-22T10:00:00Z'); +const at = (offsetMs) => new Date(BASE + offsetMs).toISOString(); + +// Real subagent sessionId convention (read-session-file.js:subagentSessionId), +// so the id sidebar.js rebuilds from an IPC payload matches the rendered item. +const subId = (parentId, agentId) => `sub:${parentId}:${agentId}`; + +// n subagents under `parentId`, agent-0 newest … agent-(n-1) oldest. +function subagents(parentId, n) { + const list = []; + for (let i = 0; i < n; i++) { + list.push({ + sessionId: subId(parentId, 'agent-' + i), + parentSessionId: parentId, + agentId: 'agent-' + i, + subagentType: 'explore', + description: 'child ' + i, + modified: at(-1000 * (i + 1)), + messageCount: 1, + }); + } + return list; +} + +function projectWithChildren(n = 15) { + return { + projectPath: '/home/dev/capped', + sessions: [ + { sessionId: 's-top-1', name: 'main', summary: 'main', modified: at(0), archived: 0 }, + ...subagents('s-top-1', n), + ], + }; +} + +function projectWithOrphans(n = 15) { + return { + projectPath: '/home/dev/capped-orphans', + sessions: [ + { sessionId: 's-top-1', name: 'main', summary: 'main', modified: at(0), archived: 0 }, + ...subagents('s-ghost-parent', n), + ], + }; +} + +const itemsIn = (el) => el.querySelectorAll('.sidebar-subagent').length; + +// --------------------------------------------------------------------------- +// Children under a parent caret +// --------------------------------------------------------------------------- + +test('children: only the 10 most recent are built — the 11th has no DOM node at all', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([projectWithChildren(15)], true); + + const container = ctx.document.getElementById('subc-s-top-1'); + assert.ok(container, 'children container must exist'); + assert.equal(itemsIn(container), 10, 'exactly SUBAGENT_PREVIEW_COUNT children rendered'); + + for (let i = 0; i < 10; i++) { + assert.ok(ctx.document.getElementById('si-' + subId('s-top-1', 'agent-' + i)), + `agent-${i} is among the 10 most recent and must be rendered`); + } + for (let i = 10; i < 15; i++) { + assert.equal(ctx.document.getElementById('si-' + subId('s-top-1', 'agent-' + i)), null, + `agent-${i} is capped out — its node must NOT be built (lazy, not just hidden)`); + } + } finally { + ctx.destroy(); + } +}); + +test('children: the caret still announces the full child count', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([projectWithChildren(15)], true); + const caret = ctx.document.getElementById('sub-caret-s-top-1'); + assert.ok(caret, 'caret must exist'); + assert.match(caret.textContent, /15 subagents/, + 'the caret counts every child, not only the rendered ones'); + } finally { + ctx.destroy(); + } +}); + +test('children: an active but old subagent stays visible on top of the 10 most recent', () => { + const ctx = setupSidebarDom(); + try { + const project = projectWithChildren(15); + ctx.sidebar.renderProjects([project], true); + + // agent-14 is the oldest — capped out on the first pass. + assert.equal(ctx.document.getElementById('si-' + subId('s-top-1', 'agent-14')), null, + 'precondition: the oldest child is capped out while idle'); + + ctx.emitSubagentSpawned({ parentSessionId: 's-top-1', agentId: 'agent-14', subagentType: 'explore' }); + ctx.sidebar.renderProjects([project], false); + + const item = ctx.document.getElementById('si-' + subId('s-top-1', 'agent-14')); + assert.ok(item, 'a running subagent must be rendered however old its transcript is'); + assert.ok(item.classList.contains('running'), 'and it must carry the running indicator'); + + const container = ctx.document.getElementById('subc-s-top-1'); + assert.equal(itemsIn(container), 11, '10 most recent + 1 active = 11 rendered children'); + } finally { + ctx.destroy(); + } +}); + +test('children: the "+ N more" toggle counts the unbuilt remainder', () => { + const ctx = setupSidebarDom(); + try { + const project = projectWithChildren(15); + ctx.sidebar.renderProjects([project], true); + + const toggle = ctx.document.getElementById('submore-s-top-1'); + assert.ok(toggle, 'a "+ N more" toggle must be emitted when children are capped out'); + assert.equal(toggle.textContent, '+ 5 more'); + + ctx.emitSubagentSpawned({ parentSessionId: 's-top-1', agentId: 'agent-14', subagentType: 'explore' }); + ctx.sidebar.renderProjects([project], false); + assert.equal(ctx.document.getElementById('submore-s-top-1').textContent, '+ 4 more', + 'promoting an active subagent shrinks the remainder'); + } finally { + ctx.destroy(); + } +}); + +test('children: no "+ N more" toggle when the list fits under the cap', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([projectWithChildren(10)], true); + assert.equal(ctx.document.getElementById('submore-s-top-1'), null, + 'a list of exactly 10 must not grow a toggle'); + assert.equal(itemsIn(ctx.document.getElementById('subc-s-top-1')), 10); + } finally { + ctx.destroy(); + } +}); + +test('children: clicking "+ N more" builds the remainder', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([projectWithChildren(15)], true); + + ctx.document.getElementById('submore-s-top-1').click(); + + const container = ctx.document.getElementById('subc-s-top-1'); + assert.equal(itemsIn(container), 15, 'every child is rendered after the click'); + for (let i = 10; i < 15; i++) { + assert.ok(ctx.document.getElementById('si-' + subId('s-top-1', 'agent-' + i)), + `agent-${i} must be built by the click`); + } + assert.equal(ctx.document.getElementById('submore-s-top-1'), null, + 'the toggle removes itself once the remainder is built'); + } finally { + ctx.destroy(); + } +}); + +test('children: the expanded remainder survives a re-render', () => { + const ctx = setupSidebarDom(); + try { + const project = projectWithChildren(15); + ctx.sidebar.renderProjects([project], true); + ctx.document.getElementById('submore-s-top-1').click(); + + ctx.sidebar.renderProjects([project], false); + + const container = ctx.document.getElementById('subc-s-top-1'); + assert.equal(itemsIn(container), 15, + 'a re-render must not collapse a remainder the user expanded'); + assert.ok(ctx.document.getElementById('si-' + subId('s-top-1', 'agent-14')), + 'the oldest child must still be in the DOM after the re-render'); + assert.equal(ctx.document.getElementById('submore-s-top-1'), null, + 'and the toggle must not come back'); + } finally { + ctx.destroy(); + } +}); + +// --------------------------------------------------------------------------- +// Project-level orphan bucket +// --------------------------------------------------------------------------- + +test('orphans: only the 10 most recent are built — the 11th has no DOM node at all', () => { + const ctx = setupSidebarDom(); + try { + ctx.sidebar.renderProjects([projectWithOrphans(15)], true); + + const group = ctx.document.querySelector('.sidebar-orphan-subagents'); + assert.ok(group, 'orphan bucket must exist'); + assert.equal(itemsIn(group), 10, 'exactly SUBAGENT_PREVIEW_COUNT orphans rendered'); + assert.equal(group.querySelector('.orphan-count').textContent, '15', + 'the label still counts every orphan'); + + for (let i = 10; i < 15; i++) { + assert.equal(ctx.document.getElementById('si-' + subId('s-ghost-parent', 'agent-' + i)), null, + `orphan agent-${i} is capped out — its node must NOT be built`); + } + } finally { + ctx.destroy(); + } +}); + +test('orphans: an active but old orphan stays visible', () => { + const ctx = setupSidebarDom(); + try { + const project = projectWithOrphans(15); + ctx.sidebar.renderProjects([project], true); + assert.equal(ctx.document.getElementById('si-' + subId('s-ghost-parent', 'agent-14')), null, + 'precondition: the oldest orphan is capped out while idle'); + + ctx.emitSubagentSpawned({ parentSessionId: 's-ghost-parent', agentId: 'agent-14', subagentType: 'explore' }); + ctx.sidebar.renderProjects([project], false); + + assert.ok(ctx.document.getElementById('si-' + subId('s-ghost-parent', 'agent-14')), + 'a running orphan must be rendered however old its transcript is'); + assert.equal(itemsIn(ctx.document.querySelector('.sidebar-orphan-subagents')), 11); + } finally { + ctx.destroy(); + } +}); + +test('orphans: clicking "+ N more" builds the remainder and it survives a re-render', () => { + const ctx = setupSidebarDom(); + try { + const project = projectWithOrphans(15); + ctx.sidebar.renderProjects([project], true); + + const fId = ctx.sidebar.folderId(project.projectPath); + const toggle = ctx.document.getElementById('submore-' + fId); + assert.ok(toggle, 'orphan bucket must emit a "+ N more" toggle'); + assert.equal(toggle.textContent, '+ 5 more'); + + toggle.click(); + assert.equal(itemsIn(ctx.document.querySelector('.sidebar-orphan-subagents')), 15, + 'every orphan is rendered after the click'); + + ctx.sidebar.renderProjects([project], false); + assert.equal(itemsIn(ctx.document.querySelector('.sidebar-orphan-subagents')), 15, + 'a re-render must not collapse a remainder the user expanded'); + assert.equal(ctx.document.getElementById('submore-' + fId), null, + 'and the toggle must not come back'); + } finally { + ctx.destroy(); + } +}); + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +test('search: an active search bypasses the cap so no hit is hidden behind a click', () => { + const ctx = setupSidebarDom(); + try { + const project = projectWithOrphans(15); + ctx.window.searchMatchIds = new Set(project.sessions.map(s => s.sessionId)); + ctx.sidebar.renderProjects([project], true); + + assert.equal(itemsIn(ctx.document.querySelector('.sidebar-orphan-subagents')), 15, + 'every matched orphan must be rendered during a search'); + assert.equal(ctx.document.getElementById('submore-' + ctx.sidebar.folderId(project.projectPath)), null, + 'no "+ N more" toggle during a search'); + } finally { + ctx.destroy(); + } +});