diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 89a67b45..2484dbdd 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -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.
diff --git a/web/src/__tests__/settings-dialog-mantine.test.tsx b/web/src/__tests__/settings-dialog-mantine.test.tsx
index 72dff943..64d214ea 100644
--- a/web/src/__tests__/settings-dialog-mantine.test.tsx
+++ b/web/src/__tests__/settings-dialog-mantine.test.tsx
@@ -52,7 +52,17 @@ vi.mock('@/hooks/useToast', () => ({
}));
vi.mock('@/components/settings/tabs/GeneralTab', () => ({
- GeneralTab: () =>
General settings loaded
,
+ GeneralTab: () => (
+
+ General settings loaded
+
+
+ Fixture default agent
+
+
+ ),
}));
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( );
+ 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( );
+ 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( );
+ 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(
+
+ );
+ await waitFor(() =>
+ expect(document.activeElement).toBe(screen.getByLabelText('Fixture theme'))
+ );
+ });
+
it('renders the settings shell with direct Mantine controls', async () => {
const { baseElement } = renderWithProviders( );
diff --git a/web/src/__tests__/settings-search-index.test.ts b/web/src/__tests__/settings-search-index.test.ts
new file mode 100644
index 00000000..3190c809
--- /dev/null
+++ b/web/src/__tests__/settings-search-index.test.ts
@@ -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();
+ });
+});
diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx
index 2c34fb00..8a6a980d 100644
--- a/web/src/components/layout/Header.tsx
+++ b/web/src/components/layout/Header.tsx
@@ -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();
+ const [settingsControl, setSettingsControl] = useState();
const [searchOpen, setSearchOpen] = useState(false);
const [searchPreset, setSearchPreset] = useState();
// 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') && }
diff --git a/web/src/components/settings/SettingsDialog.tsx b/web/src/components/settings/SettingsDialog.tsx
index 9d8aabbc..4f043a47 100644
--- a/web/src/components/settings/SettingsDialog.tsx
+++ b/web/src/components/settings/SettingsDialog.tsx
@@ -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('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(
+ '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 */}
+
+
+
{!showSidebar && (
void;
+}) {
+ const [query, setQuery] = useState('');
+ const [selected, setSelected] = useState(0);
+ const listId = useId();
+ const listRef = useRef
(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 (
+
+
}
+ 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() && (
+
+ {results.length ? (
+ results.map((entry, index) => (
+
event.preventDefault()}
+ onClick={() => choose(entry)}
+ >
+ {entry.title}
+
+ {entry.description}
+ {boardOnly && entry.optionalInBoardOnly ? ' · Optional in Board Only' : ''}
+
+
+ ))
+ ) : (
+
+ No matching settings. Try a different term.
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/web/src/components/settings/settings-search-index.ts b/web/src/components/settings/settings-search-index.ts
new file mode 100644
index 00000000..82e96f47
--- /dev/null
+++ b/web/src/components/settings/settings-search-index.ts
@@ -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 = {
+ 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)
+ );
+}