From 7b5f4a5b8a1b3fad27c33a6c4260b10ca29dfb6c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 4 Oct 2026 15:37:19 -0700 Subject: [PATCH 1/3] Add tests for clearing the provider skip flag once a model is usable --- apps/web/src/provider-skip.test.ts | 40 ++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 apps/web/src/provider-skip.test.ts diff --git a/apps/web/src/provider-skip.test.ts b/apps/web/src/provider-skip.test.ts new file mode 100644 index 000000000..0fca0ba81 --- /dev/null +++ b/apps/web/src/provider-skip.test.ts @@ -0,0 +1,40 @@ +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); + }); +}); From 1180eda9e98b29c55a8a62e062b802fcfcd7ce4a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 4 Oct 2026 15:39:12 -0700 Subject: [PATCH 2/3] provider-skip: hide the no-model banner and clear the skip flag once a model is usable (CL-9911) --- apps/web/src/provider-skip-banner.tsx | 22 +++++++++++++++++++--- apps/web/src/provider-skip.test.ts | 6 +++++- apps/web/src/provider-skip.ts | 5 +++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/apps/web/src/provider-skip-banner.tsx b/apps/web/src/provider-skip-banner.tsx index 3f2e330bb..08e6dfeb9 100644 --- a/apps/web/src/provider-skip-banner.tsx +++ b/apps/web/src/provider-skip-banner.tsx @@ -1,14 +1,30 @@ 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 { 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: ["tenant", selectedTenantId ?? "none", "resolved-catalog"] as const, + queryFn: () => getResolvedCatalog(selectedTenantId ?? ""), + 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 index 0fca0ba81..85728c572 100644 --- a/apps/web/src/provider-skip.test.ts +++ b/apps/web/src/provider-skip.test.ts @@ -1,7 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { ModelInfo } from "@intx/types"; -import { clearProviderSkipWhenUsable, isProviderSkipped, setProviderSkipped } from "./provider-skip"; +import { + clearProviderSkipWhenUsable, + isProviderSkipped, + setProviderSkipped, +} from "./provider-skip"; const store = new Map(); 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); From 6f7dd4a1513ea8d025fa2cb75d3d51c4f0c89afc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 4 Oct 2026 15:56:59 -0700 Subject: [PATCH 3/3] provider-skip: key the catalog read in tenantKeys, refetch on mount, invalidate on credential change (CL-9911) --- apps/web/src/provider-skip-banner.tsx | 4 +++- apps/web/src/query-client.ts | 1 + apps/web/src/settings/credentials-section.tsx | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/provider-skip-banner.tsx b/apps/web/src/provider-skip-banner.tsx index 08e6dfeb9..5ab47801c 100644 --- a/apps/web/src/provider-skip-banner.tsx +++ b/apps/web/src/provider-skip-banner.tsx @@ -5,6 +5,7 @@ import { useEffect } from "react"; 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"; @@ -16,8 +17,9 @@ export function ProviderSkipBanner() { const { selectedTenantId } = useBench(); const skipped = useProviderSkipped(); const catalog = useQuery({ - queryKey: ["tenant", selectedTenantId ?? "none", "resolved-catalog"] as const, + queryKey: tenantKeys.resolvedCatalog(selectedTenantId ?? "none"), queryFn: () => getResolvedCatalog(selectedTenantId ?? ""), + refetchOnMount: "always", enabled: skipped && selectedTenantId !== null, }); useEffect(() => { 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