Files
kandev/apps/web/components/task/executor-settings-button.tsx
T
José Almeida 4282ba262f feat(web): unify executor surfaces in the right-side popover
The desktop task top bar carried a small cloud icon (RemoteCloudTooltip)
in the breadcrumb cluster while the right-side Executor popover showed
the same kind of info — two surfaces for one concept. Remove the cloud
icon from the desktop top bar; kanban cards, task list rows, and the
mobile top bar keep theirs since they have no popover.

The popover button now picks its icon by executor type (cloud for
sprites, container for docker, server for local PC) using a shared
getExecutorStatusIcon helper. While the prepare progress is preparing
the icon swaps to a spinner so the user can see at a glance that work
is in flight.

The popover content gains a Preparing-environment section that streams
"Step N of M: <step>", an amber inline note when the missing-sandbox
fallback is in progress, an Environment-ready row with duration on
success, and a failed-step summary on failure. Polling promotes to the
active 3s cadence while preparing so the icon flips to ready without
the user opening the popover.
2026-05-05 21:20:05 +01:00

653 lines
20 KiB
TypeScript

"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<TaskEnvironment | null>(null);
const [container, setContainer] = useState<ContainerLiveStatus | null>(null);
const [loading, setLoading] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const inFlight = useRef(false);
const lastStatusRef = useRef<EnvironmentStatusSnapshot | null>(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<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,
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 (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="relative cursor-pointer px-2"
disabled={disabled}
data-testid="executor-settings-button"
aria-label={ariaLabel}
>
<ExecutorButtonIcon executorType={executorType} preparing={isPreparing} />
<ExecutorStatusDot status={status} loading={loading} />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[340px] p-0 text-sm"
data-testid="executor-settings-popover"
>
<PrepareStatusSection summary={prepare} />
<EnvironmentInfo env={env} container={container} loading={loading} />
<div className="border-t border-border p-2 flex items-center justify-end">
<Button
variant="destructive"
size="sm"
className="cursor-pointer text-xs"
disabled={!env || isResetting}
data-testid="executor-settings-reset"
onClick={() => setResetDialogOpen(true)}
>
<IconTrash className="h-3.5 w-3.5 mr-1" /> Reset environment
</Button>
</div>
</PopoverContent>
</Popover>
<TaskResetEnvConfirmDialog
open={resetDialogOpen}
onOpenChange={setResetDialogOpen}
hasWorktreePath={Boolean(env?.worktree_path)}
isResetting={isResetting}
onConfirm={handleReset}
/>
</>
);
}
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 (
<IconLoader2
className="h-4 w-4 animate-spin"
data-testid="executor-settings-button-spinner"
/>
);
}
if (!executorType) {
return <IconBox className="h-4 w-4" data-testid="executor-status-box-icon" />;
}
const { Icon, testId } = getExecutorStatusIcon(executorType, false);
return <Icon className="h-4 w-4" data-testid={testId} />;
}
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 (
<span
aria-hidden="true"
title={`Environment ${label}`}
data-testid="executor-status-indicator"
className={`absolute right-1 top-1 h-2.5 w-2.5 rounded-full border border-background ${DOT_CLASSES[tone]} ${
loading && !status ? "animate-pulse" : ""
}`}
/>
);
}
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 p-3 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 p-3 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,
loading,
}: {
env: TaskEnvironment | null;
container: ContainerLiveStatus | null;
loading: boolean;
}) {
if (loading && !env) {
return (
<div className="flex items-center justify-center py-6 text-muted-foreground">
<IconLoader className="h-4 w-4 animate-spin" />
</div>
);
}
if (!env) {
return (
<div className="px-3 py-4 text-muted-foreground">
<p className="font-medium text-foreground">No environment yet</p>
<p className="text-xs mt-1">
An environment is created when you start a session on this task.
</p>
</div>
);
}
return (
<div className="p-3 space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-foreground">{formatExecutorType(env.executor_type)}</span>
<StatusBadge env={env} container={container} />
</div>
<EnvironmentFields env={env} container={container} />
</div>
);
}
function StatusBadge({
env,
container,
}: {
env: TaskEnvironment;
container: ContainerLiveStatus | null;
}) {
// For container-backed envs the live state is the source of truth; for the
// others fall back to the recorded TaskEnvironment.status.
const { label, tone } = resolveExecutorEnvironmentStatus(env, container);
const className = TONE_CLASSES[tone];
return (
<Badge variant="outline" className={`text-[10px] uppercase ${className}`}>
{label}
</Badge>
);
}
const TONE_CLASSES: Record<StatusTone, string> = {
running: "border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300",
stopped: "border-zinc-500/30 bg-zinc-500/10 text-zinc-700 dark:text-zinc-300",
warn: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
error: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300",
neutral: "border-muted text-muted-foreground",
};
const DOT_CLASSES: Record<StatusTone, string> = {
running: "bg-green-500",
stopped: "bg-zinc-500",
warn: "bg-amber-500",
error: "bg-red-500",
neutral: "bg-muted-foreground",
};
function EnvironmentFields({
env,
container,
}: {
env: TaskEnvironment;
container: ContainerLiveStatus | null;
}) {
const fields = useMemo(() => buildFields(env, container), [env, container]);
if (fields.length === 0) {
return <p className="text-xs text-muted-foreground">No resource details available.</p>;
}
return (
<dl className="space-y-1.5 text-xs">
{fields.map((f) => (
<Field key={f.label} label={f.label} value={f.value} copy={f.copy} />
))}
</dl>
);
}
function Field({ label, value, copy }: { label: string; value: string; copy?: boolean }) {
return (
<div className="flex items-start gap-2">
<dt className="text-muted-foreground min-w-[80px]">{label}</dt>
<dd className="flex-1 flex items-center gap-1 break-all font-mono">
<span className="flex-1">{value}</span>
{copy && (
<button
type="button"
className="cursor-pointer text-muted-foreground hover:text-foreground"
aria-label={`Copy ${label}`}
onClick={() => {
void navigator.clipboard
.writeText(value)
.then(() => toast.success(`${label} copied`));
}}
>
<IconCopy className="h-3 w-3" />
</button>
)}
</dd>
</div>
);
}
type FieldRow = { label: string; value: string; copy?: boolean };
function buildFields(env: TaskEnvironment, container: ContainerLiveStatus | null): FieldRow[] {
const rows: FieldRow[] = [];
if (env.worktree_path) {
rows.push({ label: "Worktree", value: env.worktree_path, copy: true });
}
if (env.worktree_branch) {
rows.push({ label: "Branch", value: env.worktree_branch, copy: true });
}
if (env.container_id) {
const short = env.container_id.slice(0, 12);
rows.push({ label: "Container", value: short, copy: true });
// Use `sh` rather than `bash` — user-built images may only ship
// /bin/sh (busybox/alpine/etc.), and the bootstrap entrypoint already
// assumes sh-only.
rows.push({
label: "Shell",
value: `docker exec -it ${short} sh`,
copy: true,
});
if (container?.started_at && container.state === "running") {
rows.push({ label: "Uptime", value: formatUptime(container.started_at) });
}
}
if (env.sandbox_id) {
rows.push({ label: "Sprite", value: env.sandbox_id, copy: true });
}
return rows;
}
function formatUptime(startedAt: string): string {
const startedMs = Date.parse(startedAt);
if (Number.isNaN(startedMs)) return startedAt;
const elapsedSec = Math.max(0, Math.floor((Date.now() - startedMs) / 1000));
if (elapsedSec < 60) return `${elapsedSec}s`;
const min = Math.floor(elapsedSec / 60);
if (min < 60) return `${min}m`;
const hr = Math.floor(min / 60);
return `${hr}h ${min % 60}m`;
}
function formatExecutorType(type: string): string {
switch (type) {
case "local_pc":
case "worktree":
return "Local (worktree)";
case "local_docker":
return "Local Docker";
case "sprites":
return "Sprites sandbox";
case "remote_docker":
return "Remote Docker";
default:
return type || "Unknown executor";
}
}