From 8fdec2ff3e5db6f1073c3f62d3df7946ff107d24 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Mon, 24 Aug 2026 00:03:14 +0200 Subject: [PATCH] fix(sidebar): surface search hits that only match a subagent transcript processProjectSessions() dropped a project entirely when its only search match lived inside a subagent transcript: the orphan-subagent guard disqualified itself whenever any filter, search included, was active, even though refreshSidebar had already discarded projects with zero matching sessions before this guard runs. Restrict the disqualification to showStarredOnly/showRunningOnly/showTodayOnly, which a subagent can never satisfy, and auto-expand the orphan bucket while a search is active so the surfaced hit isn't hidden behind an extra click. --- .ai/contexts/subagent-observability.md | 35 ++++---- public/sidebar.js | 5 +- test/dom-sidebar-search-subagent-hits.test.js | 87 +++++++++++++++++++ 3 files changed, 109 insertions(+), 18 deletions(-) create mode 100644 test/dom-sidebar-search-subagent-hits.test.js 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(); + } +});