import { useState, useEffect, useCallback, useRef } from "react"; import { THINKING_LEVELS } from "@kb/core"; import type { Settings } from "@kb/core"; import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels } from "../api"; import type { AuthProvider, ModelInfo } from "../api"; import type { ToastType } from "../hooks/useToast"; /** * Settings sections configuration. * * Each section groups related settings fields under a sidebar nav item. * To add a new section: * 1. Add an entry to SETTINGS_SECTIONS with a unique id and label * 2. Add a corresponding case in renderSectionFields() * * Sections: * - general: Task prefix configuration * - scheduling: Concurrency, poll interval, file overlap serialization * - worktrees: Worktree limits, init commands, recycling * - commands: Test and build command configuration * - merge: Auto-merge settings * - model: Default AI model selection for agent sessions * - authentication: OAuth provider status, login/logout (operates independently of Save) */ const SETTINGS_SECTIONS = [ { id: "general", label: "General" }, { id: "model", label: "Model" }, { id: "scheduling", label: "Scheduling" }, { id: "worktrees", label: "Worktrees" }, { id: "commands", label: "Commands" }, { id: "merge", label: "Merge" }, { id: "authentication", label: "Authentication" }, ] as const; type SectionId = (typeof SETTINGS_SECTIONS)[number]["id"]; interface SettingsModalProps { onClose: () => void; addToast: (message: string, type?: ToastType) => void; } export function SettingsModal({ onClose, addToast }: SettingsModalProps) { const [form, setForm] = useState({ maxConcurrent: 2, maxWorktrees: 4, pollIntervalMs: 15000, groupOverlappingFiles: false, autoMerge: false, recycleWorktrees: false, includeTaskIdInCommit: true, worktreeInitCommand: "" }); const [loading, setLoading] = useState(true); const [activeSection, setActiveSection] = useState(SETTINGS_SECTIONS[0].id); const [prefixError, setPrefixError] = useState(null); // Auth state (independent of the settings save flow) const [authProviders, setAuthProviders] = useState([]); const [authLoading, setAuthLoading] = useState(false); const [authActionInProgress, setAuthActionInProgress] = useState(null); const pollIntervalRef = useRef | null>(null); // Model state const [availableModels, setAvailableModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); useEffect(() => { fetchSettings() .then((s) => { setForm(s); setLoading(false); }) .catch((err) => { addToast(err.message, "error"); setLoading(false); }); }, [addToast]); // 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 === "model") { setModelsLoading(true); fetchModels() .then((models) => setAvailableModels(models)) .catch(() => setAvailableModels([])) .finally(() => setModelsLoading(false)); } }, [activeSection]); 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, loadAuthStatus]); 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]); 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], ); const handleSave = useCallback(async () => { if (prefixError) return; try { const payload = { ...form, worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined, taskPrefix: form.taskPrefix?.trim() || undefined, }; await updateSettings(payload); addToast("Settings saved", "success"); onClose(); } catch (err: any) { addToast(err.message, "error"); } }, [form, prefixError, onClose, addToast]); const renderSectionFields = () => { switch (activeSection) { case "general": return ( <>

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)}
); case "model": { // Group models by provider const modelsByProvider = availableModels.reduce>((acc, m) => { (acc[m.provider] ??= []).push(m); return acc; }, {}); const selectedValue = form.defaultProvider && form.defaultModelId ? `${form.defaultProvider}/${form.defaultModelId}` : ""; return ( <>

Model

{modelsLoading ? (
Loading available models…
) : availableModels.length === 0 ? (
No models available. Configure authentication first.
) : (
Select the AI model used for agent sessions. "Use default" lets the engine choose automatically.
)} {(() => { 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.
); })()} ); } case "scheduling": return ( <>

Scheduling

setForm((f) => ({ ...f, maxConcurrent: Number(e.target.value) })) } />
setForm((f) => ({ ...f, pollIntervalMs: Number(e.target.value) })) } />
When enabled, tasks that modify the same files are queued serially to avoid merge conflicts
); case "worktrees": return ( <>

Worktrees

setForm((f) => ({ ...f, maxWorktrees: Number(e.target.value) })) } /> 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
); case "commands": return ( <>

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 ( <>

Merge

When enabled, tasks that pass review are automatically merged into the main branch
When disabled, merge commit messages omit the task ID from the scope (e.g. feat: ... instead of feat(KB-001): ...)
); case "authentication": return ( <>

Authentication

{authLoading ? (
Loading authentication status…
) : authProviders.length === 0 ? (
No OAuth providers available
) : ( authProviders.map((provider) => (
{provider.name} {provider.authenticated ? "✓ Authenticated" : "✗ Not authenticated"}
{authActionInProgress === provider.id ? ( ) : provider.authenticated ? ( ) : ( )}
)) )} Login and logout take effect immediately — no need to save. ); } }; return (

Settings

{loading ? (
Loading…
) : (
{renderSectionFields()}
)}
); }