import chalk from 'chalk'; import type { UUID } from 'crypto'; import figures from 'figures'; import * as React from 'react'; import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'; import type { CommandResultDisplay, ResumeEntrypoint } from '../../commands.js'; import { LogSelector } from '../../components/LogSelector.js'; import { MessageResponse } from '../../components/MessageResponse.js'; import { Spinner } from '../../components/Spinner.js'; import { useIsInsideModal } from '../../context/modalContext.js'; import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { setClipboard } from '@anthropic/ink'; import { Box, Text } from '@anthropic/ink'; import type { LocalJSXCommandCall } from '../../types/command.js'; import type { LogOption } from '../../types/logs.js'; import { agenticSessionSearch } from '../../utils/agenticSessionSearch.js'; import { checkCrossProjectResume } from '../../utils/crossProjectResume.js'; import { getWorktreePaths } from '../../utils/getWorktreePaths.js'; import { logError } from '../../utils/log.js'; import { getLastSessionLog, getSessionIdFromLog, isCustomTitleEnabled, isLiteLog, loadAllProjectsMessageLogs, loadFullLog, loadSameRepoMessageLogs, searchSessionsByCustomTitle, } from '../../utils/sessionStorage.js'; import { validateUuid } from '../../utils/uuid.js'; type ResumeResult = | { resultType: 'sessionNotFound'; arg: string } | { resultType: 'multipleMatches'; arg: string; count: number }; function resumeHelpMessage(result: ResumeResult): string { switch (result.resultType) { case 'sessionNotFound': return `Session ${chalk.bold(result.arg)} was not found. Run ${chalk.bold('/resume')} without arguments to browse all sessions.`; case 'multipleMatches': return `Found ${result.count} sessions matching ${chalk.bold(result.arg)}. Run ${chalk.bold('/resume')} to pick one from the list.`; } } function ResumeError({ message, args, onDone, }: { message: string; args: string; onDone: () => void; }): React.ReactNode { React.useEffect(() => { const timer = setTimeout(onDone, 0); return () => clearTimeout(timer); }, [onDone]); return ( {figures.pointer} /resume {args} {message} ); } function ResumeCommand({ onDone, onResume, }: { onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; onResume: (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => Promise; }): React.ReactNode { const [logs, setLogs] = React.useState([]); const [worktreePaths, setWorktreePaths] = React.useState([]); const [loading, setLoading] = React.useState(true); const [resuming, setResuming] = React.useState(false); const [showAllProjects, setShowAllProjects] = React.useState(false); const { rows } = useTerminalSize(); const insideModal = useIsInsideModal(); const loadLogs = React.useCallback( async (allProjects: boolean, paths: string[]) => { setLoading(true); try { const allLogs = allProjects ? await loadAllProjectsMessageLogs() : await loadSameRepoMessageLogs(paths); const resumable = filterResumableSessions(allLogs, getSessionId()); if (resumable.length === 0) { onDone('No conversations found to resume'); return; } setLogs(resumable); } catch (_err) { onDone('Failed to load conversations'); } finally { setLoading(false); } }, [onDone], ); React.useEffect(() => { async function init() { const paths = await getWorktreePaths(getOriginalCwd()); setWorktreePaths(paths); void loadLogs(false, paths); } void init(); }, [loadLogs]); const handleToggleAllProjects = React.useCallback(() => { const newValue = !showAllProjects; setShowAllProjects(newValue); void loadLogs(newValue, worktreePaths); }, [showAllProjects, loadLogs, worktreePaths]); async function handleSelect(log: LogOption) { const sessionId = validateUuid(getSessionIdFromLog(log)); if (!sessionId) { onDone('Failed to resume conversation'); return; } // Load full messages for lite logs const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; // Check if this conversation is from a different directory const crossProjectCheck = checkCrossProjectResume(fullLog, showAllProjects, worktreePaths); if (crossProjectCheck.isCrossProject) { if (crossProjectCheck.isSameRepoWorktree) { // Same repo worktree - can resume directly setResuming(true); void onResume(sessionId, fullLog, 'slash_command_picker'); return; } // Different project - show command instead of resuming const raw = await setClipboard((crossProjectCheck as { command: string }).command); if (raw) process.stdout.write(raw); // Format the output message const message = [ '', 'This conversation is from a different directory.', '', 'To resume, run:', ` ${(crossProjectCheck as { command: string }).command}`, '', '(Command copied to clipboard)', '', ].join('\n'); onDone(message, { display: 'user' }); return; } // Same directory - proceed with resume setResuming(true); void onResume(sessionId, fullLog, 'slash_command_picker'); } function handleCancel() { onDone('Resume cancelled', { display: 'system' }); } if (loading) { return ( Loading conversations… ); } if (resuming) { return ( Resuming conversation… ); } return ( loadLogs(showAllProjects, worktreePaths)} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} /> ); } export function filterResumableSessions(logs: LogOption[], currentSessionId: string): LogOption[] { return logs.filter(l => !l.isSidechain && getSessionIdFromLog(l) !== currentSessionId); } export const call: LocalJSXCommandCall = async (onDone, context, args) => { const onResume = async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => { try { await context.resume?.(sessionId, log, entrypoint); onDone(undefined, { display: 'skip' }); } catch (error) { logError(error as Error); onDone(`Failed to resume: ${(error as Error).message}`); } }; const arg = args?.trim(); // No argument provided - show picker if (!arg) { return ; } // Load logs to search (includes same-repo worktrees) const worktreePaths = await getWorktreePaths(getOriginalCwd()); const logs = await loadSameRepoMessageLogs(worktreePaths); if (logs.length === 0) { const message = 'No conversations found to resume.'; return onDone(message)} />; } // First, check if arg is a valid UUID const maybeSessionId = validateUuid(arg); if (maybeSessionId) { const matchingLogs = logs .filter(l => getSessionIdFromLog(l) === maybeSessionId) .sort((a, b) => b.modified.getTime() - a.modified.getTime()); if (matchingLogs.length > 0) { const log = matchingLogs[0]!; const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; void onResume(maybeSessionId, fullLog, 'slash_command_session_id'); return null; } // Enriched logs didn't find it — try direct file lookup. This handles // sessions filtered out by enrichLogs (e.g., first message >16KB makes // firstPrompt extraction fail, causing the session to be dropped). const directLog = await getLastSessionLog(maybeSessionId); if (directLog) { void onResume(maybeSessionId, directLog, 'slash_command_session_id'); return null; } } // Next, try exact custom title match (only if feature is enabled) if (isCustomTitleEnabled()) { const titleMatches = await searchSessionsByCustomTitle(arg, { exact: true, }); if (titleMatches.length === 1) { const log = titleMatches[0]!; const sessionId = getSessionIdFromLog(log); if (sessionId) { const fullLog = isLiteLog(log) ? await loadFullLog(log) : log; void onResume(sessionId, fullLog, 'slash_command_title'); return null; } } // Multiple matches - show error if (titleMatches.length > 1) { const message = resumeHelpMessage({ resultType: 'multipleMatches', arg, count: titleMatches.length, }); return onDone(message)} />; } } // No match found - show error const message = resumeHelpMessage({ resultType: 'sessionNotFound', arg }); return onDone(message)} />; };