From fc3be25e2549698a21571f4f773d8b32a643bece Mon Sep 17 00:00:00 2001 From: Borges <42881523+BORGESAG@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:36:28 -0300 Subject: [PATCH] feat(sessions): add /sessions to resume or delete saved conversations (#2262) * feat(sessions): add /sessions to resume or delete saved conversations - New /sessions command: lists this repo's saved conversations; Enter opens Resume / Delete / Back; delete asks for confirmation (Cancel by default); `d` pressed twice deletes the focused session. - Deleting removes the transcript and its sidecars (.cast, .replay.json, /), refuses the active session and validates the id and path. - Fix: late metadata writes no longer recreate a deleted transcript. - Fix: launches that only run slash commands no longer create a session or print a resume hint at exit. Closes #2223 * fix(sessions): keep writing to a transcript whose delete failed - deleteSessionFiles: if removing the transcript fails, unmark it so later writes are not dropped while the file is still on disk - forgetDeletedSession: also wait for in-flight direct appends to the deleted path, not only queued writes - docstrings for sessionKey, isDeletedSessionFile, restoreSessionWrites * fix(sessions): wait for every removal before restoring writes A sidecar failing fast made Promise.all reject while the transcript was still being removed, so writes were restored and could recreate it. Settle all removals first; restore only if the transcript survived, and treat a leftover sidecar as an orphan once the transcript is gone. * fix(sessions): restore writes when the deletion barrier fails forgetDeletedSession marks the transcript before flushing; if a pending write fails, flush rejects and nothing is removed, but the transcript stayed marked and later writes were skipped. Undo the mark and rethrow. * fix(sessions): keep the list usable when a resume fails to load loadFullLog ran outside any error handling, so a transcript that failed to load left the dialog busy and ignoring input. Report the error, clear busy and return to the list. * fix(sessions): do not resume a transcript that failed to load loadFullLog falls back to the message-less lite log when a transcript can't be read, so resuming it opened an empty conversation. Treat that fallback as a load failure, and test it with the real loader. * fix(sessions): drop buffered entries when the session changes Local-command entries are buffered until the first real message. If the active session switched before that, they were written into the new session's transcript. Track the session that owns the buffer and drop it on a switch. --- README.md | 9 + src/commands.ts | 2 + src/commands/sessions/index.ts | 10 + src/commands/sessions/sessions.test.tsx | 284 +++++++++++++++++ src/commands/sessions/sessions.tsx | 296 ++++++++++++++++++ src/i18n/commandDescriptions.ts | 1 + src/i18n/languages/en.ts | 2 + src/i18n/languages/vi.ts | 2 + src/utils/sessionDeletion.test.ts | 106 +++++++ src/utils/sessionDeletion.ts | 85 +++++ .../sessionStorage.commandOnlySession.test.ts | 153 +++++++++ .../sessionStorage.deletedSession.test.ts | 206 ++++++++++++ src/utils/sessionStorage.ts | 139 +++++++- 13 files changed, 1286 insertions(+), 9 deletions(-) create mode 100644 src/commands/sessions/index.ts create mode 100644 src/commands/sessions/sessions.test.tsx create mode 100644 src/commands/sessions/sessions.tsx create mode 100644 src/utils/sessionDeletion.test.ts create mode 100644 src/utils/sessionDeletion.ts create mode 100644 src/utils/sessionStorage.commandOnlySession.test.ts create mode 100644 src/utils/sessionStorage.deletedSession.test.ts diff --git a/README.md b/README.md index e54af9aad..0df86e51e 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,15 @@ openclaude --continue --fork-session Forking is conversation branching only. It does not create filesystem isolation, copy your working tree, or create a git worktree branch. +### Manage saved conversations + +Run `/sessions` inside OpenClaude to list this project's saved conversations. +Press Enter on one to **Resume**, **Delete** (asks for confirmation, defaulting +to Cancel), or go **Back** to the list. As a shortcut, press `d` on a row and +then `d` again to delete it; any other key in between cancels. Deleting removes +the transcript and its sidecar files from disk and cannot be undone. The +session you are currently in is not listed and cannot be deleted. + ### Background sessions Run long non-interactive prompts detached from the current terminal: diff --git a/src/commands.ts b/src/commands.ts index 02f8dc759..01af8dade 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -56,6 +56,7 @@ import resume, { continueCommand } from './commands/resume/index.js' import review, { ultrareview } from './commands/review.js' import search from './commands/search/index.js' import session from './commands/session/index.js' +import sessions from './commands/sessions/index.js' import setContextWindow from './commands/set-context-window/index.js' import share from './commands/share/index.js' import skills from './commands/skills/index.js' @@ -342,6 +343,7 @@ const COMMANDS = memoize((): Command[] => [ resume, search, session, + sessions, setContextWindow, skills, stats, diff --git a/src/commands/sessions/index.ts b/src/commands/sessions/index.ts new file mode 100644 index 000000000..bb5ec3a25 --- /dev/null +++ b/src/commands/sessions/index.ts @@ -0,0 +1,10 @@ +import type { Command } from '../../commands.js' + +const sessions = { + type: 'local-jsx', + name: 'sessions', + description: 'List saved conversations to resume or delete them', + load: () => import('./sessions.js'), +} satisfies Command + +export default sessions diff --git a/src/commands/sessions/sessions.test.tsx b/src/commands/sessions/sessions.test.tsx new file mode 100644 index 000000000..4bc7d7984 --- /dev/null +++ b/src/commands/sessions/sessions.test.tsx @@ -0,0 +1,284 @@ +import { existsSync } from 'fs' +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { PassThrough } from 'node:stream' +import { stripVTControlCharacters as stripAnsi } from 'node:util' + +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import * as React from 'react' + +import { createRoot } from '../../ink.js' +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' +import { AppStateProvider } from '../../state/AppState.js' +import type { LocalJSXCommandContext } from '../../types/command.js' +import type { LogOption } from '../../types/logs.js' + +const SYNC_START = '\x1B[?2026h' +const SYNC_END = '\x1B[?2026l' +const ENTER = '\r' +const ESC = '\x1b' +const DOWN = '\x1b[B' + +const ALPHA = '11111111-1111-4111-8111-111111111111' +const BETA = '22222222-2222-4222-8222-222222222222' +const GAMMA = '33333333-3333-4333-8333-333333333333' + +type TestStdin = PassThrough & { + isTTY: boolean + setRawMode: (mode: boolean) => void + ref: () => void + unref: () => void +} + +let projectDir: string +let allLogs: LogOption[] + +function extractLastFrame(output: string): string { + let lastFrame: string | null = null + let cursor = 0 + while (cursor < output.length) { + const start = output.indexOf(SYNC_START, cursor) + if (start === -1) break + const contentStart = start + SYNC_START.length + const end = output.indexOf(SYNC_END, contentStart) + if (end === -1) break + const frame = output.slice(contentStart, end) + if (frame.trim().length > 0) lastFrame = frame + cursor = end + SYNC_END.length + } + return lastFrame ?? output +} + +async function waitUntil(read: () => T, predicate: (value: T) => boolean): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < 2500) { + const value = read() + if (predicate(value)) return value + await Bun.sleep(10) + } + throw new Error(`Timed out; last value: ${String(read())}`) +} + +async function makeLog(sessionId: string, title: string, minutesAgo: number): Promise { + const fullPath = join(projectDir, `${sessionId}.jsonl`) + await writeFile(fullPath, '{}\n') + await mkdir(join(projectDir, sessionId), { recursive: true }) + const modified = new Date(Date.now() - minutesAgo * 60_000) + return { + date: modified.toISOString(), + messages: [], + fullPath, + value: minutesAgo, + created: modified, + modified, + firstPrompt: title, + customTitle: title, + messageCount: 2, + isSidechain: false, + sessionId, + } +} + +async function runSessions() { + const { call } = await import(`./sessions.js?${Date.now()}-${Math.random()}`) + const done: Array = [] + const resume = mock((_sessionId: string, _log: LogOption, _entrypoint: string) => + Promise.resolve(), + ) + const node = await call( + (result?: string) => { + done.push(result) + }, + { resume } as unknown as LocalJSXCommandContext, + '', + ) + + let output = '' + const stdout = new PassThrough() + const stdin = new PassThrough() as TestStdin + stdin.isTTY = true + stdin.setRawMode = () => {} + stdin.ref = () => {} + stdin.unref = () => {} + ;(stdout as unknown as { columns: number }).columns = 120 + stdout.on('data', chunk => { + output += chunk.toString() + }) + + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + root.render( + + {node} + , + ) + + const frame = (): string => stripAnsi(extractLastFrame(output)) + return { + done, + resume, + waitFor: (predicate: (f: string) => boolean) => waitUntil(frame, predicate), + press: async (...keys: string[]) => { + for (const key of keys) { + stdin.write(key) + await Bun.sleep(30) + } + }, + unmount: () => { + root.unmount() + stdin.end() + stdout.end() + }, + } +} + +describe('/sessions', () => { + let mounted: Awaited> | undefined + + beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'openclaude-sessions-cmd-')) + allLogs = [ + await makeLog(ALPHA, 'alpha task', 1), + await makeLog(BETA, 'beta task', 2), + await makeLog(GAMMA, 'gamma task', 3), + ] + const real = await import('../../utils/sessionStorage.js') + mock.module('../../utils/getWorktreePaths.js', () => ({ + getWorktreePaths: () => Promise.resolve([]), + })) + // Mirror the disk: a deleted transcript drops out of the next reload. + mock.module('../../utils/sessionStorage.js', () => ({ + ...real, + getSessionIdFromLog: (log: LogOption) => log.sessionId, + isLiteLog: () => false, + loadSameRepoMessageLogs: () => + Promise.resolve(allLogs.filter(log => existsSync(log.fullPath!))), + })) + }) + + afterEach(async () => { + mounted?.unmount() + mounted = undefined + mock.restore() + await rm(projectDir, { recursive: true, force: true }) + }) + + test('lists saved conversations with navigation and delete hints', async () => { + mounted = await runSessions() + const frame = await mounted.waitFor(f => f.includes('Sessions (3)')) + expect(frame).toContain('alpha task') + expect(frame).toContain('gamma task') + expect(frame).toContain('d d delete') + expect(frame).toContain('Esc close') + }) + + test('Enter opens Resume / Delete / Back, and Back returns to the list', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(ENTER) + const actions = await mounted.waitFor(f => f.includes('Return to the list')) + expect(actions).toContain('Resume') + expect(actions).toContain('Delete') + + await mounted.press(DOWN, DOWN, ENTER) + expect(await mounted.waitFor(f => f.includes('Sessions (3)'))).toContain('Sessions (3)') + }) + + test('Esc in the sub-menu goes back instead of closing', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + await mounted.press(ESC) + expect(await mounted.waitFor(f => f.includes('Sessions (3)'))).toContain('Sessions (3)') + expect(mounted.done).toEqual([]) + }) + + test('Delete asks for confirmation defaulting to Cancel, then removes the files', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(DOWN, ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + await mounted.press(DOWN, ENTER) + const confirm = await mounted.waitFor(f => f.includes('This cannot be undone')) + expect(confirm).toContain('Delete "beta task"?') + + // Enter on the default option cancels. + await mounted.press(ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + expect(existsSync(join(projectDir, `${BETA}.jsonl`))).toBe(true) + + await mounted.press(ENTER) + await mounted.waitFor(f => f.includes('This cannot be undone')) + await mounted.press(DOWN, ENTER) + const after = await mounted.waitFor(f => f.includes('Deleted "beta task"')) + expect(after).toContain('Sessions (2)') + expect(existsSync(join(projectDir, `${BETA}.jsonl`))).toBe(false) + expect(existsSync(join(projectDir, BETA))).toBe(false) + expect(existsSync(join(projectDir, `${ALPHA}.jsonl`))).toBe(true) + }) + + test('d pressed twice deletes the focused conversation; another key disarms it', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(DOWN, DOWN, 'd') + await mounted.waitFor(f => f.includes('Press d again to delete "gamma task"')) + + await mounted.press('x') + await mounted.waitFor(f => !f.includes('Press d again')) + expect(existsSync(join(projectDir, `${GAMMA}.jsonl`))).toBe(true) + + await mounted.press('d', 'd') + const after = await mounted.waitFor(f => f.includes('Deleted "gamma task"')) + expect(after).toContain('Sessions (2)') + expect(existsSync(join(projectDir, `${GAMMA}.jsonl`))).toBe(false) + }) + + test('Resume hands the session to the REPL resume flow', async () => { + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(DOWN, ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + await mounted.press(ENTER) + await waitUntil(() => mounted!.resume.mock.calls.length, n => n > 0) + expect(mounted.resume.mock.calls[0]?.[0]).toBe(BETA) + expect(mounted.resume.mock.calls[0]?.[2]).toBe('slash_command_picker') + }) + + test('a transcript that fails to load is not resumed and leaves the list usable', async () => { + // Real loadFullLog (kept by beforeEach's spread): the test transcripts hold + // no messages, so it falls back to the lite log, as for unreadable files. + const real = await import('../../utils/sessionStorage.js') + mock.module('../../utils/sessionStorage.js', () => ({ + ...real, + getSessionIdFromLog: (log: LogOption) => log.sessionId, + // beforeEach stubs isLiteLog to false; restore the upstream check. + isLiteLog: (log: LogOption) => + log.messages.length === 0 && log.sessionId !== undefined, + loadSameRepoMessageLogs: () => + Promise.resolve(allLogs.filter(log => existsSync(log.fullPath!))), + })) + mounted = await runSessions() + await mounted.waitFor(f => f.includes('Sessions (3)')) + await mounted.press(DOWN, ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + await mounted.press(ENTER) + await mounted.waitFor(f => f.includes('Transcript could not be loaded')) + expect(mounted.resume.mock.calls.length).toBe(0) + + // Not stuck as busy: the list still takes input. + await mounted.press(ENTER) + await mounted.waitFor(f => f.includes('Return to the list')) + }) + + test('reports when there is nothing to list', async () => { + allLogs = [] + mounted = await runSessions() + await waitUntil(() => mounted!.done.length, n => n > 0) + expect(mounted.done[0]).toBe('No saved conversations in this project.') + }) +}) diff --git a/src/commands/sessions/sessions.tsx b/src/commands/sessions/sessions.tsx new file mode 100644 index 000000000..b22e2b5da --- /dev/null +++ b/src/commands/sessions/sessions.tsx @@ -0,0 +1,296 @@ +import type { UUID } from 'crypto' +import * as React from 'react' +import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js' +import { Select } from '../../components/CustomSelect/index.js' +import { Dialog } from '../../components/design-system/Dialog.js' +import { Box, Text, useInput } from '../../ink.js' +import type { + LocalJSXCommandCall, + LocalJSXCommandOnDone, + ResumeEntrypoint, +} from '../../types/command.js' +import type { LogOption } from '../../types/logs.js' +import { formatLogMetadata } from '../../utils/format.js' +import { getWorktreePaths } from '../../utils/getWorktreePaths.js' +import { getLogDisplayTitle, logError } from '../../utils/log.js' +import { deleteSessionFiles } from '../../utils/sessionDeletion.js' +import { + getSessionIdFromLog, + getTranscriptPathForSession, + isLiteLog, + loadFullLog, + loadSameRepoMessageLogs, +} from '../../utils/sessionStorage.js' +import { validateUuid } from '../../utils/uuid.js' +import { filterResumableSessions } from '../resume/resume.js' + +type View = + | { kind: 'list' } + | { kind: 'actions'; log: LogOption; focus?: 'resume' | 'delete' | 'back' } + | { kind: 'confirm-delete'; log: LogOption } + +type Notice = { text: string; error?: boolean } + +type OnResume = ( + sessionId: UUID, + log: LogOption, + entrypoint: ResumeEntrypoint, +) => Promise + +const SESSIONS_LIST_HINT = + '↑/↓ navigate · Enter options · d d delete · Esc close' + +/** Stable React key and focus id for a saved conversation row. */ +function sessionKey(log: LogOption): string { + return getSessionIdFromLog(log) ?? log.fullPath ?? String(log.value) +} + +/** + * `/sessions`: list this repo's saved conversations, open one to resume or + * delete it, and go back without leaving the command. `d` pressed twice on + * a row deletes it directly (#2223); Ctrl+D stays the global exit key. + */ +function SessionsManager({ + onDone, + onResume, +}: { + onDone: LocalJSXCommandOnDone + onResume: OnResume +}): React.ReactNode { + const [logs, setLogs] = React.useState(null) + const [view, setView] = React.useState({ kind: 'list' }) + const [focusedKey, setFocusedKey] = React.useState() + const [pendingDeleteKey, setPendingDeleteKey] = React.useState(null) + const [notice, setNotice] = React.useState(null) + const [busy, setBusy] = React.useState(false) + // Remounts the list so it picks up a new default focus after a reload. + const [listVersion, setListVersion] = React.useState(0) + + const loadLogs = React.useCallback(async (): Promise => { + const paths = await getWorktreePaths(getOriginalCwd()) + const all = await loadSameRepoMessageLogs(paths) + return filterResumableSessions(all, getSessionId()) + }, []) + + React.useEffect(() => { + loadLogs() + .then(loaded => { + if (loaded.length === 0) { + onDone('No saved conversations in this project.', { display: 'system' }) + return + } + setLogs(loaded) + setFocusedKey(sessionKey(loaded[0]!)) + }) + .catch((error: unknown) => { + logError(error as Error) + onDone('Failed to load conversations', { display: 'system' }) + }) + }, [loadLogs, onDone]) + + const close = React.useCallback(() => { + onDone(undefined, { display: 'skip' }) + }, [onDone]) + + const backToList = (log?: LogOption) => { + if (log) setFocusedKey(sessionKey(log)) + setView({ kind: 'list' }) + setListVersion(v => v + 1) + } + + const deleteLog = async (log: LogOption) => { + if (!logs) return + setPendingDeleteKey(null) + setBusy(true) + const index = logs.findIndex(l => sessionKey(l) === sessionKey(log)) + const sessionId = getSessionIdFromLog(log) ?? '' + const title = getLogDisplayTitle(log) + try { + const result = await deleteSessionFiles({ + sessionId, + transcriptPath: log.fullPath ?? getTranscriptPathForSession(sessionId), + currentSessionId: getSessionId(), + }) + if (!result.ok) { + setNotice({ text: result.reason, error: true }) + backToList(log) + return + } + const remaining = await loadLogs() + if (remaining.length === 0) { + onDone(`Deleted "${title}". No saved conversations left.`, { display: 'system' }) + return + } + setLogs(remaining) + // Keep focus on the same row position rather than jumping elsewhere. + setFocusedKey(sessionKey(remaining[Math.min(Math.max(index, 0), remaining.length - 1)]!)) + setNotice({ text: `Deleted "${title}".` }) + setView({ kind: 'list' }) + setListVersion(v => v + 1) + } catch (error) { + logError(error as Error) + setNotice({ text: `Failed to delete "${title}": ${(error as Error).message}`, error: true }) + backToList(log) + } finally { + setBusy(false) + } + } + + const resumeLog = async (log: LogOption) => { + const sessionId = validateUuid(getSessionIdFromLog(log)) + if (!sessionId) { + setNotice({ text: 'Failed to resume conversation', error: true }) + return + } + setBusy(true) + try { + const fullLog = isLiteLog(log) ? await loadFullLog(log) : log + // loadFullLog falls back to the message-less lite log when the + // transcript can't be read; resuming it would open an empty conversation. + if (isLiteLog(fullLog)) { + throw new Error('Transcript could not be loaded') + } + await onResume(sessionId, fullLog, 'slash_command_picker') + } catch (error) { + // Loading the transcript failed (onResume reports its own errors): stay + // usable instead of leaving the dialog stuck as busy. + logError(error as Error) + setNotice({ + text: `Failed to resume "${getLogDisplayTitle(log)}": ${(error as Error).message}`, + error: true, + }) + setBusy(false) + backToList(log) + } + } + + useInput( + (input, key) => { + if (!logs || key.ctrl || key.meta) return + const focused = logs.find(l => sessionKey(l) === focusedKey) + if (input === 'd' && focused) { + if (pendingDeleteKey === sessionKey(focused)) { + void deleteLog(focused) + } else { + setPendingDeleteKey(sessionKey(focused)) + setNotice({ text: `Press d again to delete "${getLogDisplayTitle(focused)}".` }) + } + return + } + if (pendingDeleteKey) { + setPendingDeleteKey(null) + setNotice(null) + } + }, + { isActive: view.kind === 'list' && !busy && logs !== null }, + ) + + if (!logs) { + return Loading conversations… + } + + const noticeLine = notice ? ( + {notice.text} + ) : null + + if (view.kind === 'confirm-delete') { + const { log } = view + return ( + setView({ kind: 'actions', log, focus: 'delete' })} + > + { + setNotice(null) + if (value === 'resume') void resumeLog(log) + else if (value === 'delete') setView({ kind: 'confirm-delete', log }) + else backToList(log) + }} + onCancel={() => backToList(log)} + /> + + + ) + } + + return ( + + + {noticeLine} +