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:
@@ -1,38 +1,54 @@
|
|||||||
|
import type { Context } from 'hono';
|
||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession, deleteClaudeSession, renameClaudeSession } from './claude-sessions';
|
import {
|
||||||
|
getClaudeSessionsCwd,
|
||||||
|
listClaudePwds,
|
||||||
|
listClaudeSessions,
|
||||||
|
loadClaudeSession,
|
||||||
|
deleteClaudeSession,
|
||||||
|
renameClaudeSession,
|
||||||
|
} from './claude-sessions';
|
||||||
|
|
||||||
export const chatRouter = createRouter();
|
export const chatRouter = createRouter();
|
||||||
|
|
||||||
// GET /chat/sessions — the /chat route's conversations, read straight from Claude's own transcript
|
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
|
||||||
// store for the dedicated claude_sessions working directory (Claude is the source of truth).
|
// claude_sessions dir. Claude groups sessions by cwd, so this selects which project group we read.
|
||||||
chatRouter.get('/sessions', (ctx) => {
|
const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getClaudeSessionsCwd(email);
|
||||||
|
|
||||||
|
// GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions.
|
||||||
|
chatRouter.get('/pwds', (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const sessions = listClaudeSessions(email, getClaudeSessionsCwd(email));
|
return ctx.json({ pwds: listClaudePwds(email), default: getClaudeSessionsCwd(email) });
|
||||||
return ctx.json({ sessions });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /chat/sessions/:id — one conversation's full transcript, parsed into display-ready messages.
|
// GET /chat/sessions[?cwd=] — the conversations for a working directory, from Claude's transcripts.
|
||||||
|
chatRouter.get('/sessions', (ctx) => {
|
||||||
|
const email = ctx.get('user').email;
|
||||||
|
return ctx.json({ sessions: listClaudeSessions(email, cwdOf(ctx, email)) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /chat/sessions/:id[?cwd=] — one conversation's full transcript, parsed into display messages.
|
||||||
chatRouter.get('/sessions/:id', (ctx) => {
|
chatRouter.get('/sessions/:id', (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const detail = loadClaudeSession(email, getClaudeSessionsCwd(email), ctx.req.param('id'));
|
const detail = loadClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id'));
|
||||||
if (!detail) return ctx.text('Not found', 404);
|
if (!detail) return ctx.text('Not found', 404);
|
||||||
return ctx.json(detail);
|
return ctx.json(detail);
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /chat/sessions/:id — remove a conversation (deletes Claude's transcript file).
|
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation (deletes Claude's transcript file).
|
||||||
chatRouter.delete('/sessions/:id', (ctx) => {
|
chatRouter.delete('/sessions/:id', (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const ok = deleteClaudeSession(email, getClaudeSessionsCwd(email), ctx.req.param('id'));
|
const ok = deleteClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id'));
|
||||||
if (!ok) return ctx.text('Not found', 404);
|
if (!ok) return ctx.text('Not found', 404);
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /chat/sessions/:id/title — rename by writing a summary entry into Claude's transcript.
|
// PATCH /chat/sessions/:id/title[?cwd=] — rename by writing a summary entry into Claude's transcript.
|
||||||
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||||
const email = ctx.get('user').email;
|
const email = ctx.get('user').email;
|
||||||
const { title } = await ctx.req.json<{ title?: string }>();
|
const { title } = await ctx.req.json<{ title?: string }>();
|
||||||
if (!title?.trim()) return ctx.text('title is required', 400);
|
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||||
const ok = renameClaudeSession(email, getClaudeSessionsCwd(email), ctx.req.param('id'), title.trim());
|
const ok = renameClaudeSession(email, cwdOf(ctx, email), ctx.req.param('id'), title.trim());
|
||||||
if (!ok) return ctx.text('Not found', 404);
|
if (!ok) return ctx.text('Not found', 404);
|
||||||
return ctx.json({ ok: true });
|
return ctx.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, rmSync, appendFileSync } from 'node:fs';
|
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, rmSync, appendFileSync, openSync, readSync, closeSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '../../data-path';
|
||||||
|
|
||||||
@@ -232,6 +232,66 @@ export function renameClaudeSession(email: string, cwd: string, sessionId: strin
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Working directories (pwds) ──
|
||||||
|
// Every dir you've run Claude in shows up as a project group under ~/.claude/projects. We read the
|
||||||
|
// real `cwd` back from each group's transcripts so the UI can offer "jump to any project's sessions".
|
||||||
|
|
||||||
|
/** Read the `cwd` recorded in a transcript, from a bounded head read (cwd appears in early entries). */
|
||||||
|
function firstCwd(filePath: string): string {
|
||||||
|
let fd: number | undefined;
|
||||||
|
try {
|
||||||
|
fd = openSync(filePath, 'r');
|
||||||
|
const buf = Buffer.alloc(32768);
|
||||||
|
const n = readSync(fd, buf, 0, buf.length, 0);
|
||||||
|
return buf.toString('utf-8', 0, n).match(/"cwd":"([^"]*)"/)?.[1] ?? '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
} finally {
|
||||||
|
if (fd !== undefined) closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||||
|
|
||||||
|
/** All working directories that have Claude sessions, plus the default /chat dir. Newest first. */
|
||||||
|
export function listClaudePwds(email: string): ClaudePwd[] {
|
||||||
|
const projectsDir = claudeProjectsDir(email);
|
||||||
|
const defaultCwd = getClaudeSessionsCwd(email);
|
||||||
|
const byCwd = new Map<string, { count: number; updatedAt: string }>();
|
||||||
|
|
||||||
|
if (existsSync(projectsDir)) {
|
||||||
|
for (const group of readdirSync(projectsDir)) {
|
||||||
|
const groupDir = join(projectsDir, group);
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = readdirSync(groupDir).filter((f) => f.endsWith('.jsonl'));
|
||||||
|
} catch {
|
||||||
|
continue; // not a directory
|
||||||
|
}
|
||||||
|
if (files.length === 0) continue;
|
||||||
|
|
||||||
|
const cwd = firstCwd(join(groupDir, files[0]!));
|
||||||
|
if (!cwd) continue;
|
||||||
|
let updatedAt = '';
|
||||||
|
for (const f of files) {
|
||||||
|
const m = statSync(join(groupDir, f)).mtime.toISOString();
|
||||||
|
if (m > updatedAt) updatedAt = m;
|
||||||
|
}
|
||||||
|
const prev = byCwd.get(cwd);
|
||||||
|
byCwd.set(cwd, {
|
||||||
|
count: (prev?.count ?? 0) + files.length,
|
||||||
|
updatedAt: prev && prev.updatedAt > updatedAt ? prev.updatedAt : updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!byCwd.has(defaultCwd)) byCwd.set(defaultCwd, { count: 0, updatedAt: '' });
|
||||||
|
|
||||||
|
return [...byCwd.entries()]
|
||||||
|
.map(([cwd, v]) => ({ cwd, sessionCount: v.count, updatedAt: v.updatedAt, isDefault: cwd === defaultCwd }))
|
||||||
|
.sort((a, b) => (a.isDefault ? -1 : b.isDefault ? 1 : b.updatedAt.localeCompare(a.updatedAt)));
|
||||||
|
}
|
||||||
|
|
||||||
/** List sessions Claude has stored for a given working directory, newest first. */
|
/** List sessions Claude has stored for a given working directory, newest first. */
|
||||||
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
|
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
|
||||||
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
||||||
|
|||||||
@@ -299,10 +299,15 @@ async function handleClaudeCodeChat(
|
|||||||
const { email, username, userId } = ws.data;
|
const { email, username, userId } = ws.data;
|
||||||
const homeDir = getHomeDirForRole(email, ws.data.role);
|
const homeDir = getHomeDirForRole(email, ws.data.role);
|
||||||
|
|
||||||
// The standalone /chat route runs every session from a dedicated `claude_sessions` working
|
// The standalone /chat route runs from a chosen working directory (the pwd selector) or, by default,
|
||||||
// directory, so its transcripts form their own Claude "project" group (source of truth) instead of
|
// a dedicated `claude_sessions` dir — so transcripts form their own Claude "project" group per cwd.
|
||||||
// polluting the home-dir history. Other contexts (email/project panels) keep their own cwd.
|
// Other contexts (email/project panels) keep their own cwd.
|
||||||
const cwd = msg.context === 'chat' ? ensureClaudeSessionsCwd(email) : resolveCwd(email, ws.data.role, msg.cwd);
|
const cwd =
|
||||||
|
msg.context === 'chat'
|
||||||
|
? msg.cwd?.trim()
|
||||||
|
? resolveCwd(email, ws.data.role, msg.cwd)
|
||||||
|
: ensureClaudeSessionsCwd(email)
|
||||||
|
: resolveCwd(email, ws.data.role, msg.cwd);
|
||||||
|
|
||||||
const groupSlug = msg.groupSlug || null;
|
const groupSlug = msg.groupSlug || null;
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useLocation } from 'react-router';
|
|||||||
import { Save, Loader2 } from 'lucide-react';
|
import { Save, Loader2 } from 'lucide-react';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { getHostHome } from 'state/useModels';
|
|
||||||
import { useSavedSessions } from 'state/useSavedSessions';
|
import { useSavedSessions } from 'state/useSavedSessions';
|
||||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||||
import { usePiChat, EmbeddableChat } from '../Chat';
|
import { usePiChat, EmbeddableChat } from '../Chat';
|
||||||
@@ -131,7 +130,9 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: ini
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sandboxed = !isSuperAdmin;
|
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
|
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 { useClaudeSessions } from 'state/useClaudeSessions';
|
||||||
import type { SelectedSession } from './ChatDetailPanel';
|
import type { SelectedSession } from './ChatDetailPanel';
|
||||||
import type { ChatMessage } from '../Chat/types';
|
import type { ChatMessage } from '../Chat/types';
|
||||||
|
import { PwdSelector } from './PwdSelector';
|
||||||
|
|
||||||
// Reads the /chat conversation list from Claude's own transcript store (source of truth).
|
// 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.
|
// Clicking a session loads its transcript and continues the real Claude session via --resume.
|
||||||
export const SessionList = () => {
|
export const SessionList = () => {
|
||||||
const navigate = useNavigate();
|
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 [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
||||||
const [openingId, setOpeningId] = useState<string | null>(null);
|
const [openingId, setOpeningId] = useState<string | null>(null);
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
@@ -58,9 +61,15 @@ export const SessionList = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-hidden">
|
<div className="flex flex-col h-full overflow-hidden">
|
||||||
{/* Header */}
|
{/* 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">
|
<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">
|
||||||
<h2 className="text-sm font-medium text-duck-dark/70 dark:text-foreground/70">Sessions</h2>
|
<PwdSelector
|
||||||
<div className="flex items-center gap-1.5">
|
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
|
<button
|
||||||
onClick={() => refetch()}
|
onClick={() => refetch()}
|
||||||
className="p-1 rounded text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal cursor-pointer transition-colors"
|
className="p-1 rounded text-duck-dark/40 dark:text-foreground/40 hover:text-duck-teal cursor-pointer transition-colors"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ export { useDashboardState } from './useDashboardState';
|
|||||||
export { usePiModels, useVisiblePiModels, useUserVisibleModels, useEnabledPiModels, modelKey } from './useModels';
|
export { usePiModels, useVisiblePiModels, useUserVisibleModels, useEnabledPiModels, modelKey } from './useModels';
|
||||||
export type { ModelOption } from './useModels';
|
export type { ModelOption } from './useModels';
|
||||||
export { useAccessPolicy } from './useAccessPolicy';
|
export { useAccessPolicy } from './useAccessPolicy';
|
||||||
export { useClaudeSessions } from './useClaudeSessions';
|
export { useClaudeSessions, useChatPwds } from './useClaudeSessions';
|
||||||
export type { ClaudeSessionSummary } from './useClaudeSessions';
|
export type { ClaudeSessionSummary, ClaudePwd } from './useClaudeSessions';
|
||||||
export { useRecentModels } from './useRecentModels';
|
export { useRecentModels } from './useRecentModels';
|
||||||
export { usePlans } from './usePlans';
|
export { usePlans } from './usePlans';
|
||||||
export { useLandingPage } from './useLandingPage';
|
export { useLandingPage } from './useLandingPage';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useCallback } from 'react';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
|
||||||
const QUERY_KEY = ['CLAUDE_SESSIONS'];
|
const SESSIONS_KEY = 'CLAUDE_SESSIONS';
|
||||||
|
|
||||||
export type ClaudeSessionSummary = {
|
export type ClaudeSessionSummary = {
|
||||||
id: string; // Claude session uuid (= transcript filename)
|
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[] };
|
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 type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||||
export function useClaudeSessions() {
|
|
||||||
|
// 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 client = useClient();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
const q = cwdQuery(cwd);
|
||||||
|
|
||||||
const { data, isLoading, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({
|
const { data, isLoading, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({
|
||||||
queryKey: QUERY_KEY,
|
queryKey: [SESSIONS_KEY, cwd ?? 'default'],
|
||||||
enabled: isAuthenticated,
|
enabled: isAuthenticated,
|
||||||
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>('/chat/sessions'),
|
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`),
|
||||||
staleTime: 30 * 1000,
|
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(
|
const deleteSession = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
await client.delete(`/chat/sessions/${id}`);
|
await client.delete(`/chat/sessions/${id}${q}`);
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
[client, invalidate],
|
[client, q, invalidate],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renameSession = useCallback(
|
const renameSession = useCallback(
|
||||||
async (id: string, title: string) => {
|
async (id: string, title: string) => {
|
||||||
await client.patch(`/chat/sessions/${id}/title`, { title });
|
await client.patch(`/chat/sessions/${id}/title${q}`, { title });
|
||||||
invalidate();
|
invalidate();
|
||||||
},
|
},
|
||||||
[client, invalidate],
|
[client, q, invalidate],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession, deleteSession, renameSession, invalidate };
|
return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession, deleteSession, renameSession, invalidate };
|
||||||
|
|||||||
Reference in New Issue
Block a user