live splits into Agents and Commanders, and the payload says which is which

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:25:17 +00:00
co-authored by Claude Opus 5
parent 6dae3e7a14
commit ebfa09e067
5 changed files with 134 additions and 18 deletions
+19 -1
View File
@@ -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,
};
});
+1 -1
View File
@@ -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 {
@@ -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);
});
});
@@ -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<LiveKind>(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 <LoadingBlock label="Asking the agent…" />;
if (!sessions.length) {
return (
<EmptyBlock
icon={MessageSquare}
title="Nothing running"
hint="Conversations with an agent behind them right now show up here."
/>
);
}
return (
<DataList>
{sessions.map((session) => (
<LiveRow key={session.sessionKey} session={session} activeId={activeId} home={home} />
))}
</DataList>
<div className="flex min-h-0 flex-1 flex-col">
{/* 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. */}
<Tabs value={kind} onValueChange={(v) => setKind(v as LiveKind)} className="shrink-0 px-2 pt-2">
<TabsList className="flex h-auto w-full items-center gap-1 bg-transparent p-0 text-muted-foreground">
{LIVE_TABS.map((tab) => {
const count = tab.value === 'commanders' ? commanders.length : agents.length;
return (
<TabsTrigger
key={tab.value}
value={tab.value}
className="flex-1 cursor-pointer rounded-md border border-transparent px-2 py-1 text-xs font-medium leading-normal ring-0 transition-all hover:bg-foreground/5 hover:text-foreground focus-visible:ring-0 data-[state=active]:border-border data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
>
{tab.label}
<span className={`ml-1 tabular-nums ${count > 0 ? 'text-success' : 'opacity-50'}`}>({count})</span>
</TabsTrigger>
);
})}
</TabsList>
</Tabs>
{shown.length === 0 ? (
<EmptyBlock
icon={kind === 'commanders' ? Radar : MessageSquare}
title={kind === 'commanders' ? 'No commander running' : 'Nothing running'}
hint={
kind === 'commanders'
? 'A commander appears here while it is taking a turn — including a turn nobody asked for.'
: 'Conversations with an agent behind them right now show up here.'
}
/>
) : (
<DataList>
{shown.map((session) => (
<LiveRow key={session.sessionKey} session={session} activeId={activeId} home={home} />
))}
</DataList>
)}
</div>
);
};
@@ -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/<id>` 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 (
<DataRow
to={transcriptId ? chatSessionPath(transcriptId) : undefined}
to={to}
title={session.title ?? 'Starting…'}
selected={!!transcriptId && transcriptId === activeId}
meta={[
@@ -26,6 +26,14 @@ export type LiveSession = {
*/
title: string | null;
cwd: string | null;
/**
* A commander rather than an ordinary chat, derived server-side from the cwd.
*
* Every consumer of this list needs it, and one of them cannot work around its absence. The sessions
* panel splits Live into two tabs; the iPhone app swipes between live chats, has no tabs, and so can only
* keep a deploy commander out of that swipe order if the payload names it.
*/
isCommander: boolean;
};
const LIVE_KEY = 'CHAT_LIVE_SESSIONS';