chat: pwd selector — switch the /chat working directory

The Sessions panel header gets a pwd selector: the default claude_sessions dir,
plus every directory that already has Claude sessions (auto-discovered by reading
the real cwd back from ~/.claude/projects), plus free-text entry. Switching pwd
refetches the list for that cwd's Claude project group and runs New Chat / resume
in it. Backend: GET /chat/pwds + a ?cwd= param on the session ops; the WS handler
honors a chosen cwd for /chat (default claude_sessions). Browse-modal picker next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 12:41:49 +00:00
co-authored by Claude Opus 4.8
parent 420aa08783
commit 04bde70ed3
8 changed files with 237 additions and 36 deletions
@@ -3,7 +3,6 @@ import { useLocation } from 'react-router';
import { Save, Loader2 } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useAuth } from 'hooks/useAuth';
import { getHostHome } from 'state/useModels';
import { useSavedSessions } from 'state/useSavedSessions';
import { useClaudeSessions } from 'state/useClaudeSessions';
import { usePiChat, EmbeddableChat } from '../Chat';
@@ -131,7 +130,9 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: ini
};
const sandboxed = !isSuperAdmin;
const cwd = isSuperAdmin ? { path: getHostHome() } : locationState?.cwd;
// Run the session in the pwd chosen in the Sessions panel; null → backend default (claude_sessions).
const [activeCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
const cwd = activeCwd ? { path: activeCwd } : locationState?.cwd;
const initialMessage = locationState?.initialMessage
? {
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { FolderOpen, ChevronDown, Check, CornerDownLeft } from 'lucide-react';
import { useChatPwds } from 'state/useClaudeSessions';
type PwdSelectorProps = {
value: string | null; // null = the default claude_sessions dir
onChange: (cwd: string | null) => void;
};
// A shorter, friendlier label for a working directory.
const shorten = (cwd: string) => cwd.replace(/^\/home\/[^/]+/, '~');
const basename = (cwd: string) => cwd.split('/').filter(Boolean).pop() ?? cwd;
export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => {
const { pwds, defaultCwd } = useChatPwds();
const [open, setOpen] = useState(false);
const [custom, setCustom] = useState('');
const isDefaultActive = value === null || value === defaultCwd;
const label = isDefaultActive ? 'claude_sessions' : basename(value!);
const pick = (cwd: string | null) => {
onChange(cwd);
setOpen(false);
setCustom('');
};
const submitCustom = () => {
const p = custom.trim();
if (p) pick(p);
};
return (
<div className="relative">
<button
onClick={() => setOpen((o) => !o)}
title={isDefaultActive ? defaultCwd ?? 'Default' : value!}
className="flex items-center gap-1.5 max-w-[13rem] rounded-md border border-duck-dark/10 dark:border-foreground/10 px-2 py-1 text-xs text-duck-dark/70 dark:text-foreground/70 hover:bg-duck-dark/5 dark:hover:bg-foreground/5 cursor-pointer transition-colors"
>
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-duck-teal/70" />
<span className="truncate">{label}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute left-0 z-20 mt-1 w-80 rounded-md border border-duck-dark/10 dark:border-foreground/10 bg-background shadow-lg">
<div className="max-h-72 overflow-y-auto py-1">
{pwds.map((p) => {
const selected = p.isDefault ? isDefaultActive : value === p.cwd;
return (
<button
key={p.cwd}
onClick={() => pick(p.isDefault ? null : p.cwd)}
title={p.cwd}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs cursor-pointer hover:bg-duck-dark/5 dark:hover:bg-foreground/5 ${selected ? 'text-duck-teal' : 'text-duck-dark/70 dark:text-foreground/70'}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${selected ? 'opacity-100' : 'opacity-0'}`} />
<span className="min-w-0 flex-1 truncate">{p.isDefault ? 'Default · claude_sessions' : shorten(p.cwd)}</span>
{p.sessionCount > 0 && <span className="shrink-0 opacity-40">{p.sessionCount}</span>}
</button>
);
})}
</div>
<div className="flex items-center gap-1 border-t border-duck-dark/10 dark:border-foreground/10 p-2">
<input
value={custom}
onChange={(ev) => setCustom(ev.target.value)}
onKeyDown={(ev) => ev.key === 'Enter' && submitCustom()}
placeholder="Enter a path…"
className="min-w-0 flex-1 bg-transparent px-1 text-xs outline-none placeholder:opacity-40"
/>
<button
onClick={submitCustom}
disabled={!custom.trim()}
className="shrink-0 p-1 rounded text-duck-teal hover:bg-duck-teal/10 cursor-pointer disabled:opacity-30 disabled:cursor-default"
title="Use this path"
>
<CornerDownLeft className="h-3.5 w-3.5" />
</button>
</div>
</div>
</>
)}
</div>
);
};
@@ -5,12 +5,15 @@ import { usePanelChannel } from 'hooks/usePanelChannel';
import { useClaudeSessions } from 'state/useClaudeSessions';
import type { SelectedSession } from './ChatDetailPanel';
import type { ChatMessage } from '../Chat/types';
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();
const { sessions, isLoading, refetch, loadSession, deleteSession, renameSession } = useClaudeSessions();
// The working directory the list operates on (null = the default claude_sessions dir).
const [activeCwd, setActiveCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
const { sessions, isLoading, refetch, loadSession, deleteSession, renameSession } = useClaudeSessions(activeCwd);
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [openingId, setOpeningId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
@@ -58,9 +61,15 @@ export const SessionList = () => {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="shrink-0 flex items-center justify-between px-4 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
<div className="flex items-center gap-1.5">
<div className="shrink-0 flex items-center gap-2 px-3 py-2 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60">
<PwdSelector
value={activeCwd}
onChange={(cwd) => {
setActiveCwd(cwd);
setSelected(null); // sessions belong to a cwd — clear the open one when switching
}}
/>
<div className="ml-auto flex items-center gap-1.5">
<button
onClick={() => refetch()}
className="p-1 rounded text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal cursor-pointer transition-colors"
+2 -2
View File
@@ -5,8 +5,8 @@ export { useDashboardState } from './useDashboardState';
export { usePiModels, useVisiblePiModels, useUserVisibleModels, useEnabledPiModels, modelKey } from './useModels';
export type { ModelOption } from './useModels';
export { useAccessPolicy } from './useAccessPolicy';
export { useClaudeSessions } from './useClaudeSessions';
export type { ClaudeSessionSummary } from './useClaudeSessions';
export { useClaudeSessions, useChatPwds } from './useClaudeSessions';
export type { ClaudeSessionSummary, ClaudePwd } from './useClaudeSessions';
export { useRecentModels } from './useRecentModels';
export { usePlans } from './usePlans';
export { useLandingPage } from './useLandingPage';
+34 -11
View File
@@ -3,7 +3,7 @@ import { useCallback } from 'react';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
const QUERY_KEY = ['CLAUDE_SESSIONS'];
const SESSIONS_KEY = 'CLAUDE_SESSIONS';
export type ClaudeSessionSummary = {
id: string; // Claude session uuid (= transcript filename)
@@ -22,37 +22,60 @@ export type ClaudeSessionMessage =
export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeSessionMessage[] };
/** The /chat route's sessions, read straight from Claude's own transcript store (source of truth). */
export function useClaudeSessions() {
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
// Query string for the selected working directory (null/undefined = the default claude_sessions dir).
const cwdQuery = (cwd?: string | null) => (cwd ? `?cwd=${encodeURIComponent(cwd)}` : '');
/** The default /chat dir plus every directory that already has Claude sessions. */
export function useChatPwds() {
const client = useClient();
const { isAuthenticated } = useAuth();
const { data } = useQuery<{ pwds: ClaudePwd[]; default: string }>({
queryKey: ['CHAT_PWDS'],
enabled: isAuthenticated,
queryFn: () => client.get<{ pwds: ClaudePwd[]; default: string }>('/chat/pwds'),
staleTime: 30 * 1000,
});
return { pwds: data?.pwds ?? [], defaultCwd: data?.default ?? null };
}
/** Sessions for a working directory, read from Claude's own transcript store (source of truth). */
export function useClaudeSessions(cwd?: string | null) {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const q = cwdQuery(cwd);
const { data, isLoading, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({
queryKey: QUERY_KEY,
queryKey: [SESSIONS_KEY, cwd ?? 'default'],
enabled: isAuthenticated,
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>('/chat/sessions'),
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`),
staleTime: 30 * 1000,
});
const loadSession = (id: string) => client.get<ClaudeSessionDetail>(`/chat/sessions/${id}`);
const loadSession = (id: string) => client.get<ClaudeSessionDetail>(`/chat/sessions/${id}${q}`);
const invalidate = useCallback(() => queryClient.invalidateQueries({ queryKey: QUERY_KEY }), [queryClient]);
// Invalidate every cwd's list (also refreshes pwd counts) — cheap and avoids stale lists.
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: [SESSIONS_KEY] });
queryClient.invalidateQueries({ queryKey: ['CHAT_PWDS'] });
}, [queryClient]);
const deleteSession = useCallback(
async (id: string) => {
await client.delete(`/chat/sessions/${id}`);
await client.delete(`/chat/sessions/${id}${q}`);
invalidate();
},
[client, invalidate],
[client, q, invalidate],
);
const renameSession = useCallback(
async (id: string, title: string) => {
await client.patch(`/chat/sessions/${id}/title`, { title });
await client.patch(`/chat/sessions/${id}/title${q}`, { title });
invalidate();
},
[client, invalidate],
[client, q, invalidate],
);
return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession, deleteSession, renameSession, invalidate };