FN-6027: show and space default workflow model overrides
Expose default workflow model lane overrides directly in project settings. - load and persist default workflow model lane overrides from the active project workflow - add inline workflow lane status, reset, save, and advanced policy actions in project model settings - add CSS contract and modal coverage for the workflow model action row spacing and placement Files changed: .../dashboard/app/components/SettingsModal.css | 20 ++ .../components/__tests__/SettingsModal.test.tsx | 20 +- .../SettingsModal.workflow-model-lane-css.test.ts | 24 ++ .../settings/sections/ProjectModelsSection.tsx | 245 ++++++++++++++++++++- 4 files changed, 298 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-6027 Fusion-Task-Lineage: 981a5f01-f4b7-467e-9e2f-4ac8e4ed40fb
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -1526,9 +1526,11 @@ describe("SettingsModal", () => {
|
||||
async function setupWorkflowModelLaneTest({
|
||||
stored = {},
|
||||
effective = {},
|
||||
renderProps = {},
|
||||
}: {
|
||||
stored?: Record<string, unknown>;
|
||||
effective?: Record<string, unknown>;
|
||||
renderProps?: Partial<ComponentProps<typeof SettingsModal>>;
|
||||
} = {}) {
|
||||
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" }],
|
||||
|
||||
@@ -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(/(?<!-)\b(?:[1-9]\d*)px\b/);
|
||||
});
|
||||
|
||||
it("keeps the workflow model action row usable at the mobile breakpoint", async () => {
|
||||
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\);[^}]*\}/);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>, 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<WorkflowSettingValuesPayload | null>(null);
|
||||
const [workflowLoading, setWorkflowLoading] = useState(false);
|
||||
const [workflowPending, setWorkflowPending] = useState<Record<string, unknown>>({});
|
||||
const [workflowSaving, setWorkflowSaving] = useState(false);
|
||||
const [workflowRejections, setWorkflowRejections] = useState<Record<string, WorkflowSettingRejection>>({});
|
||||
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) --- */}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">Per-phase model lanes</h4>
|
||||
<MovedSettingsStub
|
||||
message={t(
|
||||
{/* --- Default workflow model lanes --- */}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">Default workflow model lanes</h4>
|
||||
<p className="settings-description">
|
||||
{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.
|
||||
</p>
|
||||
{!projectId ? (
|
||||
<div className="settings-empty-state settings-muted">Open a project to edit workflow model lanes.</div>
|
||||
) : workflowLoading ? (
|
||||
<div className="settings-empty-state">Loading workflow model lanes…</div>
|
||||
) : availableModels.length === 0 ? (
|
||||
<div className="settings-empty-state settings-muted">
|
||||
No models available. Configure authentication before selecting workflow model lanes.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{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 (
|
||||
<div className="form-group" key={pair.id} data-testid={`workflow-model-lane-${pair.id}`}>
|
||||
<div className="settings-model-lane-label-row">
|
||||
<label htmlFor={`workflow-${pair.id}-model`}>{pair.label}</label>
|
||||
<span
|
||||
className={`settings-lane-badge ${customized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`}
|
||||
title={customized ? "Explicitly set for this project workflow" : "Inherited from workflow defaults"}
|
||||
>
|
||||
{customized ? "Override (Project)" : "Inherited (Workflow)"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="settings-model-lane-control-row">
|
||||
<div className="settings-model-lane-control-main">
|
||||
<CustomModelDropdown
|
||||
id={`workflow-${pair.id}-model`}
|
||||
label={pair.label}
|
||||
models={availableModels}
|
||||
value={value}
|
||||
onChange={(next) => setWorkflowPairValue(pair, next)}
|
||||
placeholder="Use workflow default"
|
||||
defaultOptionLabel="Use workflow default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleModelFavorite={onToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
{customized && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
title="Reset to inherit from workflow"
|
||||
onClick={() => resetWorkflowPairValue(pair)}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<small>{pair.help}</small>
|
||||
{error ? <small className="settings-error" data-testid={`workflow-model-lane-error-${pair.id}`}>{error}</small> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="settings-model-lane-actions" aria-label="Default workflow model lane actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
data-testid="save-workflow-model-lanes"
|
||||
onClick={saveWorkflowLanes}
|
||||
disabled={!workflowDirty || workflowSaving}
|
||||
>
|
||||
{workflowSaving ? "Saving…" : "Save workflow models"}
|
||||
</button>
|
||||
{onOpenWorkflowSettings ? (
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={onOpenWorkflowSettings}>
|
||||
Advanced workflow policy
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* --- Model Presets --- */}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">Model Presets</h4>
|
||||
|
||||
Reference in New Issue
Block a user