"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { IconAlertTriangle, IconBox, IconCheck, IconCopy, IconLoader, IconLoader2, IconTrash, } from "@tabler/icons-react"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; import { toast } from "sonner"; import { fetchTaskEnvironmentLive, resetTaskEnvironment, type ContainerLiveStatus, type TaskEnvironment, } from "@/lib/api/domains/task-environment-api"; import { ApiError } from "@/lib/api/client"; import { useAppStore } from "@/components/state-provider"; import { getExecutorStatusIcon } from "@/lib/executor-icons"; import type { PrepareStepInfo, SessionPrepareState, } from "@/lib/state/slices/session-runtime/types"; import { TaskResetEnvConfirmDialog } from "./task-reset-env-confirm-dialog"; import { getEnvironmentStatusSnapshot, resolveExecutorEnvironmentStatus, type StatusTone, type EnvironmentStatusSnapshot, } from "./executor-environment-status"; type ExecutorSettingsButtonProps = { taskId?: string | null; sessionId?: string | null; disabled?: boolean; }; type PreparePhase = "idle" | "preparing" | "preparing_fallback" | "ready" | "failed"; type PrepareSummary = { phase: PreparePhase; current: PrepareStepInfo | null; currentIndex: number; totalSteps: number; fallbackWarning: string | null; failedStep: PrepareStepInfo | null; durationMs: number | null; }; const ACTIVE_POLL_INTERVAL_MS = 3000; const BACKGROUND_POLL_INTERVAL_MS = 7000; /** * Owns the env+container fetch/poll lifecycle and the reset action so the * popover component stays small. Polls more quickly while the popover is open * and less frequently while closed so the toolbar icon can still reflect * externally stopped/restarted containers. */ function useTaskEnvironment(taskId: string | null | undefined, active: boolean) { const [env, setEnv] = useState(null); const [container, setContainer] = useState(null); const [loading, setLoading] = useState(false); const [isResetting, setIsResetting] = useState(false); const inFlight = useRef(false); const lastStatusRef = useRef(null); // hasLoadedRef tracks "have we ever fetched successfully" so the spinner // only shows on the first open. Keeping it in a ref instead of state means // `loadEnv` doesn't depend on `env` โ€” without that, every successful fetch // creates a new `env` reference, forces a new `loadEnv` identity, and the // polling effect's cleanup+rerun fires immediately, turning the 3-second // poll into a tight loop. const hasLoadedRef = useRef(false); useEffect(() => { hasLoadedRef.current = false; lastStatusRef.current = null; setEnv(null); setContainer(null); setLoading(false); }, [taskId]); const updateState = useCallback( (nextEnv: TaskEnvironment | null, nextContainer: ContainerLiveStatus | null) => { const nextStatus = getEnvironmentStatusSnapshot(nextEnv, nextContainer); maybeNotifyEnvironmentStatus(lastStatusRef.current, nextStatus); lastStatusRef.current = nextStatus; setEnv(nextEnv); setContainer(nextContainer); }, [], ); const loadEnv = useCallback(async () => { if (!taskId || inFlight.current) return; inFlight.current = true; setLoading((prev) => prev || (active && !hasLoadedRef.current)); try { const data = await fetchTaskEnvironmentLive(taskId); hasLoadedRef.current = true; updateState(data.environment, data.container ?? null); } catch (err) { // Only treat 404 as "no environment yet" โ€” a transient 500 / auth / // network error should leave the last-known view in place rather than // erase a valid environment and disable the Reset action. if (err instanceof ApiError && err.status === 404) { hasLoadedRef.current = true; updateState(null, null); } } finally { inFlight.current = false; setLoading(false); } }, [active, taskId, updateState]); useEffect(() => { if (!taskId) return; void loadEnv(); const intervalMs = active ? ACTIVE_POLL_INTERVAL_MS : BACKGROUND_POLL_INTERVAL_MS; const interval = window.setInterval(() => void loadEnv(), intervalMs); return () => window.clearInterval(interval); }, [active, taskId, loadEnv]); const reset = useCallback( async ({ pushBranch }: { pushBranch: boolean }) => { if (!taskId) return false; setIsResetting(true); try { await resetTaskEnvironment(taskId, { push_branch: pushBranch }); toast.success("Environment reset"); lastStatusRef.current = getEnvironmentStatusSnapshot(null, null); setEnv(null); setContainer(null); return true; } catch (err) { const msg = err instanceof Error ? err.message : "Unknown error"; toast.error(`Reset failed: ${msg}`); return false; } finally { setIsResetting(false); } }, [taskId], ); const status = useMemo( () => (env ? resolveExecutorEnvironmentStatus(env, container) : null), [env, container], ); return { env, container, loading, isResetting, reset, status }; } /** * Reduces the full prepare-progress slice for one session into a UI-friendly * summary. The reducer is the only place that decides what counts as "still * preparing" vs "ready" vs "failed", and it surfaces the fallback notice as * a first-class phase so the popover can render dedicated UI for the * "previous sandbox went away" recovery path. */ function usePrepareSummary(sessionId: string | null): PrepareSummary { const prepareState = useAppStore((state) => sessionId ? (state.prepareProgress?.bySessionId?.[sessionId] ?? null) : null, ); return useMemo(() => summarizePrepare(prepareState), [prepareState]); } const IDLE_PREPARE_SUMMARY: PrepareSummary = { phase: "idle", current: null, currentIndex: 0, totalSteps: 0, fallbackWarning: null, failedStep: null, durationMs: null, }; function summarizePrepare(state: SessionPrepareState | null): PrepareSummary { if (!state) return IDLE_PREPARE_SUMMARY; const steps = state.steps ?? []; const fallbackStep = steps.find(isFallbackNoticeStep) ?? null; const failedStep = steps.find((s) => s.status === "failed") ?? null; const runningStep = steps.find((s) => s.status === "running") ?? null; const { current, currentIndex } = pickCurrentStep(steps, runningStep); return { phase: derivePhase(state.status, fallbackStep, failedStep), current, currentIndex, totalSteps: steps.length, fallbackWarning: fallbackStep?.warning ?? null, failedStep, durationMs: state.durationMs ?? null, }; } function pickCurrentStep( steps: PrepareStepInfo[], runningStep: PrepareStepInfo | null, ): { current: PrepareStepInfo | null; currentIndex: number } { if (runningStep) { return { current: runningStep, currentIndex: steps.indexOf(runningStep) }; } const lastCompletedIdx = lastIndexWhere( steps, (s) => s.status === "completed" || s.status === "skipped" || s.status === "failed", ); if (lastCompletedIdx >= 0) { return { current: steps[lastCompletedIdx], currentIndex: lastCompletedIdx }; } return { current: steps[0] ?? null, currentIndex: 0 }; } function derivePhase( status: string, fallbackStep: PrepareStepInfo | null, failedStep: PrepareStepInfo | null, ): PreparePhase { if (status === "preparing") return fallbackStep ? "preparing_fallback" : "preparing"; if (status === "failed" || failedStep) return "failed"; if (status === "completed") return "ready"; return "idle"; } function isFallbackNoticeStep(step: PrepareStepInfo): boolean { return step.status === "skipped" && step.name === "Reconnecting cloud sandbox"; } function lastIndexWhere(items: T[], pred: (item: T) => boolean): number { for (let i = items.length - 1; i >= 0; i--) { if (pred(items[i])) return i; } return -1; } export function ExecutorSettingsButton({ taskId, sessionId, disabled, }: ExecutorSettingsButtonProps) { const [open, setOpen] = useState(false); const [resetDialogOpen, setResetDialogOpen] = useState(false); const prepare = usePrepareSummary(sessionId ?? null); const isPreparing = prepare.phase === "preparing" || prepare.phase === "preparing_fallback"; // Promote the foreground polling cadence while preparing so the icon flips // to "ready" without the user opening the popover. const { env, container, loading, isResetting, reset, status } = useTaskEnvironment( taskId, open || isPreparing, ); const handleReset = useCallback( async (opts: { pushBranch: boolean }) => { const ok = await reset(opts); if (ok) { setResetDialogOpen(false); setOpen(false); } }, [reset], ); if (!taskId) return null; const executorType = env?.executor_type ?? null; const ariaLabel = computeAriaLabel(isPreparing, status); return ( <>
); } function computeAriaLabel( preparing: boolean, status: { label: string; tone: StatusTone } | null, ): string { if (preparing) return "Executor settings, preparing environment"; if (status) return `Executor settings, environment ${status.label}`; return "Executor settings"; } function ExecutorButtonIcon({ executorType, preparing, }: { executorType: string | null; preparing: boolean; }) { if (preparing) { return ( ); } if (!executorType) { return ; } const { Icon, testId } = getExecutorStatusIcon(executorType, false); return ; } function maybeNotifyEnvironmentStatus( prev: EnvironmentStatusSnapshot | null, next: EnvironmentStatusSnapshot, ) { if (!prev || prev.key === next.key) return; if (prev.tone === "running" && next.tone !== "running") { toast.error("Executor environment stopped", { description: `Current state: ${next.label}`, }); } else if (prev.tone !== "running" && next.tone === "running") { toast.success("Executor environment running"); } } function ExecutorStatusDot({ status, loading, }: { status: { label: string; tone: StatusTone } | null; loading: boolean; }) { const tone = status?.tone ?? "neutral"; const label = status?.label ?? "not created"; return (