From 55b72a2c75a9db8e86acdf5dec5e9dded89eec86 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Wed, 28 Jan 2026 02:19:45 -0600 Subject: [PATCH] feat(US-1109): tabbed settings panel redesign - Vertical sidebar navigation with 7 tabs: General, Board, Tasks, Agents, Data, Notifications, Manage - Toggle rows with Switch components + descriptive labels - Number inputs with min/max validation and unit labels - Auto-save with debounced 500ms updates + save indicator - Reset to Defaults button per section with confirmation - Mobile: tabs collapse to dropdown selector - Keyboard navigation between tabs (arrow keys) - Existing managed lists (task types, projects, sprints, templates) moved to Manage tab --- .../telemetry/events-2026-01-28.ndjson | 4 + server/.veritas-kanban/activity.json | 67 + .../components/settings/SettingsDialog.tsx | 1667 +++++++++++------ 3 files changed, 1130 insertions(+), 608 deletions(-) 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 */} +
+

Sprints

+
+ + title="" + items={sprintsManager.items} + isLoading={sprintsManager.isLoading} + onCreate={sprintsManager.create} + onUpdate={sprintsManager.update} + onDelete={sprintsManager.remove} + onReorder={sprintsManager.reorder} + canDeleteCheck={sprintsManager.canDelete} + renderExtraFields={(item, onChange) => ( +
+ + onChange({ description: e.target.value })} + placeholder="Optional description..." + className="h-8 mt-1" + /> +
+ )} + newItemDefaults={{ description: '' }} + /> +
+
+ + {/* 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 (
- - Add Repository + Add Repository
-
- setName(e.target.value)} - placeholder="e.g., rubicon" - /> + setName(e.target.value)} placeholder="e.g., rubicon" />
-
{ - setPath(e.target.value); - setPathValid(null); - setBranches([]); - }} + onChange={(e) => { setPath(e.target.value); setPathValid(null); setBranches([]); }} placeholder="e.g., ~/Projects/rubicon" - className={cn( - pathValid === true && 'border-green-500', - pathValid === false && 'border-red-500' - )} + className={cn(pathValid === true && 'border-green-500', pathValid === false && 'border-red-500')} /> -
- {pathValid === false && ( -

- {validatePath.error?.message || 'Invalid path'} -

- )} + {pathValid === false &&

{validatePath.error?.message || 'Invalid path'}

}
- {branches.length > 0 && (
)}
-
- - +
@@ -189,7 +1078,6 @@ function AddRepoForm({ onClose }: { onClose: () => void }) { function RepoItem({ repo }: { repo: RepoConfig }) { const removeRepo = useRemoveRepo(); - return (
@@ -200,9 +1088,7 @@ function RepoItem({ repo }: { repo: RepoConfig }) {
- - {repo.defaultBranch} - + {repo.defaultBranch}