authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-12 00:48:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 00:48:54-07:00
logd83929ad0bb0d4aec800f12c332de57e873cc7e5
treeb44bfd4db0d6b4a4826a012567806bce9ccdc691
parentf498b3514b0b82e39e66c549468b2b889d5e520d
signature Signed by PGP key B5690EEEBB952194

feat(web): one sidebar click updates every machine and provider (#18053)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

11 files changed, 917 insertions(+), 111 deletions(-)

apps/web/src/components/ProviderUpdatesAction.tsx+43-30
......@@ -15,19 +15,18 @@ import { stackedThreadToast, toastManager } from "./ui/toast";
1515import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
1616
1717/**
18 * Updates every outdated provider on every connected machine at once, then
19 * reports the results in one toast. Each server queues updates that share an
20 * installer, so sending them all together is safe. The server still checks
21 * permissions: a session that cannot operate a machine gets a failure line for
22 * it. Renders nothing when no machine has a one-click update.
18 * Every outdated provider with a one-click update on every connected machine,
19 * plus a function that updates them all and reports the results in one toast.
20 * Each server queues updates that share an installer, so sending them all
21 * together is safe. The server still checks permissions: a session that
22 * cannot operate a machine gets a failure line for it. The returned promise
23 * settles when every update has finished.
2324 */
24export function ProviderUpdatesAction() {
25export function useProviderUpdates() {
2526 const { environments } = useEnvironments();
2627 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
2728 reportFailure: false,
2829 });
29 const pending = useRef(false);
30 const [isPending, setIsPending] = useState(false);
3130 const machines = useMemo(
3231 () =>
3332 environments.flatMap((environment) => {
......@@ -44,6 +43,41 @@ export function ProviderUpdatesAction() {
4443 }),
4544 [environments],
4645 );
46
47 const updateAll = async () => {
48 const runs = await Promise.all(
49 machines.flatMap(({ environmentId, label, candidates }) =>
50 candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({
51 machineLabel: label,
52 driver: candidate.driver,
53 instanceId: candidate.instanceId,
54 result: await updateProvider({
55 environmentId,
56 input: { provider: candidate.driver, instanceId: candidate.instanceId },
57 }),
58 })),
59 ),
60 );
61 const view = getProviderUpdateRunToastView(runs);
62 if (view) {
63 toastManager.add(
64 stackedThreadToast({
65 ...view,
66 description: <span className="whitespace-pre-line">{view.description}</span>,
67 }),
68 );
69 }
70 };
71
72 return { machines, updateAll };
73}
74
75/** Provider settings button for {@link useProviderUpdates}. Renders nothing
76 when no machine has a one-click update. */
77export function ProviderUpdatesAction() {
78 const { machines, updateAll } = useProviderUpdates();
79 const pending = useRef(false);
80 const [isPending, setIsPending] = useState(false);
4781 // Candidates leave the list as soon as their servers report them queued, so
4882 // keep the button while the run is in flight.
4983 if (machines.length === 0 && !isPending) {
......@@ -55,28 +89,7 @@ export function ProviderUpdatesAction() {
5589 pending.current = true;
5690 setIsPending(true);
5791 try {
58 const runs = await Promise.all(
59 machines.flatMap(({ environmentId, label, candidates }) =>
60 candidates.map(async (candidate): Promise<ProviderUpdateRun> => ({
61 machineLabel: label,
62 driver: candidate.driver,
63 instanceId: candidate.instanceId,
64 result: await updateProvider({
65 environmentId,
66 input: { provider: candidate.driver, instanceId: candidate.instanceId },
67 }),
68 })),
69 ),
70 );
71 const view = getProviderUpdateRunToastView(runs);
72 if (view) {
73 toastManager.add(
74 stackedThreadToast({
75 ...view,
76 description: <span className="whitespace-pre-line">{view.description}</span>,
77 }),
78 );
79 }
92 await updateAll();
8093 } finally {
8194 pending.current = false;
8295 setIsPending(false);
apps/web/src/components/ServerUpdateAction.tsx+6-2
......@@ -65,7 +65,9 @@ type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size"
6565 readonly appearance?: "button" | "icon";
6666};
6767
68function useServerUpdate() {
68/** Updates one server and waits until it is back on the target version.
69 Reports its own success or failure toast; never rejects. */
70export function useServerUpdate() {
6971 const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false });
7072 return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => {
7173 const { environmentId, serverLabel, selfUpdate, targetVersion } = target;
......@@ -164,7 +166,9 @@ export function ServerUpdatesAction({
164166 );
165167}
166168
167function canUpdateServer(result: AsyncResult.AsyncResult<AuthSessionState, unknown>): boolean {
169export function canUpdateServer(
170 result: AsyncResult.AsyncResult<AuthSessionState, unknown>,
171): boolean {
168172 if (result._tag !== "Success" || !result.value.authenticated) return false;
169173 return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope);
170174}
apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx+6-2
......@@ -1,5 +1,5 @@
11import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react";
2import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react";
33import type { AnimationEventHandler } from "react";
44
55const DOWNLOAD_PROGRESS_RADIUS = 14;
......@@ -10,7 +10,10 @@ export type DesktopUpdateStatusIconState =
1010 | "checking"
1111 | "available"
1212 | "downloading"
13 | "downloaded";
13 | "downloaded"
14 // Other machines or providers have updates. Matches the server update icon
15 // in Connections settings; the download arrow stays for this app's own file.
16 | "everything";
1417
1518function normalizeDesktopUpdateDownloadPercent(percent: number | null): number {
1619 if (percent === null || !Number.isFinite(percent)) return 0;
......@@ -115,6 +118,7 @@ export function DesktopUpdateStatusIcon({
115118 return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />;
116119 }
117120 if (status === "downloaded") return <DesktopUpdateDownloadedIcon />;
121 if (status === "everything") return <CircleArrowUpIcon className="size-4" />;
118122
119123 return (
120124 <RefreshIcon
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+169-75
......@@ -1,4 +1,4 @@
1import type { DesktopUpdateState } from "@t3tools/contracts";
1import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
22import { TriangleAlertIcon } from "lucide-react";
33import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react";
44import { flushSync } from "react-dom";
......@@ -13,6 +13,7 @@ import {
1313 getArm64IntelBuildWarningDescription,
1414 getDesktopUpdateActionError,
1515 getDesktopUpdateButtonTooltip,
16 getDesktopUpdateDownloadedVersion,
1617 getDesktopUpdateInstallConfirmationMessage,
1718 isDesktopUpdateButtonDisabled,
1819 resolveDesktopUpdateButtonAction,
......@@ -26,10 +27,13 @@ import { SidebarMenuItem } from "../ui/sidebar";
2627import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
2728import {
2829 DesktopUpdateStatusIcon,
30 type DesktopUpdateStatusIconState,
2931 shouldContinueDesktopUpdateCheckAnimation,
3032 shouldShowDesktopUpdateCheckIcon,
3133} from "./DesktopUpdateStatusIcon";
3234import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";
35import { UpdateEverythingDialog } from "./UpdateEverythingDialog";
36import { useUpdateEverything } from "./useUpdateEverything";
3337
3438type SidebarUpdatePopoverChangeDetails = Parameters<
3539 NonNullable<ComponentProps<typeof Popover>["onOpenChange"]>
......@@ -63,23 +67,28 @@ export function openSidebarUpdateReleaseNotesPopoverOnForwardTab(
6367
6468function resolveSidebarUpdatePresentation({
6569 action,
70 showsEverything,
6671 isDownloading,
6772 showCheckIcon,
6873}: {
6974 readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>;
75 /** Remote work is waiting or running, and this app is not downloading. */
76 readonly showsEverything: boolean;
7077 readonly isDownloading: boolean;
7178 readonly showCheckIcon: boolean;
7279}) {
73 const showUpdateDetails = action !== "none" || isDownloading;
74 const iconStatus = showCheckIcon
80 const showUpdateDetails = action !== "none" || showsEverything || isDownloading;
81 const iconStatus: DesktopUpdateStatusIconState = showCheckIcon
7582 ? "checking"
76 : action === "install"
77 ? "downloaded"
78 : isDownloading
79 ? "downloading"
80 : action === "download"
81 ? "available"
82 : "idle";
83 : isDownloading
84 ? "downloading"
85 : showsEverything
86 ? "everything"
87 : action === "install"
88 ? "downloaded"
89 : action === "download"
90 ? "available"
91 : "idle";
8392
8493 return {
8594 iconStatus,
......@@ -108,13 +117,89 @@ function SidebarUpdateArchitectureWarningContent() {
108117 );
109118}
110119
120/** Downloads this app's update. Resolves true when it is ready to install;
121 failures toast and resolve false. */
122async function downloadLocalUpdate(
123 bridge: DesktopBridge,
124 { notifyDownloaded }: { readonly notifyDownloaded: boolean },
125): Promise<boolean> {
126 try {
127 const result = await bridge.downloadUpdate();
128 if (result.completed && notifyDownloaded) {
129 showDesktopUpdateDownloadedToast(bridge, result.state);
130 }
131 const actionError = shouldToastDesktopUpdateActionResult(result)
132 ? getDesktopUpdateActionError(result)
133 : null;
134 if (actionError) {
135 toastManager.add(
136 stackedThreadToast({
137 type: "error",
138 title: "Could not download update",
139 description: actionError,
140 }),
141 );
142 }
143 return result.completed;
144 } catch (error) {
145 toastManager.add(
146 stackedThreadToast({
147 type: "error",
148 title: "Could not start update download",
149 description: error instanceof Error ? error.message : "An unexpected error occurred.",
150 }),
151 );
152 return false;
153 }
154}
155
156/** Installs this app's downloaded update, which relaunches the app.
157 Failures toast. A refused install (the download is not marked ready yet)
158 leaves the restart to the user. */
159async function installLocalUpdate(bridge: DesktopBridge): Promise<void> {
160 try {
161 const result = await bridge.installUpdate();
162 if (!result.accepted) {
163 showDesktopUpdateDownloadedToast(bridge, result.state);
164 return;
165 }
166 const actionError = shouldToastDesktopUpdateActionResult(result)
167 ? getDesktopUpdateActionError(result)
168 : null;
169 if (!actionError) return;
170 toastManager.add(
171 stackedThreadToast({
172 type: "error",
173 title: "Could not install update",
174 description: actionError,
175 }),
176 );
177 } catch (error) {
178 toastManager.add(
179 stackedThreadToast({
180 type: "error",
181 title: "Could not install update",
182 description: error instanceof Error ? error.message : "An unexpected error occurred.",
183 }),
184 );
185 }
186}
187
188/**
189 * The sidebar update button. With only a local app update it downloads, then
190 * installs. When other machines' servers or any providers are behind, one
191 * click updates everything: providers, then servers, then this app last. On
192 * web there is no local app, so the button shows only for remote updates.
193 */
111194export function SidebarUpdatePill() {
112 return isElectron ? <SidebarUpdateControl /> : null;
195 return <SidebarUpdateControl />;
113196}
114197
115198function SidebarUpdateControl() {
116199 const state = useDesktopUpdateState();
117200 const [isActionPending, setIsActionPending] = useState(false);
201 const [isUpdatingEverything, setIsUpdatingEverything] = useState(false);
202 const [isConfirmingEverything, setIsConfirmingEverything] = useState(false);
118203 const [checkAnimationKey, setCheckAnimationKey] = useState(0);
119204 const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);
120205 const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle());
......@@ -133,6 +218,13 @@ function SidebarUpdateControl() {
133218
134219 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
135220 const isDownloading = state?.status === "downloading";
221 // Remote servers follow this app to the version it is about to install.
222 const localUpdateVersion =
223 state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null;
224 const everything = useUpdateEverything(localUpdateVersion);
225 const runsEverything = everything.hasUpdates && !isDownloading;
226 // Providers leave the list once queued, so the run keeps its own state.
227 const isRunningEverything = isUpdatingEverything && !isDownloading;
136228 const showCheckIcon = shouldShowDesktopUpdateCheckIcon({
137229 isAnimationLatched: isCheckAnimationLatched,
138230 isChecking: state?.status === "checking",
......@@ -140,21 +232,28 @@ function SidebarUpdateControl() {
140232 });
141233 const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({
142234 action,
235 showsEverything: runsEverything || isRunningEverything,
143236 isDownloading,
144237 showCheckIcon,
145238 });
146 const tooltip = showUpdateDetails
147 ? state
148 ? getDesktopUpdateButtonTooltip(state)
149 : "Update available"
150 : showCheckIcon
151 ? "Checking for updates…"
152 : "Check for updates";
239 const tooltip = isRunningEverything
240 ? "Updating…"
241 : runsEverything
242 ? everything.summary
243 : showUpdateDetails
244 ? state
245 ? getDesktopUpdateButtonTooltip(state)
246 : "Update available"
247 : showCheckIcon
248 ? "Checking for updates…"
249 : "Check for updates";
153250 const disabled = showCheckIcon
154251 ? true
155 : showUpdateDetails
156 ? isDesktopUpdateButtonDisabled(state)
157 : !canCheckForUpdate(state);
252 : runsEverything
253 ? false
254 : showUpdateDetails
255 ? isDesktopUpdateButtonDisabled(state)
256 : !canCheckForUpdate(state);
158257 const isInteractionDisabled = disabled || isActionPending;
159258 const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover(
160259 showUpdateDetails,
......@@ -175,39 +274,20 @@ function SidebarUpdateControl() {
175274
176275 const handleAction = useCallback(async () => {
177276 const bridge = window.desktopBridge;
178 if (!bridge || !state) return;
179277 if (isInteractionDisabled) return;
180278
279 if (runsEverything) {
280 setIsConfirmingEverything(true);
281 return;
282 }
283
284 if (!bridge || !state) return;
181285 setIsActionPending(true);
182286
183287 if (action === "download") {
184 void bridge
185 .downloadUpdate()
186 .then((result) => {
187 if (result.completed) {
188 showDesktopUpdateDownloadedToast(bridge, result.state);
189 }
190 if (!shouldToastDesktopUpdateActionResult(result)) return;
191 const actionError = getDesktopUpdateActionError(result);
192 if (!actionError) return;
193 toastManager.add(
194 stackedThreadToast({
195 type: "error",
196 title: "Could not download update",
197 description: actionError,
198 }),
199 );
200 })
201 .catch((error) => {
202 toastManager.add(
203 stackedThreadToast({
204 type: "error",
205 title: "Could not start update download",
206 description: error instanceof Error ? error.message : "An unexpected error occurred.",
207 }),
208 );
209 })
210 .finally(() => setIsActionPending(false));
288 void downloadLocalUpdate(bridge, { notifyDownloaded: true }).finally(() =>
289 setIsActionPending(false),
290 );
211291 return;
212292 }
213293
......@@ -232,30 +312,7 @@ function SidebarUpdateControl() {
232312 setIsActionPending(false);
233313 return;
234314 }
235 void bridge
236 .installUpdate()
237 .then((result) => {
238 if (!shouldToastDesktopUpdateActionResult(result)) return;
239 const actionError = getDesktopUpdateActionError(result);
240 if (!actionError) return;
241 toastManager.add(
242 stackedThreadToast({
243 type: "error",
244 title: "Could not install update",
245 description: actionError,
246 }),
247 );
248 })
249 .catch((error) => {
250 toastManager.add(
251 stackedThreadToast({
252 type: "error",
253 title: "Could not install update",
254 description: error instanceof Error ? error.message : "An unexpected error occurred.",
255 }),
256 );
257 })
258 .finally(() => setIsActionPending(false));
315 void installLocalUpdate(bridge).finally(() => setIsActionPending(false));
259316 return;
260317 }
261318
......@@ -286,7 +343,33 @@ function SidebarUpdateControl() {
286343 );
287344 })
288345 .finally(() => setIsActionPending(false));
289 }, [action, isInteractionDisabled, prefersReducedMotion, state]);
346 }, [action, isInteractionDisabled, prefersReducedMotion, runsEverything, state]);
347
348 const runEverything = useCallback(async () => {
349 const bridge = window.desktopBridge;
350 setIsConfirmingEverything(false);
351 setIsActionPending(true);
352 setIsUpdatingEverything(true);
353 try {
354 await everything.run({
355 ...(bridge && action === "download"
356 ? { downloadLocal: () => downloadLocalUpdate(bridge, { notifyDownloaded: false }) }
357 : {}),
358 ...(bridge && action !== "none" ? { installLocal: () => installLocalUpdate(bridge) } : {}),
359 });
360 } catch (error) {
361 toastManager.add(
362 stackedThreadToast({
363 type: "error",
364 title: "Could not update everything",
365 description: error instanceof Error ? error.message : "An unexpected error occurred.",
366 }),
367 );
368 } finally {
369 setIsUpdatingEverything(false);
370 setIsActionPending(false);
371 }
372 }, [action, everything]);
290373
291374 const handleCheckAnimationIteration = useCallback(() => {
292375 setIsCheckAnimationLatched(
......@@ -297,6 +380,9 @@ function SidebarUpdateControl() {
297380 );
298381 }, [prefersReducedMotion, state?.status]);
299382
383 // Web has no local app to check, so it only shows the button for remote work.
384 if (!isElectron && !runsEverything && !isUpdatingEverything) return null;
385
300386 const updateButton = (
301387 <button
302388 type="button"
......@@ -314,7 +400,7 @@ function SidebarUpdateControl() {
314400 "text-(--sidebar-icon-color)",
315401 !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
316402 ),
317 disabled && !showUpdateIconState && "opacity-60",
403 ((disabled && !showUpdateIconState) || isRunningEverything) && "opacity-60",
318404 )}
319405 onClick={handleAction}
320406 onBlur={() => {
......@@ -414,6 +500,14 @@ function SidebarUpdateControl() {
414500 </PopoverPopup>
415501 ) : null}
416502 </Popover>
503 <UpdateEverythingDialog
504 open={isConfirmingEverything}
505 onOpenChange={setIsConfirmingEverything}
506 summary={everything.summary}
507 lines={everything.lines}
508 restarts={everything.restarts}
509 onConfirm={() => void runEverything()}
510 />
417511 </SidebarMenuItem>
418512 );
419513}
apps/web/src/components/sidebar/UpdateEverythingDialog.tsx created+117
......@@ -0,0 +1,117 @@
1import { useAtomValue } from "@effect/atom-react";
2import { EnvironmentId } from "@t3tools/contracts";
3import { Atom } from "effect/reactivity";
4
5import { environmentThreadShells } from "~/state/threads";
6import {
7 AlertDialog,
8 AlertDialogClose,
9 AlertDialogDescription,
10 AlertDialogFooter,
11 AlertDialogHeader,
12 AlertDialogPopup,
13 AlertDialogTitle,
14} from "../ui/alert-dialog";
15import { Button } from "../ui/button";
16import type { RestartingMachine } from "./updateEverything.logic";
17
18/** A thread an agent is working on now. A restart stops these; queued threads wait. */
19const isRunning = (status: string) =>
20 status === "preparing" || status === "starting" || status === "running";
21
22/** Running threads across the environments in a newline-joined key. */
23const runningThreadCountAtom = Atom.family((environmentIds: string) =>
24 Atom.make((get) =>
25 environmentIds
26 .split("\n")
27 .filter((id) => id !== "")
28 .reduce(
29 (count, id) =>
30 count +
31 get(environmentThreadShells.environmentThreadsAtom(EnvironmentId.make(id))).filter(
32 (thread) => isRunning(thread.status),
33 ).length,
34 0,
35 ),
36 ).pipe(Atom.withLabel(`update-everything-running-threads:${environmentIds}`)),
37);
38
39interface UpdateEverythingDialogProps {
40 readonly open: boolean;
41 readonly onOpenChange: (open: boolean) => void;
42 readonly summary: string;
43 readonly lines: ReadonlyArray<string>;
44 readonly restarts: ReadonlyArray<RestartingMachine>;
45 readonly onConfirm: () => void;
46}
47
48/**
49 * Confirms an "update everything" run. It counts the running threads that the
50 * restarts stop, and how many can resume afterwards because "Continue threads
51 * after restarts" is on for their server. Recovery still skips some, such as a
52 * thread waiting on the user, so the text says "can resume", not "will".
53 */
54export function UpdateEverythingDialog({
55 open,
56 onOpenChange,
57 ...content
58}: UpdateEverythingDialogProps) {
59 return (
60 <AlertDialog open={open} onOpenChange={onOpenChange}>
61 <AlertDialogPopup>
62 {/* The popup unmounts when closed, so the thread count is read only while open. */}
63 <UpdateEverythingDialogContent {...content} />
64 </AlertDialogPopup>
65 </AlertDialog>
66 );
67}
68
69function UpdateEverythingDialogContent({
70 summary,
71 lines,
72 restarts,
73 onConfirm,
74}: Omit<UpdateEverythingDialogProps, "open" | "onOpenChange">) {
75 const stopped = useAtomValue(
76 runningThreadCountAtom(
77 restarts
78 .filter((machine) => !machine.continuesThreads)
79 .map((machine) => machine.environmentId)
80 .join("\n"),
81 ),
82 );
83 const continuing = useAtomValue(
84 runningThreadCountAtom(
85 restarts
86 .filter((machine) => machine.continuesThreads)
87 .map((machine) => machine.environmentId)
88 .join("\n"),
89 ),
90 );
91 const running = stopped + continuing;
92 const threads = `${running} running ${running === 1 ? "thread" : "threads"}`;
93
94 return (
95 <>
96 <AlertDialogHeader>
97 <AlertDialogTitle>{summary}?</AlertDialogTitle>
98 <AlertDialogDescription className="whitespace-pre-line">
99 {lines.join("\n")}
100 </AlertDialogDescription>
101 {running > 0 ? (
102 <p className="pt-2 text-sm">
103 {stopped === 0
104 ? `${threads} will be interrupted and can resume after the restart.`
105 : continuing === 0
106 ? `${threads} will be interrupted.`
107 : `${threads} will be interrupted. ${continuing} can resume after the restart.`}
108 </p>
109 ) : null}
110 </AlertDialogHeader>
111 <AlertDialogFooter>
112 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
113 <Button onClick={onConfirm}>Update</Button>
114 </AlertDialogFooter>
115 </>
116 );
117}
apps/web/src/components/sidebar/updateEverything.logic.test.ts created+206
......@@ -0,0 +1,206 @@
1import { EnvironmentId, type ServerSelfUpdateCapability } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 collectRestartingMachines,
6 collectServerUpdateTargets,
7 describeUpdateEverything,
8 runUpdateEverything,
9 type UpdateEverythingMachine,
10} from "./updateEverything.logic";
11
12function machine(
13 id: string,
14 serverVersion: string,
15 selfUpdate: ServerSelfUpdateCapability | undefined,
16 overrides: Partial<UpdateEverythingMachine> = {},
17): UpdateEverythingMachine {
18 return {
19 environmentId: EnvironmentId.make(id),
20 label: id,
21 serverConfig: {
22 environment: {
23 environmentId: EnvironmentId.make(id),
24 label: id,
25 platform: { os: "linux", arch: "x64" },
26 serverVersion,
27 capabilities: {
28 repositoryIdentity: true,
29 ...(selfUpdate ? { serverSelfUpdate: selfUpdate } : {}),
30 ...(selfUpdate === "desktop-managed" ? { desktopAppUpdate: true } : {}),
31 },
32 },
33 settings: { continueThreadsAfterServerUpdate: false },
34 },
35 connected: true,
36 hostedByThisApp: false,
37 canMaintain: true,
38 updateState: { status: "idle" },
39 ...overrides,
40 };
41}
42
43describe("collectServerUpdateTargets", () => {
44 it("updates only remote servers that can move to the target on their channel", () => {
45 const targets = collectServerUpdateTargets(
46 [
47 machine("this-app", "0.0.40", "desktop-managed", { hostedByThisApp: true }),
48 machine("service", "0.0.40", "boot-service"),
49 machine("nightly-service", "0.0.49-nightly.20261001.3", "boot-service"),
50 machine("other-desktop", "0.0.49-nightly.20261001.3", "desktop-managed"),
51 machine("offline", "0.0.40", "boot-service", { connected: false }),
52 machine("no-scope", "0.0.40", "boot-service", { canMaintain: false }),
53 machine("current", "0.0.50", "boot-service"),
54 machine("manual", "0.0.40", undefined),
55 ],
56 "0.0.50",
57 );
58
59 expect(targets.map((target) => [target.serverLabel, target.targetVersion])).toEqual([
60 ["service", "0.0.50"],
61 ["other-desktop", "0.0.50"],
62 ]);
63 });
64});
65
66describe("collectRestartingMachines", () => {
67 it("lists updated servers, and this app's servers only when it installs", () => {
68 const withContinuation = (
69 id: string,
70 overrides: Partial<UpdateEverythingMachine> = {},
71 enabled = false,
72 ) => {
73 const base = machine(id, "0.0.40", "boot-service", overrides);
74 return {
75 ...base,
76 serverConfig: {
77 environment: {
78 ...base.serverConfig!.environment,
79 capabilities: {
80 ...base.serverConfig!.environment.capabilities,
81 threadRestartContinuation: true,
82 },
83 },
84 settings: { continueThreadsAfterServerUpdate: enabled },
85 },
86 };
87 };
88 const machines = [
89 withContinuation("this-app", { hostedByThisApp: true }, true),
90 withContinuation("alvin", {}, true),
91 withContinuation("opted-out", {}, false),
92 machine("old", "0.0.40", "boot-service"),
93 withContinuation("providers-only"),
94 ];
95 const servers = ["alvin", "opted-out", "old"].map((id) => ({
96 environmentId: EnvironmentId.make(id),
97 }));
98
99 expect(collectRestartingMachines(machines, servers, true)).toEqual([
100 { environmentId: "this-app", continuesThreads: true },
101 { environmentId: "alvin", continuesThreads: true },
102 { environmentId: "opted-out", continuesThreads: false },
103 { environmentId: "old", continuesThreads: false },
104 ]);
105 expect(
106 collectRestartingMachines(machines, servers, false).map((entry) => entry.environmentId),
107 ).toEqual(["alvin", "opted-out", "old"]);
108 });
109});
110
111describe("runUpdateEverything", () => {
112 it("restarts servers after providers finish and installs this app last", async () => {
113 const calls: string[] = [];
114 let finishProviders = () => {};
115 const run = runUpdateEverything({
116 downloadLocal: async () => {
117 calls.push("download");
118 return true;
119 },
120 updateProviders: () =>
121 new Promise<void>((resolve) => {
122 calls.push("providers");
123 finishProviders = resolve;
124 }),
125 updateServers: async () => {
126 calls.push("servers");
127 },
128 installLocal: async () => {
129 calls.push("install");
130 },
131 });
132
133 await new Promise((resolve) => setTimeout(resolve, 0));
134 expect(calls).toEqual(["download", "providers"]);
135 finishProviders();
136 await run;
137 expect(calls).toEqual(["download", "providers", "servers", "install"]);
138 });
139
140 it("still updates remote machines when the local download fails", async () => {
141 const calls: string[] = [];
142 await runUpdateEverything({
143 downloadLocal: async () => false,
144 updateProviders: async () => {
145 calls.push("providers");
146 },
147 updateServers: async () => {
148 calls.push("servers");
149 },
150 installLocal: async () => {
151 calls.push("install");
152 },
153 });
154
155 expect(calls).toEqual(["providers", "servers"]);
156 });
157});
158
159describe("describeUpdateEverything", () => {
160 it("summarizes the run and lists each machine once", () => {
161 const alvin = EnvironmentId.make("alvin");
162 expect(
163 describeUpdateEverything({
164 localVersion: "0.0.50",
165 servers: [
166 {
167 environmentId: alvin,
168 serverLabel: "alvin",
169 selfUpdate: "boot-service",
170 targetVersion: "0.0.50",
171 },
172 {
173 environmentId: EnvironmentId.make("studio"),
174 serverLabel: "studio",
175 selfUpdate: "desktop-managed",
176 targetVersion: "0.0.50",
177 },
178 ],
179 providerMachines: [
180 { environmentId: alvin, label: "alvin", providers: ["Codex", "Claude"] },
181 { environmentId: EnvironmentId.make("box"), label: "box", providers: ["Cursor"] },
182 ],
183 }),
184 ).toEqual({
185 summary: "Update this app, 2 servers, and 3 providers",
186 lines: [
187 "This app: 0.0.50, restarts last",
188 "alvin: server to 0.0.50, Codex, Claude",
189 "studio: desktop app update",
190 "box: Cursor",
191 ],
192 });
193 });
194
195 it("reads naturally with one kind of update", () => {
196 expect(
197 describeUpdateEverything({
198 localVersion: null,
199 servers: [],
200 providerMachines: [
201 { environmentId: EnvironmentId.make("alvin"), label: "alvin", providers: ["Codex"] },
202 ],
203 }).summary,
204 ).toBe("Update 1 provider");
205 });
206});
apps/web/src/components/sidebar/updateEverything.logic.ts created+212
......@@ -0,0 +1,212 @@
1import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
2import type { EnvironmentId, ServerConfig } from "@t3tools/contracts";
3import { cliReleaseChannelOf } from "@t3tools/shared/cliRelease";
4
5import {
6 resolveServerSelfUpdateCapability,
7 resolveVersionMismatch,
8 supportsDesktopAppUpdate,
9 supportsServerUpdateThreadContinuation,
10} from "~/versionSkew";
11import type { ServerUpdateTarget } from "../ServerUpdateAction";
12
13export interface UpdateEverythingMachine {
14 readonly environmentId: EnvironmentId;
15 readonly label: string;
16 readonly serverConfig: {
17 readonly environment: ServerConfig["environment"];
18 readonly settings: Pick<ServerConfig["settings"], "continueThreadsAfterServerUpdate">;
19 } | null;
20 readonly connected: boolean;
21 /** Served by the desktop app this client runs in (the primary backend or a
22 local WSL backend). Its server updates when the app installs. */
23 readonly hostedByThisApp: boolean;
24 /** The session holds the scope a server update needs. */
25 readonly canMaintain: boolean;
26 readonly updateState: ServerUpdateState;
27}
28
29/** A server that restarts during a run. */
30export interface RestartingMachine {
31 readonly environmentId: EnvironmentId;
32 /** The server resumes interrupted threads after it restarts. */
33 readonly continuesThreads: boolean;
34}
35
36/**
37 * The servers a run restarts: each server it updates, and the servers this app
38 * hosts when this app installs. Provider updates restart nothing.
39 */
40export function collectRestartingMachines(
41 machines: ReadonlyArray<UpdateEverythingMachine>,
42 servers: ReadonlyArray<Pick<ServerUpdateTarget, "environmentId">>,
43 installsLocal: boolean,
44): RestartingMachine[] {
45 const updated = new Set(servers.map((server) => server.environmentId));
46 return machines.flatMap((machine) => {
47 const config = machine.serverConfig;
48 if (
49 config === null ||
50 !machine.connected ||
51 !(updated.has(machine.environmentId) || (installsLocal && machine.hostedByThisApp))
52 ) {
53 return [];
54 }
55 return [
56 {
57 environmentId: machine.environmentId,
58 continuesThreads:
59 config.environment.capabilities.threadRestartContinuation === true &&
60 config.settings.continueThreadsAfterServerUpdate === true,
61 },
62 ];
63 });
64}
65
66/**
67 * The servers that "update everything" moves to `targetVersion`: connected,
68 * permitted, able to update over RPC, and behind the target. Two kinds stay
69 * out. Servers this app hosts would relaunch the app mid-run, and the app's
70 * own install updates them anyway. A service on another release channel
71 * stays there; a desktop-managed server follows its own app's update feed, so
72 * the channel rule does not apply to it.
73 */
74export function collectServerUpdateTargets(
75 machines: ReadonlyArray<UpdateEverythingMachine>,
76 targetVersion: string,
77): ServerUpdateTarget[] {
78 return machines.flatMap((machine) => {
79 const config = machine.serverConfig;
80 if (
81 config === null ||
82 !machine.connected ||
83 machine.hostedByThisApp ||
84 !machine.canMaintain ||
85 machine.updateState.status === "running"
86 ) {
87 return [];
88 }
89 const selfUpdate = resolveServerSelfUpdateCapability(config);
90 const desktopAppUpdate = supportsDesktopAppUpdate(config);
91 if (selfUpdate === null || (selfUpdate === "desktop-managed" && !desktopAppUpdate)) {
92 return [];
93 }
94 const serverVersion = config.environment.serverVersion;
95 if (resolveVersionMismatch(serverVersion, targetVersion) === null) return [];
96 if (
97 selfUpdate !== "desktop-managed" &&
98 cliReleaseChannelOf(serverVersion) !== cliReleaseChannelOf(targetVersion)
99 ) {
100 return [];
101 }
102 return [
103 {
104 environmentId: machine.environmentId,
105 serverLabel: machine.label,
106 selfUpdate,
107 installation: config.environment.capabilities.serverInstallation,
108 desktopAppUpdate,
109 threadContinuation: supportsServerUpdateThreadContinuation(config),
110 continueThreadsAfterServerUpdate: config.settings.continueThreadsAfterServerUpdate ?? false,
111 targetVersion,
112 },
113 ];
114 });
115}
116
117export interface UpdateEverythingSteps {
118 /** Downloads this app's update. Resolves true when it is ready to install. */
119 readonly downloadLocal?: () => Promise<boolean>;
120 readonly updateProviders: () => Promise<void>;
121 readonly updateServers: () => Promise<void>;
122 /** Installs this app's update. The relaunch ends the run. */
123 readonly installLocal?: () => Promise<void>;
124}
125
126/**
127 * Runs one "update everything" pass. Providers
128 * finish before any server restarts, so no install is cut off. The local download runs alongside the
129 * remote work. This app installs last because its relaunch ends the run; a
130 * failed download skips only that install. Steps report their own results.
131 */
132export async function runUpdateEverything(steps: UpdateEverythingSteps): Promise<void> {
133 const [localReady] = await Promise.all([
134 steps.downloadLocal?.() ?? Promise.resolve(true),
135 steps.updateProviders().then(steps.updateServers),
136 ]);
137 if (localReady) await steps.installLocal?.();
138}
139
140export interface UpdateEverythingPlan {
141 /** The version this desktop app installs, or null when it is not updating. */
142 readonly localVersion: string | null;
143 readonly servers: ReadonlyArray<
144 Pick<ServerUpdateTarget, "environmentId" | "serverLabel" | "selfUpdate" | "targetVersion">
145 >;
146 readonly providerMachines: ReadonlyArray<{
147 readonly environmentId: EnvironmentId;
148 readonly label: string;
149 /** Display names of the outdated providers on that machine. */
150 readonly providers: ReadonlyArray<string>;
151 }>;
152}
153
154function countLabel(count: number, noun: string): string | null {
155 return count === 0 ? null : `${count} ${noun}${count === 1 ? "" : "s"}`;
156}
157
158function joinList(parts: ReadonlyArray<string>): string {
159 if (parts.length <= 2) return parts.join(" and ");
160 return `${parts.slice(0, -1).join(", ")}, and ${parts.at(-1)}`;
161}
162
163/**
164 * Text for the sidebar tooltip and the confirmation. `summary` reads like
165 * "Update this app, 2 servers, and 3 providers". `lines` has one entry per
166 * machine, this app first, such as "alvin: server to 0.0.50, Codex, Claude".
167 */
168export function describeUpdateEverything(plan: UpdateEverythingPlan): {
169 readonly summary: string;
170 readonly lines: ReadonlyArray<string>;
171} {
172 const providerCount = plan.providerMachines.reduce(
173 (count, machine) => count + machine.providers.length,
174 0,
175 );
176 const summary = `Update ${joinList(
177 [
178 plan.localVersion ? "this app" : null,
179 countLabel(plan.servers.length, "server"),
180 countLabel(providerCount, "provider"),
181 ].filter((part) => part !== null),
182 )}`;
183
184 const machines = new Map<EnvironmentId, { label: string; items: string[] }>();
185 const itemsFor = (environmentId: EnvironmentId, label: string) => {
186 const existing = machines.get(environmentId);
187 if (existing) return existing.items;
188 const items: string[] = [];
189 machines.set(environmentId, { label, items });
190 return items;
191 };
192 for (const server of plan.servers) {
193 // A desktop-managed server installs whatever its own app's update feed
194 // offers, so it has no target version to show.
195 itemsFor(server.environmentId, server.serverLabel).push(
196 server.selfUpdate === "desktop-managed"
197 ? "desktop app update"
198 : `server to ${server.targetVersion}`,
199 );
200 }
201 for (const machine of plan.providerMachines) {
202 itemsFor(machine.environmentId, machine.label).push(...machine.providers);
203 }
204
205 return {
206 summary,
207 lines: [
208 ...(plan.localVersion ? [`This app: ${plan.localVersion}, restarts last`] : []),
209 ...Array.from(machines.values(), ({ label, items }) => `${label}: ${items.join(", ")}`),
210 ],
211 };
212}
apps/web/src/components/sidebar/useUpdateEverything.ts created+94
......@@ -0,0 +1,94 @@
1import { useAtomValue } from "@effect/atom-react";
2import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
3import { Atom } from "effect/reactivity";
4import { useMemo } from "react";
5
6import { APP_VERSION } from "~/branding";
7import { isDesktopLocalConnectionTarget } from "~/connection/desktopLocal";
8import { isElectron } from "~/env";
9import { useEnvironments } from "~/state/environments";
10import { useNpmReleasePublished } from "~/state/npmRelease";
11import { serverEnvironment } from "~/state/server";
12import { environmentSession } from "~/state/session";
13import { useProviderUpdates } from "../ProviderUpdatesAction";
14import { canUpdateServer, useServerUpdate } from "../ServerUpdateAction";
15import {
16 collectRestartingMachines,
17 collectServerUpdateTargets,
18 describeUpdateEverything,
19 runUpdateEverything,
20} from "./updateEverything.logic";
21
22/**
23 * The remote half of the sidebar update button: outdated providers on every
24 * connected machine, and servers behind the version this client runs or is
25 * about to install (`localUpdateVersion`). Servers that install from npm appear
26 * only once npm confirms that version. `run` performs the whole pass, including
27 * this app's own download and install when given.
28 */
29export function useUpdateEverything(localUpdateVersion: string | null) {
30 const targetVersion = localUpdateVersion ?? APP_VERSION;
31 const { environments } = useEnvironments();
32 const machinesAtom = useMemo(
33 () =>
34 Atom.make((get) =>
35 environments.map((environment) => ({
36 environmentId: environment.environmentId,
37 label: environment.label,
38 serverConfig: environment.serverConfig,
39 connected: environment.connection.phase === "connected",
40 hostedByThisApp:
41 isElectron &&
42 (environment.entry.target._tag === "PrimaryConnectionTarget" ||
43 isDesktopLocalConnectionTarget(environment.entry.target)),
44 canMaintain: canUpdateServer(
45 get(environmentSession.sessionStateAtom(environment.environmentId)),
46 ),
47 updateState: get(serverEnvironment.updateStateAtom(environment.environmentId)),
48 })),
49 ),
50 [environments],
51 );
52 const machines = useAtomValue(machinesAtom);
53 const behindServers = collectServerUpdateTargets(machines, targetVersion);
54 // A desktop-managed server installs from its own app's update feed, so only
55 // servers that download t3 from npm wait for npm to publish the version.
56 const needsNpm = behindServers.some((server) => server.selfUpdate !== "desktop-managed");
57 const published = useNpmReleasePublished(needsNpm ? targetVersion : null);
58 const servers = published
59 ? behindServers
60 : behindServers.filter((server) => server.selfUpdate === "desktop-managed");
61 const restarts = collectRestartingMachines(machines, servers, localUpdateVersion !== null);
62 const providers = useProviderUpdates();
63 const updateServer = useServerUpdate();
64
65 const { summary, lines } = describeUpdateEverything({
66 localVersion: localUpdateVersion,
67 servers,
68 providerMachines: providers.machines.map((machine) => ({
69 environmentId: machine.environmentId,
70 label: machine.label,
71 providers: machine.candidates.map(
72 (candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver,
73 ),
74 })),
75 });
76
77 return {
78 hasUpdates: servers.length > 0 || providers.machines.length > 0,
79 /** Short label for the tooltip and the confirmation title. */
80 summary,
81 /** One line per machine, for the confirmation. */
82 lines,
83 /** The servers this run restarts, which stops their running threads. */
84 restarts,
85 run: (local: { downloadLocal?: () => Promise<boolean>; installLocal?: () => Promise<void> }) =>
86 runUpdateEverything({
87 ...local,
88 updateProviders: providers.updateAll,
89 updateServers: async () => {
90 await Promise.all(servers.map((server) => updateServer(server)));
91 },
92 }),
93 };
94}
apps/web/src/state/npmRelease.ts created+41
......@@ -0,0 +1,41 @@
1import { useAtomValue } from "@effect/atom-react";
2import * as Effect from "effect/Effect";
3import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http";
4import { AsyncResult, Atom } from "effect/reactivity";
5
6/**
7 * Whether `t3@<version>` is published on npm. A 404 is false; network and
8 * registry errors stay unknown. Both re-check every few minutes, so a release
9 * that lands later shows up without a reload.
10 */
11const npmReleasePublishedAtom = Atom.family((version: string) =>
12 Atom.make(
13 Effect.gen(function* () {
14 const response = yield* HttpClient.get(
15 `https://registry.npmjs.org/t3/${encodeURIComponent(version)}`,
16 );
17 if (response.status === 404) return false;
18 const body = yield* HttpClientResponse.filterStatusOk(response).pipe(
19 Effect.flatMap((ok) => ok.json),
20 );
21 return typeof body === "object" && body !== null && "version" in body
22 ? body.version === version
23 : false;
24 }).pipe(Effect.provide(FetchHttpClient.layer)),
25 ).pipe(
26 Atom.withRefresh("5 minutes"),
27 Atom.keepAlive,
28 Atom.withLabel(`npm-release-published:${version}`),
29 ),
30);
31
32const NO_VERSION_ATOM = Atom.make(AsyncResult.success(false)).pipe(
33 Atom.withLabel("npm-release-published:none"),
34);
35
36/** True only once npm confirms the version, so callers can hide work that
37 would download it until then. */
38export function useNpmReleasePublished(version: string | null): boolean {
39 const result = useAtomValue(version ? npmReleasePublishedAtom(version) : NO_VERSION_ATOM);
40 return AsyncResult.getOrElse(result, () => false);
41}
apps/web/src/versionSkew.ts+4-2
......@@ -49,7 +49,8 @@ function versionCore(version: string): string {
4949
5050/**
5151 * The skew a user can act on: the connected server runs an older T3 Code than
52 * this client, so the server is the side that needs updating.
52 * this client (or than `clientVersion`, the version this client is about to
53 * update to), so the server is the side that needs updating.
5354 *
5455 * Two nightly builds compare their full versions, including the date and run.
5556 * Other combinations compare their core `major.minor.patch` only, so a stable
......@@ -59,8 +60,9 @@ function versionCore(version: string): string {
5960 */
6061export function resolveVersionMismatch(
6162 serverVersion: string | null | undefined,
63 clientVersion: string = APP_VERSION,
6264): VersionMismatch | null {
63 const normalizedClientVersion = normalizeVersion(APP_VERSION);
65 const normalizedClientVersion = normalizeVersion(clientVersion);
6466 const normalizedServerVersion = normalizeVersion(serverVersion);
6567 if (!normalizedClientVersion || !normalizedServerVersion) {
6668 return null;
docs/user/updating.md+19
......@@ -5,6 +5,25 @@ When a server is behind your web or desktop app, an update notice appears in the
55conversation and **Settings → Connections**. Update the machine named in that
66notice.
77
8## Update everything at once
9
10The update button at the bottom of the sidebar updates every connected machine
11in one step. It updates outdated providers, then servers that are behind, then
12the desktop app you are using, which restarts last. Servers move to the
13version your desktop app is installing, or to your web app's version. A
14background service stays on its current release channel. A remote desktop app
15installs the latest release from its own channel. Hover the button to see what
16it will update.
17
18Server updates appear only after that version is published to npm. Remote
19desktop apps do not wait for npm. Servers that need
20a manual update are not included. Use the notice or **Settings → Connections**
21for those.
22
23The confirmation counts the running threads that the restarts will interrupt,
24and how many can resume afterwards. See **Continue threads after restarts**
25below.
26
827## Before you update
928
1029Server updates restart the connection and can interrupt active agents and