From a761e03ddc09693d85d36ee793e82ceca29b762b Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Sun, 23 Aug 2026 22:42:03 +0200 Subject: [PATCH] fix(sidebar): exclude subagents from project archive-all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buildProjectsFromCache groups every session_cache row by projectPath, subagent rows included, so project.sessions is a flat list of parents and children. The project header's "Archive all sessions" button treated it as top-level only: it counted subagents in its confirmation prompt, archived each subagent transcript so it disappeared from under its parent, and called stopSession on any subagent id present in activePtyIds. This also makes the bulk button consistent with the per-session archive button, which never touched children. Not archiving the children exposes a latent hole in processProjectSessions' skip guard: the archived parents are gone from project.sessions but the subagents remain, so filtered is empty while the array is not, and the whole project dropped out of the default view — header and orphan bucket alike, the latter living past the render loop's continue. Keep the project when unarchived subagents remain and no filter is active, so the existing orphan bucket renders them. The status bar's session total had the same flat-list defect and disagreed with the stats panel, which counts WHERE parentSessionId IS NULL. --- .ai/contexts/subagent-observability.md | 58 +++++++++++ public/app.js | 2 +- public/sidebar.js | 6 +- test/dom-project-archive-all.test.js | 131 +++++++++++++++++++++++++ 4 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 test/dom-project-archive-all.test.js diff --git a/.ai/contexts/subagent-observability.md b/.ai/contexts/subagent-observability.md index c21490a2..b3d254e7 100644 --- a/.ai/contexts/subagent-observability.md +++ b/.ai/contexts/subagent-observability.md @@ -215,11 +215,69 @@ process, not from mtime. *new* query scoped to a slug-group's subtree must apply this guard too — it's not automatic. +## `project.sessions` carries subagents + +`buildProjectsFromCache` (`session-cache.js`) groups **every** `session_cache` +row by `projectPath`, subagent rows included — they are indexed from +`//subagents/agent-*.jsonl` with the same `folder` and +`projectPath` as their parent, and the row keeps `parentSessionId` set. So the +`project.sessions` array the renderer receives is a flat list of parents *and* +children. + +Every consumer must therefore drop `s.parentSessionId` rows itself before +treating the array as "the project's sessions". `processProjectSessions` +(`public/sidebar.js`) does exactly that (`allSessions.filter(s => !s.parentSessionId)`) +after handing the full list to `buildSubagentIndex`; the DB side does the same +in `getTotalCounts` (`db.js`), which counts `WHERE parentSessionId IS NULL`. + +Two consumers had missed it and were fixed together: + +- the project-level **Archive all sessions** button — it counted subagents in + its confirmation prompt ("Archive all 87 sessions…" on a project with a + handful of real ones), archived each subagent transcript so it vanished from + under its parent, and called `stopSession` on any subagent id that happened + to be in `activePtyIds`; +- the status bar's `N sessions` total (`renderDefaultStatus`, `public/app.js`), + which disagreed with the stats panel's own total for the same reason. + +Covered by `test/dom-project-archive-all.test.js`. + +### The knock-on: the project vanished instead + +Not archiving the children exposed a latent hole in `processProjectSessions`'s +skip guard. After an archive-all, `buildProjectsFromCache(false)` drops the +now-archived parent rows but keeps the unarchived subagent rows, so +`project.sessions.length > 0` while `filtered` (top-level only) is empty — the +guard returned `null`, the render loop hit `continue`, and the project vanished +from the default view entirely: no header, and no orphan bucket either, because +that bucket lives inside `buildSessionsList`, past the `continue`. No data was +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. + ## 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/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/app.js b/public/app.js index 2d87a08f..94af87ca 100644 --- a/public/app.js +++ b/public/app.js @@ -1298,7 +1298,7 @@ window.api.onProjectsChanged(() => { let activityTimer = null; function renderDefaultStatus() { - const totalSessions = cachedAllProjects.reduce((n, p) => n + p.sessions.length, 0); + const totalSessions = cachedAllProjects.reduce((n, p) => n + p.sessions.filter(s => !s.parentSessionId).length, 0); const totalProjects = cachedAllProjects.length; const running = activePtyIds.size; const parts = []; diff --git a/public/sidebar.js b/public/sidebar.js index 5ebcf744..e0c4788c 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -439,7 +439,9 @@ function renderProjects(projects, resort) { }); } const anyFilterActive = showStarredOnly || showRunningOnly || showTodayOnly || searchMatchIds !== null; - if (filtered.length === 0 && !project._projectMatchedOnly && (project.sessions.length > 0 || anyFilterActive)) return null; + // see .ai/contexts/subagent-observability.md + const keepForOrphanSubagents = !anyFilterActive && subagentIndex.size > 0; + if (filtered.length === 0 && !project._projectMatchedOnly && !keepForOrphanSubagents && (project.sessions.length > 0 || anyFilterActive)) return null; // Sort filtered = [...filtered].sort((a, b) => { @@ -824,7 +826,7 @@ function rebindSidebarEvents(projects) { if (archiveGroupBtn) { archiveGroupBtn.onclick = async (e) => { e.stopPropagation(); - const sessions = project.sessions.filter(s => !s.archived); + const sessions = project.sessions.filter(s => !s.parentSessionId && !s.archived); if (sessions.length === 0) return; const shortName = shortProjectPath(project.projectPath); if (!confirm(`Archive all ${sessions.length} session${sessions.length > 1 ? 's' : ''} in ${shortName}?`)) return; diff --git a/test/dom-project-archive-all.test.js b/test/dom-project-archive-all.test.js new file mode 100644 index 00000000..fbbb9621 --- /dev/null +++ b/test/dom-project-archive-all.test.js @@ -0,0 +1,131 @@ +// Regression coverage for the project-level "Archive all sessions" button. +// See .ai/contexts/subagent-observability.md. + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { setupSidebarDom, makeSampleProject } = require('./dom-setup'); + +function installRecordingApi(ctx) { + const calls = []; + ctx.window.api = new Proxy({}, { + get(_target, prop) { + return (...args) => { + calls.push({ method: String(prop), args }); + return Promise.resolve({ ok: true }); + }; + }, + }); + return calls; +} + +function archiveButtonFor(ctx, project) { + const header = ctx.document.getElementById('ph-' + ctx.sidebar.folderId(project.projectPath)); + assert.ok(header, 'project header must render'); + const btn = header.querySelector('.project-archive-btn'); + assert.ok(btn, 'project archive button must render'); + return btn; +} + +test('project archive-all: confirmation counts only top-level sessions', async () => { + const ctx = setupSidebarDom(); + try { + const project = makeSampleProject(); + const calls = installRecordingApi(ctx); + let prompt = null; + ctx.window.confirm = (message) => { prompt = message; return false; }; + + ctx.sidebar.renderProjects([project], true); + await archiveButtonFor(ctx, project).onclick(new ctx.window.MouseEvent('click')); + + assert.match(prompt, /Archive all 1 session in /, + `confirmation must count 1 top-level session, got: ${prompt}`); + assert.deepEqual(calls, [], 'declining the confirmation must archive nothing'); + } finally { + ctx.destroy(); + } +}); + +test('project archive-all: subagents are neither archived nor stopped', async () => { + const ctx = setupSidebarDom(); + try { + const project = makeSampleProject(); + const calls = installRecordingApi(ctx); + ctx.window.confirm = () => true; + for (const id of ['s-top-1', 's-sub-1', 's-sub-2', 's-sub-orphan']) ctx.window.activePtyIds.add(id); + + ctx.sidebar.renderProjects([project], true); + await archiveButtonFor(ctx, project).onclick(new ctx.window.MouseEvent('click')); + + const archived = calls.filter(c => c.method === 'archiveSession').map(c => c.args[0]); + assert.deepEqual(archived, ['s-top-1'], 'only the unarchived top-level session may be archived'); + + const stopped = calls.filter(c => c.method === 'stopSession').map(c => c.args[0]); + assert.deepEqual(stopped, ['s-top-1'], 'stopSession must not reach subagent ids'); + + const subagents = project.sessions.filter(s => s.parentSessionId); + assert.ok(subagents.every(s => !s.archived), 'subagent objects must keep archived falsy'); + } finally { + ctx.destroy(); + } +}); + +test('project archive-all: project survives the re-render with only subagents left', async () => { + const ctx = setupSidebarDom(); + try { + const project = makeSampleProject(); + installRecordingApi(ctx); + ctx.window.confirm = () => true; + + ctx.sidebar.renderProjects([project], true); + await archiveButtonFor(ctx, project).onclick(new ctx.window.MouseEvent('click')); + + const surviving = project.sessions.filter(s => !s.archived); + assert.ok(surviving.length > 0 && surviving.every(s => s.parentSessionId), + 'precondition: only unarchived subagents remain after the click'); + + const reloaded = { ...project, sessions: surviving }; + ctx.sidebar.renderProjects([reloaded], true); + + const header = ctx.document.getElementById('ph-' + ctx.sidebar.folderId(project.projectPath)); + assert.ok(header, 'project header must survive when unarchived subagents remain'); + + const orphanGroup = ctx.document.querySelector('.sidebar-orphan-subagents'); + assert.ok(orphanGroup, 'remaining subagents must render in the orphan bucket'); + assert.equal(orphanGroup.querySelectorAll('[data-subagent]').length, surviving.length, + 'every surviving subagent must be rendered'); + } finally { + ctx.destroy(); + } +}); + +test('project archive-all: guard still hides a project whose top-level sessions are filtered out', () => { + const ctx = setupSidebarDom(); + try { + const project = makeSampleProject(); + ctx.window.showStarredOnly = true; + ctx.window.sessionMap.set('s-top-1', project.sessions[0]); + project.sessions[0].starred = false; + + ctx.sidebar.renderProjects([project], true); + + assert.equal(ctx.document.getElementById('ph-' + ctx.sidebar.folderId(project.projectPath)), null, + 'an active filter with no matching top-level session must still hide the project'); + } finally { + ctx.destroy(); + } +}); + +test('project archive-all: guard still renders an empty project', () => { + const ctx = setupSidebarDom(); + try { + const project = makeSampleProject({ sessions: [] }); + + ctx.sidebar.renderProjects([project], true); + + assert.ok(ctx.document.getElementById('ph-' + ctx.sidebar.folderId(project.projectPath)), + 'a project directory with no sessions at all must keep rendering'); + } finally { + ctx.destroy(); + } +});