import { useState, useEffect, useCallback } from "react"; import type { Settings } from "@hai/core"; import { fetchSettings, updateSettings } 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() */ const SETTINGS_SECTIONS = [ { id: "scheduling", label: "Scheduling" }, { id: "worktrees", label: "Worktrees" }, { id: "commands", label: "Commands" }, { id: "merge", label: "Merge" }, ] 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, worktreeInitCommand: "" }); const [loading, setLoading] = useState(true); const [activeSection, setActiveSection] = useState(SETTINGS_SECTIONS[0].id); useEffect(() => { fetchSettings() .then((s) => { setForm(s); setLoading(false); }) .catch((err) => { addToast(err.message, "error"); setLoading(false); }); }, [addToast]); 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 () => { try { const payload = { ...form, worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined, }; await updateSettings(payload); addToast("Settings saved", "success"); onClose(); } catch (err: any) { addToast(err.message, "error"); } }, [form, onClose, addToast]); const renderSectionFields = () => { switch (activeSection) { 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
); } }; return (

Settings

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