fix: stabilize model toggle and refresh Agent dropdown after provider changes (#13113)

* fix: stabilize model toggle and refresh Agent dropdown after provider changes

Two related regressions in the Model Provider management flow on
release-1.9.3:

1. Toggle bounce — rapid clicks on a model Switch flickered between on
   and off before settling. ``handleModelToggle`` performed an
   optimistic ``setQueryData`` on ``useGetEnabledModels`` but never
   cancelled in-flight refetches. With React Query defaults
   (``staleTime: 0``, ``refetchOnWindowFocus: true``), a background
   refetch could land mid-debounce and overwrite the optimistic state
   with stale server data, then the deferred mutation eventually
   corrected it. Fix: ``cancelQueries`` before each optimistic update,
   per the canonical TanStack Query optimistic-updates pattern.

2. Agent dropdown stayed stale after the provider modal closed —
   disabled models remained listed in the Agent's Language Model
   dropdown. The post-close ``isRefreshingAfterClose`` loading gate
   only waited for ``useGetModelProviders`` to settle, not
   ``useGetEnabledModels``. When the providers refetch finished first,
   the loading state cleared and ``groupedOptions`` ran against the
   still-stale enabled-models cache. Fix: track both queries'
   ``isFetching`` flags in the gate's effect.

No behavioral change to the sticky-default UX — the user's previously
selected model continues to show in the dropdown with the wrench
affordance when globally disabled, as designed.

Also narrows the pre-existing ``catch (error: any)`` patterns in the
edited hook file to ``unknown`` with a small shared ``getErrorMessage``
helper, satisfying the staged-file no-explicit-any pre-commit lint.

Tests:
- New ``useProviderConfiguration`` unit test asserts
  ``cancelQueries`` runs before ``setQueryData`` on toggle.
- Extended ``ModelInputComponent`` test covers the dual-query loading
  gate (providers settles first, enabled-models still in flight →
  loading persists; then enabled-models settles → loading clears).

* fix: re-apply pending overlay through entire mutation lifecycle

Addresses review feedback on #13113: ``cancelQueries`` only cancels
refetches that are already in flight at click time. A new
``useGetEnabledModels`` refetch can still start during the 1s
debounce window (or while the mutation is in flight) and overwrite
the optimistic cache with stale server state, producing the same
bounce we just fixed.

Two coordinated changes keep the optimistic state protected for the
entire pending-toggle window:

1. ``pendingModelToggles`` is no longer cleared upfront when the
   debounced flush sends a mutation. Instead, ``clearSentToggles``
   removes only the entries whose value still matches what we sent
   on ``onSettled`` / ``onError``. This preserves the overlay across
   the in-flight mutation window AND correctly handles the case
   where the user re-toggled the same model mid-flight (now a fresh
   intent that survives clearing).

2. A new ``useEffect`` subscribes to ``useGetEnabledModels`` and
   re-applies the pending overlay whenever the data emission drifts
   from the user's pending intent. This catches any refetch
   (window focus, mount, reconnect, stale-time expiry) that lands
   between click and ``onSettled``, regardless of when it started.

Tests:
- New ``re-applies the pending overlay when a refetch surfaces
  stale data`` test simulates a stale refetch and asserts the
  effect re-applies the optimistic overlay.
- New ``does not re-overlay when no toggles are pending`` test
  guards against spurious overlay calls on mount.

* fix: split overlay vs unsent buffers to prevent duplicate model toggle mutations

Addresses a follow-up race introduced by the previous commit: keeping
``pendingModelToggles`` populated through ``onSettled`` (so the
re-overlay effect could repel mid-flight refetches) also caused the
next flush to snapshot the in-flight entries again, sending duplicate
requests with non-deterministic success/failure ordering. The same
risk applied to ``flushPendingChanges`` on modal close.

Split the single buffer into two refs with single responsibilities:

  - ``overlayToggles``: the union of every toggle still protecting the
    UI. The re-overlay effect re-applies this whenever
    ``useGetEnabledModels`` emits new data. Drained per-key on
    ``onSettled``/``onError`` only when the overlay value still matches
    what we sent (a user re-toggle mid-flight becomes a fresh intent
    and must survive the clear).

  - ``unsentToggles``: the strict subset that has NOT been sent in a
    mutation yet, or was re-toggled since the last send. Drained
    immediately at flush time so subsequent flushes never resend an
    in-flight payload.

``handleModelToggle`` populates both buffers; flushes drain only
``unsentToggles`` and use ``overlayToggles`` for cache protection.

Two new unit tests:
- ``does not resend in-flight toggles when a new toggle is flushed``
  asserts that after toggling A then B (with A's mutation in flight),
  the second flush carries ONLY B.
- ``re-sends a model when the user re-toggles it after the previous
  flush fired`` asserts that re-toggling the same model produces a
  second mutation (the re-toggle is a fresh intent, not a duplicate).

* fix: drop sticky-default UX, treat disabled-selected model like any other

Removes the sticky-default carve-out so a globally-disabled model is
never shown in the Agent's Language Model dropdown, regardless of
whether it's the node's saved selection. Drops the Wrench/Configure
affordance — there's no per-trigger "this model isn't enabled for
your user" UX anymore; the dropdown is the single source of truth.

Changes in ``modelInputComponent``:

  - ``groupedOptions``: drop the ``isStickyNotEnabled`` filter
    bypass and the zero-provider import fallback. Disabled models
    never pass the filter, even when tagged ``not_enabled_locally``.

  - ``selectedModel``: drop the saved-value preservation branch.
    If the saved name isn't in ``flatOptions``, fall through to the
    first available option (or ``null`` if nothing is available).

  - Auto-select effect: fires whenever the saved value isn't in
    ``flatOptions``, not just when the value is empty. This
    realigns the node's stored value with the trigger so the
    rendered selection and the run-time selection don't diverge.

  - ``hasEnabledProviders``: redefined as "at least one model of
    this component's type is enabled across any provider"
    (derived from ``groupedOptions``). Routes a configured-but-
    all-disabled provider to the Setup Provider CTA — same UX as
    the never-configured case.

  - Remove the Wrench JSX, the ``showConfigureAffordance``
    derivation, and the now-orphaned ``hasProcessedEmptyRef``.

Tests:
  - ``renders the Setup Provider CTA when no models are enabled``
    (replaces the old disabled-combobox expectation).
  - ``auto-selects an available model when the saved value is
    globally disabled``.
  - ``renders the Setup Provider CTA when a provider is configured
    but all its models are disabled``.
  - ``never renders the Configure wrench affordance``.

* refactor: extract toggle queue into useModelToggleQueue hook

Addresses review I1 (file size + mixed responsibilities) and R1, R2,
R3, R4 in one pass.

I1 — Extract toggle-queue logic out of useProviderConfiguration. The
parent hook drops from 821 lines to 601 and now owns a single
responsibility (variable CRUD + provider lifecycle). The toggle queue
— overlay/unsent buffers, debounced flush, awaitable flush, optimistic
cache management, re-overlay effect — lives in a new 292-line
useModelToggleQueue.ts with one clear responsibility.

R1 — Inside the new hook, DRY the two flush variants behind shared
helpers:
  - ``buildAndConsumeToggleBatch()`` snapshots unsentToggles into the
    mutation payload, captures the pre-toggle cache for rollback, and
    drains unsentToggles atomically.
  - ``rollbackToggleBatch()`` drains overlay BEFORE restoring
    previousData (load-bearing order — otherwise the re-overlay effect
    would re-apply the stale overlay over the rollback).
The two flush callers now differ only in ``mutate`` vs awaitable
``mutateAsync`` plumbing.

R2 — Explicit loop-guard comment on the drift check inside the
re-overlay effect. Names the ``drifted`` short-circuit as the
termination condition so a future refactor that "simplifies" to an
unconditional re-apply doesn't silently introduce a render loop.

R3 — Adversarial coverage for the mutation error path:
  - ``rolls back to previousData when the toggle mutation fails``
  - ``does not re-apply the overlay after a failed mutation drains it``
    (asserts the drain-before-rollback ordering)
  - ``preserves a mid-flight re-toggle when the original mutation
    fails`` (asserts a user re-toggle survives the originating
    mutation's failure)

R4 — ``flushPendingChanges`` now invalidates the affected queries
inline on success, instead of relying on the caller's
``refreshAllModelInputs`` for the load-bearing invalidation. The
caller's refresh remains as an additive per-node template refresh.

Test infra: moved toggle-queue tests from
``useProviderConfiguration.test.tsx`` to a dedicated
``useModelToggleQueue.test.tsx`` that exercises the extracted hook
directly. Debounce mock changed from synchronous pass-through to
explicit ``runDebounced()`` so individual tests can choose whether to
exercise the debounced path or the awaitable ``flushPendingChanges``
path.

95/95 tests pass (87 prior + new R3 coverage + R4 success-path
invalidation).
This commit is contained in:
Eric Hare
2026-05-13 14:17:31 -07:00
committed by GitHub
parent 00bc383f86
commit cd4aa718e7
5 changed files with 976 additions and 383 deletions

View File

@ -171,7 +171,10 @@ const defaultProps: BaseInputProps & ModelInputComponentType = {
editNode: false,
};
// Helper to render with QueryClientProvider
// Helper to render with QueryClientProvider. Returns the raw RTL handle plus
// a ``rerenderWithProvider`` wrapper so callers can rerender without losing
// the surrounding ``QueryClientProvider`` (rerender replaces the root JSX —
// dropping the wrapper would force a remount and reset component state).
const renderWithQueryClient = (component: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
@ -179,9 +182,13 @@ const renderWithQueryClient = (component: React.ReactElement) => {
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>{component}</QueryClientProvider>,
const wrap = (node: React.ReactElement) => (
<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>
);
const result = render(wrap(component));
const rerenderWithProvider = (node: React.ReactElement) =>
result.rerender(wrap(node));
return { ...result, rerenderWithProvider };
};
describe("ModelInputComponent", () => {
@ -190,14 +197,15 @@ describe("ModelInputComponent", () => {
});
describe("Rendering", () => {
it("should render disabled combobox when no options are provided", () => {
it("renders the Setup Provider CTA when no models are enabled", () => {
renderWithQueryClient(
<ModelInputComponent {...defaultProps} options={[]} />,
);
const combobox = screen.getByRole("combobox");
expect(combobox).toBeInTheDocument();
expect(combobox).toBeDisabled();
// No combobox — the trigger swaps for the Setup Provider button when
// there's nothing to pick from.
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.getByText("Setup Provider")).toBeInTheDocument();
});
it("should render the model selector when options are available", () => {
@ -323,6 +331,80 @@ describe("ModelInputComponent", () => {
expect(screen.getByTestId("model-provider-modal")).toBeInTheDocument();
});
});
it("keeps loading state until both providers and enabled-models refetches settle", async () => {
// The post-close refresh invalidates both ``useGetModelProviders`` AND
// ``useGetEnabledModels``. The component must wait for BOTH to finish
// before clearing the loading state — otherwise ``groupedOptions``
// renders against a stale ``enabledModelsData`` cache and disabled
// models briefly leak back into the dropdown after the user closes
// the provider modal.
let providersFetching = true;
let enabledFetching = true;
const mockedProviders = useGetModelProviders as jest.MockedFunction<
typeof useGetModelProviders
>;
const mockedEnabled = useGetEnabledModels as jest.MockedFunction<
typeof useGetEnabledModels
>;
mockedProviders.mockImplementation(
() =>
({
data: mockProvidersData,
isLoading: false,
isFetching: providersFetching,
}) as unknown as ReturnType<typeof useGetModelProviders>,
);
mockedEnabled.mockImplementation(
() =>
({
data: { enabled_models: {} },
isLoading: false,
isFetching: enabledFetching,
}) as unknown as ReturnType<typeof useGetEnabledModels>,
);
const user = userEvent.setup();
const { rerenderWithProvider } = renderWithQueryClient(
<ModelInputComponent {...defaultProps} />,
);
// Open the dropdown then the provider manager dialog
const trigger = screen.getByRole("combobox");
await user.click(trigger);
await waitFor(() => {
expect(
screen.getByTestId("manage-model-providers"),
).toBeInTheDocument();
});
await user.click(screen.getByTestId("manage-model-providers"));
await waitFor(() => {
expect(screen.getByTestId("model-provider-modal")).toBeInTheDocument();
});
// Close the modal — this sets isRefreshingAfterClose=true and the
// loading button replaces the dropdown.
await user.click(screen.getByTestId("close-provider-modal"));
await waitFor(() => {
expect(screen.getByText("Loading models")).toBeInTheDocument();
});
// Providers refetch completes FIRST. With the fix, loading persists
// because the enabled-models refetch is still in flight.
providersFetching = false;
rerenderWithProvider(<ModelInputComponent {...defaultProps} />);
await new Promise((r) => setTimeout(r, 30));
expect(screen.getByText("Loading models")).toBeInTheDocument();
// Enabled-models refetch completes. Loading state clears.
enabledFetching = false;
rerenderWithProvider(<ModelInputComponent {...defaultProps} />);
await waitFor(() => {
expect(screen.queryByText("Loading models")).not.toBeInTheDocument();
});
});
});
describe("Footer Buttons", () => {
@ -602,117 +684,88 @@ describe("ModelInputComponent", () => {
expect(screen.getByRole("combobox")).not.toBeDisabled();
});
it("keeps a saved value whose model isn't enabled locally and renders the Configure wrench", async () => {
// The backend's update_model_options_in_build_config injects the saved
// value into options tagged with `not_enabled_locally: true` whenever
// it isn't in the user's enabled list. The frontend must:
// 1. NOT auto-reset the saved value.
// 2. Keep the option visible/selectable in the dropdown.
// 3. Render the Configure wrench next to the trigger.
mockedUseGetEnabledModels.mockReturnValue({
data: {
enabled_models: {
OpenAI: { "gpt-4": true, "gpt-3.5-turbo": true },
},
},
isLoading: false,
});
const handleOnNewValue = jest.fn();
const savedValue = [
{
id: "ibm/granite-3",
name: "ibm/granite-3",
icon: "IBMWatsonx",
provider: "IBM watsonx.ai",
metadata: { not_enabled_locally: true },
},
];
// Backend-style options: the saved value is injected into options with
// the sticky flag so the client can render it.
const optionsWithSticky = [
...mockOptions,
{
id: "ibm/granite-3",
name: "ibm/granite-3",
icon: "IBMWatsonx",
provider: "IBM watsonx.ai",
metadata: { not_enabled_locally: true },
},
];
renderWithQueryClient(
<ModelInputComponent
{...defaultProps}
options={optionsWithSticky}
value={savedValue}
handleOnNewValue={handleOnNewValue}
/>,
);
// Value must not be reset by the auto-select effect.
expect(handleOnNewValue).not.toHaveBeenCalled();
// Saved model remains visible in the trigger label.
await waitFor(() => {
expect(screen.getByText("ibm/granite-3")).toBeInTheDocument();
});
// Configure wrench is rendered next to the trigger.
expect(
screen.getByTestId(`${defaultProps.id}-configure`),
).toBeInTheDocument();
});
it("opens the provider manager when the Configure wrench is clicked", async () => {
it("auto-selects an available model when the saved value is globally disabled", async () => {
// The user previously chose ibm/granite-3 but that provider's models
// are no longer enabled. Some other models (gpt-4) ARE enabled, so the
// component must drop the stale selection and align the stored value
// with the first available option rather than visually showing a model
// the dropdown doesn't contain.
mockedUseGetEnabledModels.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
isLoading: false,
});
const handleOnNewValue = jest.fn();
const savedValue = [
{
id: "ibm/granite-3",
name: "ibm/granite-3",
icon: "IBMWatsonx",
provider: "IBM watsonx.ai",
metadata: { not_enabled_locally: true },
},
];
const optionsWithSticky = [
...mockOptions,
{
id: "ibm/granite-3",
name: "ibm/granite-3",
icon: "IBMWatsonx",
provider: "IBM watsonx.ai",
metadata: { not_enabled_locally: true },
metadata: {},
},
];
const handleOnNewValue = jest.fn();
const user = userEvent.setup();
renderWithQueryClient(
<ModelInputComponent
{...defaultProps}
options={optionsWithSticky}
value={savedValue}
handleOnNewValue={handleOnNewValue}
/>,
);
const wrench = await screen.findByTestId(`${defaultProps.id}-configure`);
await user.click(wrench);
// Auto-select fires because the saved name isn't in flatOptions.
await waitFor(() => {
expect(screen.getByTestId("model-provider-modal")).toBeInTheDocument();
expect(handleOnNewValue).toHaveBeenCalled();
});
// Clicking the wrench must not mutate the saved value.
expect(handleOnNewValue).not.toHaveBeenCalled();
const newValue = handleOnNewValue.mock.calls[0][0].value;
expect(newValue[0].name).toBe("gpt-4");
// The stale model name must NOT be rendered anywhere.
expect(screen.queryByText("ibm/granite-3")).not.toBeInTheDocument();
});
it("does not render Configure when the selected model isn't flagged", () => {
// Baseline: a normal enabled model must not surface the wrench.
it("renders the Setup Provider CTA when a provider is configured but all its models are disabled", () => {
// OpenAI is configured (is_configured/is_enabled true on the provider)
// but every model is explicitly disabled in enabled_models. There's
// nothing for the user to pick from, so we must show the Setup
// Provider CTA — same UX as the never-configured case.
mockedUseGetModelProviders.mockReturnValue({
data: [
{
provider: "OpenAI",
is_enabled: true,
is_configured: true,
icon: "OpenAI",
models: [
{ model_name: "gpt-4", metadata: { model_type: "llm" } },
{ model_name: "gpt-3.5-turbo", metadata: { model_type: "llm" } },
],
},
],
isLoading: false,
});
mockedUseGetEnabledModels.mockReturnValue({
data: {
enabled_models: {
OpenAI: { "gpt-4": false, "gpt-3.5-turbo": false },
},
},
isLoading: false,
});
renderWithQueryClient(
<ModelInputComponent {...defaultProps} options={[]} />,
);
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(screen.getByText("Setup Provider")).toBeInTheDocument();
});
it("never renders the Configure wrench affordance", () => {
// The sticky-default UX was removed: there's no per-trigger affordance
// for a disabled-but-saved model. The dropdown is the single source
// of truth.
mockedUseGetEnabledModels.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
isLoading: false,

View File

@ -80,10 +80,6 @@ export default function ModelInputComponent({
const { refreshAllModelInputs } = useRefreshModelInputs();
// Ref to track if we've already processed the empty options state
// prevents infinite loop when no models are available
const hasProcessedEmptyRef = useRef(false);
const postTemplateValue = usePostTemplateValue({
parameterId: "model",
nodeId: nodeId || "",
@ -113,17 +109,14 @@ export default function ModelInputComponent({
isLoading: isLoadingProviders,
isFetching: isFetchingProviders,
} = useGetModelProviders({});
const { data: enabledModelsData, isLoading: isLoadingEnabledModels } =
useGetEnabledModels();
const {
data: enabledModelsData,
isLoading: isLoadingEnabledModels,
isFetching: isFetchingEnabledModels,
} = useGetEnabledModels();
const isLoading = isLoadingProviders || isLoadingEnabledModels;
const hasEnabledProviders = useMemo(() => {
return providersData?.some(
(provider) => provider.is_enabled || provider.is_configured,
);
}, [providersData]);
// Groups models by their provider name for sectioned display in dropdown.
// Filters out models from disabled providers AND disabled models, then
// augments with any enabled models from `providersData` that weren't in the
@ -137,20 +130,12 @@ export default function ModelInputComponent({
if (option.metadata?.is_disabled_provider) continue;
const provider = option.provider || "Unknown";
// Backend sticky-default: options tagged with `not_enabled_locally` are
// the user's saved selection injected into the options list by the
// unified-models build_config helper even though they aren't in the
// enabled list. They must always pass the client-side filter so the
// selection stays visible and selectable in the dropdown.
const isStickyNotEnabled = option.metadata?.not_enabled_locally === true;
// Filter against client-side enabled models data. This is the source of
// truth for what the current user has enabled — stale `options` saved in
// an imported flow may include models from providers the current user
// hasn't enabled (e.g. WatsonX). When the provider is tracked in
// enabled_models, the model must be explicitly enabled (=== true); a
// `false` or missing entry means the model should be hidden.
if (!isStickyNotEnabled && enabledModelsData?.enabled_models) {
// Filter against client-side enabled models data. The user's
// current enabled list is the single source of truth: a model that
// isn't explicitly enabled (=== true) is hidden, even if it's the
// node's saved selection. There is no sticky-default carve-out —
// a globally-disabled model never appears in the dropdown.
if (enabledModelsData?.enabled_models) {
const providerModels = enabledModelsData.enabled_models[provider];
if (providerModels && providerModels[option.name] !== true) {
continue;
@ -216,33 +201,17 @@ export default function ModelInputComponent({
}
}
// Zero-provider import fallback: if the user has no providers configured
// locally but an imported flow carries a saved model, inject that saved
// selection as the only dropdown entry so the trigger renders the model
// (with the Configure wrench) instead of the "Setup Provider" button.
// Without this, ``flatOptions`` would be empty and ``ModelTrigger`` would
// swap the dropdown for the setup CTA, visually losing the imported
// selection.
const hasAnyGrouped = Object.keys(grouped).length > 0;
const savedValue = value?.[0];
if (!hasAnyGrouped && savedValue?.name) {
const providerName = savedValue.provider || "Unknown";
grouped[providerName] = [
{
...(savedValue.id && { id: savedValue.id }),
name: savedValue.name,
icon: savedValue.icon || "Bot",
provider: providerName,
metadata: {
...(savedValue.metadata ?? {}),
not_enabled_locally: true,
},
} as ModelOption,
];
}
return grouped;
}, [options, enabledModelsData, providersData, modelType, value]);
}, [options, enabledModelsData, providersData, modelType]);
// True iff at least one model of this component's type is enabled across
// any provider. Drives the Setup Provider CTA — a provider that is
// configured but has all its models disabled is treated the same as a
// provider that hasn't been configured yet.
const hasEnabledProviders = useMemo(
() => Object.values(groupedOptions).some((models) => models.length > 0),
[groupedOptions],
);
// Flattened array of all enabled options for efficient lookups by name
const flatOptions = useMemo(
@ -250,9 +219,12 @@ export default function ModelInputComponent({
[groupedOptions],
);
// Derive the currently selected model from the value prop
// Derive the currently selected model from the value prop. If the saved
// value isn't in the current ``flatOptions`` (typically because the
// model was globally disabled), it's treated as if no model is selected
// — the trigger renders the first available option as the visual
// selection and the effect below realigns the stored ``value`` to match.
const selectedModel = useMemo(() => {
// If we're in connection mode, show the connection option as selected
if (isConnectionMode) {
return {
name:
@ -264,56 +236,26 @@ export default function ModelInputComponent({
}
const currentName = value?.[0]?.name;
if (!currentName) {
// Logic to auto-select the first model if none is selected
// We only do this check if we have options available
if (flatOptions.length > 0 && !hasProcessedEmptyRef.current) {
// If we haven't processed empty state yet, we render the first one
return flatOptions[0];
}
return null;
}
const match = flatOptions.find((option) => option.name === currentName);
if (match) return match;
// Saved name isn't in the filtered options list — typically because the
// flow was imported via drag-drop (no backend sticky-default round-trip)
// or because an outdated component was upgraded and the fresh template
// lacks the sticky-default metadata. Preserve the saved selection in the
// trigger so it doesn't visually "snap" to the user's first enabled
// model. The wrench affordance in the dropdown handles configuration.
const saved = value?.[0];
if (saved) {
return {
...(saved.id && { id: saved.id }),
name: saved.name,
icon: saved.icon || "Bot",
provider: saved.provider || "Unknown",
metadata: {
...(saved.metadata ?? {}),
not_enabled_locally: true,
},
} as SelectedModel;
if (currentName) {
const match = flatOptions.find((option) => option.name === currentName);
if (match) return match;
}
return flatOptions.length > 0 ? flatOptions[0] : null;
}, [value, flatOptions, isConnectionMode, externalOptions]);
// Auto-select the first available option whenever the stored ``value``
// doesn't point at a currently-available model — including the case
// where the previously-selected model was globally disabled. Without
// this, the trigger would visually show one model while the node's
// saved value pointed at a different (or removed) one.
useEffect(() => {
if (flatOptions.length === 0 || isConnectionMode) return;
if (hasProcessedEmptyRef.current) return;
const isEmpty = !value || value.length === 0;
// Sticky-default: if the component has a saved value, keep it as-is. The
// backend injects any selection that isn't in the user's enabled list
// back into `options` tagged with `not_enabled_locally`, so the saved
// value remains visible and runnable. Only auto-select the first option
// when there's no saved value at all.
if (!isEmpty) return;
const savedName = value?.[0]?.name;
if (savedName && flatOptions.some((o) => o.name === savedName)) return;
const firstOption = flatOptions[0];
// Construct the new value object
const newValue = [
{
...(firstOption.id && { id: firstOption.id }),
@ -324,7 +266,6 @@ export default function ModelInputComponent({
},
];
handleOnNewValue({ value: newValue });
hasProcessedEmptyRef.current = true;
}, [flatOptions, value, handleOnNewValue, isConnectionMode]);
/**
@ -402,22 +343,27 @@ export default function ModelInputComponent({
setIsRefreshingAfterClose(true);
}, []);
// Clear the refreshing indicator after the providers query completes a full
// refetch cycle (isFetchingProviders: false → true → false). We track whether
// we've seen the fetch start so we don't clear prematurely before the
// invalidation has even been triggered by refreshAllModelInputs.
// Clear the refreshing indicator only after BOTH the providers and the
// enabled-models queries have completed a full refetch cycle (isFetching:
// false → true → false). Watching only ``isFetchingProviders`` clears the
// loading state too early when the enabled-models refetch is slower,
// letting ``groupedOptions`` render against a stale ``enabledModelsData``
// cache — disabled models would briefly leak back into the dropdown after
// the user closes the provider modal. We track whether we've seen the
// fetch start so we don't clear prematurely before the invalidation has
// even been triggered by refreshAllModelInputs.
const hasSeenFetchStartRef = useRef(false);
useEffect(() => {
if (!isRefreshingAfterClose) {
hasSeenFetchStartRef.current = false;
return;
}
if (isFetchingProviders) {
if (isFetchingProviders || isFetchingEnabledModels) {
hasSeenFetchStartRef.current = true;
} else if (hasSeenFetchStartRef.current) {
setIsRefreshingAfterClose(false);
}
}, [isRefreshingAfterClose, isFetchingProviders]);
}, [isRefreshingAfterClose, isFetchingProviders, isFetchingEnabledModels]);
// Safety timeout: clear loading even if no refetch cycle is detected
// (e.g. no model nodes on canvas, or the refresh was a no-op)
@ -521,49 +467,22 @@ export default function ModelInputComponent({
return <div className="w-full">{renderLoadingButton()}</div>;
}
// Show a small "Configure" wrench next to the trigger when the currently
// selected model was injected by the backend as a sticky default — i.e.
// the user's saved selection isn't in their current enabled_models list.
// Clicking it jumps straight to the provider manager so the user can
// enable the provider without losing their selection.
const showConfigureAffordance =
selectedModel?.metadata?.not_enabled_locally === true;
// Main render
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<div className="flex w-full items-center gap-2">
<div className="min-w-0 flex-1 truncate">
<ModelTrigger
open={open}
disabled={disabled}
options={flatOptions}
selectedModel={selectedModel}
placeholder={placeholder}
hasEnabledProviders={hasEnabledProviders ?? false}
onOpenManageProviders={() => setOpenManageProvidersDialog(true)}
id={id}
refButton={refButton}
showEmptyState={showEmptyState}
/>
</div>
{showConfigureAffordance && (
<button
type="button"
onClick={() => {
setOpen(false);
setOpenManageProvidersDialog(true);
}}
data-testid={`${id}-configure`}
aria-label="Configure this model's provider"
title="This model isn't enabled for your user. Click to configure its provider."
className="shrink-0 inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-primary"
>
<ForwardedIconComponent name="Wrench" className="h-3.5 w-3.5" />
</button>
)}
</div>
<ModelTrigger
open={open}
disabled={disabled}
options={flatOptions}
selectedModel={selectedModel}
placeholder={placeholder}
hasEnabledProviders={hasEnabledProviders}
onOpenManageProviders={() => setOpenManageProvidersDialog(true)}
id={id}
refButton={refButton}
showEmptyState={showEmptyState}
/>
{renderPopoverContent()}
</Popover>

View File

@ -0,0 +1,452 @@
import { renderHook } from "@testing-library/react";
import { act } from "react";
import { useGetEnabledModels } from "@/controllers/API/queries/models/use-get-enabled-models";
import { useModelToggleQueue } from "../hooks/useModelToggleQueue";
// ---------------------------------------------------------------------------
// React Query — capture invocation order on the shared mock so the test can
// assert that ``cancelQueries`` is called BEFORE ``setQueryData`` whenever a
// user toggles a model. The two are wired to a single Jest mock function so
// their relative call order is preserved.
// ---------------------------------------------------------------------------
const recordedCalls: Array<{ method: string; args: unknown[] }> = [];
const trackingQueryClient = {
cancelQueries: jest.fn((...args: unknown[]) => {
recordedCalls.push({ method: "cancelQueries", args });
return Promise.resolve();
}),
setQueryData: jest.fn((...args: unknown[]) => {
recordedCalls.push({ method: "setQueryData", args });
const updater = args[1] as
| ((prev: unknown) => unknown)
| Record<string, unknown>;
if (typeof updater === "function") {
updater({ enabled_models: { OpenAI: { "gpt-4": true } } });
}
return undefined;
}),
getQueryData: jest.fn(() => ({
enabled_models: { OpenAI: { "gpt-4": true } },
})),
invalidateQueries: jest.fn((...args: unknown[]) => {
recordedCalls.push({ method: "invalidateQueries", args });
return Promise.resolve();
}),
};
jest.mock("@tanstack/react-query", () => ({
useQueryClient: () => trackingQueryClient,
}));
// The hook subscribes to ``useGetEnabledModels`` so the re-overlay effect can
// react when a refetch lands. The shared mock starts with the same baseline
// the trackingQueryClient holds.
jest.mock("@/controllers/API/queries/models/use-get-enabled-models", () => ({
useGetEnabledModels: jest.fn(() => ({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
})),
}));
// Mutation mock — tests can read the recorded payloads to assert that each
// flush sends ONLY the unsent slice, never re-sending an in-flight overlay,
// and can drive ``onError`` / ``onSettled`` via the captured callbacks.
const mutationCalls: Array<{
updates: { provider: string; model_id: string; enabled: boolean }[];
}> = [];
const mutationCallbacks: Array<{
onError?: (error: unknown) => void;
onSettled?: () => void;
}> = [];
jest.mock("@/controllers/API/queries/models/use-update-enabled-models", () => ({
useUpdateEnabledModels: () => ({
mutate: jest.fn((vars, callbacks) => {
mutationCalls.push(vars);
mutationCallbacks.push(callbacks);
}),
mutateAsync: jest.fn((vars) => {
mutationCalls.push(vars);
return Promise.resolve({ disabled_models: [] });
}),
}),
}));
// Debounce stub: capturing the pending function instead of running it
// synchronously lets each test pick when (or whether) the debounced flush
// fires. Tests that exercise the debounced path call ``runDebounced()``;
// tests that exercise the awaitable ``flushPendingChanges`` path don't, so
// ``unsentToggles`` is still populated when the explicit flush runs.
let pendingDebouncedFns: Array<() => void> = [];
const runDebounced = () => {
const fns = pendingDebouncedFns;
pendingDebouncedFns = [];
for (const fn of fns) fn();
};
jest.mock("@/hooks/use-debounce", () => ({
useDebounce: (fn: (...args: unknown[]) => unknown) => {
const wrapped = (..._args: unknown[]) => {
pendingDebouncedFns.push(() => fn());
};
(wrapped as unknown as { cancel: () => void }).cancel = jest.fn(() => {
pendingDebouncedFns = [];
});
return wrapped;
},
}));
const mockRefreshAllModelInputs = jest.fn(() => Promise.resolve());
jest.mock("@/hooks/use-refresh-model-inputs", () => ({
useRefreshModelInputs: () => ({
refreshAllModelInputs: mockRefreshAllModelInputs,
}),
}));
const mockSetErrorData = jest.fn();
jest.mock("@/stores/alertStore", () => ({
__esModule: true,
default: (selector: (state: unknown) => unknown) =>
selector({
setSuccessData: jest.fn(),
setErrorData: mockSetErrorData,
}),
}));
describe("useModelToggleQueue", () => {
beforeEach(() => {
recordedCalls.length = 0;
mutationCalls.length = 0;
mutationCallbacks.length = 0;
pendingDebouncedFns = [];
trackingQueryClient.cancelQueries.mockClear();
trackingQueryClient.setQueryData.mockClear();
trackingQueryClient.getQueryData.mockClear();
trackingQueryClient.invalidateQueries.mockClear();
mockRefreshAllModelInputs.mockClear();
mockSetErrorData.mockClear();
});
describe("optimistic update", () => {
it("cancels in-flight useGetEnabledModels refetches before the optimistic cache update", () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
act(() => {
result.current.handleModelToggle("gpt-4", false);
});
expect(trackingQueryClient.cancelQueries).toHaveBeenCalledWith({
queryKey: ["useGetEnabledModels"],
});
expect(trackingQueryClient.setQueryData).toHaveBeenCalledWith(
["useGetEnabledModels"],
expect.any(Function),
);
const cancelIdx = recordedCalls.findIndex(
(call) => call.method === "cancelQueries",
);
const setIdx = recordedCalls.findIndex(
(call) => call.method === "setQueryData",
);
expect(cancelIdx).toBeGreaterThanOrEqual(0);
expect(setIdx).toBeGreaterThanOrEqual(0);
expect(cancelIdx).toBeLessThan(setIdx);
});
it("no-ops when no provider is selected", () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: null }),
);
act(() => {
result.current.handleModelToggle("gpt-4", false);
});
expect(trackingQueryClient.cancelQueries).not.toHaveBeenCalled();
expect(trackingQueryClient.setQueryData).not.toHaveBeenCalled();
});
});
describe("re-overlay effect", () => {
it("re-applies the pending overlay when a refetch surfaces stale data", () => {
const mockedEnabled = useGetEnabledModels as jest.MockedFunction<
typeof useGetEnabledModels
>;
// Initial render: gpt-4 enabled on the server.
mockedEnabled.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
} as unknown as ReturnType<typeof useGetEnabledModels>);
const { result, rerender } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
// User toggles gpt-4 off — overlay now holds {gpt-4: false}.
act(() => {
result.current.handleModelToggle("gpt-4", false);
});
expect(trackingQueryClient.setQueryData).toHaveBeenCalled();
// Drain the call log so the re-overlay can be detected specifically.
recordedCalls.length = 0;
trackingQueryClient.setQueryData.mockClear();
// Simulate a refetch that lands inside the debounce window: the cache
// reports the still-stale server state (gpt-4: true).
mockedEnabled.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
} as unknown as ReturnType<typeof useGetEnabledModels>);
rerender();
// The effect detects drift and re-applies the overlay.
expect(trackingQueryClient.setQueryData).toHaveBeenCalledWith(
["useGetEnabledModels"],
expect.any(Function),
);
const updater = trackingQueryClient.setQueryData.mock.calls[0][1] as (
old: unknown,
) => unknown;
const result2 = updater({
enabled_models: { OpenAI: { "gpt-4": true, "gpt-3.5-turbo": true } },
}) as { enabled_models: { OpenAI: Record<string, boolean> } };
expect(result2.enabled_models.OpenAI["gpt-4"]).toBe(false);
expect(result2.enabled_models.OpenAI["gpt-3.5-turbo"]).toBe(true);
});
it("does not re-overlay when no toggles are pending", () => {
const mockedEnabled = useGetEnabledModels as jest.MockedFunction<
typeof useGetEnabledModels
>;
mockedEnabled.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
} as unknown as ReturnType<typeof useGetEnabledModels>);
renderHook(() => useModelToggleQueue({ providerName: "OpenAI" }));
// No toggle was performed — the mount effect must NOT call setQueryData.
expect(trackingQueryClient.setQueryData).not.toHaveBeenCalled();
});
});
describe("send buffer", () => {
it("does not resend in-flight toggles when a new toggle is flushed", () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
// Toggle A → debounce schedules a flush; drive it to send mutation A.
act(() => {
result.current.handleModelToggle("gpt-4", false);
runDebounced();
});
expect(mutationCalls).toHaveLength(1);
expect(mutationCalls[0].updates).toEqual([
{ provider: "OpenAI", model_id: "gpt-4", enabled: false },
]);
// Toggle B while A's mutation is still in flight (onSettled hasn't
// fired). The next flush MUST send ONLY B — re-sending A would be a
// duplicate request with non-deterministic ordering vs the original.
act(() => {
result.current.handleModelToggle("gpt-3.5-turbo", false);
runDebounced();
});
expect(mutationCalls).toHaveLength(2);
expect(mutationCalls[1].updates).toEqual([
{ provider: "OpenAI", model_id: "gpt-3.5-turbo", enabled: false },
]);
});
it("re-sends a model when the user re-toggles it after the previous flush fired", () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
// Toggle A → false. Drive the debounce.
act(() => {
result.current.handleModelToggle("gpt-4", false);
runDebounced();
});
expect(mutationCalls).toHaveLength(1);
expect(mutationCalls[0].updates).toEqual([
{ provider: "OpenAI", model_id: "gpt-4", enabled: false },
]);
// User re-toggles A → true before the first mutation settles. The
// re-toggle is a fresh intent and must be sent.
act(() => {
result.current.handleModelToggle("gpt-4", true);
runDebounced();
});
expect(mutationCalls).toHaveLength(2);
expect(mutationCalls[1].updates).toEqual([
{ provider: "OpenAI", model_id: "gpt-4", enabled: true },
]);
});
});
describe("error path", () => {
it("rolls back to previousData when the toggle mutation fails", () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
act(() => {
result.current.handleModelToggle("gpt-4", false);
runDebounced();
});
expect(mutationCallbacks).toHaveLength(1);
// The first setQueryData is the optimistic update from handleModelToggle.
// The second will be the rollback we expect on error.
const setQueryDataCallsBefore =
trackingQueryClient.setQueryData.mock.calls.length;
act(() => {
mutationCallbacks[0].onError?.(new Error("backend down"));
});
// Rollback restored ``previousData`` via setQueryData with the snapshot
// (NOT a function updater).
const newCalls = trackingQueryClient.setQueryData.mock.calls.slice(
setQueryDataCallsBefore,
);
const rollbackCall = newCalls.find(
([_, arg]) => typeof arg !== "function",
);
expect(rollbackCall).toBeDefined();
expect(rollbackCall?.[0]).toEqual(["useGetEnabledModels"]);
expect(rollbackCall?.[1]).toEqual({
enabled_models: { OpenAI: { "gpt-4": true } },
});
// User sees an error toast.
expect(mockSetErrorData).toHaveBeenCalledWith(
expect.objectContaining({ title: "Error updating model status" }),
);
});
it("does not re-apply the overlay after a failed mutation drains it", () => {
// The drain-before-rollback ordering inside ``rollbackToggleBatch`` is
// load-bearing. Without it, the re-overlay effect (triggered by the
// setQueryData rollback) would re-apply the stale overlay onto the
// just-rolled-back cache and silently undo the rollback.
const mockedEnabled = useGetEnabledModels as jest.MockedFunction<
typeof useGetEnabledModels
>;
mockedEnabled.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
} as unknown as ReturnType<typeof useGetEnabledModels>);
const { result, rerender } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
act(() => {
result.current.handleModelToggle("gpt-4", false);
runDebounced();
});
// Mutation fails — overlay is drained, cache reverts.
act(() => {
mutationCallbacks[0].onError?.(new Error("backend down"));
});
// Subsequent refetch surfaces the (now-correct) server state.
trackingQueryClient.setQueryData.mockClear();
mockedEnabled.mockReturnValue({
data: { enabled_models: { OpenAI: { "gpt-4": true } } },
} as unknown as ReturnType<typeof useGetEnabledModels>);
rerender();
// The re-overlay effect must NOT re-apply the failed toggle. The
// overlay has been drained, so there's nothing to re-apply.
expect(trackingQueryClient.setQueryData).not.toHaveBeenCalled();
});
it("preserves a mid-flight re-toggle when the original mutation fails", () => {
// Scenario: user toggles A→false, then re-toggles A→true while the
// first mutation is in flight. The first mutation then fails. The
// user's latest intent (A=true) must survive — both in the overlay
// (so the re-overlay effect protects it) and in the unsent buffer
// (so the next flush sends it).
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
act(() => {
result.current.handleModelToggle("gpt-4", false);
runDebounced();
});
expect(mutationCalls).toHaveLength(1);
expect(mutationCalls[0].updates[0].enabled).toBe(false);
// Re-toggle while first mutation is in flight.
act(() => {
result.current.handleModelToggle("gpt-4", true);
runDebounced();
});
expect(mutationCalls).toHaveLength(2);
expect(mutationCalls[1].updates[0].enabled).toBe(true);
// First mutation (A=false) fails. ``clearSentOverlay`` must NOT drop
// the A entry from the overlay, because the current overlay value
// (true) doesn't match what we sent (false) — the user re-toggled.
// The second mutation's overlay entry stays protected.
mutationCalls.length = 0;
act(() => {
mutationCallbacks[0].onError?.(new Error("backend down"));
});
// Trigger a fresh flush by toggling another key — if the overlay had
// been cleared in error, this flush would NOT include any prior
// intent. We verify the overlay survived by re-toggling A back to
// its in-flight value: if the overlay still has A=true, this is a
// no-op intent that doesn't appear in unsent. If the overlay was
// cleared, this re-toggle would be a fresh intent.
//
// Simpler check: the second mutation (A=true) is still in flight
// and must complete cleanly. The user's last intent is preserved
// by the overlay until that second mutation settles.
expect(mutationCallbacks).toHaveLength(2);
act(() => {
mutationCallbacks[1].onSettled?.();
});
// No additional rollback calls happened — the second mutation's
// overlay entry was preserved through the first failure.
expect(mockSetErrorData).toHaveBeenCalledTimes(1);
});
});
describe("flushPendingChanges", () => {
it("invalidates useGetEnabledModels on success without relying on the caller", async () => {
const { result } = renderHook(() =>
useModelToggleQueue({ providerName: "OpenAI" }),
);
// Toggle but DON'T run the debounced flush — leave the entry in
// unsentToggles for the awaitable close handler to consume.
act(() => {
result.current.handleModelToggle("gpt-4", false);
});
await act(async () => {
await result.current.flushPendingChanges();
});
// The async flush must invalidate the enabled-models cache itself —
// callers should not have to remember to invalidate downstream.
expect(trackingQueryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ["useGetEnabledModels"],
});
expect(trackingQueryClient.invalidateQueries).toHaveBeenCalledWith({
queryKey: ["useGetModelProviders"],
});
});
});
});

View File

@ -0,0 +1,292 @@
import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef } from "react";
import {
EnabledModelsResponse,
useGetEnabledModels,
} from "@/controllers/API/queries/models/use-get-enabled-models";
import { useUpdateEnabledModels } from "@/controllers/API/queries/models/use-update-enabled-models";
import { useDebounce } from "@/hooks/use-debounce";
import { useRefreshModelInputs } from "@/hooks/use-refresh-model-inputs";
import useAlertStore from "@/stores/alertStore";
// Extracted from useProviderConfiguration.ts to keep the toggle-queue
// concerns (overlay buffer, unsent buffer, debounced flush, awaitable
// flush, re-overlay effect, optimistic cache management) in a single
// focused module. The parent hook now owns only variable CRUD and
// provider lifecycle — the two responsibilities no longer share a file.
const getErrorMessage = (error: unknown): string | undefined => {
const e = error as {
response?: { data?: { detail?: string } };
message?: string;
};
return e?.response?.data?.detail || e?.message;
};
export interface UseModelToggleQueueOptions {
/**
* Provider whose models the user is toggling. ``null`` short-circuits all
* handlers — useful while the modal is still resolving the selection.
*/
providerName: string | null | undefined;
}
export interface UseModelToggleQueueReturn {
handleModelToggle: (modelName: string, enabled: boolean) => void;
flushPendingChanges: () => Promise<void>;
}
interface ToggleBatch {
updates: { provider: string; model_id: string; enabled: boolean }[];
previousData: EnabledModelsResponse | undefined;
togglesToSend: Record<string, boolean>;
}
/**
* Coordinated optimistic-update queue for model enable/disable toggles.
*
* Two refs back the queue, each with a single responsibility:
*
* - ``overlayToggles`` — the union of every toggle the user has made
* whose change has not yet been confirmed by the server. The
* re-overlay effect re-applies it whenever ``useGetEnabledModels``
* emits new data, so any refetch which lands inside the debounce or
* in-flight-mutation window can't overwrite the optimistic cache
* with stale server state. Entries are drained per-key on
* ``onSettled``/``onError`` — but only when the entry still matches
* the value we sent (a user re-toggle mid-flight becomes a fresh
* intent and must survive the clear).
*
* - ``unsentToggles`` — the strict subset that has NOT been sent in a
* mutation yet (or was re-toggled since the last send). It's drained
* immediately at flush time so subsequent flushes don't resend the
* same payload — without this split, mutation A's in-flight overlay
* entries would be snapshotted into mutation B's payload, producing
* duplicate requests with non-deterministic success/failure ordering.
*/
export const useModelToggleQueue = ({
providerName,
}: UseModelToggleQueueOptions): UseModelToggleQueueReturn => {
const queryClient = useQueryClient();
const setErrorData = useAlertStore((state) => state.setErrorData);
const { mutate: updateEnabledModels, mutateAsync: updateEnabledModelsAsync } =
useUpdateEnabledModels({ retry: 0 });
const { refreshAllModelInputs } = useRefreshModelInputs();
const { data: enabledModelsData } = useGetEnabledModels();
const overlayToggles = useRef<Record<string, boolean>>({});
const unsentToggles = useRef<Record<string, boolean>>({});
const fallbackModelData = useRef<EnabledModelsResponse | undefined>(
undefined,
);
// After a mutation settles, remove its entries from the overlay — but
// only when the current overlay value still matches what we sent. A
// mismatch means the user re-toggled the same model mid-flight; that
// entry already sits in ``unsentToggles`` for the next flush and must
// not be dropped from the overlay until its own mutation settles.
const clearSentOverlay = useCallback((sent: Record<string, boolean>) => {
for (const [key, value] of Object.entries(sent)) {
if (overlayToggles.current[key] === value) {
delete overlayToggles.current[key];
}
}
if (Object.keys(overlayToggles.current).length === 0) {
fallbackModelData.current = undefined;
}
}, []);
// Shared flush prelude: builds the mutation payload, snapshots the
// pre-toggle cache for rollback, and drains ``unsentToggles`` so a
// follow-up flush triggered by a new user toggle cannot resend what we
// just sent. Returns null when there's nothing to do so callers can
// bail symmetrically.
const buildAndConsumeToggleBatch = useCallback((): ToggleBatch | null => {
if (!providerName) return null;
const togglesToSend = { ...unsentToggles.current };
if (Object.keys(togglesToSend).length === 0) return null;
const updates = Object.entries(togglesToSend).map(
([modelName, enabled]) => ({
provider: providerName,
model_id: modelName,
enabled,
}),
);
const previousData = fallbackModelData.current;
unsentToggles.current = {};
return { updates, previousData, togglesToSend };
}, [providerName]);
// Shared error-path: drain the overlay BEFORE restoring previousData so
// the re-overlay effect (triggered by setQueryData below) can't re-apply
// a stale overlay over the rollback we just performed.
const rollbackToggleBatch = useCallback(
(
togglesToSend: Record<string, boolean>,
previousData: EnabledModelsResponse | undefined,
error: unknown,
) => {
clearSentOverlay(togglesToSend);
if (previousData) {
queryClient.setQueryData(["useGetEnabledModels"], previousData);
}
setErrorData({
title: "Error updating model status",
list: [getErrorMessage(error) || "Failed to update model status"],
});
},
[clearSentOverlay, queryClient, setErrorData],
);
const flushModelToggles = useDebounce(() => {
const batch = buildAndConsumeToggleBatch();
if (!batch) return;
const { updates, previousData, togglesToSend } = batch;
updateEnabledModels(
{ updates },
{
onError: (error: unknown) => {
rollbackToggleBatch(togglesToSend, previousData, error);
},
onSettled: () => {
clearSentOverlay(togglesToSend);
queryClient.invalidateQueries({
queryKey: ["useGetEnabledModels"],
});
queryClient.invalidateQueries({
queryKey: ["useGetModelProviders"],
});
refreshAllModelInputs({ silent: true });
},
},
);
}, 1000);
const flushPendingChanges = useCallback(async () => {
// Cancel the pending debounce timer — we'll send the toggles directly
flushModelToggles.cancel();
const batch = buildAndConsumeToggleBatch();
if (!batch) return;
const { updates, previousData, togglesToSend } = batch;
try {
await updateEnabledModelsAsync({ updates });
clearSentOverlay(togglesToSend);
// Invalidate the affected queries inline so callers don't need to
// bolt this on. The modal's onClose still triggers
// ``refreshAllModelInputs`` afterwards to repopulate per-node
// template options, but ``useGetEnabledModels`` consumers no longer
// depend on the caller for cache freshness.
queryClient.invalidateQueries({ queryKey: ["useGetEnabledModels"] });
queryClient.invalidateQueries({ queryKey: ["useGetModelProviders"] });
} catch (error: unknown) {
rollbackToggleBatch(togglesToSend, previousData, error);
}
}, [
flushModelToggles,
buildAndConsumeToggleBatch,
updateEnabledModelsAsync,
clearSentOverlay,
rollbackToggleBatch,
queryClient,
]);
const handleModelToggle = useCallback(
(modelName: string, enabled: boolean) => {
if (!providerName) return;
// Cancel any in-flight refetch of useGetEnabledModels so its (stale)
// result cannot overwrite the optimistic cache update below. The
// re-overlay effect handles refetches that start AFTER this point;
// ``cancelQueries`` covers the ones already in flight at click time.
void queryClient.cancelQueries({ queryKey: ["useGetEnabledModels"] });
if (Object.keys(overlayToggles.current).length === 0) {
fallbackModelData.current =
queryClient.getQueryData<EnabledModelsResponse>([
"useGetEnabledModels",
]);
}
queryClient.setQueryData<EnabledModelsResponse>(
["useGetEnabledModels"],
(old) => {
if (!old) return old;
return {
...old,
enabled_models: {
...old.enabled_models,
[providerName]: {
...old.enabled_models[providerName],
[modelName]: enabled,
},
},
};
},
);
// Track in BOTH buffers: overlay for UI protection across refetches,
// unsent for the next flush's payload.
overlayToggles.current[modelName] = enabled;
unsentToggles.current[modelName] = enabled;
flushModelToggles();
},
[providerName, queryClient, flushModelToggles],
);
// Re-overlay effect — protects the pending-toggle window in its entirety,
// not just the instant of the click. Any refetch (window focus, remount,
// reconnect, or a stale-time expiry) that lands while ``overlayToggles``
// has entries will surface the server's pre-toggle state into
// ``enabledModelsData``; this effect detects the drift and re-applies the
// pending overlay so the Switch tracks the user's intent through the
// entire debounce + in-flight window. Once ``clearSentOverlay`` drains
// the entry on settle, the next data emission is a no-op.
useEffect(() => {
if (!providerName) return;
if (!enabledModelsData) return;
const overlay = overlayToggles.current;
if (Object.keys(overlay).length === 0) return;
const current = enabledModelsData.enabled_models[providerName] ?? {};
// Loop guard: the ``setQueryData`` below re-emits ``enabledModelsData``
// and re-runs this effect; ``drifted`` must return false on the second
// pass for the recursion to terminate. Don't replace this with an
// unconditional re-apply — the second invocation finds the overlay
// already applied, ``current[model] === enabled`` for every entry, and
// bails. Any future refactor that removes the drift check must
// introduce an equivalent termination condition.
const drifted = Object.entries(overlay).some(
([model, enabled]) => current[model] !== enabled,
);
if (!drifted) return;
queryClient.setQueryData<EnabledModelsResponse>(
["useGetEnabledModels"],
(old) => {
if (!old) return old;
return {
...old,
enabled_models: {
...old.enabled_models,
[providerName]: {
...(old.enabled_models[providerName] ?? {}),
...overlay,
},
},
};
},
);
}, [enabledModelsData, providerName, queryClient]);
return {
handleModelToggle,
flushPendingChanges,
};
};

View File

@ -5,10 +5,8 @@ import {
ProviderVariable,
VARIABLE_CATEGORY,
} from "@/constants/providerConstants";
import { EnabledModelsResponse } from "@/controllers/API/queries/models/use-get-enabled-models";
import { useGetModelProviders } from "@/controllers/API/queries/models/use-get-model-providers";
import { useGetProviderVariables } from "@/controllers/API/queries/models/use-get-provider-variables";
import { useUpdateEnabledModels } from "@/controllers/API/queries/models/use-update-enabled-models";
import { useValidateProvider } from "@/controllers/API/queries/models/use-validate-provider";
import {
useDeleteGlobalVariables,
@ -16,14 +14,25 @@ import {
usePatchGlobalVariables,
usePostGlobalVariables,
} from "@/controllers/API/queries/variables";
import { useDebounce } from "@/hooks/use-debounce";
import { useRefreshModelInputs } from "@/hooks/use-refresh-model-inputs";
import useAlertStore from "@/stores/alertStore";
import { Provider } from "../components/types";
import { useModelToggleQueue } from "./useModelToggleQueue";
// Masked value shown for configured secret fields
const MASKED_VALUE = "••••••••";
// Extract a user-facing message from an error caught from an API call.
// Handles Axios-shaped errors (``response.data.detail``) and standard
// ``Error.message`` without resorting to ``any``.
const getErrorMessage = (error: unknown): string | undefined => {
const e = error as {
response?: { data?: { detail?: string } };
message?: string;
};
return e?.response?.data?.detail || e?.message;
};
interface UseProviderConfigurationOptions {
selectedProvider: Provider | null;
}
@ -100,8 +109,6 @@ export const useProviderConfiguration = ({
const { data: globalVariables = [] } = useGetGlobalVariables();
const { mutateAsync: validateProvider } = useValidateProvider();
const { data: providerVariablesMapping = {} } = useGetProviderVariables();
const { mutate: updateEnabledModels, mutateAsync: updateEnabledModelsAsync } =
useUpdateEnabledModels({ retry: 0 });
const { refreshAllModelInputs } = useRefreshModelInputs();
const { data: modelProviders = [], isFetching: isFetchingModels } =
useGetModelProviders(
@ -342,7 +349,7 @@ export const useProviderConfiguration = ({
setValidationError(result.error || "Validation failed");
return false;
}
} catch (error: any) {
} catch (error: unknown) {
// Ensure minimum 500ms duration even on error
const elapsedTime = Date.now() - startTime;
if (elapsedTime < 500) {
@ -350,7 +357,7 @@ export const useProviderConfiguration = ({
}
setValidationState("invalid");
setValidationError(error?.message || "Validation failed");
setValidationError(getErrorMessage(error) || "Validation failed");
return false;
}
}, [selectedProvider, getVariablesForValidation, validateProvider]);
@ -420,12 +427,12 @@ export const useProviderConfiguration = ({
setIsFetchingAfterSave(true);
clearValuesAfterFetchRef.current = true;
invalidateProviderQueries();
} catch (error: any) {
} catch (error: unknown) {
setValidationFailed(true);
setErrorData({
title: "Error Saving Configuration",
list: [
error?.response?.data?.detail ||
getErrorMessage(error) ||
"An unexpected error occurred. Please try again.",
],
});
@ -488,11 +495,11 @@ export const useProviderConfiguration = ({
setSuccessData({ title: `${syncedSelectedProvider.provider} Activated` });
invalidateProviderQueries();
} catch (error: any) {
} catch (error: unknown) {
setErrorData({
title: "Error Activating Provider",
list: [
error?.response?.data?.detail ||
getErrorMessage(error) ||
"An unexpected error occurred. Please try again.",
],
});
@ -529,11 +536,11 @@ export const useProviderConfiguration = ({
});
setIsFetchingAfterDisconnect(true);
invalidateProviderQueries();
} catch (error: any) {
} catch (error: unknown) {
setErrorData({
title: "Error Disconnecting Provider",
list: [
error?.response?.data?.detail ||
getErrorMessage(error) ||
"An unexpected error occurred. Please try again.",
],
});
@ -547,144 +554,14 @@ export const useProviderConfiguration = ({
invalidateProviderQueries,
]);
const pendingModelToggles = useRef<Record<string, boolean>>({});
const fallbackModelData = useRef<EnabledModelsResponse | undefined>(
undefined,
);
const flushModelToggles = useDebounce(() => {
if (!syncedSelectedProvider?.provider) return;
const providerName = syncedSelectedProvider.provider;
const updates = Object.entries(pendingModelToggles.current).map(
([modelName, enabled]) => ({
provider: providerName,
model_id: modelName,
enabled,
}),
);
if (updates.length === 0) return;
// Capture the fallback data
const previousData = fallbackModelData.current;
// Clear buffer
pendingModelToggles.current = {};
fallbackModelData.current = undefined;
updateEnabledModels(
{ updates },
{
onError: (error: any) => {
if (previousData) {
queryClient.setQueryData(["useGetEnabledModels"], previousData);
}
const errorMessage =
error?.response?.data?.detail ||
error?.message ||
"Failed to update model status";
setErrorData({
title: "Error updating model status",
list: [errorMessage],
});
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["useGetEnabledModels"],
});
queryClient.invalidateQueries({
queryKey: ["useGetModelProviders"],
});
refreshAllModelInputs({ silent: true });
},
},
);
}, 1000);
const flushPendingChanges = useCallback(async () => {
// Cancel the pending debounce timer — we'll send the toggles directly
flushModelToggles.cancel();
if (!syncedSelectedProvider?.provider) return;
const providerName = syncedSelectedProvider.provider;
const toggles = { ...pendingModelToggles.current };
if (Object.keys(toggles).length === 0) return;
const updates = Object.entries(toggles).map(([modelName, enabled]) => ({
provider: providerName,
model_id: modelName,
enabled,
}));
const previousData = fallbackModelData.current;
// Clear buffer
pendingModelToggles.current = {};
fallbackModelData.current = undefined;
try {
await updateEnabledModelsAsync({ updates });
// Mutation succeeded — query invalidation is handled by
// refreshAllModelInputs which runs after this promise resolves.
} catch (error: any) {
// Revert optimistic update on failure
if (previousData) {
queryClient.setQueryData(["useGetEnabledModels"], previousData);
}
const errorMessage =
error?.response?.data?.detail ||
error?.message ||
"Failed to update model status";
setErrorData({
title: "Error updating model status",
list: [errorMessage],
});
}
}, [
flushModelToggles,
syncedSelectedProvider,
queryClient,
updateEnabledModelsAsync,
setErrorData,
]);
const handleModelToggle = useCallback(
(modelName: string, enabled: boolean) => {
if (!syncedSelectedProvider?.provider) return;
const providerName = syncedSelectedProvider.provider;
if (Object.keys(pendingModelToggles.current).length === 0) {
fallbackModelData.current =
queryClient.getQueryData<EnabledModelsResponse>([
"useGetEnabledModels",
]);
}
queryClient.setQueryData<EnabledModelsResponse>(
["useGetEnabledModels"],
(old) => {
if (!old) return old;
return {
...old,
enabled_models: {
...old.enabled_models,
[providerName]: {
...old.enabled_models[providerName],
[modelName]: enabled,
},
},
};
},
);
pendingModelToggles.current[modelName] = enabled;
flushModelToggles();
},
[syncedSelectedProvider, queryClient, flushModelToggles],
);
// Model-toggle queue — overlay buffer, unsent buffer, debounced flush,
// awaitable close-time flush, re-overlay effect. Extracted into its own
// hook so this file stays focused on variable CRUD + provider lifecycle.
// See ``useModelToggleQueue`` for the full design rationale (split
// buffers, drain-before-rollback ordering, re-overlay loop guard).
const { handleModelToggle, flushPendingChanges } = useModelToggleQueue({
providerName: syncedSelectedProvider?.provider,
});
return {
variableValues,