FN-7795: add thinking-level selectors to fallback model pickers

Adds a per-lane thinking-level selector to every fallback model picker across global, project, and workflow Settings surfaces, wiring them into the existing null-as-delete save paths.

- Add inline thinking-level dropdowns to the global Fallback Model, workflow-declared planning/validator fallback lanes, and the project-scoped Title Summarizer fallback picker via CustomModelDropdown's `showThinkingLevel`.
- Route `planningFallbackThinkingLevel`/`validatorFallbackThinkingLevel` through workflow settings PATCH and `titleSummarizerFallbackThinkingLevel` through project settings save-split, both with null-as-delete semantics on reset.
- Move Title Summarizer fallback out of the workflow-declared model pairs into a dedicated project-scoped lane in ProjectModelsSection so its thinking companion isn't tied to workflow settings.
- Update WorkflowSettingsPanel to surface the new fallback thinking companion keys.
- Extend/adjust tests (settings-save-split, settings-sections, SettingsModal.models-auth, WorkflowSettingsPanel, core settings-migration) to cover the new selectors and save routing.
- Document the fallback thinking-level runtime behavior in docs/settings-reference.md and docs/dashboard-guide.md.
- Add a minor changeset describing the new fallback thinking-level selectors.

Files changed:
 .changeset/fn-7795-fallback-thinking-selectors.md  |   7 +
 docs/dashboard-guide.md                            |   4 +-
 docs/settings-reference.md                         |  10 +-
 packages/core/src/__tests__/settings-migration.test.ts  |  14 +-
 packages/dashboard/app/__tests__/settings-save-split.test.ts      |  35 +++++
 packages/dashboard/app/__tests__/settings-sections.test.tsx       | 163 ++++++++++++++++++++-
 packages/dashboard/app/components/WorkflowSettingsPanel.tsx       |   6 +-
 packages/dashboard/app/components/__tests__/SettingsModal.models-auth.test.tsx   |  11 +-
 packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx       |  38 +++++
 packages/dashboard/app/components/settings/save-split.ts          |   9 +-
 packages/dashboard/app/components/settings/sections/GlobalModelsSection.tsx      |   8 +-
 packages/dashboard/app/components/settings/sections/ProjectModelsSection.tsx     |  58 ++++++--
 12 files changed, 328 insertions(+), 35 deletions(-)

Fusion-Task-Id: FN-7795

Fusion-Task-Lineage: ec990d47-defe-4717-993a-56988afe8d7d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 12:02:46 -07:00
parent fc4acd4d66
commit 3d5cc0ada4
12 changed files with 328 additions and 35 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add inline thinking-level selectors to every fallback model picker in Settings.
category: feature
dev: Binds fallback pickers (global Fallback Model, workflow planning/validator fallback lanes, project title-summarizer fallback) to the FN-7793 keys via CustomModelDropdown showThinkingLevel; save-split routes fallbackThinkingLevel (global) and titleSummarizerFallbackThinkingLevel (project) with null-as-delete parity.

View File

@@ -343,8 +343,8 @@ Behavior:
<!-- FNXC:WorkflowEditor 2026-06-29-20:09: Optional-group containers are visual boundaries in the workflow editor. Top-level workflow edges attach to the group boundary, while template-child edges remain inside the group so operators can distinguish optional-block connectivity from the inner step implementation.
FNXC:WorkflowEditor 2026-06-29-21:10: Optional-group template entry/exit ownership remains visual-only. Non-editable boundary connector lines must explain how template children attach to the container without persisting fake edges into workflow IR. -->
- Optional-group, foreach, and loop containers show their template nodes inside the block. Canvas connections between the surrounding workflow and the block attach to the container boundary; connections between template nodes stay inside the block. Optional groups also draw non-editable entry/exit connector lines between the boundary and the template entry/exit nodes so single-step blocks such as Plan Review and Code Review do not look disconnected; those visual connectors are not saved into workflow IR.
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs are saved together; custom or non-model string values can still use typed inputs. 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**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow.
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Known workflow model values use the same model dropdown picker as **Settings → Project Models** so provider/model pairs and their inline Thinking Level companions are saved together; custom or non-model string values can still use typed inputs. Definitions remain available for custom workflow schema authoring.
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, Reviewer, and declared Planning/Reviewer fallback model lanes from **Project Models**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow. Settings fallback model pickers (Global Fallback Model, workflow fallbacks, and project Title Summarizer Fallback) include the same inline Thinking Level selector when their companion key exists.
- On desktop, the editor uses a multi-panel canvas layout for editing the graph and adjacent workflow metadata. The **Show simple editor** toggle switches that same workflow into the graph-outline editor with dedicated **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions** tabs.
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the Board/List workflow dropdown row edit action opens directly to the selected workflow editor when that selected workflow is available.
- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and optional-group/foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. The structural **start** node opens an inspector for the workflow entry column when the workflow defines columns; the **Name** field remains unavailable because the start label is structural. For custom workflows, editable outline rows also expose **Move up** and **Move down** controls that reorder steps within their current column or template parent; built-in workflows remain read-only and hide those controls.

