"use client"; import { 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 { cn } from "@/lib/utils"; import type { PrepareStepInfo, SessionPrepareState, } from "@/lib/state/slices/session-runtime/types"; const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]/g; function stripAnsi(s: string): string { return s.replace(ANSI_RE, ""); } 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") 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 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} )} )}
); } export function PrepareProgress({ sessionId }: PrepareProgressProps) { const { status, prepareState } = usePrepareStatus(sessionId); const session = useAppStore((state) => state.taskSessions.items[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); if (!prepareState) return null; const hasSessionInfo = Boolean(session?.worktree_path || session?.worktree_branch); const visibleSteps = prepareState.steps.filter( (step) => step.name.trim() !== "" || Boolean(step.output) || Boolean(step.error) || Boolean(step.warning), ); 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) => ( ))}
); }