From 6dae3e7a149b4f664d0f1cea49f2507e865022c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 21 Aug 2026 19:22:06 +0000 Subject: [PATCH] a commander's transcript was fetched, returned, and then ignored MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/apps/CommandHQ/CommandHqDetail.tsx | 74 +++++++++++-------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx index a4978f32..52008275 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx @@ -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 ( +
+ + +
+ ); +} + +/** 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(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
Loading…
; } - return ( -
- - -
- ); + return ; } export const CommandHqDetail = () => {