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:
@@ -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[] = [
|
||||
{
|
||||
|
||||
+6
-2
@@ -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 (
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user