diff --git a/src/commands/replay/replay.tsx b/src/commands/replay/replay.tsx index 8bfcb55ae9..f03ef31e13 100644 --- a/src/commands/replay/replay.tsx +++ b/src/commands/replay/replay.tsx @@ -3,10 +3,12 @@ import chalk from 'chalk'; import type { UUID } from 'crypto'; import * as React from 'react'; import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'; -import { LogSelector } from '../../components/LogSelector.js'; +import { DeleteSessionConfirm } from '../../components/DeleteSessionConfirm.js'; +import { LogSelector, getResumeLogDisplayTitle } from '../../components/LogSelector.js'; import { MessageResponse } from '../../components/MessageResponse.js'; import { Spinner } from '../../components/Spinner.js'; import { useIsInsideModal } from '../../context/modalContext.js'; +import { useSessionDelete } from '../../hooks/useSessionDelete.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { Box, Text } from '../../ink.js'; import type { LocalJSXCommandCall } from '../../types/command.js'; @@ -98,6 +100,15 @@ function ReplaySessionPicker({ onDone('Replay cancelled'); } + const sessionDelete = useSessionDelete({ + getActiveSessionId: () => getSessionId(), + onDeleted: (sessionId: string) => { + setLogs(prev => prev.filter(l => getSessionIdFromLog(l) !== sessionId)); + setSelected(false); + setReplayIndex(null); + }, + }); + if (loading) { return ( @@ -120,14 +131,34 @@ function ReplaySessionPicker({ ); } + const deleteBanner = sessionDelete.deleteError || sessionDelete.deleteNotice ? ( + + {sessionDelete.deleteError && {sessionDelete.deleteError}} + {sessionDelete.deleteNotice && {sessionDelete.deleteNotice}} + + ) : null; + return ( - {}} - /> + + {sessionDelete.deletingLog && ( + + )} + {}} + /> + {deleteBanner} + ); } diff --git a/src/commands/resume/resume.tsx b/src/commands/resume/resume.tsx index 4f81af9a1f..6148430d75 100644 --- a/src/commands/resume/resume.tsx +++ b/src/commands/resume/resume.tsx @@ -9,11 +9,13 @@ import { saveGoalState } from '../../services/goal/persistence.js'; import { resumeGoal } from '../../services/goal/state.js'; import type { GoalState } from '../../services/goal/types.js'; import type { CommandResultDisplay, ResumeEntrypoint } from '../../commands.js'; -import { LogSelector } from '../../components/LogSelector.js'; +import { DeleteSessionConfirm } from '../../components/DeleteSessionConfirm.js'; +import { LogSelector, getResumeLogDisplayTitle } from '../../components/LogSelector.js'; import { MessageResponse } from '../../components/MessageResponse.js'; import { SessionSummary } from '../../components/SessionSummary.js'; import { Spinner } from '../../components/Spinner.js'; import { useIsInsideModal } from '../../context/modalContext.js'; +import { useSessionDelete } from '../../hooks/useSessionDelete.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { setClipboard } from '../../ink/termio/osc.js'; import { Box, Text, useInput } from '../../ink.js'; @@ -255,6 +257,23 @@ function ResumeCommand({ display: 'system' }); } + // Shared Del-to-delete flow (same guards/confirm/dialog as the --resume + // picker). The entry is dropped locally, matching ResumeConversation. + const sessionDelete = useSessionDelete({ + getActiveSessionId: () => getSessionId(), + onDeleted: (sessionId: string) => { + setLogs(prev => prev.filter(l => getSessionIdFromLog(l) !== sessionId)); + // Belt-and-braces: the picker is visible only while no session is + // selected, but never resume a session that was just deleted. + if ( + selectedSession && + getSessionIdFromLog(selectedSession.log) === sessionId + ) { + setSelectedSession(null); + setSessionSummary(null); + } + }, + }); if (loading) { return @@ -273,7 +292,16 @@ function ResumeCommand({ return ; } - return loadLogs(showAllProjects, worktreePaths)} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />; + const deleteBanner = sessionDelete.deleteError || sessionDelete.deleteNotice ? + {sessionDelete.deleteError && {sessionDelete.deleteError}} + {sessionDelete.deleteNotice && {sessionDelete.deleteNotice}} + : null; + + return + {sessionDelete.deletingLog && } + loadLogs(showAllProjects, worktreePaths)} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} /> + {deleteBanner} + ; } export function filterResumableSessions(logs: LogOption[], currentSessionId: string): LogOption[] { return logs.filter(l => !l.isSidechain && getSessionIdFromLog(l) !== currentSessionId); diff --git a/src/hooks/useSessionDelete.test.tsx b/src/hooks/useSessionDelete.test.tsx new file mode 100644 index 0000000000..b9cd6c559c --- /dev/null +++ b/src/hooks/useSessionDelete.test.tsx @@ -0,0 +1,209 @@ +import { PassThrough } from 'node:stream' + +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import React from 'react' + +import { createRoot } from '../ink.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import type { LogOption } from '../types/logs.js' +import { useSessionDelete } from './useSessionDelete.js' + +const ACTIVE_ID = '11111111-1111-4111-8111-111111111111' +const TARGET_ID = '22222222-2222-4222-8222-222222222222' +const BG_ID = '33333333-3333-4333-8333-333333333333' + +function makeLog(sessionId: string): LogOption { + return { + sessionId, + messages: [], + firstPrompt: 'test prompt', + } as unknown as LogOption +} + +type HookApi = ReturnType + +function createTestStreams() { + const stdout = new PassThrough() + const stdin = new PassThrough() as PassThrough & { + isTTY: boolean + setRawMode: (mode: boolean) => void + ref: () => void + unref: () => void + } + stdin.isTTY = true + stdin.setRawMode = () => {} + stdin.ref = () => {} + stdin.unref = () => {} + return { stdout, stdin } +} + +async function waitFor( + predicate: () => boolean, + timeoutMs = 2000, +): Promise { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + if (predicate()) return + await Bun.sleep(10) + } + throw new Error('Timed out waiting for useSessionDelete test') +} + +function Probe({ + apiRef, + options, +}: { + apiRef: { current: HookApi | null } + options: Parameters[0] +}): React.ReactNode { + const api = useSessionDelete(options) + apiRef.current = api + return null +} + +async function renderHook( + options: Parameters[0], +): Promise<{ apiRef: { current: HookApi | null }; cleanup: () => void }> { + const { stdout, stdin } = createTestStreams() + const root = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + const apiRef: { current: HookApi | null } = { current: null } + root.render() + await waitFor(() => apiRef.current !== null) + return { + apiRef, + cleanup: () => { + root.unmount() + stdin.end() + stdout.end() + }, + } +} + +function baseOptions(overrides?: Partial[0]>) { + return { + getActiveSessionId: () => ACTIVE_ID, + onDeleted: mock(() => {}), + listRunningSessionIds: async () => new Set(), + deleteSessionFn: mock(async (_id: string) => {}), + ...overrides, + } +} + +beforeEach(async () => { + await acquireSharedMutationLock('hooks/useSessionDelete.test.tsx') +}) + +afterEach(() => { + try { + mock.restore() + } finally { + releaseSharedMutationLock() + } +}) + +describe('useSessionDelete', () => { + test('request on a deletable session opens the pending delete', async () => { + const options = baseOptions() + const { apiRef, cleanup } = await renderHook(options) + try { + apiRef.current!.handleDeleteRequest(makeLog(TARGET_ID)) + await waitFor(() => apiRef.current!.deletingLog !== null) + expect(apiRef.current!.isDeletePending).toBe(true) + expect(apiRef.current!.deleteError).toBeNull() + } finally { + cleanup() + } + }) + + test('confirm deletes, drops the entry, and shows a notice', async () => { + const options = baseOptions() + const { apiRef, cleanup } = await renderHook(options) + try { + apiRef.current!.handleDeleteRequest(makeLog(TARGET_ID)) + await waitFor(() => apiRef.current!.deletingLog !== null) + apiRef.current!.handleDeleteConfirm() + await waitFor( + () => + (options.deleteSessionFn as ReturnType).mock.calls + .length > 0, + ) + await waitFor(() => apiRef.current!.deletingLog === null) + expect(options.deleteSessionFn).toHaveBeenCalledWith(TARGET_ID) + expect(options.onDeleted).toHaveBeenCalledWith(TARGET_ID) + expect(apiRef.current!.deleteNotice).toContain('Deleted') + } finally { + cleanup() + } + }) + + test('cancel clears the pending delete without deleting', async () => { + const options = baseOptions() + const { apiRef, cleanup } = await renderHook(options) + try { + apiRef.current!.handleDeleteRequest(makeLog(TARGET_ID)) + await waitFor(() => apiRef.current!.deletingLog !== null) + apiRef.current!.handleDeleteCancel() + await waitFor(() => apiRef.current!.deletingLog === null) + expect(options.deleteSessionFn).not.toHaveBeenCalled() + expect(options.onDeleted).not.toHaveBeenCalled() + } finally { + cleanup() + } + }) + + test('active session is blocked with an explanatory error', async () => { + const options = baseOptions() + const { apiRef, cleanup } = await renderHook(options) + try { + apiRef.current!.handleDeleteRequest(makeLog(ACTIVE_ID)) + await waitFor(() => apiRef.current!.deleteError !== null) + expect(apiRef.current!.deleteError).toContain('active session') + expect(apiRef.current!.deletingLog).toBeNull() + expect(options.deleteSessionFn).not.toHaveBeenCalled() + } finally { + cleanup() + } + }) + + test('live background session is blocked with an explanatory error', async () => { + const options = baseOptions({ + listRunningSessionIds: async () => new Set([BG_ID]), + }) + const { apiRef, cleanup } = await renderHook(options) + try { + apiRef.current!.handleDeleteRequest(makeLog(BG_ID)) + await waitFor(() => apiRef.current!.deleteError !== null) + expect(apiRef.current!.deleteError).toContain('background session') + expect(apiRef.current!.deletingLog).toBeNull() + expect(options.deleteSessionFn).not.toHaveBeenCalled() + } finally { + cleanup() + } + }) + + test('already-deleted session drops the entry with an "Already deleted" notice', async () => { + const options = baseOptions({ + deleteSessionFn: mock(async (_id: string) => { + throw new Error(`Session not found: ${_id}`) + }), + }) + const { apiRef, cleanup } = await renderHook(options) + try { + apiRef.current!.handleDeleteRequest(makeLog(TARGET_ID)) + await waitFor(() => apiRef.current!.deletingLog !== null) + apiRef.current!.handleDeleteConfirm() + await waitFor(() => apiRef.current!.deletingLog === null) + expect(options.onDeleted).toHaveBeenCalledWith(TARGET_ID) + expect(apiRef.current!.deleteNotice).toContain('Already deleted') + } finally { + cleanup() + } + }) +}) diff --git a/src/hooks/useSessionDelete.ts b/src/hooks/useSessionDelete.ts new file mode 100644 index 0000000000..b352464504 --- /dev/null +++ b/src/hooks/useSessionDelete.ts @@ -0,0 +1,141 @@ +import * as React from 'react' + +import { listBackgroundSessions } from '../cli/bgRegistry.js' +import { getResumeLogDisplayTitle } from '../components/LogSelector.js' +import { deleteSession } from '../entrypoints/sdk/sessions.js' +import { getDeleteBlockReason } from '../screens/resumeDeleteGuards.js' +import { logEvent } from '../services/analytics/index.js' +import type { LogOption } from '../types/logs.js' +import { errorMessage } from '../utils/errors.js' +import { getSessionIdFromLog } from '../utils/sessionStorage.js' + +export type UseSessionDeleteOptions = { + /** Returns the id of the currently active session (never deletable). */ + getActiveSessionId: () => string + /** Called after a successful delete so the host can drop the entry. */ + onDeleted: (sessionId: string) => void + /** Override for tests. Defaults to live background-session lookup. */ + listRunningSessionIds?: () => Promise> + /** Override for tests. Defaults to the real session-file deletion. */ + deleteSessionFn?: (sessionId: string) => Promise +} + +async function defaultListRunningSessionIds(): Promise> { + const bgSessions = await listBackgroundSessions() + return new Set( + bgSessions + .filter(s => s.status === 'running' || s.status === 'unknown') + .map(s => s.sessionId), + ) +} + +/** + * Shared delete state machine for session pickers (Del-to-delete flow). + * + * Extracted from the `--resume` CLI picker (`ResumeConversation`, which keeps + * its own inline copy untouched) so the in-REPL `/resume` and `/replay` + * pickers share identical guard/confirm/dialog behavior with zero drift. + * Hosts render `DeleteSessionConfirm` + error/notice banners themselves and + * pass `handleDeleteRequest` / `isDeletePending` to `LogSelector`. + */ +export function useSessionDelete({ + getActiveSessionId, + onDeleted, + listRunningSessionIds = defaultListRunningSessionIds, + deleteSessionFn = deleteSession, +}: UseSessionDeleteOptions): { + deletingLog: LogOption | null + deleteError: string | null + deleteNotice: string | null + isDeletePending: boolean + handleDeleteRequest: (log: LogOption) => void + handleDeleteConfirm: () => void + handleDeleteCancel: () => void +} { + const [deletingLog, setDeletingLog] = React.useState(null) + const [deleteError, setDeleteError] = React.useState(null) + const [deleteNotice, setDeleteNotice] = React.useState(null) + + async function handleDeleteRequest(log: LogOption): Promise { + const targetId = getSessionIdFromLog(log) + setDeleteError(null) + setDeleteNotice(null) + if (!targetId) { + setDeleteError('Cannot determine the session id for this entry.') + return + } + if (targetId === getActiveSessionId()) { + setDeleteError('Cannot delete the active session.') + return + } + try { + const runningIds = await listRunningSessionIds() + const blockReason = getDeleteBlockReason( + targetId, + getActiveSessionId(), + runningIds, + ) + if (blockReason) { + setDeleteError(blockReason) + return + } + } catch (e) { + setDeleteError(`Failed to check background sessions: ${errorMessage(e)}`) + return + } + setDeletingLog(log) + } + + async function handleDeleteConfirm(): Promise { + const target = deletingLog + if (!target) return + const targetId = getSessionIdFromLog(target) + if (!targetId) { + setDeletingLog(null) + return + } + try { + await deleteSessionFn(targetId) + onDeleted(targetId) + setDeletingLog(null) + setDeleteNotice(`Deleted ${getResumeLogDisplayTitle(target)}.`) + logEvent('tengu_session_delete', { + confirmed: true, + success: true, + }) + } catch (e) { + if (e instanceof Error && e.message.startsWith('Session not found')) { + onDeleted(targetId) + setDeletingLog(null) + setDeleteNotice('Already deleted.') + logEvent('tengu_session_delete', { + confirmed: true, + success: true, + }) + return + } + setDeleteError(`Failed to delete: ${errorMessage(e)}`) + logEvent('tengu_session_delete', { + confirmed: true, + success: false, + }) + } + } + + function handleDeleteCancel(): void { + logEvent('tengu_session_delete', { + confirmed: false, + }) + setDeletingLog(null) + } + + return { + deletingLog, + deleteError, + deleteNotice, + isDeletePending: deletingLog !== null, + handleDeleteRequest: (log: LogOption) => void handleDeleteRequest(log), + handleDeleteConfirm: () => void handleDeleteConfirm(), + handleDeleteCancel, + } +}