feat: search settings and link directly to controls

This commit is contained in:
bradgroux 2026-09-07 14:30:47 -05:00
parent 42dc7ec41d
commit 0c3d04183c
7 changed files with 453 additions and 9 deletions

View file

@ -2645,3 +2645,11 @@ A persistent header status bar that gives you a real-time overview of system hea
---
_Last updated: 2026-03-21 · [Back to README](../README.md)_
### Find and link to settings
Use **Search settings** to find a category or control by its label or common terms such as theme, backup, token, and default agent. Arrow keys select a result; Enter opens its existing section and moves focus to the target. Empty results leave focus in the search field. Results respect the section and control permissions. Board Only is a focus preset, not a permission boundary: optional agent and automation results remain available, are marked optional, and follow core results.
Support links can append a settings fragment to the app URL, for example `/#settings/general/general-appearance`, `/#settings/maintenance/maintenance-backup`, or `/#settings/multi-user/multi-user-api-access`. Selecting a search result updates this fragment so the current URL can be copied. Closing Settings clears it. Links never perform the setting's action, and a control target that is unknown or unavailable falls back to an allowed section.
Data configures telemetry, budgets, and archiving; Maintenance contains backup, restore, logs, and storage tools; Manage contains task-data management. The existing categories have not moved.

View file

