From f53681e6071231fc6543eb5266e2d3d2c95226e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 19 Aug 2026 17:02:50 +0100 Subject: [PATCH] fix(web): show model option loading state (#2806) --- .../components/model-config-selector.test.tsx | 120 ++++++++++++++ apps/web/components/model-config-selector.tsx | 77 ++++++--- .../settings/profile-form-fields.test.tsx | 64 ++++++++ .../settings/profile-form-fields.tsx | 3 + .../settings/profile-model-fields.tsx | 6 + .../tests/settings/agent-profile-acp.spec.ts | 20 +++ ...bile-agent-profile-config-selector.spec.ts | 29 +++- .../settings/use-dynamic-models.test.ts | 61 +++++++ .../domains/settings/use-dynamic-models.ts | 6 +- .../model-options-loading-spinner/plan.md | 150 ++++++++++++++++++ .../task-01-selector-loading-state.md | 86 ++++++++++ .../task-02-desktop-mobile-e2e.md | 78 +++++++++ docs/specs/agents/dynamic-provider-options.md | 45 ++++-- 13 files changed, 705 insertions(+), 40 deletions(-) create mode 100644 docs/plans/model-options-loading-spinner/plan.md create mode 100644 docs/plans/model-options-loading-spinner/task-01-selector-loading-state.md create mode 100644 docs/plans/model-options-loading-spinner/task-02-desktop-mobile-e2e.md diff --git a/apps/web/components/model-config-selector.test.tsx b/apps/web/components/model-config-selector.test.tsx index 196896fcad..7c88169378 100644 --- a/apps/web/components/model-config-selector.test.tsx +++ b/apps/web/components/model-config-selector.test.tsx @@ -102,6 +102,126 @@ describe("ModelConfigSelector", () => { }); }); +describe("ModelConfigSelector loading behavior", () => { + it("keeps the picker open after selecting a model without existing dependent options", () => { + const onModelChange = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName })); + fireEvent.click(screen.getByRole("option", { name: /Model B/ })); + + expect(onModelChange).toHaveBeenCalledWith("model-b"); + expect(screen.getByRole("option", { name: /Model B/ })).toBeTruthy(); + }); + + it("replaces dependent options with a loading row until the snapshot resolves", () => { + const { rerender } = render( + {}} + configOptions={[ + { + type: "select", + id: "effort", + name: "Effort", + currentValue: "medium", + options: [{ value: "medium", name: "Medium" }], + }, + ]} + />, + ); + + fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName })); + expect(screen.getByTestId(effortTriggerTestId)).toBeTruthy(); + + rerender( + {}} + configOptions={[ + { + type: "select", + id: "effort", + name: "Effort", + currentValue: "medium", + options: [{ value: "medium", name: "Medium" }], + }, + ]} + configOptionsLoading + keepOpenOnModelChange + />, + ); + + const loadingRow = screen.getByTestId("model-config-options-loading"); + expect(loadingRow.getAttribute("role")).toBe("status"); + expect(screen.getByRole("status", { name: "Loading model options…" })).toBe(loadingRow); + expect(screen.queryByTestId(effortTriggerTestId)).toBeNull(); + const selectedModelRow = screen.getByTestId("model-config-selected-row"); + expect(selectedModelRow.querySelector("svg.tabler-icon-loader")).toBeTruthy(); + expect(selectedModelRow.querySelector("svg.tabler-icon-check.absolute")).toBeNull(); + + rerender( + {}} + configOptions={[ + { + type: "select", + id: "effort", + name: "Effort", + currentValue: "max", + options: [{ value: "max", name: "Max" }], + }, + ]} + keepOpenOnModelChange + />, + ); + + expect(screen.queryByTestId("model-config-options-loading")).toBeNull(); + expect(selectedModelRow.querySelector("svg.tabler-icon-loader")).toBeNull(); + expect(selectedModelRow.querySelector("svg.tabler-icon-check.absolute")).toBeTruthy(); + expect(screen.getByTestId(effortTriggerTestId).textContent).toContain("Max"); + }); +}); + +describe("ModelConfigSelector loading guard", () => { + it("keeps the picker open when options are loading without the explicit open flag", () => { + const onModelChange = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: modelSettingsButtonName })); + fireEvent.click(screen.getByRole("option", { name: /Model B/ })); + + expect(onModelChange).toHaveBeenCalledWith("model-b"); + expect(screen.getByTestId("model-config-options-loading")).toBeTruthy(); + }); +}); + describe("ModelConfigSelector filtering", () => { it("deduplicates repeated model ids from provider config", () => { const modelConfig: SelectConfigOption = { diff --git a/apps/web/components/model-config-selector.tsx b/apps/web/components/model-config-selector.tsx index c8310c8acf..d88471cffe 100644 --- a/apps/web/components/model-config-selector.tsx +++ b/apps/web/components/model-config-selector.tsx @@ -19,6 +19,7 @@ import { import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { ScrollArea } from "@kandev/ui/scroll-area"; import { Separator } from "@kandev/ui/separator"; +import { Spinner } from "@kandev/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; export type ModelSelectorOption = { @@ -186,10 +187,12 @@ function triggerDetails( function ModelRow({ model, selected, + loading, onSelect, }: { model: ModelSelectorOption; selected: boolean; + loading: boolean; onSelect: (value: string) => void; }) { const item = ( @@ -198,6 +201,7 @@ function ModelRow({ keywords={[model.name, model.description ?? "", model.id]} onSelect={() => !model.disabled && onSelect(model.id)} disabled={model.disabled} + data-testid={selected ? "model-config-selected-row" : undefined} className={cn("relative pr-7", model.disabled && "opacity-40 cursor-not-allowed")} >
@@ -213,16 +217,17 @@ function ModelRow({ {model.usageMultiplier} )}
- + {selected && loading ? ( +