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
58 changes: 58 additions & 0 deletions .ai/contexts/subagent-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<folder>/<parent>/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
Expand Down
2 changes: 1 addition & 1 deletion public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
6 changes: 4 additions & 2 deletions public/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
Expand Down
131 changes: 131 additions & 0 deletions test/dom-project-archive-all.test.js
Original file line number Diff line number Diff line change
@@ -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();
}
});
Loading