From 749c80c87701ff7f84deb9fd84fcf53cd8a3900f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 22 Aug 2026 22:01:28 +0000 Subject: [PATCH] the server says which live sessions are commanders, and can return just one kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /chat/live already classified every row from `command_hq_sessions`; what it did not do was filter, so each client repeated that itself. Fine for the web list, which shows both tabs at once. Wrong for the iPhone app, which swipes between live chats, has no room for tabs, and must simply never be handed a commander. `?kind=agent|commander` does it server-side. Counts always describe the whole set regardless of the filter — the web tabs show both numbers side by side and both must be right whichever tab is open, so a filter that also hid the other count would force a second request to render one screen. An unrecognised kind is a 400 rather than "everything": a mistyped `?kind=agents` silently returning commanders is the precise swipe-order bug this exists to prevent, so the plural forms are accepted too, since the client's own type is already plural. And /chat/sessions no longer tags rows it is about to delete. It called listCommandHqSessionIds twice — once to mark commanders, once to remove exactly those rows four lines later — so isCommander was false on every row that survived. A field that could only ever lie, plus a second identical query per request to produce it. A list that excludes commanders has nothing to say about which of its rows is one; withCommanderFlag had no other caller and is gone. Not restarted: the shared checkout currently fails typecheck on another agent's untracked Minesweeper work, and a restart is when half-applied work ships. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/chat.ts | 51 +++++++++++++++++++------ src/servers/api/chat/claude-sessions.ts | 13 +------ 2 files changed, 41 insertions(+), 23 deletions(-) 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? *