diff --git a/.ai/contexts/subagent-observability.md b/.ai/contexts/subagent-observability.md index b3d254e..16ad5f8 100644 --- a/.ai/contexts/subagent-observability.md +++ b/.ai/contexts/subagent-observability.md @@ -255,22 +255,24 @@ 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 @@ -278,6 +280,7 @@ needs a UI decision (render the hit as an orphan group?), not just a filter. - `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 diff --git a/public/sidebar.js b/public/sidebar.js index e0c4788..6938f3f 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -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 @@ -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'); diff --git a/test/dom-sidebar-search-subagent-hits.test.js b/test/dom-sidebar-search-subagent-hits.test.js new file mode 100644 index 0000000..a07d73d --- /dev/null +++ b/test/dom-sidebar-search-subagent-hits.test.js @@ -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(); + } +});