import { useState, useEffect, useCallback, useRef } from "react"; import { Globe, Folder } from "lucide-react"; import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core"; import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, PromptKey, AgentPromptsConfig } from "@fusion/core"; import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemory, saveMemory, fetchGlobalConcurrency, updateGlobalConcurrency } from "../api"; import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities } from "../api"; import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus"; import type { ToastType } from "../hooks/useToast"; import { ThemeSelector } from "./ThemeSelector"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { FileEditor } from "./FileEditor"; import { PluginManager } from "./PluginManager"; import { AgentPromptsManager } from "./AgentPromptsManager"; import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets"; /** * Settings sections configuration. * * Each section groups related settings fields under a sidebar nav item. * Sections have a `scope` to indicate where their settings are stored: * - "global": User-level settings stored in ~/.pi/fusion/settings.json (shared across projects) * - "project": Project-specific settings stored in .fusion/config.json * - undefined: Section operates independently of settings storage (e.g. authentication) * * Group headers (isGroupHeader: true) are non-clickable labels that visually group sections. * * To add a new section: * 1. Add an entry to SETTINGS_SECTIONS with a unique id, label, and scope * 2. Add a corresponding case in renderSectionFields() * * Sections: * - authentication: OAuth provider status, login/logout (independent) * - appearance: Theme and color settings (global) * - notifications: ntfy.sh notification settings (global) * - global-models: Default/fallback models and thinking level (global) * - project-models: Planning & validator models, model presets, and AI summarization (project) * - general: Task prefix configuration (project) * - scheduling: Concurrency, poll interval, file overlap serialization, and step execution * settings (runStepsInNewSessions, maxParallelSteps) (project) * - worktrees: Worktree limits, init commands, recycling (project) * - commands: Test and build command configuration (project) * - merge: Auto-merge settings (project) * - memory: Project memory settings (project) * - prompts: Agent prompt customization (project) * - backups: Database backup settings (project) * - plugins: Plugin management (project) */ /** Section entry type with optional icon */ type SettingsSection = { id: string; label: string; scope: "global" | "project" | undefined; icon?: typeof Globe; isGroupHeader?: boolean; }; const SETTINGS_SECTIONS: SettingsSection[] = [ // Global group { id: "authentication", label: "Authentication", scope: undefined, icon: Globe }, { id: "appearance", label: "Appearance", scope: "global" }, { id: "notifications", label: "Notifications", scope: "global" }, { id: "global-models", label: "Models", scope: "global" }, { id: "project-models", label: "Project Models", scope: "project" }, { id: "__global_header", label: "Global", scope: undefined, isGroupHeader: true }, // Project group { id: "__project_header", label: "Project", scope: undefined, isGroupHeader: true }, { id: "general", label: "General", scope: "project" }, { id: "scheduling", label: "Scheduling", scope: "project" }, { id: "worktrees", label: "Worktrees", scope: "project" }, { id: "commands", label: "Commands", scope: "project" }, { id: "merge", label: "Merge", scope: "project" }, { id: "memory", label: "Memory", scope: "project" }, { id: "experimental", label: "Experimental Features", scope: "project" }, { id: "prompts", label: "Prompts", scope: "project" }, { id: "backups", label: "Backups", scope: "project" }, { id: "plugins", label: "Plugins", scope: "project" }, ]; export type SectionId = SettingsSection["id"]; interface SettingsModalProps { onClose: () => void; addToast: (message: string, type?: ToastType) => void; projectId?: string; /** Optional section to show when the modal first opens. Defaults to first non-group-header section. */ initialSection?: SectionId; /** Current theme mode */ themeMode?: ThemeMode; /** Current color theme */ colorTheme?: ColorTheme; /** Called when theme mode changes */ onThemeModeChange?: (mode: ThemeMode) => void; /** Called when color theme changes */ onColorThemeChange?: (theme: ColorTheme) => void; /** Optional callback when user wants to reopen the onboarding guide */ onReopenOnboarding?: () => void; } export function SettingsModal({ onClose, addToast, projectId, initialSection, themeMode = "dark", colorTheme = "default", onThemeModeChange, onColorThemeChange, onReopenOnboarding, }: SettingsModalProps) { const [form, setForm] = useState({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: true, autoMerge: true, mergeStrategy: "direct", recycleWorktrees: false, worktreeNaming: "random", includeTaskIdInCommit: true, worktreeInitCommand: "", ntfyEnabled: false, ntfyTopic: undefined }); const [loading, setLoading] = useState(true); // Track initial values to detect explicit clears for null-as-delete semantics const [initialValues, setInitialValues] = useState(null); // Track scoped settings for inheritance detection (fetched alongside merged settings) // This stores the raw { global, project } structure from the API const [scopedSettings, setScopedSettings] = useState<{ global: GlobalSettings; project: Partial } | null>(null); // Track initial scoped values for null-as-delete semantics on project overrides const [initialScopedValues, setInitialScopedValues] = useState<{ global: GlobalSettings; project: Partial } | null>(null); // Find the first non-group-header section for default active section const firstNonHeaderSection = SETTINGS_SECTIONS.find((s) => !s.isGroupHeader); const [activeSection, setActiveSection] = useState(initialSection ?? firstNonHeaderSection?.id ?? "authentication"); const [prefixError, setPrefixError] = useState(null); /** Get the scope of the currently active section */ const activeSectionScope = SETTINGS_SECTIONS.find((s) => s.id === activeSection)?.scope; // Auth state (independent of the settings save flow) const [authProviders, setAuthProviders] = useState([]); const [authLoading, setAuthLoading] = useState(false); const [authActionInProgress, setAuthActionInProgress] = useState(null); const [apiKeyInputs, setApiKeyInputs] = useState>({}); const [apiKeyErrors, setApiKeyErrors] = useState>({}); const pollIntervalRef = useRef | null>(null); // Model state const [availableModels, setAvailableModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); // Test notification state const [testNotificationLoading, setTestNotificationLoading] = useState(false); const [editingPresetId, setEditingPresetId] = useState(null); const [presetDraft, setPresetDraft] = useState(null); // Backup state const [backupInfo, setBackupInfo] = useState(null); const [backupLoading, setBackupLoading] = useState(false); // Project memory state const [memoryContent, setMemoryContent] = useState(""); const [memoryLoading, setMemoryLoading] = useState(false); const [memoryDirty, setMemoryDirty] = useState(false); // Global concurrency state const [globalMaxConcurrent, setGlobalMaxConcurrent] = useState(4); // Import/Export state const [importDialogOpen, setImportDialogOpen] = useState(false); const [, setImportFile] = useState(null); const [importPreview, setImportPreview] = useState(null); const [importLoading, setImportLoading] = useState(false); const [importScope, setImportScope] = useState<'global' | 'project' | 'both'>('both'); const [importMerge, setImportMerge] = useState(true); const fileInputRef = useRef(null); useEffect(() => { // Load both merged and scoped settings to enable inheritance detection Promise.all([fetchSettings(projectId), fetchSettingsByScope(projectId)]) .then(([s, scoped]) => { setForm(s); setInitialValues(s); // Store initial values to detect explicit clears setScopedSettings(scoped); setInitialScopedValues(scoped); // Store initial scoped values for null-as-delete setLoading(false); }) .catch((err) => { addToast(err.message, "error"); setLoading(false); }); }, [addToast, projectId]); useEffect(() => { fetchGlobalConcurrency() .then((state) => setGlobalMaxConcurrent(state.globalMaxConcurrent)) .catch(() => { // Silently fail — global concurrency may not be available }); }, []); // Load auth status when the authentication section is active const loadAuthStatus = useCallback(async () => { try { const { providers } = await fetchAuthStatus(); setAuthProviders(providers); } catch { // Silently fail — auth may not be configured } }, []); useEffect(() => { if (activeSection === "global-models" || activeSection === "project-models") { setModelsLoading(true); fetchModels() .then((response) => { setAvailableModels(response.models); setFavoriteProviders(response.favoriteProviders); setFavoriteModels(response.favoriteModels); }) .catch(() => setAvailableModels([])) .finally(() => setModelsLoading(false)); } }, [activeSection]); useEffect(() => { if (activeSection === "backups") { setBackupLoading(true); fetchBackups(projectId) .then((info) => setBackupInfo(info)) .catch(() => setBackupInfo(null)) .finally(() => setBackupLoading(false)); } }, [activeSection, projectId]); useEffect(() => { if (activeSection !== "memory" || memoryDirty) { return; } let cancelled = false; setMemoryLoading(true); fetchMemory(projectId) .then(({ content }) => { if (cancelled) return; setMemoryContent(content); setMemoryDirty(false); }) .catch((err: any) => { if (cancelled) return; addToast(err?.message || "Failed to load project memory", "error"); setMemoryContent(""); }) .finally(() => { if (!cancelled) { setMemoryLoading(false); } }); return () => { cancelled = true; }; }, [activeSection, memoryDirty, projectId, addToast]); useEffect(() => { if (activeSection === "authentication") { setAuthLoading(true); loadAuthStatus().finally(() => setAuthLoading(false)); } // Clean up polling when leaving auth section return () => { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } }; }, [activeSection, loadAuthStatus]); const handleLogin = useCallback(async (providerId: string) => { setAuthActionInProgress(providerId); try { const { url } = await loginProvider(providerId); window.open(url, "_blank"); // Poll for auth completion every 2 seconds pollIntervalRef.current = setInterval(async () => { try { const { providers } = await fetchAuthStatus(); setAuthProviders(providers); const provider = providers.find((p) => p.id === providerId); if (provider?.authenticated) { if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } setAuthActionInProgress(null); addToast("Login successful", "success"); } } catch { // Continue polling on transient errors } }, 2000); } catch (err: any) { addToast(err.message || "Login failed", "error"); setAuthActionInProgress(null); } }, [addToast]); const handleLogout = useCallback(async (providerId: string) => { setAuthActionInProgress(providerId); try { await logoutProvider(providerId); await loadAuthStatus(); addToast("Logged out", "success"); } catch (err: any) { addToast(err.message || "Logout failed", "error"); } finally { setAuthActionInProgress(null); } }, [addToast, loadAuthStatus]); const handleSaveApiKey = useCallback(async (providerId: string) => { const key = apiKeyInputs[providerId]?.trim(); if (!key) { setApiKeyErrors((prev) => ({ ...prev, [providerId]: "API key is required" })); return; } setAuthActionInProgress(providerId); setApiKeyErrors((prev) => { const next = { ...prev }; delete next[providerId]; return next; }); try { await saveApiKey(providerId, key); setApiKeyInputs((prev) => { const next = { ...prev }; delete next[providerId]; return next; }); await loadAuthStatus(); addToast("API key saved", "success"); } catch (err: any) { setApiKeyErrors((prev) => ({ ...prev, [providerId]: err.message || "Failed to save API key" })); } finally { setAuthActionInProgress(null); } }, [apiKeyInputs, addToast, loadAuthStatus]); const handleClearApiKey = useCallback(async (providerId: string) => { setAuthActionInProgress(providerId); try { await clearApiKey(providerId); setApiKeyInputs((prev) => { const next = { ...prev }; delete next[providerId]; return next; }); setApiKeyErrors((prev) => { const next = { ...prev }; delete next[providerId]; return next; }); await loadAuthStatus(); addToast("API key cleared", "success"); } catch (err: any) { addToast(err.message || "Failed to clear API key", "error"); } finally { setAuthActionInProgress(null); } }, [addToast, loadAuthStatus]); const handleTestNotification = useCallback(async () => { // Validate ntfy is enabled and topic is valid if (!form.ntfyEnabled || !form.ntfyTopic || !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic)) { return; } setTestNotificationLoading(true); try { const result = await testNtfyNotification({ ntfyEnabled: form.ntfyEnabled, ntfyTopic: form.ntfyTopic, }, projectId); if (result.success) { addToast("Test notification sent — check your ntfy app!", "success"); } else { addToast("Failed to send test notification", "error"); } } catch (err: any) { addToast(err.message || "Failed to send test notification", "error"); } finally { setTestNotificationLoading(false); } }, [addToast, form.ntfyEnabled, form.ntfyTopic, projectId]); const handleBackupNow = useCallback(async () => { setBackupLoading(true); try { const result = await createBackup(projectId); if (result.success) { addToast("Backup created successfully", "success"); // Refresh backup list const info = await fetchBackups(projectId); setBackupInfo(info); } else { addToast(result.error || "Failed to create backup", "error"); } } catch (err: any) { addToast(err.message || "Failed to create backup", "error"); } finally { setBackupLoading(false); } }, [addToast, projectId]); // Export/Import handlers const handleExport = useCallback(async () => { try { // Default scope based on active section const scope = activeSectionScope === "global" ? "global" : activeSectionScope === "project" ? "project" : "both"; const data = await exportSettings(scope, projectId); // Create and download the JSON file const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); const filename = `fusion-settings-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)}.json`; link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); const scopeLabel = scope === "global" ? "global" : scope === "project" ? "project" : "all"; addToast(`Settings exported (${scopeLabel} scope)`, "success"); } catch (err: any) { addToast(err.message || "Failed to export settings", "error"); } }, [addToast, activeSectionScope, projectId]); const handleFileSelect = useCallback(async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setImportFile(file); setImportLoading(true); try { const text = await file.text(); const data = JSON.parse(text) as SettingsExportData; setImportPreview(data); setImportDialogOpen(true); } catch (err: any) { addToast(`Invalid JSON file: ${err.message}`, "error"); setImportFile(null); } finally { setImportLoading(false); } }, [addToast]); const handleImport = useCallback(async () => { if (!importPreview) return; setImportLoading(true); try { const result = await importSettings(importPreview, { scope: importScope, merge: importMerge }, projectId); if (result.success) { const parts: string[] = []; if (result.globalCount > 0) parts.push(`${result.globalCount} global`); if (result.projectCount > 0) parts.push(`${result.projectCount} project`); addToast(`Imported ${parts.join(", ")} setting(s)`, "success"); setImportDialogOpen(false); setImportPreview(null); setImportFile(null); // Refresh settings to show imported values const refreshed = await fetchSettings(projectId); setForm(refreshed); } else { addToast(result.error || "Import failed", "error"); } } catch (err: any) { addToast(err.message || "Failed to import settings", "error"); } finally { setImportLoading(false); } }, [addToast, importPreview, importScope, importMerge, projectId]); const handleToggleFavorite = useCallback(async (provider: string) => { const currentFavorites = favoriteProviders; const isFavorite = currentFavorites.includes(provider); const newFavorites = isFavorite ? currentFavorites.filter((p) => p !== provider) : [provider, ...currentFavorites]; setFavoriteProviders(newFavorites); try { await updateGlobalSettings({ favoriteProviders: newFavorites, favoriteModels }); } catch { setFavoriteProviders(currentFavorites); } }, [favoriteProviders, favoriteModels]); const handleToggleModelFavorite = useCallback(async (modelId: string) => { const currentFavorites = favoriteModels; const isFavorite = currentFavorites.includes(modelId); const newFavorites = isFavorite ? currentFavorites.filter((m) => m !== modelId) : [modelId, ...currentFavorites]; setFavoriteModels(newFavorites); try { await updateGlobalSettings({ favoriteProviders, favoriteModels: newFavorites }); } catch { setFavoriteModels(currentFavorites); } }, [favoriteModels, favoriteProviders]); useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey); }, [onClose]); const handleOverlayClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget) onClose(); }, [onClose], ); /** * Lane status types: * - "overridden": Both provider and model keys are explicitly set in project scope * - "inherited": Provider/model keys are not set in project scope (fallback to global) */ type LaneStatus = "overridden" | "inherited"; /** * Model lane keys that can be overridden at the project level. * Each lane has global baseline keys and project override keys. */ interface ModelLane { laneId: string; label: string; globalProviderKey: keyof GlobalSettings; globalModelKey: keyof GlobalSettings; projectProviderKey: keyof Settings; projectModelKey: keyof Settings; helperText: string; fallbackOrder: string; } /** All five model lanes with their global and project override keys */ const MODEL_LANES: ModelLane[] = [ { laneId: "default", label: "Default Model", globalProviderKey: "defaultProvider", globalModelKey: "defaultModelId", projectProviderKey: "defaultProviderOverride", projectModelKey: "defaultModelIdOverride", helperText: "Default AI model used for task execution when no per-task override is set.", fallbackOrder: "Project override → Global default lane → Automatic resolution", }, { laneId: "execution", label: "Execution Model", globalProviderKey: "executionGlobalProvider", globalModelKey: "executionGlobalModelId", projectProviderKey: "executionProvider", projectModelKey: "executionModelId", helperText: "AI model used for task implementation (executor agent).", fallbackOrder: "Project override → Global execution lane → Global default lane → Automatic resolution", }, { laneId: "planning", label: "Planning Model", globalProviderKey: "planningGlobalProvider", globalModelKey: "planningGlobalModelId", projectProviderKey: "planningProvider", projectModelKey: "planningModelId", helperText: "AI model used for task specification (triage).", fallbackOrder: "Project override → Global planning lane → Global default lane → Automatic resolution", }, { laneId: "validator", label: "Validator Model", globalProviderKey: "validatorGlobalProvider", globalModelKey: "validatorGlobalModelId", projectProviderKey: "validatorProvider", projectModelKey: "validatorModelId", helperText: "AI model used for code and specification review.", fallbackOrder: "Project override → Global validator lane → Global default lane → Automatic resolution", }, { laneId: "summarization", label: "Title Summarization Model", globalProviderKey: "titleSummarizerGlobalProvider", globalModelKey: "titleSummarizerGlobalModelId", projectProviderKey: "titleSummarizerProvider", projectModelKey: "titleSummarizerModelId", helperText: "AI model used for auto-generating task titles from descriptions.", fallbackOrder: "Project override → Global summarization lane → Global planning lane → Global default lane → Automatic resolution", }, ]; /** * Compute the status of a model lane from scoped project data. * Returns "overridden" when both project lane keys are explicitly set, * "inherited" when they are absent (fallback to global lane). */ function getLaneStatus(lane: ModelLane): LaneStatus { if (!scopedSettings?.project) return "inherited"; const provider = scopedSettings.project[lane.projectProviderKey as keyof Settings]; const model = scopedSettings.project[lane.projectModelKey as keyof Settings]; return provider !== undefined || model !== undefined ? "overridden" : "inherited"; } /** * Compute the display value for a model lane dropdown. * Returns the provider/model pair when explicitly set, or empty string for inherited. */ function getLaneValue(lane: ModelLane): string { const provider = form[lane.projectProviderKey as keyof Settings] as string | undefined; const model = form[lane.projectModelKey as keyof Settings] as string | undefined; if (provider && model) { return `${provider}/${model}`; } return ""; } /** * Update a model lane's provider and model values in the form. */ function updateLaneValue(lane: ModelLane, value: string): void { if (!value) { // Clearing the dropdown - check if this is an inherited lane const status = getLaneStatus(lane); if (status === "inherited") { // Don't write anything to form for inherited lanes return; } // For overridden lanes, setting to undefined clears the override (null-as-delete) setForm((f) => ({ ...f, [lane.projectProviderKey]: undefined, [lane.projectModelKey]: undefined, })); } else { const slashIdx = value.indexOf("/"); setForm((f) => ({ ...f, [lane.projectProviderKey]: value.slice(0, slashIdx), [lane.projectModelKey]: value.slice(slashIdx + 1), })); } } /** * Reset a model lane back to inherited state (null-as-delete for project override). */ function resetLaneValue(lane: ModelLane): void { const status = getLaneStatus(lane); if (status === "inherited") return; // Nothing to reset // Set to undefined to trigger null-as-delete on save setForm((f) => ({ ...f, [lane.projectProviderKey]: undefined, [lane.projectModelKey]: undefined, })); } const handleSave = useCallback(async () => { if (prefixError || presetDraft) return; try { const payload = { ...form, worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined, taskPrefix: form.taskPrefix?.trim() || undefined, }; // Always save both global and project settings with strict scope separation. // // SCOPE RULES: // - Global lane keys (executionGlobalProvider, planningGlobalProvider, etc.) // go to updateGlobalSettings // - Project override lane keys (executionProvider, planningProvider, etc.) // go to updateSettings ONLY when explicitly changed from initial state // - Inherited project lanes (unset in project scope) are NOT written to project payload // - Resetting a project lane sends null to delete it from project scope const globalPatch: Partial = {}; for (const [key, value] of Object.entries(payload)) { if (isGlobalSettingsKey(key)) { // Implement null-as-delete semantics for global settings: // - undefined values are dropped during JSON serialization // - To explicitly clear a field, send null instead // - We detect explicit clears by comparing with initial values: // if current value is undefined AND initial was defined, use null const initialValue = initialValues?.[key as keyof GlobalSettings]; if (value === undefined && initialValue !== undefined) { (globalPatch as any)[key] = null; // null means "explicitly clear" } else { (globalPatch as any)[key] = value; } } } // Project settings: Only include keys that were explicitly changed. // This prevents inherited effective values from being persisted as explicit overrides. const projectPatch: Partial = {}; for (const [key, value] of Object.entries(payload)) { if (key === "githubTokenConfigured") continue; // server-only field if (!isProjectSettingsKey(key)) continue; // Get the initial project-scoped value (null if not set) const initialProjectValue = initialScopedValues?.project?.[key as keyof Settings]; // Check if this value is a model lane key that tracks inheritance const isModelLaneKey = [ "planningProvider", "planningModelId", "validatorProvider", "validatorModelId", "executionProvider", "executionModelId", "titleSummarizerProvider", "titleSummarizerModelId", "defaultProviderOverride", "defaultModelIdOverride", "planningFallbackProvider", "planningFallbackModelId", "validatorFallbackProvider", "validatorFallbackModelId", "titleSummarizerFallbackProvider", "titleSummarizerFallbackModelId", ].includes(key); if (isModelLaneKey) { // For model lanes: only write if explicitly changed from initial project state if (value !== initialProjectValue) { // Detect explicit reset: current is undefined/null but initial was set if ((value === undefined || value === null) && initialProjectValue !== undefined && initialProjectValue !== null) { (projectPatch as any)[key] = null; // null-as-delete } else if (value !== undefined) { (projectPatch as any)[key] = value; } } } else { // For non-model settings: existing behavior (projectPatch as any)[key] = value; } } // Save both scopes in parallel if they have changes. // Note: themeMode/colorTheme may also be write-through via useTheme callbacks // in the Appearance section; duplicate global writes are intentional/idempotent, // while this save path persists the full settings form in one action. await Promise.all([ Object.keys(globalPatch).length > 0 ? updateGlobalSettings(globalPatch) : Promise.resolve(), Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch, projectId) : Promise.resolve(), updateGlobalConcurrency({ globalMaxConcurrent: globalMaxConcurrent ?? 4 }), ]); addToast("Settings saved", "success"); onClose(); } catch (err: any) { addToast(err.message, "error"); } }, [form, globalMaxConcurrent, prefixError, presetDraft, initialValues, initialScopedValues, onClose, addToast, projectId]); const handleSaveMemory = useCallback(async () => { try { await saveMemory(memoryContent, projectId); setMemoryDirty(false); addToast("Memory saved", "success"); } catch (err: any) { addToast(err?.message || "Failed to save memory", "error"); } }, [memoryContent, projectId, addToast]); const savePresetDraft = () => { if (!presetDraft) return; const nextName = presetDraft.name.trim(); if (!nextName) { addToast("Preset name is required", "error"); return; } const presets = form.modelPresets || []; // For new presets, generate unique ID from name; for edits, keep existing ID let nextId: string; if (editingPresetId) { nextId = editingPresetId; } else { nextId = generateUniquePresetId(nextName, presets); } const normalizedDraft: ModelPreset = { id: nextId, name: nextName, executorProvider: presetDraft.executorProvider, executorModelId: presetDraft.executorModelId, validatorProvider: presetDraft.validatorProvider, validatorModelId: presetDraft.validatorModelId, }; setForm((current) => { const existing = current.modelPresets || []; const nextPresets = editingPresetId ? existing.map((preset) => (preset.id === editingPresetId ? normalizedDraft : preset)) : [...existing, normalizedDraft]; return { ...current, modelPresets: nextPresets }; }); setEditingPresetId(null); setPresetDraft(null); }; /** Render a scope indicator banner for the current section with theme-aware Lucide icons */ const renderScopeBanner = () => { if (activeSectionScope === "global") { return (
These settings are shared across all your Fusion projects.
); } if (activeSectionScope === "project") { return (
These settings only affect this project.
); } return null; }; const renderSectionFields = () => { switch (activeSection) { case "general": return ( <> {renderScopeBanner()}

General

{ const val = e.target.value; setForm((f) => ({ ...f, taskPrefix: val || undefined })); if (val && !/^[A-Z]{1,10}$/.test(val)) { setPrefixError("Prefix must be 1–10 uppercase letters"); } else { setPrefixError(null); } }} /> {prefixError && {prefixError}} {!prefixError && Prefix for new task IDs (e.g. KB, PROJ)}
When enabled, AI-generated task specifications require manual approval before moving to Todo
Show the floating chat button in the dashboard. Chat is still accessible from the More menu.
); case "global-models": { const selectedValue = form.defaultProvider && form.defaultModelId ? `${form.defaultProvider}/${form.defaultModelId}` : ""; return ( <> {renderScopeBanner()} {/* --- Default Model --- */}

Default Model

{modelsLoading ? (
Loading available models…
) : availableModels.length === 0 ? (
No models available. Configure authentication first.
) : ( <>
{ if (!val) { setForm((f) => ({ ...f, defaultProvider: undefined, defaultModelId: undefined })); } else { const slashIdx = val.indexOf("/"); setForm((f) => ({ ...f, defaultProvider: val.slice(0, slashIdx), defaultModelId: val.slice(slashIdx + 1), })); } }} placeholder="Use default" favoriteProviders={favoriteProviders} onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} /> Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically.
{ if (!val) { setForm((f) => ({ ...f, fallbackProvider: undefined, fallbackModelId: undefined })); } else { const slashIdx = val.indexOf("/"); setForm((f) => ({ ...f, fallbackProvider: val.slice(0, slashIdx), fallbackModelId: val.slice(slashIdx + 1), })); } }} placeholder="No fallback" favoriteProviders={favoriteProviders} onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} /> Used automatically if the primary default model hits a retryable provider error like rate limiting or overload.
)} {(() => { const selectedModel = availableModels.find( (m) => m.provider === form.defaultProvider && m.id === form.defaultModelId, ); if (selectedModel && !selectedModel.reasoning) return null; return (
Controls how much reasoning effort the AI model uses. Higher levels produce better results but cost more.
); })()} {/* --- OpenRouter Model Sync --- */}

