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(); + } +});