(null);
@@ -412,30 +419,46 @@ function ModelConfigSelectorContent({
key={model.id}
model={model}
selected={model.id === currentModelValue}
+ loading={configOptionsLoading}
onSelect={onModelSelect}
/>
))}
- {extraConfigOptions.length > 0 && (
+ {configOptionsLoading ? (
<>
-
-
- {extraConfigOptions.map((option) => (
- onConfigSelect(option.id)}
- triggerRef={(element) => {
- triggerRefs.current[option.id] = element;
- }}
- />
- ))}
-
-
+
+
+ {t("agents:resolvingModelOptions")}
+
>
+ ) : (
+ extraConfigOptions.length > 0 && (
+ <>
+
+
+
+ {extraConfigOptions.map((option) => (
+ onConfigSelect(option.id)}
+ triggerRef={(element) => {
+ triggerRefs.current[option.id] = element;
+ }}
+ />
+ ))}
+
+
+ >
+ )
)}
>
);
@@ -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}
/>
diff --git a/apps/web/components/settings/profile-form-fields.test.tsx b/apps/web/components/settings/profile-form-fields.test.tsx
index 753d671f6f..33bd24f08a 100644
--- a/apps/web/components/settings/profile-form-fields.test.tsx
+++ b/apps/web/components/settings/profile-form-fields.test.tsx
@@ -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 = {
diff --git a/apps/web/components/settings/profile-form-fields.tsx b/apps/web/components/settings/profile-form-fields.tsx
index 129e5bed0a..3aa723be26 100644
--- a/apps/web/components/settings/profile-form-fields.tsx
+++ b/apps/web/components/settings/profile-form-fields.tsx
@@ -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}
/>
{hasModes && (
diff --git a/apps/web/components/settings/profile-model-fields.tsx b/apps/web/components/settings/profile-model-fields.tsx
index b62e2e1c8b..d24c25cfef 100644
--- a/apps/web/components/settings/profile-model-fields.tsx
+++ b/apps/web/components/settings/profile-model-fields.tsx
@@ -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}
/>
);
diff --git a/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts b/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts
index 65f2ee8140..83f3b9ccb3 100644
--- a/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts
+++ b/apps/web/e2e/tests/settings/agent-profile-acp.spec.ts
@@ -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();
diff --git a/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts b/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts
index 01dc58df70..5077e85461 100644
--- a/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts
+++ b/apps/web/e2e/tests/settings/mobile-agent-profile-config-selector.spec.ts
@@ -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,
});
diff --git a/apps/web/hooks/domains/settings/use-dynamic-models.test.ts b/apps/web/hooks/domains/settings/use-dynamic-models.test.ts
index 533c9353a2..052e315529 100644
--- a/apps/web/hooks/domains/settings/use-dynamic-models.test.ts
+++ b/apps/web/hooks/domains/settings/use-dynamic-models.test.ts
@@ -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", () => {
diff --git a/apps/web/hooks/domains/settings/use-dynamic-models.ts b/apps/web/hooks/domains/settings/use-dynamic-models.ts
index 03773f3557..ec6db23d94 100644
--- a/apps/web/hooks/domains/settings/use-dynamic-models.ts
+++ b/apps/web/hooks/domains/settings/use-dynamic-models.ts
@@ -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);
}
diff --git a/docs/plans/model-options-loading-spinner/plan.md b/docs/plans/model-options-loading-spinner/plan.md
new file mode 100644
index 0000000000..f30d758654
--- /dev/null
+++ b/docs/plans/model-options-loading-spinner/plan.md
@@ -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.
diff --git a/docs/plans/model-options-loading-spinner/task-01-selector-loading-state.md b/docs/plans/model-options-loading-spinner/task-01-selector-loading-state.md
new file mode 100644
index 0000000000..56e87959fc
--- /dev/null
+++ b/docs/plans/model-options-loading-spinner/task-01-selector-loading-state.md
@@ -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.
diff --git a/docs/plans/model-options-loading-spinner/task-02-desktop-mobile-e2e.md b/docs/plans/model-options-loading-spinner/task-02-desktop-mobile-e2e.md
new file mode 100644
index 0000000000..5f23291c0e
--- /dev/null
+++ b/docs/plans/model-options-loading-spinner/task-02-desktop-mobile-e2e.md
@@ -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.
diff --git a/docs/specs/agents/dynamic-provider-options.md b/docs/specs/agents/dynamic-provider-options.md
index 8faa5a11ad..b62e4e46ee 100644
--- a/docs/specs/agents/dynamic-provider-options.md
+++ b/docs/specs/agents/dynamic-provider-options.md
@@ -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.