OpenRouter Models

When enabled, the dashboard fetches the latest available models from the OpenRouter API on startup, so the model picker always shows the most up-to-date catalog. Disable to skip the initial API call and use only the built-in model list.
); } case "project-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)); // Filter model lanes to show in project scope (planning, validator, summarization) // Default and execution lanes are global-only const projectModelLanes = MODEL_LANES.filter( (lane) => lane.laneId === "planning" || lane.laneId === "validator" || lane.laneId === "summarization", ); return ( <> {renderScopeBanner()} {/* --- Token Cap --- */}

Token Cap

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

{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"; return (
{isOverridden ? "Override (Project)" : "Inherited (Global)"}
updateLaneValue(lane, val)} placeholder="Use global" favoriteProviders={favoriteProviders} onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} />
{isOverridden && ( )}
{lane.helperText} Falls back to: {lane.fallbackOrder}.
); })} )} {/* --- Fallback Models --- */}

Fallback Models

{modelsLoading ? (
Loading available models…
) : availableModels.length === 0 ? (
No models available.
) : ( <>
{ if (!val) { setForm((f) => ({ ...f, planningFallbackProvider: undefined, planningFallbackModelId: undefined })); } else { const slashIdx = val.indexOf("/"); setForm((f) => ({ ...f, planningFallbackProvider: val.slice(0, slashIdx), planningFallbackModelId: val.slice(slashIdx + 1), })); } }} placeholder="Use global fallback" favoriteProviders={favoriteProviders} onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} /> Used if the planning model fails due to rate limits or provider overload. Defaults to the global fallback model.
{ if (!val) { setForm((f) => ({ ...f, validatorFallbackProvider: undefined, validatorFallbackModelId: undefined })); } else { const slashIdx = val.indexOf("/"); setForm((f) => ({ ...f, validatorFallbackProvider: val.slice(0, slashIdx), validatorFallbackModelId: val.slice(slashIdx + 1), })); } }} placeholder="Use global fallback" favoriteProviders={favoriteProviders} onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} /> Used if the validator model fails due to rate limits or provider overload. Defaults to the global fallback model.
)} {/* --- 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={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} />
{ 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={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} />
)}
) : null}
{form.autoSelectModelPreset ? ( <> {(["S", "M", "L"] as const).map((sizeKey) => (
))} ) : null} {/* --- AI Summarization --- */}

