diff --git a/apps/web/src/provider-skip-banner.tsx b/apps/web/src/provider-skip-banner.tsx index 3f2e330bb..5ab47801c 100644 --- a/apps/web/src/provider-skip-banner.tsx +++ b/apps/web/src/provider-skip-banner.tsx @@ -1,14 +1,32 @@ import { NoUsableModelBanner } from "@/chat"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect } from "react"; -import { useNavigate } from "./navigation"; -import { useProviderSkipped } from "./provider-skip"; +import { useBench } from "./bench-context"; +import { useNavigate, useSessionUser } from "./navigation"; +import { clearProviderSkipWhenUsable, useProviderSkipped } from "./provider-skip"; +import { tenantKeys } from "./query-client"; +import { getResolvedCatalog } from "./settings/inference"; export const PROVIDER_SETTINGS_PATH = "/settings/credentials"; -/** Shown wherever a model is required, until a provider is connected. */ +/** Shown wherever a model is required, until a usable model exists. */ export function ProviderSkipBanner() { const navigate = useNavigate(); + const user = useSessionUser(); + const { selectedTenantId } = useBench(); const skipped = useProviderSkipped(); + const catalog = useQuery({ + queryKey: tenantKeys.resolvedCatalog(selectedTenantId ?? "none"), + queryFn: () => getResolvedCatalog(selectedTenantId ?? ""), + refetchOnMount: "always", + enabled: skipped && selectedTenantId !== null, + }); + useEffect(() => { + if (user !== undefined && catalog.data !== undefined) { + clearProviderSkipWhenUsable(user.id, catalog.data); + } + }, [user, catalog.data]); if (!skipped) return null; return navigate(PROVIDER_SETTINGS_PATH)} />; } diff --git a/apps/web/src/provider-skip.test.ts b/apps/web/src/provider-skip.test.ts new file mode 100644 index 000000000..85728c572 --- /dev/null +++ b/apps/web/src/provider-skip.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { ModelInfo } from "@intx/types"; + +import { + clearProviderSkipWhenUsable, + isProviderSkipped, + setProviderSkipped, +} from "./provider-skip"; + +const store = new Map(); + +function model(offerings: number): ModelInfo { + return { offerings: Array.from({ length: offerings }, () => ({})) } as unknown as ModelInfo; +} + +beforeEach(() => { + store.clear(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + }, + }); +}); + +afterEach(() => { + Reflect.deleteProperty(globalThis, "localStorage"); +}); + +describe("clearProviderSkipWhenUsable", () => { + test("clears the skip flag once a model has an offering", () => { + setProviderSkipped("u1", true); + clearProviderSkipWhenUsable("u1", [model(1)]); + expect(isProviderSkipped("u1")).toBe(false); + }); + + test("keeps the skip flag while no model has an offering", () => { + setProviderSkipped("u1", true); + clearProviderSkipWhenUsable("u1", [model(0)]); + expect(isProviderSkipped("u1")).toBe(true); + }); +}); diff --git a/apps/web/src/provider-skip.ts b/apps/web/src/provider-skip.ts index a27e5a254..cae446ad7 100644 --- a/apps/web/src/provider-skip.ts +++ b/apps/web/src/provider-skip.ts @@ -5,6 +5,7 @@ import { reportError } from "@corbits/error-sink"; import { useSyncExternalStore } from "react"; import { useSessionUser } from "./navigation"; +import { hasUsableModel, type ModelInfo } from "./settings/inference"; const listeners = new Set<() => void>(); @@ -31,6 +32,10 @@ export function setProviderSkipped(userId: string, skipped: boolean): void { for (const listener of listeners) listener(); } +export function clearProviderSkipWhenUsable(userId: string, models: readonly ModelInfo[]): void { + if (isProviderSkipped(userId) && hasUsableModel(models)) setProviderSkipped(userId, false); +} + function subscribe(listener: () => void): () => void { listeners.add(listener); return () => listeners.delete(listener); diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index 00debd023..76293ccf4 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -69,6 +69,7 @@ export const tenantKeys = { routineRuns: (tenantId: string, routineId: string) => ["tenant", tenantId, "routines", routineId, "runs"] as const, routineRunHistories: (tenantId: string) => ["tenant", tenantId, "routine-run-histories"] as const, + resolvedCatalog: (tenantId: string) => ["tenant", tenantId, "resolved-catalog"] as const, definitions: (tenantId: string) => ["tenant", tenantId, "definitions"] as const, agentDirectory: (tenantId: string) => ["tenant", tenantId, "agents", "directory"] as const, // The deploy roster (deployments -> runs -> assets join): every surface diff --git a/apps/web/src/settings/credentials-section.tsx b/apps/web/src/settings/credentials-section.tsx index c32dd4355..7b349b663 100644 --- a/apps/web/src/settings/credentials-section.tsx +++ b/apps/web/src/settings/credentials-section.tsx @@ -143,6 +143,7 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n void queryClient.invalidateQueries({ queryKey: ["tenant", tenantId, "settings-models"], }); + void queryClient.invalidateQueries({ queryKey: tenantKeys.resolvedCatalog(tenantId) }); } // Re-signing in files the tokens under the same credential name, so the