diff --git a/.veritas-kanban/telemetry/events-2026-01-28.ndjson b/.veritas-kanban/telemetry/events-2026-01-28.ndjson
index 77558d1d..25940f9e 100644
--- a/.veritas-kanban/telemetry/events-2026-01-28.ndjson
+++ b/.veritas-kanban/telemetry/events-2026-01-28.ndjson
@@ -376,3 +376,7 @@
{"type":"task.status_changed","taskId":"task_20260128_iG-ISb","project":"platform","status":"in-progress","previousStatus":"todo","id":"evt_ksH0_KoAniP_","timestamp":"2026-01-28T08:13:40.507Z"}
{"type":"task.status_changed","taskId":"task_20260128_X6nifI","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_odb919zCXAY_","timestamp":"2026-01-28T08:13:54.704Z"}
{"type":"task.status_changed","taskId":"task_20260128_XvmMga","project":"brain","status":"done","previousStatus":"in-progress","id":"evt_jnEvHTbJxLMW","timestamp":"2026-01-28T08:15:54.946Z"}
+{"type":"task.status_changed","taskId":"task_20260128_X6nifI","project":"veritas-kanban","status":"done","previousStatus":"in-progress","id":"evt_YajbhOKzUsOY","timestamp":"2026-01-28T08:16:32.285Z"}
+{"type":"task.status_changed","taskId":"task_20260128_gUHeIV","project":"veritas-kanban","status":"done","previousStatus":"todo","id":"evt_G9eTdXe2ODgX","timestamp":"2026-01-28T08:16:32.375Z"}
+{"type":"task.status_changed","taskId":"task_20260128_KbDn0E","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_6RFeSMLm9ozB","timestamp":"2026-01-28T08:16:32.436Z"}
+{"type":"task.created","taskId":"task_20260128_8KRThU","project":"veritas-kanban","status":"todo","id":"evt_HYsNYmQp-M1h","timestamp":"2026-01-28T08:16:39.653Z"}
diff --git a/server/.veritas-kanban/activity.json b/server/.veritas-kanban/activity.json
index 27731182..8cb6cfa3 100644
--- a/server/.veritas-kanban/activity.json
+++ b/server/.veritas-kanban/activity.json
@@ -1,4 +1,71 @@
[
+ {
+ "id": "activity_1769588199653_whhfki2dz",
+ "type": "task_created",
+ "taskId": "task_20260128_8KRThU",
+ "taskTitle": "FEATURE REQUEST: Change Review Column to Blocked",
+ "details": {
+ "type": "feature-request-n30mhV",
+ "priority": "medium",
+ "project": "veritas-kanban"
+ },
+ "timestamp": "2026-01-28T08:16:39.653Z"
+ },
+ {
+ "id": "activity_1769588192436_i9tzea6mk",
+ "type": "status_changed",
+ "taskId": "task_20260128_KbDn0E",
+ "taskTitle": "US-1109: Settings UI — Tabbed panel redesign",
+ "details": {
+ "from": "todo",
+ "status": "in-progress"
+ },
+ "timestamp": "2026-01-28T08:16:32.436Z"
+ },
+ {
+ "id": "activity_1769588192387_wjoormah7",
+ "type": "comment_added",
+ "taskId": "task_20260128_gUHeIV",
+ "taskTitle": "US-1102: Feature toggle React context & hooks",
+ "details": {
+ "author": "Veritas",
+ "preview": "useFeatureSettings, useFeatureSetting, useUpdateFe..."
+ },
+ "timestamp": "2026-01-28T08:16:32.387Z"
+ },
+ {
+ "id": "activity_1769588192375_z068olrt4",
+ "type": "status_changed",
+ "taskId": "task_20260128_gUHeIV",
+ "taskTitle": "US-1102: Feature toggle React context & hooks",
+ "details": {
+ "from": "todo",
+ "status": "done"
+ },
+ "timestamp": "2026-01-28T08:16:32.375Z"
+ },
+ {
+ "id": "activity_1769588192354_kmcif9395",
+ "type": "comment_added",
+ "taskId": "task_20260128_X6nifI",
+ "taskTitle": "US-1101: Settings infrastructure & config schema expansion",
+ "details": {
+ "author": "Veritas",
+ "preview": "FeatureSettings types + defaults, deep merge in Co..."
+ },
+ "timestamp": "2026-01-28T08:16:32.354Z"
+ },
+ {
+ "id": "activity_1769588192286_615p4w08r",
+ "type": "status_changed",
+ "taskId": "task_20260128_X6nifI",
+ "taskTitle": "US-1101: Settings infrastructure & config schema expansion",
+ "details": {
+ "from": "in-progress",
+ "status": "done"
+ },
+ "timestamp": "2026-01-28T08:16:32.286Z"
+ },
{
"id": "activity_1769588154981_unig8oz3f",
"type": "comment_added",
diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx
index ac54c36a..5e4bff4f 100644
--- a/web/src/components/settings/SettingsDialog.tsx
+++ b/web/src/components/settings/SettingsDialog.tsx
@@ -1,4 +1,4 @@
-import { useState, useRef } from 'react';
+import { useState, useRef, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
+import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
@@ -27,10 +28,11 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
-import {
- useConfig,
- useAddRepo,
- useRemoveRepo,
+import { ScrollArea } from '@/components/ui/scroll-area';
+import {
+ useConfig,
+ useAddRepo,
+ useRemoveRepo,
useValidateRepoPath,
useUpdateAgents,
useSetDefaultAgent,
@@ -41,8 +43,20 @@ import {
useDeleteTemplate,
type TaskTemplate,
} from '@/hooks/useTemplates';
-import { Plus, Trash2, Check, X, Loader2, FolderGit2, Bot, Star, FileText, Download, Upload, HelpCircle, Info } from 'lucide-react';
-import type { RepoConfig, AgentConfig, AgentType, TaskPriority, TaskTypeConfig, SprintConfig, ProjectConfig } from '@veritas-kanban/shared';
+import {
+ useFeatureSettings,
+ useDebouncedFeatureUpdate,
+} from '@/hooks/useFeatureSettings';
+import {
+ Plus, Trash2, Check, X, Loader2, FolderGit2, Bot, Star, FileText,
+ Download, Upload, HelpCircle, Info, Settings2, Layout, ListTodo,
+ Cpu, Database, Bell, Archive, RotateCcw, Save,
+} from 'lucide-react';
+import type {
+ RepoConfig, AgentConfig, AgentType, TaskPriority,
+ TaskTypeConfig, SprintConfig, ProjectConfig,
+} from '@veritas-kanban/shared';
+import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import { cn } from '@/lib/utils';
import { TEMPLATE_CATEGORIES, getCategoryIcon, getCategoryLabel } from '@/lib/template-categories';
import { exportAllTemplates, parseTemplateFile, checkDuplicateName } from '@/lib/template-io';
@@ -51,36 +65,956 @@ import { useProjectsManager, AVAILABLE_PROJECT_COLORS } from '@/hooks/useProject
import { useSprintsManager } from '@/hooks/useSprints';
import { ManagedListManager } from './ManagedListManager';
+// ============ Tab Configuration ============
+
+type TabId = 'general' | 'board' | 'tasks' | 'agents' | 'data' | 'notifications' | 'manage';
+
+interface TabDef {
+ id: TabId;
+ label: string;
+ icon: React.ElementType;
+}
+
+const TABS: TabDef[] = [
+ { id: 'general', label: 'General', icon: Settings2 },
+ { id: 'board', label: 'Board', icon: Layout },
+ { id: 'tasks', label: 'Tasks', icon: ListTodo },
+ { id: 'agents', label: 'Agents', icon: Cpu },
+ { id: 'data', label: 'Data', icon: Database },
+ { id: 'notifications', label: 'Notifications', icon: Bell },
+ { id: 'manage', label: 'Manage', icon: Archive },
+];
+
+// ============ Settings Dialog Props ============
+
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
+// ============ Shared Setting Row Components ============
+
+function SettingRow({ label, description, children }: {
+ label: string;
+ description?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
{label}
+ {description && (
+
{description}
+ )}
+
+
{children}
+
+ );
+}
+
+function ToggleRow({ label, description, checked, onCheckedChange }: {
+ label: string;
+ description?: string;
+ checked: boolean;
+ onCheckedChange: (v: boolean) => void;
+}) {
+ return (
+
+
+
+ );
+}
+
+function NumberRow({ label, description, value, onChange, min, max, unit }: {
+ label: string;
+ description?: string;
+ value: number;
+ onChange: (v: number) => void;
+ min?: number;
+ max?: number;
+ unit?: string;
+}) {
+ return (
+
+
+ {
+ const v = parseInt(e.target.value, 10);
+ if (!isNaN(v)) {
+ const clamped = Math.max(min ?? -Infinity, Math.min(max ?? Infinity, v));
+ onChange(clamped);
+ }
+ }}
+ min={min}
+ max={max}
+ className="w-20 h-8 text-right"
+ />
+ {unit && {unit}}
+
+
+ );
+}
+
+function SectionHeader({ title, onReset }: { title: string; onReset?: () => void }) {
+ return (
+
+
{title}
+ {onReset && (
+
+
+
+
+
+
+ Reset to defaults?
+
+ This will reset all {title.toLowerCase()} settings to their default values.
+
+
+
+ Cancel
+ Reset
+
+
+
+ )}
+
+ );
+}
+
+function SaveIndicator({ isPending }: { isPending: boolean }) {
+ const [showSaved, setShowSaved] = useState(false);
+ const [wasPending, setWasPending] = useState(false);
+
+ useEffect(() => {
+ if (isPending) {
+ setWasPending(true);
+ } else if (wasPending) {
+ setShowSaved(true);
+ setWasPending(false);
+ const timer = setTimeout(() => setShowSaved(false), 1500);
+ return () => clearTimeout(timer);
+ }
+ }, [isPending, wasPending]);
+
+ if (isPending) {
+ return (
+
+
+ Saving...
+
+ );
+ }
+ if (showSaved) {
+ return (
+
+
+ Saved
+
+ );
+ }
+ return null;
+}
+
+// ============ Tab Panels ============
+
+// --- General Tab ---
+function GeneralTab() {
+ const { data: config, isLoading } = useConfig();
+ const [showAddForm, setShowAddForm] = useState(false);
+
+ return (
+
+ {/* Repositories */}
+
+
+
Git Repositories
+ {!showAddForm && (
+
+ )}
+
+ {showAddForm &&
setShowAddForm(false)} />}
+ {isLoading ? (
+ Loading...
+ ) : config?.repos.length === 0 ? (
+
+ No repositories configured.
+
+ ) : (
+
+ {config?.repos.map((repo) => )}
+
+ )}
+
+
+ {/* Default Agent */}
+
+
Default Agent
+ {isLoading ? (
+
Loading...
+ ) : (
+
+ {config?.agents.filter(a => a.enabled).map((agent) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function AgentDefaultItem({ agent, isDefault }: { agent: AgentConfig; isDefault: boolean }) {
+ const setDefaultAgent = useSetDefaultAgent();
+ return (
+
+
+
+ {agent.name}
+
+
+
+ );
+}
+
+// --- Board Tab ---
+function BoardTab() {
+ const { settings } = useFeatureSettings();
+ const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
+
+ const update = (key: string, value: any) => {
+ debouncedUpdate({ board: { [key]: value } });
+ };
+
+ const resetBoard = () => {
+ debouncedUpdate({ board: DEFAULT_FEATURE_SETTINGS.board });
+ };
+
+ return (
+
+
+
+
+
+
+ update('showDashboard', v)}
+ />
+ update('showArchiveSuggestions', v)}
+ />
+
+
+
+ update('showPriorityIndicators', v)}
+ />
+ update('showProjectBadges', v)}
+ />
+ update('showSprintBadges', v)}
+ />
+ update('enableDragAndDrop', v)}
+ />
+
+
+ );
+}
+
+// --- Tasks Tab ---
+function TasksTab() {
+ const { settings } = useFeatureSettings();
+ const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
+
+ const update = (key: string, value: any) => {
+ debouncedUpdate({ tasks: { [key]: value } });
+ };
+
+ const resetTasks = () => {
+ debouncedUpdate({ tasks: DEFAULT_FEATURE_SETTINGS.tasks });
+ };
+
+ return (
+
+
+
+
+
+
+ update('enableTimeTracking', v)}
+ />
+ update('enableSubtaskAutoComplete', v)}
+ />
+ update('enableDependencies', v)}
+ />
+ update('enableAttachments', v)}
+ />
+ {settings.tasks.enableAttachments && (
+ <>
+ update('attachmentMaxFileSize', v * 1024 * 1024)}
+ min={1}
+ max={100}
+ unit="MB"
+ />
+ update('attachmentMaxPerTask', v)}
+ min={1}
+ max={100}
+ />
+ update('attachmentMaxTotalSize', v * 1024 * 1024)}
+ min={1}
+ max={500}
+ unit="MB"
+ />
+ >
+ )}
+ update('enableComments', v)}
+ />
+
+
+
+
+
+ );
+}
+
+// --- Agents Tab ---
+function AgentsTab() {
+ const { data: config, isLoading } = useConfig();
+ const { settings } = useFeatureSettings();
+ const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
+ const updateAgents = useUpdateAgents();
+
+ const update = (key: string, value: any) => {
+ debouncedUpdate({ agents: { [key]: value } });
+ };
+
+ const handleToggleAgent = (agentType: AgentType) => {
+ if (!config) return;
+ const updatedAgents = config.agents.map(a =>
+ a.type === agentType ? { ...a, enabled: !a.enabled } : a
+ );
+ updateAgents.mutate(updatedAgents);
+ };
+
+ const resetAgents = () => {
+ debouncedUpdate({ agents: DEFAULT_FEATURE_SETTINGS.agents });
+ };
+
+ return (
+
+ {/* Agent List */}
+
+
Installed Agents
+ {isLoading ? (
+
Loading...
+ ) : (
+
+ {config?.agents.map((agent) => (
+
handleToggleAgent(agent.type)}
+ />
+ ))}
+
+ )}
+
+
+ {/* Agent Behavior */}
+
+
+
+
+
+
+ update('timeoutMinutes', v)}
+ min={5}
+ max={480}
+ unit="min"
+ />
+ update('autoCommitOnComplete', v)}
+ />
+ update('autoCleanupWorktrees', v)}
+ />
+ update('enablePreview', v)}
+ />
+
+
+
+ );
+}
+
+function AgentToggleItem({ agent, onToggle }: { agent: AgentConfig; onToggle: () => void }) {
+ return (
+
+
+
+
+
+ {agent.name}
+
+
+ {agent.command} {agent.args.join(' ')}
+
+
+
+
+
+ );
+}
+
+// --- Data Tab ---
+function DataTab() {
+ const { settings } = useFeatureSettings();
+ const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
+
+ const updateTelemetry = (key: string, value: any) => {
+ debouncedUpdate({ telemetry: { [key]: value } });
+ };
+
+ const updateArchive = (key: string, value: any) => {
+ debouncedUpdate({ archive: { [key]: value } });
+ };
+
+ const resetData = () => {
+ debouncedUpdate({
+ telemetry: DEFAULT_FEATURE_SETTINGS.telemetry,
+ archive: DEFAULT_FEATURE_SETTINGS.archive,
+ });
+ };
+
+ return (
+
+
+
+
+
+
+ {/* Telemetry */}
+
+ updateTelemetry('enabled', v)}
+ />
+ {settings.telemetry.enabled && (
+ <>
+ updateTelemetry('retentionDays', v)}
+ min={7}
+ max={365}
+ unit="days"
+ />
+ updateTelemetry('enableTraces', v)}
+ />
+ updateTelemetry('enableActivityTracking', v)}
+ />
+ >
+ )}
+
+
+ {/* Archive */}
+
+
Archive
+
+ updateArchive('autoArchiveEnabled', v)}
+ />
+ {settings.archive.autoArchiveEnabled && (
+ updateArchive('autoArchiveAfterDays', v)}
+ min={1}
+ max={365}
+ unit="days"
+ />
+ )}
+
+
+
+ );
+}
+
+// --- Notifications Tab ---
+function NotificationsTab() {
+ const { settings } = useFeatureSettings();
+ const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
+
+ const update = (key: string, value: any) => {
+ debouncedUpdate({ notifications: { [key]: value } });
+ };
+
+ const resetNotifications = () => {
+ debouncedUpdate({ notifications: DEFAULT_FEATURE_SETTINGS.notifications });
+ };
+
+ return (
+
+
+
+
+
+
+ update('enabled', v)}
+ />
+ {settings.notifications.enabled && (
+ <>
+ update('onTaskComplete', v)}
+ />
+ update('onAgentFailure', v)}
+ />
+ update('onReviewNeeded', v)}
+ />
+
+ update('channel', e.target.value)}
+ placeholder="19:abc...@thread.tacv2"
+ className="w-48 h-8 text-xs"
+ />
+
+ >
+ )}
+
+
+ );
+}
+
+// --- Manage Tab (existing managed lists + templates) ---
+function ManageTab() {
+ const { data: _config } = useConfig();
+ const { data: templates, isLoading: templatesLoading } = useTemplates();
+ const taskTypesManager = useTaskTypesManager();
+ const projectsManager = useProjectsManager();
+ const sprintsManager = useSprintsManager();
+ const [showAddTemplateForm, setShowAddTemplateForm] = useState(false);
+ const [showTemplateHelp, setShowTemplateHelp] = useState(false);
+ const createTemplate = useCreateTemplate();
+ const fileInputRef = useRef(null);
+
+ const handleExportTemplates = () => {
+ if (!templates || templates.length === 0) {
+ alert('No templates to export.');
+ return;
+ }
+ exportAllTemplates(templates);
+ };
+
+ const handleImportClick = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileSelect = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ try {
+ const parsed = await parseTemplateFile(file);
+ const templatesToImport = Array.isArray(parsed) ? parsed : [parsed];
+ let imported = 0;
+ let skipped = 0;
+ for (const template of templatesToImport) {
+ if (checkDuplicateName(template.name, templates || [])) {
+ skipped++;
+ continue;
+ }
+ await createTemplate.mutateAsync({
+ name: template.name,
+ description: template.description,
+ category: template.category,
+ taskDefaults: template.taskDefaults,
+ subtaskTemplates: template.subtaskTemplates,
+ blueprint: template.blueprint,
+ });
+ imported++;
+ }
+ alert(`Import complete: ${imported} imported${skipped > 0 ? `, ${skipped} duplicates skipped` : ''}.`);
+ } catch (err) {
+ alert(`Import failed: ${err instanceof Error ? err.message : 'Invalid file'}`);
+ } finally {
+ if (fileInputRef.current) fileInputRef.current.value = '';
+ }
+ };
+
+ return (
+
+ {/* Task Types */}
+
+
Task Types
+
+
+ title=""
+ items={taskTypesManager.items}
+ isLoading={taskTypesManager.isLoading}
+ onCreate={taskTypesManager.create}
+ onUpdate={taskTypesManager.update}
+ onDelete={taskTypesManager.remove}
+ onReorder={taskTypesManager.reorder}
+ canDeleteCheck={taskTypesManager.canDelete}
+ renderExtraFields={(item, onChange) => (
+
+
+
+
+
+
+
+
+
+
+ )}
+ newItemDefaults={{ icon: 'Code', color: 'border-l-gray-500' }}
+ />
+
+
+
+ {/* Projects */}
+
+
Projects
+
+
+ title=""
+ items={projectsManager.items}
+ isLoading={projectsManager.isLoading}
+ onCreate={projectsManager.create}
+ onUpdate={projectsManager.update}
+ onDelete={projectsManager.remove}
+ onReorder={projectsManager.reorder}
+ canDeleteCheck={projectsManager.canDelete}
+ renderExtraFields={(item, onChange) => (
+
+
+
+ onChange({ description: e.target.value })}
+ placeholder="Optional description..."
+ className="h-8 mt-1"
+ />
+
+
+
+
+
+
+ )}
+ newItemDefaults={{ description: '', color: 'bg-blue-500/20' }}
+ />
+
+
+
+ {/* Sprints */}
+
+
+ {/* Templates */}
+
+
+
+
Task Templates
+
+
+
+
+ {templates && templates.length > 0 && (
+
+ )}
+ {!showAddTemplateForm && (
+
+ )}
+
+
+
+ {showTemplateHelp && (
+
+
+
+
+
Template Guide
+
+
Simple: Pre-fill fields + subtask lists
+
Categories: Bug 🐛, Feature ✨, Sprint 🔄
+
Variables: {'{{date}}'}, {'{{project}}'}, {'{{custom}}'}
+
Blueprints: Multi-task with dependencies
+
+
+
+
+ )}
+
+
+
+ {showAddTemplateForm && (
+
setShowAddTemplateForm(false)} />
+ )}
+
+ {templatesLoading ? (
+ Loading...
+ ) : !templates || templates.length === 0 ? (
+
+ No templates created.
+
+ ) : (
+
+ {templates.map((template) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+// ============ Shared Sub-Components (extracted from original) ============
+
function AddRepoForm({ onClose }: { onClose: () => void }) {
const [name, setName] = useState('');
const [path, setPath] = useState('');
const [defaultBranch, setDefaultBranch] = useState('main');
const [branches, setBranches] = useState([]);
const [pathValid, setPathValid] = useState(null);
-
const addRepo = useAddRepo();
const validatePath = useValidateRepoPath();
const handleValidatePath = async () => {
if (!path) return;
-
try {
const result = await validatePath.mutateAsync(path);
setPathValid(result.valid);
setBranches(result.branches);
-
- if (result.branches.includes('main')) {
- setDefaultBranch('main');
- } else if (result.branches.includes('master')) {
- setDefaultBranch('master');
- } else if (result.branches.length > 0) {
- setDefaultBranch(result.branches[0]);
- }
+ if (result.branches.includes('main')) setDefaultBranch('main');
+ else if (result.branches.includes('master')) setDefaultBranch('master');
+ else if (result.branches.length > 0) setDefaultBranch(result.branches[0]);
} catch {
setPathValid(false);
setBranches([]);
@@ -97,89 +1031,44 @@ function AddRepoForm({ onClose }: { onClose: () => void }) {
return (