AI Summarization

When enabled, tasks created without a title but with descriptions over 200 characters will automatically get an AI-generated title (max 60 characters).
{(form.autoSummarizeTitles || false) && ( <>
{modelsLoading ? ( Loading available models... ) : availableModels.length === 0 ? ( No models available. Configure authentication first. ) : ( { if (!val) { setForm((f) => ({ ...f, titleSummarizerProvider: undefined, titleSummarizerModelId: undefined, })); return; } const slashIdx = val.indexOf("/"); setForm((f) => ({ ...f, titleSummarizerProvider: val.slice(0, slashIdx), titleSummarizerModelId: val.slice(slashIdx + 1), })); }} placeholder="Use fallback model" favoriteProviders={favoriteProviders} onToggleFavorite={handleToggleFavorite} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleModelFavorite} /> )} {form.titleSummarizerProvider && form.titleSummarizerModelId ? "Using explicitly configured model" : form.planningProvider && form.planningModelId ? "(using planning model)" : form.defaultProvider && form.defaultModelId ? "(using default model)" : "(using automatic model selection)"}
)} ); } case "appearance": return ( <> {renderScopeBanner()}

Appearance

{ setForm((f) => ({ ...f, themeMode: mode })); onThemeModeChange?.(mode); }} onColorThemeChange={(theme) => { setForm((f) => ({ ...f, colorTheme: theme })); onColorThemeChange?.(theme); }} /> ); case "scheduling": return ( <> {renderScopeBanner()}

Scheduling

{ const val = e.target.value; setGlobalMaxConcurrent(val === "" ? undefined : Number(val)); }} /> Maximum concurrent agents across all projects
{ const val = e.target.value; setForm((f) => ({ ...f, maxConcurrent: val === "" ? undefined : Number(val) } as any)); }} />
{ const val = e.target.value; setForm((f) => ({ ...f, pollIntervalMs: val === "" ? undefined : Number(val) } as any)); }} />
{ const val = e.target.value; const num = Number(val); setForm((f) => ({ ...f, taskStuckTimeoutMs: val && num > 0 ? num * 60000 : undefined })); }} /> Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.
When enabled, tasks with stale specifications (PROMPT.md older than the threshold) are automatically sent back to triage for re-specification
{ const val = e.target.value; const num = Number(val); setForm((f) => ({ ...f, specStalenessMaxAgeMs: val !== "" ? num * 3600000 : undefined })); }} disabled={!form.specStalenessEnabled} /> Maximum age in hours before a specification is considered stale. Default: 6 hours.
{ const val = e.target.value; const num = Number(val); setForm((f) => ({ ...f, maxStuckKills: val && num > 0 ? num : undefined })); }} /> Maximum stuck-detector retries before a task is marked failed. Default: 6.
When enabled, tasks that modify the same files are queued serially to avoid merge conflicts
Step Execution
Run each task step in its own fresh agent session for better isolation and error recovery. Failed steps can be retried individually.
{ const val = e.target.value; setForm((f) => ({ ...f, maxParallelSteps: val === "" ? undefined : Number(val) })); }} disabled={!form.runStepsInNewSessions} /> Maximum number of steps to run in parallel when file scopes don't overlap (1-4)
); case "worktrees": return ( <> {renderScopeBanner()}

Worktrees

{ const val = e.target.value; setForm((f) => ({ ...f, maxWorktrees: val === "" ? undefined : Number(val) } as any)); }} /> Limits total git worktrees including in-review tasks
setForm((f) => ({ ...f, worktreeInitCommand: e.target.value })) } /> Shell command to run in each new worktree after creation
When enabled, completed task worktrees are returned to an idle pool instead of being deleted, preserving build caches for faster startup
{form.recycleWorktrees ? "Naming style is not applicable when recycling worktrees — pooled worktrees retain their existing names" : "How to name fresh worktree directories. Only applies when recycling is off."}
); case "commands": return ( <> {renderScopeBanner()}

Commands

setForm((f) => ({ ...f, testCommand: e.target.value || undefined })) } /> Command used to run tests — injected into generated task specs
setForm((f) => ({ ...f, buildCommand: e.target.value || undefined })) } /> Command used to build the project — injected into generated task specs
); case "merge": return ( <> {renderScopeBanner()}

Merge

When enabled, tasks that pass review are automatically merged into the main branch
Controls what happens after a task reaches In Review. Direct mode preserves Fusion's current local squash-merge behavior. Pull request mode keeps the task in In Review while Fusion waits for GitHub reviews and required checks before merging the PR.
When disabled, merge commit messages omit the task ID from the scope (e.g. feat: ... instead of feat(KB-001): ...)
When enabled, all commits made by Fusion include --author{" "} attribution identifying them as AI-generated
{form.commitAuthorEnabled !== false && ( <>
setForm((f) => ({ ...f, commitAuthorName: e.target.value || undefined, })) } /> Name used in commit author attribution
setForm((f) => ({ ...f, commitAuthorEmail: e.target.value || undefined, })) } /> Email used in commit author attribution
)}
When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.), generated files (dist/*, *.gen.ts), and trivial whitespace conflicts are resolved automatically without AI intervention. Complex code conflicts still require AI review.
When enabled, lock files (package-lock.json, pnpm-lock.yaml, etc.) are resolved using 'ours' strategy, generated files (dist/*, *.gen.ts) using 'theirs' strategy, and trivial whitespace conflicts are auto-resolved without spawning an AI agent. Complex code conflicts still require AI review.
); case "memory": { // Fetch backend status when memory section is active const { currentBackend, capabilities, availableBackends, loading: backendLoading, error: backendError, refresh: refreshBackendStatus, } = useMemoryBackendStatus({ projectId }); // Determine if editing is allowed const isMemoryEnabled = form.memoryEnabled !== false; const isBackendWritable = capabilities?.writable ?? true; const isEditingAllowed = isMemoryEnabled && isBackendWritable; // Backend display names const backendNames: Record = { file: "File (.fusion/memory.md)", readonly: "Read-Only", qmd: "QMD (Quantized Memory Distillation)", }; return ( <> {renderScopeBanner()}

Memory

When enabled, agents will consult and update .fusion/memory.md with durable project learnings
{/* Backend type selector */}
Choose how project memory is stored. File backend uses .fusion/memory.md. QMD enables advanced memory features.
{/* Backend capabilities info */} {backendLoading ? (
Loading backend status...
) : backendError ? (
Failed to load backend status: {backendError}
) : currentBackend ? (
Current backend: {backendNames[currentBackend] || currentBackend} {!isBackendWritable && " (read-only)"} {isBackendWritable && capabilities && ( Supports: {capabilities.readable ? "read" : ""}{capabilities.readable && capabilities.writable ? ", " : ""}{capabilities.writable ? "write" : ""}{capabilities.supportsAtomicWrite ? ", atomic writes" : ""}{capabilities.persistent ? ", persistent" : ""} )}
) : null}
This file stores durable project learnings that agents consult during triage and execution.
{/* Read-only state warnings */} {!isMemoryEnabled && (
Memory is currently disabled. You can view the file, but editing is read-only until memory is re-enabled.
)} {isMemoryEnabled && !isBackendWritable && (
The selected backend ({backendNames[currentBackend || "file"] || currentBackend || "file"}) is read-only. You can view the file, but saving is disabled. Select a writable backend (File or QMD) to enable editing.
)} {memoryLoading ? (
Loading memory…
) : (
{ setMemoryContent(content); setMemoryDirty(true); }} readOnly={!isEditingAllowed} filePath=".fusion/memory.md" />
)} {memoryDirty && isEditingAllowed && (
)} {memoryDirty && !isEditingAllowed && (
Cannot save: {isMemoryEnabled ? "Backend is read-only" : "Memory is disabled"}
)} ); } case "experimental": { const experimentalFeatures = form.experimentalFeatures ?? {}; const featureFlags = Object.entries(experimentalFeatures).sort(([a], [b]) => a.localeCompare(b)); return ( <> {renderScopeBanner()}

Experimental Features

Experimental features are early capabilities that are not yet fully stable. Enable them to test new functionality, but be aware they may change or be removed.
{featureFlags.length === 0 ? (
No experimental features configured. Features will appear here once added by the system.
) : (
{featureFlags.map(([key, enabled]) => ( ))}
)} ); } case "backups": return ( <> {renderScopeBanner()}

Database Backups

When enabled, the database is backed up automatically on a schedule
setForm((f) => ({ ...f, autoBackupSchedule: e.target.value })) } disabled={!form.autoBackupEnabled} /> Cron expression for backup timing. Default: 0 2 * * * (daily at 2 AM). Examples: 0 * * * * (hourly), 0 0 * * 0 (weekly), */15 * * * * (every 15 min) {form.autoBackupSchedule && !/^[\s\d*,/-]+$/.test(form.autoBackupSchedule) && ( Invalid cron expression format )}
{ const val = e.target.value; setForm((f) => ({ ...f, autoBackupRetention: val === "" ? undefined : Number(val) })); }} disabled={!form.autoBackupEnabled} /> Number of backup files to keep (oldest are deleted first). Range: 1-100. {form.autoBackupRetention !== undefined && (form.autoBackupRetention < 1 || form.autoBackupRetention > 100) && ( Must be between 1 and 100 )}
setForm((f) => ({ ...f, autoBackupDir: e.target.value })) } disabled={!form.autoBackupEnabled} /> Directory for backup files, relative to project root {form.autoBackupDir && form.autoBackupDir.includes("..") && ( Path cannot contain parent directory traversal (..) )}
{backupLoading ? (
Loading backup info…
) : backupInfo ? (
{backupInfo.count} backups
{backupInfo.totalSize > 1024 * 1024 ? `${(backupInfo.totalSize / (1024 * 1024)).toFixed(1)} MB` : `${(backupInfo.totalSize / 1024).toFixed(1)} KB`} total size
{backupInfo.backups.length > 0 && (
View {backupInfo.backups.length} backup(s)
    {backupInfo.backups.slice(0, 10).map((backup) => (
  • {backup.filename} {backup.size > 1024 * 1024 ? `${(backup.size / (1024 * 1024)).toFixed(1)} MB` : `${(backup.size / 1024).toFixed(1)} KB`}
  • ))} {backupInfo.backups.length > 10 && (
  • ...and {backupInfo.backups.length - 10} more
  • )}
)}
) : null}
); case "notifications": return ( <> {renderScopeBanner()}

Notifications

Receive push notifications when tasks complete or fail via ntfy.sh
{form.ntfyEnabled && ( <>
{ const val = e.target.value; setForm((f) => ({ ...f, ntfyTopic: val || undefined })); }} /> Your ntfy.sh topic name (1–64 alphanumeric/hyphen/underscore characters).{" "} Learn more about ntfy.sh {form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && ( Topic must be 1–64 alphanumeric, hyphen, or underscore characters )}
When a task moves to In Review (ready for review) When a task is successfully merged to main When a task fails during execution (high priority) When a task specification needs manual approval before execution When an agent hands off a task for human review (high priority)
{ const val = e.target.value; setForm((f) => ({ ...f, ntfyDashboardHost: val || undefined })); }} /> Base URL for deep links in notifications. When set, clicking a notification opens the dashboard directly to the task. {form.ntfyDashboardHost && !/^https?:\/\/.+/.test(form.ntfyDashboardHost) && ( Must be a valid URL starting with http:// or https:// )}
)} ); case "prompts": return ( <> {renderScopeBanner()}

Prompts

{ setForm((f) => ({ ...f, agentPrompts, })); }} promptOverrides={form.promptOverrides} onPromptOverridesChange={(overrides) => { setForm((f) => ({ ...f, promptOverrides: overrides, })); }} /> ); case "plugins": return ( <>

Plugins

); case "authentication": // Sort providers: authenticated first, then unauthenticated. Within each bucket, sort alphabetically by name. const sortedProviders = [...authProviders].sort((a, b) => { if (a.authenticated !== b.authenticated) { return a.authenticated ? -1 : 1; } return a.name.localeCompare(b.name); }); const authenticatedProviders = sortedProviders.filter(p => p.authenticated); const unauthenticatedProviders = sortedProviders.filter(p => !p.authenticated); return ( <>

