Files
kandev/apps/web/components/task/executor-settings-button.tsx
T
2026-09-02 13:50:03 +00:00

310 lines
8.8 KiB
TypeScript

"use client";
import { forwardRef, useCallback, useState } from "react";
import { IconBox, IconLoader2 } from "@tabler/icons-react";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card";
import { Button } from "@kandev/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@kandev/ui/drawer";
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 { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
import { PrepareStatusSection } from "./executor-prepare-status";
import { ExecutorEnvironmentDisclosure } from "./executor-environment-disclosure";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
type ExecutorSettingsButtonProps = {
taskId?: string | null;
sessionId?: string | null;
disabled?: boolean;
};
export function ExecutorSettingsButton({
taskId,
sessionId,
disabled,
}: ExecutorSettingsButtonProps) {
const usesTouchDrawer = useTouchDrawer();
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,
kubernetes,
kubernetesLoaded,
kubernetesError,
loading,
refreshing,
isResetting,
reset,
refresh,
status,
} = useTaskEnvironment(taskId, sessionId, 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);
const content = (
<>
<PrepareStatusSection summary={prepare} />
<ExecutorEnvironmentDisclosure
env={env}
container={container}
ssh={ssh}
kubernetes={kubernetes}
kubernetesLoaded={kubernetesLoaded}
kubernetesError={kubernetesError}
loading={loading}
refreshing={refreshing}
isResetting={isResetting}
touch={usesTouchDrawer}
onRefresh={refresh}
onReset={() => setResetDialogOpen(true)}
/>
</>
);
return (
<ExecutorDisclosureSurface
touch={usesTouchDrawer}
open={open}
onOpenChange={setOpen}
content={content}
trigger={{
ariaLabel,
disabled,
executorType,
loading,
preparing: isPreparing,
status,
}}
resetDialog={{
open: resetDialogOpen,
onOpenChange: setResetDialogOpen,
hasWorktreePath,
isResetting,
onConfirm: handleReset,
}}
/>
);
}
type ExecutorDisclosureSurfaceProps = {
touch: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
content: React.ReactNode;
trigger: Omit<ExecutorTriggerProps, "expanded" | "touch">;
resetDialog: React.ComponentProps<typeof TaskResetEnvConfirmDialog>;
};
function ExecutorDisclosureSurface({
touch,
open,
onOpenChange,
content,
trigger,
resetDialog,
}: ExecutorDisclosureSurfaceProps) {
const { t } = useTranslation();
if (touch) {
return (
<>
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerTrigger asChild>
<ExecutorTrigger {...trigger} touch expanded={open} />
</DrawerTrigger>
<DrawerContent
data-testid="executor-settings-drawer"
className="max-h-[78dvh] overflow-hidden pb-[env(safe-area-inset-bottom)]"
>
<DrawerHeader className="flex flex-row items-center justify-between border-b px-4 py-3">
<div className="min-w-0 text-left">
<DrawerTitle className="text-sm">{t("task:executorSettings")}</DrawerTitle>
<DrawerDescription className="truncate text-xs">
{trigger.status
? t("task:environmentStateCurrent", { state: trigger.status.label })
: t("task:executorEnvironmentIsUnavailable")}
</DrawerDescription>
</div>
<DrawerClose asChild>
<Button variant="ghost" size="sm" className="min-h-11 cursor-pointer">
{t("common:close")}
</Button>
</DrawerClose>
</DrawerHeader>
<div className="min-h-0 overflow-y-auto overscroll-contain">{content}</div>
</DrawerContent>
</Drawer>
<TaskResetEnvConfirmDialog {...resetDialog} />
</>
);
}
return (
<>
<HoverCard open={open} onOpenChange={onOpenChange} openDelay={150} closeDelay={250}>
<HoverCardTrigger asChild>
<ExecutorTrigger {...trigger} expanded={open} />
</HoverCardTrigger>
<HoverCardContent
align="start"
className="max-h-[70vh] w-[348px] max-w-[calc(100vw-2rem)] overflow-y-auto overscroll-contain rounded-xl border-border/80 p-0 text-sm shadow-xl"
data-testid="executor-settings-popover"
>
{content}
</HoverCardContent>
</HoverCard>
<TaskResetEnvConfirmDialog {...resetDialog} />
</>
);
}
type ExecutorTriggerProps = {
ariaLabel: string;
disabled?: boolean;
executorType: string | null;
loading: boolean;
preparing: boolean;
status: { label: string; tone: StatusTone } | null;
touch?: boolean;
expanded: boolean;
} & Omit<React.ComponentPropsWithoutRef<typeof Button>, "children">;
const ExecutorTrigger = forwardRef<HTMLButtonElement, ExecutorTriggerProps>(
function ExecutorTrigger(
{
ariaLabel,
disabled,
executorType,
loading,
preparing,
status,
touch = false,
expanded,
...triggerProps
},
ref,
) {
return (
<Button
{...triggerProps}
ref={ref}
type="button"
variant="ghost"
size="sm"
disabled={disabled}
aria-haspopup="dialog"
aria-expanded={expanded}
aria-label={ariaLabel}
data-testid="executor-settings-button"
className={
touch
? "relative h-11 w-11 cursor-pointer p-0 text-muted-foreground hover:text-foreground"
: "relative h-7 cursor-pointer gap-1 px-1.5 text-muted-foreground hover:text-foreground"
}
>
<ExecutorButtonIcon
executorType={executorType}
preparing={preparing}
hasError={status?.tone === "error"}
/>
<ExecutorStatusDot status={status} loading={loading} />
</Button>
);
},
);
function computeAriaLabel(
preparing: boolean,
status: { label: string; tone: StatusTone } | null,
): string {
if (preparing) return t("task:executorSettingsPreparing");
if (status) return t("task:executorSettingsWithStatus", { status: status.label });
return t("task:executorSettings");
}
function ExecutorButtonIcon({
executorType,
preparing,
hasError,
}: {
executorType: string | null;
preparing: boolean;
hasError: 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, hasError);
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 { t } = useTranslation();
const tone = status?.tone ?? "neutral";
const label = status?.label ?? "not created";
return (
<span
aria-hidden="true"
title={t("task: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" : ""
}`}
/>
);
}