From be5e45c48744835e32634e4efdeb1b3da5149364 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Sat, 7 Nov 2026 02:10:57 +0800 Subject: [PATCH 1/4] fix(web): checking for updates also checks provider versions Co-Authored-By: Claude Opus 5.5 --- .../ProviderUpdatesAction.browser.test.tsx | 25 +++++++++++++------ .../src/components/ProviderUpdatesAction.tsx | 21 ++++++++++++++++ .../components/settings/SettingsPanels.tsx | 3 +++ .../components/sidebar/SidebarUpdatePill.tsx | 3 +++ 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx index d6ca540686f0e..fda0119aab126 100644 --- a/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx @@ -21,6 +21,7 @@ const state = vi.hoisted(() => ({ }[], providers: new Map>(), requests: [] as EnvironmentId[], + checks: [] as { environmentId: EnvironmentId; input: unknown }[], sessions: new Map>>(), })); @@ -41,18 +42,19 @@ vi.mock(import("@t3tools/client-runtime/state/session"), async (original) => { }; }); vi.mock(import("~/state/server"), () => ({ - serverEnvironment: { updateProvider: {} } as never, + serverEnvironment: { updateProvider: "update", refreshProviders: "refresh" } as never, })); vi.mock(import("~/state/use-atom-command"), () => ({ - useAtomCommand: (() => - async ({ environmentId }: { environmentId: EnvironmentId }) => { - state.requests.push(environmentId); + useAtomCommand: ((command: string) => + async ({ environmentId, input }: { environmentId: EnvironmentId; input: unknown }) => { + if (command === "refresh") state.checks.push({ environmentId, input }); + else state.requests.push(environmentId); return AsyncResult.success(undefined); }) as never, })); import { appAtomRegistry } from "~/rpc/atomRegistry"; -import { useProviderUpdates } from "./ProviderUpdatesAction"; +import { useCheckProviderUpdates, useProviderUpdates } from "./ProviderUpdatesAction"; const provider: ServerProvider = { instanceId: ProviderInstanceId.make("codex"), @@ -102,6 +104,7 @@ function setup() { state.environments.map(({ environmentId }) => [environmentId, [provider]]), ); state.requests = []; + state.checks = []; const sessions = new Map>>( [ [state.environments[0]!.environmentId, Atom.make(session(["orchestration:read"]))], @@ -118,14 +121,17 @@ function setup() { return { sessions }; } -it("bulk provider updates exclude connections without a confirmed management grant", async () => { +it("provider updates and checks exclude connections without a confirmed management grant", async () => { const fixture = setup(); let updates!: ReturnType; + let check!: ReturnType; function Probe() { const nextUpdates = useProviderUpdates(); + const nextCheck = useCheckProviderUpdates(); useEffect(() => { updates = nextUpdates; - }, [nextUpdates]); + check = nextCheck; + }, [nextUpdates, nextCheck]); return null; } await using _screen = await render( @@ -140,6 +146,11 @@ it("bulk provider updates exclude connections without a confirmed management gra await updates.updateAll({ quiet: true }); }); expect(state.requests).toEqual([writableId]); + await act(async () => { + await check(); + }); + // Only a model refresh drops the server's cached latest provider versions. + expect(state.checks).toEqual([{ environmentId: writableId, input: { refreshModels: true } }]); await act(() => { appAtomRegistry.set(fixture.sessions.get(writableId)!, session(["orchestration:read"])); diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx index 57573806e45db..351d1dddf6a29 100644 --- a/apps/web/src/components/ProviderUpdatesAction.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.tsx @@ -16,6 +16,27 @@ import { Button } from "./ui/button"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +// `refreshModels` is the only refresh that drops the server's hour-long latest-version cache. +export function useCheckProviderUpdates() { + const { environments } = useEnvironments(); + const manageableIds = useEnvironmentsWithScope(environments, AuthProvidersManageScope); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + return () => + Promise.all( + environments + .filter( + (environment) => + environment.connection.phase === "connected" && + manageableIds.has(environment.environmentId), + ) + .map(({ environmentId }) => + refreshProviders({ environmentId, input: { refreshModels: true } }), + ), + ); +} + export function useProviderUpdates() { const { environments } = useEnvironments(); const manageableIds = useEnvironmentsWithScope(environments, AuthProvidersManageScope); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d408d6c8d5d94..bb4524fdd8156 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -105,6 +105,7 @@ import { StorageCleanupSections } from "./StorageSettings"; import { UpdateTrackSwitchDialog } from "./UpdateTrackSwitchDialog"; import { environmentIdsKey } from "~/state/environmentAtomList"; import { environmentSessionStatesAtom } from "~/state/session"; +import { useCheckProviderUpdates } from "../ProviderUpdatesAction"; import { canUpdateServer } from "../ServerUpdateAction"; import { useUpdateEverythingRun } from "../sidebar/updateEverythingRun"; import { awaitsTrackSwitch, channelSwitchLabel } from "../sidebar/updateEverything.logic"; @@ -157,6 +158,7 @@ function AboutVersionSection() { ); // A second run would replace the first run's progress and local download. const updateRun = useUpdateEverythingRun(); + const checkProviderUpdates = useCheckProviderUpdates(); const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge); const selectedUpdateChannel = updateState?.channel ?? "latest"; @@ -247,6 +249,7 @@ function AboutVersionSection() { } if (typeof bridge.checkForUpdate !== "function") return; + void checkProviderUpdates(); void bridge .checkForUpdate() .then((result) => { diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 6871b9149fd48..da635794879fd 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -28,6 +28,7 @@ import { shouldToastDesktopUpdateActionResult, } from "../desktopUpdate.logic"; import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; +import { useCheckProviderUpdates } from "../ProviderUpdatesAction"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Popover, PopoverCreateHandle, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { SidebarMenuItem } from "../ui/sidebar"; @@ -230,6 +231,7 @@ function SidebarUpdateControl() { const localUpdateVersion = state && action !== "none" ? getDesktopUpdateDownloadedVersion(state) : null; const everything = useUpdateEverything(localUpdateVersion); + const checkProviderUpdates = useCheckProviderUpdates(); const runsEverything = everything.hasUpdates && !isDownloading; const run = useUpdateEverythingRun(); const isRunningEverything = run !== null; @@ -325,6 +327,7 @@ function SidebarUpdateControl() { setIsCheckAnimationLatched(true); setCheckAnimationKey((key) => key + 1); } + void checkProviderUpdates(); void bridge .checkForUpdate() .then((result) => { From 82c07b08b8729b3283adb34c65be33be2500ea4f Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Sat, 7 Nov 2026 02:29:46 +0800 Subject: [PATCH 2/4] fix(web): report failed provider checks and keep the button busy until they finish Co-Authored-By: Claude Opus 5.5 --- .../src/components/ProviderUpdatesAction.tsx | 39 ++++++++++++++++--- .../components/sidebar/SidebarUpdatePill.tsx | 9 +++-- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx index 351d1dddf6a29..50b4c78fa7ca0 100644 --- a/apps/web/src/components/ProviderUpdatesAction.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.tsx @@ -1,5 +1,9 @@ import { AuthProvidersManageScope, PROVIDER_DISPLAY_NAMES } from "@t3tools/contracts"; -import { settlePromise } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + settlePromise, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { useRef, useState } from "react"; import { useEnvironmentProviders, useEnvironments } from "~/state/environments"; @@ -23,18 +27,41 @@ export function useCheckProviderUpdates() { const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); - return () => - Promise.all( + const inFlight = useRef | null>(null); + + const check = async () => { + const failures = await Promise.all( environments .filter( (environment) => environment.connection.phase === "connected" && manageableIds.has(environment.environmentId), ) - .map(({ environmentId }) => - refreshProviders({ environmentId, input: { refreshModels: true } }), - ), + .map(async ({ environmentId, label }) => { + const result = await refreshProviders({ environmentId, input: { refreshModels: true } }); + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return []; + const error = squashAtomCommandFailure(result); + return [`${label}: ${error instanceof Error ? error.message : "Refresh failed."}`]; + }), + ); + const lines = failures.flat(); + if (lines.length === 0) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not check provider updates", + description: {lines.join("\n")}, + }), ); + }; + + // A repeat click must not start a second model rediscovery while one is running. + return () => { + inFlight.current ??= check().finally(() => { + inFlight.current = null; + }); + return inFlight.current; + }; } export function useProviderUpdates() { diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index da635794879fd..28659aaf16140 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -327,8 +327,7 @@ function SidebarUpdateControl() { setIsCheckAnimationLatched(true); setCheckAnimationKey((key) => key + 1); } - void checkProviderUpdates(); - void bridge + const appCheck = bridge .checkForUpdate() .then((result) => { if (result.checked) return; @@ -351,8 +350,10 @@ function SidebarUpdateControl() { description: error instanceof Error ? error.message : "Update check failed.", }), ); - }) - .finally(() => setIsActionPending(false)); + }); + void Promise.allSettled([appCheck, checkProviderUpdates()]).then(() => + setIsActionPending(false), + ); }; const runEverything = async () => { From ae734633a95f022a47e21aa3ebb5e1207ed68ea2 Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Sat, 7 Nov 2026 02:36:11 +0800 Subject: [PATCH 3/4] fix(web): avoid ??= so React Compiler still optimizes the provider check hook Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ProviderUpdatesAction.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ProviderUpdatesAction.tsx b/apps/web/src/components/ProviderUpdatesAction.tsx index 50b4c78fa7ca0..c9de6ce6a426c 100644 --- a/apps/web/src/components/ProviderUpdatesAction.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.tsx @@ -57,10 +57,12 @@ export function useCheckProviderUpdates() { // A repeat click must not start a second model rediscovery while one is running. return () => { - inFlight.current ??= check().finally(() => { + if (inFlight.current) return inFlight.current; + const running = check().finally(() => { inFlight.current = null; }); - return inFlight.current; + inFlight.current = running; + return running; }; } From 200d74abe9cf49c470723bfd7e8e592fa07ff56a Mon Sep 17 00:00:00 2001 From: Yuruzuu Date: Sat, 7 Nov 2026 02:41:10 +0800 Subject: [PATCH 4/4] fix(web): the update button keeps spinning until provider checks finish Co-Authored-By: Claude Opus 5.5 --- .../ProviderUpdatesAction.browser.test.tsx | 27 ++++++++++++++++--- .../components/sidebar/SidebarUpdatePill.tsx | 11 ++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx index fda0119aab126..72dded8fa01ad 100644 --- a/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx +++ b/apps/web/src/components/ProviderUpdatesAction.browser.test.tsx @@ -7,6 +7,7 @@ import { ProviderInstanceId, type ServerProvider, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/reactivity"; import { useEffect } from "react"; import { expect, it, vi } from "vite-plus/test"; @@ -22,6 +23,7 @@ const state = vi.hoisted(() => ({ providers: new Map>(), requests: [] as EnvironmentId[], checks: [] as { environmentId: EnvironmentId; input: unknown }[], + checkResult: null as AsyncResult.AsyncResult | null, sessions: new Map>>(), })); @@ -47,13 +49,17 @@ vi.mock(import("~/state/server"), () => ({ vi.mock(import("~/state/use-atom-command"), () => ({ useAtomCommand: ((command: string) => async ({ environmentId, input }: { environmentId: EnvironmentId; input: unknown }) => { - if (command === "refresh") state.checks.push({ environmentId, input }); - else state.requests.push(environmentId); - return AsyncResult.success(undefined); + if (command !== "refresh") { + state.requests.push(environmentId); + return AsyncResult.success(undefined); + } + state.checks.push({ environmentId, input }); + return state.checkResult ?? AsyncResult.success(undefined); }) as never, })); import { appAtomRegistry } from "~/rpc/atomRegistry"; +import { toastManager } from "./ui/toast"; import { useCheckProviderUpdates, useProviderUpdates } from "./ProviderUpdatesAction"; const provider: ServerProvider = { @@ -105,6 +111,7 @@ function setup() { ); state.requests = []; state.checks = []; + state.checkResult = null; const sessions = new Map>>( [ [state.environments[0]!.environmentId, Atom.make(session(["orchestration:read"]))], @@ -152,6 +159,20 @@ it("provider updates and checks exclude connections without a confirmed manageme // Only a model refresh drops the server's cached latest provider versions. expect(state.checks).toEqual([{ environmentId: writableId, input: { refreshModels: true } }]); + using toast = vi.spyOn(toastManager, "add"); + state.checkResult = AsyncResult.failure(Cause.interrupt()); + await act(async () => { + await check(); + }); + expect(toast).not.toHaveBeenCalled(); + state.checkResult = AsyncResult.failure(Cause.fail(new Error("offline"))); + await act(async () => { + await check(); + }); + expect(toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Could not check provider updates" }), + ); + await act(() => { appAtomRegistry.set(fixture.sessions.get(writableId)!, session(["orchestration:read"])); }); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 28659aaf16140..60dba17ac3bed 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -201,6 +201,7 @@ function SidebarUpdateControl() { // The desktop updates this machine, whose policy the primary environment reports. const updatesDenial = managedUpdatesDenial(usePrimaryManagedPolicy()); const [isActionPending, setIsActionPending] = useState(false); + const [isCheckingProviders, setIsCheckingProviders] = useState(false); const [isConfirmingEverything, setIsConfirmingEverything] = useState(false); const [checkAnimationKey, setCheckAnimationKey] = useState(0); const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle()); @@ -237,7 +238,7 @@ function SidebarUpdateControl() { const isRunningEverything = run !== null; const showCheckIcon = shouldShowDesktopUpdateCheckIcon({ isAnimationLatched: isCheckAnimationLatched, - isChecking: state?.status === "checking", + isChecking: state?.status === "checking" || isCheckingProviders, prefersReducedMotion, }); const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({ @@ -351,9 +352,9 @@ function SidebarUpdateControl() { }), ); }); - void Promise.allSettled([appCheck, checkProviderUpdates()]).then(() => - setIsActionPending(false), - ); + setIsCheckingProviders(true); + const providerCheck = checkProviderUpdates().finally(() => setIsCheckingProviders(false)); + void Promise.allSettled([appCheck, providerCheck]).then(() => setIsActionPending(false)); }; const runEverything = async () => { @@ -389,7 +390,7 @@ function SidebarUpdateControl() { const handleCheckAnimationIteration = () => { setIsCheckAnimationLatched( shouldContinueDesktopUpdateCheckAnimation({ - isChecking: state?.status === "checking", + isChecking: state?.status === "checking" || isCheckingProviders, prefersReducedMotion, }), );