View File

@@ -263,13 +263,13 @@ govern that execution belong to the workflow.
**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, Reviewer, and the Planning/Reviewer fallback lanes declared
by the default workflow. Primary Plan/Triage, Executor, and Reviewer rows also show an inline Thinking Level control when the workflow declares `planningThinkingLevel`, `executionThinkingLevel`, or `validatorThinkingLevel`; unset means inherit. Those dropdown controls use the shared model picker and are persisted by the Settings modal's primary **Save** action, which writes
by the default workflow. Primary Plan/Triage, Executor, Reviewer, and declared fallback rows show an inline Thinking Level control when the workflow declares the companion `*ThinkingLevel` setting; unset means inherit. Those dropdown controls use the shared model picker and are persisted by the Settings modal's primary **Save** action, which writes
workflow setting values for the active project's default workflow; they do not
restore the old project settings keys. The global **Fallback Model** remains in
Settings → General Models, and workflow-specific fallbacks are also editable from
Settings → General Models and includes its own inline Thinking Level selector for `fallbackThinkingLevel`; workflow-specific fallbacks are also editable from
the workflow editor Values tab. Title summarization is separate: set it in
**Settings → Project Models → Title and Git Commit Message Summarization Model**,
with its global baseline in Settings → General/Global Models.
with its project fallback selector and global baseline in Settings → General/Global Models.
<!--
FNXC:WorkflowSettings 2026-06-17-09:13:
@@ -288,7 +288,7 @@ Actions. It has two tabs:
editable for any workflow, including built-ins. Common provider/model lane pairs
(Plan/Triage, Executor, Reviewer, and fallbacks declared by the workflow) use the
same model dropdown picker as Project Models so clearing or selecting a model
updates both keys together. Declared primary-lane thinking companions render inline
updates both keys together. Declared primary and fallback lane thinking companions render inline
and clear with the lane reset instead of as separate enum fields. Advanced/custom non-model settings still use typed
controls. Built-in Plan Review/spec and Code Review revision caps also live here:
leave `planReviewMaxRevisions` or `codeReviewMaxRevisions` empty for unbounded
@@ -978,7 +978,7 @@ Short-lived token bounds are enforced server-side:
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 with dropdown controls from Settings -> Project Models -> Default workflow model lanes (persisted by the Settings modal's primary Save) or from workflow editor -> Settings -> Values for declared workflow lanes and fallbacks. General-scope fallback selection remains the global Fallback Model picker in Settings -> General Models.
Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Primary workflow lanes declare `executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel` per `(workflow, project)`; empty thinking values inherit through the lane/global/default chain and explicit values are cleared by the lane reset action. Runtime thinking precedence for task/workflow execution is node/step `config.thinkingLevel` > task `thinkingLevel` > workflow lane thinking override > global lane thinking override > project default thinking override > global `defaultThinkingLevel`. Model-mode Chat sessions use the same executor-lane resolver with session `thinkingLevel` in the task slot, so an empty chat-session value inherits project/global defaults while a concrete New Chat selection wins for that session. The resolved value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters).
Settings model lanes can also carry optional thinking/reasoning effort overrides in the same model dropdown. Primary workflow lanes declare `executionThinkingLevel`, `planningThinkingLevel`, or `validatorThinkingLevel` per `(workflow, project)`; planning/reviewer fallback lanes declare `planningFallbackThinkingLevel` and `validatorFallbackThinkingLevel`; global fallback uses `fallbackThinkingLevel`; and project title summarization fallback uses `titleSummarizerFallbackThinkingLevel`. Empty thinking values inherit through the lane/global/default chain and explicit values are cleared by the lane reset action. Runtime thinking precedence for task/workflow execution is node/step `config.thinkingLevel` > task `thinkingLevel` > workflow lane thinking override > global lane thinking override > project default thinking override > global `defaultThinkingLevel`. Model-mode Chat sessions use the same executor-lane resolver with session `thinkingLevel` in the task slot, so an empty chat-session value inherits project/global defaults while a concrete New Chat selection wins for that session. The resolved value still flows through pi.ts' existing thinking/reasoning-conflict fallback (Fusion retries without the explicit level when a provider rejects conflicting thinking parameters).
When the planning lane has neither `planningFallback*` nor a global `fallback*` pair configured, triage now derives an **implicit fallback** from the resolved project/global default (execution) model (FN-7719). This lets a retryable primary planner-model failure (e.g. a provider 404/429) recover via one distinct swap instead of permanently failing triage with "no fallback configured" — the operator's chosen primary planner lane is unchanged, and the implicit fallback is skipped when it would equal the primary model or when test mode is active.

