import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { ModelPreset, Settings } from "@fusion/core"; import { ApiRequestError, fetchWorkflow, fetchWorkflowSettingValues, updateWorkflowSettingValues, type ModelInfo, type WorkflowSettingDefinition, type WorkflowSettingRejection, type WorkflowSettingValuesPayload, } from "../../../api"; import { CustomModelDropdown } from "../../CustomModelDropdown"; import { SettingsToggleRow } from "../SettingsToggleRow"; import { SettingsSelectRow } from "../SettingsSelectRow"; import { SettingsNumberRow } from "../SettingsNumberRow"; import { SettingsTextareaRow } from "../SettingsTextareaRow"; import { SettingsFieldRow } from "../SettingsFieldRow"; import { SettingsHelpTip } from "../SettingsHelpTip"; import { applyPresetToSelection } from "../../../utils/modelPresets"; import type { ToastType } from "../../../hooks/useToast"; import type { ModelLane, SectionBaseProps, SectionSaveHandler, SettingsFormState } from "./context"; import { LoadingSpinner } from "../../LoadingSpinner"; import { useAgentsMapCache } from "../../../hooks/useAgentsMapCache"; type LaneStatus = "inherited" | "overridden"; type WorkflowModelPair = { id: "planning" | "execution" | "validator" | "execution-fallback" | "planning-fallback" | "validator-fallback"; providerId: string; modelId: string; thinkingId?: string; credentialInstanceId?: string; label: string; help: string; }; const DEFAULT_WORKFLOW_ID = "builtin:coding"; /* FNXC:SettingsCredentialInstance 2026-08-01-10:19: Project lanes that own persisted model overrides must carry their credential instance through the same form state. The empty Default choice deletes only that instance key, preserving provider/model selection while returning resolution to the provider default. */ const PROJECT_LANE_CREDENTIAL_INSTANCE_KEYS = { default: "defaultCredentialInstanceIdOverride", merger: "mergerCredentialInstanceId", "import-translate": "importTranslateCredentialInstanceId", } as const satisfies Partial>; /* 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 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-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. FNXC:SettingsModels 2026-07-16-00:00: FN-8169 requires each workflow fallback lane to render directly under its primary lane so operators configure a model and its retry model together. Keep this catalog interleaved while preserving every lane key and declaration filter. */ const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [ { id: "planning", providerId: "planningProvider", modelId: "planningModelId", thinkingId: "planningThinkingLevel", credentialInstanceId: "planningCredentialInstanceId", label: "Plan/Triage Model", help: "Provider and model used when planning or triaging tasks. Leave unset to fall through to the global lane, then the selected workflow.", }, { id: "planning-fallback", providerId: "planningFallbackProvider", modelId: "planningFallbackModelId", thinkingId: "planningFallbackThinkingLevel", credentialInstanceId: "planningFallbackCredentialInstanceId", label: "Planning Fallback Model", help: "Fallback provider and model used when the primary Plan/Triage model cannot be used.", }, { id: "execution", providerId: "executionProvider", modelId: "executionModelId", thinkingId: "executionThinkingLevel", credentialInstanceId: "executionCredentialInstanceId", label: "Executor Model", help: "Provider and model used while executing workflow steps. Leave unset to fall through to the global lane, then the selected workflow.", }, { id: "execution-fallback", providerId: "executionFallbackProvider", modelId: "executionFallbackModelId", thinkingId: "executionFallbackThinkingLevel", credentialInstanceId: "executionFallbackCredentialInstanceId", label: "Executor Fallback Model", help: "Fallback provider and model used when the primary Executor model cannot be used.", }, { id: "validator", providerId: "validatorProvider", modelId: "validatorModelId", thinkingId: "validatorThinkingLevel", credentialInstanceId: "validatorCredentialInstanceId", label: "Reviewer Model", help: "Provider and model used for workflow review or validation lanes. Leave unset to fall through to the global lane, then the selected workflow.", }, { id: "validator-fallback", providerId: "validatorFallbackProvider", modelId: "validatorFallbackModelId", thinkingId: "validatorFallbackThinkingLevel", credentialInstanceId: "validatorFallbackCredentialInstanceId", label: "Reviewer Fallback Model", help: "Fallback provider and model used when the primary Reviewer model cannot be used.", }, ]; function declaredWorkflowModelPairs(settings?: WorkflowSettingDefinition[]): WorkflowModelPair[] { const settingsById = new Map((settings ?? []).map((setting) => [setting.id, setting])); return WORKFLOW_MODEL_PAIRS.filter((pair) => { const provider = settingsById.get(pair.providerId); const model = settingsById.get(pair.modelId); const thinking = pair.thinkingId ? settingsById.get(pair.thinkingId) : undefined; return provider?.type === "string" && model?.type === "string" && (!pair.thinkingId || thinking?.type === "enum" || thinking?.type === "string"); }).map((pair) => ({ ...pair, credentialInstanceId: settingsById.get(pair.credentialInstanceId ?? "")?.type === "string" ? pair.credentialInstanceId : undefined, })); } function modelPairValue(values: Record, pair: WorkflowModelPair): string { const provider = values[pair.providerId]; const modelId = values[pair.modelId]; return typeof provider === "string" && typeof modelId === "string" && provider && modelId ? `${provider}/${modelId}` : ""; } function workflowPendingValueEquals(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true; if (Array.isArray(a) && Array.isArray(b)) { return a.length === b.length && a.every((value, index) => Object.is(value, b[index])); } return false; } export interface ProjectModelsSectionModelProps { modelLanes: ModelLane[]; getLaneStatus: (lane: ModelLane) => LaneStatus; getLaneValue: (lane: ModelLane) => string; updateLaneValue: (lane: ModelLane, value: string) => void; resetLaneValue: (lane: ModelLane) => void; getLaneThinkingValue: (lane: ModelLane) => string; updateLaneThinkingValue: (lane: ModelLane, level: string) => void; resetLaneThinkingValue: (lane: ModelLane) => void; availableModels: ModelInfo[]; modelsLoading: boolean; favoriteProviders: string[]; favoriteModels: string[]; onToggleFavorite: (provider: string) => void; onToggleModelFavorite: (modelId: string) => void; editingPresetId: string | null; setEditingPresetId: (id: string | null) => void; presetDraft: ModelPreset | null; setPresetDraft: (updater: ModelPreset | null | ((prev: ModelPreset | null) => ModelPreset | null)) => void; onSavePresetDraft: () => void; confirmDelete: (options: { title: string; message: string; danger?: boolean; }) => Promise; } export class WorkflowLaneFlushRejection extends Error { readonly rejections: WorkflowSettingRejection[]; constructor(rejections: WorkflowSettingRejection[]) { super("Workflow model lane settings were rejected"); this.name = "WorkflowLaneFlushRejection"; this.rejections = rejections; } } export interface ProjectModelsSectionProps extends SectionBaseProps { models: ProjectModelsSectionModelProps; projectId?: string; addToast: (message: string, type?: ToastType) => void; onOpenWorkflowSettings?: () => void; registerWorkflowLaneSaver?: (saver: SectionSaveHandler | null) => void; onWorkflowLanesChange?: () => void; } export function ProjectModelsSection({ form, setForm, models, projectId, onOpenWorkflowSettings, registerWorkflowLaneSaver, onWorkflowLanesChange, }: ProjectModelsSectionProps) { const { t } = useTranslation("app"); const { agents, loading: agentsLoading } = useAgentsMapCache(projectId); const { modelLanes, getLaneStatus, getLaneValue, updateLaneValue, resetLaneValue, getLaneThinkingValue, updateLaneThinkingValue, resetLaneThinkingValue, availableModels, modelsLoading, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, editingPresetId, setEditingPresetId, presetDraft, setPresetDraft, onSavePresetDraft, confirmDelete, } = models; 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 configuredWorkflowId = typeof form.defaultWorkflowId === "string" && form.defaultWorkflowId.trim() ? form.defaultWorkflowId : DEFAULT_WORKFLOW_ID; const [workflowId, setWorkflowId] = useState(configuredWorkflowId); const [workflowPayload, setWorkflowPayload] = useState(null); const [workflowLoading, setWorkflowLoading] = useState(false); const [workflowPending, setWorkflowPending] = useState>({}); const [workflowRejections, setWorkflowRejections] = useState>({}); const [workflowModelPairs, setWorkflowModelPairs] = useState([]); const workflowReqSeq = useRef(0); const workflowDirty = Object.keys(workflowPending).length > 0; useEffect(() => { setWorkflowId(configuredWorkflowId); }, [configuredWorkflowId]); useEffect(() => { if (!projectId) { setWorkflowPayload(null); setWorkflowPending({}); setWorkflowRejections({}); setWorkflowModelPairs([]); return; } const seq = ++workflowReqSeq.current; setWorkflowLoading(true); Promise.all([ fetchWorkflow(workflowId, projectId), fetchWorkflowSettingValues(workflowId, projectId), ]) .then(([definition, payload]) => { if (workflowReqSeq.current !== seq) return; setWorkflowPayload(payload); setWorkflowPending({}); setWorkflowRejections({}); const declarations = "settings" in definition.ir ? definition.ir.settings : undefined; setWorkflowModelPairs(declaredWorkflowModelPairs(declarations)); }) .catch((err) => { if (workflowReqSeq.current !== seq) return; if (err instanceof ApiRequestError && err.status === 404 && workflowId !== DEFAULT_WORKFLOW_ID) { setWorkflowId(DEFAULT_WORKFLOW_ID); return; } setWorkflowPayload({ stored: {}, effective: {}, orphaned: [] }); setWorkflowModelPairs([]); }) .finally(() => { if (workflowReqSeq.current === seq) setWorkflowLoading(false); }); }, [projectId, workflowId]); const effectiveWorkflowValues = useMemo(() => ({ ...(workflowPayload?.effective ?? {}), ...Object.fromEntries(Object.entries(workflowPending).filter(([, value]) => value !== null)), }), [workflowPayload, workflowPending]); const setWorkflowPairValue = useCallback((pair: WorkflowModelPair, value: string) => { /* FNXC:SettingsAutoSave 2026-07-20-01:00: FN-8395 removes the primary Save button. Notify the Settings shell for every workflow-lane mutation because these pending overrides are local section state rather than keys in its shared form. */ onWorkflowLanesChange?.(); setWorkflowRejections((current) => { const next = { ...current }; delete next[pair.providerId]; delete next[pair.modelId]; if (pair.credentialInstanceId) delete next[pair.credentialInstanceId]; return next; }); setWorkflowPending((current) => { if (!value) { return { ...current, [pair.providerId]: null, [pair.modelId]: null, ...(pair.credentialInstanceId ? { [pair.credentialInstanceId]: null } : {}) }; } const slashIdx = value.indexOf("/"); if (slashIdx <= 0) return current; return { ...current, [pair.providerId]: value.slice(0, slashIdx), [pair.modelId]: value.slice(slashIdx + 1), // FNXC:ModelDropdown 2026-08-01-10:45: A user-selected provider/model pair must clear its prior credential-instance companion. ...(pair.credentialInstanceId ? { [pair.credentialInstanceId]: null } : {}), }; }); }, [onWorkflowLanesChange]); const setWorkflowCredentialInstanceValue = useCallback((pair: WorkflowModelPair, value: string) => { if (!pair.credentialInstanceId) return; onWorkflowLanesChange?.(); setWorkflowRejections((current) => { if (!current[pair.credentialInstanceId as string]) return current; const next = { ...current }; delete next[pair.credentialInstanceId as string]; return next; }); setWorkflowPending((current) => ({ ...current, [pair.credentialInstanceId as string]: value || null })); }, [onWorkflowLanesChange]); const setWorkflowThinkingValue = useCallback((pair: WorkflowModelPair, value: string) => { if (!pair.thinkingId) return; onWorkflowLanesChange?.(); setWorkflowRejections((current) => { if (!pair.thinkingId || !current[pair.thinkingId]) return current; const next = { ...current }; delete next[pair.thinkingId]; return next; }); setWorkflowPending((current) => ({ ...current, [pair.thinkingId as string]: value || null })); }, [onWorkflowLanesChange]); const resetWorkflowPairValue = useCallback((pair: WorkflowModelPair) => { setWorkflowPairValue(pair, ""); setWorkflowThinkingValue(pair, ""); setWorkflowCredentialInstanceValue(pair, ""); }, [setWorkflowCredentialInstanceValue, setWorkflowPairValue, setWorkflowThinkingValue]); const saveWorkflowLanes = useCallback(async () => { if (!projectId || !workflowDirty) return; const pendingSnapshot = { ...workflowPending }; const savedKeys = Object.keys(pendingSnapshot); try { const payload = await updateWorkflowSettingValues(workflowId, pendingSnapshot, projectId); setWorkflowPayload(payload); setWorkflowPending((current) => { const next = { ...current }; /* * FNXC:ProjectModelsWorkflowLanes 2026-07-02-13:30: * The registered Settings saver can resolve after workflow lane dropdowns changed again. Clear only snapshot-matching keys so Project Models follows the same no-lost-pending-edits invariant as Workflow Settings Values. */ for (const key of savedKeys) { if (workflowPendingValueEquals(current[key], pendingSnapshot[key])) delete next[key]; } return next; }); setWorkflowRejections({}); } catch (err) { if (err instanceof ApiRequestError && err.status === 400 && err.details) { const rejections = (err.details.rejections as WorkflowSettingRejection[] | undefined) ?? []; if (rejections.length > 0) { setWorkflowRejections(Object.fromEntries(rejections.map((rejection) => [rejection.settingId, rejection]))); throw new WorkflowLaneFlushRejection(rejections); } } throw err; } }, [projectId, workflowDirty, workflowId, workflowPending]); useEffect(() => { registerWorkflowLaneSaver?.(saveWorkflowLanes); return () => registerWorkflowLaneSaver?.(null); }, [registerWorkflowLaneSaver, saveWorkflowLanes]); // The project DEFAULT, title-summarizer, and merger lanes remain editable // here. Execution/planning/validator workflow-specific lanes still redirect to // 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. 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 => { const key = credentialInstanceKeyForLane(lane); return key && typeof form[key] === "string" ? form[key] as string : ""; }; const setCredentialInstanceValueForLane = (lane: ModelLane, instanceId: string) => { const key = credentialInstanceKeyForLane(lane); if (!key) return; setForm((current) => ({ ...current, [key]: instanceId || undefined } as SettingsFormState)); }; const summarizationLane = modelLanes.find((lane) => lane.laneId === "summarization"); const getProjectLaneLabel = (lane: ModelLane) => { if (lane.laneId === "default") { return "Project Default Model"; } if (lane.laneId === "merger") { return "Project Merger Model"; } if (lane.laneId === "summarization") { return "Project Summarization Model"; } if (lane.laneId === "import-translate") { return "Project Import Auto-Translation Model"; } return lane.label; }; const getProjectLaneHelperText = (lane: ModelLane) => { if (lane.laneId === "default") { return "Project-wide default AI model used when no more specific task or project lane override is set."; } if (lane.laneId === "merger") { return "Model used for merge conflict resolution, clean-room merge, stash-conflict recovery, and related merger agent sessions."; } if (lane.laneId === "summarization") { return "Model used for title auto-summarization, merge commit summaries, GitHub tracking issue titles, and PR title/body generation."; } if (lane.laneId === "import-translate") { return "Model used to translate foreign-language GitHub/GitLab issue titles and bodies in the Import Tasks panel. One short readonly call per issue — a cheap, fast model is usually the right pick."; } return lane.helperText; }; /* * 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. */ const mergerFallbackValue = form.mergerFallbackProvider && form.mergerFallbackModelId ? `${form.mergerFallbackProvider}/${form.mergerFallbackModelId}` : ""; const mergerFallbackThinkingValue = typeof form.mergerFallbackThinkingLevel === "string" ? form.mergerFallbackThinkingLevel : ""; const mergerFallbackCustomized = Boolean(mergerFallbackValue || mergerFallbackThinkingValue || form.mergerFallbackCredentialInstanceId); const setMergerFallbackValue = (value: string) => { if (!value) { setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined, mergerFallbackCredentialInstanceId: undefined } as SettingsFormState)); return; } const slashIdx = value.indexOf("/"); setForm((f) => ({ ...f, mergerFallbackProvider: value.slice(0, slashIdx), mergerFallbackModelId: value.slice(slashIdx + 1), // FNXC:ModelDropdown 2026-08-01-10:45: Explicit model selection clears the prior provider's credential-instance override. mergerFallbackCredentialInstanceId: undefined, } as SettingsFormState)); }; const setMergerFallbackThinkingValue = (value: string) => { setForm((f) => ({ ...f, mergerFallbackThinkingLevel: value || undefined } as SettingsFormState)); }; const resetMergerFallbackValue = () => { setForm((f) => ({ ...f, mergerFallbackProvider: undefined, mergerFallbackModelId: undefined, mergerFallbackThinkingLevel: undefined, mergerFallbackCredentialInstanceId: undefined } as SettingsFormState)); }; const renderMergerFallbackLane = () => (
{mergerFallbackCustomized ? "Override (Project)" : "Inherited (Global)"} {t("settings.projectModels.mergerFallbackHelp", "Fallback provider and model used when a merger session retries. Leave unset to use the shared global fallback model pair.")}
setForm((f) => ({ ...f, mergerFallbackCredentialInstanceId: instanceId || undefined } as SettingsFormState))} defaultThinkingLevel={form.defaultThinkingLevel}/>
{mergerFallbackCustomized && ()}
); const titleSummarizerFallbackValue = form.titleSummarizerFallbackProvider && form.titleSummarizerFallbackModelId ? `${form.titleSummarizerFallbackProvider}/${form.titleSummarizerFallbackModelId}` : ""; const titleSummarizerFallbackThinkingValue = typeof form.titleSummarizerFallbackThinkingLevel === "string" ? form.titleSummarizerFallbackThinkingLevel : ""; const titleSummarizerFallbackCustomized = Boolean(titleSummarizerFallbackValue || titleSummarizerFallbackThinkingValue || form.titleSummarizerFallbackCredentialInstanceId); const setTitleSummarizerFallbackValue = (value: string) => { if (!value) { setForm((f) => ({ ...f, titleSummarizerFallbackProvider: undefined, titleSummarizerFallbackModelId: undefined, titleSummarizerFallbackThinkingLevel: undefined, titleSummarizerFallbackCredentialInstanceId: undefined } as SettingsFormState)); return; } const slashIdx = value.indexOf("/"); setForm((f) => ({ ...f, titleSummarizerFallbackProvider: value.slice(0, slashIdx), titleSummarizerFallbackModelId: value.slice(slashIdx + 1), // FNXC:ModelDropdown 2026-08-01-10:45: A newly chosen fallback model cannot inherit a credential instance from its predecessor. titleSummarizerFallbackCredentialInstanceId: undefined, } 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, titleSummarizerFallbackCredentialInstanceId: undefined } as SettingsFormState)); }; /* * FNXC:SettingsModels 2026-07-15-12:00: * Project Summarization and its title-summarizer fallback must be colocated * with AI title and commit summarization settings, while keeping their shared * lane UI, override behavior, and responsive layout identical to other project lanes. */ const renderProjectLane = (lane: ModelLane) => { const status = getLaneStatus(lane); const value = getLaneValue(lane); const thinkingValue = getLaneThinkingValue(lane); const isOverridden = status === "overridden" || Boolean(thinkingValue) || Boolean(credentialInstanceValueForLane(lane)); const laneLabel = getProjectLaneLabel(lane); return (
{/* FNXC:SettingsHelp 2026-07-15-23:10: Lane help rides the same "?" as every other row. It reads as an exception — a lane is a label + inherited/override badge + dropdown + conditional Reset, and its copy ends in the resolved fallback CHAIN — but that argues for WHERE the tip hangs (the label row, beside the badge), not for keeping a paragraph. Left inline, Project Models was the one section still showing prose under every control while its neighbours showed an icon; the global lanes next door already use the tip. The badge stays in view precisely because it IS live state ("Override (Project)" vs "Inherited (Global)") — the fallback chain behind it explains that badge, and is what an operator opens deliberately. */}
{isOverridden ? "Override (Project)" : "Inherited (Global)"} {getProjectLaneHelperText(lane)}{t("settings.projectModels.fallsBackTo", " Falls back to: ")}{lane.fallbackOrder}.
{ updateLaneValue(lane, val); setCredentialInstanceValueForLane(lane, ""); }} credentialInstanceId={credentialInstanceValueForLane(lane)} onCredentialInstanceChange={(instanceId) => setCredentialInstanceValueForLane(lane, instanceId)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={Boolean(lane.projectThinkingKey)} thinkingLevel={thinkingValue} onThinkingLevelChange={(level) => updateLaneThinkingValue(lane, level)} defaultThinkingLevel={form.defaultThinkingLevel}/>
{isOverridden && ()}
); }; const chatDefaultKind = form.chatDefaultKind ?? "model"; const chatDefaultModelValue = form.chatDefaultModelProvider && form.chatDefaultModelId ? `${form.chatDefaultModelProvider}/${form.chatDefaultModelId}` : ""; const chatDefaultThinkingValue = typeof form.chatDefaultThinkingLevel === "string" ? form.chatDefaultThinkingLevel : ""; const chatDefaultCustomized = Boolean(form.chatNewSessionMode || form.chatDefaultKind || form.chatDefaultAgentId || chatDefaultModelValue || chatDefaultThinkingValue || form.chatDefaultCredentialInstanceId); const setChatDefaultModelValue = (value: string) => { if (!value) { setForm((f) => ({ ...f, chatDefaultKind: "model", chatDefaultAgentId: undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined, chatDefaultCredentialInstanceId: undefined } as SettingsFormState)); return; } const slashIdx = value.indexOf("/"); if (slashIdx <= 0) return; setForm((f) => ({ ...f, chatDefaultKind: "model", chatDefaultAgentId: undefined, chatDefaultModelProvider: value.slice(0, slashIdx), chatDefaultModelId: value.slice(slashIdx + 1), // FNXC:ModelDropdown 2026-08-01-10:45: A fresh chat model selection must not reuse a previous provider's credential instance. chatDefaultCredentialInstanceId: undefined, } as SettingsFormState)); }; const setChatDefaultThinkingValue = (value: string) => { setForm((f) => ({ ...f, chatDefaultThinkingLevel: value || undefined } as SettingsFormState)); }; const resetChatDefaultValue = () => { setForm((f) => ({ ...f, chatNewSessionMode: undefined, chatDefaultKind: undefined, chatDefaultAgentId: undefined, chatDefaultModelProvider: undefined, chatDefaultModelId: undefined, chatDefaultThinkingLevel: undefined, chatDefaultCredentialInstanceId: undefined } as SettingsFormState)); }; return (<> {/* --- Token Cap --- */}

{t("settings.projectModels.tokenCap", "Token Cap")}

{/* 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. */} setForm((f) => ({ ...f, tokenCap: v ? Math.trunc(v) : null } as SettingsFormState))} clearable={form.tokenCap != null} /> {/* --- Project Model 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. */} { if (!value) { setForm((current) => ({ ...current, executorEscalationProvider: undefined, executorEscalationModelId: undefined } as SettingsFormState)); return; } const slashIdx = value.indexOf("/"); if (slashIdx <= 0) return; setForm((current) => ({ ...current, executorEscalationProvider: value.slice(0, slashIdx), executorEscalationModelId: value.slice(slashIdx + 1), } as SettingsFormState)); }} placeholder={t("settings.projectModels.selectExecutorEscalationModel", "Select an escalation model")} defaultOptionLabel={t("settings.projectModels.noExecutorEscalationModel", "No escalation model")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" /> {/* 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. */}

{t("settings.projectModels.modelLanes", "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. ")}
{modelsLoading ? (
) : availableModels.length === 0 ? (
{t("settings.projectModels.noModelsAvailableConfigureAuthenticationFirst", " No models available. Configure authentication first. ")}
) : (<> {projectModelLanes.filter((lane) => lane.laneId === "default" || lane.laneId === "merger").map(renderProjectLane)} {renderMergerFallbackLane()} {projectModelLanes.filter((lane) => lane.laneId === "import-translate").map(renderProjectLane)} )} {/* 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. */}

{t("settings.projectModels.chatHeading", "Chat")}

{t("settings.projectModels.chatDescription", "Choose the default target for new Direct chats and whether New Chat should prompt or immediately use that default.")}
{/* FNXC:SettingsModels 2026-07-15-17:35: Only the mode select migrates to a primitive. The target picker below it (Model/Agent segmented toggle + model dropdown or agent select + shared Reset) is one compound control over five form keys, not a row per key, so it stays bespoke. "prompt" is the unset default rather than a stored value: anything that is not `always-default` writes `undefined` so the key is deleted rather than persisted at its default. */} setForm((f) => ({ ...f, chatNewSessionMode: v === "always-default" ? "always-default" : undefined } as SettingsFormState))} />
{chatDefaultKind === "model" ? (
{/* FNXC:SettingsHelp 2026-07-15-21:40: Model mode is a plain label + control + help row, so its help hangs off the same "?" as the New Chat behavior select directly above it instead of printing a paragraph beside it. FNXC:SettingsHelp 2026-07-16-12:45: The agent-mode branch's descriptive help now hangs off the same "?" too — operator requirement: no inline description paragraphs in Settings. Only its dynamic empty-state line ("No agents are available for this project yet.") stays inline, because it is live status explaining an empty picker and must stay in view. */}
{t("settings.projectModels.chatDefaultModelHelp", "Model-mode New Chat uses the built-in Fusion chat agent with this provider/model pair. Leave empty to fall back to prompting.")}
setForm((current) => ({ ...current, chatDefaultCredentialInstanceId: instanceId || undefined } as SettingsFormState))} placeholder={t("settings.projectModels.selectChatDefaultModel", "Select a chat default model")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" showThinkingLevel={true} thinkingLevel={chatDefaultThinkingValue} onThinkingLevelChange={setChatDefaultThinkingValue} defaultThinkingLevel={form.defaultThinkingLevel}/>
{chatDefaultCustomized && ()}
) : (
{t("settings.projectModels.chatDefaultAgentHelp", "Agent-mode New Chat starts a Direct chat with the selected durable agent.")}
{chatDefaultCustomized && ()}
{agents.length === 0 && !agentsLoading ? ({t("settings.projectModels.chatDefaultAgentEmpty", "No agents are available for this project yet.")}) : null}
)} {/* --- 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. */}

{t("settings.projectModels.defaultWorkflowModelLanes", "Project 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. ")}
{!projectId ? (
{t("settings.projectModels.openAProjectToEditWorkflowModelLanes", "Open a project to edit workflow model lanes.")}
) : workflowLoading ? (
) : availableModels.length === 0 ? (
{t("settings.projectModels.noModelsAvailableConfigureAuthenticationBeforeSelectingWorkflow", " No models available. Configure authentication before selecting workflow model lanes. ")}
) : (<> {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 (
{customized ? "Project baseline" : "Inherited (Workflow)"} {/* 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 ? {pair.help} : null}
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"}/>
{customized && ()}
{error ? {error} : null}
); })} {onOpenWorkflowSettings ? (
) : null} )} {/* --- Model Presets --- */}

{t("settings.projectModels.modelPresets", "Model Presets")}

{presets.length === 0 ? (
{t("settings.projectModels.noPresetsConfiguredYet", "No presets configured yet.")}
) : (
{presets.map((preset) => { const selection = applyPresetToSelection(preset); const summary = `${selection.executorValue || "default"} / ${selection.validatorValue || "default"}`; return (
{preset.name} {summary}
); })}
)} {!presetDraft ? (
) : null}
{presetDraft ? (
{ const name = e.target.value; setPresetDraft((current) => current ? { ...current, name } : current); }}/>
{availableModels.length === 0 ? ({t("settings.projectModels.noModelsAvailableConfigureAuthenticationFirst2", "No models available. Configure authentication first.")}) : (<>
{ if (!val) { setPresetDraft((current) => current ? { ...current, executorProvider: undefined, executorModelId: undefined, executorCredentialInstanceId: undefined } : current); return; } const slashIdx = val.indexOf("/"); setPresetDraft((current) => current ? { ...current, executorProvider: val.slice(0, slashIdx), executorModelId: val.slice(slashIdx + 1), executorCredentialInstanceId: undefined, } : current); }} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" credentialInstanceId={presetDraft.executorCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setPresetDraft((current) => current ? { ...current, ...(instanceId ? { executorCredentialInstanceId: instanceId } : { executorCredentialInstanceId: undefined }) } : current)}/>
{ if (!val) { setPresetDraft((current) => current ? { ...current, validatorProvider: undefined, validatorModelId: undefined, validatorCredentialInstanceId: undefined } : current); return; } const slashIdx = val.indexOf("/"); setPresetDraft((current) => current ? { ...current, validatorProvider: val.slice(0, slashIdx), validatorModelId: val.slice(slashIdx + 1), validatorCredentialInstanceId: undefined, } : current); }} placeholder={t("settings.projectModels.useDefault", "Use default")} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} menuWidth="readable" credentialInstanceId={presetDraft.validatorCredentialInstanceId} onCredentialInstanceChange={(instanceId) => setPresetDraft((current) => current ? { ...current, ...(instanceId ? { validatorCredentialInstanceId: instanceId } : { validatorCredentialInstanceId: undefined }) } : current)}/>
)}
) : null} setForm((current) => ({ ...current, autoSelectModelPreset: v === true }))} /> {form.autoSelectModelPreset ? (
{(["S", "M", "L"] as const).map((sizeKey) => (
))}
) : null} {/* FNXC:TaskDefinitionInputLanguage 2026-07-16-05:00: Keep task-definition language outside title and merge summarization controls: it changes triage authoring only, uses no summarizer lane, and is opt-in for supported detectable languages. The shared toggle row carries the responsive Project Models layout. */} setForm((f) => ({ ...f, taskDefinitionInInputLanguage: v === true }))} /> {/* --- AI Title and Git Commit Message Summarization --- */}
{/* 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. The formerly separate conditional paragraph (shown once any summarization feature is on) rides in the same tip as a conditional fragment; SettingsHelpTip's ReactNode children carry conditional copy, so the condition is preserved verbatim. */}

{t("settings.projectModels.aITitleAndGitCommitMessageSummarization", " AI Title and Git Commit Message Summarization ")}

{t("settings.projectModels.configuresTheModelUsedForTwoShortSummary", " Configures the model used for two short-summary jobs: auto-generating task titles from long descriptions, and generating merge commit summaries from step commits and diff stats. ")} {(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) ? t("settings.movedStub.summarizerModelInline", "These summarization model controls govern title auto-summarization, merge commit summaries, GitHub tracking titles, and PR metadata generation.") : ""}
{modelsLoading ? (
) : availableModels.length === 0 ? (
{t("settings.projectModels.noModelsAvailableConfigureAuthenticationFirst", " No models available. Configure authentication first. ")}
) : (<> {summarizationLane ? renderProjectLane(summarizationLane) : null} {/* FNXC:Settings-ThinkingLevel 2026-07-10-12:08: Title-summarizer fallback provider/model/thinking settings are project-scoped, not workflow-declared. Render it with the summarization controls so saves use project null-as-delete semantics instead of the workflow-values API. */}
{titleSummarizerFallbackCustomized ? "Override (Project)" : "Inherited (Global)"} {/* FNXC:SettingsHelp 2026-07-15-23:10: Same lane shape as the rows above, so its help hangs off the label row too — this was the last row in Settings still rendering help as a paragraph. */} {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.")}
setForm((f) => ({ ...f, titleSummarizerFallbackCredentialInstanceId: instanceId || undefined } as SettingsFormState))} defaultThinkingLevel={form.defaultThinkingLevel}/>
{titleSummarizerFallbackCustomized && ()}
)} {/* FNXC:SettingsSearch 2026-07-15-17:35: This is the row operators searched "summarize" for and could not find (FN-7907, patched again 2026-07-14). It is now indexed by descriptor key from ProjectModelsSection.search.ts, so the word in its own label is what search matches — no hand-maintained keyword list to fall behind again. */} setForm((f) => ({ ...f, autoSummarizeTitles: v === true }))} /> setForm((f) => ({ ...f, useAiMergeCommitSummary: v === true }))} /> setForm((f) => ({ ...f, prTitlePromptInstructions: v ?? "" }))} /> setForm((f) => ({ ...f, prDescriptionPromptInstructions: v ?? "" }))} />
); } export default ProjectModelsSection;