chat: delete sessions + auto-refresh the /chat list after each turn

DELETE /chat/sessions/:id removes Claude's transcript file; the list gets a
per-row delete button. The list also invalidates on turn-complete so new and
continued sessions surface without a manual refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 10:14:27 +00:00
co-authored by Claude Opus 4.8
parent 9565cd9462
commit 4215c8df0a
5 changed files with 83 additions and 33 deletions
+9 -1
View File
@@ -1,5 +1,5 @@
import { createRouter } from '../../create-router';
import { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession } from './claude-sessions';
import { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession, deleteClaudeSession } from './claude-sessions';
export const chatRouter = createRouter();
@@ -18,3 +18,11 @@ chatRouter.get('/sessions/:id', (ctx) => {
if (!detail) return ctx.text('Not found', 404);
return ctx.json(detail);
});
// DELETE /chat/sessions/:id — remove a conversation (deletes Claude's transcript file).
chatRouter.delete('/sessions/:id', (ctx) => {
const email = ctx.get('user').email;
const ok = deleteClaudeSession(email, getClaudeSessionsCwd(email), ctx.req.param('id'));
if (!ok) return ctx.text('Not found', 404);
return ctx.json({ ok: true });
});
+9 -1
View File
@@ -1,4 +1,4 @@
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync } from 'node:fs';
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { DATA_PATH } from '../../data-path';
@@ -187,6 +187,14 @@ export function loadClaudeSession(email: string, cwd: string, sessionId: string)
return { id: sessionId, model, cwd: sessionCwd, messages };
}
/** 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;
rmSync(filePath);
return true;
}
/** List sessions Claude has stored for a given working directory, newest first. */
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
@@ -5,6 +5,7 @@ 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';
import type { ChatMessage } from '../Chat/types';
@@ -92,6 +93,7 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: ini
const { user } = useAuth();
const isSuperAdmin = user?.role === 'Super Admin';
const { saveSession, updateSessionMessages } = useSavedSessions();
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
const [isSaving, setIsSaving] = useState(false);
const [savedId, setSavedId] = usePanelChannel<number | null>(SAVED_ID_CHANNEL, initialSavedId ?? null);
const savedIdRef = useRef(savedId);
@@ -100,13 +102,15 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages, savedId: ini
const resumedMessageCountRef = useRef(initialMessages?.length ?? 0);
const onTurnComplete = useCallback(() => {
// Refresh the /chat list — Claude has just written/appended this session's transcript.
invalidateClaudeSessions();
const id = savedIdRef.current;
const sid = chatSessionRef.current;
if (id != null && sid) {
const count = resumedMessageCountRef.current;
updateSessionMessages(id, sid, count > 0 ? count : undefined).catch(() => {});
}
}, [updateSessionMessages]);
}, [updateSessionMessages, invalidateClaudeSessions]);
// context 'chat' tells the backend to run this session from the dedicated claude_sessions cwd, so
// its transcript lands in Claude's own store as an isolated project group (source of truth).
@@ -1,6 +1,6 @@
import { useRef, useState, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { Plus, MessageSquare, RefreshCw, Loader2 } from 'lucide-react';
import { Plus, MessageSquare, RefreshCw, Loader2, Trash2 } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useClaudeSessions } from 'state/useClaudeSessions';
import type { SelectedSession } from './ChatDetailPanel';
@@ -10,12 +10,12 @@ import type { ChatMessage } from '../Chat/types';
// 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 } = useClaudeSessions();
const { sessions, isLoading, refetch, loadSession, deleteSession } = useClaudeSessions();
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [openingId, setOpeningId] = useState<string | null>(null);
const scrolledRef = useRef(false);
const selectedRef = useCallback((node: HTMLButtonElement | null) => {
const selectedRef = useCallback((node: HTMLDivElement | null) => {
if (node && !scrolledRef.current) {
scrolledRef.current = true;
node.scrollIntoView({ block: 'center' });
@@ -38,6 +38,11 @@ export const SessionList = () => {
}
};
const handleDelete = async (id: string) => {
if (selected?.id === id) setSelected(null);
await deleteSession(id);
};
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
@@ -75,38 +80,49 @@ export const SessionList = () => {
{sessions.map((session) => {
const isActive = selected?.id === session.id;
return (
<button
<div
key={session.id}
ref={isActive ? selectedRef : undefined}
onClick={() => handleSelect(session.id)}
disabled={!!openingId}
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 min-w-0 text-left cursor-pointer transition-colors ${
className={`group flex items-center rounded-lg border transition-colors ${
isActive
? 'border-duck-teal/30 bg-duck-teal/5 dark:bg-duck-teal/10'
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
}`}
>
{openingId === session.id ? (
<Loader2 className="h-4 w-4 shrink-0 text-duck-teal/60 animate-spin" />
) : (
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">{session.title}</div>
<div className="flex items-center gap-2 text-xs text-duck-dark/40 dark:text-foreground/40">
<span>
{new Date(session.updatedAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
<span>·</span>
<span>{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}</span>
<button
onClick={() => handleSelect(session.id)}
disabled={!!openingId}
className="flex flex-1 items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
>
{openingId === session.id ? (
<Loader2 className="h-4 w-4 shrink-0 text-duck-teal/60 animate-spin" />
) : (
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">{session.title}</div>
<div className="flex items-center gap-2 text-xs text-duck-dark/40 dark:text-foreground/40">
<span>
{new Date(session.updatedAt).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
<span>·</span>
<span>{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}</span>
</div>
</div>
</div>
</button>
</button>
<button
onClick={() => handleDelete(session.id)}
className="shrink-0 mr-2 p-1.5 rounded text-duck-dark/30 dark:text-foreground/30 opacity-0 group-hover:opacity-100 hover:text-red-500 cursor-pointer transition-colors"
title="Delete session"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
+17 -3
View File
@@ -1,7 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback } from 'react';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
const QUERY_KEY = ['CLAUDE_SESSIONS'];
export type ClaudeSessionSummary = {
id: string; // Claude session uuid (= transcript filename)
title: string;
@@ -22,10 +25,11 @@ export type ClaudeSessionDetail = { id: string; model: string; cwd: string; mess
/** The /chat route's sessions, read straight from Claude's own transcript store (source of truth). */
export function useClaudeSessions() {
const client = useClient();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuth();
const { data, isLoading, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({
queryKey: ['CLAUDE_SESSIONS'],
queryKey: QUERY_KEY,
enabled: isAuthenticated,
queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>('/chat/sessions'),
staleTime: 30 * 1000,
@@ -33,5 +37,15 @@ export function useClaudeSessions() {
const loadSession = (id: string) => client.get<ClaudeSessionDetail>(`/chat/sessions/${id}`);
return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession };
const invalidate = useCallback(() => queryClient.invalidateQueries({ queryKey: QUERY_KEY }), [queryClient]);
const deleteSession = useCallback(
async (id: string) => {
await client.delete(`/chat/sessions/${id}`);
invalidate();
},
[client, invalidate],
);
return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession, deleteSession, invalidate };
}