feat(web): show "Resuming session" feedback in popover during reconnect
The bottom Resume button on the chat panel was leaving users staring at nothing for the 5-30s reconnect window: prepare-progress events are intentionally suppressed on resume (the workspace is already there), so the popover sat in the idle phase even while session.state was STARTING. Synthesize a "resuming" phase from session state when no prepare events are flowing, rendered as a spinner row in the popover with copy "Resuming session — Reconnecting to the existing environment…". The button icon also flips to the spinner so the toolbar advertises the in-flight work without the user having to open the popover. Extract the prepare-status reducer + section component into their own file so the main popover module stays under the 600-line ESLint cap.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { IconAlertTriangle, IconCheck, IconLoader2 } from "@tabler/icons-react";
|
||||
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type {
|
||||
PrepareStepInfo,
|
||||
SessionPrepareState,
|
||||
} from "@/lib/state/slices/session-runtime/types";
|
||||
|
||||
export type PreparePhase =
|
||||
| "idle"
|
||||
| "preparing"
|
||||
| "preparing_fallback"
|
||||
| "resuming"
|
||||
| "ready"
|
||||
| "failed";
|
||||
|
||||
export type PrepareSummary = {
|
||||
phase: PreparePhase;
|
||||
current: PrepareStepInfo | null;
|
||||
currentIndex: number;
|
||||
totalSteps: number;
|
||||
fallbackWarning: string | null;
|
||||
failedStep: PrepareStepInfo | null;
|
||||
durationMs: number | null;
|
||||
};
|
||||
|
||||
const IDLE_PREPARE_SUMMARY: PrepareSummary = {
|
||||
phase: "idle",
|
||||
current: null,
|
||||
currentIndex: 0,
|
||||
totalSteps: 0,
|
||||
fallbackWarning: null,
|
||||
failedStep: null,
|
||||
durationMs: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reduces the prepare-progress slice + session state 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 missing-sandbox fallback as a first-class "preparing_fallback" phase
|
||||
* so the popover renders dedicated UI for the recovery path;
|
||||
* - a "resuming" phase synthesized from session.state == STARTING when the
|
||||
* backend skips prepare events on resume, so the popover still shows a
|
||||
* spinner instead of looking idle for the entire reconnect.
|
||||
*/
|
||||
export function usePrepareSummary(sessionId: string | null): PrepareSummary {
|
||||
const prepareState = useAppStore((state) =>
|
||||
sessionId ? (state.prepareProgress?.bySessionId?.[sessionId] ?? null) : null,
|
||||
);
|
||||
const sessionState = useAppStore((state) =>
|
||||
sessionId ? (state.taskSessions?.items?.[sessionId]?.state ?? null) : null,
|
||||
);
|
||||
return useMemo(() => summarizePrepare(prepareState, sessionState), [prepareState, sessionState]);
|
||||
}
|
||||
|
||||
export function isPreparingPhase(phase: PreparePhase): boolean {
|
||||
return phase === "preparing" || phase === "preparing_fallback" || phase === "resuming";
|
||||
}
|
||||
|
||||
function summarizePrepare(
|
||||
state: SessionPrepareState | null,
|
||||
sessionState: string | null,
|
||||
): PrepareSummary {
|
||||
if (!state) {
|
||||
if (sessionState === "STARTING") {
|
||||
return { ...IDLE_PREPARE_SUMMARY, phase: "resuming" };
|
||||
}
|
||||
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<T>(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 PrepareStatusSection({ summary }: { summary: PrepareSummary }) {
|
||||
if (summary.phase === "idle") return null;
|
||||
if (summary.phase === "resuming") return <ResumingRow />;
|
||||
if (summary.phase === "preparing" || summary.phase === "preparing_fallback") {
|
||||
return <PreparingRow summary={summary} />;
|
||||
}
|
||||
if (summary.phase === "failed") return <FailedRow summary={summary} />;
|
||||
return <ReadyRow summary={summary} />;
|
||||
}
|
||||
|
||||
function ResumingRow() {
|
||||
return (
|
||||
<div
|
||||
data-testid="executor-prepare-status"
|
||||
data-phase="resuming"
|
||||
className="border-b border-border px-3 py-2.5 space-y-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">Resuming session</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Reconnecting to the existing environment…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreparingRow({ summary }: { summary: PrepareSummary }) {
|
||||
const stepLabel = summary.current?.name ?? "Preparing...";
|
||||
const stepNumber = Math.min(summary.currentIndex + 1, summary.totalSteps);
|
||||
return (
|
||||
<div
|
||||
data-testid="executor-prepare-status"
|
||||
data-phase={summary.phase}
|
||||
className="border-b border-border px-3 py-2.5 space-y-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">Preparing environment</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{summary.totalSteps > 0
|
||||
? `Step ${stepNumber} of ${summary.totalSteps}: ${stepLabel}`
|
||||
: stepLabel}
|
||||
</div>
|
||||
{summary.phase === "preparing_fallback" && summary.fallbackWarning && (
|
||||
<div
|
||||
data-testid="executor-prepare-fallback-warning"
|
||||
className="mt-1 flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
<IconAlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{summary.fallbackWarning}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FailedRow({ summary }: { summary: PrepareSummary }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="executor-prepare-status"
|
||||
data-phase="failed"
|
||||
className="border-b border-border px-3 py-2.5 space-y-1"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<IconAlertTriangle className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">Environment preparation failed</span>
|
||||
</div>
|
||||
{summary.failedStep && (
|
||||
<div className="text-xs text-muted-foreground">Failed at: {summary.failedStep.name}</div>
|
||||
)}
|
||||
{summary.failedStep?.error && (
|
||||
<pre className="text-[11px] text-destructive whitespace-pre-wrap max-h-16 overflow-auto">
|
||||
{summary.failedStep.error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadyRow({ summary }: { summary: PrepareSummary }) {
|
||||
return (
|
||||
<div
|
||||
data-testid="executor-prepare-status"
|
||||
data-phase="ready"
|
||||
className="border-b border-border p-3 flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<IconCheck className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>
|
||||
Environment ready
|
||||
{summary.durationMs ? ` · ${formatDuration(summary.durationMs)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
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`;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionPrepareState } from "@/lib/state/slices/session-runtime/types";
|
||||
|
||||
let mockPrepareState: SessionPrepareState | null = null;
|
||||
let mockSessionState: string | null = null;
|
||||
let mockEnv: { executor_type: string; sandbox_id?: string; container_id?: string } | null = null;
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
@@ -11,6 +12,11 @@ vi.mock("@/components/state-provider", () => ({
|
||||
prepareProgress: {
|
||||
bySessionId: mockPrepareState ? { [mockPrepareState.sessionId]: mockPrepareState } : {},
|
||||
},
|
||||
taskSessions: {
|
||||
items: mockSessionState
|
||||
? { [SESSION_ID]: { id: SESSION_ID, state: mockSessionState } }
|
||||
: {},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -31,11 +37,14 @@ import { ExecutorSettingsButton } from "./executor-settings-button";
|
||||
const SESSION_ID = "session-1";
|
||||
const TASK_ID = "task-1";
|
||||
const STEP_CREATE_SANDBOX = "Creating cloud sandbox";
|
||||
const PREPARE_STATUS_TESTID = "executor-prepare-status";
|
||||
const SETTINGS_BUTTON_TESTID = "executor-settings-button";
|
||||
|
||||
describe("ExecutorSettingsButton", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockPrepareState = null;
|
||||
mockSessionState = null;
|
||||
mockEnv = null;
|
||||
});
|
||||
|
||||
@@ -87,10 +96,10 @@ describe("ExecutorSettingsButton", () => {
|
||||
render(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
|
||||
|
||||
// Open the popover by clicking the trigger.
|
||||
const trigger = screen.getByTestId("executor-settings-button");
|
||||
const trigger = screen.getByTestId(SETTINGS_BUTTON_TESTID);
|
||||
trigger.click();
|
||||
|
||||
expect(await screen.findByTestId("executor-prepare-status")).toHaveProperty(
|
||||
expect(await screen.findByTestId(PREPARE_STATUS_TESTID)).toHaveProperty(
|
||||
"dataset.phase",
|
||||
"preparing",
|
||||
);
|
||||
@@ -113,13 +122,27 @@ describe("ExecutorSettingsButton", () => {
|
||||
};
|
||||
|
||||
render(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
|
||||
screen.getByTestId("executor-settings-button").click();
|
||||
screen.getByTestId(SETTINGS_BUTTON_TESTID).click();
|
||||
|
||||
const status = await screen.findByTestId("executor-prepare-status");
|
||||
const status = await screen.findByTestId(PREPARE_STATUS_TESTID);
|
||||
expect(status.dataset.phase).toBe("preparing_fallback");
|
||||
expect(screen.getByTestId("executor-prepare-fallback-warning")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Resuming session row when session is STARTING with no prepare events", async () => {
|
||||
mockSessionState = "STARTING";
|
||||
|
||||
render(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
|
||||
|
||||
expect(screen.getByTestId("executor-settings-button-spinner")).toBeTruthy();
|
||||
screen.getByTestId(SETTINGS_BUTTON_TESTID).click();
|
||||
|
||||
const status = await screen.findByTestId(PREPARE_STATUS_TESTID);
|
||||
expect(status.dataset.phase).toBe("resuming");
|
||||
expect(screen.getByText("Resuming session")).toBeTruthy();
|
||||
expect(screen.getByText(/Reconnecting to the existing environment/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows an Environment ready row once preparation completes", async () => {
|
||||
mockPrepareState = {
|
||||
sessionId: SESSION_ID,
|
||||
@@ -129,9 +152,9 @@ describe("ExecutorSettingsButton", () => {
|
||||
};
|
||||
|
||||
render(<ExecutorSettingsButton taskId={TASK_ID} sessionId={SESSION_ID} />);
|
||||
screen.getByTestId("executor-settings-button").click();
|
||||
screen.getByTestId(SETTINGS_BUTTON_TESTID).click();
|
||||
|
||||
const status = await screen.findByTestId("executor-prepare-status");
|
||||
const status = await screen.findByTestId(PREPARE_STATUS_TESTID);
|
||||
expect(status.dataset.phase).toBe("ready");
|
||||
expect(screen.getByText(/Environment ready/)).toBeTruthy();
|
||||
expect(screen.getByText(/13s/)).toBeTruthy();
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBox,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconLoader,
|
||||
IconLoader2,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { IconBox, 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";
|
||||
@@ -22,12 +14,7 @@ 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,
|
||||
@@ -35,6 +22,11 @@ import {
|
||||
type StatusTone,
|
||||
type EnvironmentStatusSnapshot,
|
||||
} from "./executor-environment-status";
|
||||
import {
|
||||
isPreparingPhase,
|
||||
PrepareStatusSection,
|
||||
usePrepareSummary,
|
||||
} from "./executor-prepare-status";
|
||||
|
||||
type ExecutorSettingsButtonProps = {
|
||||
taskId?: string | null;
|
||||
@@ -42,18 +34,6 @@ type ExecutorSettingsButtonProps = {
|
||||
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;
|
||||
|
||||
@@ -157,89 +137,6 @@ function useTaskEnvironment(taskId: string | null | undefined, active: boolean)
|
||||
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<T>(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,
|
||||
@@ -248,7 +145,7 @@ export function ExecutorSettingsButton({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [resetDialogOpen, setResetDialogOpen] = useState(false);
|
||||
const prepare = usePrepareSummary(sessionId ?? null);
|
||||
const isPreparing = prepare.phase === "preparing" || prepare.phase === "preparing_fallback";
|
||||
const isPreparing = isPreparingPhase(prepare.phase);
|
||||
// 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(
|
||||
@@ -387,90 +284,6 @@ function ExecutorStatusDot({
|
||||
);
|
||||
}
|
||||
|
||||
function PrepareStatusSection({ summary }: { summary: PrepareSummary }) {
|
||||
if (summary.phase === "idle") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (summary.phase === "preparing" || summary.phase === "preparing_fallback") {
|
||||
const stepLabel = summary.current?.name ?? "Preparing...";
|
||||
const stepNumber = Math.min(summary.currentIndex + 1, summary.totalSteps);
|
||||
return (
|
||||
<div
|
||||
data-testid="executor-prepare-status"
|
||||
data-phase={summary.phase}
|
||||
className="border-b border-border px-3 py-2.5 space-y-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconLoader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
<span className="font-medium text-foreground">Preparing environment</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{summary.totalSteps > 0
|
||||
? `Step ${stepNumber} of ${summary.totalSteps}: ${stepLabel}`
|
||||
: stepLabel}
|
||||
</div>
|
||||
{summary.phase === "preparing_fallback" && summary.fallbackWarning && (
|
||||
<div
|
||||
data-testid="executor-prepare-fallback-warning"
|
||||
className="mt-1 flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
<IconAlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{summary.fallbackWarning}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (summary.phase === "failed") {
|
||||
return (
|
||||
<div
|
||||
data-testid="executor-prepare-status"
|
||||
data-phase="failed"
|
||||
className="border-b border-border px-3 py-2.5 space-y-1"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<IconAlertTriangle className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">Environment preparation failed</span>
|
||||
</div>
|
||||
{summary.failedStep && (
|
||||
<div className="text-xs text-muted-foreground">Failed at: {summary.failedStep.name}</div>
|
||||
)}
|
||||
{summary.failedStep?.error && (
|
||||
<pre className="text-[11px] text-destructive whitespace-pre-wrap max-h-16 overflow-auto">
|
||||
{summary.failedStep.error}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// phase === "ready"
|
||||
return (
|
||||
<div
|
||||
data-testid="executor-prepare-status"
|
||||
data-phase="ready"
|
||||
className="border-b border-border p-3 flex items-center gap-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<IconCheck className="h-3.5 w-3.5 text-green-500" />
|
||||
<span>
|
||||
Environment ready
|
||||
{summary.durationMs ? ` · ${formatDuration(summary.durationMs)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
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`;
|
||||
}
|
||||
|
||||
function EnvironmentInfo({
|
||||
env,
|
||||
container,
|
||||
|
||||
Reference in New Issue
Block a user