import "./ModelSelectorTab.css"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { updateTask } from "../api"; import type { Settings, Task, TaskDetail } from "@fusion/core"; import { getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel, } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { useFavorites } from "../hooks/useFavorites"; import { useModelsCache } from "../hooks/useModelsCache"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { ProviderIcon } from "./ProviderIcon"; interface ModelSelectorTabProps { task: Task | TaskDetail; addToast: (message: string, type?: ToastType) => void; onTaskUpdated?: (task: Task) => void; settings?: Settings; } interface ModelSelection { provider?: string; modelId?: string; } function normalizeModelField(value: string | null | undefined): string | undefined { return value ?? undefined; } function getExecutorSelection(task: Task | TaskDetail): ModelSelection { return { provider: normalizeModelField(task.modelProvider), modelId: normalizeModelField(task.modelId), }; } function getValidatorSelection(task: Task | TaskDetail): ModelSelection { return { provider: normalizeModelField(task.validatorModelProvider), modelId: normalizeModelField(task.validatorModelId), }; } function getPlanningSelection(task: Task | TaskDetail): ModelSelection { return { provider: normalizeModelField(task.planningModelProvider), modelId: normalizeModelField(task.planningModelId), }; } function resolveEffectiveExecutor( task: Task | TaskDetail, settings?: Settings, ): ModelSelection { return resolveTaskExecutionModel(task, settings); } function resolveEffectiveValidator( task: Task | TaskDetail, settings?: Settings, ): ModelSelection { return resolveTaskValidatorModel(task, settings); } function resolveEffectivePlanning( task: Task | TaskDetail, settings?: Settings, ): ModelSelection { return resolveTaskPlanningModel(task, settings); } function parseModelValue(value: string): ModelSelection { if (!value) { return { provider: undefined, modelId: undefined }; } const slashIdx = value.indexOf("/"); return { provider: value.slice(0, slashIdx), modelId: value.slice(slashIdx + 1), }; } function getDropdownValue(selection: ModelSelection): string { return selection.provider && selection.modelId ? `${selection.provider}/${selection.modelId}` : ""; } function selectionsEqual(a: ModelSelection, b: ModelSelection): boolean { return a.provider === b.provider && a.modelId === b.modelId; } function getSuccessToastMessage(target: "executor" | "validator" | "planning", selection: ModelSelection): string { const labels: Record = { executor: "Executor", validator: "Reviewer", planning: "Planning", }; const label = labels[target] || target; if (!selection.provider || !selection.modelId) { return `${label} model set to default`; } return `${label} model set to ${selection.provider}/${selection.modelId}`; } export function ModelSelectorTab({ task, addToast, onTaskUpdated, settings }: ModelSelectorTabProps) { const { availableModels, favoriteProviders, favoriteModels, toggleFavoriteProvider, toggleFavoriteModel, } = useFavorites(); const { loading: modelsLoading } = useModelsCache(); const [selectedExecutor, setSelectedExecutor] = useState(() => getExecutorSelection(task)); const [savedExecutor, setSavedExecutor] = useState(() => getExecutorSelection(task)); const [selectedValidator, setSelectedValidator] = useState(() => getValidatorSelection(task)); const [savedValidator, setSavedValidator] = useState(() => getValidatorSelection(task)); const [selectedPlanning, setSelectedPlanning] = useState(() => getPlanningSelection(task)); const [savedPlanning, setSavedPlanning] = useState(() => getPlanningSelection(task)); const [selectedThinking, setSelectedThinking] = useState(() => task.thinkingLevel ?? null); const [savedThinking, setSavedThinking] = useState(() => task.thinkingLevel ?? null); const [savingTarget, setSavingTarget] = useState<"executor" | "validator" | "planning" | "thinking" | null>(null); const activeTaskIdRef = useRef(task.id); const handleToggleFavorite = useCallback(async (provider: string) => { try { await toggleFavoriteProvider(provider); } catch { addToast("Failed to update favorites", "error"); } }, [toggleFavoriteProvider, addToast]); const handleToggleModelFavorite = useCallback(async (modelId: string) => { try { await toggleFavoriteModel(modelId); } catch { addToast("Failed to update model favorites", "error"); } }, [toggleFavoriteModel, addToast]); useEffect(() => { activeTaskIdRef.current = task.id; const nextExecutor = getExecutorSelection(task); const nextValidator = getValidatorSelection(task); const nextPlanning = getPlanningSelection(task); setSelectedExecutor(nextExecutor); setSavedExecutor(nextExecutor); setSelectedValidator(nextValidator); setSavedValidator(nextValidator); setSelectedPlanning(nextPlanning); setSavedPlanning(nextPlanning); const nextThinking = task.thinkingLevel ?? null; setSelectedThinking(nextThinking); setSavedThinking(nextThinking); setSavingTarget(null); }, [task.id, task.modelProvider, task.modelId, task.validatorModelProvider, task.validatorModelId, task.planningModelProvider, task.planningModelId, task.thinkingLevel]); const executorValue = useMemo(() => getDropdownValue(selectedExecutor), [selectedExecutor]); const validatorValue = useMemo(() => getDropdownValue(selectedValidator), [selectedValidator]); const planningValue = useMemo(() => getDropdownValue(selectedPlanning), [selectedPlanning]); const effectiveExecutor = useMemo(() => resolveEffectiveExecutor(task, settings), [task, settings]); const effectiveValidator = useMemo(() => resolveEffectiveValidator(task, settings), [task, settings]); const effectivePlanning = useMemo(() => resolveEffectivePlanning(task, settings), [task, settings]); const isSaving = savingTarget !== null; const saveSelection = useCallback( async (target: "executor" | "validator" | "planning", nextSelection: ModelSelection) => { const requestTaskId = task.id; const previousSavedExecutor = savedExecutor; const previousSavedValidator = savedValidator; const previousSavedPlanning = savedPlanning; setSavingTarget(target); try { const updates: Parameters[1] = onTaskUpdated ? (target === "executor" ? { modelProvider: nextSelection.provider ?? null, modelId: nextSelection.modelId ?? null, } : target === "validator" ? { validatorModelProvider: nextSelection.provider ?? null, validatorModelId: nextSelection.modelId ?? null, } : { planningModelProvider: nextSelection.provider ?? null, planningModelId: nextSelection.modelId ?? null, }) : { modelProvider: (target === "executor" ? nextSelection : savedExecutor).provider ?? null, modelId: (target === "executor" ? nextSelection : savedExecutor).modelId ?? null, validatorModelProvider: (target === "validator" ? nextSelection : savedValidator).provider ?? null, validatorModelId: (target === "validator" ? nextSelection : savedValidator).modelId ?? null, planningModelProvider: (target === "planning" ? nextSelection : savedPlanning).provider ?? null, planningModelId: (target === "planning" ? nextSelection : savedPlanning).modelId ?? null, }; const updatedTask = await updateTask(requestTaskId, updates); if (activeTaskIdRef.current !== requestTaskId) { return; } const nextSavedExecutor = getExecutorSelection(updatedTask); const nextSavedValidator = getValidatorSelection(updatedTask); const nextSavedPlanning = getPlanningSelection(updatedTask); setSavedExecutor(nextSavedExecutor); setSelectedExecutor(nextSavedExecutor); setSavedValidator(nextSavedValidator); setSelectedValidator(nextSavedValidator); setSavedPlanning(nextSavedPlanning); setSelectedPlanning(nextSavedPlanning); onTaskUpdated?.(updatedTask); const targetSelections: Record = { executor: nextSavedExecutor, validator: nextSavedValidator, planning: nextSavedPlanning, }; addToast( getSuccessToastMessage(target, targetSelections[target]), "success", ); } catch (err) { if (activeTaskIdRef.current !== requestTaskId) { return; } if (target === "executor") { setSelectedExecutor(previousSavedExecutor); } else if (target === "validator") { setSelectedValidator(previousSavedValidator); } else { setSelectedPlanning(previousSavedPlanning); } addToast(getErrorMessage(err) || "Failed to save model settings", "error"); } finally { if (activeTaskIdRef.current === requestTaskId) { setSavingTarget(null); } } }, [task.id, savedExecutor, savedValidator, savedPlanning, addToast, onTaskUpdated], ); const handleExecutorChange = useCallback( (value: string) => { const nextSelection = parseModelValue(value); setSelectedExecutor(nextSelection); if (selectionsEqual(nextSelection, savedExecutor)) { return; } void saveSelection("executor", nextSelection); }, [savedExecutor, saveSelection], ); const handleValidatorChange = useCallback( (value: string) => { const nextSelection = parseModelValue(value); setSelectedValidator(nextSelection); if (selectionsEqual(nextSelection, savedValidator)) { return; } void saveSelection("validator", nextSelection); }, [savedValidator, saveSelection], ); const handlePlanningChange = useCallback( (value: string) => { const nextSelection = parseModelValue(value); setSelectedPlanning(nextSelection); if (selectionsEqual(nextSelection, savedPlanning)) { return; } void saveSelection("planning", nextSelection); }, [savedPlanning, saveSelection], ); const handleThinkingChange = useCallback( async (value: string) => { const requestTaskId = task.id; const previousThinking = savedThinking; // Empty string means clear override (null) const nextValue = value === "" ? null : value; setSelectedThinking(nextValue); setSavingTarget("thinking"); try { const updatedTask = await updateTask(requestTaskId, { thinkingLevel: nextValue, }); if (activeTaskIdRef.current !== requestTaskId) { return; } const nextThinking = updatedTask.thinkingLevel ?? null; setSavedThinking(nextThinking); setSelectedThinking(nextThinking); onTaskUpdated?.(updatedTask); const effectiveDefault = settings?.defaultThinkingLevel ?? "off"; if (nextThinking === null) { addToast( `Thinking level set to default (${effectiveDefault})`, "success", ); } else { addToast( `Thinking level set to ${nextThinking}`, "success", ); } } catch (err) { if (activeTaskIdRef.current !== requestTaskId) { return; } setSelectedThinking(previousThinking); addToast(getErrorMessage(err) || "Failed to save thinking level", "error"); } finally { if (activeTaskIdRef.current === requestTaskId) { setSavingTarget(null); } } }, [task.id, savedThinking, settings, addToast, onTaskUpdated], ); const executorUsingDefault = !savedExecutor.provider && !savedExecutor.modelId; const validatorUsingDefault = !savedValidator.provider && !savedValidator.modelId; const planningUsingDefault = !savedPlanning.provider && !savedPlanning.modelId; return (

Model Configuration

Override the AI models used for this task. When not specified, project or global defaults are used.

{modelsLoading ? (
Loading available models…
) : availableModels.length === 0 ? (
No models available. Configure authentication in Settings to enable model selection.
) : ( <>
{executorUsingDefault ? ( Using default{effectiveExecutor.provider && effectiveExecutor.modelId ? ` (${effectiveExecutor.provider}/${effectiveExecutor.modelId})` : ""} ) : ( {savedExecutor.provider && } {savedExecutor.provider}/{savedExecutor.modelId} )}
The AI model used to implement this task.
{validatorUsingDefault ? ( Using default{effectiveValidator.provider && effectiveValidator.modelId ? ` (${effectiveValidator.provider}/${effectiveValidator.modelId})` : ""} ) : ( {savedValidator.provider && } {savedValidator.provider}/{savedValidator.modelId} )}
The AI model used to review code and plans for this task.
{planningUsingDefault ? ( Using default{effectivePlanning.provider && effectivePlanning.modelId ? ` (${effectivePlanning.provider}/${effectivePlanning.modelId})` : ""} ) : ( {savedPlanning.provider && } {savedPlanning.provider}/{savedPlanning.modelId} )}
The AI model used for task specification (triage phase).
{savedThinking === null ? ( Using default ({settings?.defaultThinkingLevel ?? "off"}) ) : ( {savedThinking} )}
Controls the reasoning effort for the AI agent. Higher levels use more tokens.
{executorUsingDefault && validatorUsingDefault && planningUsingDefault && savedThinking === null ? "Using project or global default models." : "Model settings are up to date."}
)}
); }