Skip to content
Merged
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
52 changes: 44 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,29 @@ 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();
});
// A model refresh always 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({ quiet: true });
});
expect(toast).not.toHaveBeenCalled();
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
53 changes: 52 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,53 @@ import { Button } from "./ui/button";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";

// `refreshModels` always drops the server's hour-long latest-version cache. `modelCatalogOnly`
// drops it only when the model manifest changed.
export function useCheckProviderUpdates() {
const { environments } = useEnvironments();
const manageableIds = useEnvironmentsWithScope(environments, AuthProvidersManageScope);
const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, {
reportFailure: false,
});
const inFlight = useRef<Promise<void> | null>(null);

const check = async (quiet: boolean) => {
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."}`];
}),
);
const lines = failures.flat();
if (quiet || 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 ({ quiet = false }: { readonly quiet?: boolean } = {}) => {
if (inFlight.current) return inFlight.current;
const running = check(quiet).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 @@ -110,6 +110,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 @@ -162,6 +163,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 @@ -252,6 +254,7 @@ function AboutVersionSection() {
}

if (typeof bridge.checkForUpdate !== "function") return;
void checkProviderUpdates();
void bridge
.checkForUpdate()
.then((result) => {
Comment thread
t3dotgg marked this conversation as resolved.
Resolved
Expand Down
20 changes: 15 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,8 @@ function SidebarUpdateControl() {
// The desktop updates this machine, whose policy the primary environment reports.
const updatesDenial = managedUpdatesDenial(usePrimaryManagedPolicy());
const [isActionPending, setIsActionPending] = useState(false);
const [providerCheck, setProviderCheck] = useState<"button" | "dialog" | null>(null);
const isCheckingProviders = providerCheck === "button";
const [isConfirmingEverything, setIsConfirmingEverything] = useState(false);
const [checkAnimationKey, setCheckAnimationKey] = useState(0);
const [releaseNotesPopoverHandle] = useState(() => PopoverCreateHandle());
Expand Down Expand Up @@ -230,12 +233,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 @@ -283,6 +287,9 @@ function SidebarUpdateControl() {

if (runsEverything) {
setIsConfirmingEverything(true);
// Machines that still cache an older latest version add their rows while the dialog is open.
setProviderCheck("dialog");
void checkProviderUpdates({ quiet: true }).finally(() => setProviderCheck(null));
return;
}

Expand Down Expand Up @@ -325,7 +332,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 +355,10 @@ function SidebarUpdateControl() {
description: error instanceof Error ? error.message : "Update check failed.",
}),
);
})
.finally(() => setIsActionPending(false));
});
setProviderCheck("button");
const providersChecked = checkProviderUpdates().finally(() => setProviderCheck(null));
void Promise.allSettled([appCheck, providersChecked]).then(() => setIsActionPending(false));
};

const runEverything = async () => {
Expand Down Expand Up @@ -385,7 +394,7 @@ function SidebarUpdateControl() {
const handleCheckAnimationIteration = () => {
setIsCheckAnimationLatched(
shouldContinueDesktopUpdateCheckAnimation({
isChecking: state?.status === "checking",
isChecking: state?.status === "checking" || isCheckingProviders,
prefersReducedMotion,
}),
);
Expand All @@ -410,6 +419,7 @@ function SidebarUpdateControl() {
providerCount={everything.providerCount}
rows={everything.rows}
restarts={everything.restarts}
checking={providerCheck === "dialog"}
onConfirm={() => void runEverything()}
/>
);
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/components/sidebar/UpdateEverythingDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ interface UpdateEverythingDialogProps {
readonly disabledReason?: string | null;
/** The confirmed work is starting; it cannot be cancelled from here. */
readonly busy?: boolean;
/** Machines are still looking for provider updates, so more rows can appear. */
readonly checking?: boolean;
}

type UpdateEverythingDialogContentProps = Omit<
Expand Down Expand Up @@ -102,6 +104,7 @@ function UpdateEverythingDialogContent({
notes = [],
disabledReason = null,
busy = false,
checking = false,
}: UpdateEverythingDialogContentProps) {
const stopped = useAtomValue(
runningThreadCountAtom(
Expand Down Expand Up @@ -141,6 +144,9 @@ function UpdateEverythingDialogContent({
{providerCount > 0
? `${providerCount} provider ${providerCount === 1 ? "update" : "updates"}`
: null}
{checking
? `${version !== null || providerCount > 0 ? " · " : ""}Checking for more…`
: null}
</AlertDialogDescription>
</AlertDialogHeader>
<ScrollArea scrollFade>
Expand Down
Loading