a commander's transcript was fetched, returned, and then ignored

The pane still showed "Send a message to start" over a bar reading 1 turn · $0.28. The id fix was
necessary and was not the cause.

useChat seeds its message list with useState(initialMessages ?? []) — read on the first render and never
again. ExistingCommander fetched the transcript AND called useChat in one component body, so the hook's
first render happened with resume still undefined and seeded itself empty. The messages landed a moment
later and the hook had no reason to look. The early `Loading…` return does not help: hooks run above it.

ChatPanelWrapper has always avoided this, and the line that does it is easy to read as a loading
nicety — `if (selectedSessionId && resuming) return null` sits ABOVE the component that owns the hook, so
the hook's first render is its only render, and it has real data. Split the same way here: a fetcher, and
a chat mounted once there is a transcript to mount it with.

Verified server-side while looking for this: loadClaudeSessionById returns the six messages for
172cdd8c, ending on the commander's answer. The data was always there and always reachable. Only the
pane could not see it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 19:22:06 +00:00
co-authored by Claude Opus 5
parent 2379260ffe
commit 6dae3e7a14
@@ -258,6 +258,49 @@ type ResumeState = {
title: string | null;
};
/**
* The chat itself, mounted only once the transcript is in hand and keyed on the session.
*
* Split from the fetch above, and that split is the whole point. `useChat` seeds its message list with
* `useState(initialMessages ?? [])` — read on the FIRST render and never again. So a component that
* fetches and calls `useChat` in one body seeds itself empty, because on that first render the fetch has
* not landed. The early `Loading…` return does not save it: hooks run before it. The messages arrive, the
* hook ignores them, and a commander that had run and answered showed "Send a message to start" over a
* metadata bar reading `1 turn · $0.28`.
*
* `ChatPanelWrapper` has always done it this way — `if (selectedSessionId && resuming) return null` sits
* ABOVE the component that owns the hook, so the hook's first render is its only render with real data.
* Same shape here.
*/
function ResumedCommander({ sessionId, resume }: { sessionId: string; resume: ResumeState }) {
const chat = useChat(undefined, resume.model ?? undefined, {
replaceUrl: false,
context: 'command-hq',
resumeSessionId: sessionId,
initialMessages: resume.messages,
paginate: { sessionId, total: resume.total, initialOffset: resume.offset },
});
return (
<div className="flex h-full flex-col">
<MetaBar
sessionId={sessionId}
title={resume.title ?? undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
/>
<EmbeddableChat
chat={chat}
sessionId={undefined}
panelId={`command-hq:${sessionId}`}
className="min-h-0 flex-1"
/>
</div>
);
}
/** Fetches the transcript and nothing else. Mounts the chat only when it has one. */
function ExistingCommander({ sessionId }: { sessionId: string }) {
const client = useClient();
const [resume, setResume] = useState<ResumeState | undefined>(undefined);
@@ -290,40 +333,11 @@ function ExistingCommander({ sessionId }: { sessionId: string }) {
};
}, [sessionId]);
const chat = useChat(undefined, resume?.model ?? undefined, {
replaceUrl: false,
context: 'command-hq',
...(resume
? {
resumeSessionId: sessionId,
initialMessages: resume.messages,
paginate: { sessionId, total: resume.total, initialOffset: resume.offset },
}
: {}),
});
if (resuming || !resume) {
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Loading…</div>;
}
return (
<div className="flex h-full flex-col">
<MetaBar
sessionId={sessionId}
title={resume.title ?? undefined}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
/>
<EmbeddableChat
key={sessionId}
chat={chat}
sessionId={undefined}
panelId={`command-hq:${sessionId}`}
className="min-h-0 flex-1"
/>
</div>
);
return <ResumedCommander key={sessionId} sessionId={sessionId} resume={resume} />;
}
export const CommandHqDetail = () => {