feat(workflow-settings): simplify workflow model lanes

This commit is contained in:
gsxdsm
2026-06-07 22:07:47 -07:00
parent cf80622875
commit 61ae1bf706
12 changed files with 903 additions and 52 deletions

View File

@@ -18,6 +18,8 @@ export interface CustomModelDropdownProps {
noChangeValue?: string;
/** Display label for noChangeValue (defaults to "No change"). */
noChangeLabel?: string;
/** Display label for the inherited/default option (defaults to "Use default"). */
defaultOptionLabel?: string;
/** List of favorite provider names in preferred order */
favoriteProviders?: string[];
/** Called when user toggles a provider's favorite status */
@@ -62,10 +64,12 @@ export function CustomModelDropdown({
onToggleModelFavorite,
noChangeValue,
noChangeLabel: noChangeLabelProp,
defaultOptionLabel: defaultOptionLabelProp,
}: CustomModelDropdownProps) {
const { t } = useTranslation("app");
const placeholder = placeholderProp ?? t("model.selectPlaceholder", "Select a model…");
const noChangeLabel = noChangeLabelProp ?? t("model.noChange", "No change");
const defaultOptionLabel = defaultOptionLabelProp ?? t("models.useDefault", "Use default");
const [isOpen, setIsOpen] = useState(false);
const [localFilter, setLocalFilter] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
@@ -143,9 +147,9 @@ export function CustomModelDropdown({
if (hasNoChangeOption) {
options.push({ type: "no-change", value: noChangeValue, label: noChangeLabel });
}
options.push({ type: "default", value: "", label: t("models.useDefault", "Use default") });
options.push({ type: "default", value: "", label: defaultOptionLabel });
return options;
}, [hasNoChangeOption, noChangeLabel, noChangeValue]);
}, [defaultOptionLabel, hasNoChangeOption, noChangeLabel, noChangeValue]);
// Build list of all selectable options (for keyboard navigation)
// Includes special rows first (optional "No change" + "Use default"),
@@ -183,14 +187,14 @@ export function CustomModelDropdown({
if (hasNoChangeOption && value === noChangeValue) {
return noChangeLabel;
}
if (!value) return t("models.useDefault", "Use default");
if (!value) return defaultOptionLabel;
const slashIdx = value.indexOf("/");
if (slashIdx === -1) return value;
const provider = value.slice(0, slashIdx);
const modelId = value.slice(slashIdx + 1);
const model = models.find((m) => m.provider === provider && m.id === modelId);
return model?.name || value;
}, [hasNoChangeOption, noChangeLabel, noChangeValue, value, models]);
}, [defaultOptionLabel, hasNoChangeOption, noChangeLabel, noChangeValue, value, models]);
// Find index of current value in options list
const currentValueIndex = useMemo(() => {

View File

@@ -2481,6 +2481,8 @@ export function SettingsModal({
scopeBanner={renderScopeBanner()}
form={form}
setForm={setForm}
projectId={projectId}
addToast={addToast}
onOpenWorkflowSettings={onOpenWorkflowSettings}
models={{
modelLanes: MODEL_LANES,

View File

@@ -246,11 +246,25 @@
}
.wf-settings-values-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.wf-settings-value-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.wf-settings-value-group-title {
margin: 0;
font-size: 0.72rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0;
}
.wf-settings-value-item {
position: relative;
display: flex;

View File

@@ -41,6 +41,11 @@ import {
ApiRequestError,
type WorkflowSettingValuesPayload,
} from "../api";
import {
getWorkflowSettingDisplay,
groupWorkflowSettings,
WORKFLOW_SETTING_GROUP_LABELS,
} from "./workflow-setting-display";
import {
SettingsToggleRow,
SettingsNumberRow,
@@ -609,10 +614,11 @@ function ValuesTab({
const value = effectiveOf(setting);
const error = rejections[setting.id]?.message;
const customized = isCustomized(setting);
const display = getWorkflowSettingDisplay(setting);
const descriptor = {
key: setting.id,
label: setting.name,
help: setting.description,
label: display.label,
help: display.description ?? setting.description,
scope: "project" as const,
};
const clearable = customized;
@@ -732,15 +738,22 @@ function ValuesTab({
</p>
) : (
<div className="wf-settings-values-list">
{settings.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>
{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>
))}
</div>
)}
@@ -805,7 +818,7 @@ export function WorkflowSettingsPanel({
addToast,
}: WorkflowSettingsPanelProps) {
const { t } = useTranslation("app");
const [tab, setTab] = useState<"definitions" | "values">("definitions");
const [tab, setTab] = useState<"definitions" | "values">(() => (settings.length > 0 ? "values" : "definitions"));
// Bind the projectId active when the panel first mounted for this workflow.
// The Values tab uses this bound id; a later change to `projectId` surfaces a

View File

@@ -6,6 +6,7 @@ import { EditorView } from "@codemirror/view";
import { SettingsModal } from "../SettingsModal";
import { __test_clearCache as clearPluginUiSlotsCache } from "../../hooks/usePluginUiSlots";
import type { PluginUiContributionEntry, SettingsExportData, UpdateCheckResponse } from "../../api";
import { ApiRequestError } from "../../api";
// --- API mocks ---
const mockFetchSettings = vi.fn();
@@ -20,6 +21,8 @@ const mockCancelProviderLogin = vi.fn();
const mockSaveApiKey = vi.fn();
const mockSubmitProviderManualCode = vi.fn();
const mockFetchModels = vi.fn();
const mockFetchWorkflowSettingValues = vi.fn();
const mockUpdateWorkflowSettingValues = vi.fn();
const mockFetchCustomProviders = vi.fn();
const mockCreateCustomProvider = vi.fn();
const mockUpdateCustomProvider = vi.fn();
@@ -80,6 +83,8 @@ vi.mock("../../api", async (importOriginal) => {
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
submitProviderManualCode: (...args: unknown[]) => mockSubmitProviderManualCode(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchWorkflowSettingValues: (...args: unknown[]) => mockFetchWorkflowSettingValues(...args),
updateWorkflowSettingValues: (...args: unknown[]) => mockUpdateWorkflowSettingValues(...args),
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
updateCustomProvider: (...args: unknown[]) => mockUpdateCustomProvider(...args),
@@ -538,6 +543,8 @@ describe("SettingsModal", () => {
mockFetchAuthStatus.mockResolvedValue({ providers: [] });
mockConfirm.mockResolvedValue(true);
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
mockFetchWorkflowSettingValues.mockResolvedValue({ stored: {}, effective: {}, orphaned: [] });
mockUpdateWorkflowSettingValues.mockResolvedValue({ stored: {}, effective: {}, orphaned: [] });
mockFetchCustomProviders.mockResolvedValue({ providers: [] });
mockCreateCustomProvider.mockResolvedValue({ provider: {} });
mockUpdateCustomProvider.mockResolvedValue({ provider: {} });
@@ -1520,6 +1527,146 @@ describe("SettingsModal", () => {
expect(globalPayload).not.toHaveProperty("defaultModelIdOverride");
}
});
async function setupWorkflowModelLaneTest({
stored = {},
effective = {},
}: {
stored?: Record<string, unknown>;
effective?: Record<string, unknown>;
} = {}) {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
defaultWorkflowId: "workflow-custom",
});
mockFetchSettingsByScope.mockResolvedValue({
global: defaultSettings,
project: { defaultWorkflowId: "workflow-custom" },
});
mockFetchModels.mockResolvedValue({
models: MODEL_FIXTURE,
favoriteProviders: [],
favoriteModels: [],
});
mockFetchWorkflowSettingValues.mockResolvedValue({
stored,
effective,
orphaned: [],
});
renderModal({ initialSection: "project-models", projectId: "proj-1" });
await waitForSettingsModalReady();
await waitFor(() => {
expect(mockFetchWorkflowSettingValues).toHaveBeenCalledWith("workflow-custom", "proj-1");
});
}
it.each([
["Plan/Triage Model", { planningProvider: "openai", planningModelId: "gpt-4o" }],
["Executor Model", { executionProvider: "openai", executionModelId: "gpt-4o" }],
["Reviewer Model", { validatorProvider: "openai", validatorModelId: "gpt-4o" }],
])("proxy-edits %s through workflow setting values for the default workflow", async (laneLabel, expectedPatch) => {
mockUpdateWorkflowSettingValues.mockResolvedValue({
stored: expectedPatch,
effective: expectedPatch,
orphaned: [],
});
await setupWorkflowModelLaneTest();
await userEvent.click(screen.getByLabelText(laneLabel));
await userEvent.click(await screen.findByText("GPT-4o"));
await userEvent.click(screen.getByTestId("save-workflow-model-lanes"));
await waitFor(() => {
expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith(
"workflow-custom",
expectedPatch,
"proj-1",
);
});
expect(mockUpdateSettings).not.toHaveBeenCalled();
});
it("resets workflow model lanes by sending null patches", async () => {
await setupWorkflowModelLaneTest({
stored: { executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" },
effective: { executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" },
});
const lane = screen.getByTestId("workflow-model-lane-execution");
await userEvent.click(within(lane).getByRole("button", { name: "Reset" }));
await userEvent.click(screen.getByTestId("save-workflow-model-lanes"));
await waitFor(() => {
expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith(
"workflow-custom",
{ executionProvider: null, executionModelId: null },
"proj-1",
);
});
});
it("falls back to builtin workflow values when the configured default workflow is stale", async () => {
mockFetchWorkflowSettingValues
.mockRejectedValueOnce(new ApiRequestError("not found", 404))
.mockResolvedValueOnce({ stored: {}, effective: {}, orphaned: [] });
mockUpdateWorkflowSettingValues.mockResolvedValue({
stored: { planningProvider: "openai", planningModelId: "gpt-4o" },
effective: { planningProvider: "openai", planningModelId: "gpt-4o" },
orphaned: [],
});
await setupWorkflowModelLaneTest();
await waitFor(() => {
expect(mockFetchWorkflowSettingValues).toHaveBeenLastCalledWith("builtin:coding", "proj-1");
});
await userEvent.click(screen.getByLabelText("Plan/Triage Model"));
await userEvent.click(await screen.findByText("GPT-4o"));
await userEvent.click(screen.getByTestId("save-workflow-model-lanes"));
await waitFor(() => {
expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith(
"builtin:coding",
{ planningProvider: "openai", planningModelId: "gpt-4o" },
"proj-1",
);
});
});
it("shows typed workflow model lane rejections without clearing pending edits", async () => {
mockUpdateWorkflowSettingValues.mockRejectedValueOnce(
new ApiRequestError("rejected", 400, {
rejections: [{ code: "unknown-setting", settingId: "planningProvider", message: "planningProvider is not declared" }],
}),
);
await setupWorkflowModelLaneTest();
await userEvent.click(screen.getByLabelText("Plan/Triage Model"));
await userEvent.click(await screen.findByText("GPT-4o"));
await userEvent.click(screen.getByTestId("save-workflow-model-lanes"));
await waitFor(() => {
expect(screen.getByTestId("workflow-model-lane-error-planning")).toHaveTextContent("planningProvider is not declared");
});
expect(screen.getByTestId("save-workflow-model-lanes")).not.toBeDisabled();
});
it("does not fetch or write workflow model lanes without an active project", async () => {
mockFetchModels.mockResolvedValue({
models: MODEL_FIXTURE,
favoriteProviders: [],
favoriteModels: [],
});
renderModal({ initialSection: "project-models" });
await waitForSettingsModalReady();
expect(screen.getByText(/Open a project to edit workflow model lanes/i)).toBeInTheDocument();
expect(mockFetchWorkflowSettingValues).not.toHaveBeenCalled();
expect(screen.queryByTestId("save-workflow-model-lanes")).not.toBeInTheDocument();
});
});
describe("settings header actions", () => {

View File

@@ -68,6 +68,7 @@ function Host({
}
const openValues = () => fireEvent.click(screen.getByTestId("wf-settings-tab-values"));
const openDefinitions = () => fireEvent.click(screen.getByTestId("wf-settings-tab-definitions"));
beforeEach(() => {
mockFetchValues.mockResolvedValue(payload());
@@ -93,6 +94,7 @@ describe("WorkflowSettingsPanel — Definitions tab", () => {
it("declares a setting of each supported type", () => {
let latest: WorkflowSettingDefinition[] = [];
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (latest = s)} />);
openDefinitions();
const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string");
for (const ty of ["text", "number", "boolean", "enum", "multi-enum"]) {
fireEvent.change(typeSelect, { target: { value: ty } });
@@ -103,6 +105,7 @@ describe("WorkflowSettingsPanel — Definitions tab", () => {
it("seeds options when switching to enum", () => {
let latest: WorkflowSettingDefinition[] = [];
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (latest = s)} />);
openDefinitions();
const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string");
fireEvent.change(typeSelect, { target: { value: "enum" } });
expect(latest[0].options).toHaveLength(1);
@@ -128,6 +131,7 @@ describe("WorkflowSettingsPanel — Definitions tab", () => {
);
}
render(<H />);
openDefinitions();
const betaItem = screen.getByTestId("wf-setting-beta");
fireEvent.click(within(betaItem).getByText("Edit id"));
const idInput = within(betaItem).getByLabelText("Setting id");
@@ -138,6 +142,8 @@ describe("WorkflowSettingsPanel — Definitions tab", () => {
it("built-in workflows render declarations read-only", () => {
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} readOnly />);
expect(screen.getByTestId("wf-settings-tab-values")).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByTestId("wf-settings-tab-definitions"));
expect(screen.getByText(/declarations are read-only/i)).toBeInTheDocument();
const nameInput = within(screen.getByTestId("wf-setting-s1")).getByLabelText("Setting name");
expect(nameInput).toBeDisabled();
@@ -165,6 +171,31 @@ describe("WorkflowSettingsPanel — Values tab", () => {
expect(screen.queryByTestId("wf-settings-customized-new-sessions")).not.toBeInTheDocument();
});
it("groups built-in workflow settings under visible category headings", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { planningProvider: "openai", planningModelId: "gpt-5" } }));
render(
<Host
readOnly
initial={[
{ id: "planningProvider", name: "Planning provider", type: "string" },
{ id: "planningModelId", name: "Planning model", type: "string" },
{ id: "validatorProvider", name: "Validator provider", type: "string" },
{ id: "requirePlanApproval", name: "Require plan approval", type: "boolean" },
{ id: "workflowStepTimeoutMs", name: "Step timeout", type: "number" },
{ id: "customThing", name: "Custom thing", type: "string" },
]}
/>,
);
await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1"));
expect(within(screen.getByTestId("wf-settings-group-models")).getByText("Models")).toBeInTheDocument();
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("Reviewer provider")).toBeInTheDocument();
});
it("batches three field edits into exactly ONE patch on Save values", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } }));
render(<Host initial={decls} />);

View File

@@ -1,24 +1,25 @@
/**
* Project Models section (U9 / KTD-10).
*
* Project-scoped model configuration that survives the workflow hard-move: token
* cap, the project DEFAULT model lane, model presets (with the inline editor and
* size-based auto-selection), and the title/commit summarization toggles. The
* per-phase execution/planning/validator lanes and the title-summarizer lane
* moved to the workflow (U4) and render as a redirect stub. The model-lane
* helpers, preset draft state/handlers, available-model list, favorites, and the
* confirm dialog all live in the shell (they share state with the save flow and
* the global model lanes) and are relayed through a `models` prop bag — mirroring
* the Authentication/Remote section conventions. Keys, lane labels, and
* conditional rendering are preserved verbatim from the original inline JSX.
* Project-scoped model configuration. The project DEFAULT model lane still saves
* as project settings. The common workflow model lanes (Plan/Triage, Executor,
* Reviewer) are now proxy-edited here for the active default workflow while
* persisting through workflow setting values, not tombstoned project keys.
*/
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 { MovedSettingsStub } from "./MovedSettingsStub";
import type { ToastType } from "../../../hooks/useToast";
import type { ModelLane, SectionBaseProps, SettingsFormState } from "./context";
type LaneStatus = "inherited" | "overridden";
@@ -46,10 +47,59 @@ export interface ProjectModelsSectionModelProps {
export interface ProjectModelsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode;
models: ProjectModelsSectionModelProps;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
onOpenWorkflowSettings?: () => void;
}
export function ProjectModelsSection({ scopeBanner, form, setForm, models, onOpenWorkflowSettings }: ProjectModelsSectionProps) {
interface WorkflowModelLane {
id: "planning" | "execution" | "validator";
label: string;
providerKey: string;
modelKey: string;
help: string;
}
const WORKFLOW_MODEL_LANES: WorkflowModelLane[] = [
{
id: "planning",
label: "Plan/Triage Model",
providerKey: "planningProvider",
modelKey: "planningModelId",
help: "Used when Fusion plans, breaks down, or triages tasks for this workflow.",
},
{
id: "execution",
label: "Executor Model",
providerKey: "executionProvider",
modelKey: "executionModelId",
help: "Used by implementation agents running this workflow.",
},
{
id: "validator",
label: "Reviewer Model",
providerKey: "validatorProvider",
modelKey: "validatorModelId",
help: "Used by review and validation agents for this workflow.",
},
];
function splitModelValue(value: string): { provider: string | null; modelId: string | null } {
if (!value) return { provider: null, modelId: null };
const slashIdx = value.indexOf("/");
if (slashIdx <= 0) return { provider: null, modelId: null };
return { provider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1) };
}
export function ProjectModelsSection({
scopeBanner,
form,
setForm,
models,
projectId,
addToast,
onOpenWorkflowSettings,
}: ProjectModelsSectionProps) {
const { t } = useTranslation("app");
const {
modelLanes,
@@ -74,6 +124,141 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, onOpe
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 defaultWorkflowId = useMemo(() => {
const raw = typeof form.defaultWorkflowId === "string" ? form.defaultWorkflowId.trim() : "";
return raw || "builtin:coding";
}, [form.defaultWorkflowId]);
const [workflowPayload, setWorkflowPayload] = useState<WorkflowSettingValuesPayload | null>(null);
const [workflowPending, setWorkflowPending] = useState<Record<string, unknown>>({});
const [workflowRejections, setWorkflowRejections] = useState<Record<string, WorkflowSettingRejection>>({});
const [resolvedWorkflowId, setResolvedWorkflowId] = useState(defaultWorkflowId);
const [workflowLoading, setWorkflowLoading] = useState(false);
const [workflowSaving, setWorkflowSaving] = useState(false);
const reqSeq = useRef(0);
const loadWorkflowValues = useCallback(async () => {
const seq = ++reqSeq.current;
if (!projectId) {
setWorkflowPayload(null);
setWorkflowPending({});
setWorkflowRejections({});
setResolvedWorkflowId(defaultWorkflowId);
return;
}
setWorkflowLoading(true);
try {
let targetWorkflowId = defaultWorkflowId;
let payload: WorkflowSettingValuesPayload;
try {
payload = await fetchWorkflowSettingValues(targetWorkflowId, projectId);
} catch (err) {
if (targetWorkflowId === "builtin:coding" || !(err instanceof ApiRequestError) || err.status !== 404) {
throw err;
}
targetWorkflowId = "builtin:coding";
payload = await fetchWorkflowSettingValues(targetWorkflowId, projectId);
}
if (reqSeq.current === seq) {
setWorkflowPayload(payload);
setWorkflowPending({});
setWorkflowRejections({});
setResolvedWorkflowId(targetWorkflowId);
}
} catch {
if (reqSeq.current === seq) {
setWorkflowPayload(null);
setWorkflowRejections({});
setResolvedWorkflowId(defaultWorkflowId);
addToast(t("settings.models.workflowLanesLoadFailed", "Failed to load workflow model settings"), "error");
}
} finally {
if (reqSeq.current === seq) setWorkflowLoading(false);
}
}, [addToast, defaultWorkflowId, projectId, t]);
useEffect(() => {
void loadWorkflowValues();
}, [loadWorkflowValues]);
const workflowValueFor = useCallback(
(key: string): unknown => {
if (Object.prototype.hasOwnProperty.call(workflowPending, key)) {
return workflowPending[key];
}
return workflowPayload?.effective?.[key];
},
[workflowPayload, workflowPending],
);
const workflowLaneValue = useCallback(
(lane: WorkflowModelLane): string => {
const provider = workflowValueFor(lane.providerKey);
const modelId = workflowValueFor(lane.modelKey);
return typeof provider === "string" && provider && typeof modelId === "string" && modelId
? `${provider}/${modelId}`
: "";
},
[workflowValueFor],
);
const workflowLaneCustomized = useCallback(
(lane: WorkflowModelLane): boolean => {
const pendingProvider = workflowPending[lane.providerKey];
const pendingModel = workflowPending[lane.modelKey];
if (pendingProvider === null && pendingModel === null) return false;
if (pendingProvider !== undefined || pendingModel !== undefined) return true;
return Boolean(
workflowPayload?.stored &&
(Object.prototype.hasOwnProperty.call(workflowPayload.stored, lane.providerKey) ||
Object.prototype.hasOwnProperty.call(workflowPayload.stored, lane.modelKey)),
);
},
[workflowPayload, workflowPending],
);
const updateWorkflowLane = useCallback((lane: WorkflowModelLane, value: string) => {
const { provider, modelId } = splitModelValue(value);
setWorkflowPending((current) => ({
...current,
[lane.providerKey]: provider,
[lane.modelKey]: modelId,
}));
setWorkflowRejections((current) => {
if (!current[lane.providerKey] && !current[lane.modelKey]) return current;
const next = { ...current };
delete next[lane.providerKey];
delete next[lane.modelKey];
return next;
});
}, []);
const saveWorkflowModelLanes = useCallback(async () => {
if (!projectId || Object.keys(workflowPending).length === 0) return;
setWorkflowSaving(true);
try {
const payload = await updateWorkflowSettingValues(resolvedWorkflowId, workflowPending, projectId);
setWorkflowPayload(payload);
setWorkflowPending({});
setWorkflowRejections({});
addToast(t("settings.models.workflowLanesSaved", "Workflow model settings saved"), "success");
} catch (err) {
if (err instanceof ApiRequestError && err.status === 400 && err.details) {
const rejList = (err.details.rejections as WorkflowSettingRejection[] | undefined) ?? [];
if (rejList.length > 0) {
const byId: Record<string, WorkflowSettingRejection> = {};
for (const r of rejList) byId[r.settingId] = r;
setWorkflowRejections(byId);
addToast(t("settings.models.workflowLanesRejected", "Some workflow model settings were rejected"), "error");
return;
}
}
addToast(t("settings.models.workflowLanesSaveFailed", "Failed to save workflow model settings"), "error");
} finally {
setWorkflowSaving(false);
}
}, [addToast, projectId, resolvedWorkflowId, t, workflowPending]);
const workflowDirty = Object.keys(workflowPending).length > 0;
// Only the project DEFAULT model lane survives in this modal. The
// per-phase execution/planning/validator lanes, their fallbacks, and the
@@ -189,15 +374,94 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, onOpe
</>
)}
{/* --- 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(
"settings.movedStub.modelLanes",
"Per-phase model lanes (execution, planning, reviewer, their fallbacks, and the title summarizer) now live on the workflow.",
)}
onOpenWorkflowSettings={onOpenWorkflowSettings}
/>
{/* --- Default workflow model lanes (workflow setting values) --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Default workflow model lanes</h4>
<p className="settings-description">
These controls edit model values on this project's default workflow ({resolvedWorkflowId}).
They use workflow settings as the source of truth.
</p>
{!projectId ? (
<div className="settings-empty-state settings-muted">
Open a project to edit workflow model lanes.
</div>
) : modelsLoading || workflowLoading ? (
<div className="settings-empty-state">Loading workflow model settings…</div>
) : availableModels.length === 0 ? (
<div className="settings-empty-state settings-muted">
No models available. Configure authentication first.
</div>
) : (
<>
{WORKFLOW_MODEL_LANES.map((lane) => {
const value = workflowLaneValue(lane);
const customized = workflowLaneCustomized(lane);
const rejection = workflowRejections[lane.providerKey] ?? workflowRejections[lane.modelKey];
return (
<div className="form-group" key={lane.id} data-testid={`workflow-model-lane-${lane.id}`}>
<div className="settings-model-lane-label-row">
<label htmlFor={`workflow-${lane.id}-model`}>{lane.label}</label>
<span
className={`settings-lane-badge ${customized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`}
title={customized ? "Explicitly set on the default workflow" : "Inherited through workflow/global defaults"}
>
{customized ? "Override (Workflow)" : "Inherited"}
</span>
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown
id={`workflow-${lane.id}-model`}
label={lane.label}
models={availableModels}
value={value}
onChange={(val) => updateWorkflowLane(lane, val)}
placeholder="Use workflow/global default"
defaultOptionLabel="Use workflow/global default"
favoriteProviders={favoriteProviders}
onToggleFavorite={onToggleFavorite}
favoriteModels={favoriteModels}
onToggleModelFavorite={onToggleModelFavorite}
/>
</div>
{customized && (
<button
type="button"
className="btn btn-ghost btn-sm"
title="Reset to inherit"
onClick={() => updateWorkflowLane(lane, "")}
style={{ whiteSpace: "nowrap" }}
>
Reset
</button>
)}
</div>
{rejection ? (
<small className="field-error" role="alert" data-testid={`workflow-model-lane-error-${lane.id}`}>
{rejection.message}
</small>
) : null}
<small>{lane.help}</small>
</div>
);
})}
<div className="settings-model-lane-actions">
<button
type="button"
className="btn btn-primary btn-sm"
data-testid="save-workflow-model-lanes"
disabled={!workflowDirty || workflowSaving}
onClick={() => void saveWorkflowModelLanes()}
>
Save workflow models
</button>
{onOpenWorkflowSettings && (
<button type="button" className="btn btn-ghost btn-sm" onClick={onOpenWorkflowSettings}>
Advanced workflow policy
</button>
)}
</div>
</>
)}
{/* --- Model Presets --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Model Presets</h4>

View File

@@ -0,0 +1,163 @@
import type { WorkflowSettingDefinition } from "../api";
export type WorkflowSettingGroup = "models" | "review" | "steps" | "advanced";
export interface WorkflowSettingDisplay {
group: WorkflowSettingGroup;
label: string;
description?: string;
}
const DISPLAY: Record<string, WorkflowSettingDisplay> = {
planningProvider: {
group: "models",
label: "Plan/Triage provider",
description: "Provider used when planning or triaging tasks.",
},
planningModelId: {
group: "models",
label: "Plan/Triage model",
description: "Model used when planning or triaging tasks.",
},
planningFallbackProvider: {
group: "models",
label: "Plan/Triage fallback provider",
},
planningFallbackModelId: {
group: "models",
label: "Plan/Triage fallback model",
},
executionProvider: {
group: "models",
label: "Executor provider",
description: "Provider used by task implementation agents.",
},
executionModelId: {
group: "models",
label: "Executor model",
description: "Model used by task implementation agents.",
},
validatorProvider: {
group: "models",
label: "Reviewer provider",
description: "Provider used by review and validation agents.",
},
validatorModelId: {
group: "models",
label: "Reviewer model",
description: "Model used by review and validation agents.",
},
validatorFallbackProvider: {
group: "models",
label: "Reviewer fallback provider",
},
validatorFallbackModelId: {
group: "models",
label: "Reviewer fallback model",
},
titleSummarizerProvider: {
group: "models",
label: "Title summarizer provider",
},
titleSummarizerModelId: {
group: "models",
label: "Title summarizer model",
},
requirePrApproval: {
group: "review",
label: "Require PR approval",
},
requirePlanApproval: {
group: "review",
label: "Require plan approval",
},
reviewHandoffPolicy: {
group: "review",
label: "Review handoff policy",
},
maxReviewerContextRetries: {
group: "review",
label: "Reviewer context retries",
},
maxReviewerFallbackRetries: {
group: "review",
label: "Reviewer fallback retries",
},
reflectionEnabled: {
group: "review",
label: "Reflection enabled",
},
workflowStepTimeoutMs: {
group: "steps",
label: "Step timeout",
},
workflowStepScopeEnforcement: {
group: "steps",
label: "Step scope enforcement",
},
planOnlyScopeLeakEnforcement: {
group: "steps",
label: "Plan-only scope leak enforcement",
},
workflowRevisionForkOnScopeMismatch: {
group: "steps",
label: "Fork revision on scope mismatch",
},
strictScopeEnforcement: {
group: "steps",
label: "Strict scope enforcement",
},
runStepsInNewSessions: {
group: "steps",
label: "Run steps in new sessions",
},
maxParallelSteps: {
group: "steps",
label: "Max parallel steps",
},
buildRetryCount: {
group: "steps",
label: "Build retry count",
},
verificationFixRetries: {
group: "steps",
label: "Verification fix retries",
},
maxPostReviewFixes: {
group: "steps",
label: "Post-review fix passes",
},
};
export const WORKFLOW_SETTING_GROUP_ORDER: WorkflowSettingGroup[] = [
"models",
"review",
"steps",
"advanced",
];
export const WORKFLOW_SETTING_GROUP_LABELS: Record<WorkflowSettingGroup, string> = {
models: "Models",
review: "Review & Approval",
steps: "Step Execution",
advanced: "Advanced",
};
export function getWorkflowSettingDisplay(setting: WorkflowSettingDefinition): WorkflowSettingDisplay {
return DISPLAY[setting.id] ?? { group: "advanced", label: setting.name, description: setting.description };
}
export function groupWorkflowSettings(
settings: WorkflowSettingDefinition[],
): Array<{ group: WorkflowSettingGroup; settings: WorkflowSettingDefinition[] }> {
const byGroup = new Map<WorkflowSettingGroup, WorkflowSettingDefinition[]>();
for (const setting of settings) {
const group = getWorkflowSettingDisplay(setting).group;
const list = byGroup.get(group) ?? [];
list.push(setting);
byGroup.set(group, list);
}
return WORKFLOW_SETTING_GROUP_ORDER
.map((group) => ({ group, settings: byGroup.get(group) ?? [] }))
.filter((entry) => entry.settings.length > 0);
}