feat(workflow-settings): simplify workflow model lanes
This commit is contained in:
5
.changeset/workflow-model-lanes-settings.md
Normal file
5
.changeset/workflow-model-lanes-settings.md
Normal 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.
|
||||
@@ -107,6 +107,8 @@ Navigation:
|
||||
|
||||
Behavior:
|
||||
- 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 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
|
||||
|
||||
@@ -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`
|
||||
@@ -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
|
||||
govern that execution belong to the workflow.
|
||||
|
||||
**Where to set them.** Open the **workflow editor** (the workflow node editor in the
|
||||
dashboard) and select the **Settings** panel. It has two tabs:
|
||||
**Where to set them.** The common model lanes for a project's default workflow are
|
||||
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
|
||||
`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` |
|
||||
| **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
|
||||
linking to the workflow editor (for one release). Set these in the workflow editor's
|
||||
**Settings → Values** tab for the workflow you want to tune.
|
||||
In the dashboard Settings modal, Project Models now exposes Plan/Triage, Executor,
|
||||
and Reviewer controls for the default workflow. Former locations for advanced
|
||||
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
|
||||
> 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
|
||||
> [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
|
||||
> in the **workflow editor → Settings → Values** tab. They are not writable through
|
||||
> `PUT /api/settings`.
|
||||
> in **Settings → Project Models** for default-workflow Plan/Triage, Executor, and
|
||||
> Reviewer lanes, or in **workflow editor → Settings → Values** for advanced
|
||||
> workflow policy. They are not writable through `PUT /api/settings`.
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
|---|---|---:|---|
|
||||
@@ -757,12 +766,12 @@ Short-lived token bounds are enforced server-side:
|
||||
|
||||
## 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
|
||||
|
||||
1. Per-task `planningModelProvider` + `planningModelId`
|
||||
2. Project `planningProvider` + `planningModelId`
|
||||
2. Default workflow lane value `planningProvider` + `planningModelId`
|
||||
3. Global `planningGlobalProvider` + `planningGlobalModelId`
|
||||
4. Project `defaultProviderOverride` + `defaultModelIdOverride`
|
||||
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
|
||||
2. Per-task `modelProvider` + `modelId`
|
||||
3. Project `executionProvider` + `executionModelId`
|
||||
3. Default workflow lane value `executionProvider` + `executionModelId`
|
||||
4. Global `executionGlobalProvider` + `executionGlobalModelId`
|
||||
5. Project `defaultProviderOverride` + `defaultModelIdOverride`
|
||||
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:
|
||||
|
||||
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`
|
||||
4. Project `defaultProviderOverride` + `defaultModelIdOverride`
|
||||
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
|
||||
|
||||
1. Per-task `validatorModelProvider` + `validatorModelId`
|
||||
2. Project `validatorProvider` + `validatorModelId`
|
||||
2. Default workflow lane value `validatorProvider` + `validatorModelId`
|
||||
3. Global `validatorGlobalProvider` + `validatorGlobalModelId`
|
||||
4. Project `defaultProviderOverride` + `defaultModelIdOverride`
|
||||
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:
|
||||
|
||||
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`)
|
||||
4. Project `defaultProviderOverride` / `defaultModelIdOverride`
|
||||
5. Global `defaultProvider` / `defaultModelId` → automatic resolution
|
||||
|
||||
@@ -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