diff --git a/apps/web/components/task/executor-settings-button.test.tsx b/apps/web/components/task/executor-settings-button.test.tsx new file mode 100644 index 0000000000..01d2fdf989 --- /dev/null +++ b/apps/web/components/task/executor-settings-button.test.tsx @@ -0,0 +1,139 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SessionPrepareState } from "@/lib/state/slices/session-runtime/types"; + +let mockPrepareState: SessionPrepareState | null = null; +let mockEnv: { executor_type: string; sandbox_id?: string; container_id?: string } | null = null; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ + prepareProgress: { + bySessionId: mockPrepareState ? { [mockPrepareState.sessionId]: mockPrepareState } : {}, + }, + }), +})); + +vi.mock("@/lib/api/domains/task-environment-api", () => ({ + fetchTaskEnvironmentLive: vi.fn().mockImplementation(async () => ({ + environment: mockEnv ?? { executor_type: "" }, + container: null, + })), + resetTaskEnvironment: vi.fn().mockResolvedValue({ success: true }), +})); + +vi.mock("./task-reset-env-confirm-dialog", () => ({ + TaskResetEnvConfirmDialog: () => null, +})); + +import { ExecutorSettingsButton } from "./executor-settings-button"; + +const SESSION_ID = "session-1"; +const TASK_ID = "task-1"; +const STEP_CREATE_SANDBOX = "Creating cloud sandbox"; + +describe("ExecutorSettingsButton", () => { + afterEach(() => { + cleanup(); + mockPrepareState = null; + mockEnv = null; + }); + + it("shows the cloud icon when the executor type is sprites", async () => { + mockEnv = { executor_type: "sprites", sandbox_id: "kandev-abc" }; + + render(); + + // Wait a tick for the live fetch to resolve. + await Promise.resolve(); + await Promise.resolve(); + expect(await screen.findByTestId("executor-status-cloud-icon")).toBeTruthy(); + }); + + it("shows the container icon for both docker variants", async () => { + mockEnv = { executor_type: "local_docker", container_id: "abcdef" }; + + render(); + + await Promise.resolve(); + expect(await screen.findByTestId("executor-status-container-icon")).toBeTruthy(); + }); + + it("swaps to a spinner while the prepare progress is preparing", async () => { + mockEnv = { executor_type: "sprites", sandbox_id: "kandev-abc" }; + mockPrepareState = { + sessionId: SESSION_ID, + status: "preparing", + steps: [{ name: STEP_CREATE_SANDBOX, status: "running" }], + }; + + render(); + + expect(screen.getByTestId("executor-settings-button-spinner")).toBeTruthy(); + expect(screen.queryByTestId("executor-status-cloud-icon")).toBeNull(); + }); + + it("renders the preparing section with current step copy", async () => { + mockPrepareState = { + sessionId: SESSION_ID, + status: "preparing", + steps: [ + { name: STEP_CREATE_SANDBOX, status: "completed" }, + { name: "Uploading agent controller", status: "running" }, + { name: "Waiting for agent controller", status: "pending" }, + ], + }; + + render(); + + // Open the popover by clicking the trigger. + const trigger = screen.getByTestId("executor-settings-button"); + trigger.click(); + + expect(await screen.findByTestId("executor-prepare-status")).toHaveProperty( + "dataset.phase", + "preparing", + ); + expect(screen.getByText(/Step 2 of 3: Uploading agent controller/)).toBeTruthy(); + }); + + it("renders the fallback warning callout when the missing-sandbox notice is present", async () => { + mockPrepareState = { + sessionId: SESSION_ID, + status: "preparing", + steps: [ + { + name: "Reconnecting cloud sandbox", + status: "skipped", + warning: + "Previous sandbox is no longer available โ€” provisioning a fresh one for this branch.", + }, + { name: STEP_CREATE_SANDBOX, status: "running" }, + ], + }; + + render(); + screen.getByTestId("executor-settings-button").click(); + + const status = await screen.findByTestId("executor-prepare-status"); + expect(status.dataset.phase).toBe("preparing_fallback"); + expect(screen.getByTestId("executor-prepare-fallback-warning")).toBeTruthy(); + }); + + it("shows an Environment ready row once preparation completes", async () => { + mockPrepareState = { + sessionId: SESSION_ID, + status: "completed", + steps: [{ name: "Creating cloud sandbox", status: "completed" }], + durationMs: 12500, + }; + + render(); + screen.getByTestId("executor-settings-button").click(); + + const status = await screen.findByTestId("executor-prepare-status"); + expect(status.dataset.phase).toBe("ready"); + expect(screen.getByText(/Environment ready/)).toBeTruthy(); + expect(screen.getByText(/13s/)).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/executor-settings-button.tsx b/apps/web/components/task/executor-settings-button.tsx index 034a6aa790..6fa2ed192c 100644 --- a/apps/web/components/task/executor-settings-button.tsx +++ b/apps/web/components/task/executor-settings-button.tsx @@ -1,7 +1,15 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { IconBox, IconCopy, IconLoader, IconTrash } from "@tabler/icons-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"; @@ -14,6 +22,12 @@ import { 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, @@ -24,9 +38,22 @@ import { 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; @@ -130,10 +157,104 @@ function useTaskEnvironment(taskId: string | null | undefined, active: boolean) return { env, container, loading, isResetting, reset, status }; } -export function ExecutorSettingsButton({ taskId, disabled }: ExecutorSettingsButtonProps) { +/** + * 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 { env, container, loading, isResetting, reset, status } = useTaskEnvironment(taskId, open); + 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 }) => { @@ -148,6 +269,9 @@ export function ExecutorSettingsButton({ taskId, disabled }: ExecutorSettingsBut if (!taskId) return null; + const executorType = env?.executor_type ?? null; + const ariaLabel = computeAriaLabel(isPreparing, status); + return ( <> @@ -158,11 +282,9 @@ export function ExecutorSettingsButton({ taskId, disabled }: ExecutorSettingsBut className="relative cursor-pointer px-2" disabled={disabled} data-testid="executor-settings-button" - aria-label={ - status ? `Executor settings, environment ${status.label}` : "Executor settings" - } + aria-label={ariaLabel} > - + @@ -171,6 +293,7 @@ export function ExecutorSettingsButton({ taskId, disabled }: ExecutorSettingsBut className="w-[340px] p-0 text-sm" data-testid="executor-settings-popover" > +