@ -52,7 +52,17 @@ vi.mock('@/hooks/useToast', () => ({
}));
vi.mock('@/components/settings/tabs/GeneralTab', () => ({
GeneralTab: () => <div>General settings loaded</div>,
GeneralTab: () => (
<div>
General settings loaded
<section id="general-appearance">
<input aria-label="Fixture theme" />
</section>
<section id="general-default-agent">
<button>Fixture default agent</button>
</section>
</div>
),
}));
vi.mock('@/components/settings/tabs/BoardTab', () => ({
@ -113,6 +123,7 @@ vi.mock('@/components/settings/tabs/MultiUserTab', () => ({
describe('SettingsDialog Mantine shell', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/');
mocks.saveError = null;
mocks.hasPermission.mockReturnValue(true);
mocks.productMode.selectedMode = 'advanced';
@ -165,6 +176,57 @@ describe('SettingsDialog Mantine shell', () => {
);
});
it('finds an existing appearance control and focuses it through keyboard selection', async () => {
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
const input = screen.getByRole('combobox', { name: 'Search settings' });
input.focus();
fireEvent.change(input, { target: { value: 'theme' } });
expect(screen.getByRole('option', { name: /Theme and appearance/ })).toBeDefined();
fireEvent.keyDown(input, { key: 'Enter' });
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByLabelText('Fixture theme'))
);
expect(window.location.hash).toBe('#settings/general/general-appearance');
});
it('keeps focus in search for empty results and filters permission-restricted controls', () => {
mocks.hasPermission.mockImplementation((permission) => permission !== 'admin:manage');
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
const input = screen.getByRole('combobox', { name: 'Search settings' });
input.focus();
fireEvent.change(input, { target: { value: 'token' } });
expect(screen.queryByRole('option', { name: /API tokens/ })).toBeNull();
expect(screen.getByText(/No matching settings/)).toBeDefined();
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(document.activeElement).toBe(input);
});
it('identifies optional controls under the Board Only focus preset', () => {
mocks.productMode.selectedMode = 'board-only';
renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);
fireEvent.change(screen.getByRole('combobox', { name: 'Search settings' }), {
target: { value: 'default agent' },
});
expect(
screen.getByRole('option', { name: /Default agent.*Optional in Board Only/ })
).toBeDefined();
});
it('opens a control from a support target after its lazy section mounts', async () => {
renderWithProviders(
<SettingsDialog
open
onOpenChange={vi.fn()}
defaultTab="general"
defaultControl="general-appearance"
/>
);
await waitFor(() =>
expect(document.activeElement).toBe(screen.getByLabelText('Fixture theme'))
);
});
it('renders the settings shell with direct Mantine controls', async () => {
const { baseElement } = renderWithProviders(<SettingsDialog open onOpenChange={vi.fn()} />);

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
SETTINGS_CONTROL_INDEX,
searchSettings,
parseSettingsSupportHash,
settingsSupportHash,
} from '@/components/settings/settings-search-index';
describe('settings support destinations', () => {
it.each([
['backup', 'maintenance-backup'],
['theme', 'general-appearance'],
['token', 'multi-user-api-access'],
['default agent', 'general-default-agent'],
])('locates %s', (query, control) => {
expect(searchSettings(SETTINGS_CONTROL_INDEX, query, false)[0].controlId).toBe(control);
});
it('round-trips a control link and rejects arbitrary fragments', () => {
expect(parseSettingsSupportHash(settingsSupportHash('general', 'general-appearance'))).toEqual({
section: 'general',
control: 'general-appearance',
});
expect(parseSettingsSupportHash('#settings/data')).toEqual({
section: 'data',
control: undefined,
});
expect(parseSettingsSupportHash('#settings/general/../../outside')).toBeNull();
expect(parseSettingsSupportHash('#other')).toBeNull();
});
});

View file

@ -1,3 +1,4 @@
import { parseSettingsSupportHash } from '@/components/settings/settings-search-index';
import { flushSync } from 'react-dom';
import {
ActionIcon,
@ -141,6 +142,7 @@ export function Header({
const [createOpen, setCreateOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState<string | undefined>();
const [settingsControl, setSettingsControl] = useState<string | undefined>();
const [searchOpen, setSearchOpen] = useState(false);
const [searchPreset, setSearchPreset] = useState<SearchPreset | undefined>();
// activityOpen removed — sidebar merged into feed (GH-66)
@ -249,8 +251,9 @@ export function Header({
}, [openSquadChatPanel, toggleBottomPanel, usesWorkbenchChat]);
const openSettingsDialog = useCallback(
(section?: string) => {
(section?: string, control?: string) => {
markPanelLoaded('settings');
setSettingsControl(control);
setSettingsTab(section);
setSettingsOpen(true);
},
@ -326,12 +329,21 @@ export function Header({
useEffect(() => {
const handleOpenSettings = (event: Event) => {
const section = (event as CustomEvent<{ section?: string }>).detail?.section;
openSettingsDialog(section);
const detail = (event as CustomEvent<{ section?: string; control?: string }>).detail;
openSettingsDialog(detail?.section, detail?.control);
};
const openSupportLink = () => {
const target = parseSettingsSupportHash(window.location.hash);
if (target) openSettingsDialog(target.section, target.control);
};
openSupportLink();
window.addEventListener('hashchange', openSupportLink);
window.addEventListener('veritas:open-settings', handleOpenSettings);
return () => window.removeEventListener('veritas:open-settings', handleOpenSettings);
return () => {
window.removeEventListener('hashchange', openSupportLink);
window.removeEventListener('veritas:open-settings', handleOpenSettings);
};
}, [openSettingsDialog]);
useEffect(() => {
@ -690,9 +702,19 @@ export function Header({
open={settingsOpen}
onOpenChange={(open) => {
setSettingsOpen(open);
if (!open) setSettingsTab(undefined);
if (!open) {
setSettingsTab(undefined);
setSettingsControl(undefined);
if (parseSettingsSupportHash(window.location.hash))
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}${window.location.search}`
);
}
}}
defaultTab={settingsTab}
defaultControl={settingsControl}
/>
)}
{loadedPanels.has('chat') && <ChatPanel open={chatOpen} onOpenChange={setChatOpen} />}

View file

@ -39,6 +39,13 @@ import {
import { DEFAULT_FEATURE_SETTINGS } from '@veritas-kanban/shared';
import type { ClientAuthPermission } from '@veritas-kanban/shared';
import { SettingsActionGroup, SettingsErrorBoundary } from './shared';
import { SettingsSearch } from './SettingsSearch';
import {
SETTINGS_CONTROL_INDEX,
SETTINGS_SECTION_DESCRIPTIONS,
settingsSupportHash,
type SettingsSearchEntry,
} from './settings-search-index';
import { ErrorBoundary } from '@/components/shared/ErrorBoundary';
// Lazy-load tab components
@ -250,12 +257,21 @@ interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
defaultTab?: string;
defaultControl?: string;
}
// ============ Main Settings Dialog ============
export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialogProps) {
export function SettingsDialog({
open,
onOpenChange,
defaultTab,
defaultControl,
}: SettingsDialogProps) {
const [activeTab, setActiveTab] = useState<TabId>('general');
const [pendingFocus, setPendingFocus] = useState<{ section: string; controlId?: string } | null>(
null
);
// Only mount the visible navigation: CSS-hidden controls are still counted
// by the focus trap while a lazy tab has no controls of its own.
const showSidebar = useMediaQuery('(min-width: 40em)');
@ -267,6 +283,42 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
[hasPermission]
);
const isBoardOnly = currentSettings.productMode?.selectedMode === 'board-only';
const searchEntries = useMemo(() => {
const allowed = new Set(TABS.filter(canUseTab).map((tab) => tab.id));
const optionalSections = new Set([
'agents',
'delegation',
'scheduler',
'queue-monitors',
'reflections',
'tool-policies',
'enforcement',
]);
const sections: SettingsSearchEntry[] = TABS.filter(canUseTab).map((tab) => ({
id: `section-${tab.id}`,
section: tab.id,
title: tab.label,
description: SETTINGS_SECTION_DESCRIPTIONS[tab.id],
optionalInBoardOnly: optionalSections.has(tab.id),
}));
const controls = SETTINGS_CONTROL_INDEX.filter(
(entry) =>
allowed.has(entry.section as TabId) &&
(!entry.requiredPermission || hasPermission(entry.requiredPermission))
);
return [...controls, ...sections];
}, [canUseTab, hasPermission]);
const selectSearchEntry = useCallback((entry: SettingsSearchEntry) => {
setActiveTab(entry.section as TabId);
setPendingFocus({ section: entry.section, controlId: entry.controlId });
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}${window.location.search}${settingsSupportHash(entry.section, entry.controlId)}`
);
}, []);
const mobileTabOptions = useMemo(
() =>
SETTINGS_NAVIGATION_GROUPS.map((group) => ({
@ -285,8 +337,12 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
const requestedTab = TABS.find((t) => t.id === defaultTab);
if (requestedTab && canUseTab(requestedTab)) {
setActiveTab(defaultTab as TabId);
const target = searchEntries.find(
(entry) => entry.section === defaultTab && entry.controlId === defaultControl
);
setPendingFocus({ section: requestedTab.id, controlId: target?.controlId });
}
}, [canUseTab, defaultTab]);
}, [canUseTab, defaultTab, defaultControl, open, searchEntries]);
useEffect(() => {
const currentTab = TABS.find((tab) => tab.id === activeTab);
@ -316,6 +372,36 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
}
}, [activeTab]);
useEffect(() => {
if (!open || !pendingFocus || !contentAreaRef.current) return;
if (pendingFocus.section !== activeTab) {
setPendingFocus(null);
return;
}
const root = contentAreaRef.current;
const focusTarget = () => {
const section = pendingFocus.controlId
? document.getElementById(pendingFocus.controlId)
: root;
if (!section || !root.contains(section)) return false;
const target =
section.querySelector<HTMLElement>(
'input:not(:disabled), select:not(:disabled), textarea:not(:disabled), button:not(:disabled), a[href]'
) ?? section;
if (target === section) target.tabIndex = -1;
target.focus({ preventScroll: true });
target.scrollIntoView({ block: 'nearest' });
setPendingFocus(null);
return true;
};
if (focusTarget()) return;
const observer = new MutationObserver(() => {
if (focusTarget()) observer.disconnect();
});
observer.observe(root, { childList: true, subtree: true });
return () => observer.disconnect();
}, [activeTab, open, pendingFocus]);
const handleExportSettings = () => {
const blob = new Blob([JSON.stringify(currentSettings, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
@ -632,7 +718,7 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
disabled={!allowed}
title={
allowed
? tab.label
? `${tab.label}: ${SETTINGS_SECTION_DESCRIPTIONS[tab.id]}`
: `${tab.requiredPermission} permission required`
}
fullWidth
@ -701,6 +787,13 @@ export function SettingsDialog({ open, onOpenChange, defaultTab }: SettingsDialo
{/* Content */}
<div className="flex-1 flex flex-col min-w-0 min-h-0">
<div className="shrink-0 border-b px-4 py-3">
<SettingsSearch
entries={searchEntries}
boardOnly={isBoardOnly}
onSelect={selectSearchEntry}
/>
</div>
{!showSidebar && (
<div
data-settings-mobile-header

View file

@ -0,0 +1,100 @@
import { useEffect, useId, useRef, useState } from 'react';
import { TextInput } from '@mantine/core';
import { Search } from 'lucide-react';
import { searchSettings, type SettingsSearchEntry } from './settings-search-index';
export function SettingsSearch({
entries,
boardOnly,
onSelect,
}: {
entries: SettingsSearchEntry[];
boardOnly: boolean;
onSelect: (entry: SettingsSearchEntry) => void;
}) {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState(0);
const listId = useId();
const listRef = useRef<HTMLDivElement>(null);
const results = searchSettings(entries, query, boardOnly);
const active = Math.min(selected, Math.max(results.length - 1, 0));
const choose = (entry: SettingsSearchEntry) => {
setQuery('');
setSelected(0);
onSelect(entry);
};
useEffect(() => {
listRef.current?.children[active]?.scrollIntoView?.({ block: 'nearest' });
}, [active, query, entries]);
return (
<div className="relative">
<TextInput
aria-label="Search settings"
placeholder="Search settings…"
leftSection={<Search size={16} aria-hidden="true" />}
value={query}
role="combobox"
aria-autocomplete="list"
aria-expanded={results.length > 0}
aria-controls={results.length ? listId : undefined}
aria-activedescendant={results.length ? `${listId}-${active}` : undefined}
onChange={(event) => {
setQuery(event.currentTarget.value);
setSelected(0);
}}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (results.length)
setSelected(
(active + (event.key === 'ArrowDown' ? 1 : results.length - 1)) % results.length
);
} else if (event.key === 'Enter' && results[active]) {
event.preventDefault();
choose(results[active]);
} else if (event.key === 'Escape' && query) {
event.preventDefault();
event.stopPropagation();
setQuery('');
}
}}
/>
{query.trim() && (
<div
className="absolute top-full inset-x-0 z-20 mt-1 max-h-64 overflow-auto rounded-md border bg-popover text-popover-foreground shadow-md"
ref={listRef}
id={listId}
role={results.length ? 'listbox' : undefined}
aria-label="Matching settings"
>
{results.length ? (
results.map((entry, index) => (
<button
key={entry.id}
id={`${listId}-${index}`}
type="button"
role="option"
aria-selected={index === active}
tabIndex={-1}
className={`block w-full px-3 py-2 text-left ${index === active ? 'bg-muted' : ''}`}
onMouseDown={(event) => event.preventDefault()}
onClick={() => choose(entry)}
>
<span className="block text-sm font-medium">{entry.title}</span>
<span className="block text-xs text-muted-foreground">
{entry.description}
{boardOnly && entry.optionalInBoardOnly ? ' · Optional in Board Only' : ''}
</span>
</button>
))
) : (
<div role="status" className="px-3 py-3 text-sm text-muted-foreground">
No matching settings. Try a different term.
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,128 @@
import type { ClientAuthPermission } from '@veritas-kanban/shared';
export interface SettingsSearchEntry {
id: string;
section: string;
title: string;
description: string;
keywords?: string;
controlId?: string;
requiredPermission?: ClientAuthPermission;
optionalInBoardOnly?: boolean;
}
export const SETTINGS_SECTION_DESCRIPTIONS: Record<string, string> = {
general: 'Appearance, product mode, display name and defaults',
board: 'Columns, card display and board behavior',
tasks: 'Task behavior, verification and editing',
agents: 'Agent providers, runtime and routing',
data: 'Telemetry retention, budgets and archiving',
notifications: 'Delivery channels and alerts',
'multi-user': 'Workspace members, devices and API access',
'workspace-capabilities': 'Workspace capabilities and connections',
delegation: 'Agent coordination and delegation rules',
scheduler: 'Scheduled workflow execution',
'queue-monitors': 'Workflow queue monitoring',
reflections: 'Reusable lessons and extraction',
trackers: 'External issue tracker connections',
security: 'Password changes and account recovery',
'tool-policies': 'Tool access and approval policies',
enforcement: 'Quality and completion requirements',
'shared-resources': 'Shared references and resources',
'doc-freshness': 'Documentation freshness checks',
maintenance: 'Backups, restore, logs and storage maintenance',
manage: 'Manage stored task data and cleanup',
};
export const SETTINGS_CONTROL_INDEX: SettingsSearchEntry[] = [
{
id: 'theme',
section: 'general',
title: 'Theme and appearance',
description: 'Choose the appearance for this browser or desktop app',
keywords: 'dark light system color',
controlId: 'general-appearance',
},
{
id: 'product-mode',
section: 'general',
title: 'Product mode',
description: 'Choose which surfaces and shortcuts to emphasize',
keywords: 'board only advanced focus preset',
controlId: 'general-product-mode',
},
{
id: 'default-agent',
section: 'general',
title: 'Default agent',
description: 'Agent selected when a task does not specify one',
keywords: 'default agent provider',
controlId: 'general-default-agent',
requiredPermission: 'agent:read',
optionalInBoardOnly: true,
},
{
id: 'backup',
section: 'maintenance',
title: 'Backup and restore',
description: 'Export or import a SQLite backup bundle',
keywords: 'backup restore export import recovery database',
controlId: 'maintenance-backup',
requiredPermission: 'backup:read',
},
{
id: 'token',
section: 'multi-user',
title: 'API tokens',
description: 'Manage scoped API access for this workspace',
keywords: 'token api key credential access',
controlId: 'multi-user-api-access',
requiredPermission: 'admin:manage',
},
{
id: 'archive',
section: 'data',
title: 'Archive settings',
description: 'Configure task archiving behavior',
keywords: 'archive retention',
controlId: 'data-archive',
requiredPermission: 'backup:read',
},
{
id: 'markdown',
section: 'tasks',
title: 'Markdown editor',
description: 'Configure task description editing',
keywords: 'markdown editor preview',
controlId: 'task-markdown',
},
];
export function settingsSupportHash(section: string, controlId?: string): string {
return `#settings/${encodeURIComponent(section)}${controlId ? `/${encodeURIComponent(controlId)}` : ''}`;
}
export function parseSettingsSupportHash(
hash: string
): { section: string; control?: string } | null {
const match = /^#settings\/([a-z][a-z-]*)(?:\/([a-z][a-z-]*))?$/.exec(hash);
return match ? { section: match[1], control: match[2] } : null;
}
export function searchSettings(
entries: SettingsSearchEntry[],
query: string,
boardOnly: boolean
): SettingsSearchEntry[] {
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return [];
return entries
.filter((entry) => {
const haystack = `${entry.title} ${entry.description} ${entry.keywords ?? ''}`.toLowerCase();
return words.every((word) => haystack.includes(word));
})
.sort(
(a, b) =>
Number(boardOnly && !!a.optionalInBoardOnly) - Number(boardOnly && !!b.optionalInBoardOnly)
);
}