import { useState, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { AlertTriangle, GitCommit, Loader2 } from 'lucide-react'; import NiceModal, { useModal } from '@ebay/nice-modal-react'; import { defineModal } from '@/lib/modals'; import { useKeySubmitTask } from '@/keyboard/hooks'; import { Scope } from '@/keyboard/registry'; import { executionProcessesApi, commitsApi } from '@/lib/api'; import { shouldShowInLogs, isCodingAgent, PROCESS_RUN_REASONS, } from '@/constants/processes'; import type { BranchStatus, ExecutionProcess } from 'shared/types'; export interface RestoreLogsDialogProps { attemptId: string; executionProcessId: string; branchStatus: BranchStatus | undefined; processes: ExecutionProcess[] | undefined; initialWorktreeResetOn?: boolean; initialForceReset?: boolean; } export type RestoreLogsDialogResult = { action: 'confirmed' | 'canceled'; performGitReset?: boolean; forceWhenDirty?: boolean; }; const RestoreLogsDialogImpl = NiceModal.create( ({ attemptId, executionProcessId, branchStatus, processes, initialWorktreeResetOn = false, initialForceReset = false, }) => { const modal = useModal(); const { t } = useTranslation(['tasks', 'common']); const [isLoading, setIsLoading] = useState(true); const [worktreeResetOn, setWorktreeResetOn] = useState( initialWorktreeResetOn ); const [forceReset, setForceReset] = useState(initialForceReset); const [acknowledgeUncommitted, setAcknowledgeUncommitted] = useState(false); // Fetched data const [targetSha, setTargetSha] = useState(null); const [targetSubject, setTargetSubject] = useState(null); const [commitsToReset, setCommitsToReset] = useState(null); const [isLinear, setIsLinear] = useState(null); // Fetch execution process and commit info useEffect(() => { let cancelled = false; setIsLoading(true); (async () => { try { const proc = await executionProcessesApi.getDetails(executionProcessId); const sha = proc.before_head_commit || null; if (cancelled) return; setTargetSha(sha); if (sha) { try { const cmp = await commitsApi.compareToHead(attemptId, sha); if (!cancelled) { setTargetSubject(cmp.subject); setCommitsToReset(cmp.is_linear ? cmp.ahead_from_head : null); setIsLinear(cmp.is_linear); } } catch { /* ignore commit info errors */ } } } finally { if (!cancelled) setIsLoading(false); } })(); return () => { cancelled = true; }; }, [attemptId, executionProcessId]); // Compute later processes from props const { laterCount, laterCoding, laterSetup, laterCleanup } = useMemo(() => { const procs = (processes || []).filter( (p) => !p.dropped && shouldShowInLogs(p.run_reason) ); const idx = procs.findIndex((p) => p.id === executionProcessId); const later = idx >= 0 ? procs.slice(idx + 1) : []; return { laterCount: later.length, laterCoding: later.filter((p) => isCodingAgent(p.run_reason)).length, laterSetup: later.filter( (p) => p.run_reason === PROCESS_RUN_REASONS.SETUP_SCRIPT ).length, laterCleanup: later.filter( (p) => p.run_reason === PROCESS_RUN_REASONS.CLEANUP_SCRIPT ).length, }; }, [processes, executionProcessId]); // Compute git reset state from branchStatus const head = branchStatus?.head_oid || null; const dirty = !!branchStatus?.has_uncommitted_changes; const needGitReset = !!(targetSha && (targetSha !== head || dirty)); const canGitReset = needGitReset && !dirty; const hasRisk = dirty; const uncommittedCount = branchStatus?.uncommitted_count ?? 0; const untrackedCount = branchStatus?.untracked_count ?? 0; const hasLater = laterCount > 0; const short = targetSha?.slice(0, 7); const isConfirmDisabled = isLoading || (dirty && !acknowledgeUncommitted) || (hasRisk && worktreeResetOn && needGitReset && !forceReset); const handleConfirm = () => { modal.resolve({ action: 'confirmed', performGitReset: worktreeResetOn, forceWhenDirty: forceReset, } as RestoreLogsDialogResult); modal.hide(); }; const handleCancel = () => { modal.resolve({ action: 'canceled' } as RestoreLogsDialogResult); modal.hide(); }; const handleOpenChange = (open: boolean) => { if (!open) { handleCancel(); } }; // CMD+Enter to confirm useKeySubmitTask(handleConfirm, { scope: Scope.DIALOG, when: modal.visible && !isConfirmDisabled, }); return ( { if (e.key === 'Escape') { e.stopPropagation(); handleCancel(); } }} > {' '} {t('restoreLogsDialog.title')}
{isLoading ? (
) : (
{hasLater && (

{t('restoreLogsDialog.historyChange.title')}

<>

{t('restoreLogsDialog.historyChange.willDelete')} {laterCount > 0 && ( <> {' '} {t( 'restoreLogsDialog.historyChange.andLaterProcesses', { count: laterCount } )} )}{' '} {t('restoreLogsDialog.historyChange.fromHistory')}

    {laterCoding > 0 && (
  • {t( 'restoreLogsDialog.historyChange.codingAgentRuns', { count: laterCoding } )}
  • )} {laterSetup + laterCleanup > 0 && (
  • {t( 'restoreLogsDialog.historyChange.scriptProcesses', { count: laterSetup + laterCleanup } )} {laterSetup > 0 && laterCleanup > 0 && ( <> {' '} {t( 'restoreLogsDialog.historyChange.setupCleanupBreakdown', { setup: laterSetup, cleanup: laterCleanup, } )} )}
  • )}

{t( 'restoreLogsDialog.historyChange.permanentWarning' )}

)} {dirty && (

{t('restoreLogsDialog.uncommittedChanges.title')}

{t( 'restoreLogsDialog.uncommittedChanges.description', { count: uncommittedCount, } )} {untrackedCount > 0 && t( 'restoreLogsDialog.uncommittedChanges.andUntracked', { count: untrackedCount, } )} .

setAcknowledgeUncommitted((v) => !v)} >
{t( 'restoreLogsDialog.uncommittedChanges.acknowledgeLabel' )}
)} {needGitReset && canGitReset && (

{t('restoreLogsDialog.resetWorktree.title')}

setWorktreeResetOn((v) => !v)} >
{worktreeResetOn ? t('restoreLogsDialog.resetWorktree.enabled') : t('restoreLogsDialog.resetWorktree.disabled')}
{worktreeResetOn && ( <>

{t( 'restoreLogsDialog.resetWorktree.restoreDescription' )}

{short && ( {short} )} {targetSubject && ( {targetSubject} )}
{((isLinear && commitsToReset !== null && commitsToReset > 0) || uncommittedCount > 0 || untrackedCount > 0) && (
    {isLinear && commitsToReset !== null && commitsToReset > 0 && (
  • {t( 'restoreLogsDialog.resetWorktree.rollbackCommits', { count: commitsToReset } )}
  • )} {uncommittedCount > 0 && (
  • {t( 'restoreLogsDialog.resetWorktree.discardChanges', { count: uncommittedCount } )}
  • )} {untrackedCount > 0 && (
  • {t( 'restoreLogsDialog.resetWorktree.untrackedPresent', { count: untrackedCount } )}
  • )}
)} )}
)} {needGitReset && !canGitReset && (

{t('restoreLogsDialog.resetWorktree.title')}

{ setWorktreeResetOn((on) => { if (forceReset) return !on; // free toggle when forced // Without force, only allow explicitly disabling reset return false; }); }} >
{forceReset ? worktreeResetOn ? t('restoreLogsDialog.resetWorktree.enabled') : t('restoreLogsDialog.resetWorktree.disabled') : t( 'restoreLogsDialog.resetWorktree.disabledUncommitted' )}
{ setForceReset((v) => { const next = !v; if (next) setWorktreeResetOn(true); return next; }); }} >
{t('restoreLogsDialog.resetWorktree.forceReset')}

{forceReset ? t( 'restoreLogsDialog.resetWorktree.uncommittedWillDiscard' ) : t( 'restoreLogsDialog.resetWorktree.uncommittedPresentHint' )}

{short && ( <>

{t( 'restoreLogsDialog.resetWorktree.restoreDescription' )}

{short} {targetSubject && ( {targetSubject} )}
)}
)}
)}
); } ); export const RestoreLogsDialog = defineModal< RestoreLogsDialogProps, RestoreLogsDialogResult >(RestoreLogsDialogImpl);