import { feature } from 'bun:bundle' import { dirname } from 'path' import React from 'react' import { useTerminalSize } from 'src/hooks/useTerminalSize.js' import { getOriginalCwd, switchSession } from '../bootstrap/state.js' import type { Command } from '../commands.js' import { LogSelector } from '../components/LogSelector.js' import { Spinner } from '../components/Spinner.js' import { restoreCostStateForSession } from '../cost-tracker.js' import { setClipboard } from '@anthropic/ink' import { Box, Text } from '@anthropic/ink' import { useKeybinding } from '../keybindings/useKeybinding.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../services/analytics/index.js' import type { MCPServerConnection, ScopedMcpServerConfig, } from '../services/mcp/types.js' import { useAppState, useSetAppState } from '../state/AppState.js' import type { Tool } from '../Tool.js' import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js' import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' import { asSessionId } from '../types/ids.js' import type { LogOption } from '../types/logs.js' import type { Message } from '../types/message.js' import { agenticSessionSearch } from '../utils/agenticSessionSearch.js' import { renameRecordingForSession } from '../utils/asciicast.js' import { updateSessionName } from '../utils/concurrentSessions.js' import { loadConversationForResume } from '../utils/conversationRecovery.js' import { checkCrossProjectResume } from '../utils/crossProjectResume.js' import type { FileHistorySnapshot } from '../utils/fileHistory.js' import { logError } from '../utils/log.js' import { createSystemMessage } from '../utils/messages.js' import { computeStandaloneAgentContext, restoreAgentFromSession, restoreWorktreeForResume, } from '../utils/sessionRestore.js' import { adoptResumedSessionFile, enrichLogs, isCustomTitleEnabled, loadAllProjectsMessageLogsProgressive, loadSameRepoMessageLogsProgressive, recordContentReplacement, resetSessionFilePointer, restoreSessionMetadata, type SessionLogResult, } from '../utils/sessionStorage.js' import type { ThinkingConfig } from '../utils/thinking.js' import type { ContentReplacementRecord } from '../utils/toolResultStorage.js' import { REPL } from './REPL.js' function parsePrIdentifier(value: string): number | null { const directNumber = parseInt(value, 10) if (!isNaN(directNumber) && directNumber > 0) { return directNumber } const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/) if (urlMatch?.[1]) { return parseInt(urlMatch[1], 10) } return null } type Props = { commands: Command[] worktreePaths: string[] initialTools: Tool[] mcpClients?: MCPServerConnection[] dynamicMcpConfig?: Record debug: boolean mainThreadAgentDefinition?: AgentDefinition autoConnectIdeFlag?: boolean strictMcpConfig?: boolean systemPrompt?: string appendSystemPrompt?: string initialSearchQuery?: string disableSlashCommands?: boolean forkSession?: boolean taskListId?: string filterByPr?: boolean | number | string thinkingConfig: ThinkingConfig onTurnComplete?: (messages: Message[]) => void | Promise } export function ResumeConversation({ commands, worktreePaths, initialTools, mcpClients, dynamicMcpConfig, debug, mainThreadAgentDefinition, autoConnectIdeFlag, strictMcpConfig = false, systemPrompt, appendSystemPrompt, initialSearchQuery, disableSlashCommands = false, forkSession, taskListId, filterByPr, thinkingConfig, onTurnComplete, }: Props): React.ReactNode { const { rows } = useTerminalSize() const agentDefinitions = useAppState(s => s.agentDefinitions) const setAppState = useSetAppState() const [logs, setLogs] = React.useState([]) const [loading, setLoading] = React.useState(true) const [resuming, setResuming] = React.useState(false) const [showAllProjects, setShowAllProjects] = React.useState(false) const [resumeData, setResumeData] = React.useState<{ messages: Message[] fileHistorySnapshots?: FileHistorySnapshot[] contentReplacements?: ContentReplacementRecord[] agentName?: string agentColor?: AgentColorName mainThreadAgentDefinition?: AgentDefinition } | null>(null) const [crossProjectCommand, setCrossProjectCommand] = React.useState< string | null >(null) const sessionLogResultRef = React.useRef(null) // Mirror of logs.length so loadMoreLogs can compute value indices outside // the setLogs updater (keeping it pure per React's contract). const logCountRef = React.useRef(0) const filteredLogs = React.useMemo(() => { let result = logs.filter(l => !l.isSidechain) if (filterByPr !== undefined) { if (filterByPr === true) { result = result.filter(l => l.prNumber !== undefined) } else if (typeof filterByPr === 'number') { result = result.filter(l => l.prNumber === filterByPr) } else if (typeof filterByPr === 'string') { const prNumber = parsePrIdentifier(filterByPr) if (prNumber !== null) { result = result.filter(l => l.prNumber === prNumber) } } } return result }, [logs, filterByPr]) const isResumeWithRenameEnabled = isCustomTitleEnabled() React.useEffect(() => { loadSameRepoMessageLogsProgressive(worktreePaths) .then(result => { sessionLogResultRef.current = result logCountRef.current = result.logs.length setLogs(result.logs) setLoading(false) }) .catch(error => { logError(error) setLoading(false) }) }, [worktreePaths]) const loadMoreLogs = React.useCallback((count: number) => { const ref = sessionLogResultRef.current if (!ref || ref.nextIndex >= ref.allStatLogs.length) return void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result => { ref.nextIndex = result.nextIndex if (result.logs.length > 0) { // enrichLogs returns fresh unshared objects — safe to mutate in place. // Offset comes from logCountRef so the setLogs updater stays pure. const offset = logCountRef.current result.logs.forEach((log, i) => { log.value = offset + i }) setLogs(prev => prev.concat(result.logs)) logCountRef.current += result.logs.length } else if (ref.nextIndex < ref.allStatLogs.length) { loadMoreLogs(count) } }) }, []) const loadLogs = React.useCallback( (allProjects: boolean) => { setLoading(true) const promise = allProjects ? loadAllProjectsMessageLogsProgressive() : loadSameRepoMessageLogsProgressive(worktreePaths) promise .then(result => { sessionLogResultRef.current = result logCountRef.current = result.logs.length setLogs(result.logs) }) .catch(error => { logError(error) }) .finally(() => { setLoading(false) }) }, [worktreePaths], ) const handleToggleAllProjects = React.useCallback(() => { const newValue = !showAllProjects setShowAllProjects(newValue) loadLogs(newValue) }, [showAllProjects, loadLogs]) function onCancel() { // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) } async function onSelect(log: LogOption) { setResuming(true) const resumeStart = performance.now() const crossProjectCheck = checkCrossProjectResume( log, showAllProjects, worktreePaths, ) if (crossProjectCheck.isCrossProject) { if (!crossProjectCheck.isSameRepoWorktree) { const cmd = (crossProjectCheck as { command: string }).command const raw = await setClipboard(cmd) if (raw) process.stdout.write(raw) setCrossProjectCommand(cmd) return } } try { const result = await loadConversationForResume(log, undefined) if (!result) { throw new Error('Failed to load conversation') } if (feature('COORDINATOR_MODE')) { /* eslint-disable @typescript-eslint/no-require-imports */ const coordinatorModule = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js') /* eslint-enable @typescript-eslint/no-require-imports */ const warning = coordinatorModule.matchSessionMode(result.mode) if (warning) { /* eslint-disable @typescript-eslint/no-require-imports */ const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } = require('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js') as typeof import('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js') /* eslint-enable @typescript-eslint/no-require-imports */ getAgentDefinitionsWithOverrides.cache.clear?.() const freshAgentDefs = await getAgentDefinitionsWithOverrides( getOriginalCwd(), ) setAppState(prev => ({ ...prev, agentDefinitions: { ...freshAgentDefs, allAgents: freshAgentDefs.allAgents, activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents), }, })) result.messages.push(createSystemMessage(warning, 'warning')) } } if (result.sessionId && !forkSession) { switchSession( asSessionId(result.sessionId), log.fullPath ? dirname(log.fullPath) : null, ) await renameRecordingForSession() await resetSessionFilePointer() restoreCostStateForSession(result.sessionId) } else if (forkSession && result.contentReplacements?.length) { await recordContentReplacement(result.contentReplacements) } const { agentDefinition: resolvedAgentDef } = restoreAgentFromSession( result.agentSetting, mainThreadAgentDefinition, agentDefinitions, ) setAppState(prev => ({ ...prev, agent: resolvedAgentDef?.agentType })) if (feature('COORDINATOR_MODE')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { saveMode } = require('../utils/sessionStorage.js') const { isCoordinatorMode } = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js') /* eslint-enable @typescript-eslint/no-require-imports */ saveMode(isCoordinatorMode() ? 'coordinator' : 'normal') } const standaloneAgentContext = computeStandaloneAgentContext( result.agentName, result.agentColor, ) if (standaloneAgentContext) { setAppState(prev => ({ ...prev, standaloneAgentContext })) } void updateSessionName(result.agentName) restoreSessionMetadata( forkSession ? { ...result, worktreeSession: undefined } : result, ) if (!forkSession) { restoreWorktreeForResume(result.worktreeSession) if (result.sessionId) { adoptResumedSessionFile() } } if (feature('CONTEXT_COLLAPSE')) { /* eslint-disable @typescript-eslint/no-require-imports */ ;( require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js') ).restoreFromEntries( result.contextCollapseCommits ?? [], result.contextCollapseSnapshot, ) /* eslint-enable @typescript-eslint/no-require-imports */ } logEvent('tengu_session_resumed', { entrypoint: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: true, resume_duration_ms: Math.round(performance.now() - resumeStart), }) setLogs([]) setResumeData({ messages: result.messages, fileHistorySnapshots: result.fileHistorySnapshots, contentReplacements: result.contentReplacements, agentName: result.agentName, agentColor: (result.agentColor === 'default' ? undefined : result.agentColor) as AgentColorName | undefined, mainThreadAgentDefinition: resolvedAgentDef, }) } catch (e) { logEvent('tengu_session_resumed', { entrypoint: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: false, }) logError(e as Error) throw e } } if (crossProjectCommand) { return } if (resumeData) { return ( ) } if (loading) { return ( Loading conversations… ) } if (resuming) { return ( Resuming conversation… ) } if (filteredLogs.length === 0) { return } return ( loadLogs(showAllProjects) : undefined } onLoadMore={loadMoreLogs} initialSearchQuery={initialSearchQuery} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} /> ) } function NoConversationsMessage(): React.ReactNode { useKeybinding( 'app:interrupt', () => { // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) }, { context: 'Global' }, ) return ( No conversations found to resume. Press Ctrl+C to exit and start a new conversation. ) } function CrossProjectMessage({ command, }: { command: string }): React.ReactNode { React.useEffect(() => { const timeout = setTimeout(() => { // eslint-disable-next-line custom-rules/no-process-exit process.exit(0) }, 100) return () => clearTimeout(timeout) }, []) return ( This conversation is from a different directory. To resume, run: {command} (Command copied to clipboard) ) }