diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 66c4e018..9345d179 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -27,6 +27,7 @@ import { stopBackgroundTask, claudeSessionContext, liveSessionTitle, + isCommandersDir, } from './claude-sessions'; import { listOpenCodeSessions, @@ -287,7 +288,21 @@ chatRouter.get('/live', async (ctx) => { // genuinely not been written yet. const transcriptId = session.claudeSessionId; const resolved = transcriptId && !isOpenCodeSessionId(transcriptId) ? liveSessionTitle(who, transcriptId) : null; - return { ...session, harness: 'claude' as const, title: resolved?.title ?? null, cwd: resolved?.cwd ?? null }; + const cwd = resolved?.cwd ?? null; + return { + ...session, + harness: 'claude' as const, + title: resolved?.title ?? null, + cwd, + // Derived from the cwd rather than looked up in `command_hq_sessions`, for the same reason the + // history listing does it: every commander shares one directory, so the directory IS the answer and + // it needs no query. It also stays right for a commander mid-first-turn, before any row exists. + // + // This field is why the route changed at all. The iPhone app swipes between LIVE chats and has no + // room for tabs, so it can only exclude commanders if the payload says which ones they are — and a + // deploy commander turning up in that swipe order is exactly what it must not do. + isCommander: cwd ? isCommandersDir(who.email, cwd) : false, + }; }); // OpenCode rows carry less, and the shape says so rather than faking parity. `isGenerating` is always @@ -326,6 +341,9 @@ chatRouter.get('/live', async (ctx) => { // shown a live row without a name. title: meta?.title && meta.title !== '(untitled)' ? meta.title : (getOpenCodePrompt(sessionKey) ?? null), cwd: meta?.cwd || null, + // Always false rather than absent: a commander is started through Command HQ, which runs claude. + // Stating it keeps the two row shapes answerable by one client filter. + isCommander: false, }; }); diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 5b06b431..4a19d92a 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -1003,7 +1003,7 @@ export function listClaudeSessions(who: ChatIdentity, cwd: string): ClaudeSessio * 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 { +export function isCommandersDir(email: string, cwd: string): boolean { try { return resolve(cwd) === resolve(getCommandersDir(email)); } catch { diff --git a/src/servers/api/chat/is-commanders-dir.test.ts b/src/servers/api/chat/is-commanders-dir.test.ts new file mode 100644 index 00000000..3c5b8244 --- /dev/null +++ b/src/servers/api/chat/is-commanders-dir.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'bun:test'; +import { resolve } from 'node:path'; +import { isCommandersDir } from './claude-sessions'; +import { getCommandersDir } from '../../data-path'; + +const EMAIL = 'pastilhas@officer.dev'; + +// The live payload's `isCommander` is derived from a cwd read out of a transcript, so it is whatever the +// process was actually in — not a path this code composed. These are the shapes that arrive. +describe('isCommandersDir', () => { + const dir = getCommandersDir(EMAIL); + + it('recognises the commanders directory', () => { + expect(isCommandersDir(EMAIL, dir)).toBe(true); + }); + + it('survives a trailing slash and a .. hop', () => { + expect(isCommandersDir(EMAIL, `${dir}/`)).toBe(true); + expect(isCommandersDir(EMAIL, `${dir}/sub/..`)).toBe(true); + }); + + it('rejects a sibling and a child', () => { + expect(isCommandersDir(EMAIL, resolve(dir, '..'))).toBe(false); + expect(isCommandersDir(EMAIL, resolve(dir, 'nested'))).toBe(false); + }); + + // A commander's cwd belongs to the account that owns it. Reading one user's directory as another's + // would file their commanders under the wrong person and, in the iPhone swipe order, show them. + it('is per account', () => { + expect(isCommandersDir('someone-else@officer.dev', dir)).toBe(false); + }); + + it('never throws on rubbish', () => { + expect(isCommandersDir(EMAIL, '')).toBe(false); + expect(isCommandersDir(EMAIL, '\0')).toBe(false); + }); +}); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index d71b0f50..b93c4d5c 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; -import { MessageSquare, Plus, RefreshCw } from 'lucide-react'; +import { MessageSquare, Plus, Radar, RefreshCw } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem } from '@/components/Data'; import { useLocalStorageState } from 'hooks/useLocalStorageState'; @@ -9,6 +9,7 @@ import { useChatPwds, useClaudeSessions } from 'state/useClaudeSessions'; import type { LiveSession } from 'state/useLiveSessions'; import { useLiveSessions } from 'state/useLiveSessions'; import { useSelectedChatSession } from '../../channels'; +import { commandHqSessionPath } from '../CommandHQ/command-hq-routes'; import { chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes'; import { DirPickerModal } from './DirPickerModal'; import { LiveMarker, SessionRow, shortenPath, useScrollActiveIntoView, useSessionRowActions } from './SessionRow'; @@ -47,6 +48,8 @@ const TABS: { value: View; label: string }[] = [ localStorage also happens to be the right scope: a phone and a desktop wanting different tabs is reasonable. */ const VIEW_KEY = 'chat.sessions.view'; +/* Which half of Live is showing. Same store and the same reasoning as the tab above it. */ +const LIVE_KIND_KEY = 'chat.sessions.liveKind'; const EXPANDED_KEY = 'chat.sessions.expanded'; export const SessionList = () => { @@ -273,8 +276,24 @@ export const SessionList = () => { few seconds before the harness announces one, and a row that navigates nowhere is worse than one that plainly cannot be clicked. */ +type LiveKind = 'agents' | 'commanders'; + +const LIVE_TABS: { value: LiveKind; label: string }[] = [ + { value: 'agents', label: 'Agents' }, + { value: 'commanders', label: 'Commanders' }, +]; + const LiveView = ({ activeId, home }: { activeId?: string; home: string | null }) => { const { live: sessions, isLoading, error, refetch } = useLiveSessions(); + const [kind, setKind] = useLocalStorageState(LIVE_KIND_KEY, 'agents'); + + /* + Split, not filtered-on-demand, because both counts are always on screen — the tabs exist to be + compared, so both numbers have to be right whichever one is showing. + */ + const agents = useMemo(() => sessions.filter((s) => !s.isCommander), [sessions]); + const commanders = useMemo(() => sessions.filter((s) => s.isCommander), [sessions]); + const shown = kind === 'commanders' ? commanders : agents; if (error) { return ( @@ -293,22 +312,49 @@ const LiveView = ({ activeId, home }: { activeId?: string; home: string | null } ); } if (isLoading && sessions.length === 0) return ; - if (!sessions.length) { - return ( - - ); - } return ( - - {sessions.map((session) => ( - - ))} - +
+ {/* Counts shown even at zero, unlike the badge on the Live tab above. That badge answers "is anything + running", where a standing `0` says nothing. These two answer "which kind, and how many", and the + empty one is half the answer — `Commanders (0)` tells you the watcher is not running, which is + the thing you came to check. */} + setKind(v as LiveKind)} className="shrink-0 px-2 pt-2"> + + {LIVE_TABS.map((tab) => { + const count = tab.value === 'commanders' ? commanders.length : agents.length; + return ( + + {tab.label} + 0 ? 'text-success' : 'opacity-50'}`}>({count}) + + ); + })} + + + + {shown.length === 0 ? ( + + ) : ( + + {shown.map((session) => ( + + ))} + + )} +
); }; @@ -322,9 +368,16 @@ const LiveView = ({ activeId, home }: { activeId?: string; home: string | null } */ const LiveRow = ({ session, activeId, home }: { session: LiveSession; activeId?: string; home: string | null }) => { const transcriptId = session.claudeSessionId; + // A commander opens in Command HQ. `/chat/` would render the same transcript with none of what makes + // it a commander legible — no category, no cost, no watcher state, and no way to disarm it. + const to = transcriptId + ? session.isCommander + ? commandHqSessionPath(transcriptId, undefined) + : chatSessionPath(transcriptId) + : undefined; return (