| author | |
| committer | |
| log | 0a04cc50def48121b9ece038c7df8b7fa789074b |
| tree | 98a1f4528dc941d1206bd95172012c9f5186b107 |
| parent | 67f640093ffea1adaa585949d87e4bc2010b0810 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 21 | 21 | getProviderUpdateInitialToastView, |
| 22 | 22 | getProviderUpdateProgressToastView, |
| 23 | 23 | getProviderUpdateRejectedToastView, |
| 24 | getProviderUpdateRunToastView, | |
| 24 | 25 | getProviderUpdateSidebarPillView, |
| 25 | 26 | hasOneClickUpdateProviderCandidate, |
| 26 | 27 | isProviderUpdateCandidate, |
| ... | ... | @@ -1058,3 +1059,90 @@ it("does not offer incompatible latest versions and restores suggestions after p |
| 1058 | 1059 | expect(isProviderSettingsUpdateCandidate(snapshot)).toBe(expected); |
| 1059 | 1060 | } |
| 1060 | 1061 | }); |
| 1062 | ||
| 1063 | describe("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 { |
| 8 | 8 | type ServerProvider, |
| 9 | 9 | } from "@t3tools/contracts"; |
| 10 | 10 | import { |
| 11 | isAtomCommandInterrupted, | |
| 11 | 12 | squashAtomCommandFailure, |
| 12 | 13 | type AtomCommandResult, |
| 13 | 14 | } from "@t3tools/client-runtime/state/runtime"; |
| ... | ... | @@ -330,6 +331,61 @@ export function getProviderUpdateProgressToastView(input: { |
| 330 | 331 | return getProviderUpdateRunningToastView(input.providerCount); |
| 331 | 332 | } |
| 332 | 333 | |
| 334 | /** One provider update sent by the cross-machine "Update all", with its result. */ | |
| 335 | export 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 | */ | |
| 350 | export 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 | ||
| 333 | 389 | export function collectUpdatedProviderSnapshots(input: { |
| 334 | 390 | readonly results: ReadonlyArray< |
| 335 | 391 | AtomCommandResult<{ readonly providers: ReadonlyArray<ServerProvider> }, unknown> |
apps/web/src/components/ProviderUpdatesAction.tsx created+112| ... | ... | @@ -0,0 +1,112 @@ |
| 1 | import { PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; | |
| 2 | import { useMemo, useRef, useState } from "react"; | |
| 3 | ||
| 4 | import { useEnvironments } from "~/state/environments"; | |
| 5 | import { serverEnvironment } from "~/state/server"; | |
| 6 | import { useAtomCommand } from "~/state/use-atom-command"; | |
| 7 | import { | |
| 8 | canOneClickUpdateProviderCandidate, | |
| 9 | collectProviderUpdateCandidates, | |
| 10 | getProviderUpdateRunToastView, | |
| 11 | type ProviderUpdateRun, | |
| 12 | } from "./ProviderUpdateLaunchNotification.logic"; | |
| 13 | import { Button } from "./ui/button"; | |
| 14 | import { stackedThreadToast, toastManager } from "./ui/toast"; | |
| 15 | import { 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 | */ | |
| 24 | export 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 { |
| 59 | 59 | isProviderUpdateActive, |
| 60 | 60 | type ProviderSettingsUpdateCandidate, |
| 61 | 61 | } from "../ProviderUpdateLaunchNotification.logic"; |
| 62 | import { ProviderUpdatesAction } from "../ProviderUpdatesAction"; | |
| 62 | 63 | import { Button } from "../ui/button"; |
| 63 | 64 | import { |
| 64 | 65 | Empty, |
| ... | ... | @@ -1065,6 +1066,7 @@ export function EnvironmentProviderSettings({ |
| 1065 | 1066 | variant="plain" |
| 1066 | 1067 | headerAction={ |
| 1067 | 1068 | <div className="flex min-w-0 items-center gap-2"> |
| 1069 | <ProviderUpdatesAction /> | |
| 1068 | 1070 | {readOnly ? ( |
| 1069 | 1071 | <span className="min-w-0 truncate text-xs text-muted-foreground"> |
| 1070 | 1072 | <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: |
| 56 | 56 | 2. Check that you updated the server's machine, not only the device you are using. |
| 57 | 57 | 3. For a command-line server, stop it and relaunch the exact version shown in the notice. |
| 58 | 58 | |
| 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 | |
| 63 | at once. Hover it to see which providers it will update. Providers that only | |
| 64 | offer a manual update command are not included. | |
| 65 | ||
| 59 | 66 | ## Mobile updates |
| 60 | 67 | |
| 61 | 68 | To update an environment from your phone, open **Settings → Environments** and |