From c8cb740b93804c4b64c7f2b68e60c99ae3089c33 Mon Sep 17 00:00:00 2001
From: gsxdsm
Date: Mon, 8 Jun 2026 12:49:53 -0700
Subject: [PATCH] FN-6026: use model dropdowns for workflow lane settings
Use the shared model picker for workflow lane settings instead of raw provider/model text entry.
- replace built-in workflow model provider/model string fields with combined dropdown controls in the Values tab
- fetch available models and save or clear paired provider/model workflow values together, including fallback lanes
- add regression coverage for dropdown rendering, selection, clearing, empty registries, and paired-key validation errors
- update dashboard and settings documentation to describe the shared workflow model picker behavior
Files changed:
docs/dashboard-guide.md | 4 +-
docs/settings-reference.md | 22 +-
packages/dashboard/app/components/WorkflowSettingsPanel.tsx | 241 +++++++++++++++++++--
packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx | 133 +++++++++++-
4 files changed, 369 insertions(+), 31 deletions(-)
Fusion-Task-Id: FN-6026
Fusion-Task-Lineage: 7f41120f-f764-4011-9aa5-a323e0aa64d6
---
docs/dashboard-guide.md | 4 +-
docs/settings-reference.md | 22 +-
.../app/components/WorkflowSettingsPanel.tsx | 241 ++++++++++++++++--
.../__tests__/WorkflowSettingsPanel.test.tsx | 133 +++++++++-
4 files changed, 369 insertions(+), 31 deletions(-)
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 13fe5d076f..41497bfd16 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -108,8 +108,8 @@ Navigation:
Behavior:
- Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels
- Read-only built-in workflows are inspectable in the same canvas as custom workflows, including connected success, failure, and rework edges for their graph topology.
-- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Definitions remain available for custom workflow schema authoring.
-- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; those controls write workflow setting values for the active default workflow.
+- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring.
+- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; those dropdown controls write workflow setting values for the active default workflow.
- On desktop, the editor uses a multi-panel layout for editing the graph and adjacent workflow metadata
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet, opens to the workflow list with no workflow preselected, prompts users to select a workflow to edit, and uses larger workflow-editor touch targets so each section remains scrollable and usable on phones
- The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens
diff --git a/docs/settings-reference.md b/docs/settings-reference.md
index 70ca351211..3ebd4e11f1 100644
--- a/docs/settings-reference.md
+++ b/docs/settings-reference.md
@@ -183,9 +183,9 @@ govern that execution belong to the workflow.
**Where to set them.** The common model lanes for a project's default workflow are
available directly in **Settings → Project Models → Default workflow model lanes**:
-Plan/Triage, Executor, and Reviewer. Those controls still write workflow setting
-values for the active project's default workflow; they do not restore the old
-project settings keys.
+Plan/Triage, Executor, and Reviewer. Those dropdown controls use the shared model
+picker and still write workflow setting values for the active project's default
+workflow; they do not restore the old project settings keys.
For step execution, review/approval policy, fallbacks, title summarization, and
custom workflow settings, open the **workflow editor** (the workflow node editor in
@@ -194,8 +194,11 @@ the dashboard) and select the **Settings** panel. It has two tabs:
- **Definitions** — the typed declarations and defaults (read-only for the built-in
`builtin:coding` workflow; editable for custom workflows).
- **Values** — the per-project values for the workflow that is open. Values are
- editable for any workflow, including built-ins. Edits batch and commit through a
- single **Save** in the Values tab.
+ editable for any workflow, including built-ins. Common provider/model lane pairs
+ (Plan/Triage, Executor, Reviewer, and fallbacks declared by the workflow) use the
+ same model dropdown picker as Project Models so clearing or selecting a model
+ updates both keys together. Advanced/custom non-model settings still use typed
+ controls. Edits batch and commit through a single **Save** in the Values tab.
**How values resolve.** The engine resolves *effective settings* per task as
`stored value ?? declaration default`. A built-in workflow with no stored value
@@ -231,9 +234,10 @@ These groups moved out of project settings and into workflow settings (built-in
| **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks) |
In the dashboard Settings modal, Project Models now exposes Plan/Triage, Executor,
-and Reviewer controls for the default workflow. Former locations for advanced
-workflow policy still show a short redirect stub linking to the workflow editor
-(for one release).
+and Reviewer dropdown controls for the default workflow. The workflow editor's
+Settings → Values tab uses the same dropdown picker for declared provider/model
+pairs, including fallbacks. Former locations for advanced workflow policy still
+show a short redirect stub linking to the workflow editor (for one release).
> Note: the global baseline model lanes (`executionGlobalProvider` etc.) and
> integrity guarantees stay where they are — only the per-workflow process policy
@@ -766,7 +770,7 @@ Short-lived token bounds are enforced server-side:
## Model Selection Hierarchy
-Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited from Settings -> Project Models -> Default workflow model lanes.
+Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited with dropdown controls from Settings -> Project Models -> Default workflow model lanes or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks.
### Planning model
diff --git a/packages/dashboard/app/components/WorkflowSettingsPanel.tsx b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx
index 7a033fd0d5..04bcf7727b 100644
--- a/packages/dashboard/app/components/WorkflowSettingsPanel.tsx
+++ b/packages/dashboard/app/components/WorkflowSettingsPanel.tsx
@@ -26,7 +26,7 @@
* silently rebinding writes. With no active project it shows a requires-project
* state and no write path.
*/
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, AlertTriangle, ChevronRight, ChevronDown, Save, RotateCcw } from "lucide-react";
import type {
@@ -36,9 +36,11 @@ import type {
WorkflowSettingRejection,
} from "../api";
import {
+ fetchModels,
fetchWorkflowSettingValues,
updateWorkflowSettingValues,
ApiRequestError,
+ type ModelInfo,
type WorkflowSettingValuesPayload,
} from "../api";
import {
@@ -47,6 +49,7 @@ import {
WORKFLOW_SETTING_GROUP_LABELS,
} from "./workflow-setting-display";
import {
+ SettingsFieldRow,
SettingsToggleRow,
SettingsNumberRow,
SettingsSelectRow,
@@ -54,6 +57,7 @@ import {
SettingsTextareaRow,
} from "./settings";
import type { ToastType } from "../hooks/useToast";
+import { CustomModelDropdown } from "./CustomModelDropdown";
import "./WorkflowSettingsPanel.css";
interface WorkflowSettingsPanelProps {
@@ -487,6 +491,73 @@ function rawValueDisplay(value: unknown): string {
}
}
+interface WorkflowModelLanePair {
+ id: string;
+ providerId: string;
+ modelId: string;
+ label: string;
+ help: string;
+}
+
+const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
+ {
+ id: "planning",
+ providerId: "planningProvider",
+ modelId: "planningModelId",
+ label: "Plan/Triage Model",
+ help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the default lane.",
+ },
+ {
+ id: "execution",
+ providerId: "executionProvider",
+ modelId: "executionModelId",
+ label: "Executor Model",
+ help: "Provider and model used by task implementation agents. Leave unset to inherit from the default lane.",
+ },
+ {
+ id: "validator",
+ providerId: "validatorProvider",
+ modelId: "validatorModelId",
+ label: "Reviewer Model",
+ help: "Provider and model used by review and validation agents. Leave unset to inherit from the default lane.",
+ },
+ {
+ id: "planning-fallback",
+ providerId: "planningFallbackProvider",
+ modelId: "planningFallbackModelId",
+ label: "Planning Fallback Model",
+ help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.",
+ },
+ {
+ id: "validator-fallback",
+ providerId: "validatorFallbackProvider",
+ modelId: "validatorFallbackModelId",
+ label: "Reviewer Fallback Model",
+ help: "Fallback provider and model used when the primary Reviewer model cannot be used.",
+ },
+ {
+ id: "title-summarizer",
+ providerId: "titleSummarizerProvider",
+ modelId: "titleSummarizerModelId",
+ label: "Title Summarizer Model",
+ help: "Provider and model used for title summarization when this workflow declares the lane.",
+ },
+ {
+ id: "title-summarizer-fallback",
+ providerId: "titleSummarizerFallbackProvider",
+ modelId: "titleSummarizerFallbackModelId",
+ label: "Title Summarizer Fallback Model",
+ help: "Fallback provider and model used for title summarization when this workflow declares the lane.",
+ },
+];
+
+function splitModelDropdownValue(value: string): { provider: string; modelId: string } | null {
+ if (!value) return null;
+ const slashIdx = value.indexOf("/");
+ if (slashIdx <= 0 || slashIdx === value.length - 1) return null;
+ return { provider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1) };
+}
+
function ValuesTab({
workflowId,
settings,
@@ -510,7 +581,27 @@ function ValuesTab({
const [rejections, setRejections] = useState>({});
const [saving, setSaving] = useState(false);
const [orphanOpen, setOrphanOpen] = useState(false);
+ const [availableModels, setAvailableModels] = useState([]);
+ const [modelsLoading, setModelsLoading] = useState(false);
+ const [favoriteProviders, setFavoriteProviders] = useState([]);
+ const [favoriteModels, setFavoriteModels] = useState([]);
const reqSeq = useRef(0);
+ const modelReqSeq = useRef(0);
+
+ const settingsById = useMemo(() => new Map(settings.map((setting) => [setting.id, setting])), [settings]);
+ const modelLanePairs = useMemo(
+ () =>
+ WORKFLOW_MODEL_LANE_CATALOG.filter((pair) => {
+ const provider = settingsById.get(pair.providerId);
+ const model = settingsById.get(pair.modelId);
+ return provider?.type === "string" && model?.type === "string";
+ }),
+ [settingsById],
+ );
+ const modelPairSettingIds = useMemo(
+ () => new Set(modelLanePairs.flatMap((pair) => [pair.providerId, pair.modelId])),
+ [modelLanePairs],
+ );
const staleContext =
boundProjectId !== undefined && currentProjectId !== undefined && currentProjectId !== boundProjectId;
@@ -537,6 +628,36 @@ function ValuesTab({
void load();
}, [load]);
+ useEffect(() => {
+ if (boundProjectId === undefined || modelLanePairs.length === 0) {
+ setAvailableModels([]);
+ setFavoriteProviders([]);
+ setFavoriteModels([]);
+ setModelsLoading(false);
+ return;
+ }
+ const seq = ++modelReqSeq.current;
+ setModelsLoading(true);
+ fetchModels()
+ .then((res) => {
+ if (modelReqSeq.current !== seq) return;
+ setAvailableModels(res.models ?? []);
+ setFavoriteProviders(res.favoriteProviders ?? []);
+ setFavoriteModels(res.favoriteModels ?? []);
+ })
+ .catch(() => {
+ if (modelReqSeq.current === seq) {
+ setAvailableModels([]);
+ setFavoriteProviders([]);
+ setFavoriteModels([]);
+ addToast(t("workflowSettings.modelsLoadFailed", "Failed to load available models"), "error");
+ }
+ })
+ .finally(() => {
+ if (modelReqSeq.current === seq) setModelsLoading(false);
+ });
+ }, [boundProjectId, modelLanePairs.length, addToast, t]);
+
// No active project bound → requires-project state, no write path.
if (boundProjectId === undefined) {
return (
@@ -580,6 +701,22 @@ function ValuesTab({
const clearValue = (id: string) => setValue(id, null);
+ const setModelPairValue = (pair: WorkflowModelLanePair, value: string) => {
+ const split = splitModelDropdownValue(value);
+ setPending((prev) => ({
+ ...prev,
+ [pair.providerId]: split?.provider ?? null,
+ [pair.modelId]: split?.modelId ?? null,
+ }));
+ setRejections((prev) => {
+ if (!prev[pair.providerId] && !prev[pair.modelId]) return prev;
+ const next = { ...prev };
+ delete next[pair.providerId];
+ delete next[pair.modelId];
+ return next;
+ });
+ };
+
const dirty = Object.keys(pending).length > 0;
const save = useCallback(async () => {
@@ -611,6 +748,62 @@ function ValuesTab({
}
}, [dirty, workflowId, pending, boundProjectId, addToast, t]);
+ const valueOfSettingId = (id: string): unknown => {
+ const setting = settingsById.get(id);
+ return setting ? effectiveOf(setting) : undefined;
+ };
+
+ const isCustomizedId = (id: string): boolean => {
+ const setting = settingsById.get(id);
+ return setting ? isCustomized(setting) : false;
+ };
+
+ const renderModelPairControl = (pair: WorkflowModelLanePair) => {
+ const providerValue = valueOfSettingId(pair.providerId);
+ const modelValue = valueOfSettingId(pair.modelId);
+ const value = typeof providerValue === "string" && typeof modelValue === "string" ? `${providerValue}/${modelValue}` : "";
+ const error = rejections[pair.providerId]?.message ?? rejections[pair.modelId]?.message;
+ const customized = isCustomizedId(pair.providerId) || isCustomizedId(pair.modelId);
+ const dropdownDisabled = modelsLoading || availableModels.length === 0;
+ const emptyHelp =
+ !modelsLoading && availableModels.length === 0
+ ? ` ${t("workflowSettings.noModelsAvailable", "No models are available. Configure authentication before selecting a workflow model.")}`
+ : "";
+
+ return (
+
+ setModelPairValue(pair, "")}
+ >
+ setModelPairValue(pair, next)}
+ placeholder={t("workflowSettings.selectModel", "Select a model…")}
+ defaultOptionLabel={t("workflowSettings.useInheritedModel", "Use inherited/default model")}
+ disabled={dropdownDisabled}
+ favoriteProviders={favoriteProviders}
+ favoriteModels={favoriteModels}
+ />
+
+ {customized && (
+
+ {t("workflowSettings.customized", "Customized")}
+
+ )}
+
+ );
+ };
+
const renderValueControl = (setting: WorkflowSettingDefinition) => {
const value = effectiveOf(setting);
const error = rejections[setting.id]?.message;
@@ -739,23 +932,35 @@ function ValuesTab({
) : (
- {groupWorkflowSettings(settings).map(({ group, settings: groupSettings }) => (
-
-
- {t(`workflowSettings.group.${group}`, WORKFLOW_SETTING_GROUP_LABELS[group])}
-
- {groupSettings.map((setting) => (
-
- {renderValueControl(setting)}
- {isCustomized(setting) && (
-
- {t("workflowSettings.customized", "Customized")}
-
- )}
-
- ))}
-
- ))}
+ {groupWorkflowSettings(settings).map(({ group, settings: groupSettings }) => {
+ const groupSettingIds = new Set(groupSettings.map((setting) => setting.id));
+ const groupPairs =
+ group === "models"
+ ? modelLanePairs.filter(
+ (pair) => groupSettingIds.has(pair.providerId) && groupSettingIds.has(pair.modelId),
+ )
+ : [];
+ const primitiveSettings = groupSettings.filter((setting) => !modelPairSettingIds.has(setting.id));
+
+ return (
+
+
+ {t(`workflowSettings.group.${group}`, WORKFLOW_SETTING_GROUP_LABELS[group])}
+
+ {groupPairs.map((pair) => renderModelPairControl(pair))}
+ {primitiveSettings.map((setting) => (
+
+ {renderValueControl(setting)}
+ {isCustomized(setting) && (
+
+ {t("workflowSettings.customized", "Customized")}
+
+ )}
+
+ ))}
+
+ );
+ })}
)}
diff --git a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx
index 0aa705f569..72a132bd78 100644
--- a/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx
+++ b/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx
@@ -16,11 +16,12 @@ import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
expect.extend(jestDomMatchers);
// Keep the real module (type re-exports, ApiRequestError, every other helper)
-// and override only the two value-endpoint functions.
+// and override only the model/value endpoint functions.
vi.mock("../../api", async () => {
const actual = await vi.importActual("../../api");
return {
...actual,
+ fetchModels: vi.fn(),
fetchWorkflowSettingValues: vi.fn(),
updateWorkflowSettingValues: vi.fn(),
};
@@ -31,6 +32,7 @@ import type { WorkflowSettingDefinition, WorkflowSettingValuesPayload } from "..
import { ApiRequestError } from "../../api";
import { WorkflowSettingsPanel } from "../WorkflowSettingsPanel";
+const mockFetchModels = vi.mocked(apiModule.fetchModels);
const mockFetchValues = vi.mocked(apiModule.fetchWorkflowSettingValues);
const mockUpdateValues = vi.mocked(apiModule.updateWorkflowSettingValues);
@@ -70,7 +72,17 @@ function Host({
const openValues = () => fireEvent.click(screen.getByTestId("wf-settings-tab-values"));
const openDefinitions = () => fireEvent.click(screen.getByTestId("wf-settings-tab-definitions"));
+const modelResponse = {
+ models: [
+ { provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 },
+ { provider: "anthropic", id: "claude-sonnet", name: "Claude Sonnet", reasoning: true, contextWindow: 200000 },
+ ],
+ favoriteProviders: [],
+ favoriteModels: [],
+};
+
beforeEach(() => {
+ mockFetchModels.mockResolvedValue(modelResponse);
mockFetchValues.mockResolvedValue(payload());
mockUpdateValues.mockResolvedValue(payload());
});
@@ -192,7 +204,7 @@ describe("WorkflowSettingsPanel — Values tab", () => {
expect(within(screen.getByTestId("wf-settings-group-review")).getByText("Review & Approval")).toBeInTheDocument();
expect(within(screen.getByTestId("wf-settings-group-steps")).getByText("Step Execution")).toBeInTheDocument();
expect(within(screen.getByTestId("wf-settings-group-advanced")).getByText("Advanced")).toBeInTheDocument();
- expect(screen.getByLabelText("Plan/Triage provider")).toBeInTheDocument();
+ expect(screen.getByLabelText("Plan/Triage Model")).toBeInTheDocument();
expect(screen.getByLabelText("Reviewer provider")).toBeInTheDocument();
});
@@ -312,4 +324,121 @@ describe("WorkflowSettingsPanel — Values tab", () => {
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { "timeout-ms": null }, "proj-1"));
});
+
+ const modelDecls: WorkflowSettingDefinition[] = [
+ { id: "planningProvider", name: "Planning provider", type: "string" },
+ { id: "planningModelId", name: "Planning model", type: "string" },
+ { id: "executionProvider", name: "Execution provider", type: "string" },
+ { id: "executionModelId", name: "Execution model", type: "string" },
+ { id: "validatorProvider", name: "Validator provider", type: "string" },
+ { id: "validatorModelId", name: "Validator model", type: "string" },
+ { id: "planningFallbackProvider", name: "Planning fallback provider", type: "string" },
+ { id: "planningFallbackModelId", name: "Planning fallback model", type: "string" },
+ { id: "customModelProvider", name: "Custom model provider", type: "string" },
+ ];
+
+ async function openPlanningDropdown() {
+ const trigger = await screen.findByLabelText("Plan/Triage Model");
+ fireEvent.click(trigger);
+ return trigger;
+ }
+
+ it("renders built-in model lane pairs as dropdowns without raw provider/model text inputs", async () => {
+ mockFetchValues.mockResolvedValue(
+ payload({
+ stored: { planningProvider: "openai", planningModelId: "gpt-5" },
+ effective: { planningProvider: "openai", planningModelId: "gpt-5" },
+ }),
+ );
+ render();
+
+ await waitFor(() => expect(mockFetchModels).toHaveBeenCalledTimes(1));
+ expect(screen.getByLabelText("Plan/Triage Model")).toHaveTextContent("GPT-5");
+ expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
+ expect(screen.getByLabelText("Reviewer Model")).toBeInTheDocument();
+ expect(screen.getByLabelText("Planning Fallback Model")).toBeInTheDocument();
+ expect(screen.queryByRole("textbox", { name: "Plan/Triage provider" })).not.toBeInTheDocument();
+ expect(screen.queryByRole("textbox", { name: "Plan/Triage model" })).not.toBeInTheDocument();
+ expect(screen.getByLabelText("Custom model provider")).toBeInTheDocument();
+ expect(screen.getByTestId("wf-settings-customized-planning")).toBeInTheDocument();
+ });
+
+ it("selecting a workflow model writes provider and model id together", async () => {
+ render();
+ await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
+
+ await openPlanningDropdown();
+ fireEvent.click(await screen.findByRole("option", { name: /Claude Sonnet/i }));
+ fireEvent.click(screen.getByTestId("wf-settings-save-values"));
+
+ await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
+ expect(mockUpdateValues).toHaveBeenCalledWith(
+ "wf-1",
+ { planningProvider: "anthropic", planningModelId: "claude-sonnet" },
+ "proj-1",
+ );
+ });
+
+ it("clearing a workflow model dropdown writes paired null values", async () => {
+ mockFetchValues.mockResolvedValue(
+ payload({
+ stored: { planningProvider: "openai", planningModelId: "gpt-5" },
+ effective: { planningProvider: "openai", planningModelId: "gpt-5" },
+ }),
+ );
+ render();
+ await waitFor(() => expect(screen.getByTestId("wf-settings-customized-planning")).toBeInTheDocument());
+
+ await openPlanningDropdown();
+ fireEvent.click(await screen.findByRole("option", { name: /Use inherited\/default model/i }));
+ fireEvent.click(screen.getByTestId("wf-settings-save-values"));
+
+ await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
+ expect(mockUpdateValues).toHaveBeenCalledWith(
+ "wf-1",
+ { planningProvider: null, planningModelId: null },
+ "proj-1",
+ );
+ });
+
+ it("shows inherited/default dropdown state for undefined values without a customized badge", async () => {
+ render();
+ await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1"));
+ expect(screen.getByLabelText("Plan/Triage Model")).toHaveTextContent("Use inherited/default model");
+ expect(screen.queryByTestId("wf-settings-customized-planning")).not.toBeInTheDocument();
+ });
+
+ it("keeps known model lanes dropdown-backed when the model registry is empty", async () => {
+ mockFetchModels.mockResolvedValueOnce({ ...modelResponse, models: [] });
+ render();
+
+ await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
+ const trigger = screen.getByLabelText("Plan/Triage Model");
+ expect(trigger).toBeDisabled();
+ expect(screen.getAllByText(/No models are available/i).length).toBeGreaterThan(0);
+ expect(screen.queryByLabelText(/^Plan\/Triage provider$/i)).not.toBeInTheDocument();
+ });
+
+ it("surfaces paired-key rejections on the combined row while preserving pending selection", async () => {
+ mockUpdateValues.mockRejectedValueOnce(
+ new ApiRequestError("rejected", 400, {
+ rejections: [{ code: "type-mismatch", settingId: "planningModelId", message: "model is invalid" }],
+ }),
+ );
+ render();
+ await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
+
+ await openPlanningDropdown();
+ fireEvent.click(await screen.findByRole("option", { name: /Claude Sonnet/i }));
+ fireEvent.click(screen.getByTestId("wf-settings-save-values"));
+
+ const row = await screen.findByTestId("wf-settings-value-planning");
+ expect(within(row).getByRole("alert")).toHaveTextContent("model is invalid");
+ expect(within(row).getByLabelText("Plan/Triage Model")).toHaveTextContent("Claude Sonnet");
+ expect(mockUpdateValues).toHaveBeenCalledWith(
+ "wf-1",
+ { planningProvider: "anthropic", planningModelId: "claude-sonnet" },
+ "proj-1",
+ );
+ });
});