feat(workflow-settings): simplify workflow model lanes
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -2481,6 +2481,8 @@ export function SettingsModal({
|
||||
scopeBanner={renderScopeBanner()}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
onOpenWorkflowSettings={onOpenWorkflowSettings}
|
||||
models={{
|
||||
modelLanes: MODEL_LANES,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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>
|
||||
|
||||
163
packages/dashboard/app/components/workflow-setting-display.ts
Normal file
163
packages/dashboard/app/components/workflow-setting-display.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user