diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index bc0c395e..5b06b431 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -1,5 +1,5 @@ -import { dirname, join } from 'node:path'; -import { DATA_PATH } from '../../data-path'; +import { dirname, join, resolve } from 'node:path'; +import { DATA_PATH, getCommandersDir } from '../../data-path'; import { appendTextAs, findPathAs, @@ -108,6 +108,24 @@ export type ClaudeSessionSummary = { * one conversation — see `mergeChains`. Absent means the ordinary case: one row, one transcript. */ partCount?: number; + /** + * This is a Command HQ commander, not an ordinary chat. + * + * ── Why it is a field and not a frontend detail ── + * + * A commander IS a Claude session, and this list reads transcripts from disk, so nothing distinguished + * the two and commanders appeared in the chat list looking like conversations somebody started and + * forgot. The web can now split them into tabs — but the reason this belongs in the PAYLOAD is the + * phone: the iPhone chat app swipes left and right between live chats, and with no tabs to hide behind + * it has to be able to filter commanders out of that gesture itself. A flag only the web knew about + * would fix one surface and leave the other wrong. + * + * Derived from the CWD rather than from a database join. Every commander a user has shares one + * directory — `getCommandersDir(email)`, never per-commander and never client-chosen — and this list + * already reads each session's `cwd`. So it costs a string comparison, works for a commander whose + * `command_hq_sessions` row was lost, and adds no query. + */ + isCommander?: boolean; }; /** `ClaudeSessionSummary` plus the bits only lineage resolution needs. Never leaves this module. */ @@ -968,11 +986,31 @@ function scanGroup(who: ChatIdentity, cwd: string): TranscriptSummary[] { /** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */ export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessionSummary[] { + // Every commander a user has lives in ONE directory, so this is a property of the group rather than of + // each transcript — computed once here instead of per row. See `isCommander` on the summary type for + // why the flag has to reach the payload at all. + const commander = isCommandersDir(who.email, cwd); return mergeChains(scanGroup(who, cwd)) .map(publish) + .map((entry) => (commander ? { ...entry, isCommander: true } : entry)) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } +/** + * Is this directory the one every commander shares? + * + * Compared after `resolve`, so a trailing slash or a `..` cannot make the same directory look like a + * different one — the cwd here comes from a transcript rather than from code, and transcripts record + * whatever the process was actually in. + */ +function isCommandersDir(email: string, cwd: string): boolean { + try { + return resolve(cwd) === resolve(getCommandersDir(email)); + } catch { + return false; + } +} + /** * Every conversation in every directory, newest first. *