View File

@@ -180,8 +180,16 @@ describe("settings hard-move migration (U4)", () => {
expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerModelId", undefined);
expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerFallbackProvider", undefined);
expect(DEFAULT_PROJECT_SETTINGS).toHaveProperty("titleSummarizerFallbackModelId", undefined);
// 26 keys after removing buildTimeoutMs plus the summarizer lane from the moved catalog.
expect(MOVED_SETTINGS_KEYS.length).toBe(26);
/*
* FNXC:Settings-ThinkingLevel 2026-07-10-12:30:
* 31 keys after removing buildTimeoutMs plus the summarizer lane from the moved catalog, plus the
* primary-lane thinking companions (executionThinkingLevel/planningThinkingLevel/validatorThinkingLevel,
* FN-7770-7772) and the fallback-lane thinking companions (planningFallbackThinkingLevel/
* validatorFallbackThinkingLevel, FN-7793) declared alongside their provider/model pairs above.
* Review FN-7795 found this count stale at 26 (pre-existing drift from earlier thinking-level tasks,
* not introduced by FN-7795) and corrected it here so `pnpm test`/`verify:fast` stay green.
*/
expect(MOVED_SETTINGS_KEYS.length).toBe(31);
});
it("workflow-native triage policy settings are excluded from moved/project schemas", () => {
@@ -190,7 +198,7 @@ describe("settings hard-move migration (U4)", () => {
expect(PROJECT_SETTINGS_KEYS, `${setting.id} must not be a project schema key`).not.toContain(setting.id);
expect(DEFAULT_PROJECT_SETTINGS as Record<string, unknown>).not.toHaveProperty(setting.id);
}
expect(MOVED_SETTINGS_KEYS.length).toBe(26);
expect(MOVED_SETTINGS_KEYS.length).toBe(31);
});
it("fresh project post-init: marker set, effective values equal declaration defaults, no moved key in PROJECT_SETTINGS_KEYS", async () => {

View File

@@ -521,6 +521,41 @@ describe("splitSettingsSave", () => {
expect(globalPatch).toEqual({ ntfyTopic: null });
});
it("routes fallback thinking keys through their owning settings scopes with null-as-delete", () => {
const globalResult = splitSettingsSave({
payload: { fallbackThinkingLevel: "high" },
initialValues: { fallbackThinkingLevel: undefined } as never,
initialScopedValues: { global: {}, project: {} } as never,
activeSection: "global-models",
});
expect(globalResult.globalPatch).toEqual({ fallbackThinkingLevel: "high" });
expect(globalResult.projectPatch).toEqual({});
const globalClearResult = splitSettingsSave({
payload: { fallbackThinkingLevel: undefined },
initialValues: { fallbackThinkingLevel: "high" } as never,
initialScopedValues: { global: { fallbackThinkingLevel: "high" }, project: {} } as never,
activeSection: "project-models",
});
expect(globalClearResult.globalPatch).toEqual({ fallbackThinkingLevel: null });
const projectResult = splitSettingsSave({
payload: { titleSummarizerFallbackThinkingLevel: "low" },
initialValues: {} as never,
initialScopedValues: { global: {}, project: {} } as never,
activeSection: "project-models",
});
expect(projectResult.projectPatch).toEqual({ titleSummarizerFallbackThinkingLevel: "low" });
const projectClearResult = splitSettingsSave({
payload: { titleSummarizerFallbackThinkingLevel: undefined },
initialValues: {} as never,
initialScopedValues: { global: {}, project: { titleSummarizerFallbackThinkingLevel: "medium" } } as never,
activeSection: "project-models",
});
expect(projectClearResult.projectPatch).toEqual({ titleSummarizerFallbackThinkingLevel: null });
});
it("drops plain-undefined global keys that were never set", () => {
const payload: Record<string, unknown> = {
ntfyTopic: undefined, // never had a value → passed through as undefined

View File

@@ -10,7 +10,7 @@
*/
import { useState } from "react";
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import { render, screen, fireEvent, cleanup, waitFor, act, within } from "@testing-library/react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import { AppearanceSection } from "../components/settings/sections/AppearanceSection";
@@ -61,6 +61,9 @@ vi.mock("../components/CustomModelDropdown", () => ({
>
{label}
</button>
<button type="button" data-testid={`mock-model-clear-${id ?? label}`} onClick={() => onChange?.("")}>
clear
</button>
{showThinkingLevel ? (
<button type="button" data-testid={`mock-thinking-${id ?? label}`} onClick={() => onThinkingLevelChange?.(thinkingLevel ? "" : "high")}>
thinking:{thinkingLevel || "inherit"}
@@ -324,6 +327,51 @@ describe("GlobalModelsSection", () => {
fireEvent.click(screen.getByTestId("mock-thinking-global-execution-model"));
expect(updateLaneThinkingValue).toHaveBeenCalledWith(expect.objectContaining({ laneId: "execution" }), "");
});
it("wires global fallback thinking value and clears it with the fallback model", () => {
function GlobalFallbackHost() {
const [form, setForm] = useState<SettingsFormState>({
defaultThinkingLevel: "medium",
fallbackProvider: "anthropic",
fallbackModelId: "claude-sonnet-4-5",
fallbackThinkingLevel: "high",
} as SettingsFormState);
return (
<GlobalModelsSection
scopeBanner={null}
form={form}
setForm={setForm}
availableModels={[{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true }]}
modelsLoading={false}
globalModelLanes={[]}
getLaneThinkingValue={() => ""}
updateLaneThinkingValue={vi.fn()}
resetLaneThinkingValue={vi.fn()}
favoriteProviders={[]}
favoriteModels={[]}
onToggleFavorite={vi.fn()}
onToggleModelFavorite={vi.fn()}
addToast={vi.fn()}
/>
);
}
render(<GlobalFallbackHost />);
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-thinking-visible", "true");
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-thinking-value", "high");
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-default-thinking", "medium");
fireEvent.click(screen.getByTestId("mock-thinking-fallbackModel"));
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-thinking-value", "");
fireEvent.click(screen.getByTestId("mock-thinking-fallbackModel"));
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-thinking-value", "high");
fireEvent.click(screen.getByTestId("mock-model-clear-fallbackModel"));
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-value", "");
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-thinking-value", "");
});
});
describe("ProjectModelsSection", () => {
@@ -411,14 +459,121 @@ describe("ProjectModelsSection", () => {
expect(resetLaneThinkingValue).toHaveBeenCalledWith(expect.objectContaining({ laneId: "default" }));
});
it("wires project title-summarizer fallback thinking and clears it with reset", () => {
function ProjectTitleFallbackHost() {
const [form, setForm] = useState<SettingsFormState>({
defaultThinkingLevel: "medium",
titleSummarizerFallbackProvider: "anthropic",
titleSummarizerFallbackModelId: "claude-sonnet-4-5",
titleSummarizerFallbackThinkingLevel: "high",
} as SettingsFormState);
return (
<ProjectModelsSection
scopeBanner={null}
form={form}
setForm={setForm}
models={{
...models,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
addToast={vi.fn()}
/>
);
}
render(<ProjectTitleFallbackHost />);
const dropdown = screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel");
expect(dropdown).toHaveAttribute("data-thinking-visible", "true");
expect(dropdown).toHaveAttribute("data-thinking-value", "high");
expect(dropdown).toHaveAttribute("data-default-thinking", "medium");
fireEvent.click(screen.getByTestId("mock-thinking-titleSummarizerFallbackModel"));
expect(screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel")).toHaveAttribute("data-thinking-value", "");
fireEvent.click(screen.getByTestId("mock-thinking-titleSummarizerFallbackModel"));
expect(screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel")).toHaveAttribute("data-thinking-value", "high");
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
expect(screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel")).toHaveAttribute("data-value", "");
expect(screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel")).toHaveAttribute("data-thinking-value", "");
});
it("wires workflow fallback lane thinking render, persist, and reset", async () => {
let saver: (() => Promise<void>) | null = null;
vi.mocked(fetchWorkflow).mockResolvedValue({
id: "builtin:coding",
name: "Coding",
ir: {
settings: [
{ id: "planningFallbackProvider", name: "Planning fallback provider", type: "string" },
{ id: "planningFallbackModelId", name: "Planning fallback model", type: "string" },
{ id: "planningFallbackThinkingLevel", name: "Planning fallback thinking", type: "enum" },
{ id: "validatorFallbackProvider", name: "Reviewer fallback provider", type: "string" },
{ id: "validatorFallbackModelId", name: "Reviewer fallback model", type: "string" },
{ id: "validatorFallbackThinkingLevel", name: "Reviewer fallback thinking", type: "enum" },
],
},
} as never);
vi.mocked(fetchWorkflowSettingValues).mockResolvedValueOnce({
stored: { validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet-4-5", validatorFallbackThinkingLevel: "high" },
effective: { validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet-4-5", validatorFallbackThinkingLevel: "high" },
orphaned: [],
});
vi.mocked(updateWorkflowSettingValues).mockResolvedValueOnce({
stored: { validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet-4-5", validatorFallbackThinkingLevel: "high" },
effective: { validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet-4-5", validatorFallbackThinkingLevel: "high" },
orphaned: [],
});
render(
<ProjectModelsSection
scopeBanner={null}
form={{ defaultWorkflowId: "builtin:coding", defaultThinkingLevel: "medium" } as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
projectId="project-1"
addToast={vi.fn()}
registerWorkflowLaneSaver={(next) => {
saver = next;
}}
/>,
);
const planning = await screen.findByTestId("mock-model-dropdown-workflow-planning-fallback-model");
expect(planning).toHaveAttribute("data-thinking-visible", "true");
expect(planning).toHaveAttribute("data-default-thinking", "medium");
expect(screen.getByTestId("mock-model-dropdown-workflow-validator-fallback-model")).toHaveAttribute("data-thinking-value", "high");
fireEvent.click(screen.getByTestId("mock-thinking-workflow-planning-fallback-model"));
await act(async () => {
await saver!();
});
expect(updateWorkflowSettingValues).toHaveBeenLastCalledWith("builtin:coding", { planningFallbackThinkingLevel: "high" }, "project-1");
fireEvent.click(within(screen.getByTestId("workflow-model-lane-validator-fallback")).getByRole("button", { name: "Reset" }));
await act(async () => {
await saver!();
});
expect(updateWorkflowSettingValues).toHaveBeenLastCalledWith("builtin:coding", {
validatorFallbackProvider: null,
validatorFallbackModelId: null,
validatorFallbackThinkingLevel: null,
}, "project-1");
});
it("opts default workflow model lane dropdowns into readable menu width", async () => {
vi.mocked(fetchWorkflow).mockResolvedValueOnce({
vi.mocked(fetchWorkflow).mockResolvedValue({
id: "builtin:coding",
name: "Coding",
ir: {
settings: [
{ id: "planningProvider", name: "Planning Provider", type: "string" },
{ id: "planningModelId", name: "Planning Model", type: "string" },
{ id: "planningThinkingLevel", name: "Planning thinking", type: "enum" },
],
},
} as never);
@@ -444,15 +599,17 @@ describe("ProjectModelsSection", () => {
it("preserves workflow lane edits made while the registered saver is in flight", async () => {
let saver: (() => Promise<void>) | null = null;
let resolveSave!: (value: WorkflowSettingValuesPayload) => void;
vi.mocked(fetchWorkflow).mockResolvedValueOnce({
vi.mocked(fetchWorkflow).mockResolvedValue({
id: "builtin:coding",
name: "Coding",
ir: {
settings: [
{ id: "planningProvider", name: "Planning Provider", type: "string" },
{ id: "planningModelId", name: "Planning Model", type: "string" },
{ id: "planningThinkingLevel", name: "Planning thinking", type: "enum" },
{ id: "executionProvider", name: "Execution Provider", type: "string" },
{ id: "executionModelId", name: "Execution Model", type: "string" },
{ id: "executionThinkingLevel", name: "Execution thinking", type: "enum" },
],
},
} as never);

View File

@@ -513,8 +513,8 @@ export interface WorkflowModelLanePair {
FNXC:WorkflowSettings 2026-06-17-09:13:
Title summarization is owned by project/global Settings → Project Models, not workflow Values. Keep this workflow-editor catalog limited to workflow-executed model lanes so custom declarations cannot create misleading title-summarizer dropdowns whose values are orphaned for built-in workflows.
FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
Workflow Values renders thinking controls inline with declared primary model lanes only. The companion setting ids are excluded from generic enum rendering so operators get one inherit/override/reset affordance and undeclared lanes leave no empty control shell.
FNXC:Settings-ThinkingLevel 2026-07-10-12:03:
Workflow Values renders thinking controls inline with declared primary and fallback model lanes. The companion setting ids are excluded from generic enum rendering so operators get one inherit/override/reset affordance and undeclared lanes leave no empty control shell.
*/
export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
{
@@ -545,6 +545,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
id: "planning-fallback",
providerId: "planningFallbackProvider",
modelId: "planningFallbackModelId",
thinkingId: "planningFallbackThinkingLevel",
label: "Planning Fallback Model",
help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.",
},
@@ -552,6 +553,7 @@ export const WORKFLOW_MODEL_LANE_CATALOG: WorkflowModelLanePair[] = [
id: "validator-fallback",
providerId: "validatorFallbackProvider",
modelId: "validatorFallbackModelId",
thinkingId: "validatorFallbackThinkingLevel",
label: "Reviewer Fallback Model",
help: "Fallback provider and model used when the primary Reviewer model cannot be used.",
},

View File

@@ -369,10 +369,10 @@ describe("SettingsModal", () => {
const fallbackWorkflowModelSettingIds = [
"planningFallbackProvider",
"planningFallbackModelId",
"planningFallbackThinkingLevel",
"validatorFallbackProvider",
"validatorFallbackModelId",
"titleSummarizerFallbackProvider",
"titleSummarizerFallbackModelId",
"validatorFallbackThinkingLevel",
];
async function setupWorkflowModelLaneTest({
@@ -483,7 +483,7 @@ describe("SettingsModal", () => {
expect(screen.getByTestId("workflow-model-lane-planning-fallback")).toBeInTheDocument();
expect(screen.getByTestId("workflow-model-lane-validator-fallback")).toBeInTheDocument();
expect(screen.getByTestId("workflow-model-lane-title-summarizer-fallback")).toBeInTheDocument();
expect(screen.getByTestId("project-model-lane-title-summarizer-fallback")).toBeInTheDocument();
cleanup();
mockFetchWorkflow.mockClear();
@@ -493,10 +493,9 @@ describe("SettingsModal", () => {
expect(screen.queryByTestId("workflow-model-lane-planning-fallback")).not.toBeInTheDocument();
expect(screen.queryByTestId("workflow-model-lane-validator-fallback")).not.toBeInTheDocument();
expect(screen.queryByTestId("workflow-model-lane-title-summarizer-fallback")).not.toBeInTheDocument();
expect(screen.getByTestId("project-model-lane-title-summarizer-fallback")).toBeInTheDocument();
expect(screen.queryByText("Planning Fallback Model")).not.toBeInTheDocument();
expect(screen.queryByText("Reviewer Fallback Model")).not.toBeInTheDocument();
expect(screen.queryByText("Title Summarizer Fallback Model")).not.toBeInTheDocument();
});
it("persists fallback workflow model lane edits through the primary Settings Save", async () => {
@@ -537,7 +536,7 @@ describe("SettingsModal", () => {
await waitFor(() => {
expect(mockUpdateWorkflowSettingValues).toHaveBeenCalledWith(
"workflow-custom",
{ validatorFallbackProvider: null, validatorFallbackModelId: null },
{ validatorFallbackProvider: null, validatorFallbackModelId: null, validatorFallbackThinkingLevel: null },
"proj-1",
);
});

View File

@@ -593,6 +593,10 @@ describe("WorkflowSettingsPanel — Values tab", () => {
{ id: "validatorThinkingLevel", name: "Validator thinking level", type: "enum", options: [{ value: "minimal", label: "Minimal" }] },
{ id: "planningFallbackProvider", name: "Planning fallback provider", type: "string" },
{ id: "planningFallbackModelId", name: "Planning fallback model", type: "string" },
{ id: "planningFallbackThinkingLevel", name: "Planning fallback thinking level", type: "enum", options: [{ value: "high", label: "High" }] },
{ id: "validatorFallbackProvider", name: "Validator fallback provider", type: "string" },
{ id: "validatorFallbackModelId", name: "Validator fallback model", type: "string" },
{ id: "validatorFallbackThinkingLevel", name: "Validator fallback thinking level", type: "enum", options: [{ value: "low", label: "Low" }] },
{ id: "customModelProvider", name: "Custom model provider", type: "string" },
];
@@ -684,6 +688,40 @@ describe("WorkflowSettingsPanel — Values tab", () => {
expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { planningThinkingLevel: null }, "proj-1");
});
it("edits workflow fallback lane thinking inline and reset clears the companion setting", async () => {
mockFetchValues.mockResolvedValue(
payload({
stored: { validatorFallbackThinkingLevel: "low", validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet" },
effective: { validatorFallbackThinkingLevel: "low", validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet" },
}),
);
mockUpdateValues.mockResolvedValueOnce(payload({
stored: { validatorFallbackThinkingLevel: "low", validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet" },
effective: { validatorFallbackThinkingLevel: "low", validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet" },
}));
render(<Host initial={modelDecls} readOnly />);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
fireEvent.click(await screen.findByLabelText("Planning Fallback Model"));
fireEvent.change(screen.getByTestId("custom-model-dropdown-thinking"), { target: { value: "high" } });
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { planningFallbackThinkingLevel: "high" }, "proj-1");
mockUpdateValues.mockClear();
const fallbackRow = screen.getByTestId("wf-settings-value-validator-fallback");
fireEvent.click(within(fallbackRow).getByRole("button", { name: "Reset to default" }));
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", {
validatorFallbackProvider: null,
validatorFallbackModelId: null,
validatorFallbackThinkingLevel: null,
}, "proj-1");
});
it("does not render a thinking control when a lane lacks its companion declaration", async () => {
const withoutThinking = modelDecls.filter((setting) => setting.id !== "planningThinkingLevel");
render(<Host initial={withoutThinking} readOnly />);

View File

@@ -36,11 +36,16 @@ import type { GlobalSettings, Settings } from "@fusion/core";
* needs the same changed-only/null-as-delete handling as the project default
* lane overrides. Execution/planning/validator lanes still live on workflow
* settings and are filtered out before the project branch is reached.
*
* FNXC:Settings-ThinkingLevel 2026-07-10-12:10:
* The project-scoped title-summarizer fallback thinking companion must travel
* with its provider/model pair so clearing the inline selector serializes as
* null-as-delete instead of being dropped as an unchanged inherited value.
*/
export const MODEL_LANE_KEYS = [
"defaultProviderOverride", "defaultModelIdOverride",
"titleSummarizerProvider", "titleSummarizerModelId",
"titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId",
"titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", "titleSummarizerFallbackThinkingLevel",
] as const;
const MODEL_LANE_KEY_SET = new Set<string>(MODEL_LANE_KEYS);
@@ -125,6 +130,7 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"defaultModelId",
"fallbackProvider",
"fallbackModelId",
"fallbackThinkingLevel",
"defaultThinkingLevel",
"modelRouterEnabled",
"modelRouterCheapProvider",
@@ -148,6 +154,7 @@ export const GLOBAL_SECTION_KEYS: Record<string, ReadonlySet<string>> = {
"defaultModelId",
"fallbackProvider",
"fallbackModelId",
"fallbackThinkingLevel",
"defaultThinkingLevel",
"modelRouterEnabled",
"modelRouterCheapProvider",

View File

@@ -63,9 +63,10 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
<div className="form-group">
<label htmlFor="fallbackModel">{t("settings.globalModels.fallbackModel", "Fallback Model")}</label>
{/* FNXC:Settings-ThinkingLevel 2026-07-10-12:00: Global fallback model selection owns its own thinking-level companion (`fallbackThinkingLevel`). Clearing the fallback picker must clear the companion value so null-as-delete reset parity matches the per-lane model pickers. */}
<CustomModelDropdown id="fallbackModel" label="Fallback Model" models={availableModels} value={form.fallbackProvider && form.fallbackModelId ? `${form.fallbackProvider}/${form.fallbackModelId}` : ""} onChange={(val) => {
if (!val) {
setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined }));
setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined, fallbackThinkingLevel: undefined }));
}
else {
const slashIdx = val.indexOf("/");
@@ -75,7 +76,10 @@ export function GlobalModelsSection({ scopeBanner, form, setForm, availableModel
fallbackModelId: val.slice(slashIdx + 1),
}));
}
}} placeholder={t("settings.globalModels.noFallback", "No fallback")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite}/>
}} placeholder={t("settings.globalModels.noFallback", "No fallback")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} showThinkingLevel={(() => {
const selectedModel = availableModels.find((m) => m.provider === form.fallbackProvider && m.id === form.fallbackModelId);
return selectedModel ? Boolean(selectedModel.reasoning) : true;
})()} thinkingLevel={form.fallbackThinkingLevel || ""} onThinkingLevelChange={(level) => setForm((f) => ({ ...f, fallbackThinkingLevel: (level as ThinkingLevel) || undefined }))} defaultThinkingLevel={form.defaultThinkingLevel}/>
<small>{t("settings.globalModels.usedAutomaticallyIfThePrimaryDefaultModelHits", "Used automatically if the primary default model hits a retryable provider error like rate limiting or overload. No default \u2014 unset.")}</small>
</div>
</>)}

View File

@@ -10,7 +10,7 @@ import type { ModelLane, SectionBaseProps, SectionSaveHandler, SettingsFormState
import { LoadingSpinner } from "../../LoadingSpinner";
type LaneStatus = "inherited" | "overridden";
type WorkflowModelPair = {
id: "planning" | "execution" | "validator" | "planning-fallback" | "validator-fallback" | "title-summarizer-fallback";
id: "planning" | "execution" | "validator" | "planning-fallback" | "validator-fallback";
providerId: string;
modelId: string;
thinkingId?: string;
@@ -20,10 +20,10 @@ type WorkflowModelPair = {
const DEFAULT_WORKFLOW_ID = "builtin:coding";
/*
FNXC:SettingsModels 2026-06-16-19:58:
Fallback model lanes must be configurable in all Settings surfaces: General uses the global Fallback Model, Workflow Values uses declared workflow settings, and Project Models exposes only fallback pairs declared by the active default workflow so saves never PATCH undeclared keys.
Fallback model lanes must be configurable in all Settings surfaces: General uses the global Fallback Model, Workflow Values uses declared workflow settings, and Project Models exposes workflow fallback pairs declared by the active default workflow plus project-scoped title-summarizer fallback keys so saves never PATCH undeclared keys.
FNXC:Settings-ThinkingLevel 2026-07-10-00:00:
Primary workflow model lanes may expose an inline thinking selector only when the active workflow declares the matching companion setting. Reset must clear both the model pair and the thinking companion; fallback/title-summarizer rows intentionally render no orphan thinking shell.
FNXC:Settings-ThinkingLevel 2026-07-10-12:08:
Workflow fallback lanes may expose an inline thinking selector only when the active workflow declares the matching companion setting, while the title-summarizer fallback uses project-scoped keys below. Reset must clear both the model pair and the thinking companion; undeclared rows intentionally render no orphan thinking shell.
*/
const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
{
@@ -54,6 +54,7 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
id: "planning-fallback",
providerId: "planningFallbackProvider",
modelId: "planningFallbackModelId",
thinkingId: "planningFallbackThinkingLevel",
label: "Planning Fallback Model",
help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.",
},
@@ -61,16 +62,10 @@ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [
id: "validator-fallback",
providerId: "validatorFallbackProvider",
modelId: "validatorFallbackModelId",
thinkingId: "validatorFallbackThinkingLevel",
label: "Reviewer Fallback Model",
help: "Fallback provider and model used when the primary Reviewer model cannot be used.",
},
{
id: "title-summarizer-fallback",
providerId: "titleSummarizerFallbackProvider",
modelId: "titleSummarizerFallbackModelId",
label: "Title Summarizer Fallback Model",
help: "Fallback provider and model used when the primary Title Summarizer model cannot be used.",
},
];
function declaredWorkflowModelPairs(settings?: WorkflowSettingDefinition[]): WorkflowModelPair[] {
const settingsById = new Map((settings ?? []).map((setting) => [setting.id, setting]));
@@ -296,6 +291,31 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
}
return lane.helperText;
};
const titleSummarizerFallbackValue = form.titleSummarizerFallbackProvider && form.titleSummarizerFallbackModelId
? `${form.titleSummarizerFallbackProvider}/${form.titleSummarizerFallbackModelId}`
: "";
const titleSummarizerFallbackThinkingValue = typeof form.titleSummarizerFallbackThinkingLevel === "string"
? form.titleSummarizerFallbackThinkingLevel
: "";
const titleSummarizerFallbackCustomized = Boolean(titleSummarizerFallbackValue || titleSummarizerFallbackThinkingValue);
const setTitleSummarizerFallbackValue = (value: string) => {
if (!value) {
setForm((f) => ({ ...f, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined } as SettingsFormState));
return;
}
const slashIdx = value.indexOf("/");
setForm((f) => ({
...f,
titleSummarizerFallbackProvider: value.slice(0, slashIdx),
titleSummarizerFallbackModelId: value.slice(slashIdx + 1),
} as SettingsFormState));
};
const setTitleSummarizerFallbackThinkingValue = (value: string) => {
setForm((f) => ({ ...f, titleSummarizerFallbackThinkingLevel: value || undefined } as SettingsFormState));
};
const resetTitleSummarizerFallbackValue = () => {
setForm((f) => ({ ...f, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined } as SettingsFormState));
};
return (<>
{scopeBanner}
@@ -341,6 +361,22 @@ export function ProjectModelsSection({ scopeBanner, form, setForm, models, proje
</small>
</div>);
})}
{/* FNXC:Settings-ThinkingLevel 2026-07-10-12:08: Title-summarizer fallback provider/model/thinking settings are project-scoped, not workflow-declared. Render this fallback beside the project summarization lane so saves use project null-as-delete semantics instead of the workflow-values API. */}
<div className="form-group" data-testid="project-model-lane-title-summarizer-fallback">
<div className="settings-model-lane-label-row">
<label htmlFor="titleSummarizerFallbackModel">{t("settings.projectModels.titleSummarizerFallbackModel", "Title Summarizer Fallback Model")}</label>
<span className={`settings-lane-badge ${titleSummarizerFallbackCustomized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`} title={titleSummarizerFallbackCustomized ? "Explicitly set for this project" : "Inherited from global settings"}>
{titleSummarizerFallbackCustomized ? "Override (Project)" : "Inherited (Global)"}
</span>
</div>
<div className="settings-model-lane-control-row">
<div className="settings-model-lane-control-main">
<CustomModelDropdown id="titleSummarizerFallbackModel" label="Title Summarizer Fallback Model" models={availableModels} value={titleSummarizerFallbackValue} onChange={setTitleSummarizerFallbackValue} placeholder={t("settings.projectModels.useGlobal", "Use global")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={titleSummarizerFallbackThinkingValue} onThinkingLevelChange={setTitleSummarizerFallbackThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/>
</div>
{titleSummarizerFallbackCustomized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromGlobal", "Reset to inherit from global")} onClick={resetTitleSummarizerFallbackValue} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
</div>
<small>{t("settings.projectModels.titleSummarizerFallbackHelp", "Fallback provider and model used when the primary Title Summarizer model cannot be used. Falls back to the global summarization lane and then the default model chain.")}</small>
</div>
</>)}
{/* --- Default workflow model lanes --- */}