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

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Expose default-workflow Plan/Triage, Executor, and Reviewer model lanes from Project Models settings while keeping workflow setting values as the source of truth.

View File

@@ -107,6 +107,8 @@ Navigation:
Behavior: Behavior:
- Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels - Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Definitions remain available for custom workflow schema authoring.
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; those controls write workflow setting values for the active default workflow.
- On desktop, the editor uses a multi-panel layout for editing the graph and adjacent workflow metadata - On desktop, the editor uses a multi-panel layout for editing the graph and adjacent workflow metadata
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet and stacks the sidebar, canvas, inspector, and settings/authoring panels vertically so each section remains scrollable and usable on phones - On viewports `<=768px`, the editor switches to a full-screen mobile sheet and stacks the sidebar, canvas, inspector, and settings/authoring panels vertically so each section remains scrollable and usable on phones
- The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens - The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens

View File

@@ -0,0 +1,197 @@
---
title: "feat: Simplify workflow settings and restore main settings model lanes"
type: feat
status: active
date: 2026-06-08
depth: standard
origin: none (follow-up to docs/plans/2026-06-04-002-feat-workflow-settings-mechanism-plan.md)
---
# feat: Simplify workflow settings and restore main settings model lanes
## Summary
Keep workflow setting values as the only persisted authority for moved workflow policy, but make the common path usable again from Settings. Main project settings should expose Plan/Triage, Executor, and Reviewer model selectors by proxy-editing the active default workflow's model-lane values. The workflow editor should stop leading with schema mechanics for normal users and instead present grouped value sections with an advanced definitions area.
---
## Problem Frame
The workflow settings hard move solved the source-of-truth problem: per-phase model lanes and workflow execution policy no longer live as ambient project settings. The resulting UX is too expensive for the most common task. A user who only wants to set the model used for planning/triage, execution, or review now has to leave Settings, open the workflow editor, understand definitions vs values, and find raw setting ids among unrelated workflow policy knobs.
The fix is not to reintroduce the old project settings keys. Those keys are intentionally tombstoned in `packages/core/src/moved-settings.ts`, and stale writes are stripped before persistence. The fix is to add a first-class proxy surface in Settings for the active project's default workflow values, while making the workflow settings panel itself grouped, value-first, and less schema-editor-shaped.
---
## Requirements
**Main Settings Model Lanes**
- R1. The Project Models section exposes three project-level workflow model lanes: Plan/Triage, Executor, and Reviewer.
- R2. Saving those controls writes workflow setting values for the active project's default workflow, never the tombstoned project settings keys.
- R3. The selectors preserve the current model picker affordances: provider/model choices, favorites, loading/empty states, reset-to-inherit, and visible inherited/customized state.
- R4. When no active project or default workflow is available, the controls render a clear disabled state and do not write.
**Workflow Settings Usability**
- R5. Workflow settings values are grouped by user intent: Models, Review & Approval, Step Execution, and Advanced.
- R6. For built-in workflows, the panel opens to grouped Values by default and hides Definitions behind a read-only Advanced area.
- R7. For custom workflows, declaration editing remains available, but normal value editing stays the primary surface.
- R8. Moved-setting redirect stubs in Settings are replaced or narrowed so users are not sent away for the model-lane case.
**Authority and Compatibility**
- R9. `MOVED_SETTINGS_KEYS` remains the persistence boundary; no moved key is restored to `DEFAULT_PROJECT_SETTINGS`, settings export project payloads, sync diffs, or CLI `settings set`.
- R10. Engine model resolution remains unchanged: workflow lane value -> global lane -> project default -> global default.
---
## Scope Boundaries
### In scope
- Project Models UI for Plan/Triage, Executor, and Reviewer model lanes, backed by workflow setting-value endpoints.
- A reusable grouped workflow-setting value editor shared between `ProjectModelsSection` and `WorkflowSettingsPanel`.
- Narrow copy and navigation changes for moved-setting stubs.
- Focused tests for proxy writes, grouped rendering, and persistence-boundary regressions.
### Deferred
- Full workflow setting sync across nodes. The existing "workflow settings are not synced yet" stance remains.
- Per-task workflow setting overrides.
- New model-lane types or engine resolution changes.
- Reworking workflow declaration schema.
### Out of scope
- Re-adding `executionProvider`, `planningProvider`, `validatorProvider`, or related moved keys to project settings.
- Changing task-level model override behavior in `TaskForm`.
- Moving merge trait or capacity settings into this follow-up.
---
## Key Technical Decisions
- KTD-1 — **Proxy-edit default workflow values from Settings.** The Project Models section reads the active default workflow id, calls `fetchWorkflowSettingValues(workflowId, projectId)`, and saves patches through `updateWorkflowSettingValues`. This keeps the source of truth in `workflow_settings` while restoring the main Settings workflow users expect.
- KTD-2 — **Use user-facing lane names, not storage names.** Settings labels should be Plan/Triage, Executor, and Reviewer. They map to `planningProvider`/`planningModelId`, `executionProvider`/`executionModelId`, and `validatorProvider`/`validatorModelId`. The existing engine still calls the reviewer lane `validator`; the UI should not expose that implementation vocabulary as the primary label.
- KTD-3 — **Grouped values are display metadata, not IR schema.** Add dashboard-side grouping metadata for known workflow settings rather than expanding `WorkflowSettingDefinition` immediately. Custom or unknown settings fall into Advanced. This avoids a schema change for a UX-only regrouping.
- KTD-4 — **One reusable value editor.** Extract the Values-tab row rendering from `WorkflowSettingsPanel.tsx` into a reusable component that can render a filtered set of workflow settings. `ProjectModelsSection` uses it in model-lane mode; `WorkflowSettingsPanel` uses it for all grouped sections.
- KTD-5 — **Keep save authorities separate.** Settings modal project/global saves continue through `updateSettings` / `updateGlobalSettings`. Workflow model-lane saves happen through a dedicated workflow-values save path. The UI may place them on the same screen, but the payloads must not be fused.
---
## High-Level Technical Design
```mermaid
flowchart TB
PM["Project Models section"] --> DW["Resolve active default workflow id"]
DW --> GET["GET /workflows/:id/setting-values?projectId"]
GET --> LANE["Grouped model lane editor<br/>Plan/Triage, Executor, Reviewer"]
LANE --> PATCH["PATCH /workflows/:id/setting-values<br/>planning*, execution*, validator*"]
PATCH --> TABLE["workflow_settings table"]
TABLE --> RES["resolveEffectiveSettingsDetailed"]
RES --> ENG["Engine model resolution<br/>workflow -> global lane -> project default -> global default"]
PM -. "must not send" .-> DEAD["updateSettings moved-key payload"]
```
The Settings modal remains a shell over multiple authorities. Its regular Save button can either save normal project settings and workflow lane values in one user action with two API calls, or the lane group can have its own inline "Save workflow models" action. Implementation should choose the smaller UI change after checking the current dirty-state model in `SettingsModal.tsx`; the invariant is that moved model keys never enter the project settings payload.
---
## Implementation Units
### U1. Workflow setting display metadata and grouped value editor
- **Goal:** Create the reusable display and editing primitives needed by both Settings and the workflow editor.
- **Requirements:** R3, R5, R6, R7
- **Files:** `packages/dashboard/app/components/workflow-setting-display.ts` (new), `packages/dashboard/app/components/WorkflowSettingValueEditor.tsx` (new), `packages/dashboard/app/components/WorkflowSettingValueEditor.css` (new), `packages/dashboard/app/components/WorkflowSettingsPanel.tsx`, `packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx`
- **Approach:** Add a dashboard-side catalog mapping known ids to group, label, description, and lane pair metadata. Extract the current Values-tab row logic into `WorkflowSettingValueEditor`, accepting declarations, stored/effective values, orphaned values, allowed ids/group filter, project stale state, and save callback. Unknown declarations render under Advanced.
- **Test scenarios:**
- Built-in workflow values render grouped as Models, Review & Approval, Step Execution, Advanced.
- Plan/Triage, Executor, and Reviewer labels appear instead of raw planning/execution/validator storage names.
- Unknown custom setting renders in Advanced.
- Orphaned values still render in the disclosure and delete through a null patch.
- Built-in workflow opens to Values by default; Definitions remains read-only and secondary.
### U2. Main Settings proxy model lanes for the default workflow
- **Goal:** Let users set Plan/Triage, Executor, and Reviewer models from Project Models without restoring project setting keys.
- **Requirements:** R1, R2, R3, R4, R8, R9
- **Files:** `packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx`, `packages/dashboard/app/components/SettingsModal.tsx`, `packages/dashboard/app/components/__tests__/SettingsModal.test.tsx`, `packages/dashboard/app/components/__tests__/SettingsModalNodeRouting.test.tsx`
- **Approach:** Resolve the active project's default workflow id from the project settings already loaded in the modal, defaulting to `builtin:coding` when unset, matching `workflow-settings-resolver.ts`. Load workflow setting values for that id and render only model lane pairs. Reset clears the stored workflow value via null patch. Keep the project default model lane in the existing project-settings save path.
- **Test scenarios:**
- Project Models renders Plan/Triage, Executor, and Reviewer lane controls when a project is active.
- Editing Plan/Triage writes `planningProvider` and `planningModelId` through `updateWorkflowSettingValues`, not `updateSettings`.
- Editing Executor writes `executionProvider` and `executionModelId`.
- Editing Reviewer writes `validatorProvider` and `validatorModelId`.
- Reset sends nulls for the relevant workflow setting ids.
- No active project disables workflow model controls and makes no API call.
- Normal Project Default Model still saves through `updateSettings`.
### U3. Save-flow and error handling integration
- **Goal:** Make mixed project-settings and workflow-values edits feel coherent without blurring persistence boundaries.
- **Requirements:** R2, R3, R9
- **Files:** `packages/dashboard/app/components/SettingsModal.tsx`, `packages/dashboard/app/components/settings/save-split.ts`, `packages/dashboard/app/__tests__/settings-save-split.test.ts`, `packages/dashboard/app/components/__tests__/SettingsModal.test.tsx`
- **Approach:** Keep `splitSettingsSave` focused on actual settings keys. Add a separate workflow-values dirty state in `SettingsModal`. If the global Save button covers both authorities, run the normal settings save and workflow-values save as separate calls and report partial failures with section-local errors. If the workflow lane group uses its own save button, keep the global dirty state untouched by workflow lane edits.
- **Test scenarios:**
- A workflow lane edit never appears in `splitSettingsSave` output.
- A combined save issues separate `updateSettings` and `updateWorkflowSettingValues` calls when both authorities are dirty.
- A workflow value rejection renders on the lane row and does not discard pending edits.
- A project settings save failure does not falsely report workflow lane values as saved.
### U4. Narrow moved-setting stubs and workflow editor entry points
- **Goal:** Stop sending users away from Settings for the model-lane case while keeping workflow-owned policy discoverable.
- **Requirements:** R5, R8
- **Files:** `packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx`, `packages/dashboard/app/components/settings/sections/MergeSection.tsx`, `packages/dashboard/app/components/settings/sections/SchedulingSection.tsx`, `packages/dashboard/app/components/settings/sections/MovedSettingsStub.tsx`, `packages/dashboard/app/components/__tests__/SettingsModal.test.tsx`
- **Approach:** Replace the Project Models moved stub with the proxy model controls. Keep or narrow stubs for step execution and review/approval settings that remain workflow-editor-only. Update stub copy to say "Advanced workflow policy" instead of implying all per-phase model lanes require leaving Settings.
- **Test scenarios:**
- Project Models no longer renders the generic "Open workflow settings" stub for per-phase model lanes.
- Merge/Scheduling still do not render moved setting inputs such as `verificationFixRetries`.
- Stub button still opens the workflow editor for advanced workflow policy.
### U5. Documentation and permanent boundary tests
- **Goal:** Document the simplified mental model and guard against accidental project-key resurrection.
- **Requirements:** R9, R10
- **Files:** `docs/settings-reference.md`, `docs/dashboard-guide.md`, `packages/core/src/__tests__/settings-parity.test.ts`, `packages/core/src/__tests__/settings-consistency.test.ts`, `packages/dashboard/app/__tests__/settings-sections.test.tsx`
- **Approach:** Update docs to state that Settings can edit default-workflow model lanes, while workflow settings remain the source of truth. Extend parity tests to assert moved model-lane keys are absent from project/global schema lists and present in built-in workflow declarations. Add a UI section test that Project Models contains the proxy lane surface.
- **Test scenarios:**
- `executionProvider`, `planningProvider`, and `validatorProvider` remain absent from `PROJECT_SETTINGS_KEYS`.
- Built-in workflow declarations still include all proxy-edited model lane ids.
- Settings section inventory includes Project Models proxy controls and no stale moved-key stub for model lanes.
---
## Acceptance Examples
- AE1. Given an active project using the default built-in workflow, when a user sets Plan/Triage to `openai/gpt-5` from Project Models and saves, then `workflow_settings` stores `planningProvider: "openai"` and `planningModelId: "gpt-5"` for `(builtin:coding, projectId)`, and the project settings payload contains neither key.
- AE2. Given a project with a custom default workflow, when a user sets Executor from Project Models, then the value is written for that custom workflow id, not globally for every workflow.
- AE3. Given no project is selected, when Settings opens Project Models, then workflow model lane controls are disabled and no workflow setting-value request is sent.
- AE4. Given a built-in workflow is opened in the workflow editor, when the Settings panel opens, then grouped Values are primary and Definitions are read-only secondary details.
---
## Risks & Dependencies
- **Default workflow ambiguity:** Settings must resolve the same default workflow id as the engine, including unset defaults falling back to `builtin:coding`. Reuse the resolver's normalization behavior in tests.
- **Two-authority save UX:** A shared Save button can create partial-success states. Keep errors section-local and avoid mutating the project settings payload with workflow keys.
- **Terminology drift:** Reviewer maps to `validator*` internally. Tests should assert the user-facing label so the UI does not regress back to storage vocabulary.
---
## Sources
- Prior workflow settings hard move: `docs/plans/2026-06-04-002-feat-workflow-settings-mechanism-plan.md`
- Workflow setting value authority: `packages/core/src/workflow-settings.ts`, `packages/core/src/store.ts`
- Effective settings resolver and engine merge semantics: `packages/core/src/workflow-settings-resolver.ts`
- Built-in moved setting declarations: `packages/core/src/builtin-workflow-settings.ts`
- Tombstone boundary: `packages/core/src/moved-settings.ts`
- Current workflow settings UI: `packages/dashboard/app/components/WorkflowSettingsPanel.tsx`
- Current Project Models section and moved stub: `packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx`, `packages/dashboard/app/components/settings/sections/MovedSettingsStub.tsx`

