Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 40 additions & 8 deletions apps/web/src/components/ProviderUpdatesAction.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,6 +22,8 @@ const state = vi.hoisted(() => ({
}[],
providers: new Map<EnvironmentId, ReadonlyArray<ServerProvider>>(),
requests: [] as EnvironmentId[],
checks: [] as { environmentId: EnvironmentId; input: unknown }[],
checkResult: null as AsyncResult.AsyncResult<unknown, unknown> | null,
sessions: new Map<EnvironmentId, Atom.Writable<AsyncResult.AsyncResult<AuthSessionState>>>(),
}));

Expand All @@ -41,18 +44,23 @@ 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);
return AsyncResult.success(undefined);
useAtomCommand: ((command: string) =>
async ({ environmentId, input }: { environmentId: EnvironmentId; input: unknown }) => {
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 { useProviderUpdates } from "./ProviderUpdatesAction";
import { toastManager } from "./ui/toast";
import { useCheckProviderUpdates, useProviderUpdates } from "./ProviderUpdatesAction";

const provider: ServerProvider = {
instanceId: ProviderInstanceId.make("codex"),
Expand Down Expand Up @@ -102,6 +110,8 @@ function setup() {
state.environments.map(({ environmentId }) => [environmentId, [provider]]),
);
state.requests = [];
state.checks = [];
state.checkResult = null;
const sessions = new Map<EnvironmentId, Atom.Writable<AsyncResult.AsyncResult<AuthSessionState>>>(
[
[state.environments[0]!.environmentId, Atom.make(session(["orchestration:read"]))],
Expand All @@ -118,14 +128,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<typeof useProviderUpdates>;
let check!: ReturnType<typeof useCheckProviderUpdates>;
function Probe() {
const nextUpdates = useProviderUpdates();
const nextCheck = useCheckProviderUpdates();
useEffect(() => {
updates = nextUpdates;
}, [nextUpdates]);
check = nextCheck;
}, [nextUpdates, nextCheck]);
return null;
}
await using _screen = await render(
Expand All @@ -140,6 +153,25 @@ 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 } }]);

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"]));
Expand Down
52 changes: 51 additions & 1 deletion apps/web/src/components/ProviderUpdatesAction.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -16,6 +20,52 @@ 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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});
const inFlight = useRef<Promise<void> | null>(null);

const check = async () => {
const failures = await Promise.all(
environments
.filter(
(environment) =>
environment.connection.phase === "connected" &&
manageableIds.has(environment.environmentId),
)
.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."}`];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}),
);
const lines = failures.flat();
if (lines.length === 0) return;
toastManager.add(
stackedThreadToast({
type: "error",
title: "Could not check provider updates",
description: <span className="whitespace-pre-line">{lines.join("\n")}</span>,
}),
);
};

// A repeat click must not start a second model rediscovery while one is running.
return () => {
if (inFlight.current) return inFlight.current;
const running = check().finally(() => {
inFlight.current = null;
});
inFlight.current = running;
return running;
};
}

export function useProviderUpdates() {
const { environments } = useEnvironments();
const manageableIds = useEnvironmentsWithScope(environments, AuthProvidersManageScope);
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -247,6 +249,7 @@ function AboutVersionSection() {
}

if (typeof bridge.checkForUpdate !== "function") return;
void checkProviderUpdates();
void bridge
.checkForUpdate()
.then((result) => {
Expand Down
15 changes: 10 additions & 5 deletions apps/web/src/components/sidebar/SidebarUpdatePill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -200,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());
Expand Down Expand Up @@ -230,12 +232,13 @@ 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;
const showCheckIcon = shouldShowDesktopUpdateCheckIcon({
isAnimationLatched: isCheckAnimationLatched,
isChecking: state?.status === "checking",
isChecking: state?.status === "checking" || isCheckingProviders,
prefersReducedMotion,
});
const { iconStatus, showUpdateDetails, showUpdateIconState } = resolveSidebarUpdatePresentation({
Expand Down Expand Up @@ -325,7 +328,7 @@ function SidebarUpdateControl() {
setIsCheckAnimationLatched(true);
setCheckAnimationKey((key) => key + 1);
}
void bridge
const appCheck = bridge
.checkForUpdate()
.then((result) => {
if (result.checked) return;
Expand All @@ -348,8 +351,10 @@ function SidebarUpdateControl() {
description: error instanceof Error ? error.message : "Update check failed.",
}),
);
})
.finally(() => setIsActionPending(false));
});
setIsCheckingProviders(true);
const providerCheck = checkProviderUpdates().finally(() => setIsCheckingProviders(false));
void Promise.allSettled([appCheck, providerCheck]).then(() => setIsActionPending(false));
};

const runEverything = async () => {
Expand Down Expand Up @@ -385,7 +390,7 @@ function SidebarUpdateControl() {
const handleCheckAnimationIteration = () => {
setIsCheckAnimationLatched(
shouldContinueDesktopUpdateCheckAnimation({
isChecking: state?.status === "checking",
isChecking: state?.status === "checking" || isCheckingProviders,
prefersReducedMotion,
}),
);
Expand Down
Loading