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
+63
View File
@@ -212,6 +212,69 @@ be built, but it is its own piece of work.
---
## 13. The chat URL: `?cwd=` is gone, and a session decides its own directory
**Where:** the address bar, everywhere in `/chat`. Also the **Run agent** dialog in the file browser
right-click menu.
**What to do:**
1. Open `/chat`, pick a project in the pwd dropdown. The URL is now `/chat/g/home/pastilhas/dockers/…`
a readable path, not `?cwd=%2Fhome%2Fpastilhas%2F…`.
2. Click a conversation. The URL becomes just `/chat/<uuid>`**no group at all**. Copy it.
3. Paste it into a fresh tab. It should open the same conversation, with the list beside it scoped to
that conversation's project, and the pwd dropdown showing that project.
4. **The one that used to be broken:** in that fresh tab, send a message _immediately_, before anything
settles. It should run in the project directory, not in the general chat directory.
5. Right-click a folder in the file browser → **Run agent** → run one → **Open in chat**. It should land
on that folder's group list.
6. Mobile: open a conversation, hit back. You should return to the project's list, not to an empty
default one.
**What changed, and why it is more than cosmetic.**
The group used to be a query parameter, and the session's working directory was read _back off that
query parameter_ to decide where the agent actually executes. So the address bar was the authority on
where code runs. Two consequences, both of which you had noticed as "sometimes it doesn't load in the
right place":
- A pasted or refreshed `/chat/<id>` arrives with **no `?cwd=` at all**. The screen then fetched the
session and wrote the cwd into the URL — but there was a window before that landed, and a turn sent in
that window ran in the default `general_chat_sessions` directory instead of the project.
- Even after it landed, the URL was hand-editable, so a `?cwd=` naming one project and a session
belonging to another could disagree. Nothing reconciled them; the URL simply won.
The fix is not really "path instead of query string" — that part is presentation. It is that **there is
now one source of truth for a session's directory: the session.** `loadClaudeSessionById` already scans
every project group and reads the real cwd out of the transcript, so the id alone determines it. The
directory now travels on the selection (`SelectedSession.cwd`), which is what the composer reads. The URL
no longer carries it for a session, so it cannot contradict it.
A group, on the other hand, genuinely _is_ addressable state and belongs in the URL — so it is a path
suffix behind a `g/` discriminator: `/chat/g/home/me/project`, `/chat/new/g/home/me/project`. Spelled as
a path rather than a percent-encoded blob because Officer always sits behind a reverse proxy and `%2F` is
exactly the character proxies normalise or reject.
The vocabulary lives in one file, `apps/ChatHistory/chat-routes.ts``chatListPath`, `chatNewPath`,
`chatSessionPath`, `cwdFromSplat` — so a link built in a panel and a link built in a screen cannot drift.
**The agentic side, which you flagged.** `startAgentRun` used to return a literal
`chatUrl: '/chat?cwd=…'` — the server holding an opinion about frontend URL shape, and therefore holding
a copy that goes stale the moment that shape changes. It now returns just `cwd` (which it already did),
and `AgentRunnerModal` builds the link with `chatListPath`. One less place that knows what a chat URL
looks like.
**Also fixed in passing:** the permalink written after a turn completes (`useChat`) used to carry the
whole query string forward, which re-attached a stale `?cwd=` to the new session's URL. It now strips
`cwd` and leaves everything else alone.
**Not verified:** as with everything else here, none of this has been through a browser. The typecheck is
clean and the route ranking has been checked against React Router 7's scoring (a `/chat/g/*` pattern
scores 23 against `/chat/:sessionId`'s 17, so a group path can never be mistaken for a session id) — but
step 4 above is the one I most want you to actually try, because it is the bug this was for.
---
## Things noticed and deliberately left alone
- **`useChatWebSocket` silently ignores unparseable frames.** That one is intentional and the comment
+6
View File
@@ -35,8 +35,14 @@ export function App() {
<Route path="/settings/system" element={<Dashboard.SystemSettings />} />
<Route path="/settings/integrations" element={<Dashboard.IntegrationsSettings />} />
<Route path="/settings/user-management" element={<Dashboard.UserManagementSettings />} />
{/* A project group is a working directory, so it is spelled as a path, not ?cwd= — see
apps/ChatHistory/chat-routes.ts. The splat has to be last in a pattern, which is why
"new" comes before the group rather than after it. A session carries no group: the
transcript records its own cwd and the server resolves it by id. */}
<Route path="/chat" element={<Dashboard.SessionListPage />} />
<Route path="/chat/new" element={<Dashboard.SessionListPage isNew />} />
<Route path="/chat/new/g/*" element={<Dashboard.SessionListPage isNew />} />
<Route path="/chat/g/*" element={<Dashboard.SessionListPage />} />
<Route path="/chat/:sessionId" element={<Dashboard.SessionListPage />} />
<Route path="/plans" element={<Dashboard.Plans />} />
<Route path="/files" element={<Dashboard.FilesScreen />} />
@@ -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>
+6 -3
View File
@@ -117,10 +117,14 @@ type StartAgentRunParams = {
export type StartAgentRunResult = {
sessionKey: string;
dirName: string;
/**
* The directory the run executes in — which is also its project group in /chat, so the caller can
* build the link to it. This used to also return a ready-made `chatUrl`, which meant the server held
* an opinion about frontend URL shape and drifted the moment that shape changed. `cwd` is the fact;
* the URL is the frontend's business (`chatListPath`).
*/
cwd: string;
model: string;
/** Where to look at this run: the agent's own project group in /chat, newest session on top. */
chatUrl: string;
};
export async function startAgentRun(params: StartAgentRunParams): Promise<StartAgentRunResult> {
@@ -204,6 +208,5 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
dirName: agent.dirName,
cwd,
model,
chatUrl: `/chat?cwd=${encodeURIComponent(cwd)}`,
};
}
+29 -4
View File
@@ -255,10 +255,35 @@ export function loadClaudeSessionById(email: string, sessionId: string): ClaudeS
return null;
}
/**
* The transcript file for a session: in the named group if it is there, otherwise wherever it actually
* is. The caller's cwd is a hint, not an authority — a session's group is a property of the session,
* and the two disagree routinely (the list is showing one group while you act on a row from another,
* or a deep link hasn't resolved its group yet). Reads have always fallen back like this; writes did
* not, so delete and rename returned "not found" for a session that was plainly on screen.
*/
function findTranscript(email: string, cwd: string, sessionId: string): string | null {
const preferred = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
if (existsSync(preferred)) return preferred;
const projectsDir = claudeProjectsDir(email);
let slugs: string[];
try {
slugs = readdirSync(projectsDir);
} catch {
return null;
}
for (const slug of slugs) {
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
if (existsSync(filePath)) return filePath;
}
return null;
}
/** Delete a session by removing its transcript file. Returns false if it didn't exist. */
export function deleteClaudeSession(email: string, cwd: string, sessionId: string): boolean {
const filePath = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
if (!existsSync(filePath)) return false;
const filePath = findTranscript(email, cwd, sessionId);
if (!filePath) return false;
rmSync(filePath);
return true;
}
@@ -269,8 +294,8 @@ export function deleteClaudeSession(email: string, cwd: string, sessionId: strin
* timestamp is written so the rename doesn't reorder the list.
*/
export function renameClaudeSession(email: string, cwd: string, sessionId: string, title: string): boolean {
const filePath = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
if (!existsSync(filePath)) return false;
const filePath = findTranscript(email, cwd, sessionId);
if (!filePath) return false;
// Attach the summary to the transcript's tip (the last entry carrying a uuid).
let leafUuid = sessionId;
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { useLocation } from 'react-router';
import { Unplug } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useAuth } from 'hooks/useAuth';
@@ -15,6 +15,13 @@ export type SelectedSession = {
initialMessages?: ChatMessage[];
total?: number; // full transcript length — initialMessages is only the tail window
initialOffset?: number; // absolute index of initialMessages[0]; older remain above when > 0
/**
* The directory this session actually runs in, taken from its own transcript. Carried here rather
* than read back off the URL: the id already determines it, and when the URL was the authority a
* fresh deep-link had no cwd at all until the resolve landed — so a turn sent in that window ran in
* the default dir instead of the project. For a new chat it is the group the list is showing.
*/
cwd?: string | null;
} | null;
const CHANNEL = 'chat:selected-session';
@@ -71,9 +78,10 @@ type NewChatProps = {
initialMessages?: ChatMessage[];
total?: number;
initialOffset?: number;
sessionCwd?: string | null;
};
function NewChat({ resumeSummary, resumeSessionId, initialMessages, total, initialOffset }: NewChatProps) {
function NewChat({ resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd }: NewChatProps) {
const location = useLocation();
const locationState = location.state as ChatLocationState;
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
@@ -98,10 +106,12 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages, total, initi
: undefined,
});
// Run the session in the pwd the URL names; absent → backend default (general_chat_sessions).
const [searchParams] = useSearchParams();
const activeCwd = searchParams.get('cwd');
const cwd = activeCwd ? { path: activeCwd } : locationState?.cwd;
// Run the session in ITS OWN directory. This used to read `?cwd=` straight off the URL, which made
// the address bar the authority on where an agent executes — so a pasted /chat/<id> ran its first
// turn in the default dir until the deep-link resolve caught up, and a hand-edited cwd could point a
// resumed session anywhere. The session's transcript is the only thing that knows, so it wins;
// absent (a genuinely new chat) → the group the list is on, else the backend default.
const cwd = sessionCwd ? { path: sessionCwd } : locationState?.cwd;
const initialMessage = locationState?.initialMessage
? {
@@ -152,6 +162,7 @@ export const ChatDetailPanel = () => {
initialMessages={selected.initialMessages}
total={selected.total}
initialOffset={selected.initialOffset}
sessionCwd={selected.cwd}
/>
);
};
@@ -1,5 +1,5 @@
import { useRef, useState, useCallback } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router';
import { useNavigate, useParams } from 'react-router';
import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, RelativeTime } from '@/components/Data';
@@ -7,23 +7,23 @@ import { usePanelChannel } from 'hooks/usePanelChannel';
import { errorText } from 'helpers/error-text';
import { useClaudeSessions } from 'state/useClaudeSessions';
import type { SelectedSession } from './ChatDetailPanel';
import { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes';
import { PwdSelector } from './PwdSelector';
// Reads the /chat conversation list from Claude's own transcript store (source of truth).
// Clicking a session loads its transcript and continues the real Claude session via --resume.
export const SessionList = () => {
const navigate = useNavigate();
// The working directory the list operates on (absent = the default general_chat_sessions dir).
// In the URL, not a channel: which project group you're looking at is addressable state, so
// /chat?cwd=<dir> has to be a link anyone can hand out — an agent run points at its own group
// this way, and it survives a refresh.
const [searchParams, setSearchParams] = useSearchParams();
const activeCwd = searchParams.get('cwd');
// Which session is open is the URL too. Reading it from the route rather than the selection channel
// means the highlight is correct on a deep link and on a back/forward, before any panel has published.
const { sessionId } = useParams<{ sessionId: string }>();
const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd);
// Which session is open, and which project group we're browsing, both come from the route. Reading
// them there rather than from the selection channel means the highlight and the group are correct on
// a deep link and on back/forward, before any panel has published.
const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>();
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
// A group path when we're on one; otherwise the open session's own directory, so /chat/<id> shows
// that session among its neighbours instead of snapping the list back to the default group. Null =
// the default general_chat_sessions dir.
const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null;
const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const [confirmingId, setConfirmingId] = useState<string | null>(null);
@@ -36,8 +36,9 @@ export const SessionList = () => {
}
}, []);
const search = searchParams.toString();
const linkTo = (id: string) => (search ? `/chat/${id}?${search}` : `/chat/${id}`);
// No group in a session link, deliberately: the id already determines the directory. It used to
// carry `?cwd=`, which is how a link could name a group the session doesn't belong to.
const linkTo = chatSessionPath;
const startRename = (id: string, current: string) => {
setConfirmingId(null);
@@ -65,7 +66,9 @@ export const SessionList = () => {
try {
await deleteSession(id);
if (selected?.id === id) setSelected(null);
if (sessionId === id) navigate({ pathname: '/chat', search }, { replace: true });
// Back to the group's list, not the default one — deleting the open session shouldn't also move
// you out of the project you were working in.
if (sessionId === id) navigate(chatListPath(activeCwd), { replace: true });
} catch (err) {
toast.error(`Could not delete the session: ${errorText(err)}`);
}
@@ -77,16 +80,8 @@ export const SessionList = () => {
<PwdSelector
value={activeCwd}
onChange={(cwd) => {
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (cwd) next.set('cwd', cwd);
else next.delete('cwd');
return next;
},
{ replace: true },
);
setSelected(null); // sessions belong to a cwd — clear the open one when switching
navigate(chatListPath(cwd), { replace: true });
}}
/>
<div className="ml-auto flex items-center gap-1.5">
@@ -100,9 +95,10 @@ export const SessionList = () => {
</button>
<button
onClick={() => {
setSelected({ id: `new:${Date.now()}` });
// Carry the cwd: a new chat starts in the pwd the list is showing, and that now lives in the URL.
navigate({ pathname: '/chat/new', search }, { replace: true });
// A new chat starts in the group the list is showing, and says so in both places: on the
// selection (which is what the composer actually runs in) and in the URL.
setSelected({ id: `new:${Date.now()}`, cwd: activeCwd });
navigate(chatNewPath(activeCwd), { replace: true });
}}
// Was duck-teal filled with duck-yellow text. duck-teal is a bright cyan in dark mode and
// duck-yellow has no dark override at all, so the pair sat around 2:1 contrast either way —
@@ -0,0 +1,42 @@
/**
* The chat URL vocabulary — one place, so a link built in a panel and a link built on the server
* cannot drift apart.
*
* A project group is a working directory, and a working directory is an absolute path, so it goes in
* the URL *as* a path: `/chat/g/home/me/project`. The leading slash is implied — every cwd is absolute
* — and that is what keeps the URL free of `%2F`, which proxies normalise or reject and Officer always
* sits behind one. It stays readable and copy-pasteable, which a percent-encoded blob or a lossy slug
* would not.
*
* **A session is not addressed by group.** Its transcript records its own cwd and the server resolves
* it from the id alone, so `/chat/<id>` is already complete. Carrying the group as well would be a
* second copy of a fact the id determines — and a second copy is free to disagree, which is exactly
* how a resumed session used to get launched in the wrong directory.
*/
/** Discriminator segment, so a group path can never be mistaken for a session id. */
export const GROUP_SEGMENT = 'g';
/** Absolute cwd → path suffix. Encoded per segment; React Router decodes it the same way. */
const encodeCwd = (cwd: string): string => cwd.split('/').filter(Boolean).map(encodeURIComponent).join('/');
/** The list for a group. `null` = the default general_chat_sessions group. */
export const chatListPath = (cwd: string | null | undefined): string =>
cwd ? `/chat/${GROUP_SEGMENT}/${encodeCwd(cwd)}` : '/chat';
/** A new chat in a group. `null` = the default group. */
export const chatNewPath = (cwd: string | null | undefined): string =>
cwd ? `/chat/new/${GROUP_SEGMENT}/${encodeCwd(cwd)}` : '/chat/new';
/** A conversation. Deliberately group-free — see the note above. */
export const chatSessionPath = (sessionId: string): string => `/chat/${sessionId}`;
/**
* Route splat → absolute cwd. React Router hands the splat back already decoded (per segment, with any
* decoded `/` re-escaped), so this only has to put the leading slash back. An empty splat — `/chat/g`
* with nothing after it — means the default group rather than the filesystem root.
*/
export const cwdFromSplat = (splat: string | undefined): string | null => {
const trimmed = splat?.replace(/^\/+|\/+$/g, '');
return trimmed ? `/${trimmed}` : null;
};
@@ -6,6 +6,7 @@ import { ChatDetailPanel } from './ChatDetailPanel';
export { SessionList };
export { ChatDetailPanel };
export type { SelectedSession } from './ChatDetailPanel';
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './chat-routes';
export const appRegistryMetas: AppRegistryMeta[] = [
{
@@ -5,6 +5,7 @@ import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
import { useClient } from 'hooks/useClient';
import { chatListPath } from '../../../ChatHistory/chat-routes';
import type { AgentSummary } from '../../useAgents';
import { TaskInputForm, type TaskInputDef } from './TaskRunnerModal';
@@ -19,7 +20,7 @@ type AgentDetail = {
inputs?: Record<string, TaskInputDef>;
};
type StartResult = { sessionKey: string; cwd: string; model: string; chatUrl: string };
type StartResult = { sessionKey: string; cwd: string; model: string };
type AgentRunnerModalProps = {
open: boolean;
@@ -85,7 +86,10 @@ export const AgentRunnerModal = ({ open, onOpenChange, agent, entryName, entryFu
const openChat = () => {
if (!result) return;
onOpenChange(false);
navigate(result.chatUrl);
// The run's cwd IS its project group, so link to that group's list. Built here rather than handed
// down from the server, which used to return a literal `/chat?cwd=…` and so kept its own stale copy
// of the chat URL shape.
navigate(chatListPath(result.cwd));
};
return (
+12 -7
View File
@@ -281,14 +281,19 @@ 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.
// Make the address bar a permalink. This runs on `result`, not `session:init`, because the id
// there is officer's per-connection key (`msg.sessionId || randomUUID()`), which
// `/chat/sessions/:id` cannot resolve — only the turn reports the real transcript uuid.
//
// The permalink is bare: no group, and `?cwd=` is stripped rather than carried. The transcript
// records its own cwd and the server resolves it from the id, so naming the group again could
// only ever contradict it — which is what a hand-edited or stale `?cwd=` used to do. Anything
// else in the query string is left alone.
if (replaceUrl && msg.claudeSessionId) {
window.history.replaceState(null, '', `/chat/${msg.claudeSessionId}${window.location.search}`);
const params = new URLSearchParams(window.location.search);
params.delete('cwd');
const search = params.toString();
window.history.replaceState(null, '', `/chat/${msg.claudeSessionId}${search ? `?${search}` : ''}`);
}
commitStreaming();
setMessages((prev) => [
+2
View File
@@ -26,6 +26,8 @@ export type { UseEmbeddableChatType, UseChatType, UseAttachmentsType, UseAudioRe
export * from './apps/Chat/types';
export { SessionList, ChatDetailPanel } from './apps/ChatHistory';
export type { SelectedSession } from './apps/ChatHistory';
// The chat URL vocabulary, so the /chat screen, the panels and the server all spell a group the same way.
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './apps/ChatHistory';
export { CodeEditorView } from './apps/CodeEditor';
// The route helpers, so the /headscale screen and the nav agree on one spelling of the section URL.
export { DEFAULT_HEADSCALE_SECTION, headscaleSectionPath, isHeadscaleSection } from './apps/Headscale/shared';