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
40 changes: 40 additions & 0 deletions .ai/contexts/subagent-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,46 @@ an agent tracked from its first line. The renderer's 60 s TTL has the same
shape. Anything that needs true liveness would have to come from the parent
process, not from mtime.

## Subagent children inside a slug group (issue #128 ask 4, rehab-plan.md A3)

- **The bug**: `buildSlugGroup()` used to append its sessions via the raw
`buildSessionItem(session)`, never through `appendSubagentChildren()` — only
the ungrouped/top-level render path (`buildSessionsList`) called that
helper. Any session rendered inside a slug group (i.e. any second-or-later
rerun of a schedule sharing a slug — the only real producer, see
`schedule-runner.js:createScheduleSession()`) silently lost its subagent
caret/children.
- **The fix**: `appendSubagentChildren()` was hoisted from a closure inside
`renderProjects()` to module scope (it never captured any of that
function's locals) so `buildSlugGroup(slug, sessions, subagentIndex)` can
call it directly for each session it renders, exactly like
`buildSessionsList` does for ungrouped sessions.
- **The knock-on bug this caused**: a slug-group `<div>` carries no
`dataset.sessionId` of its own, so the "orphan subagents" pass in
`buildSessionsList` — which built `allTopLevelIds` from
`item.element.dataset.sessionId` — never counted the sessions grouped
inside it as accounted-for. Their subagents were treated as parentless and
duplicated into the project's "Orphan subagents" bucket even after the fix
above attached them correctly inside the group. `collectTopLevelSessionIds(el)`
fixes this by walking into `el` for nested `[data-session-id]` session-items
(excluding subagent ones) when `el` itself isn't a session item.
- **Coverage**: `test/dom-slug-group-subagent-nesting.test.js` seeds two
schedule-rerun-shaped sessions sharing a slug plus a subagent parented to
one of them, and pins both the caret-attachment fix and the
no-duplicate-orphan fix (failed on both before the fix, confirmed by
reverting it locally during development).
- **A second knock-on bug (PR #134 review F1)**: nesting the subagent's
caret/children as DOM siblings inside the group means every DOM query
scoped to `.slug-group` that matches on `.session-item` alone now also
matches the nested subagent item (`buildSubagentItem` includes
`session-item` in its className for shared styling). The "Archive all
sessions in group" handler (`rebindSidebarEvents`, `.slug-group-archive-btn`)
had exactly this query and, unguarded, called `archiveSession`/`stopSession`
on the subagent's id. Fixed with the same `:not([data-subagent])` guard
already used elsewhere in this file (e.g. the per-item click wiring). Any
*new* query scoped to a slug-group's subtree must apply this guard too —
it's not automatic.

## 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`
Expand Down
111 changes: 64 additions & 47 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,56 @@ function buildSubagentItem(session) {
return item;
}

function buildSlugGroup(slug, sessions) {
// Shared by buildSessionsList and buildSlugGroup — see .ai/contexts/subagent-observability.md
function appendSubagentChildren(parentEl, parentSessionId, subagentIndex) {
const children = subagentIndex && subagentIndex.get(parentSessionId);
if (!children || children.length === 0) return;

const expandedSet = getExpandedSubagents();
const caretId = caretIdFor(parentSessionId);
const isExpanded = expandedSet.has(parentSessionId);

const caret = document.createElement('div');
caret.className = 'sidebar-children-caret js-stateful';
caret.id = caretId;
if (isExpanded) caret.classList.add('expanded');
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 childrenContainer = document.createElement('div');
childrenContainer.className = 'sidebar-subagents-container js-stateful';
childrenContainer.id = 'subc-' + parentSessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
childrenContainer.style.display = isExpanded ? '' : 'none';

for (const child of children) {
childrenContainer.appendChild(buildSubagentItem(child));
}

caret.addEventListener('click', (e) => {
e.stopPropagation();
const open = childrenContainer.style.display !== 'none';
childrenContainer.style.display = open ? 'none' : '';
caret.classList.toggle('expanded', !open);
const set = getExpandedSubagents();
if (open) { set.delete(parentSessionId); } else { set.add(parentSessionId); }
saveExpandedSubagents(set);
});

parentEl.after(caret);
caret.after(childrenContainer);
}

// See .ai/contexts/subagent-observability.md (slug-group orphan-detection fix)
function collectTopLevelSessionIds(el) {
const ids = [];
if (el.dataset && el.dataset.sessionId && !el.dataset.subagent) ids.push(el.dataset.sessionId);
el.querySelectorAll('[data-session-id]').forEach((child) => {
if (!child.dataset.subagent) ids.push(child.dataset.sessionId);
});
return ids;
}

function buildSlugGroup(slug, sessions, subagentIndex) {
const group = document.createElement('div');
const id = slugId(slug);
const expanded = getExpandedSlugs().has(id);
Expand Down Expand Up @@ -290,7 +339,9 @@ function buildSlugGroup(slug, sessions) {
if (promoted.length > 0) {
group.classList.add('has-promoted');
for (const session of promoted) {
sessionsContainer.appendChild(buildSessionItem(session));
const sessionEl = buildSessionItem(session);
sessionsContainer.appendChild(sessionEl);
appendSubagentChildren(sessionEl, session.sessionId, subagentIndex);
}
if (rest.length > 0) {
const moreBtn = document.createElement('div');
Expand All @@ -302,15 +353,19 @@ function buildSlugGroup(slug, sessions) {
olderDiv.className = 'slug-group-older js-stateful';
olderDiv.id = 'sgo-' + id;
for (const session of rest) {
olderDiv.appendChild(buildSessionItem(session));
const sessionEl = buildSessionItem(session);
olderDiv.appendChild(sessionEl);
appendSubagentChildren(sessionEl, session.sessionId, subagentIndex);
}

sessionsContainer.appendChild(moreBtn);
sessionsContainer.appendChild(olderDiv);
}
} else {
for (const session of sessions) {
sessionsContainer.appendChild(buildSessionItem(session));
const sessionEl = buildSessionItem(session);
sessionsContainer.appendChild(sessionEl);
appendSubagentChildren(sessionEl, session.sessionId, subagentIndex);
}
}

Expand Down Expand Up @@ -416,7 +471,7 @@ function renderProjects(projects, resort) {
const mostRecentTime = Math.max(...sessions.map(s => new Date(s.modified).getTime()));
const hasRunning = sessions.some(s => activePtyIds.has(s.sessionId) || pendingSessions.has(s.sessionId));
const hasPinned = sessions.some(s => s.starred);
const element = sessions.length === 1 ? buildSessionItem(sessions[0]) : buildSlugGroup(slug, sessions);
const element = sessions.length === 1 ? buildSessionItem(sessions[0]) : buildSlugGroup(slug, sessions, subagentIndex);
allItems.push({ sortTime: mostRecentTime, pinned: hasPinned, running: hasRunning, element });
}

Expand Down Expand Up @@ -466,45 +521,6 @@ function renderProjects(projects, resort) {
};
}

// Append subagent children beneath a session item element.
function appendSubagentChildren(parentEl, parentSessionId, subagentIndex) {
const children = subagentIndex && subagentIndex.get(parentSessionId);
if (!children || children.length === 0) return;

const expandedSet = getExpandedSubagents();
const caretId = caretIdFor(parentSessionId);
const isExpanded = expandedSet.has(parentSessionId);

const caret = document.createElement('div');
caret.className = 'sidebar-children-caret js-stateful';
caret.id = caretId;
if (isExpanded) caret.classList.add('expanded');
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 childrenContainer = document.createElement('div');
childrenContainer.className = 'sidebar-subagents-container js-stateful';
childrenContainer.id = 'subc-' + parentSessionId.replace(/[^a-zA-Z0-9_-]/g, '_');
childrenContainer.style.display = isExpanded ? '' : 'none';

for (const child of children) {
childrenContainer.appendChild(buildSubagentItem(child));
}

caret.addEventListener('click', (e) => {
e.stopPropagation();
const open = childrenContainer.style.display !== 'none';
childrenContainer.style.display = open ? 'none' : '';
caret.classList.toggle('expanded', !open);
const set = getExpandedSubagents();
if (open) { set.delete(parentSessionId); } else { set.add(parentSessionId); }
saveExpandedSubagents(set);
});

parentEl.after(caret);
caret.after(childrenContainer);
}

// Build the sessions list DOM (shared between projects and worktrees)
function buildSessionsList(fId, visible, older, subagentIndex, projectPath) {
const sessionsList = document.createElement('div');
Expand Down Expand Up @@ -534,9 +550,10 @@ function renderProjects(projects, resort) {
sessionsList.appendChild(olderList);
}

// Orphan subagents: children whose parentSessionId has no top-level session in this project
// Orphan subagents: children whose parentSessionId has no top-level session in this project.
// See .ai/contexts/subagent-observability.md for why collectTopLevelSessionIds is needed here.
if (subagentIndex) {
const allTopLevelIds = new Set([...visible, ...older].map(i => i.element.dataset && i.element.dataset.sessionId).filter(Boolean));
const allTopLevelIds = new Set([...visible, ...older].flatMap(i => collectTopLevelSessionIds(i.element)));
const orphans = [];
for (const [parentId, kids] of subagentIndex) {
if (!allTopLevelIds.has(parentId)) {
Expand Down Expand Up @@ -892,7 +909,7 @@ function rebindSidebarEvents(projects) {
archiveBtn.onclick = async (e) => {
e.stopPropagation();
const group = header.parentElement;
const sessionItems = group.querySelectorAll('.session-item');
const sessionItems = group.querySelectorAll('.session-item:not([data-subagent])');
for (const item of sessionItems) {
const sid = item.dataset.sessionId;
const session = sessionMap.get(sid);
Expand Down
Loading
Loading