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.
139 lines
4.2 KiB
TypeScript
139 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
|
import { getExecutorStatusIcon } from "@/lib/executor-icons";
|
|
|
|
type RemoteStatusData = {
|
|
remote_name?: string;
|
|
remote_state?: string;
|
|
remote_created_at?: string;
|
|
remote_checked_at?: string;
|
|
remote_status_error?: string;
|
|
};
|
|
|
|
type RemoteCloudTooltipProps = {
|
|
taskId: string;
|
|
sessionId?: string | null;
|
|
executorType?: string | null;
|
|
fallbackName?: string | null;
|
|
iconClassName?: string;
|
|
/** When provided, uses this data directly instead of fetching via WS on hover. */
|
|
status?: RemoteStatusData | null;
|
|
};
|
|
|
|
const CONNECTED_THRESHOLD_MS = 2 * 60 * 1000;
|
|
|
|
function getCloudState(status: RemoteStatusData | 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";
|
|
}
|
|
|
|
function formatTimestamp(value?: string): string | null {
|
|
if (!value) return null;
|
|
const d = new Date(value);
|
|
if (Number.isNaN(d.getTime())) return null;
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
function RemoteCloudStatusContent({
|
|
remoteName,
|
|
remoteState,
|
|
createdAt,
|
|
checkedAt,
|
|
remoteStatusError,
|
|
loading,
|
|
}: {
|
|
remoteName: string;
|
|
remoteState?: string;
|
|
createdAt: string | null;
|
|
checkedAt: string | null;
|
|
remoteStatusError?: string;
|
|
loading: boolean;
|
|
}) {
|
|
return (
|
|
<TooltipContent side="top" className="space-y-0.5">
|
|
<div className="font-medium">{remoteName}</div>
|
|
{remoteState && <div>State: {remoteState}</div>}
|
|
{createdAt && <div>Created: {createdAt}</div>}
|
|
{checkedAt && <div>Last check: {checkedAt}</div>}
|
|
{remoteStatusError && (
|
|
<div className="text-destructive">Status failed: {remoteStatusError}</div>
|
|
)}
|
|
{loading && <div>Loading status...</div>}
|
|
</TooltipContent>
|
|
);
|
|
}
|
|
|
|
const CLOUD_STATE_CLASSES: Record<ReturnType<typeof getCloudState>, string> = {
|
|
error: "text-destructive",
|
|
connected: "text-emerald-500",
|
|
stale: "text-muted-foreground",
|
|
};
|
|
|
|
export function RemoteCloudTooltip({
|
|
taskId,
|
|
sessionId,
|
|
executorType,
|
|
fallbackName,
|
|
iconClassName = "h-3.5 w-3.5",
|
|
status: externalStatus,
|
|
}: RemoteCloudTooltipProps) {
|
|
const hasExternalStatus = externalStatus !== undefined;
|
|
const [open, setOpen] = useState(false);
|
|
const [fetchedStatus, setFetchedStatus] = useState<RemoteStatusData | null>(null);
|
|
const [fetchedSessionId, setFetchedSessionId] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (hasExternalStatus) return;
|
|
if (!open || !sessionId || fetchedSessionId === sessionId) return;
|
|
|
|
const client = getWebSocketClient();
|
|
if (!client) return;
|
|
|
|
client
|
|
.request<RemoteStatusData>(
|
|
"task.session.status",
|
|
{ task_id: taskId, session_id: sessionId },
|
|
10000,
|
|
)
|
|
.then((res) => setFetchedStatus(res))
|
|
.catch(() => {})
|
|
.finally(() => setFetchedSessionId(sessionId));
|
|
}, [hasExternalStatus, open, fetchedSessionId, sessionId, taskId]);
|
|
|
|
const status = hasExternalStatus ? (externalStatus ?? null) : fetchedStatus;
|
|
const remoteName = status?.remote_name ?? fallbackName ?? "Remote executor";
|
|
const cloudState = getCloudState(status);
|
|
const loading = Boolean(
|
|
!hasExternalStatus && open && sessionId && fetchedSessionId !== sessionId,
|
|
);
|
|
const icon = getExecutorStatusIcon(executorType, cloudState === "error");
|
|
const Icon = icon.Icon;
|
|
|
|
return (
|
|
<Tooltip onOpenChange={setOpen}>
|
|
<TooltipTrigger asChild>
|
|
<span className="cursor-default">
|
|
<Icon
|
|
data-testid={icon.testId}
|
|
className={`${iconClassName} ${CLOUD_STATE_CLASSES[cloudState]}`}
|
|
/>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<RemoteCloudStatusContent
|
|
remoteName={remoteName}
|
|
remoteState={status?.remote_state}
|
|
createdAt={formatTimestamp(status?.remote_created_at)}
|
|
checkedAt={formatTimestamp(status?.remote_checked_at)}
|
|
remoteStatusError={status?.remote_status_error}
|
|
loading={loading}
|
|
/>
|
|
</Tooltip>
|
|
);
|
|
}
|