import { useEffect, useRef, useState, useCallback } from 'react'; import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, X, Minimize2, Terminal as TerminalIcon, } from 'lucide-react'; import { Modal } from '@/components/ui/modal'; import { DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { StructuredLogRow } from '@/components/log-rendering/StructuredLogRow'; import TerminalComponent from '@/components/Terminal'; import { useDeployFeedback, VERB_LABELS } from '@/context/DeployFeedbackContext'; const AUTO_CLOSE_SECONDS = 4; // Warn that an in-flight operation has gone quiet after this much silence. The // backend idle-output timeout terminates a truly hung step later (default // ~10min); this earlier heads-up keeps the modal from looking falsely busy. const STALL_WARN_MS = 75_000; interface DeployFeedbackModalProps { isMinimized: boolean; onMinimize: () => void; } function formatElapsed(seconds: number): string { if (seconds >= 60) { const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}m ${s}s`; } return `${seconds}s`; } export function DeployFeedbackModal({ isMinimized, onMinimize }: DeployFeedbackModalProps) { const { panelState, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose } = useDeployFeedback(); const [showRaw, setShowRaw] = useState(false); const [elapsedSeconds, setElapsedSeconds] = useState(0); const [countdown, setCountdown] = useState(AUTO_CLOSE_SECONDS); const [userScrolledUp, setUserScrolledUp] = useState(false); const startTimeRef = useRef(0); const elapsedIntervalRef = useRef | null>(null); const countdownIntervalRef = useRef | null>(null); const autoCloseHoveredRef = useRef(false); const scrollRef = useRef(null); const { isOpen, stackName, action, status, errorMessage, progressUnavailable } = panelState; useEffect(() => { if (isOpen) { // eslint-disable-next-line react-hooks/set-state-in-effect setShowRaw(false); // eslint-disable-next-line react-hooks/set-state-in-effect setElapsedSeconds(0); // eslint-disable-next-line react-hooks/set-state-in-effect setCountdown(AUTO_CLOSE_SECONDS); // eslint-disable-next-line react-hooks/set-state-in-effect setUserScrolledUp(false); startTimeRef.current = 0; } }, [isOpen]); useEffect(() => { if (isOpen && status !== 'preparing') { if (startTimeRef.current === 0) startTimeRef.current = Date.now(); if (elapsedIntervalRef.current !== null) return; elapsedIntervalRef.current = setInterval(() => { setElapsedSeconds(Math.floor((Date.now() - startTimeRef.current) / 1000)); }, 1000); } else { if (elapsedIntervalRef.current !== null) { clearInterval(elapsedIntervalRef.current); elapsedIntervalRef.current = null; } } return () => { if (elapsedIntervalRef.current !== null) { clearInterval(elapsedIntervalRef.current); elapsedIntervalRef.current = null; } }; }, [isOpen, status]); const clearCountdownInterval = useCallback(() => { if (countdownIntervalRef.current !== null) { clearInterval(countdownIntervalRef.current); countdownIntervalRef.current = null; } }, []); const startCountdown = useCallback(() => { clearCountdownInterval(); setCountdown(AUTO_CLOSE_SECONDS); let remaining = AUTO_CLOSE_SECONDS; countdownIntervalRef.current = setInterval(() => { remaining -= 1; setCountdown(remaining); if (remaining <= 0) { clearCountdownInterval(); if (!autoCloseHoveredRef.current) { onPanelClose(); } } }, 1000); }, [clearCountdownInterval, onPanelClose]); useEffect(() => { if (status === 'succeeded' && isOpen) { // eslint-disable-next-line react-hooks/set-state-in-effect startCountdown(); } else { clearCountdownInterval(); } return () => { clearCountdownInterval(); }; }, [status, isOpen, startCountdown, clearCountdownInterval]); useEffect(() => { if (!userScrolledUp && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [logRows.length, userScrolledUp]); const handleScroll = useCallback(() => { const el = scrollRef.current; if (!el) return; const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; setUserScrolledUp(distanceFromBottom > 32); }, []); const handleMouseEnter = useCallback(() => { autoCloseHoveredRef.current = true; clearCountdownInterval(); }, [clearCountdownInterval]); const handleMouseLeave = useCallback(() => { autoCloseHoveredRef.current = false; if (status === 'succeeded' && isOpen) { startCountdown(); } }, [status, isOpen, startCountdown]); const handleOpenChange = useCallback( (open: boolean) => { if (!open) onPanelClose(); }, [onPanelClose] ); const isDialogOpen = isOpen && !isMinimized; const verbLabel = VERB_LABELS[action]; // Re-evaluated each second by the elapsed-time interval's re-render. Warns // while the operation is still streaming but has produced no output for a // while, including the case where no first line ever arrived. Suppressed once // the progress stream is unavailable: no more output can arrive, so the modal // already shows "Live progress unavailable" and a stall warning would be noise. const lastLine = logRows.length > 0 ? logRows[logRows.length - 1].message : null; const secondsSinceOutput = lastOutputAt > 0 ? Math.floor((Date.now() - lastOutputAt) / 1000) : 0; const stalled = status === 'streaming' && !progressUnavailable && lastOutputAt > 0 && Date.now() - lastOutputAt > STALL_WARN_MS; return (
{verbLabel.present} {stackName} {/* Header */}
{verbLabel.present} {stackName} {status !== 'preparing' && elapsedSeconds > 0 && ( {formatElapsed(elapsedSeconds)} )}
{/* Stalled-output warning: in-flight but quiet */} {stalled && (

{lastLine ? `No new output for ${formatElapsed(secondsSinceOutput)}. The operation may be stalled.` : 'No output received yet. The operation may be stalled.'}

{lastLine && (

{lastLine}

)}
)} {/* Body */}
{logRows.length === 0 ? ( ) : (
{logRows.map((row) => ( ))}
)}
{/* always mounted so onMessage feeds structured rows even before user toggles raw view */}
{/* Footer */}
); } interface StatusIndicatorProps { status: 'preparing' | 'streaming' | 'succeeded' | 'failed'; progressUnavailable: boolean; rowCount: number; errorMessage?: string; countdown: number; } function StatusIndicator({ status, progressUnavailable, rowCount, errorMessage, countdown }: StatusIndicatorProps) { // While the deploy is still in flight (preparing/streaming) but the progress // socket is gone, the deploy keeps running server-side with no live output. if (progressUnavailable && (status === 'preparing' || status === 'streaming')) { return (
Live progress unavailable
); } if (status === 'preparing') { return (
Connecting...
); } if (status === 'streaming') { return (
{rowCount} {rowCount === 1 ? 'line' : 'lines'}
); } if (status === 'succeeded') { return (
Succeeded closes in {countdown}s
); } // failed return (
{errorMessage ?? 'Failed'}
); } interface EmptyBodyProps { status: 'preparing' | 'streaming' | 'succeeded' | 'failed'; progressUnavailable: boolean; } function EmptyBody({ status, progressUnavailable }: EmptyBodyProps) { if (progressUnavailable && (status === 'preparing' || status === 'streaming')) { return (
Live progress is unavailable for this deploy. It continues running in the background.
); } if (status === 'preparing') { return (
Connecting to log stream...
); } return (
Waiting for output...
); }