"started" is a latch, not a message count

The pre-start pane keyed off `messages.length === 0`, which stands in for "not
started yet" right up to the moment it matters. Lose the socket mid-turn and the
messages come back empty, so the setup card and its Start button reappear on a
commander that is already running — and pressing Start sends the opening brief a
second time.

That happened tonight, after a restart I ran while a commander was live: the turn
died, the pane reset, Send re-sent the brief, and the commander was asked to
introduce itself twice. The owner deleted it and started over, which was the right
call and should not have been necessary.

So it latches. Once anything says this conversation exists — a message, a
transcript id, a turn in flight — it stays started. Deliberately not derived from
live state, since surviving the moment that state goes empty is the entire job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 00:11:04 +00:00
co-authored by Claude Opus 5
parent 5c802b07b5
commit a2ddf258b6
@@ -230,6 +230,23 @@ function NewCommander({
// updates nothing and says nothing. Same root cause as the navigate below.
const { setCategory } = useCommandHqCommanderActions(chat.claudeSessionId ?? '');
const [filed, setFiled] = useState(false);
/*
Has this commander been started — which is NOT the same as "has it any messages".
`messages.length === 0` was standing in for it, and it lies in exactly the situation that matters.
Lose the socket mid-turn and the messages come back empty, so the setup pane and its Start button
reappear on a commander that is already running; pressing Start then sends the opening brief a
SECOND time, and the agent answers a question nobody asked twice. Seen tonight, after a restart I
ran while a commander was live.
A latch, not a derived value. Once started, always started — the point is to survive the moment the
live state goes empty, so anything recomputed from that state is the bug rather than the fix.
*/
const [started, setStarted] = useState(false);
useEffect(() => {
if (chat.messages.length > 0 || chat.claudeSessionId || chat.isGenerating) setStarted(true);
}, [chat.messages.length, chat.claudeSessionId, chat.isGenerating]);
/*
Refresh the grid when the first turn ends.
@@ -313,7 +330,7 @@ function NewCommander({
So the scroller is here, where the height is actually constrained, and the composer below keeps
`shrink-0` so it stays reachable at the bottom whatever the zoom.
*/}
{chat.messages.length === 0 && (
{!started && (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<CommanderSetup name={name} onName={setName} cwd={cwd} onPickDirectory={() => setPickerOpen(true)} />
<StartCommanderButton
@@ -345,11 +362,9 @@ function NewCommander({
context="command-hq"
{...(cwd ? { cwd: { path: cwd } } : {})}
disabled={
chat.messages.length === 0 && !ready
? { reason: !name.trim() ? 'Name it first…' : 'Choose a working directory first…' }
: false
!started && !ready ? { reason: !name.trim() ? 'Name it first…' : 'Choose a working directory first…' } : false
}
className={chat.messages.length === 0 ? 'shrink-0' : 'min-h-0 flex-1'}
className={!started ? 'shrink-0' : 'min-h-0 flex-1'}
/>
<DirPickerModal open={pickerOpen} onClose={() => setPickerOpen(false)} onSelect={setCwd} />
</div>