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:
@@ -1,5 +1,5 @@
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession, deleteClaudeSession } from './claude-sessions';
|
import { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession, deleteClaudeSession, renameClaudeSession } from './claude-sessions';
|
||||||
|
|
||||||
export const chatRouter = createRouter();
|
export const chatRouter = createRouter();
|
||||||
|
|
||||||
@@ -26,3 +26,13 @@ chatRouter.delete('/sessions/:id', (ctx) => {
|
|||||||
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.
|
||||||
|
chatRouter.patch('/sessions/:id/title', async (ctx) => {
|
||||||
|
const email = ctx.get('user').email;
|
||||||
|
const { title } = await ctx.req.json<{ title?: string }>();
|
||||||
|
if (!title?.trim()) return ctx.text('title is required', 400);
|
||||||
|
const ok = renameClaudeSession(email, getClaudeSessionsCwd(email), ctx.req.param('id'), title.trim());
|
||||||
|
if (!ok) return ctx.text('Not found', 404);
|
||||||
|
return ctx.json({ ok: true });
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, rmSync } from 'node:fs';
|
import { readdirSync, readFileSync, existsSync, statSync, mkdirSync, rmSync, appendFileSync } 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';
|
||||||
|
|
||||||
@@ -52,6 +52,8 @@ type Entry = {
|
|||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
|
uuid?: string;
|
||||||
|
summary?: string;
|
||||||
message?: { role?: string; content?: unknown };
|
message?: { role?: string; content?: unknown };
|
||||||
isMeta?: boolean;
|
isMeta?: boolean;
|
||||||
};
|
};
|
||||||
@@ -64,7 +66,8 @@ function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let title = '';
|
let firstUserText = '';
|
||||||
|
let summaryTitle = ''; // a `summary` entry (our rename, appended to the transcript) wins over the first message
|
||||||
let cwd = '';
|
let cwd = '';
|
||||||
let firstTs = '';
|
let firstTs = '';
|
||||||
let lastTs = '';
|
let lastTs = '';
|
||||||
@@ -83,10 +86,12 @@ function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary
|
|||||||
if (!firstTs) firstTs = entry.timestamp;
|
if (!firstTs) firstTs = entry.timestamp;
|
||||||
lastTs = entry.timestamp;
|
lastTs = entry.timestamp;
|
||||||
}
|
}
|
||||||
if (entry.type === 'user' || entry.type === 'assistant') {
|
if (entry.type === 'summary' && typeof entry.summary === 'string') {
|
||||||
|
summaryTitle = entry.summary; // last one wins
|
||||||
|
} else if (entry.type === 'user' || entry.type === 'assistant') {
|
||||||
messageCount += 1;
|
messageCount += 1;
|
||||||
if (!title && entry.type === 'user' && !entry.isMeta) {
|
if (!firstUserText && entry.type === 'user' && !entry.isMeta) {
|
||||||
title = entryText(entry.message).split('\n')[0]!.trim();
|
firstUserText = entryText(entry.message).split('\n')[0]!.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +99,7 @@ function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary
|
|||||||
const mtime = statSync(filePath).mtime.toISOString();
|
const mtime = statSync(filePath).mtime.toISOString();
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
title: title || '(untitled)',
|
title: summaryTitle || firstUserText || '(untitled)',
|
||||||
cwd,
|
cwd,
|
||||||
createdAt: firstTs || mtime,
|
createdAt: firstTs || mtime,
|
||||||
updatedAt: lastTs || mtime,
|
updatedAt: lastTs || mtime,
|
||||||
@@ -195,6 +200,35 @@ export function deleteClaudeSession(email: string, cwd: string, sessionId: strin
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a session by appending a `summary` entry to its transcript — Claude's own on-disk format, so
|
||||||
|
* the title lives in .claude (source of truth). Our reader takes the last summary as the title; no
|
||||||
|
* timestamp is written so the rename doesn't reorder the list.
|
||||||
|
*/
|
||||||
|
export function renameClaudeSession(email: string, cwd: string, sessionId: string, title: string): boolean {
|
||||||
|
const filePath = join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`);
|
||||||
|
if (!existsSync(filePath)) return false;
|
||||||
|
|
||||||
|
// Attach the summary to the transcript's tip (the last entry carrying a uuid).
|
||||||
|
let leafUuid = sessionId;
|
||||||
|
const lines = readFileSync(filePath, 'utf-8').split('\n');
|
||||||
|
for (let i = lines.length - 1; i >= 0; i--) {
|
||||||
|
if (!lines[i]!.trim()) continue;
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(lines[i]!) as Entry;
|
||||||
|
if (entry.uuid) {
|
||||||
|
leafUuid = entry.uuid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* skip */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendFileSync(filePath, `${JSON.stringify({ type: 'summary', summary: title, leafUuid })}\n`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useRef, useState, useCallback } from 'react';
|
import { useRef, useState, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
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 { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||||
import type { SelectedSession } from './ChatDetailPanel';
|
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.
|
// 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 } = useClaudeSessions();
|
const { sessions, isLoading, refetch, loadSession, deleteSession, renameSession } = useClaudeSessions();
|
||||||
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 [editValue, setEditValue] = useState('');
|
||||||
|
const [confirmingId, setConfirmingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const scrolledRef = useRef(false);
|
const scrolledRef = useRef(false);
|
||||||
const selectedRef = useCallback((node: HTMLDivElement | null) => {
|
const selectedRef = useCallback((node: HTMLDivElement | null) => {
|
||||||
@@ -23,23 +26,32 @@ export const SessionList = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSelect = async (id: string) => {
|
const handleSelect = async (id: string) => {
|
||||||
if (openingId) return;
|
if (openingId || editingId) return;
|
||||||
setOpeningId(id);
|
setOpeningId(id);
|
||||||
try {
|
try {
|
||||||
const detail = await loadSession(id);
|
const detail = await loadSession(id);
|
||||||
setSelected({
|
setSelected({ id, model: detail.model, resumeSessionId: id, initialMessages: detail.messages as ChatMessage[] });
|
||||||
id,
|
|
||||||
model: detail.model,
|
|
||||||
resumeSessionId: id,
|
|
||||||
initialMessages: detail.messages as ChatMessage[],
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setOpeningId(null);
|
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) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (selected?.id === id) setSelected(null);
|
if (selected?.id === id) setSelected(null);
|
||||||
|
setConfirmingId(null);
|
||||||
await deleteSession(id);
|
await deleteSession(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,6 +91,8 @@ export const SessionList = () => {
|
|||||||
|
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const isActive = selected?.id === session.id;
|
const isActive = selected?.id === session.id;
|
||||||
|
const isEditing = editingId === session.id;
|
||||||
|
const isConfirming = confirmingId === session.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
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'
|
: 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<button
|
{isEditing ? (
|
||||||
onClick={() => handleSelect(session.id)}
|
<div className="flex flex-1 items-center gap-2 px-4 py-3 min-w-0">
|
||||||
disabled={!!openingId}
|
<input
|
||||||
className="flex flex-1 items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
|
autoFocus
|
||||||
>
|
value={editValue}
|
||||||
{openingId === session.id ? (
|
onChange={(ev) => setEditValue(ev.target.value)}
|
||||||
<Loader2 className="h-4 w-4 shrink-0 text-duck-teal/60 animate-spin" />
|
onKeyDown={(ev) => {
|
||||||
) : (
|
if (ev.key === 'Enter') commitRename();
|
||||||
<MessageSquare className="h-4 w-4 shrink-0 text-duck-teal/60" />
|
if (ev.key === 'Escape') setEditingId(null);
|
||||||
)}
|
}}
|
||||||
<div className="min-w-0 flex-1">
|
onBlur={commitRename}
|
||||||
<div className="text-sm font-medium text-duck-dark/80 dark:text-foreground/80 truncate">{session.title}</div>
|
className="flex-1 min-w-0 bg-transparent border-b border-duck-teal/40 text-sm outline-none"
|
||||||
<div className="flex items-center gap-2 text-xs text-duck-dark/40 dark:text-foreground/40">
|
/>
|
||||||
<span>
|
<button onMouseDown={(ev) => ev.preventDefault()} onClick={commitRename} className="p-1 text-duck-teal hover:opacity-80 cursor-pointer" title="Save">
|
||||||
{new Date(session.updatedAt).toLocaleDateString(undefined, {
|
<Check className="h-3.5 w-3.5" />
|
||||||
month: 'short',
|
</button>
|
||||||
day: 'numeric',
|
<button onMouseDown={(ev) => ev.preventDefault()} onClick={() => setEditingId(null)} className="p-1 opacity-50 hover:opacity-100 cursor-pointer" title="Cancel">
|
||||||
hour: '2-digit',
|
<X className="h-3.5 w-3.5" />
|
||||||
minute: '2-digit',
|
</button>
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{session.messageCount} msg{session.messageCount === 1 ? '' : 's'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
) : (
|
||||||
<button
|
<>
|
||||||
onClick={() => handleDelete(session.id)}
|
<button
|
||||||
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"
|
onClick={() => handleSelect(session.id)}
|
||||||
title="Delete session"
|
disabled={!!openingId}
|
||||||
>
|
className="flex flex-1 items-center gap-3 px-4 py-3 min-w-0 text-left cursor-pointer"
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
>
|
||||||
</button>
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -47,5 +47,13 @@ export function useClaudeSessions() {
|
|||||||
[client, invalidate],
|
[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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user