import type { EnvironmentId, ServerSelfUpdateCapability } from "@t3tools/contracts"; import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import type { ComponentProps } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useEnvironmentSettings } from "~/hooks/useSettings"; import { serverEnvironment } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; // The wire "installing" stage is a sub-second launcher handoff, so the UI // folds it into the download phase; everything after the handoff is the // restart the user is actually waiting through. const UPDATE_STAGE_LABELS: Record = { downloading: "Downloading…", installing: "Downloading…", resuming: "Restarting…", }; const pendingUpdateEnvironmentIds = new Set(); export function serverUpdateStageLabel(stage: ServerUpdateStage): string { return UPDATE_STAGE_LABELS[stage]; } function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } /** * One-row status for an in-flight server update: "Downloading…" then * "Restarting…". The update is a wait, not a warning: a single pulsing dot * and label, no step rail, no versions. Failure turns the row red with the * rollback reason. */ export function ServerUpdateProgress({ state, }: { readonly state: Exclude; }) { if (state.status === "failed") { return (
} /> {state.message}
); } return (
); } /** * Offers the update path advertised by a version-skewed server. Self-updates * delegate their full lifecycle to client-runtime so this component can * unmount during reconnect without losing operation state. */ export function ServerUpdateAction({ environmentId, serverLabel, selfUpdate, desktopAppUpdate = false, threadContinuation = false, targetVersion, label = "Update", variant = "outline", size = "xs", }: { readonly environmentId: EnvironmentId; readonly serverLabel: string; readonly selfUpdate: ServerSelfUpdateCapability | null; /** The desktop app supervising this server accepts remote update requests (capabilities.desktopAppUpdate). */ readonly desktopAppUpdate?: boolean; /** The server can durably continue running provider turns after updating. */ readonly threadContinuation?: boolean; readonly targetVersion: string; readonly label?: string; readonly variant?: ComponentProps["variant"]; readonly size?: ComponentProps["size"]; }) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; const continueThreadsAfterServerUpdate = useEnvironmentSettings( environmentId, (settings) => settings.continueThreadsAfterServerUpdate, ); const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false, }); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ target: "update command", onCopy: ({ command }) => { toastManager.add({ type: "success", title: "Update command copied", description: `Run \`${command}\` on ${serverLabel} to update it.`, }); }, onError: (error) => { toastManager.add({ type: "error", title: "Could not copy update command", description: error.message, }); }, }); const handleUpdate = async () => { if (pendingUpdateEnvironmentIds.has(environmentId)) { return; } if (isDesktopAppUpdate) { // No themed host mounted (undefined) means proceed: the click itself // was the request. This is the only confirmation in the flow; the // remote machine installs without asking anyone there. const confirmed = (await requestConfirmDialog( `Update the T3 Code desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`, )) ?? true; if (!confirmed) { return; } } if (pendingUpdateEnvironmentIds.has(environmentId)) { return; } pendingUpdateEnvironmentIds.add(environmentId); try { const result = await updateServer({ environmentId, input: { targetVersion, ...(threadContinuation && continueThreadsAfterServerUpdate ? { continueRunningThreads: true } : {}), }, }); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { return; } toastManager.add({ type: "error", title: "Server update failed", description: updateFailureMessage(squashAtomCommandFailure(result)), }); return; } toastManager.add({ type: "success", title: `${serverLabel} updated`, description: isDesktopAppUpdate ? `Desktop app relaunched on ${result.value.targetVersion}.` : `Reconnected on t3@${result.value.targetVersion}.`, }); } finally { pendingUpdateEnvironmentIds.delete(environmentId); } }; if (selfUpdate === "desktop-managed" && !desktopAppUpdate) { return ( Update the desktop app on that machine to update this server. ); } if (selfUpdate === null) { const command = manualServerUpdateCommand(targetVersion); return ( ); } return ( ); }