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
This commit is contained in:
gsxdsm
2026-06-08 12:49:53 -07:00
parent 5976b63fda
commit c8cb740b93
4 changed files with 369 additions and 31 deletions

View File

@@ -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<Record<string, WorkflowSettingRejection>>({});
const [saving, setSaving] = useState(false);
const [orphanOpen, setOrphanOpen] = useState(false);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
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 (
<div key={pair.id} className="wf-settings-value-item" data-testid={`wf-settings-value-${pair.id}`}>
<SettingsFieldRow
htmlFor={`wf-model-lane-${pair.id}`}
label={pair.label}
help={`${pair.help}${emptyHelp}`}
error={error}
scope="project"
disabled={modelsLoading}
clearable={customized}
onClear={() => setModelPairValue(pair, "")}
>
<CustomModelDropdown
id={`wf-model-lane-${pair.id}`}
label={pair.label}
models={availableModels}
value={value}
onChange={(next) => setModelPairValue(pair, next)}
placeholder={t("workflowSettings.selectModel", "Select a model…")}
defaultOptionLabel={t("workflowSettings.useInheritedModel", "Use inherited/default model")}
disabled={dropdownDisabled}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
/>
</SettingsFieldRow>
{customized && (
<span className="wf-settings-customized" data-testid={`wf-settings-customized-${pair.id}`}>
{t("workflowSettings.customized", "Customized")}
</span>
)}
</div>
);
};
const renderValueControl = (setting: WorkflowSettingDefinition) => {
const value = effectiveOf(setting);
const error = rejections[setting.id]?.message;
@@ -739,23 +932,35 @@ function ValuesTab({
</p>
) : (
<div className="wf-settings-values-list">
{groupWorkflowSettings(settings).map(({ group, settings: groupSettings }) => (
<section key={group} className="wf-settings-value-group" data-testid={`wf-settings-group-${group}`}>
<h4 className="wf-settings-value-group-title">
{t(`workflowSettings.group.${group}`, WORKFLOW_SETTING_GROUP_LABELS[group])}
</h4>
{groupSettings.map((setting) => (
<div key={setting.id} className="wf-settings-value-item" data-testid={`wf-settings-value-${setting.id}`}>
{renderValueControl(setting)}
{isCustomized(setting) && (
<span className="wf-settings-customized" data-testid={`wf-settings-customized-${setting.id}`}>
{t("workflowSettings.customized", "Customized")}
</span>
)}
</div>
))}
</section>
))}
{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 (
<section key={group} className="wf-settings-value-group" data-testid={`wf-settings-group-${group}`}>
<h4 className="wf-settings-value-group-title">
{t(`workflowSettings.group.${group}`, WORKFLOW_SETTING_GROUP_LABELS[group])}
</h4>
{groupPairs.map((pair) => renderModelPairControl(pair))}
{primitiveSettings.map((setting) => (
<div key={setting.id} className="wf-settings-value-item" data-testid={`wf-settings-value-${setting.id}`}>
{renderValueControl(setting)}
{isCustomized(setting) && (
<span className="wf-settings-customized" data-testid={`wf-settings-customized-${setting.id}`}>
{t("workflowSettings.customized", "Customized")}
</span>
)}
</div>
))}
</section>
);
})}
</div>
)}

View File

@@ -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<typeof import("../../api")>("../../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(<Host initial={modelDecls} readOnly />);
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(<Host initial={modelDecls} readOnly />);
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(<Host initial={modelDecls} readOnly />);
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(<Host initial={modelDecls} readOnly />);
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(<Host initial={modelDecls} readOnly />);
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(<Host initial={modelDecls} readOnly />);
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",
);
});
});