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,