import { useState, useEffect, useCallback, useRef } from "react"; import { THINKING_LEVELS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS } from "@fusion/core"; import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core"; import { fetchSettings, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings } from "../api"; import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData } from "../api"; import type { ToastType } from "../hooks/useToast"; import { ThemeSelector } from "./ThemeSelector"; import { CustomModelDropdown } from "./CustomModelDropdown"; import { applyPresetToSelection, generatePresetId, validatePresetId } 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/kb/settings.json (shared across projects) * - "project": Project-specific settings stored in .fusion/config.json * - undefined: Section operates independently of settings storage (e.g. authentication) * * 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: * - general: Task prefix configuration (project) * - default-model: Default AI model selection (global) * - execution-model: Planning and validator model selection (project) * - model-presets: Reusable model presets (project) * - ai-summarization: Auto-summarization settings (project) * - appearance: Theme and color settings (global) * - scheduling: Concurrency, poll interval, file overlap serialization (project) * - worktrees: Worktree limits, init commands, recycling (project) * - commands: Test and build command configuration (project) * - merge: Auto-merge settings (project) * - notifications: ntfy.sh notification settings (global) * - authentication: OAuth provider status, login/logout (independent) */ const SETTINGS_SECTIONS = [ { id: "general", label: "General", scope: "project" as const }, { id: "default-model", label: "Default Model", scope: "global" as const }, { id: "execution-model", label: "Execution Model", scope: "project" as const }, { id: "model-presets", label: "Model Presets", scope: "project" as const }, { id: "ai-summarization", label: "AI Summarization", scope: "project" as const }, { id: "appearance", label: "Appearance", scope: "global" as const }, { id: "scheduling", label: "Scheduling", scope: "project" as const }, { id: "worktrees", label: "Worktrees", scope: "project" as const }, { id: "commands", label: "Commands", scope: "project" as const }, { id: "merge", label: "Merge", scope: "project" as const }, { id: "backups", label: "Backups", scope: "project" as const }, { id: "notifications", label: "Notifications", scope: "global" as const }, { id: "authentication", label: "Authentication", scope: undefined }, ] as const; export type SectionId = (typeof SETTINGS_SECTIONS)[number]["id"]; interface SettingsModalProps { onClose: () => void; addToast: (message: string, type?: ToastType) => void; /** Optional section to show when the modal first opens. Defaults to "general". */ 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; } export function SettingsModal({ onClose, addToast, initialSection, themeMode = "dark", colorTheme = "default", onThemeModeChange, onColorThemeChange, }: 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); const [activeSection, setActiveSection] = useState(initialSection ?? SETTINGS_SECTIONS[0].id); 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 pollIntervalRef = useRef | null>(null); // Model state const [availableModels, setAvailableModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(false); // Test notification state const [testNotificationLoading, setTestNotificationLoading] = useState(false); const [editingPresetId, setEditingPresetId] = useState(null); const [presetDraft, setPresetDraft] = useState(null); const [presetIdTouched, setPresetIdTouched] = useState(false); // Backup state const [backupInfo, setBackupInfo] = useState(null); const [backupLoading, setBackupLoading] = useState(false); // Import/Export state const [importDialogOpen, setImportDialogOpen] = useState(false); const [importFile, 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(() => { 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 === "default-model" || activeSection === "execution-model") { setModelsLoading(true); fetchModels() .then((response) => setAvailableModels(response.models)) .catch(() => setAvailableModels([])) .finally(() => setModelsLoading(false)); } }, [activeSection]); useEffect(() => { if (activeSection === "backups") { setBackupLoading(true); fetchBackups() .then((info) => setBackupInfo(info)) .catch(() => setBackupInfo(null)) .finally(() => setBackupLoading(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]); 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, }); 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]); const handleBackupNow = useCallback(async () => { setBackupLoading(true); try { const result = await createBackup(); if (result.success) { addToast("Backup created successfully", "success"); // Refresh backup list const info = await fetchBackups(); 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]); // 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); // 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 = `kb-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]); 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 }); if (result.success) { const parts = []; 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(); 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]); 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 || presetDraft) return; try { const payload = { ...form, worktreeInitCommand: form.worktreeInitCommand?.trim() || undefined, taskPrefix: form.taskPrefix?.trim() || undefined, }; // Always save both global and project settings. // The backend filters each appropriately (updateSettings ignores global keys, // updateGlobalSettings ignores project keys). This ensures fields in sections // are persisted correctly based on their scope. const globalKeySet = new Set(GLOBAL_SETTINGS_KEYS); const globalPatch: Partial = {}; for (const [key, value] of Object.entries(payload)) { if (globalKeySet.has(key)) { (globalPatch as any)[key] = value; } } const projectKeySet = new Set(PROJECT_SETTINGS_KEYS as readonly string[]); const projectPatch: Partial = {}; for (const [key, value] of Object.entries(payload)) { if (key === "githubTokenConfigured") continue; // server-only field if (projectKeySet.has(key)) { (projectPatch as any)[key] = value; } } // Save both scopes in parallel if they have changes await Promise.all([ Object.keys(globalPatch).length > 0 ? updateGlobalSettings(globalPatch) : Promise.resolve(), Object.keys(projectPatch).length > 0 ? updateSettings(projectPatch) : Promise.resolve(), ]); addToast("Settings saved", "success"); onClose(); } catch (err: any) { addToast(err.message, "error"); } }, [form, prefixError, presetDraft, onClose, addToast]); const savePresetDraft = () => { if (!presetDraft) return; const nextId = presetDraft.id.trim(); const nextName = presetDraft.name.trim(); if (!nextName || !nextId || !validatePresetId(nextId)) { addToast("Preset name is required and ID must be 1–32 letters, numbers, hyphens, or underscores", "error"); return; } const presets = form.modelPresets || []; if (presets.some((preset) => preset.id === nextId && preset.id !== editingPresetId)) { addToast("Preset ID must be unique", "error"); return; } 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); setPresetIdTouched(false); }; /** Render a scope indicator banner for the current section */ const renderScopeBanner = () => { if (activeSectionScope === "global") { return (
🌐 These settings are shared across all your kb 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
); case "default-model": { const selectedValue = form.defaultProvider && form.defaultModelId ? `${form.defaultProvider}/${form.defaultModelId}` : ""; return ( <> {renderScopeBanner()}

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" /> Default AI model used for task execution when no per-task override is set. "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 "execution-model": { const planningValue = form.planningProvider && form.planningModelId ? `${form.planningProvider}/${form.planningModelId}` : ""; const validatorValue = form.validatorProvider && form.validatorModelId ? `${form.validatorProvider}/${form.validatorModelId}` : ""; return ( <> {renderScopeBanner()}

Execution Model

{modelsLoading ? (
Loading available models…
) : availableModels.length === 0 ? (
No models available. Configure authentication first.
) : ( <>
{ if (!val) { setForm((f) => ({ ...f, planningProvider: undefined, planningModelId: undefined })); } else { const slashIdx = val.indexOf("/"); setForm((f) => ({ ...f, planningProvider: val.slice(0, slashIdx), planningModelId: val.slice(slashIdx + 1), })); } }} placeholder="Use default" /> AI model used for task planning and specification (triage). Falls back to Default Model when not set.
{ if (!val) { setForm((f) => ({ ...f, validatorProvider: undefined, validatorModelId: undefined })); } else { const slashIdx = val.indexOf("/"); setForm((f) => ({ ...f, validatorProvider: val.slice(0, slashIdx), validatorModelId: val.slice(slashIdx + 1), })); } }} placeholder="Use default" /> AI model used for code and specification review. Falls back to Default Model when not set.
)} ); } case "model-presets": { 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)); return ( <> {renderScopeBanner()}

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, id: presetIdTouched ? current.id : generatePresetId(name), } : current); }} />
{ setPresetIdTouched(true); setPresetDraft((current) => current ? { ...current, id: e.target.value } : current); }} /> {presetDraft.id && !validatePresetId(presetDraft.id) ? ( ID must be 1–32 letters, numbers, hyphens, or underscores ) : ( Slug-friendly unique identifier used for preset mappings. )}
{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" />
{ 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" />
)}
) : null}
{form.autoSelectModelPreset ? ( <> {(["S", "M", "L"] as const).map((sizeKey) => (
))} ) : null} ); } case "ai-summarization": return ( <> {renderScopeBanner()}

AI Summarization

When enabled, tasks created without a title but with descriptions over 140 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" /> )} {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

setForm((f) => ({ ...f, maxConcurrent: Number(e.target.value) })) } />
setForm((f) => ({ ...f, pollIntervalMs: Number(e.target.value) })) } />
{ 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 that modify the same files are queued serially to avoid merge conflicts
); case "worktrees": return ( <> {renderScopeBanner()}

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
{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 kb's current local squash-merge behavior. Pull request mode keeps the task in In Review while kb 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, 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 "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 )}
setForm((f) => ({ ...f, autoBackupRetention: Number(e.target.value) })) } 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)
)} ); case "authentication": return ( <>

Authentication

{authLoading ? (
Loading authentication status…
) : authProviders.length === 0 ? (
No OAuth providers available
) : ( <> {!authProviders.some(p => p.authenticated) && (
Sign in to at least one provider to get started.
)} {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()}
)}
{/* 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.
)}
); }