From dbec927d768c2bc89559ab16c2a604089eb3c74d Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:39:32 +0200 Subject: [PATCH 1/4] feat: add semantic search to settings page Implement search functionality for settings with: - Search input component - Search results overlay - useSettingsSearch hook - Updated all settings cards to support search indexing --- .../components/BrowserPreviewSettingsCard.tsx | 2 +- .../components/DefaultAgentSettingsCard.tsx | 2 +- .../components/HiddenToolsSettingsCard.tsx | 5 +- src/renderer/components/IntegrationsCard.tsx | 1 + .../components/KeyboardSettingsCard.tsx | 2 +- .../components/NotificationSettingsCard.tsx | 2 +- .../components/RepositorySettingsCard.tsx | 2 +- .../ResourceMonitorSettingsCard.tsx | 2 +- .../components/ReviewAgentSettingsCard.tsx | 5 +- .../components/RightSidebarSettingsCard.tsx | 2 +- src/renderer/components/SettingsPage.tsx | 175 +++++-- .../components/SettingsSearchInput.tsx | 55 ++ .../components/SettingsSearchResults.tsx | 113 +++++ .../components/TaskHoverActionCard.tsx | 2 +- src/renderer/components/TaskSettingsRows.tsx | 10 +- src/renderer/components/TelemetryCard.tsx | 2 +- .../components/TerminalSettingsCard.tsx | 2 +- src/renderer/components/ThemeCard.tsx | 2 +- .../components/WorkspaceProviderInfoCard.tsx | 5 +- src/renderer/hooks/useSettingsSearch.ts | 474 ++++++++++++++++++ 20 files changed, 808 insertions(+), 57 deletions(-) create mode 100644 src/renderer/components/SettingsSearchInput.tsx create mode 100644 src/renderer/components/SettingsSearchResults.tsx create mode 100644 src/renderer/hooks/useSettingsSearch.ts diff --git a/src/renderer/components/BrowserPreviewSettingsCard.tsx b/src/renderer/components/BrowserPreviewSettingsCard.tsx index 218884a03..fb3f74666 100644 --- a/src/renderer/components/BrowserPreviewSettingsCard.tsx +++ b/src/renderer/components/BrowserPreviewSettingsCard.tsx @@ -6,7 +6,7 @@ export default function BrowserPreviewSettingsCard() { const { settings, updateSettings, isLoading, isSaving } = useAppSettings(); return ( -
+
Show localhost links in browser diff --git a/src/renderer/components/DefaultAgentSettingsCard.tsx b/src/renderer/components/DefaultAgentSettingsCard.tsx index bb1fab9f0..c623b725d 100644 --- a/src/renderer/components/DefaultAgentSettingsCard.tsx +++ b/src/renderer/components/DefaultAgentSettingsCard.tsx @@ -21,7 +21,7 @@ const DefaultAgentSettingsCard: React.FC = () => { }; return ( -
+

Default agent

diff --git a/src/renderer/components/HiddenToolsSettingsCard.tsx b/src/renderer/components/HiddenToolsSettingsCard.tsx index 83ea76de9..f998a3120 100644 --- a/src/renderer/components/HiddenToolsSettingsCard.tsx +++ b/src/renderer/components/HiddenToolsSettingsCard.tsx @@ -64,7 +64,10 @@ export default function HiddenToolsSettingsCard() { }, [availability, labels]); return ( -

+
{sortedApps.map((app) => { const isDetected = availability[app.id] ?? app.alwaysAvailable ?? false; diff --git a/src/renderer/components/IntegrationsCard.tsx b/src/renderer/components/IntegrationsCard.tsx index 7e509e7b1..984e1d125 100644 --- a/src/renderer/components/IntegrationsCard.tsx +++ b/src/renderer/components/IntegrationsCard.tsx @@ -566,6 +566,7 @@ const IntegrationsCard: React.FC = () => { return ( <>
diff --git a/src/renderer/components/KeyboardSettingsCard.tsx b/src/renderer/components/KeyboardSettingsCard.tsx index 05dde88f7..9cac53316 100644 --- a/src/renderer/components/KeyboardSettingsCard.tsx +++ b/src/renderer/components/KeyboardSettingsCard.tsx @@ -284,7 +284,7 @@ const KeyboardSettingsCard: React.FC = () => { }; return ( -
+
{CONFIGURABLE_SHORTCUTS.map((shortcut) => (
diff --git a/src/renderer/components/NotificationSettingsCard.tsx b/src/renderer/components/NotificationSettingsCard.tsx index 8a07cbf6e..7c61d7ec2 100644 --- a/src/renderer/components/NotificationSettingsCard.tsx +++ b/src/renderer/components/NotificationSettingsCard.tsx @@ -22,7 +22,7 @@ const NotificationSettingsCard: React.FC = () => { }; return ( -
+
{/* Master toggle */}
diff --git a/src/renderer/components/RepositorySettingsCard.tsx b/src/renderer/components/RepositorySettingsCard.tsx index 4a3eeadc1..338f40bc4 100644 --- a/src/renderer/components/RepositorySettingsCard.tsx +++ b/src/renderer/components/RepositorySettingsCard.tsx @@ -26,7 +26,7 @@ const RepositorySettingsCard: React.FC = () => { }, [repository?.branchPrefix]); return ( -
+
{ const showResourceMonitor = settings?.interface?.showResourceMonitor ?? false; return ( -
+
Show resource monitor in titlebar diff --git a/src/renderer/components/ReviewAgentSettingsCard.tsx b/src/renderer/components/ReviewAgentSettingsCard.tsx index 8834776a0..9d4b48a44 100644 --- a/src/renderer/components/ReviewAgentSettingsCard.tsx +++ b/src/renderer/components/ReviewAgentSettingsCard.tsx @@ -54,7 +54,10 @@ const ReviewAgentSettingsCard: React.FC = () => { }; return ( -
+

Review preset

diff --git a/src/renderer/components/RightSidebarSettingsCard.tsx b/src/renderer/components/RightSidebarSettingsCard.tsx index b98064842..8b7cd3146 100644 --- a/src/renderer/components/RightSidebarSettingsCard.tsx +++ b/src/renderer/components/RightSidebarSettingsCard.tsx @@ -10,7 +10,7 @@ const RightSidebarSettingsCard: React.FC = () => { const autoRightSidebarBehavior = interfaceSettings?.autoRightSidebarBehavior ?? false; return ( -
+ @@ -346,33 +433,45 @@ export const SettingsPage: React.FC = ({ initialTab, onClose {/* Content container */} - {currentContent && ( -
-
- {/* Page title */} -
-
-

{currentContent.title}

-

{currentContent.description}

-
- -
- - {/* Sections */} - {currentContent.sections.map((section, index) => ( -
- {section.title && ( -
-

{section.title}

- {section.action &&
{section.action}
} +
+
+ {searchQuery.trim() ? ( + + ) : ( + currentContent && ( + <> + {/* Page title */} +
+
+

{currentContent.title}

+

+ {currentContent.description} +

+
+ +
+ + {/* Sections */} + {currentContent.sections.map((section, index) => ( +
+ {section.title && ( +
+

{section.title}

+ {section.action &&
{section.action}
} +
+ )} + {section.component}
- )} - {section.component} -
- ))} -
+ ))} + + ) + )}
- )} +
diff --git a/src/renderer/components/SettingsSearchInput.tsx b/src/renderer/components/SettingsSearchInput.tsx new file mode 100644 index 000000000..81f8fd707 --- /dev/null +++ b/src/renderer/components/SettingsSearchInput.tsx @@ -0,0 +1,55 @@ +import React, { useEffect, forwardRef } from 'react'; +import { Search, X, Command } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +interface SettingsSearchInputProps { + query: string; + onQueryChange: (query: string) => void; +} + +export const SettingsSearchInput = forwardRef( + ({ query, onQueryChange }, ref) => { + return ( +
+ + onQueryChange(e.target.value)} + className="h-9 w-72 rounded-lg border-border/60 bg-muted/40 pl-9 pr-16 text-sm placeholder:text-muted-foreground/60 hover:border-border hover:bg-muted focus:border-primary/50 focus:bg-background" + /> +
+ {query ? ( + + ) : ( + + + F + + )} +
+
+ ); + } +); + +SettingsSearchInput.displayName = 'SettingsSearchInput'; + +export default SettingsSearchInput; diff --git a/src/renderer/components/SettingsSearchResults.tsx b/src/renderer/components/SettingsSearchResults.tsx new file mode 100644 index 000000000..7d5d78f9c --- /dev/null +++ b/src/renderer/components/SettingsSearchResults.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import { motion } from 'motion/react'; +import { ChevronRight } from 'lucide-react'; +import type { SearchResult } from '@/hooks/useSettingsSearch'; +import { groupResultsByTab } from '@/hooks/useSettingsSearch'; +import type { SettingsPageTab } from './SettingsPage'; + +const TAB_LABELS: Record = { + general: 'General', + 'clis-models': 'Agents', + integrations: 'Integrations', + repository: 'Repository', + interface: 'Interface', + account: 'Account', + docs: 'Docs', +}; + +interface SettingsSearchResultsProps { + results: SearchResult[]; + query: string; + onResultClick: (tabId: SettingsPageTab, settingId: string) => void; +} + +function highlightMatches(text: string, query: string) { + if (!query.trim()) return text; + + const queryLower = query.toLowerCase(); + const textLower = text.toLowerCase(); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + let index = textLower.indexOf(queryLower); + while (index !== -1) { + if (index > lastIndex) { + parts.push(text.slice(lastIndex, index)); + } + parts.push( + + {text.slice(index, index + query.length)} + + ); + lastIndex = index + query.length; + index = textLower.indexOf(queryLower, lastIndex); + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length > 0 ? parts : text; +} + +export const SettingsSearchResults: React.FC = ({ + results, + query, + onResultClick, +}) => { + const grouped = React.useMemo(() => groupResultsByTab(results), [results]); + + if (results.length === 0) { + return ( +
+

No settings found for "{query}"

+
+ ); + } + + return ( +
+
+

+ {results.length} {results.length === 1 ? 'result' : 'results'} +

+
+ + {Array.from(grouped.entries()).map(([tabId, tabResults]) => ( + +

+ {TAB_LABELS[tabId as SettingsPageTab] || tabId} +

+
+ {tabResults.map((result) => ( + + ))} +
+
+ ))} +
+ ); +}; + +export default SettingsSearchResults; diff --git a/src/renderer/components/TaskHoverActionCard.tsx b/src/renderer/components/TaskHoverActionCard.tsx index 82a899050..411686fe7 100644 --- a/src/renderer/components/TaskHoverActionCard.tsx +++ b/src/renderer/components/TaskHoverActionCard.tsx @@ -13,7 +13,7 @@ const TaskHoverActionCard: React.FC = () => { }; return ( -
+

Task hover action

diff --git a/src/renderer/components/TaskSettingsRows.tsx b/src/renderer/components/TaskSettingsRows.tsx index 95dd7d854..8866cdfc3 100644 --- a/src/renderer/components/TaskSettingsRows.tsx +++ b/src/renderer/components/TaskSettingsRows.tsx @@ -21,7 +21,7 @@ export const AutoGenerateTaskNamesRow: React.FC = ({ taskSettings }) = (taskSettings.errorScope === 'autoGenerateName' || taskSettings.errorScope === 'load'); return ( -

+

Auto-generate task names

@@ -47,7 +47,7 @@ export const AutoInferTaskNamesRow: React.FC = ({ taskSettings }) => { const isDisabled = taskSettings.loading || taskSettings.saving || !taskSettings.autoGenerateName; return ( -
+

@@ -78,7 +78,7 @@ export const AutoApproveByDefaultRow: React.FC = ({ taskSettings }) => Boolean(taskSettings.error) && taskSettings.errorScope === 'autoApproveByDefault'; return ( -

+
@@ -120,7 +120,7 @@ export const CreateWorktreeByDefaultRow: React.FC = ({ taskSettings }) Boolean(taskSettings.error) && taskSettings.errorScope === 'createWorktreeByDefault'; return ( -
+

Create worktree by default

@@ -143,7 +143,7 @@ export const AutoTrustWorktreesRow: React.FC = ({ taskSettings }) => { const showError = Boolean(taskSettings.error) && taskSettings.errorScope === 'autoTrustWorktrees'; return ( -
+
diff --git a/src/renderer/components/TelemetryCard.tsx b/src/renderer/components/TelemetryCard.tsx index 9ed96f4a4..e42c8097c 100644 --- a/src/renderer/components/TelemetryCard.tsx +++ b/src/renderer/components/TelemetryCard.tsx @@ -8,7 +8,7 @@ const TelemetryCard: React.FC = () => { useTelemetryConsent(); return ( -
+

Privacy & Telemetry

diff --git a/src/renderer/components/TerminalSettingsCard.tsx b/src/renderer/components/TerminalSettingsCard.tsx index 511335ba7..03bb536e5 100644 --- a/src/renderer/components/TerminalSettingsCard.tsx +++ b/src/renderer/components/TerminalSettingsCard.tsx @@ -177,7 +177,7 @@ const TerminalSettingsCard: React.FC = () => { const hasAnyResults = filteredPopularOptions.length > 0 || filteredInstalledOptions.length > 0; return ( -
+

Terminal font

diff --git a/src/renderer/components/ThemeCard.tsx b/src/renderer/components/ThemeCard.tsx index a41da76f1..1d07aaa76 100644 --- a/src/renderer/components/ThemeCard.tsx +++ b/src/renderer/components/ThemeCard.tsx @@ -13,7 +13,7 @@ const ThemeCard: React.FC = () => { ]; return ( -
+
Color mode
Choose how Emdash looks.
diff --git a/src/renderer/components/WorkspaceProviderInfoCard.tsx b/src/renderer/components/WorkspaceProviderInfoCard.tsx index 4dd88b0e8..671b5f338 100644 --- a/src/renderer/components/WorkspaceProviderInfoCard.tsx +++ b/src/renderer/components/WorkspaceProviderInfoCard.tsx @@ -261,7 +261,10 @@ export const WorkspaceProviderInfoCard: React.FC = () => { return ( <> -
+

Workspace Provider

Run tasks on your own infrastructure. Configure provision and teardown scripts per-project diff --git a/src/renderer/hooks/useSettingsSearch.ts b/src/renderer/hooks/useSettingsSearch.ts new file mode 100644 index 000000000..f4e8e3f01 --- /dev/null +++ b/src/renderer/hooks/useSettingsSearch.ts @@ -0,0 +1,474 @@ +import { useMemo, useState, useCallback, useRef, useEffect } from 'react'; + +export interface SearchableSetting { + id: string; + label: string; + description: string; + category: string; + keywords: string[]; + synonyms: string[]; + tabId: string; +} + +// Comprehensive settings index with semantic keywords and synonyms +export const SETTINGS_INDEX: SearchableSetting[] = [ + // General Tab + { + id: 'telemetry', + label: 'Telemetry', + description: 'Help improve Emdash by sending anonymous usage data', + category: 'Privacy', + keywords: ['telemetry', 'analytics', 'tracking', 'data collection', 'usage'], + synonyms: ['statistics', 'metrics', 'monitoring', 'reporting', 'analytics'], + tabId: 'general', + }, + { + id: 'auto-generate-task-names', + label: 'Auto-generate task names', + description: 'Automatically generate task names from context', + category: 'Tasks', + keywords: ['task names', 'auto generate', 'automatic naming', 'task titles'], + synonyms: ['naming', 'titles', 'automatic names', 'generate names'], + tabId: 'general', + }, + { + id: 'auto-infer-task-names', + label: 'Auto-infer task names', + description: 'Infer task names from the first message', + category: 'Tasks', + keywords: ['infer names', 'detect names', 'smart naming'], + synonyms: ['guess names', 'auto detect', 'smart titles'], + tabId: 'general', + }, + { + id: 'auto-approve-by-default', + label: 'Auto-approve by default', + description: 'Automatically approve tool operations without asking', + category: 'Tasks', + keywords: ['auto approve', 'permissions', 'tool approval', 'automatic approval'], + synonyms: ['skip prompts', 'less strict', 'automatic permissions', 'approve automatically'], + tabId: 'general', + }, + { + id: 'create-worktree-by-default', + label: 'Create worktree by default', + description: 'Create a git worktree for each new task', + category: 'Tasks', + keywords: ['worktree', 'git worktree', 'branch isolation', 'task isolation'], + synonyms: ['git branches', 'isolated workspace', 'separate branch'], + tabId: 'general', + }, + { + id: 'auto-trust-worktrees', + label: 'Auto-trust worktrees', + description: 'Automatically trust repositories in new worktrees', + category: 'Tasks', + keywords: ['trust', 'worktree trust', 'repository trust', 'git trust'], + synonyms: ['trusted repos', 'auto trust', 'repository verification'], + tabId: 'general', + }, + { + id: 'notifications-enabled', + label: 'Enable notifications', + description: 'Show notification messages', + category: 'Notifications', + keywords: ['notifications', 'alerts', 'messages', 'banner'], + synonyms: ['popups', 'alerts', 'notify', 'warnings'], + tabId: 'general', + }, + { + id: 'notification-sound', + label: 'Notification sound', + description: 'Play a sound when notifications appear', + category: 'Notifications', + keywords: ['sound', 'audio', 'notification sound', 'beep'], + synonyms: ['chime', 'alert sound', 'ding', 'notification audio'], + tabId: 'general', + }, + { + id: 'os-notifications', + label: 'OS notifications', + description: 'Show native operating system notifications', + category: 'Notifications', + keywords: ['os notifications', 'native notifications', 'system notifications'], + synonyms: ['desktop alerts', 'native alerts', 'system alerts'], + tabId: 'general', + }, + { + id: 'sound-focus-mode', + label: 'Sound focus mode', + description: 'When to play notification sounds', + category: 'Notifications', + keywords: ['focus mode', 'sound mode', 'notification timing'], + synonyms: ['quiet mode', 'do not disturb', 'sound settings'], + tabId: 'general', + }, + { + id: 'sound-profile', + label: 'Sound profile', + description: 'Choose the notification sound style', + category: 'Notifications', + keywords: ['sound profile', 'audio theme', 'notification style'], + synonyms: ['sound theme', 'audio profile', 'notification tone'], + tabId: 'general', + }, + + // Agents Tab + { + id: 'default-agent', + label: 'Default agent', + description: 'Choose the default AI agent for new tasks', + category: 'Agents', + keywords: ['default agent', 'default model', 'preferred agent', 'ai provider'], + synonyms: ['default ai', 'main agent', 'preferred model', 'default llm'], + tabId: 'clis-models', + }, + { + id: 'review-agent', + label: 'Review agent', + description: 'Enable code review with an AI agent', + category: 'Agents', + keywords: ['review', 'code review', 'review agent', 'pr review'], + synonyms: ['code check', 'review bot', 'pr reviewer', 'code analysis'], + tabId: 'clis-models', + }, + { + id: 'review-prompt', + label: 'Review prompt', + description: 'Custom instructions for the review agent', + category: 'Agents', + keywords: ['review prompt', 'custom instructions', 'review settings'], + synonyms: ['review instructions', 'custom prompt', 'review guidelines'], + tabId: 'clis-models', + }, + { + id: 'cli-agents', + label: 'CLI agents', + description: 'Manage installed CLI agents and their status', + category: 'Agents', + keywords: ['cli agents', 'command line', 'installed agents', 'agent status'], + synonyms: ['terminal agents', 'cli tools', 'command agents'], + tabId: 'clis-models', + }, + + // Integrations Tab + { + id: 'integrations', + label: 'Integrations', + description: 'Connect external services like GitHub, Linear, Jira', + category: 'Integrations', + keywords: ['integrations', 'github', 'linear', 'jira', 'gitlab', 'services'], + synonyms: ['connections', 'external services', 'third party', 'apps'], + tabId: 'integrations', + }, + { + id: 'workspace-provider', + label: 'Workspace provider', + description: 'Configure workspace provisioning settings', + category: 'Integrations', + keywords: ['workspace', 'provisioning', 'cloud workspace', 'remote'], + synonyms: ['cloud dev', 'remote workspace', 'workspace setup'], + tabId: 'integrations', + }, + + // Repository Tab + { + id: 'branch-prefix', + label: 'Branch prefix', + description: 'Prefix for automatically generated branch names', + category: 'Repository', + keywords: ['branch prefix', 'branch naming', 'git branches', 'prefix'], + synonyms: ['branch name', 'naming convention', 'git prefix'], + tabId: 'repository', + }, + { + id: 'push-on-create', + label: 'Push on create', + description: 'Automatically push branches when creating a PR', + category: 'Repository', + keywords: ['push', 'auto push', 'git push', 'branch push'], + synonyms: ['auto publish', 'automatic push', 'push branches'], + tabId: 'repository', + }, + { + id: 'auto-close-issues', + label: 'Auto-close linked issues', + description: 'Close linked issues when PR is created', + category: 'Repository', + keywords: ['close issues', 'linked issues', 'auto close', 'issue tracking'], + synonyms: ['close tickets', 'auto resolve', 'issue automation'], + tabId: 'repository', + }, + + // Interface Tab + { + id: 'theme', + label: 'Theme', + description: 'Choose your preferred color theme', + category: 'Appearance', + keywords: ['theme', 'color scheme', 'dark mode', 'light mode', 'appearance'], + synonyms: ['colors', 'visual style', 'ui theme', 'dark theme', 'light theme'], + tabId: 'interface', + }, + { + id: 'terminal-font-family', + label: 'Terminal font family', + description: 'Custom font for the integrated terminal', + category: 'Terminal', + keywords: ['terminal font', 'font family', 'monospace', 'terminal style'], + synonyms: ['console font', 'terminal typeface', 'font settings'], + tabId: 'interface', + }, + { + id: 'terminal-font-size', + label: 'Terminal font size', + description: 'Font size for the integrated terminal', + category: 'Terminal', + keywords: ['font size', 'terminal size', 'text size', 'zoom'], + synonyms: ['terminal zoom', 'text zoom', 'font scaling'], + tabId: 'interface', + }, + { + id: 'auto-copy-selection', + label: 'Auto-copy on selection', + description: 'Automatically copy text when selected in terminal', + category: 'Terminal', + keywords: ['auto copy', 'copy selection', 'clipboard', 'terminal copy'], + synonyms: ['automatic copy', 'select to copy', 'clipboard sync'], + tabId: 'interface', + }, + { + id: 'mac-option-is-meta', + label: 'Mac Option is Meta', + description: 'Use Option key as Meta key in terminal', + category: 'Terminal', + keywords: ['option key', 'meta key', 'mac terminal', 'keyboard'], + synonyms: ['alt key', 'terminal keys', 'modifier keys'], + tabId: 'interface', + }, + { + id: 'keyboard-shortcuts', + label: 'Keyboard shortcuts', + description: 'Customize keyboard shortcuts for common actions', + category: 'Keyboard', + keywords: ['keyboard', 'shortcuts', 'keybindings', 'hotkeys', 'custom keys'], + synonyms: ['key combos', 'shortcut keys', 'keyboard commands', 'accelerators'], + tabId: 'interface', + }, + { + id: 'auto-right-sidebar', + label: 'Auto right sidebar', + description: 'Automatically show/hide right sidebar based on context', + category: 'Workspace', + keywords: ['sidebar', 'right panel', 'auto hide', 'auto show'], + synonyms: ['side panel', 'right sidebar', 'panel behavior'], + tabId: 'interface', + }, + { + id: 'resource-monitor', + label: 'Resource monitor', + description: 'Show system resource usage in the sidebar', + category: 'Workspace', + keywords: ['resource monitor', 'cpu', 'memory', 'system stats'], + synonyms: ['performance', 'system monitor', 'usage stats', 'resources'], + tabId: 'interface', + }, + { + id: 'browser-preview', + label: 'Browser preview', + description: 'Enable built-in browser preview for web projects', + category: 'Workspace', + keywords: ['browser', 'preview', 'web preview', 'live preview'], + synonyms: ['web view', 'browser view', 'site preview', 'live reload'], + tabId: 'interface', + }, + { + id: 'task-hover-action', + label: 'Task hover action', + description: 'Action to show when hovering over tasks', + category: 'Workspace', + keywords: ['task hover', 'hover action', 'delete', 'archive'], + synonyms: ['hover behavior', 'task actions', 'mouse hover'], + tabId: 'interface', + }, + { + id: 'hidden-tools', + label: 'Hidden tools', + description: 'Manage which tools are hidden from the interface', + category: 'Tools', + keywords: ['hidden tools', 'tool visibility', 'show tools', 'hide tools'], + synonyms: ['tool settings', 'visible tools', 'tool preferences'], + tabId: 'interface', + }, +]; + +export interface SearchResult { + setting: SearchableSetting; + score: number; + matches: { + label: boolean; + description: boolean; + keywords: boolean; + synonyms: boolean; + }; +} + +function calculateSimilarity(query: string, text: string): number { + const queryLower = query.toLowerCase(); + const textLower = text.toLowerCase(); + + // Exact match + if (textLower === queryLower) return 1; + + // Starts with query + if (textLower.startsWith(queryLower)) return 0.9; + + // Contains query as whole word + if (new RegExp(`\\b${queryLower}\\b`).test(textLower)) return 0.8; + + // Contains query + if (textLower.includes(queryLower)) return 0.6; + + // Check for word-by-word partial matches + const queryWords = queryLower.split(/\s+/); + const textWords = textLower.split(/\s+/); + + let matchCount = 0; + for (const queryWord of queryWords) { + if (queryWord.length < 2) continue; + for (const textWord of textWords) { + if (textWord.includes(queryWord) || queryWord.includes(textWord)) { + matchCount++; + break; + } + } + } + + if (matchCount > 0) { + return 0.4 * (matchCount / queryWords.length); + } + + return 0; +} + +export function searchSettings(query: string): SearchResult[] { + if (!query.trim()) return []; + + const results: SearchResult[] = []; + + for (const setting of SETTINGS_INDEX) { + let score = 0; + const matches = { + label: false, + description: false, + keywords: false, + synonyms: false, + }; + + // Check label (highest weight) + const labelScore = calculateSimilarity(query, setting.label); + if (labelScore > 0) { + score += labelScore * 4; + matches.label = true; + } + + // Check description + const descScore = calculateSimilarity(query, setting.description); + if (descScore > 0) { + score += descScore * 2; + matches.description = true; + } + + // Check keywords + for (const keyword of setting.keywords) { + const keywordScore = calculateSimilarity(query, keyword); + if (keywordScore > 0) { + score += keywordScore * 1.5; + matches.keywords = true; + } + } + + // Check synonyms (semantic matching) + for (const synonym of setting.synonyms) { + const synonymScore = calculateSimilarity(query, synonym); + if (synonymScore > 0) { + score += synonymScore * 1.2; + matches.synonyms = true; + } + } + + if (score > 0.3) { + results.push({ setting, score, matches }); + } + } + + // Sort by score descending + return results.sort((a, b) => b.score - a.score); +} + +export function useSettingsSearch() { + const [query, setQuery] = useState(''); + const inputRef = useRef(null); + + const focusSearch = useCallback(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + return { + query, + setQuery, + inputRef, + focusSearch, + }; +} + +export function groupResultsByTab(results: SearchResult[]) { + const grouped = new Map(); + + for (const result of results) { + const tabId = result.setting.tabId; + if (!grouped.has(tabId)) { + grouped.set(tabId, []); + } + grouped.get(tabId)!.push(result); + } + + return grouped; +} + +// Highlight matches - returns array of strings and indices for highlighting +export function getHighlightSegments( + text: string, + query: string +): Array<{ text: string; match: boolean }> { + if (!query.trim()) return [{ text, match: false }]; + + const queryLower = query.toLowerCase(); + const textLower = text.toLowerCase(); + + const segments: Array<{ text: string; match: boolean }> = []; + let lastIndex = 0; + + // Find all occurrences + let index = textLower.indexOf(queryLower); + while (index !== -1) { + // Add text before match + if (index > lastIndex) { + segments.push({ text: text.slice(lastIndex, index), match: false }); + } + + // Add highlighted match + segments.push({ text: text.slice(index, index + query.length), match: true }); + + lastIndex = index + query.length; + index = textLower.indexOf(queryLower, lastIndex); + } + + // Add remaining text + if (lastIndex < text.length) { + segments.push({ text: text.slice(lastIndex), match: false }); + } + + return segments.length > 0 ? segments : [{ text, match: false }]; +} From 81437913dd7b1d3c1609a0ce698d4a2541359f60 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Apr 2026 10:55:37 +0200 Subject: [PATCH 2/4] refactor: improve settings search components and integrations --- src/renderer/components/IntegrationsCard.tsx | 55 +++++----- src/renderer/components/SettingsPage.tsx | 19 ++-- .../components/SettingsSearchInput.tsx | 14 +-- .../components/SettingsSearchResults.tsx | 12 +-- .../components/integrations/registry.ts | 25 +++++ src/renderer/hooks/useSettingsSearch.ts | 100 ++++++++++++------ src/test/renderer/settingsSearchIndex.test.ts | 28 +++++ 7 files changed, 170 insertions(+), 83 deletions(-) create mode 100644 src/renderer/components/integrations/registry.ts create mode 100644 src/test/renderer/settingsSearchIndex.test.ts diff --git a/src/renderer/components/IntegrationsCard.tsx b/src/renderer/components/IntegrationsCard.tsx index 984e1d125..90c088017 100644 --- a/src/renderer/components/IntegrationsCard.tsx +++ b/src/renderer/components/IntegrationsCard.tsx @@ -26,6 +26,7 @@ import { useModalContext } from '../contexts/ModalProvider'; import GitLabSetupForm from './integrations/GitLabSetupForm'; import ForgejoSetupForm from './integrations/ForgejoSetupForm'; import { GithubDeviceFlowModal } from './GithubDeviceFlowModal'; +import { INTEGRATION_REGISTRY, type IntegrationId } from './integrations/registry'; /** Light mode: original SVG colors. Dark / dark-black: primary colour. */ const SvgLogo = ({ raw }: { raw: string }) => { @@ -472,21 +473,27 @@ const IntegrationsCard: React.FC = () => { } }, []); - const integrations = [ + // Runtime state per integration. Static metadata (name/description/order) + // lives in INTEGRATION_REGISTRY so it can be shared with the settings + // search index. + const integrationRuntime: Record< + IntegrationId, { - id: 'github', - name: 'GitHub', - description: 'Connect your repositories', + logoSvg: string; + connected: boolean; + loading: boolean; + onConnect: () => void; + onDisconnect: () => void; + } + > = { + github: { logoSvg: githubSvg, connected: authenticated, loading: isLoading, onConnect: handleGithubConnect, onDisconnect: handleGithubDisconnect, }, - { - id: 'linear', - name: 'Linear', - description: 'Work on Linear tickets', + linear: { logoSvg: linearSvg, connected: linearConnected, loading: linearLoading, @@ -496,10 +503,7 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handleLinearDisconnect, }, - { - id: 'jira', - name: 'Jira', - description: 'Work on Jira tickets', + jira: { logoSvg: jiraSvg, connected: jiraConnected, loading: jiraLoading, @@ -509,10 +513,7 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handleJiraDisconnect, }, - { - id: 'gitlab', - name: 'GitLab', - description: 'Work on GitLab issues', + gitlab: { logoSvg: gitlabSvg, connected: gitlabConnected, loading: gitlabLoading, @@ -522,10 +523,7 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handleGitlabDisconnect, }, - { - id: 'plain', - name: 'Plain', - description: 'Work on support threads', + plain: { logoSvg: plainSvg, connected: plainConnected, loading: plainLoading, @@ -535,10 +533,7 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handlePlainDisconnect, }, - { - id: 'forgejo', - name: 'Forgejo', - description: 'Work on Forgejo issues', + forgejo: { logoSvg: forgejoSvg, connected: forgejoConnected, loading: forgejoLoading, @@ -548,10 +543,7 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handleForgejoDisconnect, }, - { - id: 'sentry', - name: 'Sentry', - description: 'Fix errors from Sentry', + sentry: { logoSvg: sentrySvg, connected: sentryConnected, loading: sentryLoading, @@ -561,7 +553,12 @@ const IntegrationsCard: React.FC = () => { }, onDisconnect: handleSentryDisconnect, }, - ]; + }; + + const integrations = INTEGRATION_REGISTRY.map((entry) => ({ + ...entry, + ...integrationRuntime[entry.id as IntegrationId], + })); return ( <> diff --git a/src/renderer/components/SettingsPage.tsx b/src/renderer/components/SettingsPage.tsx index 3556dc866..718b7f264 100644 --- a/src/renderer/components/SettingsPage.tsx +++ b/src/renderer/components/SettingsPage.tsx @@ -7,7 +7,6 @@ import { Button } from './ui/button'; import SettingsSearchInput from './SettingsSearchInput'; import SettingsSearchResults from './SettingsSearchResults'; import { searchSettings, type SearchResult } from '@/hooks/useSettingsSearch'; -import { groupResultsByTab } from '@/hooks/useSettingsSearch'; // Import existing settings cards import TelemetryCard from './TelemetryCard'; @@ -139,6 +138,14 @@ export const SETTING_ELEMENT_IDS: Record = { 'hidden-tools': 'hidden-tools-settings-card', }; +function getTabButtonClasses(isActive: boolean, isExternal: boolean): string { + const base = + 'flex w-full items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors'; + if (isActive) return `${base} bg-muted text-foreground`; + if (isExternal) return `${base} text-muted-foreground hover:bg-muted/60`; + return `${base} text-foreground hover:bg-muted/60`; +} + interface SectionConfig { title?: string; action?: React.ReactNode; @@ -417,13 +424,7 @@ export const SettingsPage: React.FC = ({ initialTab, onClose setActiveTab(tab.id as SettingsPageTab); } }} - className={`flex w-full items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors ${ - isActive - ? 'bg-muted text-foreground' - : tab.isExternal - ? 'text-muted-foreground hover:bg-muted/60' - : 'text-foreground hover:bg-muted/60' - }`} + className={getTabButtonClasses(isActive, !!tab.isExternal)} > {tab.label} {tab.isExternal && } @@ -431,8 +432,6 @@ export const SettingsPage: React.FC = ({ initialTab, onClose ); })} - - {/* Content container */}

{searchQuery.trim() ? ( diff --git a/src/renderer/components/SettingsSearchInput.tsx b/src/renderer/components/SettingsSearchInput.tsx index 81f8fd707..5245840a7 100644 --- a/src/renderer/components/SettingsSearchInput.tsx +++ b/src/renderer/components/SettingsSearchInput.tsx @@ -1,5 +1,7 @@ -import React, { useEffect, forwardRef } from 'react'; -import { Search, X, Command } from 'lucide-react'; +import React, { forwardRef } from 'react'; +import { Search, X } from 'lucide-react'; + +const isMac = typeof navigator !== 'undefined' && /mac|iphone|ipad|ipod/i.test(navigator.platform); import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; @@ -29,9 +31,7 @@ export const SettingsSearchInput = forwardRef { onQueryChange(''); - if (typeof ref === 'function') { - // ref callback - can't easily call focus - } else if (ref) { + if (ref && typeof ref === 'object') { ref.current?.focus(); } }} @@ -39,8 +39,8 @@ export const SettingsSearchInput = forwardRef ) : ( - - + + {isMac ? '⌘' : 'Ctrl'} F )} diff --git a/src/renderer/components/SettingsSearchResults.tsx b/src/renderer/components/SettingsSearchResults.tsx index 7d5d78f9c..a0343fe1d 100644 --- a/src/renderer/components/SettingsSearchResults.tsx +++ b/src/renderer/components/SettingsSearchResults.tsx @@ -35,7 +35,7 @@ function highlightMatches(text: string, query: string) { parts.push(text.slice(lastIndex, index)); } parts.push( - + {text.slice(index, index + query.length)} ); @@ -67,11 +67,9 @@ export const SettingsSearchResults: React.FC = ({ return (
-
-

- {results.length} {results.length === 1 ? 'result' : 'results'} -

-
+

+ {results.length} {results.length === 1 ? 'result' : 'results'} +

{Array.from(grouped.entries()).map(([tabId, tabResults]) => ( = ({ key={result.setting.id} type="button" onClick={() => onResultClick(tabId as SettingsPageTab, result.setting.id)} - className="group flex items-start justify-between gap-4 rounded-lg border border-border/60 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50" + className="group flex items-start justify-between gap-4 rounded-md px-4 py-3 text-left transition-colors hover:bg-muted/60" >
diff --git a/src/renderer/components/integrations/registry.ts b/src/renderer/components/integrations/registry.ts new file mode 100644 index 000000000..1aa60a606 --- /dev/null +++ b/src/renderer/components/integrations/registry.ts @@ -0,0 +1,25 @@ +// Static metadata for all integrations rendered by IntegrationsCard. +// +// This is the source of truth for which integrations exist. Both the +// IntegrationsCard UI and the settings search index are validated against +// this list (see src/test/renderer/settingsSearchIndex.test.ts) so that +// adding a new integration here will fail tests until it is also added to +// SETTINGS_INDEX. + +export interface IntegrationRegistryEntry { + id: string; + name: string; + description: string; +} + +export const INTEGRATION_REGISTRY: readonly IntegrationRegistryEntry[] = [ + { id: 'github', name: 'GitHub', description: 'Connect your repositories' }, + { id: 'linear', name: 'Linear', description: 'Work on Linear tickets' }, + { id: 'jira', name: 'Jira', description: 'Work on Jira tickets' }, + { id: 'gitlab', name: 'GitLab', description: 'Work on GitLab issues' }, + { id: 'plain', name: 'Plain', description: 'Work on support threads' }, + { id: 'forgejo', name: 'Forgejo', description: 'Work on Forgejo issues' }, + { id: 'sentry', name: 'Sentry', description: 'Fix errors from Sentry' }, +] as const; + +export type IntegrationId = (typeof INTEGRATION_REGISTRY)[number]['id']; diff --git a/src/renderer/hooks/useSettingsSearch.ts b/src/renderer/hooks/useSettingsSearch.ts index f4e8e3f01..7558f03a8 100644 --- a/src/renderer/hooks/useSettingsSearch.ts +++ b/src/renderer/hooks/useSettingsSearch.ts @@ -1,5 +1,3 @@ -import { useMemo, useState, useCallback, useRef, useEffect } from 'react'; - export interface SearchableSetting { id: string; label: string; @@ -161,6 +159,69 @@ export const SETTINGS_INDEX: SearchableSetting[] = [ synonyms: ['connections', 'external services', 'third party', 'apps'], tabId: 'integrations', }, + { + id: 'github', + label: 'GitHub', + description: 'Connect your GitHub repositories', + category: 'Integrations', + keywords: ['github', 'git', 'repository', 'pull request', 'pr', 'issues'], + synonyms: ['gh', 'octocat', 'source control'], + tabId: 'integrations', + }, + { + id: 'gitlab', + label: 'GitLab', + description: 'Work on GitLab issues', + category: 'Integrations', + keywords: ['gitlab', 'git', 'repository', 'merge request', 'mr', 'issues'], + synonyms: ['gl', 'source control'], + tabId: 'integrations', + }, + { + id: 'forgejo', + label: 'Forgejo', + description: 'Work on Forgejo issues', + category: 'Integrations', + keywords: ['forgejo', 'gitea', 'git', 'self-hosted', 'repository', 'issues'], + synonyms: ['codeberg', 'self hosted git'], + tabId: 'integrations', + }, + { + id: 'linear', + label: 'Linear', + description: 'Work on Linear tickets', + category: 'Integrations', + keywords: ['linear', 'tickets', 'issues', 'project management', 'tracker'], + synonyms: ['linear app', 'issue tracker'], + tabId: 'integrations', + }, + { + id: 'jira', + label: 'Jira', + description: 'Work on Jira tickets', + category: 'Integrations', + keywords: ['jira', 'atlassian', 'tickets', 'issues', 'project management'], + synonyms: ['issue tracker', 'atlassian jira'], + tabId: 'integrations', + }, + { + id: 'plain', + label: 'Plain', + description: 'Work on support threads', + category: 'Integrations', + keywords: ['plain', 'support', 'customer support', 'tickets', 'threads'], + synonyms: ['helpdesk', 'support inbox'], + tabId: 'integrations', + }, + { + id: 'sentry', + label: 'Sentry', + description: 'Fix errors from Sentry', + category: 'Integrations', + keywords: ['sentry', 'errors', 'exceptions', 'monitoring', 'crash reporting'], + synonyms: ['error tracking', 'observability', 'bug tracker'], + tabId: 'integrations', + }, { id: 'workspace-provider', label: 'Workspace provider', @@ -313,20 +374,17 @@ export interface SearchResult { }; } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + function calculateSimilarity(query: string, text: string): number { const queryLower = query.toLowerCase(); const textLower = text.toLowerCase(); - // Exact match if (textLower === queryLower) return 1; - - // Starts with query if (textLower.startsWith(queryLower)) return 0.9; - - // Contains query as whole word - if (new RegExp(`\\b${queryLower}\\b`).test(textLower)) return 0.8; - - // Contains query + if (new RegExp(`\\b${escapeRegExp(queryLower)}\\b`).test(textLower)) return 0.8; if (textLower.includes(queryLower)) return 0.6; // Check for word-by-word partial matches @@ -337,7 +395,7 @@ function calculateSimilarity(query: string, text: string): number { for (const queryWord of queryWords) { if (queryWord.length < 2) continue; for (const textWord of textWords) { - if (textWord.includes(queryWord) || queryWord.includes(textWord)) { + if (textWord.length >= 2 && textWord.includes(queryWord)) { matchCount++; break; } @@ -402,28 +460,10 @@ export function searchSettings(query: string): SearchResult[] { } } - // Sort by score descending return results.sort((a, b) => b.score - a.score); } -export function useSettingsSearch() { - const [query, setQuery] = useState(''); - const inputRef = useRef(null); - - const focusSearch = useCallback(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }, []); - - return { - query, - setQuery, - inputRef, - focusSearch, - }; -} - -export function groupResultsByTab(results: SearchResult[]) { +export function groupResultsByTab(results: SearchResult[]): Map { const grouped = new Map(); for (const result of results) { diff --git a/src/test/renderer/settingsSearchIndex.test.ts b/src/test/renderer/settingsSearchIndex.test.ts new file mode 100644 index 000000000..98e808475 --- /dev/null +++ b/src/test/renderer/settingsSearchIndex.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { SETTINGS_INDEX } from '../../renderer/hooks/useSettingsSearch'; +import { INTEGRATION_REGISTRY } from '../../renderer/components/integrations/registry'; + +describe('settings search index', () => { + it('has an entry for every integration in the registry', () => { + const indexedIds = new Set(SETTINGS_INDEX.map((entry) => entry.id)); + const missing = INTEGRATION_REGISTRY.filter((entry) => !indexedIds.has(entry.id)).map( + (entry) => entry.id + ); + + expect( + missing, + `INTEGRATION_REGISTRY contains ids that are not present in SETTINGS_INDEX: ${missing.join( + ', ' + )}. Add an entry to SETTINGS_INDEX in src/renderer/hooks/useSettingsSearch.ts so the new integration is searchable.` + ).toEqual([]); + }); + + it('routes every integration entry to the integrations tab', () => { + const integrationIds = new Set(INTEGRATION_REGISTRY.map((entry) => entry.id)); + const misrouted = SETTINGS_INDEX.filter( + (entry) => integrationIds.has(entry.id) && entry.tabId !== 'integrations' + ); + + expect(misrouted).toEqual([]); + }); +}); From 35be7fc4da4b6d27a5b131966b8b850065b5bda7 Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:01:52 +0200 Subject: [PATCH 3/4] refactor: simplify settings search index and scoring Collapse keywords/synonyms into a single aliases array, drop unused category/matches fields and the duplicated getHighlightSegments helper, and replace the multi-tier similarity + word fallback with a flat exact/startsWith/includes scorer. Behaviour for normal queries is unchanged; edge cases (special chars, 1-char queries) are more predictable. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/hooks/useSettingsSearch.ts | 299 +++++------------------- 1 file changed, 62 insertions(+), 237 deletions(-) diff --git a/src/renderer/hooks/useSettingsSearch.ts b/src/renderer/hooks/useSettingsSearch.ts index 7558f03a8..7c70a1655 100644 --- a/src/renderer/hooks/useSettingsSearch.ts +++ b/src/renderer/hooks/useSettingsSearch.ts @@ -2,112 +2,95 @@ export interface SearchableSetting { id: string; label: string; description: string; - category: string; - keywords: string[]; - synonyms: string[]; + aliases: string[]; tabId: string; } -// Comprehensive settings index with semantic keywords and synonyms +export interface SearchResult { + setting: SearchableSetting; + score: number; +} + +// Hand-curated index of searchable settings. Order does not matter; results +// are ranked by score. When adding a new setting, prefer adding a few +// natural-language aliases over adding more weight tiers. export const SETTINGS_INDEX: SearchableSetting[] = [ // General Tab { id: 'telemetry', label: 'Telemetry', description: 'Help improve Emdash by sending anonymous usage data', - category: 'Privacy', - keywords: ['telemetry', 'analytics', 'tracking', 'data collection', 'usage'], - synonyms: ['statistics', 'metrics', 'monitoring', 'reporting', 'analytics'], + aliases: ['analytics', 'tracking', 'data collection', 'usage', 'metrics', 'monitoring'], tabId: 'general', }, { id: 'auto-generate-task-names', label: 'Auto-generate task names', description: 'Automatically generate task names from context', - category: 'Tasks', - keywords: ['task names', 'auto generate', 'automatic naming', 'task titles'], - synonyms: ['naming', 'titles', 'automatic names', 'generate names'], + aliases: ['naming', 'titles', 'automatic names', 'task titles'], tabId: 'general', }, { id: 'auto-infer-task-names', label: 'Auto-infer task names', description: 'Infer task names from the first message', - category: 'Tasks', - keywords: ['infer names', 'detect names', 'smart naming'], - synonyms: ['guess names', 'auto detect', 'smart titles'], + aliases: ['detect names', 'smart naming', 'guess names'], tabId: 'general', }, { id: 'auto-approve-by-default', label: 'Auto-approve by default', description: 'Automatically approve tool operations without asking', - category: 'Tasks', - keywords: ['auto approve', 'permissions', 'tool approval', 'automatic approval'], - synonyms: ['skip prompts', 'less strict', 'automatic permissions', 'approve automatically'], + aliases: ['permissions', 'tool approval', 'skip prompts', 'approve automatically'], tabId: 'general', }, { id: 'create-worktree-by-default', label: 'Create worktree by default', description: 'Create a git worktree for each new task', - category: 'Tasks', - keywords: ['worktree', 'git worktree', 'branch isolation', 'task isolation'], - synonyms: ['git branches', 'isolated workspace', 'separate branch'], + aliases: ['worktree', 'git worktree', 'branch isolation', 'isolated workspace'], tabId: 'general', }, { id: 'auto-trust-worktrees', label: 'Auto-trust worktrees', description: 'Automatically trust repositories in new worktrees', - category: 'Tasks', - keywords: ['trust', 'worktree trust', 'repository trust', 'git trust'], - synonyms: ['trusted repos', 'auto trust', 'repository verification'], + aliases: ['trust', 'repository trust', 'trusted repos'], tabId: 'general', }, { id: 'notifications-enabled', label: 'Enable notifications', description: 'Show notification messages', - category: 'Notifications', - keywords: ['notifications', 'alerts', 'messages', 'banner'], - synonyms: ['popups', 'alerts', 'notify', 'warnings'], + aliases: ['alerts', 'banner', 'popups', 'notify'], tabId: 'general', }, { id: 'notification-sound', label: 'Notification sound', description: 'Play a sound when notifications appear', - category: 'Notifications', - keywords: ['sound', 'audio', 'notification sound', 'beep'], - synonyms: ['chime', 'alert sound', 'ding', 'notification audio'], + aliases: ['audio', 'beep', 'chime', 'ding', 'alert sound'], tabId: 'general', }, { id: 'os-notifications', label: 'OS notifications', description: 'Show native operating system notifications', - category: 'Notifications', - keywords: ['os notifications', 'native notifications', 'system notifications'], - synonyms: ['desktop alerts', 'native alerts', 'system alerts'], + aliases: ['native notifications', 'system notifications', 'desktop alerts'], tabId: 'general', }, { id: 'sound-focus-mode', label: 'Sound focus mode', description: 'When to play notification sounds', - category: 'Notifications', - keywords: ['focus mode', 'sound mode', 'notification timing'], - synonyms: ['quiet mode', 'do not disturb', 'sound settings'], + aliases: ['focus mode', 'quiet mode', 'do not disturb'], tabId: 'general', }, { id: 'sound-profile', label: 'Sound profile', description: 'Choose the notification sound style', - category: 'Notifications', - keywords: ['sound profile', 'audio theme', 'notification style'], - synonyms: ['sound theme', 'audio profile', 'notification tone'], + aliases: ['audio theme', 'sound theme', 'notification tone'], tabId: 'general', }, @@ -116,36 +99,28 @@ export const SETTINGS_INDEX: SearchableSetting[] = [ id: 'default-agent', label: 'Default agent', description: 'Choose the default AI agent for new tasks', - category: 'Agents', - keywords: ['default agent', 'default model', 'preferred agent', 'ai provider'], - synonyms: ['default ai', 'main agent', 'preferred model', 'default llm'], + aliases: ['default model', 'preferred agent', 'ai provider', 'default llm'], tabId: 'clis-models', }, { id: 'review-agent', label: 'Review agent', description: 'Enable code review with an AI agent', - category: 'Agents', - keywords: ['review', 'code review', 'review agent', 'pr review'], - synonyms: ['code check', 'review bot', 'pr reviewer', 'code analysis'], + aliases: ['code review', 'pr review', 'review bot', 'code analysis'], tabId: 'clis-models', }, { id: 'review-prompt', label: 'Review prompt', description: 'Custom instructions for the review agent', - category: 'Agents', - keywords: ['review prompt', 'custom instructions', 'review settings'], - synonyms: ['review instructions', 'custom prompt', 'review guidelines'], + aliases: ['custom instructions', 'review instructions', 'review guidelines'], tabId: 'clis-models', }, { id: 'cli-agents', label: 'CLI agents', description: 'Manage installed CLI agents and their status', - category: 'Agents', - keywords: ['cli agents', 'command line', 'installed agents', 'agent status'], - synonyms: ['terminal agents', 'cli tools', 'command agents'], + aliases: ['command line', 'installed agents', 'terminal agents', 'cli tools'], tabId: 'clis-models', }, @@ -154,81 +129,63 @@ export const SETTINGS_INDEX: SearchableSetting[] = [ id: 'integrations', label: 'Integrations', description: 'Connect external services like GitHub, Linear, Jira', - category: 'Integrations', - keywords: ['integrations', 'github', 'linear', 'jira', 'gitlab', 'services'], - synonyms: ['connections', 'external services', 'third party', 'apps'], + aliases: ['connections', 'external services', 'third party', 'apps'], tabId: 'integrations', }, { id: 'github', label: 'GitHub', description: 'Connect your GitHub repositories', - category: 'Integrations', - keywords: ['github', 'git', 'repository', 'pull request', 'pr', 'issues'], - synonyms: ['gh', 'octocat', 'source control'], + aliases: ['gh', 'git', 'repository', 'pull request', 'pr', 'octocat', 'source control'], tabId: 'integrations', }, { id: 'gitlab', label: 'GitLab', description: 'Work on GitLab issues', - category: 'Integrations', - keywords: ['gitlab', 'git', 'repository', 'merge request', 'mr', 'issues'], - synonyms: ['gl', 'source control'], + aliases: ['gl', 'git', 'repository', 'merge request', 'mr', 'source control'], tabId: 'integrations', }, { id: 'forgejo', label: 'Forgejo', description: 'Work on Forgejo issues', - category: 'Integrations', - keywords: ['forgejo', 'gitea', 'git', 'self-hosted', 'repository', 'issues'], - synonyms: ['codeberg', 'self hosted git'], + aliases: ['gitea', 'self-hosted', 'self hosted git', 'codeberg', 'repository'], tabId: 'integrations', }, { id: 'linear', label: 'Linear', description: 'Work on Linear tickets', - category: 'Integrations', - keywords: ['linear', 'tickets', 'issues', 'project management', 'tracker'], - synonyms: ['linear app', 'issue tracker'], + aliases: ['tickets', 'issues', 'project management', 'issue tracker'], tabId: 'integrations', }, { id: 'jira', label: 'Jira', description: 'Work on Jira tickets', - category: 'Integrations', - keywords: ['jira', 'atlassian', 'tickets', 'issues', 'project management'], - synonyms: ['issue tracker', 'atlassian jira'], + aliases: ['atlassian', 'tickets', 'issues', 'project management'], tabId: 'integrations', }, { id: 'plain', label: 'Plain', description: 'Work on support threads', - category: 'Integrations', - keywords: ['plain', 'support', 'customer support', 'tickets', 'threads'], - synonyms: ['helpdesk', 'support inbox'], + aliases: ['support', 'customer support', 'helpdesk', 'support inbox'], tabId: 'integrations', }, { id: 'sentry', label: 'Sentry', description: 'Fix errors from Sentry', - category: 'Integrations', - keywords: ['sentry', 'errors', 'exceptions', 'monitoring', 'crash reporting'], - synonyms: ['error tracking', 'observability', 'bug tracker'], + aliases: ['errors', 'exceptions', 'monitoring', 'error tracking', 'observability'], tabId: 'integrations', }, { id: 'workspace-provider', label: 'Workspace provider', description: 'Configure workspace provisioning settings', - category: 'Integrations', - keywords: ['workspace', 'provisioning', 'cloud workspace', 'remote'], - synonyms: ['cloud dev', 'remote workspace', 'workspace setup'], + aliases: ['provisioning', 'cloud workspace', 'remote workspace'], tabId: 'integrations', }, @@ -237,27 +194,21 @@ export const SETTINGS_INDEX: SearchableSetting[] = [ id: 'branch-prefix', label: 'Branch prefix', description: 'Prefix for automatically generated branch names', - category: 'Repository', - keywords: ['branch prefix', 'branch naming', 'git branches', 'prefix'], - synonyms: ['branch name', 'naming convention', 'git prefix'], + aliases: ['branch naming', 'git branches', 'naming convention'], tabId: 'repository', }, { id: 'push-on-create', label: 'Push on create', description: 'Automatically push branches when creating a PR', - category: 'Repository', - keywords: ['push', 'auto push', 'git push', 'branch push'], - synonyms: ['auto publish', 'automatic push', 'push branches'], + aliases: ['auto push', 'git push', 'auto publish'], tabId: 'repository', }, { id: 'auto-close-issues', label: 'Auto-close linked issues', description: 'Close linked issues when PR is created', - category: 'Repository', - keywords: ['close issues', 'linked issues', 'auto close', 'issue tracking'], - synonyms: ['close tickets', 'auto resolve', 'issue automation'], + aliases: ['close tickets', 'issue automation', 'auto resolve'], tabId: 'repository', }, @@ -266,197 +217,107 @@ export const SETTINGS_INDEX: SearchableSetting[] = [ id: 'theme', label: 'Theme', description: 'Choose your preferred color theme', - category: 'Appearance', - keywords: ['theme', 'color scheme', 'dark mode', 'light mode', 'appearance'], - synonyms: ['colors', 'visual style', 'ui theme', 'dark theme', 'light theme'], + aliases: ['color scheme', 'dark mode', 'light mode', 'appearance', 'colors', 'ui theme'], tabId: 'interface', }, { id: 'terminal-font-family', label: 'Terminal font family', description: 'Custom font for the integrated terminal', - category: 'Terminal', - keywords: ['terminal font', 'font family', 'monospace', 'terminal style'], - synonyms: ['console font', 'terminal typeface', 'font settings'], + aliases: ['terminal font', 'monospace', 'console font', 'typeface'], tabId: 'interface', }, { id: 'terminal-font-size', label: 'Terminal font size', description: 'Font size for the integrated terminal', - category: 'Terminal', - keywords: ['font size', 'terminal size', 'text size', 'zoom'], - synonyms: ['terminal zoom', 'text zoom', 'font scaling'], + aliases: ['text size', 'terminal zoom', 'font scaling'], tabId: 'interface', }, { id: 'auto-copy-selection', label: 'Auto-copy on selection', description: 'Automatically copy text when selected in terminal', - category: 'Terminal', - keywords: ['auto copy', 'copy selection', 'clipboard', 'terminal copy'], - synonyms: ['automatic copy', 'select to copy', 'clipboard sync'], + aliases: ['clipboard', 'select to copy', 'terminal copy'], tabId: 'interface', }, { id: 'mac-option-is-meta', label: 'Mac Option is Meta', description: 'Use Option key as Meta key in terminal', - category: 'Terminal', - keywords: ['option key', 'meta key', 'mac terminal', 'keyboard'], - synonyms: ['alt key', 'terminal keys', 'modifier keys'], + aliases: ['option key', 'meta key', 'alt key', 'modifier keys'], tabId: 'interface', }, { id: 'keyboard-shortcuts', label: 'Keyboard shortcuts', description: 'Customize keyboard shortcuts for common actions', - category: 'Keyboard', - keywords: ['keyboard', 'shortcuts', 'keybindings', 'hotkeys', 'custom keys'], - synonyms: ['key combos', 'shortcut keys', 'keyboard commands', 'accelerators'], + aliases: ['keybindings', 'hotkeys', 'shortcut keys', 'accelerators'], tabId: 'interface', }, { id: 'auto-right-sidebar', label: 'Auto right sidebar', description: 'Automatically show/hide right sidebar based on context', - category: 'Workspace', - keywords: ['sidebar', 'right panel', 'auto hide', 'auto show'], - synonyms: ['side panel', 'right sidebar', 'panel behavior'], + aliases: ['side panel', 'right panel', 'panel behavior'], tabId: 'interface', }, { id: 'resource-monitor', label: 'Resource monitor', description: 'Show system resource usage in the sidebar', - category: 'Workspace', - keywords: ['resource monitor', 'cpu', 'memory', 'system stats'], - synonyms: ['performance', 'system monitor', 'usage stats', 'resources'], + aliases: ['cpu', 'memory', 'system stats', 'performance', 'usage stats'], tabId: 'interface', }, { id: 'browser-preview', label: 'Browser preview', description: 'Enable built-in browser preview for web projects', - category: 'Workspace', - keywords: ['browser', 'preview', 'web preview', 'live preview'], - synonyms: ['web view', 'browser view', 'site preview', 'live reload'], + aliases: ['web preview', 'live preview', 'web view', 'live reload'], tabId: 'interface', }, { id: 'task-hover-action', label: 'Task hover action', description: 'Action to show when hovering over tasks', - category: 'Workspace', - keywords: ['task hover', 'hover action', 'delete', 'archive'], - synonyms: ['hover behavior', 'task actions', 'mouse hover'], + aliases: ['hover behavior', 'task actions', 'mouse hover'], tabId: 'interface', }, { id: 'hidden-tools', label: 'Hidden tools', description: 'Manage which tools are hidden from the interface', - category: 'Tools', - keywords: ['hidden tools', 'tool visibility', 'show tools', 'hide tools'], - synonyms: ['tool settings', 'visible tools', 'tool preferences'], + aliases: ['tool visibility', 'show tools', 'hide tools', 'tool preferences'], tabId: 'interface', }, ]; -export interface SearchResult { - setting: SearchableSetting; - score: number; - matches: { - label: boolean; - description: boolean; - keywords: boolean; - synonyms: boolean; - }; -} - -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function calculateSimilarity(query: string, text: string): number { - const queryLower = query.toLowerCase(); - const textLower = text.toLowerCase(); - - if (textLower === queryLower) return 1; - if (textLower.startsWith(queryLower)) return 0.9; - if (new RegExp(`\\b${escapeRegExp(queryLower)}\\b`).test(textLower)) return 0.8; - if (textLower.includes(queryLower)) return 0.6; - - // Check for word-by-word partial matches - const queryWords = queryLower.split(/\s+/); - const textWords = textLower.split(/\s+/); - - let matchCount = 0; - for (const queryWord of queryWords) { - if (queryWord.length < 2) continue; - for (const textWord of textWords) { - if (textWord.length >= 2 && textWord.includes(queryWord)) { - matchCount++; - break; - } - } - } - - if (matchCount > 0) { - return 0.4 * (matchCount / queryWords.length); - } - +// Score a single field. Higher is better; 0 means no match. +function scoreField(query: string, text: string): number { + const q = query.toLowerCase(); + const t = text.toLowerCase(); + if (t === q) return 1; + if (t.startsWith(q)) return 0.8; + if (t.includes(q)) return 0.5; return 0; } export function searchSettings(query: string): SearchResult[] { - if (!query.trim()) return []; + const trimmed = query.trim(); + if (!trimmed) return []; const results: SearchResult[] = []; for (const setting of SETTINGS_INDEX) { let score = 0; - const matches = { - label: false, - description: false, - keywords: false, - synonyms: false, - }; - - // Check label (highest weight) - const labelScore = calculateSimilarity(query, setting.label); - if (labelScore > 0) { - score += labelScore * 4; - matches.label = true; - } - - // Check description - const descScore = calculateSimilarity(query, setting.description); - if (descScore > 0) { - score += descScore * 2; - matches.description = true; - } - - // Check keywords - for (const keyword of setting.keywords) { - const keywordScore = calculateSimilarity(query, keyword); - if (keywordScore > 0) { - score += keywordScore * 1.5; - matches.keywords = true; - } + score += scoreField(trimmed, setting.label) * 4; + score += scoreField(trimmed, setting.description) * 2; + for (const alias of setting.aliases) { + score += scoreField(trimmed, alias) * 1.5; } - // Check synonyms (semantic matching) - for (const synonym of setting.synonyms) { - const synonymScore = calculateSimilarity(query, synonym); - if (synonymScore > 0) { - score += synonymScore * 1.2; - matches.synonyms = true; - } - } - - if (score > 0.3) { - results.push({ setting, score, matches }); + if (score > 0) { + results.push({ setting, score }); } } @@ -476,39 +337,3 @@ export function groupResultsByTab(results: SearchResult[]): Map { - if (!query.trim()) return [{ text, match: false }]; - - const queryLower = query.toLowerCase(); - const textLower = text.toLowerCase(); - - const segments: Array<{ text: string; match: boolean }> = []; - let lastIndex = 0; - - // Find all occurrences - let index = textLower.indexOf(queryLower); - while (index !== -1) { - // Add text before match - if (index > lastIndex) { - segments.push({ text: text.slice(lastIndex, index), match: false }); - } - - // Add highlighted match - segments.push({ text: text.slice(index, index + query.length), match: true }); - - lastIndex = index + query.length; - index = textLower.indexOf(queryLower, lastIndex); - } - - // Add remaining text - if (lastIndex < text.length) { - segments.push({ text: text.slice(lastIndex), match: false }); - } - - return segments.length > 0 ? segments : [{ text, match: false }]; -} From afa4f849ddabdf5eec5de87abb6e7553ba19ec8c Mon Sep 17 00:00:00 2001 From: Jan Burzinski <156842394+janburzinski@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:15:41 +0200 Subject: [PATCH 4/4] refactor(settings): inline scroll target into search index Move elementId from a separate SETTING_ELEMENT_IDS map in SettingsPage into the SearchableSetting type as a required field. Single source of truth, and TypeScript now enforces that every searchable setting has a scroll target. --- src/renderer/components/SettingsPage.tsx | 52 ++----------------- .../components/SettingsSearchResults.tsx | 4 +- src/renderer/hooks/useSettingsSearch.ts | 40 ++++++++++++++ 3 files changed, 45 insertions(+), 51 deletions(-) diff --git a/src/renderer/components/SettingsPage.tsx b/src/renderer/components/SettingsPage.tsx index 718b7f264..6adda849c 100644 --- a/src/renderer/components/SettingsPage.tsx +++ b/src/renderer/components/SettingsPage.tsx @@ -98,46 +98,6 @@ interface SettingsPageProps { onClose: () => void; } -// Map setting IDs to their DOM element IDs for scrolling -export const SETTING_ELEMENT_IDS: Record = { - // General - telemetry: 'telemetry-card', - 'auto-generate-task-names': 'auto-generate-task-names-row', - 'auto-infer-task-names': 'auto-infer-task-names-row', - 'auto-approve-by-default': 'auto-approve-by-default-row', - 'create-worktree-by-default': 'create-worktree-by-default-row', - 'auto-trust-worktrees': 'auto-trust-worktrees-row', - 'notifications-enabled': 'notification-settings-card', - 'notification-sound': 'notification-settings-card', - 'os-notifications': 'notification-settings-card', - 'sound-focus-mode': 'notification-settings-card', - 'sound-profile': 'notification-settings-card', - // Agents - 'default-agent': 'default-agent-settings-card', - 'review-agent': 'review-agent-settings-card', - 'review-prompt': 'review-agent-settings-card', - 'cli-agents': 'cli-agents-section', - // Integrations - integrations: 'integrations-card', - 'workspace-provider': 'workspace-provider-card', - // Repository - 'branch-prefix': 'repository-settings-card', - 'push-on-create': 'repository-settings-card', - 'auto-close-issues': 'repository-settings-card', - // Interface - theme: 'theme-card', - 'terminal-font-family': 'terminal-settings-card', - 'terminal-font-size': 'terminal-settings-card', - 'auto-copy-selection': 'terminal-settings-card', - 'mac-option-is-meta': 'terminal-settings-card', - 'keyboard-shortcuts': 'keyboard-settings-card', - 'auto-right-sidebar': 'right-sidebar-settings-card', - 'resource-monitor': 'resource-monitor-settings-card', - 'browser-preview': 'browser-preview-settings-card', - 'task-hover-action': 'task-hover-action-card', - 'hidden-tools': 'hidden-tools-settings-card', -}; - function getTabButtonClasses(isActive: boolean, isExternal: boolean): string { const base = 'flex w-full items-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-colors'; @@ -165,20 +125,14 @@ export const SettingsPage: React.FC = ({ initialTab, onClose setSearchResults(query.trim() ? searchSettings(query) : []); }, []); - const handleSearchResultClick = useCallback((tabId: SettingsPageTab, settingId: string) => { + const handleSearchResultClick = useCallback((tabId: SettingsPageTab, elementId: string) => { setSearchQuery(''); setSearchResults([]); setActiveTab(tabId); - // Use requestAnimationFrame to scroll after React has re-rendered + // Scroll after React has re-rendered the newly active tab. requestAnimationFrame(() => { - const elementId = SETTING_ELEMENT_IDS[settingId]; - if (elementId) { - const element = document.getElementById(elementId); - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - } + document.getElementById(elementId)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); }, []); diff --git a/src/renderer/components/SettingsSearchResults.tsx b/src/renderer/components/SettingsSearchResults.tsx index a0343fe1d..0e34d7927 100644 --- a/src/renderer/components/SettingsSearchResults.tsx +++ b/src/renderer/components/SettingsSearchResults.tsx @@ -18,7 +18,7 @@ const TAB_LABELS: Record = { interface SettingsSearchResultsProps { results: SearchResult[]; query: string; - onResultClick: (tabId: SettingsPageTab, settingId: string) => void; + onResultClick: (tabId: SettingsPageTab, elementId: string) => void; } function highlightMatches(text: string, query: string) { @@ -87,7 +87,7 @@ export const SettingsSearchResults: React.FC = ({