diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 13eb167c..50f1b770 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -20,7 +20,6 @@ import { listClaudePwds, listClaudeSessions, listAllClaudeSessions, - withCommanderFlag, loadClaudeSession, loadClaudeSessionById, deleteClaudeSession, @@ -130,15 +129,10 @@ chatRouter.get('/sessions', async (ctx) => { // the row renderer) is indifferent to which directories the rows came from. const all = ctx.req.query('all') === '1'; - // Which of these are commanders comes from the ROWS, not from where the transcript lives — a commander - // chooses its own working directory now, so the directory answers nothing. One query for the whole set. - const commanderIds = await listCommandHqSessionIds(user.id); - const claude = withCommanderFlag(all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd), commanderIds).map( - (s) => ({ - ...s, - harness: 'claude' as const, - }), - ); + const claude = (all ? listAllClaudeSessions(who) : listClaudeSessions(who, cwd)).map((s) => ({ + ...s, + harness: 'claude' as const, + })); // OpenCode's store is shared and unscoped, so for anyone but the owner this list is other people's // conversations. Empty rather than filtered: there is no per-user field to filter ON. // @@ -157,6 +151,12 @@ chatRouter.get('/sessions', async (ctx) => { Never fatal — a database hiccup on a feature /chat does not otherwise depend on must not take the whole conversation list down with it, the same posture `getChatSessionModels` below already takes. + + This route does NOT report `isCommander` on its rows, and that is not an omission. It used to: rows were + tagged from this same set a few lines above and then removed by it here, so the flag was false on every + row that survived — a field that could only ever lie, plus a second identical query to produce it. A list + that excludes commanders has nothing to say about which of its rows is one. `/chat/live` is where the + flag is real, because that route keeps both kinds. */ const filed = await listCommandHqSessionIds(user.id).catch(() => new Set()); const sessions = merged.filter((s) => !filed.has(s.id)); @@ -366,7 +366,36 @@ chatRouter.get('/live', async (ctx) => { }; }); - return ctx.json({ sessions: [...sessions, ...openCodeSessions] }); + const rows = [...sessions, ...openCodeSessions]; + + /* + `?kind=agent|commander` — the split, done here rather than by each client. + + The classification was already server-side; what was not was the FILTER, so every client repeated it. + That is fine for the web list, which has both tabs on screen, and wrong for the iPhone app, which swipes + between live chats, has no room for tabs, and must simply never be handed a commander. + + Counts always describe the WHOLE set, filtered or not. The web tabs show `Agents (n) / Commanders (n)` + side by side and both numbers have to be right whichever tab is open — a filter that also hid the other + count would force a second request to render one screen, which is how a server-side filter usually ends + up costing more than the client-side one it replaced. + + An unrecognised `kind` is a 400, never "everything". The plural forms are accepted because the client's + own type is already `'agents' | 'commanders'`, and the failure that silence would buy is precise: a + mistyped `?kind=agents` returning commanders is exactly the swipe-order bug this exists to prevent. + */ + const kind = ctx.req.query('kind'); + const counts = { + agents: rows.filter((s) => !s.isCommander).length, + commanders: rows.filter((s) => s.isCommander).length, + }; + if (kind !== undefined && !['agent', 'agents', 'commander', 'commanders'].includes(kind)) { + return ctx.json({ error: `Unknown kind '${kind}' — expected 'agent' or 'commander'` }, 400); + } + const wantCommanders = kind === 'commander' || kind === 'commanders'; + const shown = kind === undefined ? rows : rows.filter((s) => s.isCommander === wantCommanders); + + return ctx.json({ sessions: shown, counts }); }); // DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index dba70740..a959e41e 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -997,7 +997,7 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] { * * Identity is now the `command_hq_sessions` row, which is the only thing that survives the cwd being free. * It is applied by the CALLER, because that requires a database read and this function is synchronous over a - * directory scan — see `withCommanderFlag`. + * directory scan. */ export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] { return mergeChains(scanGroup(who, cwd)) @@ -1005,17 +1005,6 @@ export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessio .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } -/** - * Mark which of these are commanders, from the rows rather than from where they live. - * - * One query per request rather than one per session: `listCommandHqSessionIds` returns the whole set for an - * account, and a set membership test is what the per-row decoration needs. Commanders are few, so the set is - * small; the alternative — a lookup per transcript — would turn one list into fifty queries. - */ -export function withCommanderFlag(rows: T[], commanderIds: ReadonlySet): T[] { - return rows.map((row) => (commanderIds.has(row.id) ? { ...row, isCommander: true } : row)); -} - /** * Is this the default commanders directory? *