Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions src/commands/replay/replay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<Box>
Expand All @@ -120,14 +131,34 @@ function ReplaySessionPicker({
);
}

const deleteBanner = sessionDelete.deleteError || sessionDelete.deleteNotice ? (
<Box flexDirection="column" marginTop={1}>
{sessionDelete.deleteError && <Text color="red">{sessionDelete.deleteError}</Text>}
{sessionDelete.deleteNotice && <Text>{sessionDelete.deleteNotice}</Text>}
</Box>
) : null;

return (
<LogSelector
logs={logs}
maxHeight={insideModal ? Math.floor(rows / 2) : rows - 2}
onCancel={handleCancel}
onSelect={handleSelect}
onLogsChanged={() => {}}
/>
<Box flexDirection="column">
{sessionDelete.deletingLog && (
<DeleteSessionConfirm
sessionTitle={getResumeLogDisplayTitle(sessionDelete.deletingLog)}
sessionId={getSessionIdFromLog(sessionDelete.deletingLog) ?? ''}
onConfirm={sessionDelete.handleDeleteConfirm}
onCancel={sessionDelete.handleDeleteCancel}
/>
)}
<LogSelector
logs={logs}
maxHeight={insideModal ? Math.floor(rows / 2) : rows - 2}
onCancel={handleCancel}
onSelect={handleSelect}
onDeleteRequest={sessionDelete.handleDeleteRequest}
isInputDisabled={sessionDelete.isDeletePending}
onLogsChanged={() => {}}
/>
{deleteBanner}
</Box>
);
}

Expand Down
32 changes: 30 additions & 2 deletions src/commands/resume/resume.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <Box>
<Spinner />
Expand All @@ -273,7 +292,16 @@ function ResumeCommand({
return <ResumeConfirmation selectedSession={selectedSession} sessionSummary={sessionSummary} resuming={resuming} onResume={handleConfirmResume} onCancel={handleCancelResume} />;
}

return <LogSelector logs={logs} maxHeight={insideModal ? Math.floor(rows / 2) : rows - 2} onCancel={handleCancel} onSelect={handleSelect} onLogsChanged={() => loadLogs(showAllProjects, worktreePaths)} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />;
const deleteBanner = sessionDelete.deleteError || sessionDelete.deleteNotice ? <Box flexDirection="column" marginTop={1}>
{sessionDelete.deleteError && <Text color="red">{sessionDelete.deleteError}</Text>}
{sessionDelete.deleteNotice && <Text>{sessionDelete.deleteNotice}</Text>}
</Box> : null;

return <Box flexDirection="column">
{sessionDelete.deletingLog && <DeleteSessionConfirm sessionTitle={getResumeLogDisplayTitle(sessionDelete.deletingLog)} sessionId={getSessionIdFromLog(sessionDelete.deletingLog) ?? ''} onConfirm={sessionDelete.handleDeleteConfirm} onCancel={sessionDelete.handleDeleteCancel} />}
<LogSelector logs={logs} maxHeight={insideModal ? Math.floor(rows / 2) : rows - 2} onCancel={handleCancel} onSelect={handleSelect} onDeleteRequest={sessionDelete.handleDeleteRequest} isInputDisabled={sessionDelete.isDeletePending} onLogsChanged={() => loadLogs(showAllProjects, worktreePaths)} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />
{deleteBanner}
</Box>;
}
export function filterResumableSessions(logs: LogOption[], currentSessionId: string): LogOption[] {
return logs.filter(l => !l.isSidechain && getSessionIdFromLog(l) !== currentSessionId);
Expand Down
209 changes: 209 additions & 0 deletions src/hooks/useSessionDelete.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof useSessionDelete>

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<void> {
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<typeof useSessionDelete>[0]
}): React.ReactNode {
const api = useSessionDelete(options)
apiRef.current = api
return null
}

async function renderHook(
options: Parameters<typeof useSessionDelete>[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(<Probe apiRef={apiRef} options={options} />)
await waitFor(() => apiRef.current !== null)
return {
apiRef,
cleanup: () => {
root.unmount()
stdin.end()
stdout.end()
},
}
}

function baseOptions(overrides?: Partial<Parameters<typeof useSessionDelete>[0]>) {
return {
getActiveSessionId: () => ACTIVE_ID,
onDeleted: mock(() => {}),
listRunningSessionIds: async () => new Set<string>(),
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<typeof mock>).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()
}
})
})
Loading
Loading