/** * Project Models section (U9 / KTD-10). * * Project-scoped model configuration that survives the workflow hard-move: token * cap, the project DEFAULT model lane, the restored title-summarizer lane, * model presets (with the inline editor and size-based auto-selection), and the * title/commit summarization toggles. The per-phase execution/planning/validator * lanes still live on the workflow and render as a redirect stub. The * model-lane helpers, preset draft state/handlers, available-model list, * favorites, and the confirm dialog all live in the shell (they share state * with the save flow and the global model lanes) and are relayed through a * `models` prop bag — mirroring the Authentication/Remote section conventions. * Keys, lane labels, and conditional rendering are preserved verbatim from the * original inline JSX. */ import type { ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { ModelPreset, Settings } from "@fusion/core"; import { ApiRequestError, fetchWorkflowSettingValues, updateWorkflowSettingValues, type ModelInfo, type WorkflowSettingRejection, type WorkflowSettingValuesPayload, } from "../../../api"; import { CustomModelDropdown } from "../../CustomModelDropdown"; import { applyPresetToSelection } from "../../../utils/modelPresets"; import type { ToastType } from "../../../hooks/useToast"; import type { ModelLane, SectionBaseProps, SettingsFormState } from "./context"; type LaneStatus = "inherited" | "overridden"; type WorkflowModelPair = { id: "planning" | "execution" | "validator"; providerId: string; modelId: string; label: string; help: string; }; const DEFAULT_WORKFLOW_ID = "builtin:coding"; const WORKFLOW_MODEL_PAIRS: WorkflowModelPair[] = [ { id: "planning", providerId: "planningProvider", modelId: "planningModelId", label: "Plan/Triage Model", help: "Provider and model used when planning or triaging tasks. Leave unset to inherit from the workflow default.", }, { id: "execution", providerId: "executionProvider", modelId: "executionModelId", label: "Executor Model", help: "Provider and model used while executing workflow steps. Leave unset to inherit from the workflow default.", }, { id: "validator", providerId: "validatorProvider", modelId: "validatorModelId", label: "Reviewer Model", help: "Provider and model used for workflow review or validation lanes. Leave unset to inherit from the workflow default.", }, ]; 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}` : ""; } export interface ProjectModelsSectionModelProps { modelLanes: ModelLane[]; getLaneStatus: (lane: ModelLane) => LaneStatus; getLaneValue: (lane: ModelLane) => string; updateLaneValue: (lane: ModelLane, value: string) => void; resetLaneValue: (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 interface ProjectModelsSectionProps extends SectionBaseProps { scopeBanner: ReactNode; models: ProjectModelsSectionModelProps; projectId?: string; addToast: (message: string, type?: ToastType) => void; onOpenWorkflowSettings?: () => void; } export function ProjectModelsSection({ scopeBanner, form, setForm, models, projectId, onOpenWorkflowSettings, }: ProjectModelsSectionProps) { const { t } = useTranslation("app"); const { modelLanes, getLaneStatus, getLaneValue, updateLaneValue, resetLaneValue, 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 [workflowSaving, setWorkflowSaving] = useState(false); const [workflowRejections, setWorkflowRejections] = useState>({}); const workflowReqSeq = useRef(0); const workflowDirty = Object.keys(workflowPending).length > 0; useEffect(() => { setWorkflowId(configuredWorkflowId); }, [configuredWorkflowId]); useEffect(() => { if (!projectId) { setWorkflowPayload(null); setWorkflowPending({}); setWorkflowRejections({}); return; } const seq = ++workflowReqSeq.current; setWorkflowLoading(true); fetchWorkflowSettingValues(workflowId, projectId) .then((payload) => { if (workflowReqSeq.current !== seq) return; setWorkflowPayload(payload); setWorkflowPending({}); setWorkflowRejections({}); }) .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: [] }); }) .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) => { setWorkflowRejections((current) => { const next = { ...current }; delete next[pair.providerId]; delete next[pair.modelId]; return next; }); setWorkflowPending((current) => { if (!value) { return { ...current, [pair.providerId]: null, [pair.modelId]: null }; } const slashIdx = value.indexOf("/"); if (slashIdx <= 0) return current; return { ...current, [pair.providerId]: value.slice(0, slashIdx), [pair.modelId]: value.slice(slashIdx + 1), }; }); }, []); const resetWorkflowPairValue = useCallback((pair: WorkflowModelPair) => { setWorkflowPairValue(pair, ""); }, [setWorkflowPairValue]); const saveWorkflowLanes = useCallback(async () => { if (!projectId || !workflowDirty) return; setWorkflowSaving(true); try { const payload = await updateWorkflowSettingValues(workflowId, workflowPending, projectId); setWorkflowPayload(payload); setWorkflowPending({}); 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]))); return; } } throw err; } finally { setWorkflowSaving(false); } }, [projectId, workflowDirty, workflowId, workflowPending]); // The project DEFAULT lane and restored title-summarizer lane remain editable // here. Execution/planning/validator workflow-specific lanes still redirect to // workflow settings below. const projectModelLanes = modelLanes.filter((lane) => ["default", "summarization"].includes(lane.laneId)); const getProjectLaneLabel = (lane: ModelLane) => { if (lane.laneId === "default") { return "Project Default Model"; } if (lane.laneId === "summarization") { return "Project Summarization 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 === "summarization") { return "Model used for title auto-summarization, merge commit summaries, GitHub tracking issue titles, and PR title/body generation."; } return lane.helperText; }; return ( <> {scopeBanner} {/* --- Token Cap --- */}

Token Cap

{ const val = e.target.value; setForm((f) => ({ ...f, tokenCap: val ? parseInt(val, 10) : null } as SettingsFormState)); }} /> {form.tokenCap != null && ( )}
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.
{/* --- Project Model Lanes --- */}

Model Lanes

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 ? (
Loading available models…
) : availableModels.length === 0 ? (
No models available. Configure authentication first.
) : ( <> {projectModelLanes.map((lane) => { const status = getLaneStatus(lane); const value = getLaneValue(lane); const isOverridden = status === "overridden"; const laneLabel = getProjectLaneLabel(lane); return (
{isOverridden ? "Override (Project)" : "Inherited (Global)"}
updateLaneValue(lane, val)} placeholder={lane.laneId === "default" ? "Use global default" : "Use global"} favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} />
{isOverridden && ( )}
{getProjectLaneHelperText(lane)} Falls back to: {lane.fallbackOrder}.
); })} )} {/* --- Default workflow model lanes --- */}

Default workflow model lanes

{t( "settings.movedStub.modelLanes", "Per-phase model lanes (execution, planning, reviewer, and their fallbacks) now live on the workflow.", )} These project overrides apply to the active default workflow.

{!projectId ? (
Open a project to edit workflow model lanes.
) : workflowLoading ? (
Loading workflow model lanes…
) : availableModels.length === 0 ? (
No models available. Configure authentication before selecting workflow model lanes.
) : ( <> {WORKFLOW_MODEL_PAIRS.map((pair) => { const value = modelPairValue(effectiveWorkflowValues, pair); const customized = 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 error = workflowRejections[pair.providerId]?.message ?? workflowRejections[pair.modelId]?.message; return (
{customized ? "Override (Project)" : "Inherited (Workflow)"}
setWorkflowPairValue(pair, next)} placeholder="Use workflow default" defaultOptionLabel="Use workflow default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} />
{customized && ( )}
{pair.help} {error ? {error} : null}
); })}
{onOpenWorkflowSettings ? ( ) : null}
)} {/* --- Model Presets --- */}

Model Presets

{presets.length === 0 ? (
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 ? ( No models available. Configure authentication first. ) : ( <>
{ if (!val) { setPresetDraft((current) => current ? { ...current, executorProvider: undefined, executorModelId: undefined } : current); return; } const slashIdx = val.indexOf("/"); setPresetDraft((current) => current ? { ...current, executorProvider: val.slice(0, slashIdx), executorModelId: val.slice(slashIdx + 1), } : current); }} placeholder="Use default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} />
{ if (!val) { setPresetDraft((current) => current ? { ...current, validatorProvider: undefined, validatorModelId: undefined } : current); return; } const slashIdx = val.indexOf("/"); setPresetDraft((current) => current ? { ...current, validatorProvider: val.slice(0, slashIdx), validatorModelId: val.slice(slashIdx + 1), } : current); }} placeholder="Use default" favoriteProviders={favoriteProviders} onToggleFavorite={onToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={onToggleModelFavorite} />
)}
) : null}
{form.autoSelectModelPreset ? (
{(["S", "M", "L"] as const).map((sizeKey) => (
))}
) : null} {/* --- AI Title and Git Commit Message Summarization --- */}

AI Title and Git Commit Message Summarization

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.

When enabled, tasks created without a title but with descriptions over 200 characters will automatically get an AI-generated title (max 60 characters). The same model is also used to generate fallback merge commit message bodies when the branch's commit log is empty (e.g. squash merges with no unique commits), and GitHub tracking issue titles when a tracked task has no title yet.
When enabled, merge commit messages include an AI-generated subject plus body summary (narrative + bullets + diff-stat) instead of just listing step commit subjects. Uses the title summarization model.
{(form.autoSummarizeTitles || form.useAiMergeCommitSummary || form.githubTrackingEnabledByDefault || false) && (

{t( "settings.movedStub.summarizerModelInline", "The summarization model lane above controls title auto-summarization, merge commit summaries, GitHub tracking titles, and PR metadata generation.", )}

)} ); } export default ProjectModelsSection;