"use client"; import { useEffect, useRef, useState } from "react"; import { IconCheck, IconX, IconLoader2, IconAlertTriangle, IconTerminal2, IconFolder, IconGitBranch, } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { ExpandableRow } from "@/components/task/chat/messages/expandable-row"; import { isFallbackNoticeStep } from "@/lib/prepare/summarize"; import { cn } from "@/lib/utils"; import { stripAnsi } from "@/lib/utils/ansi"; import { isSetupScriptMessage } from "@/hooks/use-processed-messages"; import type { Message } from "@/lib/types/http"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; import type { PrepareStepInfo, SessionPrepareState, } from "@/lib/state/slices/session-runtime/types"; const debug = createDebugLogger("chat:prepare-progress"); function formatStepDuration(startedAt?: string, endedAt?: string): string | null { if (!startedAt || !endedAt) return null; const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime(); if (ms < 0 || Number.isNaN(ms)) return null; if (ms < 1000) return `${ms}ms`; const secs = Math.round(ms / 1000); if (secs < 60) return `${secs}s`; const mins = Math.floor(secs / 60); const remSecs = secs % 60; return remSecs > 0 ? `${mins}m ${remSecs}s` : `${mins}m`; } type PrepareProgressProps = { sessionId: string; }; type EffectiveStatus = | "preparing" | "failed" | "completed" | "completed_with_error" | "completed_with_warnings"; function StepIcon({ status, hasWarning }: { status: string; hasWarning?: boolean }) { if (status === "completed" && hasWarning) { return ; } if (status === "completed") { return ; } if (status === "failed") { return ; } if (status === "running") { return ; } return
; } /** True when the command is short enough to display inline next to the step name. */ function isInlineCommand(cmd: string): boolean { return !cmd.includes("\n") && cmd.length <= 60; } function StepDetails({ step, blockCommand }: { step: PrepareStepInfo; blockCommand?: string }) { return (
{blockCommand && (
          {blockCommand}
        
)} {step.output && (
          {stripAnsi(step.output)}
        
)}
); } function StepWarning({ warning, warningDetail }: { warning: string; warningDetail?: string }) { const [detailExpanded, setDetailExpanded] = useState(false); return (
{warning} {warningDetail && ( <> {detailExpanded && (
              {warningDetail}
            
)} )}
); } function StepMessages({ step }: { step: PrepareStepInfo }) { return ( <> {step.warning && } {step.error && (
          {step.error}
        
)} ); } function StepRow({ step }: { step: PrepareStepInfo }) { const inlineCommand = step.command && isInlineCommand(step.command) ? step.command : undefined; const blockCommand = step.command && !isInlineCommand(step.command) ? step.command : undefined; const hasExpandable = Boolean(step.output) || Boolean(blockCommand); const [detailsExpanded, setDetailsExpanded] = useState( step.status === "running" || step.status === "failed", ); const duration = formatStepDuration(step.startedAt, step.endedAt); const nameClass = cn( "text-muted-foreground", step.status === "completed" && !step.warning && "line-through text-muted-foreground/60", ); return (
{step.name || "Preparing..."} {inlineCommand && ( {inlineCommand} )} {duration && {duration}} {hasExpandable && ( )}
{/* Content below the step header, indented past the icon */}
{detailsExpanded && }
); } // deriveStatus maps the raw prepare state + ambient signals into the effective // UI status. Kept small/pure so the hook stays a thin Zustand selector wrapper. type DeriveStatusInput = { prepareStatus: string; sessionState: string | undefined; agentctlStatus: string | undefined; hasFailedStep: boolean; hasWarnings: boolean; hasRunningStep: boolean; }; function deriveStatus(input: DeriveStatusInput): EffectiveStatus { const { prepareStatus, sessionState, agentctlStatus, hasFailedStep, hasWarnings, hasRunningStep, } = input; if (prepareStatus === "failed") return "failed"; if (prepareStatus === "completed") { if (hasFailedStep) return "completed_with_error"; if (hasWarnings) return "completed_with_warnings"; return "completed"; } // A step is still running — stay in preparing regardless of agentctl status. if (hasRunningStep) return "preparing"; // prepareStatus === "preparing" from here on. // Agentctl ready implies preparation succeeded — treat as completed even if // the completed event hasn't arrived yet. if (agentctlStatus === "ready" && !hasFailedStep && !hasWarnings) return "completed"; // If the session reached a terminal state but prepare is still "preparing", // treat it as failed — the completed event may have been lost. const isSessionTerminal = sessionState === "FAILED" || sessionState === "COMPLETED" || sessionState === "CANCELLED"; return isSessionTerminal ? "failed" : "preparing"; } function usePrepareStatus(sessionId: string) { const prepareState = useAppStore((state) => state.prepareProgress.bySessionId[sessionId] ?? null); const sessionState = useAppStore((state) => state.taskSessions.items[sessionId]?.state); const agentctlStatus = useAppStore( (state) => state.sessionAgentctl.itemsBySessionId[sessionId]?.status, ); if (!prepareState) { // No live or hydrated prepare state. If the session has moved past the // initial state, preparation must have completed — show a minimal panel // so it doesn't vanish on refresh. const sessionStarted = sessionState && sessionState !== "CREATED" && sessionState !== "STARTING"; if (sessionStarted) { return { status: "completed" as EffectiveStatus, prepareState: { sessionId, status: "completed", steps: [] } as SessionPrepareState, }; } return { status: "preparing" as const, prepareState: null }; } const status = deriveStatus({ prepareStatus: prepareState.status, sessionState, agentctlStatus, hasFailedStep: prepareState.steps.some((s) => s.status === "failed"), hasWarnings: prepareState.steps.some((s) => s.warning), hasRunningStep: prepareState.steps.some((s) => s.status === "running"), }); return { status, prepareState }; } // Tracks whether the panel is expanded. Starts from the status-derived default // (`autoExpand`) and flips back to that default whenever the status transitions // or the session changes — the "new context" rule. User's manual toggles stick // within a single status window. Prev-value pattern (not useEffect) avoids a // cascading re-render. function useExpandedFlag(sessionId: string, autoExpand: boolean) { const [expanded, setExpanded] = useState(autoExpand); const [prevSessionId, setPrevSessionId] = useState(sessionId); const [prevAutoExpand, setPrevAutoExpand] = useState(autoExpand); if (sessionId !== prevSessionId) { setPrevSessionId(sessionId); setExpanded(autoExpand); } else if (autoExpand !== prevAutoExpand) { setPrevAutoExpand(autoExpand); setExpanded(autoExpand); } return [expanded, setExpanded] as const; } function HeaderIcon({ status }: { status: EffectiveStatus }) { if (status === "preparing") { return ( ); } if (status === "failed" || status === "completed_with_error") { return ; } if (status === "completed_with_warnings") { return ; } return ; } function getHeaderLabel(status: EffectiveStatus, prepareState: SessionPrepareState): string { if (status === "preparing") return "Preparing environment..."; if (status === "failed") return prepareState.errorMessage || "Environment preparation failed"; if (status === "completed_with_error") { const failed = prepareState.steps.find((s) => s.status === "failed"); // `||` (not `??`) so an empty-string error also falls through to the name fallback. return failed?.error || `${failed?.name ?? "Environment prepared"} — step failed`; } if (status === "completed_with_warnings") { const warningSteps = prepareState.steps.filter((s) => s.warning); if (warningSteps.length === 1 && isFallbackNoticeStep(warningSteps[0])) { return "Environment prepared on a fresh sandbox"; } return "Environment prepared with warnings"; } return "Environment prepared"; } function InfoLine({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) { return (
{icon}
{children}
); } function Mono({ children }: { children: React.ReactNode }) { return {children}; } function hasStepDetails(step: PrepareStepInfo): boolean { return Boolean(step.command || step.output || step.error || step.warning || step.warningDetail); } function isVisibleStep(step: PrepareStepInfo): boolean { if (step.status === "skipped" && !hasStepDetails(step)) return false; return step.name.trim() !== "" || hasStepDetails(step); } type ScriptStatus = "starting" | "running" | "exited" | "failed"; // Map the script_execution status vocabulary (starting/running/exited/failed) // onto the PrepareStep status vocabulary (running/completed/failed). function scriptStatusToStepStatus( status: ScriptStatus | undefined, exitCode: number | undefined, ): string { if (status === "starting" || status === "running") return "running"; if (status === "failed") return "failed"; if (status === "exited") { if (exitCode === undefined || exitCode === 0) return "completed"; return "failed"; } return status ?? "running"; } type ScriptMetadata = { script_type?: string; command?: string; status?: ScriptStatus; exit_code?: number; error?: string; started_at?: string; completed_at?: string; }; // Convert a setup-script `script_execution` message into the same step shape // the existing prepare panel renders, so it slots in alongside Validate/Sync/ // Create-worktree without a parallel render path. function setupScriptMessageToStep(message: Message): PrepareStepInfo { const meta = (message.metadata ?? {}) as ScriptMetadata; const command = meta.command ?? ""; const status = scriptStatusToStepStatus(meta.status, meta.exit_code); const stepError = status === "failed" ? meta.error || (meta.exit_code !== undefined && meta.exit_code !== 0 ? `Script exited with code ${meta.exit_code}` : "Script failed") : undefined; return { name: "Run repository setup script", command, status, output: message.content || undefined, error: stepError, startedAt: meta.started_at, endedAt: meta.completed_at, }; } function useSetupScriptSteps(sessionId: string): PrepareStepInfo[] { const messages = useAppStore((state) => state.messages.bySession[sessionId]); if (!messages || messages.length === 0) return []; return messages.filter(isSetupScriptMessage).map(setupScriptMessageToStep); } function SessionInfo({ sessionId }: { sessionId: string }) { const session = useAppStore((state) => state.taskSessions.items[sessionId]); if (!session) return null; const { worktree_id, worktree_path, worktree_branch, base_branch } = session; const isWorktree = Boolean(worktree_id); // Nothing useful to show for sessions without workspace info. if (!worktree_path && !worktree_branch) return null; return (
{isWorktree && worktree_path && ( }> Isolated worktree at {worktree_path} )} {!isWorktree && worktree_path && ( }> Working in {worktree_path} )} {worktree_branch && ( }> {base_branch ? ( <> Branch {worktree_branch}, based on {base_branch} ) : ( <> On branch {worktree_branch} )} )}
); } type PrepareSnapshot = { status: string; autoExpand: boolean; expanded: boolean; visibleSteps: number; hasPrepareState: boolean; }; function prepareSnapshotChanged(prev: PrepareSnapshot | null, next: PrepareSnapshot): boolean { if (!prev) return true; return ( prev.status !== next.status || prev.autoExpand !== next.autoExpand || prev.expanded !== next.expanded || prev.visibleSteps !== next.visibleSteps || prev.hasPrepareState !== next.hasPrepareState ); } function logPrepareSnapshotChange( prev: PrepareSnapshot | null, next: PrepareSnapshot, sessionId: string, rawPrepareStatus: string, sessionState: string, ) { debug(prev ? "transition" : "init", { sessionId, ...next, prevStatus: prev?.status ?? "-", prevAutoExpand: prev?.autoExpand ?? null, prevExpanded: prev?.expanded ?? null, prevVisibleSteps: prev?.visibleSteps ?? -1, rawPrepareStatus, sessionState, }); } export function PrepareProgress({ sessionId }: PrepareProgressProps) { const { status, prepareState } = usePrepareStatus(sessionId); const session = useAppStore((state) => state.taskSessions.items[sessionId]); const setupScriptSteps = useSetupScriptSteps(sessionId); // Only the clean-success status auto-collapses. Anything with running/error/ // warning context stays open by default so the user sees what's happening. const autoExpand = status !== "completed"; const [expanded, setExpanded] = useExpandedFlag(sessionId, autoExpand); // Track status/expand transitions over the panel's lifetime — the // remote-executor bug suspects the panel staying expanded (tall) while the // env is still "preparing" combined with a Virtuoso initial-scroll race. const prevSnapshotRef = useRef(null); useEffect(() => { if (!isDebug()) return; const snapshot: PrepareSnapshot = { status, autoExpand, expanded, visibleSteps: prepareState ? prepareState.steps.filter(isVisibleStep).length : 0, hasPrepareState: Boolean(prepareState), }; const prev = prevSnapshotRef.current; if (!prepareSnapshotChanged(prev, snapshot)) return; logPrepareSnapshotChange( prev, snapshot, sessionId, prepareState?.status ?? "-", session?.state ?? "-", ); prevSnapshotRef.current = snapshot; }, [sessionId, status, autoExpand, expanded, prepareState, session?.state]); if (!prepareState) return null; const hasSessionInfo = Boolean(session?.worktree_path || session?.worktree_branch); // Per-repo setup scripts run inside worktree.Manager.Create, so visually they // belong after Create-worktree finishes. Appending keeps the existing prepare // step order intact. const visibleSteps = [...prepareState.steps.filter(isVisibleStep), ...setupScriptSteps]; const headerLabel = getHeaderLabel(status, prepareState); const isErrorStatus = status === "failed" || status === "completed_with_error"; const headerClass = cn("text-xs", isErrorStatus ? "text-destructive" : "text-muted-foreground"); return (
} header={ {headerLabel} } hasExpandableContent={visibleSteps.length > 0 || hasSessionInfo} isExpanded={expanded} onToggle={() => setExpanded(!expanded)} >
{visibleSteps.map((step, i) => ( ))}
); }