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";...@@ -15,19 +15,18 @@ import { stackedThreadToast, toastManager } from "./ui/toast";
15import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";15import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
1616
17/**17/**
18 * Updates every outdated provider on every connected machine at once, then18 * Every outdated provider with a one-click update on every connected machine,
19 * reports the results in one toast. Each server queues updates that share an19 * plus a function that updates them all and reports the results in one toast.
20 * installer, so sending them all together is safe. The server still checks20 * Each server queues updates that share an installer, so sending them all
21 * permissions: a session that cannot operate a machine gets a failure line for21 * together is safe. The server still checks permissions: a session that
22 * it. Renders nothing when no machine has a one-click update.22 * cannot operate a machine gets a failure line for it. The returned promise
23 * settles when every update has finished.
23 */24 */
24export function ProviderUpdatesAction() {25export function useProviderUpdates() {
25 const { environments } = useEnvironments();26 const { environments } = useEnvironments();
26 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {27 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
27 reportFailure: false,28 reportFailure: false,
28 });29 });
29 const pending = useRef(false);
30 const [isPending, setIsPending] = useState(false);
31 const machines = useMemo(30 const machines = useMemo(
32 () =>31 () =>
33 environments.flatMap((environment) => {32 environments.flatMap((environment) => {
...@@ -44,6 +43,41 @@ export function ProviderUpdatesAction() {...@@ -44,6 +43,41 @@ export function ProviderUpdatesAction() {
44 }),43 }),
45 [environments],44 [environments],
46 );45 );
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);
47 // Candidates leave the list as soon as their servers report them queued, so81 // Candidates leave the list as soon as their servers report them queued, so
48 // keep the button while the run is in flight.82 // keep the button while the run is in flight.
49 if (machines.length === 0 && !isPending) {83 if (machines.length === 0 && !isPending) {
...@@ -55,28 +89,7 @@ export function ProviderUpdatesAction() {...@@ -55,28 +89,7 @@ export function ProviderUpdatesAction() {
55 pending.current = true;89 pending.current = true;
56 setIsPending(true);90 setIsPending(true);
57 try {91 try {
58 const runs = await Promise.all(92 await updateAll();
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 }
80 } finally {93 } finally {
81 pending.current = false;94 pending.current = false;
82 setIsPending(false);95 setIsPending(false);
apps/web/src/components/ServerUpdateAction.tsx+6-2
...@@ -65,7 +65,9 @@ type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size"...@@ -65,7 +65,9 @@ type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size"
65 readonly appearance?: "button" | "icon";65 readonly appearance?: "button" | "icon";
66};66};
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() {
69 const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false });71 const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false });
70 return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => {72 return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => {
71 const { environmentId, serverLabel, selfUpdate, targetVersion } = target;73 const { environmentId, serverLabel, selfUpdate, targetVersion } = target;
...@@ -164,7 +166,9 @@ export function ServerUpdatesAction({...@@ -164,7 +166,9 @@ export function ServerUpdatesAction({
164 );166 );
165}167}
166168
167function canUpdateServer(result: AsyncResult.AsyncResult<AuthSessionState, unknown>): boolean {169export function canUpdateServer(
170 result: AsyncResult.AsyncResult<AuthSessionState, unknown>,
171): boolean {
168 if (result._tag !== "Success" || !result.value.authenticated) return false;172 if (result._tag !== "Success" || !result.value.authenticated) return false;
169 return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope);173 return sessionGrantsScope(result.value, AuthEnvironmentMaintainScope);
170}174}
apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx+6-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { RefreshIcon } from "~/components/ui/refresh-icon";1import { RefreshIcon } from "~/components/ui/refresh-icon";
2import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react";2import { CheckIcon, CircleArrowUpIcon, DownloadIcon, RotateCwIcon } from "lucide-react";
3import type { AnimationEventHandler } from "react";3import type { AnimationEventHandler } from "react";
44
5const DOWNLOAD_PROGRESS_RADIUS = 14;5const DOWNLOAD_PROGRESS_RADIUS = 14;
...@@ -10,7 +10,10 @@ export type DesktopUpdateStatusIconState =...@@ -10,7 +10,10 @@ export type DesktopUpdateStatusIconState =
10 | "checking"10 | "checking"
11 | "available"11 | "available"
12 | "downloading"12 | "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
15function normalizeDesktopUpdateDownloadPercent(percent: number | null): number {18function normalizeDesktopUpdateDownloadPercent(percent: number | null): number {
16 if (percent === null || !Number.isFinite(percent)) return 0;19 if (percent === null || !Number.isFinite(percent)) return 0;
...@@ -115,6 +118,7 @@ export function DesktopUpdateStatusIcon({...@@ -115,6 +118,7 @@ export function DesktopUpdateStatusIcon({
115 return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />;118 return <DesktopUpdateDownloadingIcon percent={downloadPercent ?? null} />;
116 }119 }
117 if (status === "downloaded") return <DesktopUpdateDownloadedIcon />;120 if (status === "downloaded") return <DesktopUpdateDownloadedIcon />;
121 if (status === "everything") return <CircleArrowUpIcon className="size-4" />;
118122
119 return (123 return (
120 <RefreshIcon124 <RefreshIcon
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+169-75
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { DesktopUpdateState } from "@t3tools/contracts";1import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
2import { TriangleAlertIcon } from "lucide-react";2import { TriangleAlertIcon } from "lucide-react";
3import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react";3import { type ComponentProps, useCallback, useEffect, useId, useRef, useState } from "react";
4import { flushSync } from "react-dom";4import { flushSync } from "react-dom";
...@@ -13,6 +13,7 @@ import {...@@ -13,6 +13,7 @@ import {
13 getArm64IntelBuildWarningDescription,13 getArm64IntelBuildWarningDescription,
14 getDesktopUpdateActionError,14 getDesktopUpdateActionError,
15 getDesktopUpdateButtonTooltip,15 getDesktopUpdateButtonTooltip,
16 getDesktopUpdateDownloadedVersion,
16 getDesktopUpdateInstallConfirmationMessage,17 getDesktopUpdateInstallConfirmationMessage,
17 isDesktopUpdateButtonDisabled,18 isDesktopUpdateButtonDisabled,
18 resolveDesktopUpdateButtonAction,19 resolveDesktopUpdateButtonAction,
...@@ -26,10 +27,13 @@ import { SidebarMenuItem } from "../ui/sidebar";...@@ -26,10 +27,13 @@ import { SidebarMenuItem } from "../ui/sidebar";
26import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";27import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
27import {28import {
28 DesktopUpdateStatusIcon,29 DesktopUpdateStatusIcon,
30 type DesktopUpdateStatusIconState,
29 shouldContinueDesktopUpdateCheckAnimation,31 shouldContinueDesktopUpdateCheckAnimation,
30 shouldShowDesktopUpdateCheckIcon,32 shouldShowDesktopUpdateCheckIcon,
31} from "./DesktopUpdateStatusIcon";33} from "./DesktopUpdateStatusIcon";
32import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";34import { SidebarUpdateReleaseNotes } from "./SidebarUpdateReleaseNotes";
35import { UpdateEverythingDialog } from "./UpdateEverythingDialog";
36import { useUpdateEverything } from "./useUpdateEverything";
3337
34type SidebarUpdatePopoverChangeDetails = Parameters<38type SidebarUpdatePopoverChangeDetails = Parameters<
35 NonNullable<ComponentProps<typeof Popover>["onOpenChange"]>39 NonNullable<ComponentProps<typeof Popover>["onOpenChange"]>
...@@ -63,23 +67,28 @@ export function openSidebarUpdateReleaseNotesPopoverOnForwardTab(...@@ -63,23 +67,28 @@ export function openSidebarUpdateReleaseNotesPopoverOnForwardTab(
6367
64function resolveSidebarUpdatePresentation({68function resolveSidebarUpdatePresentation({
65 action,69 action,
70 showsEverything,
66 isDownloading,71 isDownloading,
67 showCheckIcon,72 showCheckIcon,
68}: {73}: {
69 readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>;74 readonly action: ReturnType<typeof resolveDesktopUpdateButtonAction>;
75 /** Remote work is waiting or running, and this app is not downloading. */
76 readonly showsEverything: boolean;
70 readonly isDownloading: boolean;77 readonly isDownloading: boolean;
71 readonly showCheckIcon: boolean;78 readonly showCheckIcon: boolean;
72}) {79}) {
73 const showUpdateDetails = action !== "none" || isDownloading;80 const showUpdateDetails = action !== "none" || showsEverything || isDownloading;
74 const iconStatus = showCheckIcon81 const iconStatus: DesktopUpdateStatusIconState = showCheckIcon
75 ? "checking"82 ? "checking"
76 : action === "install"83 : isDownloading
77 ? "downloaded"84 ? "downloading"
78 : isDownloading85 : showsEverything
79 ? "downloading"86 ? "everything"
80 : action === "download"87 : action === "install"
81 ? "available"88 ? "downloaded"
82 : "idle";89 : action === "download"
90 ? "available"
91 : "idle";
8392
84 return {93 return {
85 iconStatus,94 iconStatus,
...@@ -108,13 +117,89 @@ function SidebarUpdateArchitectureWarningContent() {...@@ -108,13 +117,89 @@ function SidebarUpdateArchitectureWarningContent() {
108 );117 );
109}118}
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 */
111export function SidebarUpdatePill() {194export function SidebarUpdatePill() {
112 return isElectron ? <SidebarUpdateControl /> : null;195 return <SidebarUpdateControl />;
113}196}
114197
115function SidebarUpdateControl() {198function SidebarUpdateControl() {
116 const state = useDesktopUpdateState();199 const state = useDesktopUpdateState();
117 const [isActionPending, setIsActionPending] = useState(false);200 const [isActionPending, setIsActionPending] = useState(false);
201 const [isUpdatingEverything, setIsUpdatingEverything] = useState(false);
202 const [isConfirmingEverything, setIsConfirmingEverything] = useState(false);
118 const [checkAnimationKey, setCheckAnimationKey] = useState(0);203 const [checkAnimationKey, setCheckAnimationKey] = useState(0);
119 const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);204 const [isCheckAnimationLatched, setIsCheckAnimationLatched] = useState(false);
120 const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle());205 const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle());
...@@ -133,6 +218,13 @@ function SidebarUpdateControl() {...@@ -133,6 +218,13 @@ function SidebarUpdateControl() {
133218
134 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";219 const action = state ? resolveDesktopUpdateButtonAction(state) : "none";
135 const isDownloading = state?.status === "downloading";220 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;
136 const showCheckIcon = shouldShowDesktopUpdateCheckIcon({228 const showCheckIcon = shouldShowDesktopUpdateCheckIcon({
137 isAnimationLatched: isCheckAnimationLatched,229 isAnimationLatched: isCheckAnimationLatched,
138 isChecking: state?.status === "checking",230 isChecking: state?.status === "checking",
...@@ -140,21 +232,28 @@ function SidebarUpdateControl() {...@@ -140,21 +232,28 @@ function SidebarUpdateControl() {
140 });232 });
141 const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({233 const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({
142 action,234 action,
235 showsEverything: runsEverything || isRunningEverything,
143 isDownloading,236 isDownloading,
144 showCheckIcon,237 showCheckIcon,
145 });238 });
146 const tooltip = showUpdateDetails239 const tooltip = isRunningEverything
147 ? state240 ? "Updating…"
148 ? getDesktopUpdateButtonTooltip(state)241 : runsEverything
149 : "Update available"242 ? everything.summary
150 : showCheckIcon243 : showUpdateDetails
151 ? "Checking for updates…"244 ? state
152 : "Check for updates";245 ? getDesktopUpdateButtonTooltip(state)
246 : "Update available"
247 : showCheckIcon
248 ? "Checking for updates…"
249 : "Check for updates";
153 const disabled = showCheckIcon250 const disabled = showCheckIcon
154 ? true251 ? true
155 : showUpdateDetails252 : runsEverything
156 ? isDesktopUpdateButtonDisabled(state)253 ? false
157 : !canCheckForUpdate(state);254 : showUpdateDetails
255 ? isDesktopUpdateButtonDisabled(state)
256 : !canCheckForUpdate(state);
158 const isInteractionDisabled = disabled || isActionPending;257 const isInteractionDisabled = disabled || isActionPending;
159 const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover(258 const showReleaseNotesPopover = shouldUseSidebarUpdateReleaseNotesPopover(
160 showUpdateDetails,259 showUpdateDetails,
...@@ -175,39 +274,20 @@ function SidebarUpdateControl() {...@@ -175,39 +274,20 @@ function SidebarUpdateControl() {
175274
176 const handleAction = useCallback(async () => {275 const handleAction = useCallback(async () => {
177 const bridge = window.desktopBridge;276 const bridge = window.desktopBridge;
178 if (!bridge || !state) return;
179 if (isInteractionDisabled) return;277 if (isInteractionDisabled) return;
180278
279 if (runsEverything) {
280 setIsConfirmingEverything(true);
281 return;
282 }
283
284 if (!bridge || !state) return;
181 setIsActionPending(true);285 setIsActionPending(true);
182286
183 if (action === "download") {287 if (action === "download") {
184 void bridge288 void downloadLocalUpdate(bridge, { notifyDownloaded: true }).finally(() =>
185 .downloadUpdate()289 setIsActionPending(false),
186 .then((result) => {290 );
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));
211 return;291 return;
212 }292 }
213293
...@@ -232,30 +312,7 @@ function SidebarUpdateControl() {...@@ -232,30 +312,7 @@ function SidebarUpdateControl() {
232 setIsActionPending(false);312 setIsActionPending(false);
233 return;313 return;
234 }314 }
235 void bridge315 void installLocalUpdate(bridge).finally(() => setIsActionPending(false));
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));
259 return;316 return;
260 }317 }
261318
...@@ -286,7 +343,33 @@ function SidebarUpdateControl() {...@@ -286,7 +343,33 @@ function SidebarUpdateControl() {
286 );343 );
287 })344 })
288 .finally(() => setIsActionPending(false));345 .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
291 const handleCheckAnimationIteration = useCallback(() => {374 const handleCheckAnimationIteration = useCallback(() => {
292 setIsCheckAnimationLatched(375 setIsCheckAnimationLatched(
...@@ -297,6 +380,9 @@ function SidebarUpdateControl() {...@@ -297,6 +380,9 @@ function SidebarUpdateControl() {
297 );380 );
298 }, [prefersReducedMotion, state?.status]);381 }, [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
300 const updateButton = (386 const updateButton = (
301 <button387 <button
302 type="button"388 type="button"
...@@ -314,7 +400,7 @@ function SidebarUpdateControl() {...@@ -314,7 +400,7 @@ function SidebarUpdateControl() {
314 "text-(--sidebar-icon-color)",400 "text-(--sidebar-icon-color)",
315 !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",401 !isInteractionDisabled && "hover:bg-sidebar-row-hover hover:text-sidebar-foreground",
316 ),402 ),
317 disabled && !showUpdateIconState && "opacity-60",403 ((disabled && !showUpdateIconState) || isRunningEverything) && "opacity-60",
318 )}404 )}
319 onClick={handleAction}405 onClick={handleAction}
320 onBlur={() => {406 onBlur={() => {
...@@ -414,6 +500,14 @@ function SidebarUpdateControl() {...@@ -414,6 +500,14 @@ function SidebarUpdateControl() {
414 </PopoverPopup>500 </PopoverPopup>
415 ) : null}501 ) : null}
416 </Popover>502 </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 />
417 </SidebarMenuItem>511 </SidebarMenuItem>
418 );512 );
419}513}
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 {...@@ -49,7 +49,8 @@ function versionCore(version: string): string {
4949
50/**50/**
51 * The skew a user can act on: the connected server runs an older T3 Code than51 * 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.
53 *54 *
54 * Two nightly builds compare their full versions, including the date and run.55 * Two nightly builds compare their full versions, including the date and run.
55 * Other combinations compare their core `major.minor.patch` only, so a stable56 * Other combinations compare their core `major.minor.patch` only, so a stable
...@@ -59,8 +60,9 @@ function versionCore(version: string): string {...@@ -59,8 +60,9 @@ function versionCore(version: string): string {
59 */60 */
60export function resolveVersionMismatch(61export function resolveVersionMismatch(
61 serverVersion: string | null | undefined,62 serverVersion: string | null | undefined,
63 clientVersion: string = APP_VERSION,
62): VersionMismatch | null {64): VersionMismatch | null {
63 const normalizedClientVersion = normalizeVersion(APP_VERSION);65 const normalizedClientVersion = normalizeVersion(clientVersion);
64 const normalizedServerVersion = normalizeVersion(serverVersion);66 const normalizedServerVersion = normalizeVersion(serverVersion);
65 if (!normalizedClientVersion || !normalizedServerVersion) {67 if (!normalizedClientVersion || !normalizedServerVersion) {
66 return null;68 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...@@ -5,6 +5,25 @@ When a server is behind your web or desktop app, an update notice appears in the
5conversation and **Settings → Connections**. Update the machine named in that5conversation and **Settings → Connections**. Update the machine named in that
6notice.6notice.
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
8## Before you update27## Before you update
928
10Server updates restart the connection and can interrupt active agents and29Server updates restart the connection and can interrupt active agents and