chat: put the session list cwd in the url, not a panel channel

Which project group you are looking at is addressable state, so /chat?cwd=<dir> has to be a
link anyone can hand out — it survives a refresh and an agent run can point straight at its
own runs directory. Was usePanelChannel('chat:active-cwd'), which per the navigation audit is
for signals and refresh buses only. Row links and New Chat now carry the query string along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 00:28:44 +00:00
co-authored by Claude Opus 5
parent 47d03de307
commit 6b46d9f1f9
3 changed files with 35 additions and 12 deletions
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef } from 'react';
import { useParams, useNavigate } from 'react-router';
import { useParams, useNavigate, useSearchParams } from 'react-router';
import type { LayoutNode, SelectedSession } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useIsMobile } from 'hooks/useIsMobile';
@@ -38,7 +38,7 @@ type SessionListPageProps = {
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
const { sessionId } = useParams<{ sessionId: string }>();
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [, setActiveCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
const [, setSearchParams] = useSearchParams();
const client = useClient();
const selectedRef = useRef(selected);
selectedRef.current = selected;
@@ -78,7 +78,16 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
try {
const detail = await client.get<ClaudeSessionDetail>(`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`);
if (cancelled) return;
setActiveCwd(detail.cwd || null);
// 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 },
);
setSelected({
id: sessionId,
model: detail.model,
@@ -1,5 +1,5 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router';
import { useLocation, useSearchParams } from 'react-router';
import { Unplug } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useAuth } from 'hooks/useAuth';
@@ -98,8 +98,9 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages, total, initi
: undefined,
});
// Run the session in the pwd chosen in the Sessions panel; null → backend default (general_chat_sessions).
const [activeCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
// 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;
const initialMessage = locationState?.initialMessage
@@ -1,5 +1,5 @@
import { useRef, useState, useCallback } from 'react';
import { Link, useNavigate } from 'react-router';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useClaudeSessions } from 'state/useClaudeSessions';
@@ -10,8 +10,12 @@ import { PwdSelector } from './PwdSelector';
// 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 (null = the default general_chat_sessions dir).
const [activeCwd, setActiveCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
// 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');
const { sessions, isLoading, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd);
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [editingId, setEditingId] = useState<string | null>(null);
@@ -52,7 +56,15 @@ export const SessionList = () => {
<PwdSelector
value={activeCwd}
onChange={(cwd) => {
setActiveCwd(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
}}
/>
@@ -67,7 +79,8 @@ export const SessionList = () => {
<button
onClick={() => {
setSelected({ id: `new:${Date.now()}` });
navigate('/chat/new', { replace: true });
// 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: searchParams.toString() }, { replace: true });
}}
className="flex items-center gap-1.5 rounded-md bg-duck-teal hover:bg-duck-teal/90 text-duck-yellow cursor-pointer h-7 px-3 text-xs font-medium"
>
@@ -132,7 +145,7 @@ export const SessionList = () => {
) : (
<>
<Link
to={`/chat/${session.id}`}
to={{ pathname: `/chat/${session.id}`, search: searchParams.toString() }}
className="flex flex-1 items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
>
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />