authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-01 17:04:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-01 17:04:27-07:00
log0a04cc50def48121b9ece038c7df8b7fa789074b
tree98a1f4528dc941d1206bd95172012c9f5186b107
parent67f640093ffea1adaa585949d87e4bc2010b0810
signature Signed by PGP key B5690EEEBB952194

feat(web): update providers on every machine with one click (#14678)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

5 files changed, 265 insertions(+), 0 deletions(-)

apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts+88
......@@ -21,6 +21,7 @@ import {
2121 getProviderUpdateInitialToastView,
2222 getProviderUpdateProgressToastView,
2323 getProviderUpdateRejectedToastView,
24 getProviderUpdateRunToastView,
2425 getProviderUpdateSidebarPillView,
2526 hasOneClickUpdateProviderCandidate,
2627 isProviderUpdateCandidate,
......@@ -1058,3 +1059,90 @@ it("does not offer incompatible latest versions and restores suggestions after p
10581059 expect(isProviderSettingsUpdateCandidate(snapshot)).toBe(expected);
10591060 }
10601061});
1062
1063describe("getProviderUpdateRunToastView", () => {
1064 const updateState = (
1065 status: "succeeded" | "failed",
1066 message: string,
1067 ): ServerProvider["updateState"] => ({
1068 status,
1069 startedAt: checkedAt,
1070 finishedAt: laterCheckedAt,
1071 message,
1072 output: null,
1073 });
1074 const run = (
1075 machineLabel: string,
1076 providerDriver: string,
1077 result: Parameters<typeof getProviderUpdateRunToastView>[0][number]["result"],
1078 ) => ({
1079 machineLabel,
1080 driver: driver(providerDriver),
1081 instanceId: instanceId(providerDriver),
1082 result,
1083 });
1084
1085 it("lists every failed update and ignores interrupted ones", () => {
1086 const view = getProviderUpdateRunToastView([
1087 run(
1088 "Mac Studio",
1089 "codex",
1090 AsyncResult.success({
1091 providers: [
1092 provider({
1093 driver: driver("codex"),
1094 updateState: updateState("succeeded", "Provider updated."),
1095 }),
1096 ],
1097 }),
1098 ),
1099 run(
1100 "Mac Studio",
1101 "claudeAgent",
1102 AsyncResult.success({
1103 providers: [
1104 provider({
1105 driver: driver("claudeAgent"),
1106 updateState: updateState("failed", "npm exited with code 1."),
1107 }),
1108 ],
1109 }),
1110 ),
1111 run("Laptop", "codex", AsyncResult.failure(Cause.die(new Error("WebSocket closed")))),
1112 run("Server", "codex", AsyncResult.failure(Cause.interrupt())),
1113 ]);
1114
1115 expect(view).toEqual({
1116 type: "error",
1117 title: "2 of 3 provider updates failed",
1118 description: "Mac Studio · Claude: npm exited with code 1.\nLaptop · Codex: WebSocket closed",
1119 });
1120 });
1121
1122 it("reports success when every update succeeded", () => {
1123 const succeeded = AsyncResult.success({
1124 providers: [
1125 provider({
1126 driver: driver("codex"),
1127 updateState: updateState("succeeded", "Provider updated."),
1128 }),
1129 ],
1130 });
1131
1132 expect(
1133 getProviderUpdateRunToastView([
1134 run("Mac Studio", "codex", succeeded),
1135 run("Laptop", "codex", succeeded),
1136 ]),
1137 ).toEqual({
1138 type: "success",
1139 title: "2 providers updated",
1140 description: "New sessions will use the updated providers.",
1141 });
1142 expect(
1143 getProviderUpdateRunToastView([
1144 run("Server", "codex", AsyncResult.failure(Cause.interrupt())),
1145 ]),
1146 ).toBeNull();
1147 });
1148});
apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts+56
......@@ -8,6 +8,7 @@ import {
88 type ServerProvider,
99} from "@t3tools/contracts";
1010import {
11 isAtomCommandInterrupted,
1112 squashAtomCommandFailure,
1213 type AtomCommandResult,
1314} from "@t3tools/client-runtime/state/runtime";
......@@ -330,6 +331,61 @@ export function getProviderUpdateProgressToastView(input: {
330331 return getProviderUpdateRunningToastView(input.providerCount);
331332}
332333
334/** One provider update sent by the cross-machine "Update all", with its result. */
335export interface ProviderUpdateRun {
336 readonly machineLabel: string;
337 readonly driver: ProviderDriverKind;
338 readonly instanceId: ProviderInstanceId;
339 readonly result: AtomCommandResult<
340 { readonly providers: ReadonlyArray<ServerProvider> },
341 unknown
342 >;
343}
344
345/**
346 * Summarize a cross-machine "Update all" as one toast, or null when every
347 * request was interrupted. Each update that did not succeed gets its own line,
348 * so a failure on one machine is not hidden by successes on the others.
349 */
350export function getProviderUpdateRunToastView(
351 runs: ReadonlyArray<ProviderUpdateRun>,
352): Pick<ProviderUpdateToastView, "type" | "title" | "description"> | null {
353 const settled = runs.filter((run) => !isAtomCommandInterrupted(run.result));
354 if (settled.length === 0) {
355 return null;
356 }
357 const failureLines = settled.flatMap((run) => {
358 const label = `${run.machineLabel} · ${PROVIDER_DISPLAY_NAMES[run.driver] ?? run.driver}`;
359 if (run.result._tag === "Failure") {
360 const error = squashAtomCommandFailure(run.result);
361 return [`${label}: ${error instanceof Error ? error.message : "Provider update failed."}`];
362 }
363 const updateState = run.result.value.providers.find(
364 (provider) => provider.instanceId === run.instanceId,
365 )?.updateState;
366 return updateState?.status === "succeeded"
367 ? []
368 : [`${label}: ${updateState?.message ?? "Provider update did not finish."}`];
369 });
370 if (failureLines.length === 0) {
371 return {
372 type: "success",
373 title: settled.length === 1 ? "Provider updated" : `${settled.length} providers updated`,
374 description: getProviderUpdatedDescription(settled.length),
375 };
376 }
377 return {
378 type: "error",
379 title:
380 failureLines.length < settled.length
381 ? `${failureLines.length} of ${settled.length} provider updates failed`
382 : settled.length === 1
383 ? "Provider update failed"
384 : "Provider updates failed",
385 description: failureLines.join("\n"),
386 };
387}
388
333389export function collectUpdatedProviderSnapshots(input: {
334390 readonly results: ReadonlyArray<
335391 AtomCommandResult<{ readonly providers: ReadonlyArray<ServerProvider> }, unknown>
apps/web/src/components/ProviderUpdatesAction.tsx created+112
......@@ -0,0 +1,112 @@
1import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts";
2import { useMemo, useRef, useState } from "react";
3
4import { useEnvironments } from "~/state/environments";
5import { serverEnvironment } from "~/state/server";
6import { useAtomCommand } from "~/state/use-atom-command";
7import {
8 canOneClickUpdateProviderCandidate,
9 collectProviderUpdateCandidates,
10 getProviderUpdateRunToastView,
11 type ProviderUpdateRun,
12} from "./ProviderUpdateLaunchNotification.logic";
13import { Button } from "./ui/button";
14import { stackedThreadToast, toastManager } from "./ui/toast";
15import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
16
17/**
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.
23 */
24export function ProviderUpdatesAction() {
25 const { environments } = useEnvironments();
26 const updateProvider = useAtomCommand(serverEnvironment.updateProvider, {
27 reportFailure: false,
28 });
29 const pending = useRef(false);
30 const [isPending, setIsPending] = useState(false);
31 const machines = useMemo(
32 () =>
33 environments.flatMap((environment) => {
34 const providers = environment.serverConfig?.providers ?? [];
35 if (environment.connection.phase !== "connected") {
36 return [];
37 }
38 const candidates = collectProviderUpdateCandidates(providers).filter((candidate) =>
39 canOneClickUpdateProviderCandidate(candidate, providers),
40 );
41 return candidates.length > 0
42 ? [{ environmentId: environment.environmentId, label: environment.label, candidates }]
43 : [];
44 }),
45 [environments],
46 );
47 // Candidates leave the list as soon as their servers report them queued, so
48 // keep the button while the run is in flight.
49 if (machines.length === 0 && !isPending) {
50 return null;
51 }
52
53 const handleUpdate = async () => {
54 if (pending.current) return;
55 pending.current = true;
56 setIsPending(true);
57 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 }
80 } finally {
81 pending.current = false;
82 setIsPending(false);
83 }
84 };
85
86 return (
87 <Tooltip>
88 <TooltipTrigger
89 render={
90 <Button
91 size="xs"
92 variant="ghost-muted"
93 disabled={isPending}
94 onClick={() => void handleUpdate()}
95 >
96 {isPending ? "Updating…" : "Update all"}
97 </Button>
98 }
99 />
100 <TooltipPopup side="top">
101 {machines.map((machine) => (
102 <div key={machine.environmentId}>
103 {machine.label}:{" "}
104 {machine.candidates
105 .map((candidate) => PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver)
106 .join(", ")}
107 </div>
108 ))}
109 </TooltipPopup>
110 </Tooltip>
111 );
112}
apps/web/src/components/settings/ProviderSettingsPanel.tsx+2
......@@ -59,6 +59,7 @@ import {
5959 isProviderUpdateActive,
6060 type ProviderSettingsUpdateCandidate,
6161} from "../ProviderUpdateLaunchNotification.logic";
62import { ProviderUpdatesAction } from "../ProviderUpdatesAction";
6263import { Button } from "../ui/button";
6364import {
6465 Empty,
......@@ -1065,6 +1066,7 @@ export function EnvironmentProviderSettings({
10651066 variant="plain"
10661067 headerAction={
10671068 <div className="flex min-w-0 items-center gap-2">
1069 <ProviderUpdatesAction />
10681070 {readOnly ? (
10691071 <span className="min-w-0 truncate text-xs text-muted-foreground">
10701072 <ProviderLastChecked lastCheckedAt={lastCheckedAt} />
docs/user/updating.md+7
......@@ -56,6 +56,13 @@ update can roll back to the previous version. If the update still fails:
56562. Check that you updated the server's machine, not only the device you are using.
57573. For a command-line server, stop it and relaunch the exact version shown in the notice.
5858
59## Update providers
60
61**Settings → Providers** shows provider updates for the selected environment.
62**Update all** updates every outdated provider on every connected environment
63at once. Hover it to see which providers it will update. Providers that only
64offer a manual update command are not included.
65
5966## Mobile updates
6067
6168To update an environment from your phone, open **Settings → Environments** and