From 427abbe7fe961c9e2411d0223c4687f6b049feed Mon Sep 17 00:00:00 2001 From: Reinis Date: Thu, 15 Oct 2026 21:59:27 +0300 Subject: [PATCH 1/2] fix(sessions): deletion-barrier guards and command-only session fix (upstream #2262 Phase A) --- 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 +++++++++++- 5 files changed, 680 insertions(+), 9 deletions(-) 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/src/utils/sessionDeletion.test.ts b/src/utils/sessionDeletion.test.ts new file mode 100644 index 0000000000..d3cb987fea --- /dev/null +++ b/src/utils/sessionDeletion.test.ts @@ -0,0 +1,106 @@ +import { existsSync } from 'fs' +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' + +import { deleteSessionFiles } from './sessionDeletion.js' + +const SESSION = '11111111-1111-4111-8111-111111111111' +const OTHER = '22222222-2222-4222-8222-222222222222' +const CURRENT = '33333333-3333-4333-8333-333333333333' + +let projectDir: string + +async function writeSession(id: string): Promise { + const transcript = join(projectDir, `${id}.jsonl`) + await writeFile(transcript, '{}\n') + await writeFile(join(projectDir, `${id}.cast`), '') + await writeFile(join(projectDir, `${id}.replay.json`), '{}') + await mkdir(join(projectDir, id, 'tool-results'), { recursive: true }) + await writeFile(join(projectDir, id, 'tool-results', 'out.txt'), 'x') + return transcript +} + +beforeEach(async () => { + projectDir = await mkdtemp(join(tmpdir(), 'openclaude-session-delete-')) +}) + +afterEach(async () => { + await rm(projectDir, { recursive: true, force: true }) +}) + +describe('deleteSessionFiles', () => { + test('removes the transcript and every sidecar, leaving other sessions', async () => { + const transcript = await writeSession(SESSION) + const other = await writeSession(OTHER) + + const result = await deleteSessionFiles({ + sessionId: SESSION, + transcriptPath: transcript, + currentSessionId: CURRENT, + }) + + expect(result.ok).toBe(true) + expect(existsSync(transcript)).toBe(false) + expect(existsSync(join(projectDir, `${SESSION}.cast`))).toBe(false) + expect(existsSync(join(projectDir, `${SESSION}.replay.json`))).toBe(false) + expect(existsSync(join(projectDir, SESSION))).toBe(false) + expect(existsSync(other)).toBe(true) + expect(existsSync(join(projectDir, OTHER))).toBe(true) + }) + + test('succeeds when optional sidecars are missing', async () => { + const transcript = join(projectDir, `${SESSION}.jsonl`) + await writeFile(transcript, '{}\n') + + const result = await deleteSessionFiles({ + sessionId: SESSION, + transcriptPath: transcript, + currentSessionId: CURRENT, + }) + + expect(result.ok).toBe(true) + expect(existsSync(transcript)).toBe(false) + }) + + test('refuses to delete the current session', async () => { + const transcript = await writeSession(CURRENT) + + const result = await deleteSessionFiles({ + sessionId: CURRENT, + transcriptPath: transcript, + currentSessionId: CURRENT, + }) + + expect(result).toEqual({ + ok: false, + reason: 'The current session cannot be deleted.', + }) + expect(existsSync(transcript)).toBe(true) + }) + + test('rejects ids that are not UUIDs, so paths cannot escape the project dir', async () => { + const result = await deleteSessionFiles({ + sessionId: '../../etc', + transcriptPath: join(projectDir, '../../etc.jsonl'), + currentSessionId: CURRENT, + }) + + expect(result.ok).toBe(false) + }) + + test('rejects a transcript path that belongs to another session', async () => { + const other = await writeSession(OTHER) + + const result = await deleteSessionFiles({ + sessionId: SESSION, + transcriptPath: other, + currentSessionId: CURRENT, + }) + + expect(result.ok).toBe(false) + expect(existsSync(other)).toBe(true) + }) +}) diff --git a/src/utils/sessionDeletion.ts b/src/utils/sessionDeletion.ts new file mode 100644 index 0000000000..cc3583a502 --- /dev/null +++ b/src/utils/sessionDeletion.ts @@ -0,0 +1,85 @@ +import { existsSync } from 'fs' +import { rm } from 'fs/promises' +import { basename, dirname, join } from 'path' +import { logError } from './log.js' +import { forgetDeletedSession, restoreSessionWrites } from './sessionStorage.js' +import { validateUuid } from './uuid.js' + +export type DeleteSessionResult = + | { ok: true; removed: string[] } + | { ok: false; reason: string } + +/** + * Deletes a saved conversation from disk: the transcript + * (`/.jsonl`) plus its sidecars, the same set + * `cleanupOldSessionFiles` removes (`.cast`, `.replay.json` and the + * `/` dir holding tool-results and subagent transcripts). + * + * Refuses the active session: its transcript is still being appended to, and + * removing it under a live REPL breaks later writes. + */ +export async function deleteSessionFiles({ + sessionId, + transcriptPath, + currentSessionId, +}: { + sessionId: string + transcriptPath: string + currentSessionId: string +}): Promise { + const id = validateUuid(sessionId) + if (!id) { + return { ok: false, reason: `Invalid session id: ${sessionId}` } + } + if (id === currentSessionId) { + return { ok: false, reason: 'The current session cannot be deleted.' } + } + // Only ever touch files named after the validated id, next to the transcript. + if (basename(transcriptPath) !== `${id}.jsonl`) { + return { + ok: false, + reason: `Transcript path does not match session ${id}.`, + } + } + + // Block later writes first, or a pending AI title/summary/metadata append + // recreates the transcript and the session comes back. + try { + await forgetDeletedSession(id, transcriptPath) + } catch (error) { + // Nothing was removed (a pending write failed while settling): undo the + // mark so the still-present transcript keeps receiving writes. + restoreSessionWrites(transcriptPath) + throw error + } + + const projectDir = dirname(transcriptPath) + const targets = [ + transcriptPath, + join(projectDir, `${id}.cast`), + join(projectDir, `${id}.replay.json`), + join(projectDir, id), + ] + // force: sidecars are optional, so a missing one is not an error. + // allSettled: decide on the transcript only after every removal finished, + // or a fast sidecar failure would restore writes mid-removal. + const results = await Promise.allSettled( + targets.map(target => rm(target, { recursive: true, force: true })), + ) + const failures = results.flatMap(result => + result.status === 'rejected' ? [result.reason] : [], + ) + if (failures.length > 0 && existsSync(transcriptPath)) { + // The transcript survived (e.g. EBUSY on Windows): stop dropping writes + // to it, or disk and this process disagree until restart. + restoreSessionWrites(transcriptPath) + throw failures[0] + } + // The transcript is gone, so the conversation is deleted; a sidecar that + // could not be removed is only an orphan, like any missing one. + for (const failure of failures) logError(failure) + return { + ok: true, + removed: targets.filter((_, i) => results[i]!.status === 'fulfilled'), + } +} diff --git a/src/utils/sessionStorage.commandOnlySession.test.ts b/src/utils/sessionStorage.commandOnlySession.test.ts new file mode 100644 index 0000000000..fc1babcc25 --- /dev/null +++ b/src/utils/sessionStorage.commandOnlySession.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { type UUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import { + getSessionId, + isSessionPersistenceDisabled, + setSessionPersistenceDisabled, + switchSession, +} from '../bootstrap/state.js' +import type { Message } from '../types/message.js' +import { setClaudeConfigHomeDirForTesting } from './envUtils.js' +import { + createAssistantMessage, + createUserMessage, + NO_RESPONSE_REQUESTED, +} from './messages.js' +import { + flushSessionStorage, + getTranscriptPathForSession, + recordTranscript, + resetProjectForTesting, + resetSessionFilePointer, + sessionIdExists, +} from './sessionStorage.js' + +const SESSION = '00000000-0000-4000-8000-00000000c001' as UUID +const OTHER = '00000000-0000-4000-8000-00000000c002' as UUID +const tempDirs: string[] = [] +const savedEnv = { + NODE_ENV: process.env.NODE_ENV, + TEST_ENABLE_SESSION_PERSISTENCE: process.env.TEST_ENABLE_SESSION_PERSISTENCE, + ENABLE_SESSION_PERSISTENCE: process.env.ENABLE_SESSION_PERSISTENCE, +} +let originalSessionId: string +let originalPersistenceDisabled: boolean + +beforeEach(async () => { + await acquireSharedMutationLock('utils/sessionStorage.commandOnlySession.test.ts') + originalSessionId = getSessionId() + originalPersistenceDisabled = isSessionPersistenceDisabled() + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + setSessionPersistenceDisabled(false) + const configDir = await mkdtemp(join(tmpdir(), 'openclaude-command-only-')) + tempDirs.push(configDir) + setClaudeConfigHomeDirForTesting(configDir) + resetProjectForTesting() + switchSession(SESSION as never) + await resetSessionFilePointer() +}) + +afterEach(async () => { + try { + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + setSessionPersistenceDisabled(originalPersistenceDisabled) + switchSession(originalSessionId as never) + resetProjectForTesting() + await Promise.all( + tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), + ) + } finally { + releaseSharedMutationLock() + } +}) + +/** What a local slash command such as `/clear` or `/exit` records. */ +function localCommand(name: string, output: string): Message[] { + return [ + createUserMessage({ + content: 'Caveat: generated by the user.', + isMeta: true, + }), + createUserMessage({ + content: `/${name}\n${name}\n`, + }), + createUserMessage({ + content: `${output}`, + }), + ] +} + +test('a launch that only runs local commands leaves no transcript', async () => { + const messages = [ + ...localCommand('clear', ''), + ...localCommand('exit', 'Catch you later!'), + createAssistantMessage({ content: NO_RESPONSE_REQUESTED }), + ] + await recordTranscript(messages) + await flushSessionStorage() + + expect(existsSync(getTranscriptPathForSession(SESSION))).toBe(false) + // No file means no "Resume this session with" hint at exit. + expect(sessionIdExists(SESSION)).toBe(false) +}) + +test('the first real prompt saves the session with the earlier commands', async () => { + const commands = localCommand('model', 'Set model to test') + await recordTranscript(commands) + const prompt = createUserMessage({ content: 'explain this repo' }) + await recordTranscript([...commands, prompt]) + await flushSessionStorage() + + const transcript = getTranscriptPathForSession(SESSION) + expect(existsSync(transcript)).toBe(true) + const uuids = (await readFile(transcript, 'utf8')) + .trim() + .split('\n') + .map(line => JSON.parse(line).uuid) + for (const message of [...commands, prompt]) { + expect(uuids).toContain(message.uuid) + } +}) + +test('commands buffered for one session are not written into the next one', async () => { + const commands = localCommand('model', 'Set model to test') + await recordTranscript(commands) + // An SDK-style switch: the active session changes without resetting the + // shared session-file pointer. + switchSession(OTHER as never) + const prompt = createUserMessage({ content: 'explain this repo' }) + await recordTranscript([prompt]) + await flushSessionStorage() + + const uuids = (await readFile(getTranscriptPathForSession(OTHER), 'utf8')) + .trim() + .split('\n') + .map(line => JSON.parse(line).uuid) + expect(uuids).toContain(prompt.uuid) + for (const message of commands) { + expect(uuids).not.toContain(message.uuid) + } +}) + +test('a model reply also saves the session', async () => { + const reply = createAssistantMessage({ content: 'hello' }) + reply.message.model = 'test-model' + await recordTranscript([...localCommand('clear', ''), reply]) + await flushSessionStorage() + + expect(existsSync(getTranscriptPathForSession(SESSION))).toBe(true) +}) diff --git a/src/utils/sessionStorage.deletedSession.test.ts b/src/utils/sessionStorage.deletedSession.test.ts new file mode 100644 index 0000000000..b927a246bd --- /dev/null +++ b/src/utils/sessionStorage.deletedSession.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, expect, mock, spyOn, test } from 'bun:test' +import { type UUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import * as fsPromises from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import { + getSessionId, + isSessionPersistenceDisabled, + setSessionPersistenceDisabled, + switchSession, +} from '../bootstrap/state.js' +import { createGoalState } from '../services/goal/state.js' +import { setClaudeConfigHomeDirForTesting } from './envUtils.js' +import { deleteSessionFiles } from './sessionDeletion.js' +import * as sessionStorage from './sessionStorage.js' +import { + flushSessionStorage, + getTranscriptPathForSession, + recordGoalState, + resetProjectForTesting, + resetSessionFilePointer, + saveAiGeneratedTitle, + saveCustomTitle, + saveTaskSummary, +} from './sessionStorage.js' + +const DELETED = '00000000-0000-4000-8000-00000000d001' as UUID +const ACTIVE = '00000000-0000-4000-8000-00000000d002' as UUID +const tempDirs: string[] = [] +const savedEnv = { + NODE_ENV: process.env.NODE_ENV, + TEST_ENABLE_SESSION_PERSISTENCE: process.env.TEST_ENABLE_SESSION_PERSISTENCE, + ENABLE_SESSION_PERSISTENCE: process.env.ENABLE_SESSION_PERSISTENCE, +} +let originalSessionId: string +let originalPersistenceDisabled: boolean + +beforeEach(async () => { + await acquireSharedMutationLock('utils/sessionStorage.deletedSession.test.ts') + originalSessionId = getSessionId() + originalPersistenceDisabled = isSessionPersistenceDisabled() + process.env.NODE_ENV = 'development' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + setSessionPersistenceDisabled(false) + const configDir = await mkdtemp(join(tmpdir(), 'openclaude-deleted-session-')) + tempDirs.push(configDir) + setClaudeConfigHomeDirForTesting(configDir) + resetProjectForTesting() +}) + +afterEach(async () => { + try { + mock.restore() + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + setSessionPersistenceDisabled(originalPersistenceDisabled) + switchSession(originalSessionId as never) + resetProjectForTesting() + await Promise.all( + tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), + ) + } finally { + releaseSharedMutationLock() + } +}) + +/** A saved conversation that this process has already written to. */ +async function writtenOtherSession(): Promise { + const transcript = getTranscriptPathForSession(DELETED) + await mkdir(dirname(transcript), { recursive: true }) + await writeFile( + transcript, + `${JSON.stringify({ type: 'user', sessionId: DELETED, message: { role: 'user', content: 'hi' } })}\n`, + ) + switchSession(ACTIVE as never) + await resetSessionFilePointer() + // Warms the "file exists" cache used for queued writes to other sessions. + await recordGoalState(createGoalState('before delete'), DELETED) + await flushSessionStorage() + return transcript +} + +test('late metadata writes do not resurrect a deleted session transcript', async () => { + const transcript = await writtenOtherSession() + + const result = await deleteSessionFiles({ + sessionId: DELETED, + transcriptPath: transcript, + currentSessionId: ACTIVE, + }) + expect(result.ok).toBe(true) + + // Writers that finish after the delete: async AI title, periodic task + // summary, and a title write through the cached file path. + saveAiGeneratedTitle(DELETED, 'late ai title') + saveTaskSummary(DELETED, 'late summary') + await saveCustomTitle(DELETED, 'late title', transcript) + await recordGoalState(createGoalState('late goal'), DELETED) + await flushSessionStorage() + + expect(existsSync(transcript)).toBe(false) +}) + +test('writes queued before the delete are dropped, not flushed after it', async () => { + const transcript = await writtenOtherSession() + + // Queued but not yet drained when the user confirms the delete. + void recordGoalState(createGoalState('queued goal'), DELETED) + const result = await deleteSessionFiles({ + sessionId: DELETED, + transcriptPath: transcript, + currentSessionId: ACTIVE, + }) + expect(result.ok).toBe(true) + await flushSessionStorage() + + expect(existsSync(transcript)).toBe(false) +}) + +test('a transcript that could not be removed keeps receiving writes', async () => { + const transcript = await writtenOtherSession() + const realRm = fsPromises.rm + spyOn(fsPromises, 'rm').mockImplementation(async (path, options) => { + if (path === transcript) { + throw Object.assign(new Error('resource busy or locked'), { code: 'EBUSY' }) + } + return realRm(path, options) + }) + + await expect( + deleteSessionFiles({ + sessionId: DELETED, + transcriptPath: transcript, + currentSessionId: ACTIVE, + }), + ).rejects.toThrow('resource busy or locked') + mock.restore() + + await recordGoalState(createGoalState('after failed delete'), DELETED) + await flushSessionStorage() + + expect(await readFile(transcript, 'utf8')).toContain('after failed delete') +}) + +test('a sidecar failing first does not restore writes while the transcript is removed', async () => { + const transcript = await writtenOtherSession() + const realRm = fsPromises.rm + spyOn(fsPromises, 'rm').mockImplementation(async (path, options) => { + if (path === transcript) { + // Still removing when the sidecar below has already failed. + await new Promise(resolve => setTimeout(resolve, 20)) + } else if (String(path).endsWith('.cast')) { + throw Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) + } + return realRm(path, options) + }) + + const result = await deleteSessionFiles({ + sessionId: DELETED, + transcriptPath: transcript, + currentSessionId: ACTIVE, + }) + mock.restore() + expect(result.ok).toBe(true) + + await recordGoalState(createGoalState('late goal'), DELETED) + await flushSessionStorage() + + expect(existsSync(transcript)).toBe(false) +}) + +test('a failed deletion barrier keeps the transcript writable', async () => { + const transcript = await writtenOtherSession() + const realForget = sessionStorage.forgetDeletedSession + // Marks the path, then fails like a flush whose pending write errored. + spyOn(sessionStorage, 'forgetDeletedSession').mockImplementation( + async (sessionId, path) => { + await realForget(sessionId, path) + throw new Error('pending write failed') + }, + ) + + await expect( + deleteSessionFiles({ + sessionId: DELETED, + transcriptPath: transcript, + currentSessionId: ACTIVE, + }), + ).rejects.toThrow('pending write failed') + mock.restore() + + await recordGoalState(createGoalState('after failed barrier'), DELETED) + await flushSessionStorage() + + expect(await readFile(transcript, 'utf8')).toContain('after failed barrier') +}) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index 7ca4ada6f8..4b02f119b7 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -16,7 +16,7 @@ import { writeFile, } from 'fs/promises' import memoize from 'lodash-es/memoize.js' -import { basename, dirname, join } from 'path' +import { basename, dirname, join, resolve as resolvePath } from 'path' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, @@ -30,7 +30,13 @@ import { getSessionProjectDir, switchSession, } from '../bootstrap/state.js' -import { COMMAND_NAME_TAG, TICK_TAG } from '../constants/xml.js' +import { + COMMAND_NAME_TAG, + LOCAL_COMMAND_CAVEAT_TAG, + LOCAL_COMMAND_STDERR_TAG, + LOCAL_COMMAND_STDOUT_TAG, + TICK_TAG, +} from '../constants/xml.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' import * as sessionIngress from '../services/api/sessionIngress.js' import { REPL_TOOL_NAME } from '../tools/REPLTool/constants.js' @@ -82,7 +88,11 @@ import { getBranch } from './git.js' import { gracefulShutdownSync, isShuttingDown } from './gracefulShutdown.js' import { parseJSONL } from './json.js' import { logError } from './log.js' -import { extractTag, isCompactBoundaryMessage } from './messages.js' +import { + extractTag, + isCompactBoundaryMessage, + SYNTHETIC_MODEL, +} from './messages.js' import { sanitizePath } from './path.js' import { extractJsonStringField, @@ -483,6 +493,35 @@ export function isChainParticipant(m: Pick): boolean { return m.type !== 'progress' } +const LOCAL_COMMAND_TAGS = [ + COMMAND_NAME_TAG, + LOCAL_COMMAND_STDOUT_TAG, + LOCAL_COMMAND_STDERR_TAG, + LOCAL_COMMAND_CAVEAT_TAG, +] + +/** + * True for a message that makes the session worth saving: a real prompt, tool + * result or model reply. Local slash-command bookkeeping (`/clear`, `/exit`, + * `/sessions` and their output) and synthetic replies are not, so a launch + * that only runs commands leaves no transcript behind — no empty "/exit" + * entry in /resume and no resume hint for it at exit. + */ +function startsConversation(message: Message): boolean { + if (message.type === 'assistant') { + return message.message.model !== SYNTHETIC_MODEL + } + if (message.type !== 'user' || message.isMeta) return false + const content = message.message.content + const blocks = + typeof content === 'string' ? [{ type: 'text', text: content }] : content + return blocks.some( + block => + block.type !== 'text' || + !LOCAL_COMMAND_TAGS.some(tag => block.text.trimStart().startsWith(`<${tag}>`)), + ) +} + type LegacyProgressEntry = { type: 'progress' uuid: UUID @@ -901,6 +940,9 @@ class Project { // Entries buffered while sessionFile is null. Flushed by materializeSessionFile // on the first user/assistant message — prevents metadata-only session files. private pendingEntries: Entry[] = [] + // Session the buffered entries belong to; a switch drops them so they are + // not written into the next session's transcript. + private pendingEntriesSessionId: UUID | null = null private remoteIngressUrl: string | null = null private internalEventWriter: InternalEventWriter | null = null private internalEventReader: InternalEventReader | null = null @@ -1028,6 +1070,7 @@ class Project { filePath: string, data: string, ): Promise { + if (this.isDeletedSessionFile(filePath)) return transcriptRewriteHooksForTesting.beforeFileAppend?.(filePath) await mkdir(dirname(filePath), { recursive: true, mode: 0o700 }) await withTranscriptFileLock(filePath, async signal => { @@ -1093,6 +1136,7 @@ class Project { ): Promise { const earlierDirectAppends = this.pendingDirectAppends.get(filePath) if (earlierDirectAppends) await Promise.all(earlierDirectAppends) + if (this.isDeletedSessionFile(filePath)) return await withTranscriptFileLock(filePath, signal => rewrite(signal)) transcriptRewriteHooksForTesting.beforeBarrierRelease?.(filePath) } @@ -1230,6 +1274,18 @@ class Project { resetSessionFile(): void { this.sessionFile = null this.pendingEntries = [] + this.pendingEntriesSessionId = null + } + + /** Drops buffered entries that belong to a session other than `sessionId`. */ + private dropForeignPendingEntries(sessionId: UUID): void { + if ( + this.pendingEntriesSessionId !== null && + this.pendingEntriesSessionId !== sessionId + ) { + this.pendingEntries = [] + this.pendingEntriesSessionId = null + } } /** @@ -1561,12 +1617,14 @@ class Project { // (not appendEntry) so it would bypass the per-entry persistence check // and create a metadata-only file despite --no-session-persistence. if (this.shouldSkipPersistence()) return + this.dropForeignPendingEntries(getSessionId() as UUID) this.ensureCurrentSessionFile() // mode/agentSetting are cache-only pre-materialization; write them now. this.reAppendSessionMetadata() if (this.pendingEntries.length > 0) { const buffered = this.pendingEntries this.pendingEntries = [] + this.pendingEntriesSessionId = null for (const entry of buffered) { await this.appendEntry(entry) } @@ -1583,12 +1641,10 @@ class Project { return this.trackWrite(async () => { let parentUuid: UUID | null = startingParentUuid ?? null - // First user/assistant message materializes the session file. - // Hook progress/attachment messages alone stay buffered. - if ( - this.sessionFile === null && - messages.some(m => m.type === 'user' || m.type === 'assistant') - ) { + // First conversation message materializes the session file. + // Hook progress/attachment messages and local-command bookkeeping + // alone stay buffered. + if (this.sessionFile === null && messages.some(startsConversation)) { await this.materializeSessionFile() } @@ -1775,6 +1831,8 @@ class Project { if (isCurrentSession) { // Buffer until materializeSessionFile runs (first user/assistant message). if (this.sessionFile === null) { + this.dropForeignPendingEntries(currentSessionId) + this.pendingEntriesSessionId = currentSessionId this.pendingEntries.push(entry) return } @@ -1931,6 +1989,49 @@ class Project { * Caches positive results so we only stat once per session. */ private existingSessionFiles = new Map() + + /** + * Transcripts deleted (e.g. from /sessions) during this process. Writers + * that finish later — async AI title, task summary, queued appends, the + * exit-time metadata re-append — would otherwise recreate the file with + * metadata only, and the session would reappear in /sessions and /resume. + */ + private deletedSessionFiles = new Set() + + /** True when `filePath` is a transcript deleted earlier in this process. */ + isDeletedSessionFile(filePath: string): boolean { + return ( + this.deletedSessionFiles.size > 0 && + this.deletedSessionFiles.has(resolvePath(filePath)) + ) + } + + async forgetDeletedSession( + sessionId: UUID, + transcriptPath: string, + ): Promise { + this.deletedSessionFiles.add(resolvePath(transcriptPath)) + this.existingSessionFiles.delete(sessionId) + if (this.sessionFile && this.isDeletedSessionFile(this.sessionFile)) { + this.resetSessionFile() + } + // Let an in-flight drain finish so no write lands after the caller + // removes the file; queued appends for it are now skipped. + await this.flush() + // flush() does not wait for direct appends. One that passed the deleted + // check before the path was marked can still be waiting on mkdir or the + // file lock, and would recreate the file after the caller removes it. + const inFlight = [...this.pendingDirectAppends] + .filter(([path]) => this.isDeletedSessionFile(path)) + .flatMap(([, appends]) => [...appends]) + await Promise.allSettled(inFlight) + } + + /** Undo forgetDeletedSession for a transcript that could not be removed. */ + restoreSessionWrites(transcriptPath: string): void { + this.deletedSessionFiles.delete(resolvePath(transcriptPath)) + } + private async getExistingSessionFile( sessionId: UUID, ): Promise { @@ -2160,6 +2261,25 @@ export async function recordGoalState( await getProject().insertGoalState(goal, sessionId) } +/** + * Call before deleting a saved session's transcript: blocks every later + * write to it in this process and waits for pending writes to settle. + */ +export async function forgetDeletedSession( + sessionId: UUID, + transcriptPath: string, +): Promise { + await getProject().forgetDeletedSession(sessionId, transcriptPath) +} + +/** + * Undo forgetDeletedSession when the transcript is still on disk (its removal + * failed), so this process keeps writing to it instead of dropping writes. + */ +export function restoreSessionWrites(transcriptPath: string): void { + getProject().restoreSessionWrites(transcriptPath) +} + /** * Reset the session file pointer after switchSession/regenerateSessionId. * The new file is created lazily on the first user/assistant message. @@ -3527,6 +3647,7 @@ function appendEntryToFile( entry: Record, ): void { const fs = getFsImplementation() + if (project?.isDeletedSessionFile(fullPath)) return const line = jsonStringify(entry) + '\n' if (project?._deferSynchronousAppend(fullPath, line)) return fs.mkdirSync(dirname(fullPath), { mode: 0o700 }) From 1ea36499a84a03c63ebfbbe2870884a83444f300 Mon Sep 17 00:00:00 2001 From: Reinis Date: Thu, 15 Oct 2026 23:14:05 +0300 Subject: [PATCH 2/2] feat(sessions): add /sessions resume-or-delete picker (upstream #2262 Phase B) Reconciled with fork PRs #25/#26: pickers keep Delete-key+confirm, /sessions keeps Enter-menu+double-d. Deletion converges on sessionDeletion.ts. --- 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 + 8 files changed, 606 insertions(+) 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 diff --git a/README.md b/README.md index 319cdfc8e7..a078c926b3 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,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 diff --git a/src/commands.ts b/src/commands.ts index 9b046e9bd7..61b58a567d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -57,6 +57,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' @@ -344,6 +345,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 0000000000..bb5ec3a254 --- /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 0000000000..4bc7d79845 --- /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 0000000000..b22e2b5da4 --- /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} +