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, 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
); 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()}
)}
); }