fix(web): show model option loading state (#2806)

This commit is contained in:
Carlos Florêncio
2026-08-19 17:02:50 +01:00
committed by GitHub
parent 0b10edfa22
commit f53681e607
13 changed files with 705 additions and 40 deletions
@@ -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(
<ModelConfigSelector
modelOptions={[
{ id: "model-a", name: "Model A" },
{ id: "model-b", name: "Model B" },
]}
currentModel="model-a"
onModelChange={onModelChange}
keepOpenOnModelChange
/>,
);
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(
<ModelConfigSelector
modelOptions={[{ id: "model-a", name: "Model A" }]}
currentModel="model-a"
onModelChange={() => {}}
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(
<ModelConfigSelector
modelOptions={[{ id: "model-b", name: "Model B" }]}
currentModel="model-b"
onModelChange={() => {}}
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(
<ModelConfigSelector
modelOptions={[{ id: "model-b", name: "Model B" }]}
currentModel="model-b"
onModelChange={() => {}}
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(
<ModelConfigSelector
modelOptions={[
{ id: "model-a", name: "Model A" },
{ id: "model-b", name: "Model B" },
]}
currentModel="model-a"
onModelChange={onModelChange}
configOptionsLoading
/>,
);
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 = {
+54 -23
View File
@@ -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")}
>
<div className="flex min-w-0 flex-1 items-center">
@@ -213,16 +217,17 @@ function ModelRow({
<span className="shrink-0 text-xs text-muted-foreground">{model.usageMultiplier}</span>
)}
</div>
<IconCheck
className={cn("absolute right-2 h-4 w-4", selected ? "opacity-100" : "opacity-0")}
/>
{selected && loading ? (
<Spinner aria-hidden="true" className="absolute right-2" />
) : (
<IconCheck
className={cn("absolute right-2 h-4 w-4", selected ? "opacity-100" : "opacity-0")}
/>
)}
</CommandItem>
);
// cmdk's CommandItem swallows pointer events with no native tooltip slot;
// wrap disabled items in a Tooltip trigger so the gone-reason shows. The
// CommandItem is disabled (unfocusable), so the wrapper itself must be
// focusable and carry an accessible name for keyboard users to reach the
// reason.
// keep disabled items in a focusable wrapper so their gone reason is reachable.
if (model.disabled && model.disabledReason) {
return (
<Tooltip>
@@ -358,6 +363,7 @@ type ModelConfigSelectorContentProps = {
onConfigSelect: (configId: string) => void;
onConfigBack: () => void;
onConfigChange?: (configId: string, value: string) => void;
configOptionsLoading: boolean;
};
function ModelConfigSelectorContent({
@@ -369,6 +375,7 @@ function ModelConfigSelectorContent({
onConfigSelect,
onConfigBack,
onConfigChange,
configOptionsLoading,
}: ModelConfigSelectorContentProps) {
const { t } = useTranslation();
const pendingFocusConfigId = useRef<string | null>(null);
@@ -412,30 +419,46 @@ function ModelConfigSelectorContent({
key={model.id}
model={model}
selected={model.id === currentModelValue}
loading={configOptionsLoading}
onSelect={onModelSelect}
/>
))}
</CommandGroup>
</CommandList>
</Command>
{extraConfigOptions.length > 0 && (
{configOptionsLoading ? (
<>
<Separator />
<ScrollArea className="max-h-40 pr-2">
<div className="space-y-1">
{extraConfigOptions.map((option) => (
<ConfigOptionTrigger
key={option.id}
option={option}
onSelect={() => onConfigSelect(option.id)}
triggerRef={(element) => {
triggerRefs.current[option.id] = element;
}}
/>
))}
</div>
</ScrollArea>
<div
className="flex min-h-9 items-center gap-2 px-2 text-xs text-muted-foreground"
data-testid="model-config-options-loading"
role="status"
aria-label={t("agents:resolvingModelOptions")}
>
<Spinner aria-hidden="true" className="h-3.5 w-3.5" />
<span aria-hidden="true">{t("agents:resolvingModelOptions")}</span>
</div>
</>
) : (
extraConfigOptions.length > 0 && (
<>
<Separator />
<ScrollArea className="max-h-40 pr-2">
<div className="space-y-1">
{extraConfigOptions.map((option) => (
<ConfigOptionTrigger
key={option.id}
option={option}
onSelect={() => onConfigSelect(option.id)}
triggerRef={(element) => {
triggerRefs.current[option.id] = element;
}}
/>
))}
</div>
</ScrollArea>
</>
)
)}
</>
);
@@ -458,6 +481,11 @@ export type ModelConfigSelectorProps = {
/** Optional suffix appended to the trigger's model label (e.g. "(fallback)"). */
currentModelSuffix?: string;
/** Keeps the picker open while a caller resolves model-dependent options. */
configOptionsLoading?: boolean;
/** Keeps the picker open after model selection, even without existing options. */
keepOpenOnModelChange?: boolean;
/** Optional title tooltip on the trigger (e.g. explains a live-only note). */
triggerTitle?: string;
};
@@ -542,6 +570,8 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
triggerSummary = "all",
configBaseline,
currentModelSuffix,
configOptionsLoading = false,
keepOpenOnModelChange = false,
}: ModelConfigSelectorProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -571,7 +601,7 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
const onModelSelect = (value: string) => {
if (!value) return;
onModelChange(value);
if (!hasExtraConfigOptions) {
if (!keepOpenOnModelChange && !hasExtraConfigOptions && !configOptionsLoading) {
setOpen(false);
}
};
@@ -609,6 +639,7 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({
onConfigSelect={setActiveConfigId}
onConfigBack={() => setActiveConfigId(null)}
onConfigChange={onConfigChange}
configOptionsLoading={configOptionsLoading}
/>
</PopoverContent>
</Popover>
@@ -327,6 +327,70 @@ describe("ProfileFormFields initial model options", () => {
});
describe("ProfileFormFields model options after selection", () => {
it("keeps the model selector open while resolving the selected model options", async () => {
__resetModelConfigResolutionCache();
let resolveResponse: ((value: unknown) => void) | undefined;
const response = new Promise((resolve) => {
resolveResponse = resolve;
});
vi.mocked(resolveAgentModelConfig).mockReturnValueOnce(response as never);
const dynamicModelConfig: ModelConfig = {
default_model: "model-a",
current_model_id: "model-a",
available_models: [
{ id: "model-a", name: "Model A" },
{ id: "model-b", name: "Model B" },
],
config_options: [
{
type: "select",
id: reasoningEffortOptionId,
name: reasoningEffortName,
current_value: "medium",
options: [{ value: "medium", name: "Medium" }],
},
],
supports_dynamic_models: true,
};
renderStatefulForm(
formData({ model: "model-a", config_options: { [reasoningEffortOptionId]: "medium" } }),
dynamicModelConfig,
vi.fn(),
);
const selector = await screen.findByRole("button", { name: profileStartModelSettingsLabel });
fireEvent.click(selector);
fireEvent.click(screen.getByRole("option", { name: /Model B/ }));
await waitFor(() => expect(screen.getByTestId("model-config-options-loading")).toBeTruthy());
expect(screen.queryByTestId(`config-option-trigger-${reasoningEffortOptionId}`)).toBeNull();
await act(async () => {
resolveResponse?.({
agent_name: "mock-agent",
model: "model-b",
status: "ok",
config_options: [
{
type: "select",
id: reasoningEffortOptionId,
name: reasoningEffortName,
current_value: "max",
options: [{ value: "max", name: "Max" }],
},
],
error: null,
});
});
await waitFor(() =>
expect(screen.getByTestId("config-option-trigger-reasoning_effort")).toBeTruthy(),
);
expect(screen.queryByTestId("model-config-options-loading")).toBeNull();
});
it("removes a saved option value after the user changes the model", async () => {
const onChange = vi.fn();
const dynamicModelConfig: ModelConfig = {
@@ -320,6 +320,7 @@ function CapabilitiesRowContent({
isLoading,
onRefresh,
error,
modelConfig,
configOptions,
configStatus,
configError,
@@ -350,6 +351,8 @@ function CapabilitiesRowContent({
onChange={onChange}
ariaLabel={t("settings:startModelAria")}
goneModelLabel={t("settings:startModelUnavailable")}
configOptionsLoading={configIsLoading}
keepOpenOnModelChange={modelConfig.supports_dynamic_models}
/>
</div>
{hasModes && (
@@ -52,6 +52,8 @@ export function ModelPicker({
placeholder,
goneModelLabel,
disabled,
configOptionsLoading,
keepOpenOnModelChange,
}: {
profile: ProfileFormData;
models: ModelEntry[];
@@ -62,6 +64,8 @@ export function ModelPicker({
placeholder?: string;
goneModelLabel: string;
disabled?: boolean;
configOptionsLoading?: boolean;
keepOpenOnModelChange?: boolean;
}) {
const { t } = useTranslation();
const modelConfig = configOptions.find(isModelConfigOption);
@@ -106,6 +110,8 @@ export function ModelPicker({
placeholder={placeholder ?? t("settings:selectAModel")}
ariaLabel={ariaLabel}
disabled={disabled}
configOptionsLoading={configOptionsLoading}
keepOpenOnModelChange={keepOpenOnModelChange}
triggerClassName={modelIsGone ? "text-destructive" : undefined}
/>
);
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { injectLatency } from "../../helpers/causal-waits";
import { SessionPage } from "../../pages/session-page";
/**
@@ -131,6 +132,16 @@ test.describe("Agent profile — ACP-first", () => {
});
try {
await testPage.route("**/api/v1/agent-models/mock-agent/resolve", async (route) => {
const request = route.request().postDataJSON() as { model?: string };
if (request.model === "mock-smart") {
await injectLatency(
750,
"keeps the profile model-option loading state visible during resolution",
);
}
await route.fallback();
});
await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`);
const selector = testPage.getByRole("button", { name: "Profile start model settings" });
await expect(selector).toBeVisible({ timeout: 15_000 });
@@ -158,9 +169,18 @@ test.describe("Agent profile — ACP-first", () => {
// exposes Max while mock-fast exposes Medium, so this also proves the
// profile selector does not keep the previous model's option list.
await testPage.getByRole("option", { name: /Mock Smart/ }).click();
await expect(testPage.getByTestId("model-config-options-loading")).toBeVisible();
const selectedModelRow = testPage.getByTestId("model-config-selected-row");
await expect(selectedModelRow.locator("svg.tabler-icon-loader")).toBeVisible();
await expect(selectedModelRow.locator("svg.tabler-icon-check.absolute")).toHaveCount(0);
await expect(testPage.getByTestId("config-option-trigger-effort")).toHaveCount(0);
await expect(selector).toHaveAttribute("aria-expanded", "true");
await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({
timeout: 15_000,
});
await expect(testPage.getByTestId("model-config-options-loading")).toHaveCount(0);
await expect(selectedModelRow.locator("svg.tabler-icon-loader")).toHaveCount(0);
await expect(selectedModelRow.locator("svg.tabler-icon-check.absolute")).toBeVisible();
await expect(selector).toContainText("Mock Smart", { timeout: 10_000 });
await expect(testPage.getByTestId("config-option-trigger-effort")).toBeVisible();
await testPage.getByTestId("config-option-trigger-effort").click();
@@ -1,4 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import { injectLatency } from "../../helpers/causal-waits";
test.describe("Mobile agent profile config selector", () => {
test("changes a dynamic profile config option", async ({ testPage, apiClient, backend }) => {
@@ -32,6 +33,16 @@ test.describe("Mobile agent profile config selector", () => {
});
try {
await testPage.route("**/api/v1/agent-models/mock-agent/resolve", async (route) => {
const request = route.request().postDataJSON() as { model?: string };
if (request.model === "mock-smart") {
await injectLatency(
750,
"keeps the mobile profile model-option loading state visible during resolution",
);
}
await route.fallback();
});
await testPage.goto(`/settings/agents/${agent.name}/profiles/${profile.id}`);
const selector = testPage.getByRole("button", { name: "Profile start model settings" });
await expect(selector).toBeVisible({ timeout: 15_000 });
@@ -41,15 +52,29 @@ test.describe("Mobile agent profile config selector", () => {
await expect(testPage.getByTestId("profile-refresh-capabilities")).toBeEnabled({
timeout: 15_000,
});
await selector.click();
await selector.tap();
await testPage.getByRole("option", { name: /Mock Smart/ }).tap();
await expect(testPage.getByTestId("model-config-options-loading")).toBeVisible();
const selectedModelRow = testPage.getByTestId("model-config-selected-row");
await expect(selectedModelRow.locator("svg.tabler-icon-loader")).toBeVisible();
await expect(selectedModelRow.locator("svg.tabler-icon-check.absolute")).toHaveCount(0);
await expect(testPage.getByTestId("config-option-trigger-effort")).toHaveCount(0);
await expect(selector).toHaveAttribute("aria-expanded", "true");
expect(
await testPage.evaluate(
() => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
),
).toBe(true);
await expect(testPage.getByTestId("model-config-resolution-loading")).toBeHidden({
timeout: 15_000,
});
await expect(testPage.getByTestId("model-config-options-loading")).toHaveCount(0);
await expect(selectedModelRow.locator("svg.tabler-icon-loader")).toHaveCount(0);
await expect(selectedModelRow.locator("svg.tabler-icon-check.absolute")).toBeVisible();
await expect(selector).toContainText("Mock Smart", { timeout: 10_000 });
const effortTrigger = testPage.getByTestId("config-option-trigger-effort");
await expect(effortTrigger).toBeVisible();
await effortTrigger.click();
await effortTrigger.tap();
await expect(testPage.getByRole("button", { name: "Max", exact: true })).toBeVisible({
timeout: 10_000,
});
@@ -10,6 +10,7 @@ import type {
const fetchDynamicModelsMock = vi.fn();
const resolveAgentModelConfigMock = vi.fn();
const resolutionFailure = "resolution failed";
vi.mock("@/lib/api/domains/settings-api", () => ({
fetchDynamicModels: (...args: unknown[]) => fetchDynamicModelsMock(...args),
@@ -252,6 +253,66 @@ describe("useResolvedModelConfig", () => {
});
expect(result.current.configOptions).toEqual(secondOptions);
});
it("clears the previous options after selected model resolution fails", async () => {
const baseline: ConfigOptionEntry[] = [
{ type: "select", id: "effort", name: "Effort", current_value: "low", options: [] },
];
resolveAgentModelConfigMock
.mockResolvedValueOnce(resolvedResponse("model-a", baseline))
.mockRejectedValueOnce(new Error(resolutionFailure));
const { result, rerender } = renderHook(
({ model }) =>
useResolvedModelConfig("opencode", model, {
initialConfigOptions: baseline,
}),
{ initialProps: { model: "model-a" } },
);
await waitFor(() => expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(1));
rerender({ model: "model-b" });
await waitFor(() => {
expect(result.current.status).toBe("failed");
expect(result.current.isLoading).toBe(false);
});
expect(result.current.configOptions).toEqual([]);
expect(result.current.error).toBe(resolutionFailure);
});
});
describe("useResolvedModelConfig resolution failures", () => {
it("clears the previous options after a failed model resolution response", async () => {
const baseline: ConfigOptionEntry[] = [
{ type: "select", id: "effort", name: "Effort", current_value: "low", options: [] },
];
resolveAgentModelConfigMock
.mockResolvedValueOnce(resolvedResponse("model-a", baseline))
.mockResolvedValueOnce({
...resolvedResponse("model-b", baseline),
status: "failed",
error: resolutionFailure,
});
const { result, rerender } = renderHook(
({ model }) =>
useResolvedModelConfig("opencode", model, {
initialConfigOptions: baseline,
}),
{ initialProps: { model: "model-a" } },
);
await waitFor(() => expect(resolveAgentModelConfigMock).toHaveBeenCalledTimes(1));
rerender({ model: "model-b" });
await waitFor(() => {
expect(result.current.status).toBe("failed");
expect(result.current.isLoading).toBe(false);
});
expect(result.current.configOptions).toEqual([]);
expect(result.current.error).toBe(resolutionFailure);
});
});
describe("useResolvedModelConfig draft updates", () => {
@@ -448,15 +448,13 @@ export function useResolvedModelConfig(
if (result.kind === "error") {
setStatus("failed");
setError(result.error);
setResolvedOptions(stableInitialConfigOptions);
setResolvedOptions([]);
if (requestKey) setSettledRequestKey(requestKey);
} else {
const response = result.response;
setStatus(response.status);
setError(response.error ?? null);
setResolvedOptions(
response.status === "ok" ? (response.config_options ?? []) : stableInitialConfigOptions,
);
setResolvedOptions(response.status === "ok" ? (response.config_options ?? []) : []);
if (response.status === "ok" && requestKey) {
setResolvedRequestKey(requestKey);
}
@@ -0,0 +1,150 @@
---
spec: docs/specs/agents/dynamic-provider-options.md
created: 2026-08-19
status: implemented
---
# Implementation Plan: Model Options Loading Spinner
## Overview
Show model-option resolution progress inside the open agent profile selector.
The selector will keep the model list visible and replace stale option controls
with the existing localized loading state. Desktop and mobile Playwright tests
will hold the resolver response so that they can prove the transient state.
The change uses the current resolver and API contract. It does not change the
backend, persistence, or the error and retry behavior below the profile field.
## Backend
No backend change is required. The existing
`POST /api/v1/agent-models/:agentName/resolve` request owns the loading period.
## Frontend
### Shared model selector
Update `ModelConfigSelectorProps` in
`apps/web/components/model-config-selector.tsx`. Add
`configOptionsLoading?: boolean` and `keepOpenOnModelChange?: boolean`. Keep the
popover open when a caller expects model-dependent option resolution.
In `ModelConfigSelectorContent`, keep the model list visible during resolution.
Show a separated status row directly below that list. The row will use
`@kandev/ui/spinner` and `agents:resolvingModelOptions`.
The loading row will replace extra option controls while a request is pending.
It will use `data-testid="model-config-options-loading"` and an accessible
status name. Existing callers will keep their current close behavior unless
they enable asynchronous option resolution.
While the request is pending, the selected model row replaces its trailing
check icon with the same spinner. The check icon returns when the request
resolves or fails.
### Agent profile wiring
Update `apps/web/components/settings/profile-model-fields.tsx` so `ModelPicker`
accepts and passes `configOptionsLoading` and `keepOpenOnModelChange`.
Update `apps/web/components/settings/profile-form-fields.tsx` to pass
`configIsLoading` and `modelConfig.supports_dynamic_models` to the start-model
picker. Keep `ModelConfigResolutionStatus` below the field for save blocking,
errors, and retry actions.
### Mobile design note
The selector composition does not change. The existing touch-usable `Popover`
in `mobile-agent-profile-config-selector.spec.ts` is the closest mobile
example. This short, searchable picker remains a popover because the task only
adds a status row to its existing option area.
The page remains the scroll owner. The popover keeps its existing viewport
limits. The shared selector state and loading logic are identical on desktop
and mobile.
## Tests
- **What:** The shared selector stays open after a model change and shows the
loading row below the model list. The selected row shows a spinner while the
request is pending and restores its check icon afterward.
- **File:** `apps/web/components/model-config-selector.test.tsx`.
- **How:** Use a stateful component test. Start resolution from
`onModelChange`, make sure that stale option triggers are absent, and finish
the request with a resolved option.
- **What:** The profile form sends its resolver state into the shared selector.
- **File:** `apps/web/components/settings/profile-form-fields.test.tsx`.
- **How:** Hold `resolveAgentModelConfig`, select another model, and inspect the
loading and resolved states before and after the promise completes.
No locale change is required. The selector reuses
`agents:resolvingModelOptions` in all five locale catalogs.
## E2E Tests
- **Scenario:** Given an open desktop profile selector, when a model resolution
is pending, then the selected row and the status row show spinners. Resolved
controls replace the status row and the selected row restores its check after
the response.
- **File:** `apps/web/e2e/tests/settings/agent-profile-acp.spec.ts`.
- **What to verify:** The popover stays open. Both loading indicators are
visible. Stale option controls are absent. The loading indicators are
removed, the selected row shows its check, and the new model option appears
after the response.
- **Scenario:** Given the same flow on a phone, when a model resolution is
pending, then both spinners are visible and contained in the selector.
- **File:**
`apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts`.
- **What to verify:** Touch selection keeps the popover open. Both loading
indicators are visible and are removed after resolution, with the selected
row's check restored. The page has no horizontal overflow. The resolved
control remains reachable by touch.
Both tests will use `injectLatency` for the resolver route. This delay is the
test stimulus that makes the transient state observable.
## Verification Results
Implementation is complete. The task-defined checks passed:
- `pnpm install --frozen-lockfile` from `apps`.
- Focused Vitest for the shared selector and profile form: 2 files, 32 tests
passed.
- `pnpm run typecheck`, `pnpm run i18n:check`, and `pnpm run i18n:ratchet`.
- Desktop profile E2E: 4 tests passed.
- Mobile profile E2E: 2 tests passed.
- Disposable desktop and mobile PR-asset capture tests: 1 test passed in each
project. The screenshots were inspected, mapped in `manifest.json`, and
compressed for publication.
- `git diff --check` and Prettier checks for all changed source and test files.
Review fixup coverage also verifies that the selector stays open whenever
options are loading, the loading row has one accessible status announcement,
and both rejected and `status: failed` resolution responses clear stale
dependent options. The targeted regression suite now passes 46 tests.
## Implementation Waves And Parallel Candidates
Wave 1:
- [x] [task-01-selector-loading-state](task-01-selector-loading-state.md)
Wave 2:
- [x] [task-02-desktop-mobile-e2e](task-02-desktop-mobile-e2e.md)
The tasks are sequential because the Playwright tests require the selector
loading state from Task 01.
## Risks
- A model selection currently closes the selector when it has no extra option
controls. The new opt-in open behavior must start before React reports the
asynchronous loading state.
- The resolver cache can make the loading period short. Playwright must add
latency to the selected-model request instead of using a fixed wait.
- Baseline options can belong to the prior model. The loading row must replace
those controls until the authoritative snapshot arrives.
@@ -0,0 +1,86 @@
---
id: "01-selector-loading-state"
title: "Show selector loading state"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/agents/dynamic-provider-options.md"
---
# Task 01: Show Selector Loading State
## Acceptance conditions
1. A model-aware profile selector stays open after a model selection starts
option resolution.
2. The area below the model list shows a localized spinner and hides stale
option controls while resolution is pending.
3. The selected model row replaces its check icon with a spinner while
resolution is pending and restores the check icon afterward.
4. Resolved controls replace the spinner without changing the existing status,
error, or retry state below the profile field.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web exec vitest run components/model-config-selector.test.tsx components/settings/profile-form-fields.test.tsx --reporter=dot && cd web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet
```
## Files likely touched
- `apps/web/components/model-config-selector.tsx`
- `apps/web/components/model-config-selector.test.tsx`
- `apps/web/components/settings/profile-model-fields.tsx`
- `apps/web/components/settings/profile-form-fields.tsx`
- `apps/web/components/settings/profile-form-fields.test.tsx`
## Dependencies
None.
## Parallelism
Sequential. This task changes the shared selector contract and the profile
caller that uses it.
## Inputs
- The `What`, `Agent profile shows model-option progress`, and `Mobile
settings` sections in the feature spec.
- The `Frontend` and `Tests` sections in `plan.md`.
- `ModelConfigResolutionStatus` for the existing loading copy and status
behavior.
- `useProfileModelCapabilities` for the authoritative `configIsLoading` state.
## Output contract
Report the selector props, loading-row behavior, changed files, tests, risks,
and blockers. Update this task and `plan.md` with the exact results.
## Risks
- The selector must decide to stay open in the same event that changes the
model. It cannot wait for the next loading-state render.
- The loading row must not expose controls from the prior model snapshot.
## Results
Implemented the shared selector loading row and profile-form wiring. The
selector now stays open for dynamic profile models, hides stale dependent
controls during resolution, shows a spinner in the selected model row, and
restores both the check icon and resolved controls afterward. Existing callers
retain their previous close behavior by default.
The exact verification command passed:
- Focused Vitest: 2 files, 32 tests passed.
- `pnpm run typecheck` passed.
- `pnpm run i18n:check` passed. The command reported only existing orphan
catalog entries.
- `pnpm run i18n:ratchet` passed with no new violations.
Review fixup added regression coverage for the self-contained loading close
guard, the single accessible status announcement, and stale-option clearing
after rejected or failed resolution responses. The targeted suite now passes
46 tests.
@@ -0,0 +1,78 @@
---
id: "02-desktop-mobile-e2e"
title: "Prove selector loading state"
status: done
wave: 2
depends_on:
- "01-selector-loading-state"
plan: "plan.md"
spec: "../../specs/agents/dynamic-provider-options.md"
---
# Task 02: Prove Selector Loading State
## Acceptance conditions
1. Desktop Playwright coverage shows the loading row inside the open selector
and the selected-row spinner while the model-option response is pending.
2. Mobile Playwright coverage proves the same touch flow without horizontal
overflow.
3. Both tests prove that resolved option controls replace the status spinner
and the selected-row spinner is replaced by the check icon after the
response.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile && cd web && pnpm e2e:run --project chromium tests/settings/agent-profile-acp.spec.ts && pnpm e2e:run --project mobile-chrome tests/settings/mobile-agent-profile-config-selector.spec.ts
```
## Files likely touched
- `apps/web/e2e/tests/settings/agent-profile-acp.spec.ts`
- `apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts`
## Dependencies
- Task 01 provides the loading row and its stable selector.
## Parallelism
Sequential. These tests depend on Task 01 and modify existing profile flows.
## Inputs
- The `E2E Tests` and `Mobile design note` sections in `plan.md`.
- `apps/web/e2e/helpers/causal-waits.ts` for `injectLatency` and HTTP response
synchronization.
- The existing desktop and mobile profile model-option tests.
## Output contract
Report the delayed route, assertions, discovered test counts, changed files,
commands, risks, and blockers. Update this task and `plan.md` with the exact
results.
## Risks
- A cached response can skip the route delay. The test must select a model that
the page has not resolved.
- The delay is test stimulus. Do not add raw sleeps or larger assertion
timeouts.
## Results
Added resolver latency to the existing desktop and mobile profile flows. Both
flows assert that the selector remains open, both loading indicators are
visible, stale option controls are absent, the indicators are removed after
resolution, the selected-row check returns, and resolved controls return. The
mobile flow also asserts that the document has no horizontal overflow and uses
touch actions.
Verification passed:
- Desktop profile E2E: 4 tests passed.
- Mobile profile E2E: 2 tests passed.
- Disposable PR-asset capture: 1 desktop test and 1 mobile test passed. The
resulting screenshots were inspected, compressed, and verified against the
combined asset manifest.
+34 -11
View File
@@ -1,6 +1,7 @@
---
status: shipped
status: implemented
created: 2026-08-07
updated: 2026-08-19
owner: kandev
---
@@ -30,6 +31,9 @@ settings surfaces that do not have a live task session:
- After a user selects a model in the workflow session override editor or an
agent profile editor, Kandev requests a model-aware capability snapshot from
the provider through a sessionless ACP probe.
- In an agent profile editor, the open model selector stays open while Kandev
resolves the selected model. The area below the model list shows a localized
loading spinner instead of stale dependent options.
- The probe applies the requested model using generic ACP session-model logic,
captures the complete post-model `config_options` response or update, and
applies the existing ACP compatibility normalization.
@@ -60,6 +64,19 @@ to a model with additional selectable options, then the profile editor uses
the same resolved snapshot as workflow settings and allows those options to be
selected and saved.
### Agent profile shows model-option progress
Given an open agent profile model selector, when the author selects a model,
then option resolution starts. The selector stays open and shows a loading
spinner in the selected model row and below the model list. The selected row
does not show its check icon while it is resolving. The selector hides stale
dependent controls until the new option snapshot arrives.
Given that the option request is complete, when Kandev shows the resolved
snapshot or an error, then the selected row restores its check icon and the
loading spinners are not visible. The existing resolved controls or retryable
error state are available.
### Model removes an option
Given a draft with a selected option for the current model, when the author
@@ -80,8 +97,9 @@ change is required for the new identifier.
Given a model selection whose capability probe times out, is unavailable, or
returns no complete post-model option snapshot, when the settings page receives
the failure, then it keeps already persisted and currently selected values,
does not offer unverified new options, shows a retryable discovery error, and
allows a model-only save when the rest of the form is valid.
does not show stale dependent controls or offer unverified new options, shows a
retryable discovery error, and allows a model-only save when the rest of the
form is valid.
### Responses arrive out of order
@@ -103,6 +121,9 @@ resolved options in either settings flow, then controls remain in a one-column
touch-friendly layout, use the existing settings scroll owner, and do not
create document-level horizontal overflow.
Given an open agent profile model selector on a phone, when model options load,
then the same loading indicators are visible and remain inside the selector.
## Data model
No durable database schema is added.
@@ -186,14 +207,14 @@ conventions and never expose provider secrets or raw process output.
Each settings surface follows this state machine for the selected resolution
context:
| State | Event | Result |
| --- | --- | --- |
| baseline | model selected | Show baseline options and start one resolver request. |
| resolving | matching response | Replace options with the complete snapshot and reconcile the draft. |
| resolving | stale response | Ignore it and keep the newer model's state. |
| resolving | timeout/error | Keep existing values, show retryable error, and do not add controls. |
| resolved | same context requested | Reuse the runtime cache. |
| resolved | refresh or baseline invalidation | Request a fresh snapshot. |
| State | Event | Result |
| --------- | -------------------------------- | -------------------------------------------------------------------- |
| baseline | model selected | Show baseline options and start one resolver request. |
| resolving | matching response | Replace options with the complete snapshot and reconcile the draft. |
| resolving | stale response | Ignore it and keep the newer model's state. |
| resolving | timeout/error | Keep existing values, show retryable error, and do not add controls. |
| resolved | same context requested | Reuse the runtime cache. |
| resolved | refresh or baseline invalidation | Request a fresh snapshot. |
An option snapshot is replaced atomically. The frontend must not merge an old
model's choices with a new model's choices.
@@ -254,5 +275,7 @@ task session, change an agent profile, or mutate provider configuration.
out-of-order response.
- Resolver failures preserve saved data and provide a retryable, localized
status.
- The agent profile model selector shows localized progress below the model
list while model-dependent options load.
- Desktop and mobile E2E coverage verifies workflow and profile behavior, with
touch sizing and overflow assertions for the mobile workflow editor.