From 21b893473d52fdaf98e3466a07353275871a4f3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 21 Aug 2026 18:59:03 +0000 Subject: [PATCH] a new commander showed "Send a message to start" after answering correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first deploy commander ran, cost $0.66, and reported exactly the right thing — and the pane showed an empty state, because the page was fetching an id nothing knows. Two ids for one commander. `session:init` sets `sessionId` to OFFICER's key and arrives first; `session:claude` carries Claude's transcript id and arrives later. `fileAsCommander` files the row under the transcript id, and `command_hq_sessions` is keyed on it. But NewCommander navigated on `chat.sessionId` — the first one it could see — and then set `filed`, which blocked the correction when the real id turned up. So the URL held officer's key, /api/chat/sessions/ 404'd twice, and the transcript was unreachable from the UI while sitting perfectly readable on disk. The transcript id was only ever in a REF, deliberately — the reconnect handshake reads it synchronously — so writing it re-rendered nothing and no caller could react to it. It is now state as well, returned alongside sessionId, with the ref kept for the handshake that needs it. setCategory had the same fault and would have silently updated no row: it was keyed on officer's key against a table keyed on the transcript id. Co-Authored-By: Claude Opus 5 --- .../src/apps/CommandHQ/CommandHqDetail.tsx | 16 ++++++++++++---- src/workspaces/officerdev/src/hooks/useChat.ts | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx index eb0eb709..6583d7bb 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx @@ -41,20 +41,28 @@ function EmptyState() { function NewCommander({ category }: { category: number | 'none' | undefined }) { const navigate = useNavigate(); const chat = useChat(undefined, undefined, { replaceUrl: false, context: 'command-hq' }); - const { setCategory } = useCommandHqCommanderActions(chat.sessionId ?? ''); + // Also the transcript id: `command_hq_sessions` is keyed on it, so an action addressed by officer's key + // updates nothing and says nothing. Same root cause as the navigate below. + const { setCategory } = useCommandHqCommanderActions(chat.claudeSessionId ?? ''); const [filed, setFiled] = useState(false); useEffect(() => { - if (!chat.sessionId || filed) return; + // `claudeSessionId`, NOT `sessionId`. The commander is filed under Claude's transcript id + // (`fileAsCommander` in websocket.ts), and this URL has to match or the detail pane fetches an id + // nothing knows. `sessionId` arrives FIRST — `session:init` sets it to officer's own key — so + // navigating on it sent the page to an id that 404s, and `filed` then blocked the correction when the + // real one arrived. A commander that had run and answered correctly rendered as "Send a message to + // start". Waiting for the transcript id costs one more round trip and is the only id that works. + if (!chat.claudeSessionId || filed) return; setFiled(true); // `fileAsCommander` (websocket.ts) always files with no category — it has no way to know one was // requested from here. The same race the `/commanders/new` HTTP route resolves server-side by always // 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(() => {}); - navigate(commandHqSessionPath(chat.sessionId, category), { replace: true }); + navigate(commandHqSessionPath(chat.claudeSessionId, category), { replace: true }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chat.sessionId, filed]); + }, [chat.claudeSessionId, filed]); return (
diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 7f397701..c1988807 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -185,6 +185,19 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, // `session:claude` (start of turn) and `result` (end), so a chat started in this tab becomes // reattachable the moment the harness names its transcript rather than when the turn finishes. const claudeSessionIdRef = useRef(resumeSessionId ?? null); + /** + * The same value as state, for callers that have to RENDER or navigate on it. + * + * The ref above cannot serve that: it is deliberately a ref so the reconnect handshake can read it + * synchronously, which means writing it triggers no re-render and nothing outside can react to it. + * + * Command HQ needed exactly that and could not have it. `NewCommander` navigates once the transcript + * id is known, so a refresh resumes the same conversation — but the only id it could see was + * `sessionId`, which `session:init` sets to OFFICER's key. So it navigated to the wrong id, the + * commander was filed under the transcript id, and every fetch for that page 404'd: a commander that + * had genuinely run, cost money and answered correctly showed "Send a message to start". + */ + const [claudeSessionId, setClaudeSessionId] = useState(resumeSessionId ?? null); const client = useClient(); @@ -391,6 +404,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, // (`msg.sessionId || randomUUID()`) that `/chat/sessions/:id` cannot resolve. if (msg.claudeSessionId) { claudeSessionIdRef.current = msg.claudeSessionId; + setClaudeSessionId(msg.claudeSessionId); if (replaceUrl) writePermalink(msg.claudeSessionId); } commitStreaming(); @@ -830,6 +844,8 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, isConnected, isGenerating, sessionId, + /** Claude's own transcript id — what a commander is filed under, and what a permalink must use. */ + claudeSessionId, model, selectedModel, hasStarted,