diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 43ae7333..f0843b25 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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 }); +}); diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 4b03d236..511f1438 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -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)); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index c4ee3962..ae65c9a1 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -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('chat:selected-session', null); const [openingId, setOpeningId] = useState(null); + const [editingId, setEditingId] = useState(null); + const [editValue, setEditValue] = useState(''); + const [confirmingId, setConfirmingId] = useState(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 (
{ : 'border-duck-dark/10 dark:border-foreground/10 bg-background/80 hover:bg-background/90' }`} > - +
- - + ) : ( + <> + + + {isConfirming ? ( +
+ Delete? + + +
+ ) : ( +
+ + +
+ )} + + )} ); })} diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index a18d3c80..ef596cad 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -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 }; }