FN-003: consolidate model overrides and Docker tooling
Consolidate model override settings while adding supported operator tools to the Docker image. - Group project and workflow model overrides in the Settings interface with updated search metadata, translations, tests, and documentation. - Install gh, tailscale, and cloudflared from signed vendor repositories and cover the Docker configuration. - Add release changesets for the settings and container capabilities. Files changed: .changeset/docker-operator-tooling.md | 7 + .changeset/fn-003-model-override-organization.md | 7 + Dockerfile | 36 ++++- docs/dashboard-guide.md | 6 +- docs/settings-reference.md | 9 +- .../app/__tests__/settings-sections.test.tsx | 10 ++ .../__tests__/SettingsModal.models-auth.test.tsx | 2 +- .../settings/sections/GlobalModelsSection.tsx | 16 +- .../settings/sections/ProjectModelsSection.tsx | 171 +++++++++++---------- packages/dashboard/src/shared/settings-sections.ts | 6 +- packages/i18n/locales/en/app.json | 5 + .../dockerfile-workspace-manifests.test.mjs | 22 +++ 12 files changed, 200 insertions(+), 97 deletions(-) Fusion-Task-Id: FN-003 Fusion-Task-Lineage: 4df75b7f-f8dd-472c-b343-d06800d6c615 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-003-model-override-organization.md
Normal file
7
.changeset/fn-003-model-override-organization.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Organize project and workflow model overrides in one Settings group.
|
||||
category: feature
|
||||
dev: Moves the Project Models JSX into Model Overrides subgroups and adds matching i18n/search metadata.
|
||||
@@ -506,7 +506,7 @@ Behavior:
|
||||
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 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 exposes project-baseline Plan/Triage, Executor, Reviewer, and declared fallback model lanes from **Project Models**. Those dropdown values auto-save on the active default workflow and are inherited by every workflow. Resolution is task-specific selection → project baseline → global lane → selected-workflow value; Project Models therefore wins over values configured in a workflow's **Settings → Values**. Project Models also includes project-scoped **Merger** and **Title Summarization** lanes (not workflow-moved). 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.
|
||||
- The main Settings modal groups project-baseline Plan/Triage, Executor, Reviewer, and declared fallback model lanes in **Models · Project → Model Overrides → Workflow lanes**, directly after **Project lanes**. Those dropdown values auto-save on the active default workflow and are inherited by every workflow. Resolution is task-specific selection → project baseline → global lane → selected-workflow value; Project Models therefore wins over values configured in a workflow's **Settings → Values**. Project-scoped **Merger** remains in Project lanes, while **Title Summarization** and its fallback intentionally remain under **AI Title and Git Commit Message Summarization** with their enable toggles. 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.
|
||||
@@ -515,7 +515,7 @@ FNXC:WorkflowEditor 2026-06-29-21:10: Optional-group template entry/exit ownersh
|
||||
|
||||
## Custom Providers
|
||||
|
||||
Custom Providers live in **Settings → Authentication → Custom Providers**, inside the **Advanced: Custom Providers** disclosure. Use this section to add user-defined model providers that speak an OpenAI-compatible API, the OpenAI Responses API, an Anthropic-compatible API, or Google Generative AI. After a provider is saved with models, those models become selectable in model dropdowns, including **Settings → Project Models** lanes and workflow model lanes.
|
||||
Custom Providers live in **Settings → Authentication → Custom Providers**, inside the **Advanced: Custom Providers** disclosure. Use this section to add user-defined model providers that speak an OpenAI-compatible API, the OpenAI Responses API, an Anthropic-compatible API, or Google Generative AI. After a provider is saved with models, those models become selectable in model dropdowns, including **Settings → Models · Project → Model Overrides** project and workflow lanes.
|
||||
|
||||
Settings → Global Models also includes **Model pricing overrides** for Command Center estimates. The section shows a compact pricing snapshot/override-count summary; use **View pricing table** to add or edit rows with lowercased `provider:model` keys (or bare `:model` fallback keys), USD-per-1M token prices for input/output/cache read/cache write, and optional source text. **Fetch LiteLLM pricing** remains available from the collapsed summary and performs an explicit one-click refresh from LiteLLM's published model pricing JSON, replaces the override table only after a successful parse, and records the fetched timestamp/source; failed fetches keep the existing overrides.
|
||||
|
||||
@@ -551,7 +551,7 @@ Use **Detect Models** to auto-fill **Available models** while adding or editing
|
||||
- selecting **Detect Models** to query the provider's `/models` endpoint and prepend detected model IDs to the field.
|
||||
9. Select **Save Provider**.
|
||||
|
||||
Expected outcome: the provider appears in the Custom Providers list with its API type and base URL. Each saved model is then available in model dropdowns as a `{provider}/{modelId}` option, including **Settings → Project Models** default-workflow lanes and workflow model lanes in the workflow editor.
|
||||
Expected outcome: the provider appears in the Custom Providers list with its API type and base URL. Each saved model is then available in model dropdowns as a `{provider}/{modelId}` option, including **Settings → Models · Project → Model Overrides → Workflow lanes** and workflow model lanes in the workflow editor.
|
||||
|
||||
### Edit a custom provider
|
||||
|
||||
|
||||
@@ -298,9 +298,10 @@ Some knobs that used to live in this Settings reference as project settings are
|
||||
govern that execution belong to the workflow.
|
||||
|
||||
**Where to set them.** The common model lanes for every workflow in a project are
|
||||
available directly in **Settings → Project Models → Project workflow model lanes**:
|
||||
Plan/Triage, Executor, Reviewer, and their fallback lanes declared
|
||||
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 auto-saved by the Settings modal after an edit, which writes
|
||||
available directly in **Settings → Models · Project → Model Overrides → Workflow lanes**,
|
||||
immediately after **Project lanes**: Plan/Triage, Executor, Reviewer, and their fallback lanes declared
|
||||
by the default workflow. The project Summarization lane and its fallback remain under
|
||||
**AI Title and Git Commit Message Summarization** with the enable toggles that govern them. 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 auto-saved by the Settings modal after an edit, which writes
|
||||
workflow setting values on the active project's default workflow. Those stored
|
||||
values are the project model baseline inherited by every selected workflow. The
|
||||
baseline wins over global and per-workflow values; task-specific selections win
|
||||
@@ -1135,7 +1136,7 @@ Short-lived token bounds are enforced server-side:
|
||||
|
||||
## Model Selection Hierarchy
|
||||
|
||||
Fusion resolves task models as task-specific selection -> project workflow-model baseline -> global lane -> selected-workflow value -> project/global default model. The project baseline is stored as setting values on the project's active default workflow and can be edited from Settings -> Project Models -> Project workflow model lanes (auto-saved by the Settings modal). Lower-priority per-workflow values remain editable 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.
|
||||
Fusion resolves task models as task-specific selection -> project workflow-model baseline -> global lane -> selected-workflow value -> project/global default model. The project baseline is stored as setting values on the project's active default workflow and can be edited from Settings -> Models · Project -> Model Overrides -> Workflow lanes (auto-saved by the Settings modal, directly after Project lanes). The project Summarization lane and its fallback intentionally remain under AI Title and Git Commit Message Summarization with their enable toggles. Lower-priority per-workflow values remain editable 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.
|
||||
|
||||
Direct-chat defaults are project-scoped and independent of task workflow lanes. Configure them in **Settings -> Project Models -> Chat**. `chatDefaultKind: "agent"` resolves only when `chatDefaultAgentId` is set; `chatDefaultKind: "model"` resolves only when both `chatDefaultModelProvider` and `chatDefaultModelId` are set, with optional `chatDefaultThinkingLevel`. If `chatNewSessionMode` is `"always-default"` and that target resolves, every New Chat entry point creates the session directly. If the target is incomplete, or the mode is unset/`"prompt"`, Fusion opens the New Chat dialog instead and preselects the resolved default when one exists. Chat Rooms additionally support a per-room `thinkingLevel` default that applies to every room responder; clearing it inherits the resolved project/global default.
|
||||
|
||||
|
||||
@@ -833,6 +833,16 @@ describe("ProjectModelsSection", () => {
|
||||
"validator",
|
||||
"validator-fallback",
|
||||
]);
|
||||
|
||||
const projectLanes = screen.getByTestId("project-models-project-lanes");
|
||||
const workflowLanes = screen.getByTestId("project-models-workflow-lanes");
|
||||
const chat = screen.getByTestId("project-models-chat-kind");
|
||||
const summarizationSection = screen.getByTestId("project-models-ai-summarization");
|
||||
expect(projectLanes.nextElementSibling).toBe(workflowLanes);
|
||||
expect(projectLanes).toContainElement(screen.getByTestId("project-models-summarization-pointer"));
|
||||
expect(workflowLanes).toContainElement(screen.getByTestId("workflow-model-lane-planning"));
|
||||
expect(workflowLanes.compareDocumentPosition(chat) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(workflowLanes.compareDocumentPosition(summarizationSection) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("wires workflow fallback lane thinking render, persist, and reset", async () => {
|
||||
|
||||
@@ -551,7 +551,7 @@ describe("SettingsModal", () => {
|
||||
const onOpenWorkflowSettings = vi.fn();
|
||||
await setupWorkflowModelLaneTest({ renderProps: { onOpenWorkflowSettings } });
|
||||
|
||||
const workflowHeading = screen.getByRole("heading", { name: "Project workflow model lanes" });
|
||||
const workflowHeading = screen.getByRole("heading", { name: "Workflow lanes" });
|
||||
const advancedButton = screen.getByRole("button", { name: "Advanced workflow policy" });
|
||||
const actionRow = advancedButton.closest(".settings-model-lane-actions");
|
||||
const presetsHeading = screen.getByRole("heading", { name: "Model Presets" });
|
||||
|
||||
@@ -51,8 +51,14 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
|
||||
: "";
|
||||
return (<>
|
||||
|
||||
{/* --- Default Model --- */}
|
||||
<h4 className="settings-section-heading">{t("settings.globalModels.defaultModel", "Default Model")}</h4>
|
||||
{/*
|
||||
FNXC:SettingsModels 2026-08-18-06:41:
|
||||
Global and project model-override surfaces share one heading vocabulary so operators recognize the same configuration structure in both scopes. Keep the default, fallback, thinking, and role lanes contiguous; sync and pricing controls remain separate.
|
||||
*/}
|
||||
<div className="settings-field-label-row">
|
||||
<h4 className="settings-section-heading">{t("settings.globalModels.modelOverrides", "Model Overrides")}</h4>
|
||||
<SettingsHelpTip settingKey="global-model-overrides">{t("settings.globalModels.globalBaselineModelsForEachAIRoleProject", " Global baseline models for each AI role. Project settings can override these per-project. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
{modelsLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.models.loadingModels", "Loading available models…")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">
|
||||
{t("settings.models.noModels", "No models available. Configure authentication first.")}
|
||||
</div>) : (<>
|
||||
@@ -138,8 +144,6 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
|
||||
})()}
|
||||
|
||||
{availableModels.length > 0 && (<>
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.globalModels.modelLanes", "Model Lanes")}</h4>
|
||||
<p className="settings-description">{t("settings.globalModels.globalBaselineModelsForEachAIRoleProject", " Global baseline models for each AI role. Project settings can override these per-project. ")}</p>
|
||||
{globalModelLanes.map((lane) => {
|
||||
const provider = form[lane.globalProviderKey as keyof Settings] as string | undefined;
|
||||
const model = form[lane.globalModelKey as keyof Settings] as string | undefined;
|
||||
@@ -177,8 +181,6 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
|
||||
})}
|
||||
</>)}
|
||||
|
||||
<ModelPricingSection form={form} setForm={setForm} addToast={addToast} projectId={projectId}/>
|
||||
|
||||
{/* --- Startup Model Sync --- */}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.globalModels.startupModelSync", "Startup Model Sync")}</h4>
|
||||
{/*
|
||||
@@ -417,6 +419,8 @@ export function GlobalModelsSection({ form, setForm, availableModels, modelsLoad
|
||||
}))}
|
||||
/>
|
||||
</details>
|
||||
|
||||
<ModelPricingSection form={form} setForm={setForm} addToast={addToast} projectId={projectId}/>
|
||||
</>);
|
||||
}
|
||||
export default GlobalModelsSection;
|
||||
|
||||
@@ -337,7 +337,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
|
||||
// workflow settings below.
|
||||
// FNXC:Settings-MergerModel 2026-07-13-07:52: Merger is project-scoped (like summarization), not workflow-moved.
|
||||
// FNXC:GitHubImportTranslate 2026-07-15-09:30: The import-translate lane is project-scoped (like merger/summarization), so its project override must be editable here — otherwise the lane's projectProviderKey/projectModelKey would be unreachable and only the global lane could ever be set.
|
||||
// FNXC:SettingsModels 2026-07-15-12:00: Summarization is still project-scoped but is rendered with the AI summarization section below rather than the general Model Lanes list.
|
||||
// FNXC:SettingsModels 2026-08-18-06:41: Summarization remains project-scoped but stays with its AI summarization enable toggles instead of the Model Overrides group.
|
||||
const projectModelLanes = modelLanes.filter((lane) => ["default", "merger", "import-translate"].includes(lane.laneId));
|
||||
const credentialInstanceKeyForLane = (lane: ModelLane): keyof Settings | undefined => PROJECT_LANE_CREDENTIAL_INSTANCE_KEYS[lane.laneId as keyof typeof PROJECT_LANE_CREDENTIAL_INSTANCE_KEYS];
|
||||
const credentialInstanceValueForLane = (lane: ModelLane): string => {
|
||||
@@ -383,7 +383,7 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
|
||||
/*
|
||||
* FNXC:SettingsModels 2026-07-16-00:00:
|
||||
* The merger fallback is project-scoped and must sit directly after Project Merger
|
||||
* in Model Lanes so operators configure the primary and retry models together.
|
||||
* in Project lanes so operators configure the primary and retry models together.
|
||||
*/
|
||||
const mergerFallbackValue = form.mergerFallbackProvider && form.mergerFallbackModelId
|
||||
? `${form.mergerFallbackProvider}/${form.mergerFallbackModelId}`
|
||||
@@ -527,27 +527,17 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
|
||||
};
|
||||
return (<>
|
||||
|
||||
{/* --- Token Cap --- */}
|
||||
<h4 className="settings-section-heading">{t("settings.projectModels.tokenCap", "Token Cap")}</h4>
|
||||
{/*
|
||||
FNXC:SettingsModels 2026-07-15-17:35:
|
||||
The reset affordance stays conditional on an actual cap being set: "no cap" is the unset state, so offering to reset a lane that is already unset would advertise an action with nothing to undo.
|
||||
`v ? Math.trunc(v) : null` reproduces the previous `val ? parseInt(val, 10) : null` contract exactly \u2014 a token cap is a whole number of tokens, and 0 means "no cap" (null), not a cap of zero.
|
||||
FNXC:SettingsModels 2026-08-18-06:41:
|
||||
Operators configure which model does what in one pass, so project lanes and workflow lanes must remain one contiguous Model Overrides group. Chat, presets, and token-cap controls must not split the groups; summarization stays with its enable toggles below, with this pointer instead of moving the gated controls.
|
||||
*/}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "tokenCap",
|
||||
label: t("settings.projectModels.tokenCap", "Token Cap"),
|
||||
help: t("settings.projectModels.automaticallyCompactContextWhenApproachingThisTokenCount", "Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count. No default \u2014 unset (no cap)."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.projectModels.noCap", "No cap"),
|
||||
}}
|
||||
value={form.tokenCap ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, tokenCap: v ? Math.trunc(v) : null } as SettingsFormState))}
|
||||
clearable={form.tokenCap != null}
|
||||
/>
|
||||
|
||||
{/* --- Project Model Lanes --- */}
|
||||
<div className="settings-field-label-row">
|
||||
<h4 className="settings-section-heading">{t("settings.projectModels.modelOverrides", "Model Overrides")}</h4>
|
||||
<SettingsHelpTip settingKey="project-model-lanes">{t("settings.projectModels.overrideGlobalModelSettingsAtTheProjectLevel", " Override global model settings at the project level. Each lane controls a specific AI usage context. Unset lanes inherit from the corresponding global lane. The Project Default Model is the fallback for this project when a more specific lane is unset. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
<div data-testid="project-models-project-lanes">
|
||||
<h5 className="settings-section-heading">{t("settings.projectModels.projectLanesSubheading", "Project lanes")}</h5>
|
||||
{/* --- Project lanes --- */}
|
||||
{/*
|
||||
FNXC:ExecutorEscalation 2026-08-03-05:43:
|
||||
The alternate executor target is a project model choice, while Scheduling owns only the retry policy and optional node routing. Use the shared provider-aware dropdown so complete persisted pairs hydrate together, selecting a model updates both existing keys, and the default choice clears both without accepting arbitrary text.
|
||||
@@ -586,17 +576,69 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
|
||||
menuWidth="readable"
|
||||
/>
|
||||
</SettingsFieldRow>
|
||||
{/* FNXC:SettingsHelp 2026-07-16-12:45: Section description moved behind the shared "?" affordance beside the heading — operator requirement: no inline description paragraphs in Settings. */}
|
||||
<div className="settings-field-label-row">
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.projectModels.modelLanes", "Model Lanes")}</h4>
|
||||
<SettingsHelpTip settingKey="project-model-lanes">{t("settings.projectModels.overrideGlobalModelSettingsAtTheProjectLevel", " Override global model settings at the project level. Each lane controls a specific AI usage context. Unset lanes inherit from the corresponding global lane. The Project Default Model is the fallback for this project when a more specific lane is unset. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
{modelsLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.projectModels.loadingAvailableModels", "Loading available models\u2026")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.noModelsAvailableConfigureAuthenticationFirst", " No models available. Configure authentication first. ")}</div>) : (<>
|
||||
{projectModelLanes.filter((lane) => lane.laneId === "default" || lane.laneId === "merger").map(renderProjectLane)}
|
||||
{renderMergerFallbackLane()}
|
||||
{projectModelLanes.filter((lane) => lane.laneId === "import-translate").map(renderProjectLane)}
|
||||
</>)}
|
||||
|
||||
<p className="settings-field-help" data-testid="project-models-summarization-pointer">{t("settings.projectModels.summarizationPointer", "Summarization models are configured under AI Title and Git Commit Message Summarization below.")}</p>
|
||||
</div>
|
||||
<div data-testid="project-models-workflow-lanes">
|
||||
{/* --- Workflow lanes --- */}
|
||||
{/* FNXC:ProjectWorkflowModelBaseline 2026-07-22-00:00: These controls persist on the active default workflow as the cross-workflow project baseline. Runtime precedence is task-specific selection > project baseline > global lane > selected-workflow lane; keep this explanation in the shared heading help tip rather than restoring per-control prose. */}
|
||||
<div className="settings-field-label-row">
|
||||
<h5 className="settings-section-heading">{t("settings.projectModels.workflowLanesSubheading", "Workflow lanes")}</h5>
|
||||
<SettingsHelpTip settingKey="default-workflow-model-lanes">
|
||||
{t("settings.movedStub.modelLanes", "Per-phase model lanes (execution, planning, reviewer, and their fallbacks) are stored on the active default workflow.")}{t("settings.projectModels.theseProjectOverridesApplyToTheActiveDefault", " They apply as the project baseline for every workflow and take precedence over global and per-workflow values; task-specific selections still win. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
{!projectId ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.openAProjectToEditWorkflowModelLanes", "Open a project to edit workflow model lanes.")}</div>) : workflowLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.projectModels.loadingWorkflowModelLanes", "Loading workflow model lanes\u2026")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow", " No models available. Configure authentication before selecting workflow model lanes. ")}</div>) : (<>
|
||||
{workflowModelPairs.map((pair) => {
|
||||
const value = modelPairValue(effectiveWorkflowValues, pair);
|
||||
const rawThinkingValue = pair.thinkingId ? effectiveWorkflowValues[pair.thinkingId] : undefined;
|
||||
const thinkingValue: string = typeof rawThinkingValue === "string" ? rawThinkingValue : "";
|
||||
const rawCredentialInstanceId = pair.credentialInstanceId ? effectiveWorkflowValues[pair.credentialInstanceId] : undefined;
|
||||
const credentialInstanceId = typeof rawCredentialInstanceId === "string" ? rawCredentialInstanceId : "";
|
||||
const modelCustomized = Object.prototype.hasOwnProperty.call(workflowPending, pair.providerId)
|
||||
? workflowPending[pair.providerId] !== null
|
||||
: Boolean(workflowPayload?.stored && (Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.providerId)
|
||||
|| Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.modelId)));
|
||||
const thinkingCustomized = pair.thinkingId
|
||||
? (Object.prototype.hasOwnProperty.call(workflowPending, pair.thinkingId)
|
||||
? workflowPending[pair.thinkingId] !== null
|
||||
: Boolean(workflowPayload?.stored && Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.thinkingId)))
|
||||
: false;
|
||||
const credentialInstanceCustomized = pair.credentialInstanceId
|
||||
? (Object.prototype.hasOwnProperty.call(workflowPending, pair.credentialInstanceId)
|
||||
? workflowPending[pair.credentialInstanceId] !== null
|
||||
: Boolean(workflowPayload?.stored && Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.credentialInstanceId)))
|
||||
: false;
|
||||
const customized = modelCustomized || thinkingCustomized || credentialInstanceCustomized;
|
||||
const error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message ?? (pair.thinkingId ? workflowRejections[pair.thinkingId]?.message : undefined) ?? (pair.credentialInstanceId ? workflowRejections[pair.credentialInstanceId]?.message : undefined);
|
||||
return (<div className="form-group" key={pair.id} data-testid={`workflow-model-lane-${pair.id}`}>
|
||||
<div className="settings-model-lane-label-row">
|
||||
<label htmlFor={`workflow-${pair.id}-model`}>{pair.label}</label>
|
||||
<span className={`settings-lane-badge ${customized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`} title={customized ? "Explicitly set as the project baseline" : "Inherited from global, workflow, or default settings"}>
|
||||
{customized ? "Project baseline" : "Inherited (Workflow)"}
|
||||
</span>
|
||||
{/* FNXC:SettingsHelp 2026-07-15-23:10: Same affordance as the project lanes above — a workflow lane is the same shape, so its help hangs off the label row too rather than sitting under the dropdown as prose. */}
|
||||
{pair.help ? <SettingsHelpTip settingKey={`workflow-${pair.id}-model`}>{pair.help}</SettingsHelpTip> : null}
|
||||
</div>
|
||||
<div className="settings-model-lane-control-row">
|
||||
<div className="settings-model-lane-control-main">
|
||||
<CustomModelDropdown id={`workflow-${pair.id}-model`} label={pair.label} models={availableModels} value={value} onChange={(next) => setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" credentialInstanceId={credentialInstanceId} onCredentialInstanceChange={pair.credentialInstanceId ? (instanceId) => setWorkflowCredentialInstanceValue(pair, instanceId) : undefined} showThinkingLevel={Boolean(pair.thinkingId)} thinkingLevel={thinkingValue} onThinkingLevelChange={pair.thinkingId ? (level) => setWorkflowThinkingValue(pair, level) : undefined} defaultThinkingLevel={typeof form.defaultThinkingLevel === "string" ? form.defaultThinkingLevel : "off"}/>
|
||||
</div>
|
||||
{customized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromWorkflow", "Reset to inherit from workflow")} onClick={() => resetWorkflowPairValue(pair)} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
|
||||
</div>
|
||||
{error ? <small className="settings-error" data-testid={`workflow-model-lane-error-${pair.id}`}>{error}</small> : null}
|
||||
</div>);
|
||||
})}
|
||||
{onOpenWorkflowSettings ? (<div className="settings-model-lane-actions" aria-label={t("settings.projectModels.defaultWorkflowModelLaneActions", "Default workflow model lane actions")}>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={onOpenWorkflowSettings}>{t("settings.projectModels.advancedWorkflowPolicy", " Advanced workflow policy ")}</button>
|
||||
</div>) : null}
|
||||
</>)}
|
||||
|
||||
</div>
|
||||
{/* FNXC:ChatModels 2026-07-12-20:45: Project Models owns the Direct-chat default because New Chat needs a project-scoped model-or-agent target plus prompt-vs-direct creation mode without changing workflow or in-chat switcher settings. */}
|
||||
{/* FNXC:SettingsHelp 2026-07-16-12:45: Section description moved behind the shared "?" affordance beside the heading — operator requirement: no inline description paragraphs in Settings. */}
|
||||
<div className="settings-field-label-row">
|
||||
@@ -666,60 +708,6 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
|
||||
{agents.length === 0 && !agentsLoading ? (<small>{t("settings.projectModels.chatDefaultAgentEmpty", "No agents are available for this project yet.")}</small>) : null}
|
||||
</div>)}
|
||||
|
||||
{/* --- Project workflow model lanes --- */}
|
||||
{/* FNXC:SettingsHelp 2026-07-16-12:45: Section description moved behind the shared "?" affordance beside the heading — operator requirement: no inline description paragraphs in Settings. */}
|
||||
{/* FNXC:ProjectWorkflowModelBaseline 2026-07-22-00:00: These controls persist on the active default workflow as the cross-workflow project baseline. Runtime precedence is task-specific selection > project baseline > global lane > selected-workflow lane; keep this explanation in the shared heading help tip rather than restoring per-control prose. */}
|
||||
<div className="settings-field-label-row">
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.projectModels.defaultWorkflowModelLanes", "Project workflow model lanes")}</h4>
|
||||
<SettingsHelpTip settingKey="default-workflow-model-lanes">
|
||||
{t("settings.movedStub.modelLanes", "Per-phase model lanes (execution, planning, reviewer, and their fallbacks) are stored on the active default workflow.")}{t("settings.projectModels.theseProjectOverridesApplyToTheActiveDefault", " They apply as the project baseline for every workflow and take precedence over global and per-workflow values; task-specific selections still win. ")}</SettingsHelpTip>
|
||||
</div>
|
||||
{!projectId ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.openAProjectToEditWorkflowModelLanes", "Open a project to edit workflow model lanes.")}</div>) : workflowLoading ? (<div className="settings-empty-state"><LoadingSpinner label={t("settings.projectModels.loadingWorkflowModelLanes", "Loading workflow model lanes\u2026")} /></div>) : availableModels.length === 0 ? (<div className="settings-empty-state settings-muted">{t("settings.projectModels.noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow", " No models available. Configure authentication before selecting workflow model lanes. ")}</div>) : (<>
|
||||
{workflowModelPairs.map((pair) => {
|
||||
const value = modelPairValue(effectiveWorkflowValues, pair);
|
||||
const rawThinkingValue = pair.thinkingId ? effectiveWorkflowValues[pair.thinkingId] : undefined;
|
||||
const thinkingValue: string = typeof rawThinkingValue === "string" ? rawThinkingValue : "";
|
||||
const rawCredentialInstanceId = pair.credentialInstanceId ? effectiveWorkflowValues[pair.credentialInstanceId] : undefined;
|
||||
const credentialInstanceId = typeof rawCredentialInstanceId === "string" ? rawCredentialInstanceId : "";
|
||||
const modelCustomized = Object.prototype.hasOwnProperty.call(workflowPending, pair.providerId)
|
||||
? workflowPending[pair.providerId] !== null
|
||||
: Boolean(workflowPayload?.stored && (Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.providerId)
|
||||
|| Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.modelId)));
|
||||
const thinkingCustomized = pair.thinkingId
|
||||
? (Object.prototype.hasOwnProperty.call(workflowPending, pair.thinkingId)
|
||||
? workflowPending[pair.thinkingId] !== null
|
||||
: Boolean(workflowPayload?.stored && Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.thinkingId)))
|
||||
: false;
|
||||
const credentialInstanceCustomized = pair.credentialInstanceId
|
||||
? (Object.prototype.hasOwnProperty.call(workflowPending, pair.credentialInstanceId)
|
||||
? workflowPending[pair.credentialInstanceId] !== null
|
||||
: Boolean(workflowPayload?.stored && Object.prototype.hasOwnProperty.call(workflowPayload.stored, pair.credentialInstanceId)))
|
||||
: false;
|
||||
const customized = modelCustomized || thinkingCustomized || credentialInstanceCustomized;
|
||||
const error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message ?? (pair.thinkingId ? workflowRejections[pair.thinkingId]?.message : undefined) ?? (pair.credentialInstanceId ? workflowRejections[pair.credentialInstanceId]?.message : undefined);
|
||||
return (<div className="form-group" key={pair.id} data-testid={`workflow-model-lane-${pair.id}`}>
|
||||
<div className="settings-model-lane-label-row">
|
||||
<label htmlFor={`workflow-${pair.id}-model`}>{pair.label}</label>
|
||||
<span className={`settings-lane-badge ${customized ? "settings-lane-badge--override" : "settings-lane-badge--inherited"}`} title={customized ? "Explicitly set as the project baseline" : "Inherited from global, workflow, or default settings"}>
|
||||
{customized ? "Project baseline" : "Inherited (Workflow)"}
|
||||
</span>
|
||||
{/* FNXC:SettingsHelp 2026-07-15-23:10: Same affordance as the project lanes above — a workflow lane is the same shape, so its help hangs off the label row too rather than sitting under the dropdown as prose. */}
|
||||
{pair.help ? <SettingsHelpTip settingKey={`workflow-${pair.id}-model`}>{pair.help}</SettingsHelpTip> : null}
|
||||
</div>
|
||||
<div className="settings-model-lane-control-row">
|
||||
<div className="settings-model-lane-control-main">
|
||||
<CustomModelDropdown id={`workflow-${pair.id}-model`} label={pair.label} models={availableModels} value={value} onChange={(next) => setWorkflowPairValue(pair, next)} placeholder={t("settings.projectModels.useWorkflowDefault", "Use workflow default")} defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" credentialInstanceId={credentialInstanceId} onCredentialInstanceChange={pair.credentialInstanceId ? (instanceId) => setWorkflowCredentialInstanceValue(pair, instanceId) : undefined} showThinkingLevel={Boolean(pair.thinkingId)} thinkingLevel={thinkingValue} onThinkingLevelChange={pair.thinkingId ? (level) => setWorkflowThinkingValue(pair, level) : undefined} defaultThinkingLevel={typeof form.defaultThinkingLevel === "string" ? form.defaultThinkingLevel : "off"}/>
|
||||
</div>
|
||||
{customized && (<button type="button" className="btn btn-ghost btn-sm" title={t("settings.projectModels.resetToInheritFromWorkflow", "Reset to inherit from workflow")} onClick={() => resetWorkflowPairValue(pair)} style={{ whiteSpace: "nowrap" }}>{t("settings.projectModels.reset", " Reset ")}</button>)}
|
||||
</div>
|
||||
{error ? <small className="settings-error" data-testid={`workflow-model-lane-error-${pair.id}`}>{error}</small> : null}
|
||||
</div>);
|
||||
})}
|
||||
{onOpenWorkflowSettings ? (<div className="settings-model-lane-actions" aria-label={t("settings.projectModels.defaultWorkflowModelLaneActions", "Default workflow model lane actions")}>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={onOpenWorkflowSettings}>{t("settings.projectModels.advancedWorkflowPolicy", " Advanced workflow policy ")}</button>
|
||||
</div>) : null}
|
||||
</>)}
|
||||
|
||||
{/* --- Model Presets --- */}
|
||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.projectModels.modelPresets", "Model Presets")}</h4>
|
||||
<div className="form-group settings-model-presets">
|
||||
@@ -959,6 +947,27 @@ export function ProjectModelsSection({ form, setForm, models, projectId, onOpenW
|
||||
onChange={(v) => setForm((f) => ({ ...f, prDescriptionPromptInstructions: v ?? "" }))}
|
||||
/>
|
||||
</section>
|
||||
</>);
|
||||
</>)
|
||||
{/* --- Token Cap --- */}
|
||||
<h4 className="settings-section-heading">{t("settings.projectModels.tokenCap", "Token Cap")}</h4>
|
||||
{/*
|
||||
FNXC:SettingsModels 2026-07-15-17:35:
|
||||
The reset affordance stays conditional on an actual cap being set: "no cap" is the unset state, so offering to reset a lane that is already unset would advertise an action with nothing to undo.
|
||||
`v ? Math.trunc(v) : null` reproduces the previous `val ? parseInt(val, 10) : null` contract exactly \u2014 a token cap is a whole number of tokens, and 0 means "no cap" (null), not a cap of zero.
|
||||
*/}
|
||||
<SettingsNumberRow
|
||||
descriptor={{
|
||||
key: "tokenCap",
|
||||
label: t("settings.projectModels.tokenCap", "Token Cap"),
|
||||
help: t("settings.projectModels.automaticallyCompactContextWhenApproachingThisTokenCount", "Automatically compact context when approaching this token count. Leave empty for no cap (compact only on overflow errors). Set a number to proactively compact when reaching this token count. No default \u2014 unset (no cap)."),
|
||||
scope: "project",
|
||||
placeholder: t("settings.projectModels.noCap", "No cap"),
|
||||
}}
|
||||
value={form.tokenCap ?? null}
|
||||
onChange={(v) => setForm((f) => ({ ...f, tokenCap: v ? Math.trunc(v) : null } as SettingsFormState))}
|
||||
clearable={form.tokenCap != null}
|
||||
/>
|
||||
|
||||
;
|
||||
}
|
||||
export default ProjectModelsSection;
|
||||
|
||||
@@ -83,7 +83,7 @@ const SETTINGS_SECTION_DEFINITIONS: readonly SettingsSectionDefinition[] = [
|
||||
FN-8130 changes the Settings landing surface from Authentication to Appearance. Authentication remains first within its own AI & Models group, but the always-visible global Preferences section is the default instead.
|
||||
*/
|
||||
{ id: "authentication", label: "Authentication", labelKey: "settings.nav.authentication", scope: undefined, searchableText: ["login", "OAuth", "API key", "custom providers", "Anthropic", "OpenAI", "provider credentials"] },
|
||||
{ id: "global-models", label: "Models · Global", labelKey: "settings.nav.globalModels", scope: "global", searchableText: ["global models", "model presets", "favorite providers", "model pricing overrides", "LiteLLM pricing", "token pricing", "translate", "translation model", "import translation model", "import auto-translation model"] },
|
||||
{ id: "global-models", label: "Models · Global", labelKey: "settings.nav.globalModels", scope: "global", searchableText: ["global models", "model overrides", "model lanes", "planning model", "reviewer model", "model presets", "favorite providers", "model pricing overrides", "LiteLLM pricing", "token pricing", "translate", "translation model", "import translation model", "import auto-translation model"] },
|
||||
/**
|
||||
* FNXC:SettingsNavigation 2026-07-13-00:00:
|
||||
* Project Models owns the FN-7907 Direct-chat default settings. Its shared Settings search index must advertise chat-default terms and i18n labels so desktop nav, the mobile section picker, and filtered search all surface this section when operators search for Chat defaults.
|
||||
@@ -102,7 +102,11 @@ const SETTINGS_SECTION_DEFINITIONS: readonly SettingsSectionDefinition[] = [
|
||||
searchableText: [
|
||||
"default provider",
|
||||
"default model",
|
||||
"model overrides",
|
||||
"model lanes",
|
||||
"workflow model lanes",
|
||||
"planning model",
|
||||
"reviewer model",
|
||||
"executor escalation model",
|
||||
"escalation model",
|
||||
"alternate model",
|
||||
|
||||
@@ -6167,6 +6167,7 @@
|
||||
"leaveEmptyToOmitThisHeaderDefaultFusion": "Leave empty to omit this header. No default — unset (Fusion falls back to the title \"Fusion\" when unset).",
|
||||
"leaveEmptyToOmitThisHeaderDefaultHttps": "Leave empty to omit this header. No default — unset (Fusion falls back to https://runfusion.ai when unset).",
|
||||
"modelLanes": "Model Lanes",
|
||||
"modelOverrides": "Model Overrides",
|
||||
"noFallback": "No fallback",
|
||||
"openaiAnthropic": "openai, anthropic",
|
||||
"openRouterAdvanced": "OpenRouter advanced",
|
||||
@@ -6598,6 +6599,10 @@
|
||||
"loadingAvailableModels": "Loading available models…",
|
||||
"loadingWorkflowModelLanes": "Loading workflow model lanes…",
|
||||
"modelLanes": "Model Lanes",
|
||||
"modelOverrides": "Model Overrides",
|
||||
"projectLanesSubheading": "Project lanes",
|
||||
"workflowLanesSubheading": "Workflow lanes",
|
||||
"summarizationPointer": "Summarization models are configured under AI Title and Git Commit Message Summarization below.",
|
||||
"modelPresets": "Model Presets",
|
||||
"name": "Name",
|
||||
"noCap": "No cap",
|
||||
|
||||
Reference in New Issue
Block a user