4282ba262f
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.
653 lines
20 KiB
TypeScript
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";
|
|
}
|
|
}
|