From ebfa09e067336b59ef91f76e24ccf016d4e74662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 21 Aug 2026 19:25:17 +0000 Subject: [PATCH] live splits into Agents and Commanders, and the payload says which is which MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A commander is a chat by every mechanism it uses, so it turned up in Live beside the conversations somebody is actually having. With one deploy commander that is untidy. With a watcher armed per repo it is a list where the thing you are looking for is outnumbered. Two tabs inside Live, counts always shown. That last part differs from the badge on the Live tab above it, which hides at zero because a standing `0` there says nothing. These two say a lot at zero: `Commanders (0)` is how you find out the watcher is not running, which is usually why you looked. The flag is derived server-side from the cwd, not from a `command_hq_sessions` lookup — every commander shares one directory, so the directory IS the answer, it costs no query, and it is already right during a commander's first turn, before any row exists. It has to be in the payload rather than computed in the panel, and that is the point of the change: the iPhone app swipes between LIVE chats and has no room for tabs. Filtering there needs the server to name them. A deploy commander appearing in that swipe order is precisely what must not happen. A commander row also now opens Command HQ instead of /chat. Same transcript either way, but /chat renders it with nothing that makes it a commander legible — no category, no cost, no watcher state, and no way to disarm it. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/chat.ts | 20 ++++- src/servers/api/chat/claude-sessions.ts | 2 +- .../api/chat/is-commanders-dir.test.ts | 37 ++++++++ .../src/apps/ChatHistory/SessionList.tsx | 85 +++++++++++++++---- src/workspaces/state/src/useLiveSessions.ts | 8 ++ 5 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 src/servers/api/chat/is-commanders-dir.test.ts 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 (