fix(web): show model option loading state (#2806)
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user