View File

@@ -181,8 +181,15 @@ Some knobs that used to live in this Settings reference as project settings are
*how* tasks execute, so the timeouts, review gates, and per-phase model lanes that *how* tasks execute, so the timeouts, review gates, and per-phase model lanes that
govern that execution belong to the workflow. govern that execution belong to the workflow.
**Where to set them.** Open the **workflow editor** (the workflow node editor in the **Where to set them.** The common model lanes for a project's default workflow are
dashboard) and select the **Settings** panel. It has two tabs: available directly in **Settings → Project Models → Default workflow model lanes**:
Plan/Triage, Executor, and Reviewer. Those controls still write workflow setting
values for the active project's default workflow; they do not restore the old
project settings keys.
For step execution, review/approval policy, fallbacks, title summarization, and
custom workflow settings, open the **workflow editor** (the workflow node editor in
the dashboard) and select the **Settings** panel. It has two tabs:
- **Definitions** — the typed declarations and defaults (read-only for the built-in - **Definitions** — the typed declarations and defaults (read-only for the built-in
`builtin:coding` workflow; editable for custom workflows). `builtin:coding` workflow; editable for custom workflows).
@@ -223,9 +230,10 @@ These groups moved out of project settings and into workflow settings (built-in
| **Review / approval** | `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries` | | **Review / approval** | `requirePrApproval`, `requirePlanApproval`, `reviewHandoffPolicy`, `maxReviewerContextRetries`, `maxReviewerFallbackRetries` |
| **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks), `titleSummarizerProvider`/`titleSummarizerModelId` (+ fallback) | | **Per-phase model lanes** | `executionProvider`/`executionModelId`, `planningProvider`/`planningModelId` (+ fallbacks), `validatorProvider`/`validatorModelId` (+ fallbacks), `titleSummarizerProvider`/`titleSummarizerModelId` (+ fallback) |
In the dashboard Settings modal, the former locations now show a short redirect stub In the dashboard Settings modal, Project Models now exposes Plan/Triage, Executor,
linking to the workflow editor (for one release). Set these in the workflow editor's and Reviewer controls for the default workflow. Former locations for advanced
**Settings → Values** tab for the workflow you want to tune. workflow policy still show a short redirect stub linking to the workflow editor
(for one release).
> Note: the global baseline model lanes (`executionGlobalProvider` etc.) and > Note: the global baseline model lanes (`executionGlobalProvider` etc.) and
> integrity guarantees stay where they are — only the per-workflow process policy > integrity guarantees stay where they are — only the per-workflow process policy
@@ -239,8 +247,9 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
> review/approval, and per-phase model-lane keys listed under > review/approval, and per-phase model-lane keys listed under
> [Where did my setting go?](#where-did-my-setting-go) — are no longer project > [Where did my setting go?](#where-did-my-setting-go) — are no longer project
> settings. They are documented here for type/default reference only; configure them > settings. They are documented here for type/default reference only; configure them
> in the **workflow editor → Settings → Values** tab. They are not writable through > in **Settings → Project Models** for default-workflow Plan/Triage, Executor, and
> `PUT /api/settings`. > Reviewer lanes, or in **workflow editor → Settings → Values** for advanced
> workflow policy. They are not writable through `PUT /api/settings`.
| Setting | Type | Default | Description | | Setting | Type | Default | Description |
|---|---|---:|---| |---|---|---:|---|
@@ -757,12 +766,12 @@ Short-lived token bounds are enforced server-side:
## Model Selection Hierarchy ## Model Selection Hierarchy
Fusion uses a dual-scope model settings system with five lanes. Global settings provide baseline defaults, and project settings provide per-project overrides. Fusion resolves task models through workflow-backed lane values first, then global lane defaults, then the project/global default model fallback. The common workflow lanes are stored as setting values on the project's default workflow and can be edited from Settings -> Project Models -> Default workflow model lanes.
### Planning model ### Planning model
1. Per-task `planningModelProvider` + `planningModelId` 1. Per-task `planningModelProvider` + `planningModelId`
2. Project `planningProvider` + `planningModelId` 2. Default workflow lane value `planningProvider` + `planningModelId`
3. Global `planningGlobalProvider` + `planningGlobalModelId` 3. Global `planningGlobalProvider` + `planningGlobalModelId`
4. Project `defaultProviderOverride` + `defaultModelIdOverride` 4. Project `defaultProviderOverride` + `defaultModelIdOverride`
5. Global `defaultProvider` + `defaultModelId` 5. Global `defaultProvider` + `defaultModelId`
@@ -772,7 +781,7 @@ Fusion uses a dual-scope model settings system with five lanes. Global settings
1. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set 1. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set
2. Per-task `modelProvider` + `modelId` 2. Per-task `modelProvider` + `modelId`
3. Project `executionProvider` + `executionModelId` 3. Default workflow lane value `executionProvider` + `executionModelId`
4. Global `executionGlobalProvider` + `executionGlobalModelId` 4. Global `executionGlobalProvider` + `executionGlobalModelId`
5. Project `defaultProviderOverride` + `defaultModelIdOverride` 5. Project `defaultProviderOverride` + `defaultModelIdOverride`
6. Global `defaultProvider` + `defaultModelId` 6. Global `defaultProvider` + `defaultModelId`
@@ -783,7 +792,7 @@ Fusion uses a dual-scope model settings system with five lanes. Global settings
Heartbeat sessions for durable agents use this order: Heartbeat sessions for durable agents use this order:
1. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when present 1. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when present
2. Project `executionProvider` + `executionModelId` 2. Default workflow lane value `executionProvider` + `executionModelId`
3. Global `executionGlobalProvider` + `executionGlobalModelId` 3. Global `executionGlobalProvider` + `executionGlobalModelId`
4. Project `defaultProviderOverride` + `defaultModelIdOverride` 4. Project `defaultProviderOverride` + `defaultModelIdOverride`
5. Global `defaultProvider` + `defaultModelId` 5. Global `defaultProvider` + `defaultModelId`
@@ -794,7 +803,7 @@ When heartbeat has both (1) and (2-5), the runtime model is used as primary and
### Reviewer model ### Reviewer model
1. Per-task `validatorModelProvider` + `validatorModelId` 1. Per-task `validatorModelProvider` + `validatorModelId`
2. Project `validatorProvider` + `validatorModelId` 2. Default workflow lane value `validatorProvider` + `validatorModelId`
3. Global `validatorGlobalProvider` + `validatorGlobalModelId` 3. Global `validatorGlobalProvider` + `validatorGlobalModelId`
4. Project `defaultProviderOverride` + `defaultModelIdOverride` 4. Project `defaultProviderOverride` + `defaultModelIdOverride`
5. Global `defaultProvider` + `defaultModelId` 5. Global `defaultProvider` + `defaultModelId`
@@ -1370,7 +1379,7 @@ Project-scoped default permission policy for permanent-agent action gates.
All three lanes (planning / executor / reviewer) follow the same 5-tier precedence: All three lanes (planning / executor / reviewer) follow the same 5-tier precedence:
1. Per-task override (`planningModelProvider`/`Id`, `modelProvider`/`Id`, `validatorModelProvider`/`Id`) 1. Per-task override (`planningModelProvider`/`Id`, `modelProvider`/`Id`, `validatorModelProvider`/`Id`)
2. Project lane (`planningProvider`/`Id`, `executionProvider`/`Id`, `validatorProvider`/`Id`) 2. Default workflow lane value (`planningProvider`/`Id`, `executionProvider`/`Id`, `validatorProvider`/`Id`)
3. Global lane (`planningGlobalProvider`/`Id`, `executionGlobalProvider`/`Id`, `validatorGlobalProvider`/`Id`) 3. Global lane (`planningGlobalProvider`/`Id`, `executionGlobalProvider`/`Id`, `validatorGlobalProvider`/`Id`)
4. Project `defaultProviderOverride` / `defaultModelIdOverride` 4. Project `defaultProviderOverride` / `defaultModelIdOverride`
5. Global `defaultProvider` / `defaultModelId` → automatic resolution 5. Global `defaultProvider` / `defaultModelId` → automatic resolution

View File

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

View File

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

View File

@@ -246,11 +246,25 @@
} }
.wf-settings-values-list { .wf-settings-values-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.wf-settings-value-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-xs); 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 { .wf-settings-value-item {
position: relative; position: relative;
display: flex; display: flex;

View File

@@ -41,6 +41,11 @@ import {
ApiRequestError, ApiRequestError,
type WorkflowSettingValuesPayload, type WorkflowSettingValuesPayload,
} from "../api"; } from "../api";
import {
getWorkflowSettingDisplay,
groupWorkflowSettings,
WORKFLOW_SETTING_GROUP_LABELS,
} from "./workflow-setting-display";
import { import {
SettingsToggleRow, SettingsToggleRow,
SettingsNumberRow, SettingsNumberRow,
@@ -609,10 +614,11 @@ function ValuesTab({
const value = effectiveOf(setting); const value = effectiveOf(setting);
const error = rejections[setting.id]?.message; const error = rejections[setting.id]?.message;
const customized = isCustomized(setting); const customized = isCustomized(setting);
const display = getWorkflowSettingDisplay(setting);
const descriptor = { const descriptor = {
key: setting.id, key: setting.id,
label: setting.name, label: display.label,
help: setting.description, help: display.description ?? setting.description,
scope: "project" as const, scope: "project" as const,
}; };
const clearable = customized; const clearable = customized;
@@ -732,15 +738,22 @@ function ValuesTab({
</p> </p>
) : ( ) : (
<div className="wf-settings-values-list"> <div className="wf-settings-values-list">
{settings.map((setting) => ( {groupWorkflowSettings(settings).map(({ group, settings: groupSettings }) => (
<div key={setting.id} className="wf-settings-value-item" data-testid={`wf-settings-value-${setting.id}`}> <section key={group} className="wf-settings-value-group" data-testid={`wf-settings-group-${group}`}>
{renderValueControl(setting)} <h4 className="wf-settings-value-group-title">
{isCustomized(setting) && ( {t(`workflowSettings.group.${group}`, WORKFLOW_SETTING_GROUP_LABELS[group])}
<span className="wf-settings-customized" data-testid={`wf-settings-customized-${setting.id}`}> </h4>
{t("workflowSettings.customized", "Customized")} {groupSettings.map((setting) => (
</span> <div key={setting.id} className="wf-settings-value-item" data-testid={`wf-settings-value-${setting.id}`}>
)} {renderValueControl(setting)}
</div> {isCustomized(setting) && (
<span className="wf-settings-customized" data-testid={`wf-settings-customized-${setting.id}`}>
{t("workflowSettings.customized", "Customized")}
</span>
)}
</div>
))}
</section>
))} ))}
</div> </div>
)} )}
@@ -805,7 +818,7 @@ export function WorkflowSettingsPanel({
addToast, addToast,
}: WorkflowSettingsPanelProps) { }: WorkflowSettingsPanelProps) {
const { t } = useTranslation("app"); 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. // 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 // 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 { SettingsModal } from "../SettingsModal";
import { __test_clearCache as clearPluginUiSlotsCache } from "../../hooks/usePluginUiSlots"; import { __test_clearCache as clearPluginUiSlotsCache } from "../../hooks/usePluginUiSlots";
import type { PluginUiContributionEntry, SettingsExportData, UpdateCheckResponse } from "../../api"; import type { PluginUiContributionEntry, SettingsExportData, UpdateCheckResponse } from "../../api";
import { ApiRequestError } from "../../api";
// --- API mocks --- // --- API mocks ---
const mockFetchSettings = vi.fn(); const mockFetchSettings = vi.fn();
@@ -20,6 +21,8 @@ const mockCancelProviderLogin = vi.fn();
const mockSaveApiKey = vi.fn(); const mockSaveApiKey = vi.fn();
const mockSubmitProviderManualCode = vi.fn(); const mockSubmitProviderManualCode = vi.fn();
const mockFetchModels = vi.fn(); const mockFetchModels = vi.fn();
const mockFetchWorkflowSettingValues = vi.fn();
const mockUpdateWorkflowSettingValues = vi.fn();
const mockFetchCustomProviders = vi.fn(); const mockFetchCustomProviders = vi.fn();
const mockCreateCustomProvider = vi.fn(); const mockCreateCustomProvider = vi.fn();
const mockUpdateCustomProvider = vi.fn(); const mockUpdateCustomProvider = vi.fn();
@@ -80,6 +83,8 @@ vi.mock("../../api", async (importOriginal) => {
saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args), saveApiKey: (...args: unknown[]) => mockSaveApiKey(...args),
submitProviderManualCode: (...args: unknown[]) => mockSubmitProviderManualCode(...args), submitProviderManualCode: (...args: unknown[]) => mockSubmitProviderManualCode(...args),
fetchModels: (...args: unknown[]) => mockFetchModels(...args), fetchModels: (...args: unknown[]) => mockFetchModels(...args),
fetchWorkflowSettingValues: (...args: unknown[]) => mockFetchWorkflowSettingValues(...args),
updateWorkflowSettingValues: (...args: unknown[]) => mockUpdateWorkflowSettingValues(...args),
fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args), fetchCustomProviders: (...args: unknown[]) => mockFetchCustomProviders(...args),
createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args), createCustomProvider: (...args: unknown[]) => mockCreateCustomProvider(...args),
updateCustomProvider: (...args: unknown[]) => mockUpdateCustomProvider(...args), updateCustomProvider: (...args: unknown[]) => mockUpdateCustomProvider(...args),
@@ -538,6 +543,8 @@ describe("SettingsModal", () => {
mockFetchAuthStatus.mockResolvedValue({ providers: [] }); mockFetchAuthStatus.mockResolvedValue({ providers: [] });
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }); mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
mockFetchWorkflowSettingValues.mockResolvedValue({ stored: {}, effective: {}, orphaned: [] });
mockUpdateWorkflowSettingValues.mockResolvedValue({ stored: {}, effective: {}, orphaned: [] });
mockFetchCustomProviders.mockResolvedValue({ providers: [] }); mockFetchCustomProviders.mockResolvedValue({ providers: [] });
mockCreateCustomProvider.mockResolvedValue({ provider: {} }); mockCreateCustomProvider.mockResolvedValue({ provider: {} });
mockUpdateCustomProvider.mockResolvedValue({ provider: {} }); mockUpdateCustomProvider.mockResolvedValue({ provider: {} });
@@ -1520,6 +1527,146 @@ describe("SettingsModal", () => {
expect(globalPayload).not.toHaveProperty("defaultModelIdOverride"); 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", () => { describe("settings header actions", () => {

View File

@@ -68,6 +68,7 @@ function Host({
} }
const openValues = () => fireEvent.click(screen.getByTestId("wf-settings-tab-values")); const openValues = () => fireEvent.click(screen.getByTestId("wf-settings-tab-values"));
const openDefinitions = () => fireEvent.click(screen.getByTestId("wf-settings-tab-definitions"));
beforeEach(() => { beforeEach(() => {
mockFetchValues.mockResolvedValue(payload()); mockFetchValues.mockResolvedValue(payload());
@@ -93,6 +94,7 @@ describe("WorkflowSettingsPanel — Definitions tab", () => {
it("declares a setting of each supported type", () => { it("declares a setting of each supported type", () => {
let latest: WorkflowSettingDefinition[] = []; let latest: WorkflowSettingDefinition[] = [];
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (latest = s)} />); render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (latest = s)} />);
openDefinitions();
const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string"); const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string");
for (const ty of ["text", "number", "boolean", "enum", "multi-enum"]) { for (const ty of ["text", "number", "boolean", "enum", "multi-enum"]) {
fireEvent.change(typeSelect, { target: { value: ty } }); fireEvent.change(typeSelect, { target: { value: ty } });
@@ -103,6 +105,7 @@ describe("WorkflowSettingsPanel — Definitions tab", () => {
it("seeds options when switching to enum", () => { it("seeds options when switching to enum", () => {
let latest: WorkflowSettingDefinition[] = []; let latest: WorkflowSettingDefinition[] = [];
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (latest = s)} />); render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (latest = s)} />);
openDefinitions();
const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string"); const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string");
fireEvent.change(typeSelect, { target: { value: "enum" } }); fireEvent.change(typeSelect, { target: { value: "enum" } });
expect(latest[0].options).toHaveLength(1); expect(latest[0].options).toHaveLength(1);
@@ -128,6 +131,7 @@ describe("WorkflowSettingsPanel — Definitions tab", () => {
); );
} }
render(<H />); render(<H />);
openDefinitions();
const betaItem = screen.getByTestId("wf-setting-beta"); const betaItem = screen.getByTestId("wf-setting-beta");
fireEvent.click(within(betaItem).getByText("Edit id")); fireEvent.click(within(betaItem).getByText("Edit id"));
const idInput = within(betaItem).getByLabelText("Setting id"); const idInput = within(betaItem).getByLabelText("Setting id");
@@ -138,6 +142,8 @@ describe("WorkflowSettingsPanel — Definitions tab", () => {
it("built-in workflows render declarations read-only", () => { it("built-in workflows render declarations read-only", () => {
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} readOnly />); 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(); expect(screen.getByText(/declarations are read-only/i)).toBeInTheDocument();
const nameInput = within(screen.getByTestId("wf-setting-s1")).getByLabelText("Setting name"); const nameInput = within(screen.getByTestId("wf-setting-s1")).getByLabelText("Setting name");
expect(nameInput).toBeDisabled(); expect(nameInput).toBeDisabled();
@@ -165,6 +171,31 @@ describe("WorkflowSettingsPanel — Values tab", () => {
expect(screen.queryByTestId("wf-settings-customized-new-sessions")).not.toBeInTheDocument(); 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 () => { it("batches three field edits into exactly ONE patch on Save values", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } })); mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } }));
render(<Host initial={decls} />); render(<Host initial={decls} />);

View File

@@ -1,24 +1,25 @@
/** /**
* Project Models section (U9 / KTD-10). * Project Models section (U9 / KTD-10).
* *
* Project-scoped model configuration that survives the workflow hard-move: token * Project-scoped model configuration. The project DEFAULT model lane still saves
* cap, the project DEFAULT model lane, model presets (with the inline editor and * as project settings. The common workflow model lanes (Plan/Triage, Executor,
* size-based auto-selection), and the title/commit summarization toggles. The * Reviewer) are now proxy-edited here for the active default workflow while
* per-phase execution/planning/validator lanes and the title-summarizer lane * persisting through workflow setting values, not tombstoned project keys.
* 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.
*/ */
import type { ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { ModelPreset, Settings } from "@fusion/core"; 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 { CustomModelDropdown } from "../../CustomModelDropdown";
import { applyPresetToSelection } from "../../../utils/modelPresets"; import { applyPresetToSelection } from "../../../utils/modelPresets";
import { MovedSettingsStub } from "./MovedSettingsStub"; import type { ToastType } from "../../../hooks/useToast";
import type { ModelLane, SectionBaseProps, SettingsFormState } from "./context"; import type { ModelLane, SectionBaseProps, SettingsFormState } from "./context";
type LaneStatus = "inherited" | "overridden"; type LaneStatus = "inherited" | "overridden";
@@ -46,10 +47,59 @@ export interface ProjectModelsSectionModelProps {
export interface ProjectModelsSectionProps extends SectionBaseProps { export interface ProjectModelsSectionProps extends SectionBaseProps {
scopeBanner: ReactNode; scopeBanner: ReactNode;
models: ProjectModelsSectionModelProps; models: ProjectModelsSectionModelProps;
projectId?: string;
addToast: (message: string, type?: ToastType) => void;
onOpenWorkflowSettings?: () => 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 { t } = useTranslation("app");
const { const {
modelLanes, modelLanes,
@@ -74,6 +124,141 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, onOpe
const presets = form.modelPresets || []; const presets = form.modelPresets || [];
const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name })); const presetOptions = presets.map((preset) => ({ id: preset.id, name: preset.name }));
const inUsePresetIds = new Set(Object.values(form.defaultPresetBySize || {}).filter(Boolean)); 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 // Only the project DEFAULT model lane survives in this modal. The
// per-phase execution/planning/validator lanes, their fallbacks, and 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) --- */} {/* --- Default workflow model lanes (workflow setting values) --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Per-phase model lanes</h4> <h4 className="settings-section-heading settings-section-heading--spaced">Default workflow model lanes</h4>
<MovedSettingsStub <p className="settings-description">
message={t( These controls edit model values on this project's default workflow ({resolvedWorkflowId}).
"settings.movedStub.modelLanes", They use workflow settings as the source of truth.
"Per-phase model lanes (execution, planning, reviewer, their fallbacks, and the title summarizer) now live on the workflow.", </p>
)} {!projectId ? (
onOpenWorkflowSettings={onOpenWorkflowSettings} <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 --- */} {/* --- Model Presets --- */}
<h4 className="settings-section-heading settings-section-heading--spaced">Model Presets</h4> <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);
}