193 lines
6.7 KiB
TypeScript
193 lines
6.7 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useState } from "react";
|
|
import { IconBox, IconLoader2, IconTrash } from "@tabler/icons-react";
|
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
|
|
import { getExecutorStatusIcon } from "@/lib/executor-icons";
|
|
import { TaskResetEnvConfirmDialog } from "./task-reset-env-confirm-dialog";
|
|
import type { StatusTone } from "./executor-environment-status";
|
|
import { usePrepareSummary } from "@/hooks/domains/session/use-prepare-summary";
|
|
import { useTaskEnvironment } from "@/hooks/domains/session/use-task-environment";
|
|
import { isPreparingPhase } from "@/lib/prepare/summarize";
|
|
import { PrepareStatusSection } from "./executor-prepare-status";
|
|
import { EnvironmentInfo } from "./executor-environment-info";
|
|
|
|
type ExecutorSettingsButtonProps = {
|
|
taskId?: string | null;
|
|
sessionId?: string | null;
|
|
disabled?: boolean;
|
|
};
|
|
|
|
export function ExecutorSettingsButton({
|
|
taskId,
|
|
sessionId,
|
|
disabled,
|
|
}: ExecutorSettingsButtonProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [resetDialogOpen, setResetDialogOpen] = useState(false);
|
|
const prepare = usePrepareSummary(sessionId ?? null);
|
|
const isPreparing = isPreparingPhase(prepare.phase);
|
|
// Promote the foreground polling cadence while preparing so the icon flips
|
|
// to "ready" without the user hovering over the trigger.
|
|
const { env, container, ssh, loading, isResetting, reset, status } = useTaskEnvironment(
|
|
taskId,
|
|
open || isPreparing,
|
|
);
|
|
const hasWorktreePath = Boolean(env?.worktree_path);
|
|
|
|
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 (
|
|
<>
|
|
<HoverCard open={open} onOpenChange={setOpen} openDelay={150} closeDelay={250}>
|
|
<HoverCardTrigger asChild>
|
|
{/* Real <button> for free Enter/Space activation, focus rings and
|
|
disabled semantics. variant="ghost" removes the outline so the
|
|
trigger reads as info, not as a clickable action — but we keep
|
|
the click→toggle behaviour the underlying HoverCard offers. */}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
disabled={disabled}
|
|
aria-haspopup="dialog"
|
|
aria-label={ariaLabel}
|
|
data-testid="executor-settings-button"
|
|
className="relative h-7 cursor-default gap-1 px-1.5 text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ExecutorButtonIcon executorType={executorType} preparing={isPreparing} />
|
|
<ExecutorStatusDot status={status} loading={loading} />
|
|
</Button>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent
|
|
align="start"
|
|
className="w-[340px] p-0 text-sm"
|
|
data-testid="executor-settings-popover"
|
|
>
|
|
<PrepareStatusSection summary={prepare} />
|
|
<EnvironmentInfo env={env} container={container} ssh={ssh} loading={loading} />
|
|
<div className="border-t border-border px-2 py-1.5 flex items-center justify-end">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span tabIndex={!env || isResetting ? 0 : -1} aria-label="Reset environment">
|
|
<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>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent className="max-w-xs">
|
|
<p className="font-medium">Reset environment</p>
|
|
<p className="mt-1 text-xs">
|
|
Deletes the current task environment (container, sandbox, and/or worktree) and
|
|
forces a fresh one to be created for your next run.
|
|
</p>
|
|
<p className="mt-1 text-xs text-destructive">
|
|
Any uncommitted or unpushed changes are lost.
|
|
</p>
|
|
{hasWorktreePath && (
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Push your branch to its remote in the confirmation dialog to preserve committed
|
|
work before resetting.
|
|
</p>
|
|
)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
|
|
<TaskResetEnvConfirmDialog
|
|
open={resetDialogOpen}
|
|
onOpenChange={setResetDialogOpen}
|
|
hasWorktreePath={hasWorktreePath}
|
|
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} />;
|
|
}
|
|
|
|
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 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" : ""
|
|
}`}
|
|
/>
|
|
);
|
|
}
|