make the /chat url a permalink again

reloading /chat/<id>?cwd=<dir> landed on an empty general_chat_sessions instead
of the conversation. one line did both halves of it:

  if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`)

that ran on session:init, and session:init's sessionId is officer's own
per-connection key — websocket.ts mints it as `msg.sessionId || randomUUID()`.
/chat/sessions/:id resolves a CLAUDE TRANSCRIPT uuid, so the address bar ended
up naming something no lookup could find; the detail fetch 404'd and the catch
dropped you into a blank chat. the template also had no location.search, so
?cwd= — added later, for agent grounds — was thrown away every time the socket
connected, which is why the pwd picker fell back to the default group.

the transcript uuid is only known once the turn reports it, and it only started
crossing the wire in 7b6ca5f, so move the rewrite to `result`, use
claudeSessionId, and carry the query string through untouched. new chats gain a
working permalink too — /chat/new used to become an unresolvable id the same way.

mobile back had the same query-string hole; it now keeps the search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 02:15:30 +00:00
co-authored by Claude Opus 5
parent 7b6ca5f4ca
commit c591a8a771
3 changed files with 16 additions and 3 deletions
@@ -113,7 +113,9 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
locked
mobilePanelId={mobilePanelId}
onMobilePanelChange={(id) => {
if (!id) navigate('/chat', { replace: true });
// Keep the query string: ?cwd= names the project group the list is showing, so dropping it on
// mobile back sends you to an empty general_chat_sessions instead of the list you came from.
if (!id) navigate({ pathname: '/chat', search: window.location.search }, { replace: true });
}}
/>
</div>
@@ -68,7 +68,10 @@ export type ServerMessage =
parentToolUseId?: string;
}
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean; parentToolUseId?: string }
| { type: 'result'; sessionId: string; cost: MessageCost }
// `claudeSessionId` is Claude's OWN transcript uuid, and the only id `/chat/sessions/:id` can resolve.
// `sessionId` is officer's per-connection key (websocket.ts mints it with `msg.sessionId || randomUUID()`),
// which addresses nothing after the socket closes.
| { type: 'result'; sessionId: string; cost: MessageCost; claudeSessionId?: string }
| { type: 'sync:messages'; sessionId: string; messages: Message[]; isGenerating: boolean; streamingText: string }
| { type: 'error'; message: string; errorCode?: string }
| { type: 'stopped' }
@@ -219,7 +219,6 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
setSessionId(msg.sessionId);
setModel(msg.model);
setCwd(msg.cwd);
if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`);
break;
case 'assistant:delta':
@@ -276,6 +275,15 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
}
case 'result': {
// Make the address bar a permalink. This used to run on `session:init` writing `/chat/<sessionId>`,
// which was wrong twice over: that id is officer's per-connection key (`msg.sessionId ||
// randomUUID()`), which `/chat/sessions/:id` cannot resolve, and the template dropped
// `location.search` — so a reload lost both the conversation AND the `?cwd=` naming its project
// group, landing you in an empty general_chat_sessions. The transcript uuid is only known once the
// turn reports it, so wait for it, and carry the query string through untouched.
if (replaceUrl && msg.claudeSessionId) {
window.history.replaceState(null, '', `/chat/${msg.claudeSessionId}${window.location.search}`);
}
commitStreaming();
setMessages((prev) => [
...prev,