chat urls: a group is a path, a session decides its own directory

the chat group moves from ?cwd= to a path suffix behind a g/ discriminator
(/chat/g/home/me/project), and a session url carries no group at all.

the real fix is not the spelling. a session's working directory was read back
off the query string to decide where the agent executes, so the address bar was
the authority on where code runs. a pasted or refreshed /chat/<id> arrives with
no ?cwd= at all, so a turn sent before the resolve landed ran in the default
general_chat_sessions dir instead of the project; and a hand-edited ?cwd= could
name a group the session doesn't belong to, with nothing to reconcile them.

loadClaudeSessionById already resolves a session's cwd from the id alone, so the
id is the only source of truth there. it now travels on SelectedSession.cwd,
which is what the composer reads. the url can no longer contradict it.

the vocabulary lives in apps/ChatHistory/chat-routes.ts so a link built in a
panel and one built in a screen cannot drift.

also: startAgentRun no longer returns a literal chatUrl — it returns cwd and
AgentRunnerModal builds the link, so the server holds no copy of the frontend
url shape. and the post-turn permalink strips a stale ?cwd= instead of carrying
it forward onto the new session's url.

walkthrough doc gains item 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 23:59:39 +00:00
co-authored by Claude Opus 5
parent 203f65d03f
commit 236541fa2a
12 changed files with 225 additions and 68 deletions
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef } from 'react';
import { useParams, useNavigate, useSearchParams } from 'react-router';
import { useParams, useNavigate } from 'react-router';
import type { LayoutNode, SelectedSession } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { WorkspaceView, chatListPath, cwdFromSplat } from 'officerdev';
import { toast } from '@/components/ui/sonner';
import { useIsMobile } from 'hooks/useIsMobile';
import { useClient } from 'hooks/useClient';
import { errorText } from 'helpers/error-text';
import { useDashboardState } from 'state/useDashboardState';
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
import { usePanelChannel } from 'hooks/usePanelChannel';
@@ -37,9 +38,10 @@ type SessionListPageProps = {
};
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const { sessionId } = useParams<{ sessionId: string }>();
// The group is a path suffix now, not ?cwd= — see officerdev/apps/ChatHistory/chat-routes.ts.
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
const groupCwd = cwdFromSplat(splat);
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [, setSearchParams] = useSearchParams();
const client = useClient();
const selectedRef = useRef(selected);
selectedRef.current = selected;
@@ -69,7 +71,9 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
// scrolling up lazy-loads older messages. `total`/`offset` tell the chat where the window sits.
useEffect(() => {
if (isNew) {
setSelected({ id: `new:${Date.now()}` });
// A new chat has no transcript to read a cwd from, so the group in the URL is the authority —
// and it has to be on the selection, because that is what the composer runs in.
setSelected({ id: `new:${Date.now()}`, cwd: groupCwd });
return;
}
if (!sessionId) return;
@@ -79,16 +83,9 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
try {
const detail = await client.get<ClaudeSessionDetail>(`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`);
if (cancelled) return;
// replace: resolving a deep link is a canonicalisation, not a navigation the back button owes you.
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (detail.cwd) next.set('cwd', detail.cwd);
else next.delete('cwd');
return next;
},
{ replace: true },
);
// The session's own cwd rides on the selection rather than being written back into the URL.
// It used to do both, and the URL copy was the one the composer read — so a deep link ran its
// first turn in the default dir during the window before this resolve landed.
setSelected({
id: sessionId,
model: detail.model,
@@ -96,13 +93,14 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
initialMessages: detail.messages as unknown as NonNullable<SelectedSession>['initialMessages'],
total: detail.total,
initialOffset: detail.offset,
cwd: detail.cwd,
});
} catch (err) {
if (cancelled) return;
// Falling back to a bare id still opens a usable pane, but silently: you get an empty chat and
// no hint that the transcript could not be read, which is indistinguishable from a new session.
// Most often the id is stale — the transcript was deleted or pruned out from under the link.
toast.error(`Could not load this conversation: ${err instanceof Error ? err.message : 'not found'}`);
toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`);
setSelected({ id: sessionId });
}
})();
@@ -110,7 +108,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, isNew]);
}, [sessionId, isNew, groupCwd]);
return (
<div className="h-full w-full pt-2">
@@ -119,9 +117,10 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
locked
mobilePanelId={mobilePanelId}
onMobilePanelChange={(id) => {
// 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 });
// Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL;
// on /chat/<id> it isn't (deliberately — see chat-routes.ts), so fall back to the open
// session's own directory, which the resolve above put on the selection.
if (!id) navigate(chatListPath(groupCwd ?? selectedRef.current?.cwd ?? null), { replace: true });
}}
/>
</div>