310 lines
9.0 KiB
TypeScript
310 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
useState,
|
|
type ComponentPropsWithRef,
|
|
type KeyboardEvent,
|
|
type MouseEvent,
|
|
type ReactNode,
|
|
type SyntheticEvent,
|
|
} from "react";
|
|
import { IconX } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import {
|
|
Drawer,
|
|
DrawerClose,
|
|
DrawerContent,
|
|
DrawerDescription,
|
|
DrawerHeader,
|
|
DrawerTitle,
|
|
DrawerTrigger,
|
|
} from "@kandev/ui/drawer";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
|
|
import { getExecutorStatusIcon } from "@/lib/executor-icons";
|
|
import { t } from "@/lib/i18n";
|
|
import { useRemoteExecutorStatus } from "@/hooks/domains/session/use-remote-executor-status";
|
|
import type { RemoteExecutorStatusData } from "@/hooks/domains/session/remote-executor-status-resource";
|
|
import { cn } from "@/lib/utils";
|
|
import { RemoteExecutorTaskStatusSummary } from "./remote-executor-task-status-summary";
|
|
import { useTaskIconTooltipState } from "./use-task-icon-tooltip-state";
|
|
|
|
type RemoteCloudTooltipProps = {
|
|
taskId: string;
|
|
sessionId?: string | null;
|
|
executorId?: string | null;
|
|
executorType?: string | null;
|
|
fallbackName?: string | null;
|
|
iconClassName?: string;
|
|
/** When provided, uses this data directly instead of subscribing to live status. */
|
|
status?: RemoteExecutorStatusData | null;
|
|
};
|
|
|
|
const CONNECTED_THRESHOLD_MS = 2 * 60 * 1000;
|
|
|
|
function getCloudState(status: RemoteExecutorStatusData | null): "connected" | "error" | "stale" {
|
|
if (status?.remote_status_error) return "error";
|
|
if (!status?.remote_checked_at) return "stale";
|
|
const elapsed = Date.now() - new Date(status.remote_checked_at).getTime();
|
|
if (elapsed < CONNECTED_THRESHOLD_MS) return "connected";
|
|
return "stale";
|
|
}
|
|
|
|
const CLOUD_STATE_CLASSES: Record<ReturnType<typeof getCloudState>, string> = {
|
|
error: "text-destructive",
|
|
connected: "text-emerald-500",
|
|
stale: "text-muted-foreground",
|
|
};
|
|
|
|
type StatusTriggerProps = Omit<ComponentPropsWithRef<"span">, "onFocus" | "onBlur"> & {
|
|
touch: boolean;
|
|
expanded: boolean;
|
|
label: string;
|
|
icon: ReturnType<typeof getExecutorStatusIcon>;
|
|
iconClassName: string;
|
|
cloudState: ReturnType<typeof getCloudState>;
|
|
onPointerEnter?: ReturnType<typeof useTaskIconTooltipState>["onPointerEnter"];
|
|
onPointerLeave?: ReturnType<typeof useTaskIconTooltipState>["onPointerLeave"];
|
|
onFocus?: ReturnType<typeof useTaskIconTooltipState>["onFocus"];
|
|
onBlur?: ReturnType<typeof useTaskIconTooltipState>["onBlur"];
|
|
onTouchClick?: (event: MouseEvent<HTMLSpanElement>) => void;
|
|
onTouchKeyDown?: (event: KeyboardEvent<HTMLSpanElement>) => void;
|
|
};
|
|
|
|
function StatusTrigger({
|
|
touch,
|
|
expanded,
|
|
label,
|
|
icon,
|
|
iconClassName,
|
|
cloudState,
|
|
onPointerEnter,
|
|
onPointerLeave,
|
|
onFocus,
|
|
onBlur,
|
|
onTouchClick,
|
|
onTouchKeyDown,
|
|
...triggerProps
|
|
}: StatusTriggerProps) {
|
|
const Icon = icon.Icon;
|
|
return (
|
|
<span
|
|
{...triggerProps}
|
|
data-testid="remote-executor-status-trigger"
|
|
role={touch ? "button" : "img"}
|
|
tabIndex={0}
|
|
aria-label={label}
|
|
aria-haspopup={touch ? "dialog" : undefined}
|
|
aria-expanded={touch ? expanded : undefined}
|
|
onPointerEnter={onPointerEnter}
|
|
onPointerLeave={onPointerLeave}
|
|
onFocus={onFocus}
|
|
onBlur={onBlur}
|
|
onClick={(event) => {
|
|
onTouchClick?.(event);
|
|
triggerProps.onClick?.(event);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
onTouchKeyDown?.(event);
|
|
triggerProps.onKeyDown?.(event);
|
|
}}
|
|
className={cn(
|
|
"relative inline-flex shrink-0 items-center justify-center",
|
|
CLOUD_STATE_CLASSES[cloudState],
|
|
triggerProps.className,
|
|
touch &&
|
|
"cursor-pointer transition-transform duration-150 ease-out active:scale-[0.96] after:absolute after:left-1/2 after:top-1/2 after:size-11 after:-translate-x-1/2 after:-translate-y-1/2 after:content-['']",
|
|
)}
|
|
>
|
|
<Icon data-testid={icon.testId} className={iconClassName} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
type StatusDisclosureProps = {
|
|
label: string;
|
|
remoteName: string;
|
|
summary: ReactNode;
|
|
icon: ReturnType<typeof getExecutorStatusIcon>;
|
|
iconClassName: string;
|
|
cloudState: ReturnType<typeof getCloudState>;
|
|
onRefresh: () => void;
|
|
};
|
|
|
|
function stopTaskRowInteraction(event: SyntheticEvent) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
function TouchStatusDisclosure({
|
|
label,
|
|
remoteName,
|
|
summary,
|
|
icon,
|
|
iconClassName,
|
|
cloudState,
|
|
onRefresh,
|
|
}: StatusDisclosureProps) {
|
|
const [open, setOpen] = useState(false);
|
|
|
|
function show(event: MouseEvent<HTMLSpanElement> | KeyboardEvent<HTMLSpanElement>) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setOpen(true);
|
|
onRefresh();
|
|
}
|
|
|
|
function handleKeyDown(event: KeyboardEvent<HTMLSpanElement>) {
|
|
if ((event.key === "Enter" || event.key === " ") && !event.repeat) show(event);
|
|
}
|
|
|
|
return (
|
|
<span
|
|
className="contents"
|
|
onClick={stopTaskRowInteraction}
|
|
onKeyDown={stopTaskRowInteraction}
|
|
onMouseDown={stopTaskRowInteraction}
|
|
onPointerDown={stopTaskRowInteraction}
|
|
onTouchStart={stopTaskRowInteraction}
|
|
>
|
|
<Drawer open={open} onOpenChange={setOpen}>
|
|
<DrawerTrigger asChild>
|
|
<StatusTrigger
|
|
touch
|
|
expanded={open}
|
|
label={label}
|
|
icon={icon}
|
|
iconClassName={iconClassName}
|
|
cloudState={cloudState}
|
|
onTouchClick={show}
|
|
onTouchKeyDown={handleKeyDown}
|
|
/>
|
|
</DrawerTrigger>
|
|
<DrawerContent
|
|
data-testid="remote-executor-status-drawer"
|
|
className="max-h-[min(32rem,calc(100dvh-1rem))] overflow-hidden pb-[env(safe-area-inset-bottom,0px)]"
|
|
>
|
|
<DrawerHeader className="flex shrink-0 flex-row items-center justify-between border-b px-4 py-2 text-left">
|
|
<div className="min-w-0">
|
|
<DrawerTitle className="truncate text-sm">{remoteName}</DrawerTitle>
|
|
<DrawerDescription className="sr-only">{label}</DrawerDescription>
|
|
</div>
|
|
<DrawerClose asChild>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-11 w-11 shrink-0 cursor-pointer transition-transform duration-150 ease-out active:scale-[0.96]"
|
|
aria-label={t("common:close")}
|
|
>
|
|
<IconX aria-hidden="true" className="size-4" />
|
|
</Button>
|
|
</DrawerClose>
|
|
</DrawerHeader>
|
|
<div className="min-h-0 overflow-y-auto overscroll-contain p-4" data-vaul-no-drag>
|
|
{summary}
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function FinePointerStatusTooltip({
|
|
label,
|
|
summary,
|
|
icon,
|
|
iconClassName,
|
|
cloudState,
|
|
onRefresh,
|
|
}: StatusDisclosureProps) {
|
|
const tooltip = useTaskIconTooltipState(onRefresh);
|
|
return (
|
|
<Tooltip open={tooltip.open}>
|
|
<TooltipTrigger asChild>
|
|
<StatusTrigger
|
|
touch={false}
|
|
expanded={tooltip.open}
|
|
label={label}
|
|
icon={icon}
|
|
iconClassName={iconClassName}
|
|
cloudState={cloudState}
|
|
onPointerEnter={tooltip.onPointerEnter}
|
|
onPointerLeave={tooltip.onPointerLeave}
|
|
onFocus={tooltip.onFocus}
|
|
onBlur={tooltip.onBlur}
|
|
/>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="top"
|
|
sideOffset={6}
|
|
onEscapeKeyDown={tooltip.onEscapeKeyDown}
|
|
className="w-80 max-w-[calc(100vw-1rem)] p-3"
|
|
>
|
|
{summary}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export function RemoteCloudTooltip({
|
|
taskId,
|
|
sessionId,
|
|
executorId,
|
|
executorType,
|
|
fallbackName,
|
|
iconClassName = "h-3.5 w-3.5",
|
|
status: externalStatus,
|
|
}: RemoteCloudTooltipProps) {
|
|
const hasExternalStatus = externalStatus !== undefined;
|
|
const usesTouchDrawer = useTouchDrawer();
|
|
const live = useRemoteExecutorStatus(
|
|
{
|
|
executorId,
|
|
executorType,
|
|
taskId,
|
|
sessionId: sessionId ?? "",
|
|
},
|
|
!hasExternalStatus,
|
|
);
|
|
const status = hasExternalStatus ? (externalStatus ?? null) : live.status;
|
|
const remoteName = status?.remote_name ?? fallbackName ?? t("task:remoteExecutor");
|
|
const cloudState = getCloudState(status);
|
|
const loading = live.loading;
|
|
const icon = getExecutorStatusIcon(executorType, cloudState === "error");
|
|
const label = t("task:remoteExecutorStatus", { name: remoteName });
|
|
const summary = (
|
|
<RemoteExecutorTaskStatusSummary
|
|
executorType={executorType}
|
|
remoteName={remoteName}
|
|
status={status}
|
|
loading={loading}
|
|
/>
|
|
);
|
|
|
|
if (usesTouchDrawer) {
|
|
return (
|
|
<TouchStatusDisclosure
|
|
label={label}
|
|
remoteName={remoteName}
|
|
summary={summary}
|
|
icon={icon}
|
|
iconClassName={iconClassName}
|
|
cloudState={cloudState}
|
|
onRefresh={() => void live.refresh()}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<FinePointerStatusTooltip
|
|
label={label}
|
|
remoteName={remoteName}
|
|
summary={summary}
|
|
icon={icon}
|
|
iconClassName={iconClassName}
|
|
cloudState={cloudState}
|
|
onRefresh={() => void live.refresh()}
|
|
/>
|
|
);
|
|
}
|