diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index 65907bff91..013e951c7c 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -1675,6 +1675,20 @@ flex: 1; } +.settings-model-lane-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md) var(--space-xl) var(--space-lg); + margin-block: var(--space-sm) var(--space-lg); + border-bottom: var(--btn-border-width) solid var(--border); +} + +.settings-error { + color: var(--color-error); +} + .settings-summarization-actions { justify-content: flex-start; } @@ -1924,6 +1938,12 @@ padding: 0 var(--space-lg); } + .settings-model-lane-actions { + align-items: stretch; + flex-direction: column; + padding-inline: calc(var(--space-md) + var(--space-xs) / 2); + } + .settings-content .btn, .settings-preset-item-actions .btn { min-height: 36px; diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx index 57f64cb80d..9e790fb82f 100644 --- a/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SettingsModal.test.tsx @@ -1526,9 +1526,11 @@ describe("SettingsModal", () => { async function setupWorkflowModelLaneTest({ stored = {}, effective = {}, + renderProps = {}, }: { stored?: Record; effective?: Record; + renderProps?: Partial>; } = {}) { mockFetchSettings.mockResolvedValue({ ...defaultSettings, @@ -1549,7 +1551,7 @@ describe("SettingsModal", () => { orphaned: [], }); - renderModal({ initialSection: "project-models", projectId: "proj-1" }); + renderModal({ initialSection: "project-models", projectId: "proj-1", ...renderProps }); await waitForSettingsModalReady(); await waitFor(() => { @@ -1557,6 +1559,22 @@ describe("SettingsModal", () => { }); } + it("renders the workflow model save actions inside the default workflow lane section", async () => { + const onOpenWorkflowSettings = vi.fn(); + await setupWorkflowModelLaneTest({ renderProps: { onOpenWorkflowSettings } }); + + const workflowHeading = screen.getByRole("heading", { name: "Default workflow model lanes" }); + const saveButton = screen.getByTestId("save-workflow-model-lanes"); + const actionRow = saveButton.closest(".settings-model-lane-actions"); + const presetsHeading = screen.getByRole("heading", { name: "Model Presets" }); + + expect(actionRow).toBeInTheDocument(); + expect(actionRow).toHaveAttribute("aria-label", "Default workflow model lane actions"); + expect(within(actionRow as HTMLElement).getByRole("button", { name: "Advanced workflow policy" })).toBeInTheDocument(); + expect(workflowHeading.compareDocumentPosition(actionRow as HTMLElement) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect((actionRow as HTMLElement).compareDocumentPosition(presetsHeading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + it.each([ ["Plan/Triage Model", { planningProvider: "openai", planningModelId: "gpt-4o" }], ["Executor Model", { executionProvider: "openai", executionModelId: "gpt-4o" }], diff --git a/packages/dashboard/app/components/__tests__/SettingsModal.workflow-model-lane-css.test.ts b/packages/dashboard/app/components/__tests__/SettingsModal.workflow-model-lane-css.test.ts new file mode 100644 index 0000000000..685891c165 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/SettingsModal.workflow-model-lane-css.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { loadAllAppCss } from "../../test/cssFixture"; + +describe("SettingsModal workflow model lane CSS contract", () => { + it("gives the save workflow models action row dedicated tokenized spacing", async () => { + const css = await loadAllAppCss(); + const block = css.match(/\.settings-model-lane-actions\s*\{[\s\S]*?\}/)?.[0] ?? ""; + + expect(block).toContain("display: flex"); + expect(block).toContain("flex-wrap: wrap"); + expect(block).toContain("gap: var(--space-sm)"); + expect(block).toContain("padding: var(--space-md) var(--space-xl) var(--space-lg)"); + expect(block).toContain("margin-block: var(--space-sm) var(--space-lg)"); + expect(block).toContain("border-bottom: var(--btn-border-width) solid var(--border)"); + expect(block).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgba?\(/); + expect(block).not.toMatch(/(? { + const css = await loadAllAppCss(); + + expect(css).toMatch(/@media[^{}]*\(max-width:\s*768px\)[\s\S]*?\.settings-model-lane-actions\s*\{[^}]*align-items\s*:\s*stretch;[^}]*flex-direction\s*:\s*column;[^}]*padding-inline\s*:\s*calc\(var\(--space-md\) \+ var\(--space-xs\) \/ 2\);[^}]*\}/); + }); +}); diff --git a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx index 43b2611e6a..322c5c99d5 100644 --- a/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx +++ b/packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx @@ -13,18 +13,66 @@ * Keys, lane labels, and conditional rendering are preserved verbatim from the * original inline JSX. */ -import type { ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { ModelPreset, Settings } from "@fusion/core"; -import type { ModelInfo } from "../../../api"; +import { + ApiRequestError, + fetchWorkflowSettingValues, + updateWorkflowSettingValues, + type ModelInfo, + type WorkflowSettingRejection, + type WorkflowSettingValuesPayload, +} from "../../../api"; import { CustomModelDropdown } from "../../CustomModelDropdown"; import { applyPresetToSelection } from "../../../utils/modelPresets"; import type { ToastType } from "../../../hooks/useToast"; -import { MovedSettingsStub } from "./MovedSettingsStub"; import type { ModelLane, SectionBaseProps, SettingsFormState } from "./context"; type LaneStatus = "inherited" | "overridden"; +type WorkflowModelPair = { + id: "planning" | "execution" | "validator"; + providerId: string; + modelId: string; + label: string; + help: string; +}; + +const DEFAULT_WORKFLOW_ID = "builtin:coding"; + +const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [ + { + 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 workflow default.", + }, + { + id: "execution", + providerId: "executionProvider", + modelId: "executionModelId", + label: "Executor Model", + help: "Provider and model used while executing workflow steps. Leave unset to inherit from the workflow default.", + }, + { + id: "validator", + providerId: "validatorProvider", + modelId: "validatorModelId", + label: "Reviewer Model", + help: "Provider and model used for workflow review or validation lanes. Leave unset to inherit from the workflow default.", + }, +]; + +function modelPairValue(values: Record, pair: WorkflowModelPair): string { + const provider = values[pair.providerId]; + const modelId = values[pair.modelId]; + return typeof provider === "string" && typeof modelId === "string" && provider && modelId + ? `${provider}/${modelId}` + : ""; +} + export interface ProjectModelsSectionModelProps { modelLanes: ModelLane[]; getLaneStatus: (lane: ModelLane) => LaneStatus; @@ -58,6 +106,7 @@ export function ProjectModelsSection({ form, setForm, models, + projectId, onOpenWorkflowSettings, }: ProjectModelsSectionProps) { const { t } = useTranslation("app"); @@ -84,6 +133,102 @@ export function ProjectModelsSection({ const presets = form.modelPresets || []; const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name })); const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean)); + const configuredWorkflowId = typeof form.defaultWorkflowId === "string" && form.defaultWorkflowId.trim() + ? form.defaultWorkflowId + : DEFAULT_WORKFLOW_ID; + const [workflowId, setWorkflowId] = useState(configuredWorkflowId); + const [workflowPayload, setWorkflowPayload] = useState(null); + const [workflowLoading, setWorkflowLoading] = useState(false); + const [workflowPending, setWorkflowPending] = useState>({}); + const [workflowSaving, setWorkflowSaving] = useState(false); + const [workflowRejections, setWorkflowRejections] = useState>({}); + const workflowReqSeq = useRef(0); + const workflowDirty = Object.keys(workflowPending).length > 0; + + useEffect(() => { + setWorkflowId(configuredWorkflowId); + }, [configuredWorkflowId]); + + useEffect(() => { + if (!projectId) { + setWorkflowPayload(null); + setWorkflowPending({}); + setWorkflowRejections({}); + return; + } + const seq = ++workflowReqSeq.current; + setWorkflowLoading(true); + fetchWorkflowSettingValues(workflowId, projectId) + .then((payload) => { + if (workflowReqSeq.current !== seq) return; + setWorkflowPayload(payload); + setWorkflowPending({}); + setWorkflowRejections({}); + }) + .catch((err) => { + if (workflowReqSeq.current !== seq) return; + if (err instanceof ApiRequestError && err.status === 404 && workflowId !== DEFAULT_WORKFLOW_ID) { + setWorkflowId(DEFAULT_WORKFLOW_ID); + return; + } + setWorkflowPayload({ stored: {}, effective: {}, orphaned: [] }); + }) + .finally(() => { + if (workflowReqSeq.current === seq) setWorkflowLoading(false); + }); + }, [projectId, workflowId]); + + const effectiveWorkflowValues = useMemo(() => ({ + ...(workflowPayload?.effective ?? {}), + ...Object.fromEntries(Object.entries(workflowPending).filter(([, value]) => value !== null)), + }), [workflowPayload, workflowPending]); + + const setWorkflowPairValue = useCallback((pair: WorkflowModelPair, value: string) => { + setWorkflowRejections((current) => { + const next = { ...current }; + delete next[pair.providerId]; + delete next[pair.modelId]; + return next; + }); + setWorkflowPending((current) => { + if (!value) { + return { ...current, [pair.providerId]: null, [pair.modelId]: null }; + } + const slashIdx = value.indexOf("/"); + if (slashIdx <= 0) return current; + return { + ...current, + [pair.providerId]: value.slice(0, slashIdx), + [pair.modelId]: value.slice(slashIdx + 1), + }; + }); + }, []); + + const resetWorkflowPairValue = useCallback((pair: WorkflowModelPair) => { + setWorkflowPairValue(pair, ""); + }, [setWorkflowPairValue]); + + const saveWorkflowLanes = useCallback(async () => { + if (!projectId || !workflowDirty) return; + setWorkflowSaving(true); + try { + const payload = await updateWorkflowSettingValues(workflowId, workflowPending, projectId); + setWorkflowPayload(payload); + setWorkflowPending({}); + setWorkflowRejections({}); + } catch (err) { + if (err instanceof ApiRequestError && err.status === 400 && err.details) { + const rejections = (err.details.rejections as WorkflowSettingRejection[] | undefined) ?? []; + if (rejections.length > 0) { + setWorkflowRejections(Object.fromEntries(rejections.map((rejection) => [rejection.settingId, rejection]))); + return; + } + } + throw err; + } finally { + setWorkflowSaving(false); + } + }, [projectId, workflowDirty, workflowId, workflowPending]); // The project DEFAULT lane and restored title-summarizer lane remain editable // here. Execution/planning/validator workflow-specific lanes still redirect to @@ -210,15 +355,95 @@ export function ProjectModelsSection({ )} - {/* --- Per-phase model lanes (MOVED to workflow settings) --- */} -

Per-phase model lanes

- Default workflow model lanes +

+ {t( "settings.movedStub.modelLanes", "Per-phase model lanes (execution, planning, reviewer, and their fallbacks) now live on the workflow.", - )} - onOpenWorkflowSettings={onOpenWorkflowSettings} - /> + )} These project overrides apply to the active default workflow. +

+ {!projectId ? ( +
Open a project to edit workflow model lanes.
+ ) : workflowLoading ? ( +
Loading workflow model lanes…
+ ) : availableModels.length === 0 ? ( +
+ No models available. Configure authentication before selecting workflow model lanes. +
+ ) : ( + <> + {WORKFLOW_MODEL_PAIRS.map((pair) => { + const value = modelPairValue(effectiveWorkflowValues, pair); + const customized = Object.prototype.hasOwnProperty.call(workflowPending, pair.providerId) + ? workflowPending[pair.providerId] !== null + : Boolean(workflowPayload?.stored && ( + Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.providerId) + || Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.modelId) + )); + const error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message; + return ( +
+
+ + + {customized ? "Override (Project)" : "Inherited (Workflow)"} + +
+
+
+ setWorkflowPairValue(pair, next)} + placeholder="Use workflow default" + defaultOptionLabel="Use workflow default" + favoriteProviders={favoriteProviders} + onToggleFavorite={onToggleFavorite} + favoriteModels={favoriteModels} + onToggleModelFavorite={onToggleModelFavorite} + /> +
+ {customized && ( + + )} +
+ {pair.help} + {error ? {error} : null} +
+ ); + })} +
+ + {onOpenWorkflowSettings ? ( + + ) : null} +
+ + )} {/* --- Model Presets --- */}

Model Presets