the commander pane threw its own conversation away at the end of the first turn

The messages were on screen, streaming, correct. Then the turn ended and the pane went empty under a
metadata bar still reading 1 turn · $0.66.

Nothing was lost — the pane discarded it. Once the transcript id was known, NewCommander navigated onto
it, and CommandHqDetail answered that URL with a DIFFERENT component: ExistingCommander, keyed on the id.
So React unmounted the live chat, closed its socket, and re-read the transcript from the API — a file the
harness had only just finished writing. What survived was whatever that fetch returned, which was nothing.

A permalink is for a refresh, not an instruction to reload. /chat has always understood that: useChat
replaces the URL and remounts nothing. This pane replaced the URL and remounted everything.

So the pane now keeps the session it started. CommandHqDetail remembers which id came from its own
composer and keeps that component mounted while the URL names it, swapping to the resuming component only
for a commander opened from the list. Clearing the memory on a real navigation means coming back later
still resumes from the transcript rather than remounting an empty live chat.

Worse than the empty pane, and the reason this is not cosmetic: the teardown fired at session:claude for
any chat where the harness names its transcript early — the START of a turn. A commander's turns run for
tens of minutes. The pane would have dropped the stream and shown a reader nothing at all while a deploy
rolled across seven servers.

claudeSessionId is now also set at session:claude, not only at result, matching the ref beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:10:54 +00:00
co-authored by Claude Opus 5
parent 21b893473d
commit 2379260ffe
2 changed files with 51 additions and 9 deletions
@@ -33,12 +33,31 @@ function EmptyState() {
}
/**
* Starts a brand-new commander. `useChat` mints the transcript id on the harness's first response
* (`session:claude`) — once that lands, the URL is replaced onto the real id so a refresh resumes the
* same conversation instead of starting a second one. `replaceUrl: false` because `useChat`'s own permalink
* always writes `/chat/<id>` (see `writePermalink` in `useChat.ts`), which is the wrong screen here.
* A commander started in THIS pane, and it keeps the same live chat for as long as the pane is open.
*
* `useChat` mints the transcript id on the harness's first response (`session:claude`); once that lands the
* URL is replaced onto it so a refresh resumes this conversation instead of starting a second one.
* `replaceUrl: false` because `useChat`'s own permalink always writes `/chat/<id>` (see `writePermalink`),
* which is the wrong screen here.
*
* **The URL change must not remount anything.** It used to: the pane swapped to `ExistingCommander`, which
* tore down the live websocket and re-read the transcript from the API. The whole conversation vanished at
* the end of the first turn — the messages were in memory, the replacement read a file still being
* written, and what the owner saw was an empty composer under a metadata bar reading `1 turn · $0.66`.
* Tearing down mid-turn is worse than it looks with a commander, whose turns are long: the pane would drop
* the stream and the reader would watch nothing happen for twenty minutes.
*
* So this component holds the session from the composer through to the end, exactly as `/chat` does — a
* permalink is for a refresh, not an instruction to reload. `CommandHqDetail` keeps it mounted by
* remembering which id started here.
*/
function NewCommander({ category }: { category: number | 'none' | undefined }) {
function NewCommander({
category,
onStarted,
}: {
category: number | 'none' | undefined;
onStarted: (id: string) => void;
}) {
const navigate = useNavigate();
const chat = useChat(undefined, undefined, { replaceUrl: false, context: 'command-hq' });
// Also the transcript id: `command_hq_sessions` is keyed on it, so an action addressed by officer's key
@@ -60,15 +79,26 @@ function NewCommander({ category }: { category: number | 'none' | undefined }) {
// running its UPDATE last; done client-side here for the same reason, since the web composer goes
// through the websocket path instead.
if (typeof category === 'number') void setCategory.mutateAsync(category).catch(() => {});
onStarted(chat.claudeSessionId);
navigate(commandHqSessionPath(chat.claudeSessionId, category), { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chat.claudeSessionId, filed]);
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-4 py-2 text-sm font-medium text-foreground/80">
New commander
</div>
{chat.claudeSessionId ? (
<MetaBar
sessionId={chat.claudeSessionId}
title={undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
/>
) : (
<div className="flex shrink-0 items-center gap-2 border-b border-border bg-background/60 px-4 py-2 text-sm font-medium text-foreground/80">
New commander
</div>
)}
<EmbeddableChat chat={chat} sessionId={undefined} className="min-h-0 flex-1" />
</div>
);
@@ -301,7 +331,18 @@ export const CommandHqDetail = () => {
const [searchParams] = useSearchParams();
const category = categoryFromParam(searchParams.get('category'));
// The id of a commander started in this pane. While the URL still names it, `NewCommander` stays mounted
// and keeps its live socket — swapping to `ExistingCommander` here is what used to make the conversation
// disappear the moment the URL was replaced. Cleared as soon as the owner opens a different commander,
// so coming back later resumes from the transcript properly instead of showing an empty remount.
const [startedHere, setStartedHere] = useState<string | null>(null);
useEffect(() => {
if (startedHere && sessionId !== 'new' && sessionId !== startedHere) setStartedHere(null);
}, [sessionId, startedHere]);
if (!sessionId) return <EmptyState />;
if (sessionId === 'new') return <NewCommander category={category} />;
if (sessionId === 'new' || sessionId === startedHere)
return <NewCommander category={category} onStarted={setStartedHere} />;
return <ExistingCommander key={sessionId} sessionId={sessionId} />;
};
@@ -341,6 +341,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
// here rather than at `result` is the difference between "refresh mid-turn and lose the turn" and
// "refresh mid-turn and watch it carry on".
claudeSessionIdRef.current = msg.claudeSessionId;
setClaudeSessionId(msg.claudeSessionId);
if (replaceUrl) writePermalink(msg.claudeSessionId);
break;