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 +
+ +
+
+ +
+
+ ), })); 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 ( +
+
+ ); +} 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) + ); +}