Authentication

{authLoading ? (
Loading authentication status…
) : authProviders.length === 0 ? (
No providers available
) : ( <> {authenticatedProviders.length === 0 && (
Sign in to at least one provider to get started with AI models.
)} {authenticatedProviders.length > 0 && (
Authenticated
{authenticatedProviders.map((provider) => (
{provider.name} ✓ Active
{provider.type === "api_key" ? (
setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id} /> {provider.authenticated && !apiKeyInputs[provider.id] ? ( ) : ( )}
{authActionInProgress === provider.id && ( Saving… )} {apiKeyErrors[provider.id] && ( {apiKeyErrors[provider.id]} )}
) : (
{authActionInProgress === provider.id ? ( ) : ( )}
)}
))}
)} {unauthenticatedProviders.length > 0 && (
Available
{unauthenticatedProviders.map((provider) => (
{provider.name} ✗ Not connected
{provider.type === "api_key" ? (
setApiKeyInputs((prev) => ({ ...prev, [provider.id]: e.target.value }))} disabled={authActionInProgress === provider.id} />
{authActionInProgress === provider.id && ( Saving… )} {apiKeyErrors[provider.id] && ( {apiKeyErrors[provider.id]} )}
) : (
{authActionInProgress === provider.id ? ( ) : ( )}
)}
))}
)} )} Authentication changes take effect immediately — no need to save. {onReopenOnboarding && (
Re-run the setup wizard to review or update your AI provider and model configuration.
)} ); } }; return (

Settings

{loading ? (
Loading…
) : (
{renderSectionFields()}
)}
{/* Import Confirmation Dialog */} {importDialogOpen && importPreview && (
e.target === e.currentTarget && setImportDialogOpen(false)}>

Import Settings

Review the settings to be imported:

{importPreview.global && Object.keys(importPreview.global).length > 0 && (
Global Settings:
    {Object.entries(importPreview.global) .filter(([, v]) => v !== undefined) .map(([key]) => (
  • {key}
  • ))}
)} {importPreview.project && Object.keys(importPreview.project).length > 0 && (
Project Settings:
    {Object.entries(importPreview.project) .filter(([, v]) => v !== undefined) .map(([key]) => (
  • {key}
  • ))}
)}
If unchecked, existing settings will be replaced with imported values.
)}
); }