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
35 changes: 19 additions & 16 deletions .ai/contexts/subagent-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,29 +255,32 @@ lost (Show Archived brought it back), but the old behaviour hid this by
accident: archiving the children too really did empty `project.sessions`, so
the disappearance was legitimate.

The guard now carries `keepForOrphanSubagents = !anyFilterActive &&
subagentIndex.size > 0`. When `filtered` is empty, every indexed subagent is by
definition an orphan (`allTopLevelIds` is built from the rendered items, which
are none), so the existing orphan bucket renders them under a surviving header.
The `!anyFilterActive` half is load-bearing: without it, `showStarredOnly` /
`showRunningOnly` / `showTodayOnly` / an active search would resurrect every
project that merely owns a subagent, since subagents never satisfy those
filters. The other 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.

Still open, reported but not fixed: when a search matches **only** subagent
transcripts in a project, `refreshSidebar` narrows `sessions` to those matches
and `processProjectSessions` then filters them all out, so the project
disappears from the results instead of surfacing the matching transcript. That
needs a UI decision (render the hit as an orphan group?), not just a filter.
The guard now carries `keepForOrphanSubagents = subagentIndex.size > 0 &&
!showStarredOnly && !showRunningOnly && !showTodayOnly`. When `filtered` is
empty, every indexed subagent is by definition an orphan (`allTopLevelIds` is
built from the rendered items, which are none), so the existing orphan bucket
renders them under a surviving header. The three named filters are
load-bearing: without excluding them, `showStarredOnly` / `showRunningOnly` /
`showTodayOnly` would resurrect every project that merely owns a subagent,
since subagents never satisfy those filters. Search is deliberately **not** in
that exclusion list: `refreshSidebar` (`public/app.js`) has already dropped any
project with zero matching sessions before `processProjectSessions` ever runs,
so by the time this guard is reached under an active search, `subagentIndex`
being non-empty means a subagent transcript is the match — keeping the project
alive surfaces it instead of losing it. The orphan bucket's default-collapsed
state is overridden the same way (`expanded = searchMatchIds !== null || ...`)
so the matching subagent doesn't require an extra click to see. The other
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.

## 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-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
- `test/dom-sidebar-search-subagent-hits.test.js` — pins the search-only-hits-a-subagent case and the showStarredOnly regression, one test per guard clause
- `test/session-transitions.test.js` — spawn/complete/heartbeat lifecycle plus
the resurrection guards above
- `test/dom-grid-subagent-pills.test.js` — pins the grid-view IPC handler arity
Expand Down
5 changes: 3 additions & 2 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ function renderProjects(projects, resort) {
}
const anyFilterActive = showStarredOnly || showRunningOnly || showTodayOnly || searchMatchIds !== null;
// see .ai/contexts/subagent-observability.md
const keepForOrphanSubagents = !anyFilterActive && subagentIndex.size > 0;
const keepForOrphanSubagents = subagentIndex.size > 0 && !showStarredOnly && !showRunningOnly && !showTodayOnly;
if (filtered.length === 0 && !project._projectMatchedOnly && !keepForOrphanSubagents && (project.sessions.length > 0 || anyFilterActive)) return null;

// Sort
Expand Down Expand Up @@ -567,7 +567,8 @@ function renderProjects(projects, resort) {
// section is rarely the user's focus and can grow long on long-lived
// projects (this very session has 1300+ orphan subagents).
const orphanStateKey = 'orphanExpanded:' + projectPath;
const expanded = localStorage.getItem(orphanStateKey) === '1';
// see .ai/contexts/subagent-observability.md
const expanded = searchMatchIds !== null || localStorage.getItem(orphanStateKey) === '1';

const orphanGroup = document.createElement('div');
orphanGroup.className = 'sidebar-orphan-subagents' + (expanded ? '' : ' collapsed');
Expand Down
87 changes: 87 additions & 0 deletions test/dom-sidebar-search-subagent-hits.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Regression coverage: a search hit that lives only inside a subagent
// transcript used to vanish entirely from the sidebar. See
// .ai/contexts/subagent-observability.md for the full trace of how the hit
// reaches searchMatchIds and where it used to get dropped at render time
// (sidebar.js processProjectSessions()'s keepForOrphanSubagents guard, and
// the orphan-bucket default-collapsed state).

const test = require('node:test');
const assert = require('node:assert/strict');

const { setupSidebarDom, makeSampleProject } = require('./dom-setup');

// Mirrors what app.js's refreshSidebar() produces when only a subagent
// session matched the search: `sessions` is reduced to just the matching
// subagent, the parent top-level session is filtered out entirely.
function projectWithOnlySubagentHit() {
return makeSampleProject({
sessions: [
{
sessionId: 'sub:s-top-1:agent-1',
parentSessionId: 's-top-1',
agentId: 'agent-1',
subagentType: 'explore',
description: 'explore subagent',
modified: '2026-05-22T09:59:00.000Z',
messageCount: 1,
},
],
});
}

test('search hit that is only a subagent: project renders and the subagent is visible unfolded', () => {
const ctx = setupSidebarDom();
try {
ctx.window.searchMatchIds = new Set(['sub:s-top-1:agent-1']);

ctx.sidebar.renderProjects([projectWithOnlySubagentHit()], true);

const projectGroup = ctx.document.getElementById(ctx.sidebar.folderId('/home/dev/myproj'));
assert.ok(projectGroup, 'project group must still be rendered when its only match is a subagent');

const subagentItem = ctx.document.getElementById('si-sub:s-top-1:agent-1');
assert.ok(subagentItem, 'the matching subagent item must be rendered');

const orphanGroup = projectGroup.querySelector('.sidebar-orphan-subagents');
assert.ok(orphanGroup, 'orphan subagent bucket must be rendered');
assert.ok(!orphanGroup.classList.contains('collapsed'), 'orphan bucket must be expanded during an active search, not hidden behind a click');
} finally {
ctx.destroy();
}
});

test('regression: showStarredOnly still hides a project whose only sessions are an unstarred top-level plus a subagent', () => {
const ctx = setupSidebarDom();
try {
ctx.window.showStarredOnly = true;

const project = makeSampleProject({
sessions: [
{
sessionId: 's-top-1',
name: 'main session',
summary: 'top level 1',
modified: '2026-05-22T10:00:00.000Z',
starred: false,
archived: 0,
messageCount: 12,
},
{
sessionId: 's-sub-1',
parentSessionId: 's-top-1',
subagentType: 'explore',
description: 'explore subagent',
modified: '2026-05-22T09:59:00.000Z',
messageCount: 3,
},
],
});

ctx.sidebar.renderProjects([project], true);

const projectGroup = ctx.document.getElementById(ctx.sidebar.folderId('/home/dev/myproj'));
assert.equal(projectGroup, null, 'a subagent must never keep a project alive under showStarredOnly — subagents are never starred');
} finally {
ctx.destroy();
}
});
Loading