feat: establish Settings layout system (#1321)

This commit is contained in:
Brad Groux 2026-09-02 19:27:50 -05:00 • committed by GitHub
parent 06c0bd848d
commit 400b9dce36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1025 additions and 449 deletions

View file

@ -0,0 +1,35 @@
# Settings information architecture
This document is the reviewed navigation and layout contract for Settings. It keeps every existing destination available while replacing the flat 20-item list with stable task-oriented families.
## Navigation map
| Family | Destinations | Purpose |
| ------------- | --------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Core | General, Board, Tasks, Agents, Data | Everyday product behavior, execution defaults, and operational data |
| Collaboration | Notifications, Multi-user, Workspaces, Delegation | People, communication, workspace membership, and delegated work |
| Automation | Scheduler, Queues, Reflections, Trackers | Recurring work, queue observation, learning loops, and external tracking |
| Governance | Security, Tool Policies, Enforcement, Shared Resources, Doc Freshness | Trust boundaries, policy, shared authority, and controlled knowledge |
| System | Maintenance, Manage | Installation health, recovery, and managed taxonomies |
The family and destination order is canonical across desktop and compact navigation. Permission checks disable unavailable destinations without removing their location. `defaultTab` resolves only to an allowed destination and otherwise returns to the first allowed item.
In Board Only mode, General, Board, and Tasks are marked as the primary path. Every other destination remains in its normal family and carries an Advanced label. This preserves discovery and administrative access without giving the full advanced inventory equal emphasis.
## Layout contract
- `SettingsPage` provides one page title, short purpose statement, optional page-level status or actions, a consistent content width, and the page spacing rhythm.
- `SettingsSection` provides the visible restart for one logical workflow: restrained border, neutral surface, heading, description, status, actions, and optional reset confirmation. Advanced and danger tones are semantic exceptions rather than decorative color.
- `SettingRow` uses one responsive label/description and control grid. At narrow widths the control stacks below the label; at desktop widths controls share a stable column.
- `SettingsFieldGrid` arranges related multi-field forms in one or two columns without horizontal scrolling.
- `SettingsStatusCard` uses labeled neutral, success, warning, or error state. Color is supplemental to the icon, title, and description.
- `SettingsLocalNav` is reserved for related sections on a long page. It uses in-page anchors, remains horizontally scrollable at narrow widths, and does not promote subsections into global destinations.
- `SettingsHelpText`, `SettingsErrorText`, `SettingsUnit`, and `SettingsActionGroup` standardize secondary explanation, validation, numeric units, routine transfer actions, and destructive actions.
General, Board, Tasks, and Data are the reference migrations. Data demonstrates continuous local navigation; simpler pages omit it. Agents, Notifications, Multi-user, and Maintenance require dedicated workflow decomposition before migration because each combines several independent operational surfaces.
## Focus and responsive behavior
Changing a primary destination focuses and scrolls the page content to its heading. Arrow keys move through allowed destinations in canonical order. The sidebar scrolls independently at compact heights, while transfer and danger actions remain separated at its end. Compact layouts use the same grouped destination data in a select control.
The dialog grows to a wider desktop maximum while retaining viewport bounds. Page content is capped at a readable width. Rows and field grids stack below the small-screen breakpoint, semantic text remains visible at increased text size, and no section requires horizontal page scrolling.

View file

@ -1,12 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, screen } from '@testing-library/react';
import { SettingsDialog } from '@/components/settings/SettingsDialog';
import { SETTINGS_NAVIGATION_GROUPS, SettingsDialog } from '@/components/settings/SettingsDialog';
import { renderWithProviders } from './test-utils';
const mocks = vi.hoisted(() => ({
debouncedUpdate: vi.fn(),
hasPermission: vi.fn(),
toast: vi.fn(),
productMode: { selectedMode: 'advanced' as string },
}));
vi.mock('@/hooks/useFeatureSettings', () => ({
@ -22,6 +23,7 @@ vi.mock('@/hooks/useFeatureSettings', () => ({
docFreshness: {},
archive: {},
sharedResources: {},
productMode: mocks.productMode,
},
}),
useDebouncedFeatureUpdate: () => ({ debouncedUpdate: mocks.debouncedUpdate }),
@ -98,6 +100,7 @@ vi.mock('@/components/settings/tabs/MultiUserTab', () => ({
describe('SettingsDialog Mantine shell', () => {
beforeEach(() => {
mocks.hasPermission.mockReturnValue(true);
mocks.productMode.selectedMode = 'advanced';
window.HTMLElement.prototype.scrollIntoView = vi.fn();
});
@ -117,6 +120,42 @@ describe('SettingsDialog Mantine shell', () => {
expect(baseElement.querySelector('.mantine-Select-root')).toBeDefined();
});
it('groups every destination exactly once and separates destructive actions', async () => {
const { baseElement } = renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
await screen.findByText('General settings loaded');
const tabIds = SETTINGS_NAVIGATION_GROUPS.flatMap((group) => group.tabs.map((tab) => tab.id));
expect(tabIds).toHaveLength(20);
expect(new Set(tabIds).size).toBe(20);
expect(baseElement.querySelectorAll('[data-settings-nav-group]')).toHaveLength(5);
expect(baseElement.querySelector('[data-settings-actions="routine"]')).not.toBeNull();
expect(baseElement.querySelector('[data-settings-actions="danger"]')).not.toBeNull();
});
it('keeps advanced destinations visible but de-emphasized in Board Only mode', async () => {
mocks.productMode.selectedMode = 'board-only';
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
await screen.findByText('General settings loaded');
expect(screen.getByText('Board Only')).toBeDefined();
expect(
screen.getByRole('tab', { name: 'Board' }).getAttribute('data-board-only-priority')
).toBe('primary');
expect(
screen.getByRole('tab', { name: 'Agents' }).getAttribute('data-board-only-priority')
).toBe('advanced');
});
it('skips permission-disabled destinations during keyboard navigation', async () => {
mocks.hasPermission.mockImplementation((permission: string) => permission !== 'agent:read');
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} defaultTab="agents" />);
const generalTab = screen.getByRole('tab', { name: 'General' });
expect(screen.getByRole('tab', { name: 'Agents' }).hasAttribute('disabled')).toBe(true);
fireEvent.keyDown(generalTab, { key: 'ArrowDown' });
expect(await screen.findByText('Board settings loaded')).toBeDefined();
});
it('switches tab content through the Mantine sidebar buttons', async () => {
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);

View file

@ -1,6 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, screen } from '@testing-library/react';
import { SectionHeader, SettingRow, ToggleRow } from '@/components/settings/shared';
import {
SectionHeader,
SettingRow,
SettingsActionGroup,
SettingsErrorText,
SettingsFieldGrid,
SettingsHelpText,
SettingsLocalNav,
SettingsPage,
SettingsSection,
SettingsStatusCard,
ToggleRow,
} from '@/components/settings/shared';
import { renderWithProviders } from './test-utils';
describe('Settings shared Mantine rows', () => {
@ -8,7 +20,7 @@ describe('Settings shared Mantine rows', () => {
cleanup();
});
it('renders setting rows with direct Mantine layout and text primitives', () => {
it('renders responsive setting rows with a stable control column', () => {
const { container } = renderWithProviders(
<SettingRow label="Example setting" description="Helpful context">
<span>Control</span>
@ -17,10 +29,50 @@ describe('Settings shared Mantine rows', () => {
expect(screen.getByText('Example setting')).toBeDefined();
expect(screen.getByText('Helpful context')).toBeDefined();
expect(container.querySelector('.mantine-Group-root')).toBeDefined();
expect(container.querySelector('[data-settings-row]')?.className).toContain('sm:grid-cols-');
expect(container.querySelector('.mantine-Text-root')).toBeDefined();
});
it('provides the page, section, field-grid, and local-navigation layout contract', () => {
const { container } = renderWithProviders(
<SettingsPage title="Example" description="Page context">
<SettingsLocalNav label="Example sections" items={[{ id: 'section-one', label: 'One' }]} />
<SettingsSection id="section-one" title="Section one" description="Section context">
<SettingsFieldGrid>
<span>Field one</span>
<span>Field two</span>
</SettingsFieldGrid>
</SettingsSection>
</SettingsPage>
);
expect(screen.getByRole('heading', { name: 'Example' })).toBeDefined();
expect(screen.getByRole('heading', { name: 'Section one' })).toBeDefined();
expect(screen.getByRole('navigation', { name: 'Example sections' })).toBeDefined();
expect(screen.getByRole('link', { name: 'One' }).getAttribute('href')).toBe('#section-one');
expect(container.querySelector('[data-settings-field-grid]')?.className).toContain(
'sm:grid-cols-2'
);
});
it('defines status, help, error, and destructive-action treatments', () => {
const { container } = renderWithProviders(
<>
<SettingsStatusCard title="Connected" description="Provider is ready" tone="success" />
<SettingsHelpText>Helpful context</SettingsHelpText>
<SettingsErrorText>Fix this field</SettingsErrorText>
<SettingsActionGroup label="Danger zone" tone="danger">
<button type="button">Reset</button>
</SettingsActionGroup>
</>
);
expect(container.querySelector('[data-settings-status="success"]')).not.toBeNull();
expect(container.querySelector('[data-settings-help]')).not.toBeNull();
expect(container.querySelector('[data-settings-error][role="alert"]')).not.toBeNull();
expect(container.querySelector('[data-settings-actions="danger"]')).not.toBeNull();
});
it('renders toggles through Mantine Switch and preserves checked changes', () => {
const onCheckedChange = vi.fn();
const { container } = renderWithProviders(

View file

@ -1,5 +1,15 @@
import { useState, useRef, useCallback, lazy, Suspense, useEffect, useMemo } from 'react';
import { Button, Group, Modal, ScrollArea, Select, Skeleton, Stack, Text } from '@mantine/core';
import {
Badge,
Button,
Group,
Modal,
ScrollArea,
Select,
Skeleton,
Stack,
Text,
} from '@mantine/core';
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
import { useIdentity } from '@/hooks/useIdentity';
import { useToast } from '@/hooks/useToast';
@ -30,7 +40,7 @@ import {
} from 'lucide-react';
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import type { ClientAuthPermission } from '@veritas-kanban/shared';
import { SettingsErrorBoundary } from './shared';
import { SettingsActionGroup, SettingsErrorBoundary } from './shared';
import { ErrorBoundary } from '@/components/shared/ErrorBoundary';
// Lazy-load tab components
@ -133,61 +143,110 @@ interface TabDef {
label: string;
icon: React.ElementType;
requiredPermission?: ClientAuthPermission;
boardOnlyPrimary?: boolean;
}
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, requiredPermission: 'agent:read' },
{ id: 'data', label: 'Data', icon: Database, requiredPermission: 'backup:read' },
{ id: 'notifications', label: 'Notifications', icon: Bell },
{ id: 'security', label: 'Security', icon: Shield, requiredPermission: 'settings:read' },
{ id: 'multi-user', label: 'Multi-user', icon: UserCog, requiredPermission: 'workspace:read' },
interface NavigationGroup {
id: 'core' | 'collaboration' | 'automation' | 'governance' | 'system';
label: string;
tabs: TabDef[];
}
export const SETTINGS_NAVIGATION_GROUPS: NavigationGroup[] = [
{
id: 'workspace-capabilities',
label: 'Workspaces',
icon: Network,
requiredPermission: 'workspace:read',
id: 'core',
label: 'Core',
tabs: [
{ id: 'general', label: 'General', icon: Settings2, boardOnlyPrimary: true },
{ id: 'board', label: 'Board', icon: Layout, boardOnlyPrimary: true },
{ id: 'tasks', label: 'Tasks', icon: ListTodo, boardOnlyPrimary: true },
{ id: 'agents', label: 'Agents', icon: Cpu, requiredPermission: 'agent:read' },
{ id: 'data', label: 'Data', icon: Database, requiredPermission: 'backup:read' },
],
},
{
id: 'scheduler',
label: 'Scheduler',
icon: CalendarClock,
requiredPermission: 'workflow:read',
id: 'collaboration',
label: 'Collaboration',
tabs: [
{ id: 'notifications', label: 'Notifications', icon: Bell },
{
id: 'multi-user',
label: 'Multi-user',
icon: UserCog,
requiredPermission: 'workspace:read',
},
{
id: 'workspace-capabilities',
label: 'Workspaces',
icon: Network,
requiredPermission: 'workspace:read',
},
{ id: 'delegation', label: 'Delegation', icon: Plane, requiredPermission: 'agent:read' },
],
},
{
id: 'queue-monitors',
label: 'Queues',
icon: ListChecks,
requiredPermission: 'workflow:read',
id: 'automation',
label: 'Automation',
tabs: [
{
id: 'scheduler',
label: 'Scheduler',
icon: CalendarClock,
requiredPermission: 'workflow:read',
},
{
id: 'queue-monitors',
label: 'Queues',
icon: ListChecks,
requiredPermission: 'workflow:read',
},
{
id: 'reflections',
label: 'Reflections',
icon: BrainCircuit,
requiredPermission: 'workflow:read',
},
{
id: 'trackers',
label: 'Trackers',
icon: Waypoints,
requiredPermission: 'settings:read',
},
],
},
{
id: 'reflections',
label: 'Reflections',
icon: BrainCircuit,
requiredPermission: 'workflow:read',
id: 'governance',
label: 'Governance',
tabs: [
{ id: 'security', label: 'Security', icon: Shield, requiredPermission: 'settings:read' },
{
id: 'tool-policies',
label: 'Tool Policies',
icon: Lock,
requiredPermission: 'policy:read',
},
{
id: 'enforcement',
label: 'Enforcement',
icon: CheckCircle2,
requiredPermission: 'policy:read',
},
{ id: 'shared-resources', label: 'Shared Resources', icon: Boxes },
{ id: 'doc-freshness', label: 'Doc Freshness', icon: BookOpen },
],
},
{
id: 'trackers',
label: 'Trackers',
icon: Waypoints,
requiredPermission: 'settings:read',
id: 'system',
label: 'System',
tabs: [
{ id: 'maintenance', label: 'Maintenance', icon: Wrench, requiredPermission: 'backup:read' },
{ id: 'manage', label: 'Manage', icon: Archive, requiredPermission: 'backup:read' },
],
},
{ id: 'maintenance', label: 'Maintenance', icon: Wrench, requiredPermission: 'backup:read' },
{ id: 'delegation', label: 'Delegation', icon: Plane, requiredPermission: 'agent:read' },
{ id: 'tool-policies', label: 'Tool Policies', icon: Lock, requiredPermission: 'policy:read' },
{
id: 'enforcement',
label: 'Enforcement',
icon: CheckCircle2,
requiredPermission: 'policy:read',
},
{ id: 'shared-resources', label: 'Shared Resources', icon: Boxes },
{ id: 'doc-freshness', label: 'Doc Freshness', icon: BookOpen },
{ id: 'manage', label: 'Manage', icon: Archive, requiredPermission: 'backup:read' },
];
const TABS = SETTINGS_NAVIGATION_GROUPS.flatMap((group) => group.tabs);
// ============ Settings Dialog Props ============
interface SettingsDialogProps {
@ -201,19 +260,24 @@ interface SettingsDialogProps {
export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialogProps) {
const [activeTab, setActiveTab] = useState<TabId>('general');
const { hasPermission } = useIdentity();
const { settings: currentSettings } = useFeatureSettings();
const canWriteSettings = hasPermission('settings:write');
const canUseTab = useCallback(
(tab: TabDef) => !tab.requiredPermission || hasPermission(tab.requiredPermission),
[hasPermission]
);
const isBoardOnly = currentSettings.productMode?.selectedMode === 'board-only';
const mobileTabOptions = useMemo(
() =>
TABS.map((tab) => ({
value: tab.id,
label: tab.label,
disabled: !canUseTab(tab),
SETTINGS_NAVIGATION_GROUPS.map((group) => ({
group: group.label,
items: group.tabs.map((tab) => ({
value: tab.id,
label: `${tab.label}${isBoardOnly && !tab.boardOnlyPrimary ? ' · Advanced' : ''}`,
disabled: !canUseTab(tab),
})),
})),
[canUseTab]
[canUseTab, isBoardOnly]
);
// Set active tab when defaultTab changes
@ -230,7 +294,6 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
setActiveTab(TABS.find(canUseTab)?.id ?? 'general');
}
}, [activeTab, canUseTab]);
const { settings: currentSettings } = useFeatureSettings();
const { debouncedUpdate } = useDebouncedFeatureUpdate();
const settingsFileInputRef = useRef<HTMLInputElement>(null);
const { toast } = useToast();
@ -251,6 +314,7 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
useEffect(() => {
if (contentAreaRef.current) {
contentAreaRef.current.focus();
contentAreaRef.current.scrollIntoView({ block: 'start' });
}
}, [activeTab]);
@ -522,7 +586,7 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
opened={open}
onClose={() => onOpenChange(false)}
title={<span className="sr-only">Settings</span>}
size={800}
size={1040}
padding={0}
centered
trapFocus
@ -545,9 +609,21 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
onKeyDown={handleDialogKeyDown}
>
{/* Sidebar Tabs — hidden on narrow screens, shown as dropdown instead */}
<div className="hidden min-h-0 w-48 flex-col border-r bg-muted/30 py-4 sm:flex">
<div className="hidden min-h-0 w-56 flex-col border-r bg-muted/25 py-4 sm:flex">
<div className="px-4 pb-3">
<h2 className="text-sm font-semibold">Settings</h2>
<Group gap="xs">
<h2 className="text-sm font-semibold">Settings</h2>
{isBoardOnly && (
<Badge size="xs" variant="light" color="cyan">
Board Only
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" mt={4}>
{isBoardOnly
? 'Board essentials first. Advanced settings remain available.'
: 'Workspace configuration and governance.'}
</Text>
</div>
<nav
className="min-h-0 flex-1 space-y-0.5 overflow-y-auto px-2 pr-1"
@ -555,41 +631,78 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
aria-orientation="vertical"
onKeyDown={handleKeyDown}
>
<Stack gap={4}>
{TABS.map((tab, index) => {
const Icon = tab.icon;
const allowed = canUseTab(tab);
const active = activeTab === tab.id;
return (
<Button
key={tab.id}
id={`tab-${tab.id}`}
ref={index === 0 ? firstTabButtonRef : undefined}
type="button"
role="tab"
aria-selected={active}
aria-controls="settings-tab-content"
tabIndex={active ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
disabled={!allowed}
title={allowed ? tab.label : `${tab.requiredPermission} permission required`}
variant={active ? 'light' : 'subtle'}
color={active ? 'violet' : 'gray'}
<Stack gap="sm">
{SETTINGS_NAVIGATION_GROUPS.map((group) => (
<div
key={group.id}
role="group"
aria-labelledby={`settings-group-${group.id}`}
data-settings-nav-group={group.id}
>
<Text
id={`settings-group-${group.id}`}
size="xs"
radius="md"
fullWidth
justify="flex-start"
leftSection={<Icon className="h-4 w-4 flex-shrink-0" />}
c="dimmed"
fw={700}
tt="uppercase"
px="xs"
mb={3}
>
{tab.label}
</Button>
);
})}
{group.label}
</Text>
<Stack gap={2}>
{group.tabs.map((tab) => {
const Icon = tab.icon;
const allowed = canUseTab(tab);
const active = activeTab === tab.id;
const advancedInBoardOnly = isBoardOnly && !tab.boardOnlyPrimary;
return (
<Button
key={tab.id}
id={`tab-${tab.id}`}
ref={tab.id === 'general' ? firstTabButtonRef : undefined}
type="button"
role="tab"
aria-label={tab.label}
aria-selected={active}
aria-controls="settings-tab-content"
tabIndex={active ? 0 : -1}
onClick={() => setActiveTab(tab.id)}
disabled={!allowed}
title={
allowed ? tab.label : `${tab.requiredPermission} permission required`
}
variant={active ? 'light' : 'subtle'}
color={active ? 'violet' : 'gray'}
size="xs"
radius="md"
fullWidth
justify="flex-start"
leftSection={<Icon className="h-4 w-4 flex-shrink-0" />}
data-board-only-priority={advancedInBoardOnly ? 'advanced' : 'primary'}
>
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="truncate">{tab.label}</span>
{advancedInBoardOnly && (
<span
className="rounded bg-muted px-1 py-0.5 text-[9px] font-bold uppercase tracking-wide text-muted-foreground"
aria-hidden="true"
>
Advanced
</span>
)}
</span>
</Button>
);
})}
</Stack>
</div>
))}
</Stack>
</nav>
{/* Import/Export/Reset */}
<Stack gap={4} className="mt-auto shrink-0 border-t px-3 pt-3 pb-6">
<Stack gap="sm" className="mt-auto shrink-0 border-t px-3 pt-3 pb-6">
<input
ref={settingsFileInputRef}
type="file"
@ -598,46 +711,52 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
className="hidden"
aria-label="Import settings file"
/>
<Button
type="button"
onClick={handleExportSettings}
aria-label="Export settings as JSON file"
variant="subtle"
color="gray"
size="xs"
fullWidth
justify="flex-start"
leftSection={<Download className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />}
>
Export Settings
</Button>
<Button
type="button"
onClick={() => settingsFileInputRef.current?.click()}
disabled={!canWriteSettings}
aria-label="Import settings from JSON file"
variant="subtle"
color="gray"
size="xs"
fullWidth
justify="flex-start"
leftSection={<Upload className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />}
>
Import Settings
</Button>
<Button
type="button"
disabled={!canWriteSettings}
variant="subtle"
color="red"
size="xs"
fullWidth
justify="flex-start"
leftSection={<RotateCcw className="h-3.5 w-3.5 flex-shrink-0" />}
onClick={() => setResetAllOpen(true)}
>
Reset All
</Button>
<SettingsActionGroup label="Transfer">
<Button
type="button"
onClick={handleExportSettings}
aria-label="Export settings as JSON file"
variant="subtle"
color="gray"
size="xs"
fullWidth
justify="flex-start"
leftSection={
<Download className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />
}
>
Export Settings
</Button>
<Button
type="button"
onClick={() => settingsFileInputRef.current?.click()}
disabled={!canWriteSettings}
aria-label="Import settings from JSON file"
variant="subtle"
color="gray"
size="xs"
fullWidth
justify="flex-start"
leftSection={<Upload className="h-3.5 w-3.5 flex-shrink-0" aria-hidden="true" />}
>
Import Settings
</Button>
</SettingsActionGroup>
<SettingsActionGroup label="Danger zone" tone="danger">
<Button
type="button"
disabled={!canWriteSettings}
variant="subtle"
color="red"
size="xs"
fullWidth
justify="flex-start"
leftSection={<RotateCcw className="h-3.5 w-3.5 flex-shrink-0" />}
onClick={() => setResetAllOpen(true)}
>
Reset All
</Button>
</SettingsActionGroup>
</Stack>
</div>
@ -662,7 +781,7 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
<div
id="settings-tab-content"
ref={contentAreaRef}
className="px-4 py-4 focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2 sm:px-6"
className="mx-auto w-full max-w-3xl px-4 py-4 focus-visible:outline-2 focus-visible:outline-primary focus-visible:-outline-offset-2 sm:px-6 sm:py-6"
role="tabpanel"
tabIndex={-1}
aria-labelledby={`tab-${activeTab}`}

View file

@ -1,6 +1,7 @@
import { memo, useState, useEffect } from 'react';
import { NumberInput } from '@mantine/core';
import { SettingRow } from './SettingRow';
import { SettingsUnit } from './SettingsLayout';
export const NumberRow = memo(function NumberRow({
label,
@ -83,7 +84,7 @@ export const NumberRow = memo(function NumberRow({
className="w-28"
styles={{ input: { textAlign: 'right' } }}
/>
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
{unit && <SettingsUnit>{unit}</SettingsUnit>}
</div>
</SettingRow>
);
@ -107,7 +108,7 @@ export const NumberRow = memo(function NumberRow({
className="w-24"
styles={{ input: { textAlign: 'right' } }}
/>
{unit && <span className="text-xs text-muted-foreground">{unit}</span>}
{unit && <SettingsUnit>{unit}</SettingsUnit>}
</div>
</SettingRow>
);

View file

@ -1,8 +1,25 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import { Button, Group, Modal, Stack, Text } from '@mantine/core';
import { RotateCcw } from 'lucide-react';
export function SectionHeader({ title, onReset }: { title: string; onReset?: () => void }) {
export function SectionHeader({
id,
title,
description,
status,
actions,
onReset,
contained = false,
}: {
id?: string;
title: string;
description?: string;
status?: ReactNode;
actions?: ReactNode;
onReset?: () => void;
contained?: boolean;
}) {
const [resetOpen, setResetOpen] = useState(false);
const handleReset = () => {
@ -11,41 +28,62 @@ export function SectionHeader({ title, onReset }: { title: string; onReset?: ()
};
return (
<Group justify="space-between" align="center" className="mb-2 border-b pb-2">
<Text size="sm" fw={600} c="dimmed" tt="uppercase">
{title}
</Text>
{onReset && (
<>
<Button
type="button"
variant="subtle"
size="xs"
color="gray"
leftSection={<RotateCcw className="h-3 w-3" />}
onClick={() => setResetOpen(true)}
>
Reset
</Button>
<Modal
opened={resetOpen}
onClose={() => setResetOpen(false)}
title="Reset to defaults?"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
This will reset all {title.toLowerCase()} settings to their default values.
</Text>
<Group justify="flex-end">
<Button variant="subtle" color="gray" onClick={() => setResetOpen(false)}>
Cancel
</Button>
<Button onClick={handleReset}>Reset</Button>
</Group>
</Stack>
</Modal>
</>
<Group
justify="space-between"
align="flex-start"
gap="md"
wrap="wrap"
className={contained ? 'mb-2 border-b pb-3' : 'mb-2 border-b pb-2'}
>
<div className="min-w-0 flex-1">
<Group gap="xs" wrap="wrap">
<Text id={id} component="h3" size="sm" fw={650}>
{title}
</Text>
{status}
</Group>
{description && (
<Text size="xs" c="dimmed" mt={3}>
{description}
</Text>
)}
</div>
{(actions || onReset) && (
<Group gap="xs">
{actions}
{onReset && (
<>
<Button
type="button"
variant="subtle"
size="xs"
color="gray"
leftSection={<RotateCcw className="h-3 w-3" />}
onClick={() => setResetOpen(true)}
>
Reset
</Button>
<Modal
opened={resetOpen}
onClose={() => setResetOpen(false)}
title="Reset to defaults?"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
This will reset all {title.toLowerCase()} settings to their default values.
</Text>
<Group justify="flex-end">
<Button variant="subtle" color="gray" onClick={() => setResetOpen(false)}>
Cancel
</Button>
<Button onClick={handleReset}>Reset</Button>
</Group>
</Stack>
</Modal>
</>
)}
</Group>
)}
</Group>
);

View file

@ -1,6 +1,6 @@
import { memo } from 'react';
import type { ReactNode } from 'react';
import { Group, Stack, Text } from '@mantine/core';
import { Stack, Text } from '@mantine/core';
export const SettingRow = memo(function SettingRow({
label,
@ -12,7 +12,10 @@ export const SettingRow = memo(function SettingRow({
children: ReactNode;
}) {
return (
<Group justify="space-between" align="center" gap="md" py="sm" wrap="nowrap">
<div
className="grid gap-3 py-3 sm:grid-cols-[minmax(0,1fr)_minmax(10rem,14rem)] sm:items-center"
data-settings-row
>
<Stack gap={2} className="min-w-0 flex-1">
<Text size="sm" fw={500}>
{label}
@ -23,7 +26,9 @@ export const SettingRow = memo(function SettingRow({
</Text>
)}
</Stack>
<div className="flex-shrink-0">{children}</div>
</Group>
<div className="min-w-0 sm:justify-self-end [&_.mantine-InputWrapper-root]:w-full [&_.mantine-Select-root]:w-full">
{children}
</div>
</div>
);
});

View file

@ -0,0 +1,208 @@
import type { ReactNode } from 'react';
import { Alert, Group, Paper, Stack, Text } from '@mantine/core';
import { Info, TriangleAlert } from 'lucide-react';
import { SectionHeader } from './SectionHeader';
export function SettingsPage({
title,
description,
actions,
children,
}: {
title: string;
description: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<Stack gap="lg" data-settings-page={title.toLowerCase().replace(/\s+/g, '-')}>
<Group justify="space-between" align="flex-start" gap="md" wrap="wrap">
<div className="min-w-0 max-w-2xl">
<Text component="h2" size="lg" fw={700} lh={1.25}>
{title}
</Text>
<Text size="sm" c="dimmed" mt={4}>
{description}
</Text>
</div>
{actions && <div className="shrink-0">{actions}</div>}
</Group>
{children}
</Stack>
);
}
export function SettingsSection({
id,
title,
description,
status,
actions,
onReset,
tone = 'default',
divided = false,
children,
}: {
id?: string;
title: string;
description?: string;
status?: ReactNode;
actions?: ReactNode;
onReset?: () => void;
tone?: 'default' | 'advanced' | 'danger';
divided?: boolean;
children: ReactNode;
}) {
const headingId = id ? `${id}-heading` : undefined;
return (
<Paper
component="section"
id={id}
aria-labelledby={headingId}
withBorder
radius="md"
p={{ base: 'sm', sm: 'md' }}
data-settings-section={tone}
className={
tone === 'danger'
? 'border-red-500/35 bg-red-500/[0.035]'
: tone === 'advanced'
? 'bg-muted/20'
: 'bg-card'
}
>
<SectionHeader
id={headingId}
title={title}
description={description}
status={status}
actions={actions}
onReset={onReset}
contained
/>
<div className={divided ? 'divide-y' : undefined}>{children}</div>
</Paper>
);
}
export function SettingsFieldGrid({ children }: { children: ReactNode }) {
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2" data-settings-field-grid>
{children}
</div>
);
}
export function SettingsStatusCard({
title,
description,
tone = 'neutral',
actions,
}: {
title: string;
description: string;
tone?: 'neutral' | 'success' | 'warning' | 'error';
actions?: ReactNode;
}) {
const color =
tone === 'error'
? 'red'
: tone === 'warning'
? 'yellow'
: tone === 'success'
? 'green'
: 'gray';
const Icon = tone === 'warning' || tone === 'error' ? TriangleAlert : Info;
return (
<Alert
color={color}
variant="light"
role={tone === 'warning' || tone === 'error' ? 'alert' : 'status'}
icon={<Icon className="h-4 w-4" aria-hidden="true" />}
title={title}
data-settings-status={tone}
>
<Group justify="space-between" align="flex-start" gap="sm" wrap="wrap">
<Text size="sm">{description}</Text>
{actions}
</Group>
</Alert>
);
}
export function SettingsLocalNav({
label,
items,
}: {
label: string;
items: Array<{ id: string; label: string }>;
}) {
return (
<nav
aria-label={label}
className="sticky top-0 z-10 -mx-1 overflow-x-auto bg-background/95 px-1 py-2 backdrop-blur"
>
<Group gap="xs" wrap="nowrap">
{items.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
className="whitespace-nowrap rounded-md border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground hover:border-primary/50 hover:text-foreground focus-visible:outline-2 focus-visible:outline-primary"
>
{item.label}
</a>
))}
</Group>
</nav>
);
}
export function SettingsHelpText({ children }: { children: ReactNode }) {
return (
<Text size="xs" c="dimmed" data-settings-help>
{children}
</Text>
);
}
export function SettingsErrorText({ children }: { children: ReactNode }) {
return (
<Text size="xs" c="red" role="alert" data-settings-error>
{children}
</Text>
);
}
export function SettingsActionGroup({
label,
tone = 'routine',
children,
}: {
label: string;
tone?: 'routine' | 'danger';
children: ReactNode;
}) {
return (
<Stack
gap={4}
aria-label={label}
data-settings-actions={tone}
className={tone === 'danger' ? 'border-t border-red-500/30 pt-2' : undefined}
>
<Text size="xs" c={tone === 'danger' ? 'red' : 'dimmed'} fw={700} tt="uppercase">
{label}
</Text>
{children}
</Stack>
);
}
export function SettingsUnit({ children }: { children: ReactNode }) {
return (
<Text component="span" size="xs" c="dimmed" className="min-w-10 text-left">
{children}
</Text>
);
}

View file

@ -4,3 +4,14 @@ export { NumberRow } from './NumberRow';
export { SectionHeader } from './SectionHeader';
export { SaveIndicator } from './SaveIndicator';
export { SettingsErrorBoundary } from './SettingsErrorBoundary';
export {
SettingsActionGroup,
SettingsErrorText,
SettingsFieldGrid,
SettingsHelpText,
SettingsLocalNav,
SettingsPage,
SettingsSection,
SettingsStatusCard,
SettingsUnit,
} from './SettingsLayout';

View file

@ -5,9 +5,10 @@ import {
normalizeBoardColumns,
normalizeBoardDefaultStatus,
type BoardColumnConfig,
type BoardSettings,
type DashboardWidgetSettings,
} from '@veritas-kanban/shared';
import { SettingRow, ToggleRow, SectionHeader, SaveIndicator } from '../shared';
import { SettingRow, ToggleRow, SaveIndicator, SettingsPage, SettingsSection } from '../shared';
import { Plus, Trash2 } from 'lucide-react';
export function BoardTab() {
@ -17,7 +18,7 @@ export function BoardTab() {
const columns = normalizeBoardColumns(boardSettings.columns);
const defaultStatus = normalizeBoardDefaultStatus(boardSettings.defaultStatus, columns);
const update = (key: string, value: any) => {
const update = <K extends keyof BoardSettings>(key: K, value: BoardSettings[K]) => {
debouncedUpdate({ board: { [key]: value } });
};
@ -78,12 +79,18 @@ export function BoardTab() {
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<SectionHeader title="Board & Display" onReset={resetBoard} />
<SaveIndicator isPending={isPending} />
</div>
<div className="divide-y">
<SettingsPage
title="Board"
description="Control board structure, card density, visible metadata, and direct manipulation."
actions={<SaveIndicator isPending={isPending} />}
>
<SettingsSection
id="board-display"
title="Board and display"
description="Changes apply to the shared board experience."
onReset={resetBoard}
divided
>
<ToggleRow
label="Show Dashboard"
description="Display the metrics dashboard section above the board"
@ -151,7 +158,9 @@ export function BoardTab() {
<SettingRow label="Card Density" description="Compact cards use less space">
<Select
value={boardSettings.cardDensity ?? DEFAULT_FEATURE_SETTINGS.board.cardDensity}
onChange={(value) => value && update('cardDensity', value)}
onChange={(value) =>
value && update('cardDensity', value as BoardSettings['cardDensity'])
}
data={[
{ value: 'normal', label: 'Normal' },
{ value: 'compact', label: 'Compact' },
@ -272,7 +281,7 @@ export function BoardTab() {
checked={boardSettings.showDoneMetrics ?? DEFAULT_FEATURE_SETTINGS.board.showDoneMetrics}
onCheckedChange={(v) => update('showDoneMetrics', v)}
/>
</div>
</div>
</SettingsSection>
</SettingsPage>
);
}

View file

@ -1,7 +1,15 @@
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import { Select, TextInput } from '@mantine/core';
import { ToggleRow, NumberRow, SectionHeader, SaveIndicator, SettingRow } from '../shared';
import {
ToggleRow,
NumberRow,
SaveIndicator,
SettingRow,
SettingsLocalNav,
SettingsPage,
SettingsSection,
} from '../shared';
export function DataTab() {
const { settings } = useFeatureSettings();
@ -45,14 +53,28 @@ export function DataTab() {
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<SectionHeader title="Telemetry & Data" onReset={resetData} />
<SaveIndicator isPending={isPending} />
</div>
<SettingsPage
title="Data"
description="Manage telemetry retention, operating budgets, and archive lifecycle."
actions={<SaveIndicator isPending={isPending} />}
>
<SettingsLocalNav
label="Data settings sections"
items={[
{ id: 'data-telemetry', label: 'Telemetry' },
{ id: 'data-budget', label: 'Budget' },
{ id: 'data-archive', label: 'Archive' },
]}
/>
{/* Telemetry */}
<div className="divide-y">
<SettingsSection
id="data-telemetry"
title="Telemetry and data"
description="Event collection and retention. Reset restores all settings on this page."
onReset={resetData}
divided
>
<ToggleRow
label="Telemetry Collection"
description="Master toggle for all telemetry event collection"
@ -94,221 +116,221 @@ export function DataTab() {
/>
</>
)}
</div>
</SettingsSection>
{/* Budget Tracking */}
<div className="space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Budget Tracking
</h4>
<div className="divide-y">
<ToggleRow
label="Budget Tracking"
description="Track monthly token usage against budget limits"
checked={settings.budget?.enabled ?? DEFAULT_FEATURE_SETTINGS.budget.enabled}
onCheckedChange={(v) => updateBudget('enabled', v)}
/>
{(settings.budget?.enabled ?? DEFAULT_FEATURE_SETTINGS.budget.enabled) && (
<>
<NumberRow
label="Monthly Token Limit"
description="Set monthly token budget (0 = no limit)"
value={
settings.budget?.monthlyTokenLimit ??
DEFAULT_FEATURE_SETTINGS.budget.monthlyTokenLimit
}
onChange={(v) => updateBudget('monthlyTokenLimit', v)}
min={0}
max={9_999_999_999}
unit="tokens"
hideSpinners
maxLength={10}
/>
<NumberRow
label="Monthly Cost Limit"
description="Set monthly cost budget in dollars (0 = no limit)"
value={
settings.budget?.monthlyCostLimit ??
DEFAULT_FEATURE_SETTINGS.budget.monthlyCostLimit
}
onChange={(v) => updateBudget('monthlyCostLimit', v)}
min={0}
max={9_999_999_999}
unit="USD"
hideSpinners
maxLength={10}
/>
<NumberRow
label="Warning Threshold"
description="Show warning when usage exceeds this percentage of budget"
value={
settings.budget?.warningThreshold ??
DEFAULT_FEATURE_SETTINGS.budget.warningThreshold
}
onChange={(v) => updateBudget('warningThreshold', v)}
min={50}
max={99}
step={5}
unit="%"
hideSpinners
maxLength={2}
/>
<ToggleRow
label="Default Run Budget"
description="Enforce workspace defaults on agent and workflow runs"
checked={defaultRunBudget?.enabled ?? false}
onCheckedChange={(v) =>
updateDefaultRunBudget({
enabled: v,
scope: 'workspace',
name: defaultRunBudget?.name ?? 'Workspace default run budget',
})
}
/>
{(defaultRunBudget?.enabled ?? false) && (
<>
<NumberRow
label="Run Token Limit"
description="Maximum total tokens per run (0 = no limit)"
value={defaultRunLimits.totalTokens ?? 0}
onChange={(v) => updateDefaultRunLimit('totalTokens', v)}
min={0}
max={9_999_999_999}
unit="tokens"
hideSpinners
maxLength={10}
/>
<NumberRow
label="Run Cost Limit"
description="Maximum provider-reported spend per run (0 = no limit)"
value={defaultRunLimits.costUsd ?? 0}
onChange={(v) => updateDefaultRunLimit('costUsd', v)}
min={0}
max={1_000_000}
unit="USD"
hideSpinners
maxLength={8}
/>
<NumberRow
label="Tool Call Limit"
description="Maximum counted tool calls per run (0 = no limit)"
value={defaultRunLimits.toolCalls ?? 0}
onChange={(v) => updateDefaultRunLimit('toolCalls', v)}
min={0}
max={100_000}
unit="calls"
hideSpinners
maxLength={6}
/>
<NumberRow
label="Runtime Limit"
description="Maximum wall-clock runtime per run (0 = no limit)"
value={defaultRunLimits.runtimeSeconds ?? 0}
onChange={(v) => updateDefaultRunLimit('runtimeSeconds', v)}
min={0}
max={604_800}
unit="sec"
hideSpinners
maxLength={6}
/>
<NumberRow
label="Retry Limit"
description="Maximum workflow retry count per run (0 = no limit)"
value={defaultRunLimits.retries ?? 0}
onChange={(v) => updateDefaultRunLimit('retries', v)}
min={0}
max={100}
unit="retries"
hideSpinners
maxLength={3}
/>
<NumberRow
label="Fan-out Limit"
description="Maximum parallel branch width per workflow run (0 = no limit)"
value={defaultRunLimits.fanOut ?? 0}
onChange={(v) => updateDefaultRunLimit('fanOut', v)}
min={0}
max={100}
unit="branches"
hideSpinners
maxLength={3}
<SettingsSection
id="data-budget"
title="Budget tracking"
description="Monthly and per-run limits with explicit hard-threshold behavior."
divided
>
<ToggleRow
label="Budget Tracking"
description="Track monthly token usage against budget limits"
checked={settings.budget?.enabled ?? DEFAULT_FEATURE_SETTINGS.budget.enabled}
onCheckedChange={(v) => updateBudget('enabled', v)}
/>
{(settings.budget?.enabled ?? DEFAULT_FEATURE_SETTINGS.budget.enabled) && (
<>
<NumberRow
label="Monthly Token Limit"
description="Set monthly token budget (0 = no limit)"
value={
settings.budget?.monthlyTokenLimit ??
DEFAULT_FEATURE_SETTINGS.budget.monthlyTokenLimit
}
onChange={(v) => updateBudget('monthlyTokenLimit', v)}
min={0}
max={9_999_999_999}
unit="tokens"
hideSpinners
maxLength={10}
/>
<NumberRow
label="Monthly Cost Limit"
description="Set monthly cost budget in dollars (0 = no limit)"
value={
settings.budget?.monthlyCostLimit ??
DEFAULT_FEATURE_SETTINGS.budget.monthlyCostLimit
}
onChange={(v) => updateBudget('monthlyCostLimit', v)}
min={0}
max={9_999_999_999}
unit="USD"
hideSpinners
maxLength={10}
/>
<NumberRow
label="Warning Threshold"
description="Show warning when usage exceeds this percentage of budget"
value={
settings.budget?.warningThreshold ??
DEFAULT_FEATURE_SETTINGS.budget.warningThreshold
}
onChange={(v) => updateBudget('warningThreshold', v)}
min={50}
max={99}
step={5}
unit="%"
hideSpinners
maxLength={2}
/>
<ToggleRow
label="Default Run Budget"
description="Enforce workspace defaults on agent and workflow runs"
checked={defaultRunBudget?.enabled ?? false}
onCheckedChange={(v) =>
updateDefaultRunBudget({
enabled: v,
scope: 'workspace',
name: defaultRunBudget?.name ?? 'Workspace default run budget',
})
}
/>
{(defaultRunBudget?.enabled ?? false) && (
<>
<NumberRow
label="Run Token Limit"
description="Maximum total tokens per run (0 = no limit)"
value={defaultRunLimits.totalTokens ?? 0}
onChange={(v) => updateDefaultRunLimit('totalTokens', v)}
min={0}
max={9_999_999_999}
unit="tokens"
hideSpinners
maxLength={10}
/>
<NumberRow
label="Run Cost Limit"
description="Maximum provider-reported spend per run (0 = no limit)"
value={defaultRunLimits.costUsd ?? 0}
onChange={(v) => updateDefaultRunLimit('costUsd', v)}
min={0}
max={1_000_000}
unit="USD"
hideSpinners
maxLength={8}
/>
<NumberRow
label="Tool Call Limit"
description="Maximum counted tool calls per run (0 = no limit)"
value={defaultRunLimits.toolCalls ?? 0}
onChange={(v) => updateDefaultRunLimit('toolCalls', v)}
min={0}
max={100_000}
unit="calls"
hideSpinners
maxLength={6}
/>
<NumberRow
label="Runtime Limit"
description="Maximum wall-clock runtime per run (0 = no limit)"
value={defaultRunLimits.runtimeSeconds ?? 0}
onChange={(v) => updateDefaultRunLimit('runtimeSeconds', v)}
min={0}
max={604_800}
unit="sec"
hideSpinners
maxLength={6}
/>
<NumberRow
label="Retry Limit"
description="Maximum workflow retry count per run (0 = no limit)"
value={defaultRunLimits.retries ?? 0}
onChange={(v) => updateDefaultRunLimit('retries', v)}
min={0}
max={100}
unit="retries"
hideSpinners
maxLength={3}
/>
<NumberRow
label="Fan-out Limit"
description="Maximum parallel branch width per workflow run (0 = no limit)"
value={defaultRunLimits.fanOut ?? 0}
onChange={(v) => updateDefaultRunLimit('fanOut', v)}
min={0}
max={100}
unit="branches"
hideSpinners
maxLength={3}
/>
<SettingRow
label="Hard Threshold Action"
description="Action when a run reaches a hard budget limit"
>
<Select
aria-label="Hard Threshold Action"
className="w-48"
data={[
{ value: 'require-approval', label: 'Require approval' },
{ value: 'pause', label: 'Pause' },
{ value: 'downgrade', label: 'Downgrade model' },
{ value: 'cancel', label: 'Cancel' },
]}
value={defaultRunBudget?.hardAction ?? 'require-approval'}
onChange={(value) =>
updateDefaultRunBudget({ hardAction: value ?? 'require-approval' })
}
/>
</SettingRow>
{defaultRunBudget?.hardAction === 'downgrade' && (
<SettingRow
label="Hard Threshold Action"
description="Action when a run reaches a hard budget limit"
label="Downgrade Model"
description="Model route to use after a hard threshold downgrade"
>
<Select
aria-label="Hard Threshold Action"
<TextInput
aria-label="Downgrade Model"
className="w-48"
data={[
{ value: 'require-approval', label: 'Require approval' },
{ value: 'pause', label: 'Pause' },
{ value: 'downgrade', label: 'Downgrade model' },
{ value: 'cancel', label: 'Cancel' },
]}
value={defaultRunBudget?.hardAction ?? 'require-approval'}
onChange={(value) =>
updateDefaultRunBudget({ hardAction: value ?? 'require-approval' })
value={defaultRunBudget?.downgradeModel ?? ''}
onChange={(event) =>
updateDefaultRunBudget({ downgradeModel: event.currentTarget.value })
}
placeholder="gpt-4.1-mini"
/>
</SettingRow>
{defaultRunBudget?.hardAction === 'downgrade' && (
<SettingRow
label="Downgrade Model"
description="Model route to use after a hard threshold downgrade"
>
<TextInput
aria-label="Downgrade Model"
className="w-48"
value={defaultRunBudget?.downgradeModel ?? ''}
onChange={(event) =>
updateDefaultRunBudget({ downgradeModel: event.currentTarget.value })
}
placeholder="gpt-4.1-mini"
/>
</SettingRow>
)}
</>
)}
</>
)}
</div>
</div>
)}
</>
)}
</>
)}
</SettingsSection>
{/* Archive */}
<div className="space-y-3">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Archive
</h4>
<div className="divide-y">
<ToggleRow
label="Auto-Archive"
description="Automatically archive completed sprints"
checked={
settings.archive?.autoArchiveEnabled ??
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveEnabled
<SettingsSection
id="data-archive"
title="Archive"
description="Automatic lifecycle policy for completed sprints."
divided
>
<ToggleRow
label="Auto-Archive"
description="Automatically archive completed sprints"
checked={
settings.archive?.autoArchiveEnabled ??
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveEnabled
}
onCheckedChange={(v) => updateArchive('autoArchiveEnabled', v)}
/>
{(settings.archive?.autoArchiveEnabled ??
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveEnabled) && (
<NumberRow
label="Archive After"
description="Days after completion before auto-archiving"
value={
settings.archive?.autoArchiveAfterDays ??
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveAfterDays
}
onCheckedChange={(v) => updateArchive('autoArchiveEnabled', v)}
onChange={(v) => updateArchive('autoArchiveAfterDays', v)}
min={1}
max={365}
unit="days"
hideSpinners
maxLength={3}
/>
{(settings.archive?.autoArchiveEnabled ??
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveEnabled) && (
<NumberRow
label="Archive After"
description="Days after completion before auto-archiving"
value={
settings.archive?.autoArchiveAfterDays ??
DEFAULT_FEATURE_SETTINGS.archive.autoArchiveAfterDays
}
onChange={(v) => updateArchive('autoArchiveAfterDays', v)}
min={1}
max={365}
unit="days"
hideSpinners
maxLength={3}
/>
)}
</div>
</div>
</div>
)}
</SettingsSection>
</SettingsPage>
);
}

View file

@ -38,6 +38,7 @@ import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import { cn } from '@/lib/utils';
import { useTheme } from '@/hooks/useTheme';
import { PRODUCT_MODE_DEFINITIONS, productModeDefinition } from '@/lib/product-modes';
import { SettingRow, SettingsHelpText, SettingsPage, SettingsSection } from '../shared';
const PRODUCT_MODE_OPTIONS = PRODUCT_MODE_DEFINITIONS.map((mode) => ({
value: mode.id,
@ -58,38 +59,44 @@ export function GeneralTab() {
);
return (
<div className="space-y-6">
<SettingsPage
title="General"
description="Personalize the app, choose its working mode, and connect the repositories and agents used by default."
>
{/* Appearance */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Appearance</h3>
<div className="flex items-center justify-between py-2 px-3 rounded-md border bg-card">
<div className="flex items-center gap-3">
<SettingsSection
id="general-appearance"
title="Appearance"
description="Theme preference for this browser or desktop app."
>
<SettingRow
label="Dark mode"
description={theme === 'dark' ? 'Dark theme active' : 'Light theme active'}
>
<div className="flex items-center justify-end gap-3">
{theme === 'dark' ? (
<Moon className="h-4 w-4 text-muted-foreground" />
) : (
<Sun className="h-4 w-4 text-muted-foreground" />
)}
<div>
<div className="font-medium text-sm">Dark Mode</div>
<div className="text-xs text-muted-foreground">
{theme === 'dark' ? 'Dark theme active' : 'Light theme active'}
</div>
</div>
<Switch
checked={theme === 'dark'}
onChange={(event) => setTheme(event.currentTarget.checked ? 'dark' : 'light')}
aria-label="Toggle dark mode"
className="flex min-h-8 items-center"
size="sm"
/>
</div>
<Switch
checked={theme === 'dark'}
onChange={(event) => setTheme(event.currentTarget.checked ? 'dark' : 'light')}
aria-label="Toggle dark mode"
className="flex min-h-8 items-center"
size="sm"
/>
</div>
</div>
</SettingRow>
</SettingsSection>
{/* Product Mode */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Product Mode</h3>
<div className="rounded-md border p-4 bg-card space-y-4">
<SettingsSection
id="general-product-mode"
title="Product mode"
description="Prioritize the surfaces and shortcuts that match the way you work."
>
<div className="space-y-4">
<div
data-testid="product-mode-layout"
className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
@ -131,12 +138,15 @@ export function GeneralTab() {
<ModeDetail label="Shortcuts" items={activeProductMode.commandShortcuts} />
</SimpleGrid>
</div>
</div>
</SettingsSection>
{/* User Preferences */}
<div className="space-y-3">
<h3 className="text-sm font-medium">User Preferences</h3>
<div className="rounded-md border p-4 bg-card space-y-3">
<SettingsSection
id="general-user-preferences"
title="User preferences"
description="Identity used in collaborative surfaces."
>
<div className="space-y-3">
<div className="grid gap-2">
<TextInput
id="human-display-name"
@ -154,19 +164,21 @@ export function GeneralTab() {
placeholder="Human"
maw={320}
/>
<p className="text-xs text-muted-foreground">
<SettingsHelpText>
How your messages appear in Squad Chat. Shows as "{localDisplayName} (Human)" in the
chat.
</p>
</SettingsHelpText>
</div>
</div>
</div>
</SettingsSection>
{/* Repositories */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Git Repositories</h3>
{!showAddForm && (
<SettingsSection
id="general-repositories"
title="Git repositories"
description="Repositories available for task worktrees and source operations."
actions={
!showAddForm ? (
<Button
variant="outline"
size="xs"
@ -175,8 +187,9 @@ export function GeneralTab() {
>
Add Repo
</Button>
)}
</div>
) : undefined
}
>
{showAddForm && <AddRepoForm onClose={() => setShowAddForm(false)} />}
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading...</div>
@ -191,11 +204,14 @@ export function GeneralTab() {
))}
</div>
)}
</div>
</SettingsSection>
{/* Default Agent */}
<div className="space-y-3">
<h3 className="text-sm font-medium">Default Agent</h3>
<SettingsSection
id="general-default-agent"
title="Default agent"
description="Agent selected when a task does not specify one."
>
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading...</div>
) : (
@ -211,8 +227,8 @@ export function GeneralTab() {
))}
</div>
)}
</div>
</div>
</SettingsSection>
</SettingsPage>
);
}

View file

@ -1,17 +1,28 @@
import { Select } from '@mantine/core';
import { useFeatureSettings, useDebouncedFeatureUpdate } from '@/hooks/useFeatureSettings';
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import { SettingRow, ToggleRow, NumberRow, SectionHeader, SaveIndicator } from '../shared';
import {
DEFAULT_FEATURE_SETTINGS,
type MarkdownSettings,
type TaskBehaviorSettings,
} from '@veritas-kanban/shared';
import {
SettingRow,
ToggleRow,
NumberRow,
SaveIndicator,
SettingsPage,
SettingsSection,
} from '../shared';
export function TasksTab() {
const { settings } = useFeatureSettings();
const { debouncedUpdate, isPending } = useDebouncedFeatureUpdate();
const update = (key: string, value: any) => {
const update = <K extends keyof TaskBehaviorSettings>(key: K, value: TaskBehaviorSettings[K]) => {
debouncedUpdate({ tasks: { [key]: value } });
};
const updateMarkdown = (key: string, value: any) => {
const updateMarkdown = <K extends keyof MarkdownSettings>(key: K, value: MarkdownSettings[K]) => {
debouncedUpdate({ markdown: { [key]: value } });
};
@ -24,12 +35,18 @@ export function TasksTab() {
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<SectionHeader title="Task Behavior" onReset={resetTasks} />
<SaveIndicator isPending={isPending} />
</div>
<div className="divide-y">
<SettingsPage
title="Tasks"
description="Set defaults and optional capabilities for task authoring and completion."
actions={<SaveIndicator isPending={isPending} />}
>
<SettingsSection
id="task-behavior"
title="Task behavior"
description="Common task defaults and dependent attachment controls."
onReset={resetTasks}
divided
>
<ToggleRow
label="Time Tracking"
description="Enable time tracking on tasks"
@ -143,7 +160,9 @@ export function TasksTab() {
value={
settings.tasks?.defaultPriority ?? DEFAULT_FEATURE_SETTINGS.tasks.defaultPriority
}
onChange={(value) => value && update('defaultPriority', value)}
onChange={(value) =>
value && update('defaultPriority', value as TaskBehaviorSettings['defaultPriority'])
}
data={[
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
@ -156,13 +175,15 @@ export function TasksTab() {
w={128}
/>
</SettingRow>
</div>
</SettingsSection>
<div className="flex items-center justify-between">
<SectionHeader title="Markdown" onReset={resetMarkdown} />
<SaveIndicator isPending={isPending} />
</div>
<div className="divide-y">
<SettingsSection
id="task-markdown"
title="Markdown"
description="Formatting behavior for task descriptions and comments."
onReset={resetMarkdown}
divided
>
<ToggleRow
label="Enable Markdown"
description="Use Markdown formatting in task descriptions and comments"
@ -180,7 +201,7 @@ export function TasksTab() {
}
onCheckedChange={(v) => updateMarkdown('enableCodeHighlighting', v)}
/>
</div>
</div>
</SettingsSection>
</SettingsPage>
);
}