chat: rename sessions in Claude's store + delete confirm

Rename appends a {"type":"summary",...} entry to the session's JSONL transcript
(Claude's own format, so the title lives in .claude); the reader takes the last
summary as the title, without a timestamp so it doesn't reorder the list.
PATCH /chat/sessions/:id/title backs it. The list gets inline rename (pencil ->
edit in place) and a two-step delete confirm so a stray click can't nuke a
transcript. Rename verified against a synthetic store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 12:01:16 +00:00
co-authored by Claude Opus 4.8
parent 4215c8df0a
commit b3b8a59863
4 changed files with 161 additions and 49 deletions
@@ -1,6 +1,6 @@
import { useRef, useState, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { Plus, MessageSquare, RefreshCw, Loader2, Trash2 } from 'lucide-react';
import { Plus, MessageSquare, RefreshCw, Loader2, Trash2, Pencil, Check, X } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useClaudeSessions } from 'state/useClaudeSessions';
import type { SelectedSession } from './ChatDetailPanel';
@@ -10,9 +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, deleteSession } = useClaudeSessions();
const { sessions, isLoading, refetch, loadSession, deleteSession, renameSession } = useClaudeSessions();
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
const [openingId, setOpeningId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState('');
const [confirmingId, setConfirmingId] = useState<string | null>(null);
const scrolledRef = useRef(false);
const selectedRef = useCallback((node: HTMLDivElement | null) => {
@@ -23,23 +26,32 @@ export const SessionList = () => {
}, []);
const handleSelect = async (id: string) => {
if (openingId) return;
if (openingId || editingId) return;
setOpeningId(id);
try {
const detail = await loadSession(id);
setSelected({
id,
model: detail.model,
resumeSessionId: id,
initialMessages: detail.messages as ChatMessage[],
});
setSelected({ id, model: detail.model, resumeSessionId: id, initialMessages: detail.messages as ChatMessage[] });
} finally {
setOpeningId(null);
}
};
const startRename = (id: string, current: string) => {
setConfirmingId(null);
setEditingId(id);
setEditValue(current);
};
const commitRename = async () => {
const id = editingId;
const title = editValue.trim();
setEditingId(null);
if (id && title) await renameSession(id, title);
};
const handleDelete = async (id: string) => {
if (selected?.id === id) setSelected(null);
setConfirmingId(null);
await deleteSession(id);
};
@@ -79,6 +91,8 @@ export const SessionList = () => {
{sessions.map((session) => {
const isActive = selected?.id === session.id;
const isEditing = editingId === session.id;
const isConfirming = confirmingId === session.id;
return (
<div
key={session.id}
@@ -89,39 +103,85 @@ export const SessionList = () => {
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
}`}
>
<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>
{isEditing ? (
<div className="flex flex-1 items-center gap-2 px-4 py-3 min-w-0">
<input
autoFocus
value={editValue}
onChange={(ev) => setEditValue(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') commitRename();
if (ev.key === 'Escape') setEditingId(null);
}}
onBlur={commitRename}
className="flex-1 min-w-0 bg-transparent border-b border-duck-teal/40 text-sm outline-none"
/>
<button onMouseDown={(ev) => ev.preventDefault()} onClick={commitRename} className="p-1 text-duck-teal hover:opacity-80 cursor-pointer" title="Save">
<Check className="h-3.5 w-3.5" />
</button>
<button onMouseDown={(ev) => ev.preventDefault()} onClick={() => setEditingId(null)} className="p-1 opacity-50 hover:opacity-100 cursor-pointer" title="Cancel">
<X className="h-3.5 w-3.5" />
</button>
</div>
</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>
) : (
<>
<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>
</button>
{isConfirming ? (
<div className="flex shrink-0 items-center gap-1 mr-2">
<span className="text-xs text-red-500">Delete?</span>
<button onClick={() => handleDelete(session.id)} className="p-1 rounded text-red-500 hover:bg-red-500/10 cursor-pointer" title="Confirm delete">
<Check className="h-3.5 w-3.5" />
</button>
<button onClick={() => setConfirmingId(null)} className="p-1 rounded opacity-50 hover:opacity-100 cursor-pointer" title="Cancel">
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="flex shrink-0 items-center mr-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => startRename(session.id, session.title)}
className="p-1.5 rounded text-duck-dark/30 dark:text-foreground/30 hover:text-duck-teal cursor-pointer transition-colors"
title="Rename"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => setConfirmingId(session.id)}
className="p-1.5 rounded text-duck-dark/30 dark:text-foreground/30 hover:text-red-500 cursor-pointer transition-colors"
title="Delete session"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</>
)}
</div>
);
})}
@@ -47,5 +47,13 @@ export function useClaudeSessions() {
[client, invalidate],
);
return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession, deleteSession, invalidate };
const renameSession = useCallback(
async (id: string, title: string) => {
await client.patch(`/chat/sessions/${id}/title`, { title });
invalidate();
},
[client, invalidate],
);
return { sessions: data?.sessions ?? [], isLoading, refetch, loadSession, deleteSession, renameSession, invalidate };
}