| 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,6 +21,7 @@ import { |
| 21 | getProviderUpdateInitialToastView, | 21 | getProviderUpdateInitialToastView, |
| 22 | getProviderUpdateProgressToastView, | 22 | getProviderUpdateProgressToastView, |
| 23 | getProviderUpdateRejectedToastView, | 23 | getProviderUpdateRejectedToastView, |
| 24 | getProviderUpdateRunToastView, | ||
| 24 | getProviderUpdateSidebarPillView, | 25 | getProviderUpdateSidebarPillView, |
| 25 | hasOneClickUpdateProviderCandidate, | 26 | hasOneClickUpdateProviderCandidate, |
| 26 | isProviderUpdateCandidate, | 27 | isProviderUpdateCandidate, |
| ... | @@ -1058,3 +1059,90 @@ it("does not offer incompatible latest versions and restores suggestions after p | ... | @@ -1058,3 +1059,90 @@ it("does not offer incompatible latest versions and restores suggestions after p |
| 1058 | expect(isProviderSettingsUpdateCandidate(snapshot)).toBe(expected); | 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,6 +8,7 @@ import { |
| 8 | type ServerProvider, | 8 | type ServerProvider, |
| 9 | } from "@t3tools/contracts"; | 9 | } from "@t3tools/contracts"; |
| 10 | import { | 10 | import { |
| 11 | isAtomCommandInterrupted, | ||
| 11 | squashAtomCommandFailure, | 12 | squashAtomCommandFailure, |
| 12 | type AtomCommandResult, | 13 | type AtomCommandResult, |
| 13 | } from "@t3tools/client-runtime/state/runtime"; | 14 | } from "@t3tools/client-runtime/state/runtime"; |
| ... | @@ -330,6 +331,61 @@ export function getProviderUpdateProgressToastView(input: { | ... | @@ -330,6 +331,61 @@ export function getProviderUpdateProgressToastView(input: { |
| 330 | return getProviderUpdateRunningToastView(input.providerCount); | 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 | export function collectUpdatedProviderSnapshots(input: { | 389 | export function collectUpdatedProviderSnapshots(input: { |
| 334 | readonly results: ReadonlyArray< | 390 | readonly results: ReadonlyArray< |
| 335 | AtomCommandResult<{ readonly providers: ReadonlyArray<ServerProvider> }, unknown> | 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,6 +59,7 @@ import { |
| 59 | isProviderUpdateActive, | 59 | isProviderUpdateActive, |
| 60 | type ProviderSettingsUpdateCandidate, | 60 | type ProviderSettingsUpdateCandidate, |
| 61 | } from "../ProviderUpdateLaunchNotification.logic"; | 61 | } from "../ProviderUpdateLaunchNotification.logic"; |
| 62 | import { ProviderUpdatesAction } from "../ProviderUpdatesAction"; | ||
| 62 | import { Button } from "../ui/button"; | 63 | import { Button } from "../ui/button"; |
| 63 | import { | 64 | import { |
| 64 | Empty, | 65 | Empty, |
| ... | @@ -1065,6 +1066,7 @@ export function EnvironmentProviderSettings({ | ... | @@ -1065,6 +1066,7 @@ export function EnvironmentProviderSettings({ |
| 1065 | variant="plain" | 1066 | variant="plain" |
| 1066 | headerAction={ | 1067 | headerAction={ |
| 1067 | <div className="flex min-w-0 items-center gap-2"> | 1068 | <div className="flex min-w-0 items-center gap-2"> |
| 1069 | <ProviderUpdatesAction /> | ||
| 1068 | {readOnly ? ( | 1070 | {readOnly ? ( |
| 1069 | <span className="min-w-0 truncate text-xs text-muted-foreground"> | 1071 | <span className="min-w-0 truncate text-xs text-muted-foreground"> |
| 1070 | <ProviderLastChecked lastCheckedAt={lastCheckedAt} /> | 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,6 +56,13 @@ update can roll back to the previous version. If the update still fails: |
| 56 | 2. Check that you updated the server's machine, not only the device you are using. | 56 | 2. Check that you updated the server's machine, not only the device you are using. |
| 57 | 3. For a command-line server, stop it and relaunch the exact version shown in the notice. | 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 | ## Mobile updates | 66 | ## Mobile updates |
| 60 | 67 | ||
| 61 | To update an environment from your phone, open **Settings → Environments** and | 68 | To update an environment from your phone, open **Settings → Environments** and |