diff --git a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx index 6583d7bb..a4978f32 100644 --- a/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx +++ b/src/workspaces/officerdev/src/apps/CommandHQ/CommandHqDetail.tsx @@ -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/` (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/` (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 (
-
- New commander -
+ {chat.claudeSessionId ? ( + + ) : ( +
+ New commander +
+ )}
); @@ -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(null); + + useEffect(() => { + if (startedHere && sessionId !== 'new' && sessionId !== startedHere) setStartedHere(null); + }, [sessionId, startedHere]); + if (!sessionId) return ; - if (sessionId === 'new') return ; + if (sessionId === 'new' || sessionId === startedHere) + return ; return ; }; diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index c1988807..0e60fdde 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -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;