1import { useAtomValue } from "@effect/atom-react";
2import {
3 AuthEnvironmentMaintainScope,
4 type AuthSessionState,
5 sessionGrantsScope,
6} from "@t3tools/contracts";
7import type { AsyncResult } from "effect/reactivity";
8import { environmentSession } from "~/state/session";
9import type {
10 EnvironmentId,
11 ServerInstallation,
12 ServerSelfUpdateCapability,
13} from "@t3tools/contracts";
14import type { ServerUpdateStage, ServerUpdateState } from "@t3tools/client-runtime/state/server";
15import {
16 isAtomCommandInterrupted,
17 squashAtomCommandFailure,
18} from "@t3tools/client-runtime/state/runtime";
19import { CircleArrowUpIcon } from "lucide-react";
20import { type ComponentProps, useRef, useState } from "react";
21
22import { requestConfirmDialog } from "~/confirmDialog";
23import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
24import { useEnvironmentSettings } from "~/hooks/useSettings";
25import { serverEnvironment, updateOutdatedServer } from "~/state/server";
26import { appAtomRegistry } from "~/rpc/atomRegistry";
27import { useAtomCommand } from "~/state/use-atom-command";
28import { manualServerUpdateCommand } from "~/versionSkew";
29import { Button } from "./ui/button";
30import { toastManager } from "./ui/toast";
31import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
32
33// The wire "installing" stage is a sub-second launcher handoff, so the UI
34// folds it into the download phase; everything after the handoff is the
35// restart the user is actually waiting through.
36const UPDATE_STAGE_LABELS: Record<ServerUpdateStage, string> = {
37 downloading: "Downloading…",
38 installing: "Downloading…",
39 resuming: "Restarting…",
40};
41const pendingUpdateEnvironmentIds = new Set<EnvironmentId>();
42
43export function serverUpdateStageLabel(stage: ServerUpdateStage): string {
44 return UPDATE_STAGE_LABELS[stage];
45}
46
47function updateFailureMessage(error: unknown): string {
48 return error instanceof Error ? error.message : "Server update failed.";
49}
50
51export interface ServerUpdateTarget {
52 readonly environmentId: EnvironmentId;
53 readonly serverLabel: string;
54 readonly selfUpdate: ServerSelfUpdateCapability | null;
55 readonly installation?: ServerInstallation | undefined;
56 readonly desktopAppUpdate?: boolean;
57 readonly threadContinuation?: boolean;
58 readonly targetVersion: string;
59 readonly continueThreadsAfterServerUpdate?: boolean;
60}
61
62type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size" | "className"> & {
63 readonly label?: string;
64 /** "icon" renders a compact icon button with the label in a tooltip. */
65 readonly appearance?: "button" | "icon";
66};
67
68/** What {@link useServerUpdate} did. "skipped" covers a missing permission, an
69 update already in flight, and an interrupted request. */
70export type ServerUpdateOutcome =
71 | { readonly status: "updated" | "skipped" }
72 | { readonly status: "failed"; readonly message: string };
73
74/** Updates one server and waits until it is back on the target version.
75 Toasts its success or failure unless `quiet`; never rejects. */
76export function useServerUpdate() {
77 const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false });
78 return async (
79 target: ServerUpdateTarget,
80 { quiet = false }: { readonly quiet?: boolean } = {},
81 ): Promise<ServerUpdateOutcome> => {
82 const { environmentId, serverLabel, selfUpdate, targetVersion } = target;
83 if (
84 !canUpdateServer(appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId))) ||
85 pendingUpdateEnvironmentIds.has(environmentId)
86 )
87 return { status: "skipped" };
88 pendingUpdateEnvironmentIds.add(environmentId);
89 try {
90 const result = await updateServer({
91 environmentId,
92 input: {
93 targetVersion,
94 ...(target.threadContinuation && target.continueThreadsAfterServerUpdate
95 ? { continueRunningThreads: true }
96 : {}),
97 },
98 });
99 if (result._tag === "Failure") {
100 if (isAtomCommandInterrupted(result)) return { status: "skipped" };
101 throw squashAtomCommandFailure(result);
102 }
103 if (!quiet) {
104 toastManager.add({
105 type: "success",
106 title: `${serverLabel} updated`,
107 description:
108 selfUpdate === "desktop-managed"
109 ? `Desktop app relaunched on ${result.value.targetVersion}.`
110 : `Reconnected on t3@${result.value.targetVersion}.`,
111 });
112 }
113 return { status: "updated" };
114 } catch (error) {
115 const message = updateFailureMessage(error);
116 if (!quiet) {
117 toastManager.add({
118 type: "error",
119 title: `${serverLabel} update failed`,
120 description: message,
121 });
122 }
123 return { status: "failed", message };
124 } finally {
125 pendingUpdateEnvironmentIds.delete(environmentId);
126 }
127 };
128}
129
130/** Updates eligible machines independently; manual paths remain in the machine list. */
131export function ServerUpdatesAction({
132 targets,
133 label = "Update all",
134 variant = "outline",
135 size = "xs",
136 className,
137}: UpdateButtonProps & {
138 readonly targets: ReadonlyArray<ServerUpdateTarget>;
139}) {
140 const update = useServerUpdate();
141 const pending = useRef(false);
142 const [isPending, setIsPending] = useState(false);
143 const eligible = targets.filter(
144 (target) =>
145 target.selfUpdate !== null &&
146 (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate),
147 );
148 const handleUpdate = async () => {
149 if (pending.current) return;
150 pending.current = true;
151 setIsPending(true);
152 try {
153 const available = eligible.filter(
154 (target) => !pendingUpdateEnvironmentIds.has(target.environmentId),
155 );
156 const desktopTargets = available.filter((target) => target.selfUpdate === "desktop-managed");
157 if (desktopTargets.length > 0) {
158 const confirmed =
159 (await requestConfirmDialog(
160 `Update The Knife desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}? They will close and relaunch on those machines.`,
161 )) ?? true;
162 if (!confirmed) return;
163 }
164 await Promise.all(available.map((target) => update(target)));
165 } finally {
166 pending.current = false;
167 setIsPending(false);
168 }
169 };
170 return (
171 <Button
172 size={size}
173 variant={variant}
174 className={className}
175 disabled={isPending || eligible.length === 0}
176 onClick={() => void handleUpdate()}
177 >
178 {label}
179 </Button>
180 );
181}
182
183export function canUpdateServer(
184 result: AsyncResult.AsyncResult<AuthSessionState, unknown>,
185): boolean {
186 if (result._tag !== "Success" || !result.value.authenticated) return false;
187 return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope);
188}
189
190/**
191 * One-row status for an in-flight server update: "Downloading…" then
192 * "Restarting…". The update is a wait, not a warning: a single static dot
193 * and label, no step rail, no versions. Failure turns the row red with the
194 * rollback reason.
195 */
196export function ServerUpdateProgress({
197 state,
198}: {
199 readonly state: Exclude<ServerUpdateState, { status: "idle" }>;
200}) {
201 if (state.status === "failed") {
202 return (
203 <div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-destructive" role="alert">
204 <span className="size-1.5 shrink-0 rounded-full bg-destructive" aria-hidden="true" />
205 <Tooltip>
206 <TooltipTrigger render={<span className="min-w-0 truncate">{state.message}</span>} />
207 <TooltipPopup side="top">{state.message}</TooltipPopup>
208 </Tooltip>
209 </div>
210 );
211 }
212 return (
213 <div className="mt-1 flex items-center gap-2 text-xs font-medium text-foreground">
214 <span className="size-1.5 shrink-0 rounded-full bg-foreground" aria-hidden="true" />
215 <span>{serverUpdateStageLabel(state.stage)}</span>
216 </div>
217 );
218}
219
220/**
221 * Offers the update path advertised by a version-skewed server. Self-updates
222 * delegate their full lifecycle to client-runtime so this component can
223 * unmount during reconnect without losing operation state.
224 */
225export function ServerUpdateAction({
226 environmentId,
227 serverLabel,
228 selfUpdate,
229 installation,
230 desktopAppUpdate = false,
231 threadContinuation = false,
232 targetVersion,
233 label = "Update",
234 variant = "outline",
235 size = "xs",
236 className,
237 appearance = "button",
238}: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) {
239 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
240 const sessionStateAtom = environmentSession.sessionStateAtom(environmentId);
241 const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom));
242 const continueThreadsAfterServerUpdate = useEnvironmentSettings(
243 environmentId,
244 (settings) => settings.continueThreadsAfterServerUpdate,
245 );
246 const update = useServerUpdate();
247 const { copyToClipboard } = useCopyToClipboard<{ command: string }>({
248 target: installation?.kind === "npm-global" ? "update command" : "relaunch command",
249 onCopy: ({ command }) => {
250 toastManager.add({
251 type: "success",
252 title:
253 installation?.kind === "npm-global" ? "Update command copied" : "Relaunch command copied",
254 description:
255 installation?.kind === "npm-global"
256 ? `Run \`${command}\` on ${serverLabel}, then restart t3 with your usual options.`
257 : `Stop t3 on ${serverLabel}, then relaunch with \`${command}\` using the same subcommand and options. This does not update an installed t3 command.`,
258 });
259 },
260 onError: (error) => {
261 toastManager.add({
262 type: "error",
263 title: "Could not copy update command",
264 description: error.message,
265 });
266 },
267 });
268
269 const handleUpdate = async () => {
270 if (
271 !canUpdateServer(appAtomRegistry.get(sessionStateAtom)) ||
272 pendingUpdateEnvironmentIds.has(environmentId)
273 ) {
274 return;
275 }
276 if (isDesktopAppUpdate) {
277 // No themed host mounted (undefined) means proceed: the click itself
278 // was the request. This is the only confirmation in the flow; the
279 // remote machine installs without asking anyone there.
280 const confirmed =
281 (await requestConfirmDialog(
282 `Update The Knife desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`,
283 )) ?? true;
284 if (!confirmed) {
285 return;
286 }
287 }
288 if (!canUpdateServer(appAtomRegistry.get(sessionStateAtom))) return;
289 await update({
290 environmentId,
291 serverLabel,
292 selfUpdate,
293 desktopAppUpdate,
294 threadContinuation,
295 targetVersion,
296 continueThreadsAfterServerUpdate,
297 });
298 };
299
300 if (selfUpdate === "desktop-managed" && !desktopAppUpdate) {
301 return (
302 <span className="text-muted-foreground text-xs">
303 Update the desktop app on that machine to update this server.
304 </span>
305 );
306 }
307
308 const manualCommand =
309 selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null;
310 const actionLabel =
311 manualCommand !== null
312 ? installation?.kind === "npm-global"
313 ? "Copy update command"
314 : "Copy relaunch command"
315 : label;
316 const onClick =
317 manualCommand !== null
318 ? () => copyToClipboard(manualCommand, { command: manualCommand })
319 : () => void handleUpdate();
320
321 if (appearance === "icon") {
322 return (
323 <Tooltip>
324 <TooltipTrigger
325 render={
326 <Button
327 size="icon-xs"
328 variant="ghost-muted"
329 className={className}
330 aria-label={`${actionLabel} for ${serverLabel}`}
331 disabled={manualCommand === null && !canUpdate}
332 onClick={onClick}
333 />
334 }
335 >
336 <CircleArrowUpIcon className="size-3.5" />
337 </TooltipTrigger>
338 <TooltipPopup side="top">{actionLabel}</TooltipPopup>
339 </Tooltip>
340 );
341 }
342
343 return (
344 <Button
345 size={size}
346 variant={variant}
347 className={className}
348 disabled={manualCommand === null && !canUpdate}
349 onClick={onClick}
350 >
351 {actionLabel}
352 </Button>
353 );
354}
355
356/**
357 * Updates a host too old for this client to connect to. Its version comes
358 * from the host descriptor because the host never delivers a server config.
359 */
360export function OutdatedServerUpdateAction({
361 environmentId,
362 serverLabel,
363 fromVersion,
364 targetVersion,
365 label = "Update",
366}: {
367 readonly environmentId: EnvironmentId;
368 readonly serverLabel: string;
369 readonly fromVersion: string | undefined;
370 readonly targetVersion: string;
371 readonly label?: string;
372}) {
373 const update = useAtomCommand(updateOutdatedServer, { reportFailure: false });
374 const handleUpdate = async () => {
375 if (pendingUpdateEnvironmentIds.has(environmentId)) return;
376 pendingUpdateEnvironmentIds.add(environmentId);
377 try {
378 const result = await update({
379 environmentId,
380 input: { targetVersion },
381 ...(fromVersion === undefined ? {} : { fromVersion }),
382 });
383 if (result._tag === "Failure") {
384 if (isAtomCommandInterrupted(result)) return;
385 throw squashAtomCommandFailure(result);
386 }
387 toastManager.add({
388 type: "success",
389 title: `${serverLabel} updated`,
390 description: `Reconnected on t3@${result.value.targetVersion}.`,
391 });
392 } catch (error) {
393 toastManager.add({
394 type: "error",
395 title: `${serverLabel} update failed`,
396 description: updateFailureMessage(error),
397 });
398 } finally {
399 pendingUpdateEnvironmentIds.delete(environmentId);
400 }
401 };
402 return (
403 <Button size="xs" variant="outline" onClick={() => void handleUpdate()}>
404 {label}
405 </Button>
406 );
407}