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 { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession, deleteClaudeSession } from './claude-sessions';
|
||||
import { getClaudeSessionsCwd, listClaudeSessions, loadClaudeSession, deleteClaudeSession, renameClaudeSession } from './claude-sessions';
|
||||
|
||||
export const chatRouter = createRouter();
|
||||
|
||||
@@ -26,3 +26,13 @@ chatRouter.delete('/sessions/:id', (ctx) => {
|
||||
if (!ok) return ctx.text('Not found', 404);
|
||||
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 { DATA_PATH } from '../../data-path';
|
||||
|
||||
@@ -52,6 +52,8 @@ type Entry = {
|
||||
timestamp?: string;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
uuid?: string;
|
||||
summary?: string;
|
||||
message?: { role?: string; content?: unknown };
|
||||
isMeta?: boolean;
|
||||
};
|
||||
@@ -64,7 +66,8 @@ function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary
|
||||
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 firstTs = '';
|
||||
let lastTs = '';
|
||||
@@ -83,10 +86,12 @@ function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary
|
||||
if (!firstTs) firstTs = 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;
|
||||
if (!title && entry.type === 'user' && !entry.isMeta) {
|
||||
title = entryText(entry.message).split('\n')[0]!.trim();
|
||||
if (!firstUserText && entry.type === 'user' && !entry.isMeta) {
|
||||
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();
|
||||
return {
|
||||
id,
|
||||
title: title || '(untitled)',
|
||||
title: summaryTitle || firstUserText || '(untitled)',
|
||||
cwd,
|
||||
createdAt: firstTs || mtime,
|
||||
updatedAt: lastTs || mtime,
|
||||
@@ -195,6 +200,35 @@ export function deleteClaudeSession(email: string, cwd: string, sessionId: strin
|
||||
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. */
|
||||
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
|
||||
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
||||
|
||||
Reference in New Issue
Block a user