Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .ai/contexts/subagent-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<parentSessionId>` for a parent's children, `o:<projectPath>` 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:<projectPath>`,
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
Expand Down
89 changes: 86 additions & 3 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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' },
Expand Down Expand Up @@ -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);
Expand All @@ -242,14 +309,22 @@ function appendSubagentChildren(parentEl, parentSessionId, subagentIndex) {
if (parentHasActiveSubagent(parentSessionId)) caret.classList.add('has-running-child');
caret.innerHTML = `<span class="caret-arrow">&#9654;</span> ${children.length} subagent${children.length !== 1 ? 's' : ''}<span class="caret-running-dot"></span>`;

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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
Expand Down
19 changes: 19 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading