diff --git a/app/renderer/src/components/CommandPalette.tsx b/app/renderer/src/components/CommandPalette.tsx index 21914e2c..6eab7dc5 100644 --- a/app/renderer/src/components/CommandPalette.tsx +++ b/app/renderer/src/components/CommandPalette.tsx @@ -3,6 +3,7 @@ import { Search } from 'lucide-react'; import { useMeetings, LIVE_SUMMARY_PREFIX } from '@/hooks/useMeetings'; import { searchNotes, snippet } from '@/lib/noteSearch'; import { navigate, useRoute } from '@/lib/router'; +import { isMac } from '@/lib/utils'; import type { Meeting } from '@/lib/ipc'; interface PaletteContextValue { @@ -37,12 +38,21 @@ interface SettingsEntry { tab: string; title: string; sub: string; + /** Settings that only render on macOS (behind `isMac` in GeneralTab): + * "Record system audio" and "Hide dock icon". On Windows those rows don't + * exist, so indexing them would jump to a tab where nothing's there — + * filtered out below when not on mac (#405). */ + macOnly?: boolean; } // Searchable index of the app's settings, mapped to the tab each one lives on // today (post-v0.6.2 nav rail). Selecting a result opens that tab. Transcription // settings now live on the AI tab, so they map to `ai`. Adapted from @Vassista's // PR #349 and retargeted to the current tab layout. +// +// Keep titles in sync with the rendered setting labels (GeneralTab/AiTab/ +// AboutTab etc.) — the T1 spec asserts a few titles still match, to catch the +// index drifting out from under a renamed control. const SETTINGS_INDEX: SettingsEntry[] = [ { id: 'general-name', tab: 'general', title: 'Your name', sub: 'In-app greeting' }, { id: 'general-theme', tab: 'general', title: 'Appearance', sub: 'Light, dark, or system theme' }, @@ -51,10 +61,19 @@ const SETTINGS_INDEX: SettingsEntry[] = [ { id: 'general-autodetect', tab: 'general', title: 'Auto-detected meetings', sub: 'Notify when another app uses the microphone' }, { id: 'general-notifications', tab: 'general', title: 'Post meeting notifications', sub: 'Desktop notifications when notes are ready' }, { id: 'general-mic', tab: 'general', title: 'Microphone', sub: 'Input device' }, - { id: 'general-system-audio', tab: 'general', title: 'Record system audio', sub: 'Capture other participants' }, + { id: 'general-system-audio', tab: 'general', title: 'Record system audio', sub: 'Capture other participants', macOnly: true }, { id: 'general-silence', tab: 'general', title: 'Auto-stop on silence', sub: 'End a recording when it goes quiet' }, { id: 'general-launch', tab: 'general', title: 'Launch on login', sub: 'Start Steno automatically' }, - { id: 'general-dock', tab: 'general', title: 'Hide dock icon', sub: 'Menu bar / tray icon only' }, + // Cross-platform row (Electron's Tray covers the macOS menu bar and the + // Windows system tray); its rendered label switches on platform, so mirror + // that here so the title matches whatever GeneralTab shows. + { + id: 'general-menubar', + tab: 'general', + title: isMac ? 'Show in menu bar' : 'Show in system tray', + sub: 'Quick-access icon in the menu bar or system tray', + }, + { id: 'general-dock', tab: 'general', title: 'Hide dock icon', sub: 'Menu bar / tray icon only', macOnly: true }, { id: 'ai-language', tab: 'ai', title: 'Language', sub: 'Transcription and summary language' }, { id: 'ai-transcription', tab: 'ai', title: 'Transcription model', sub: 'Parakeet or Whisper' }, { id: 'ai-save-recordings', tab: 'ai', title: 'Save recordings', sub: 'Keep the audio files after transcription' }, @@ -68,8 +87,15 @@ const SETTINGS_INDEX: SettingsEntry[] = [ { id: 'advanced-analytics', tab: 'advanced', title: 'Anonymous usage analytics', sub: 'Opt in or out' }, { id: 'developer', tab: 'developer', title: 'Developer', sub: 'Diagnostics and logs' }, { id: 'about', tab: 'about', title: 'About', sub: 'Version, release notes, check for updates' }, + { id: 'about-discord', tab: 'about', title: 'Discord', sub: 'Join the community, ask questions, share feedback' }, ]; +// Only the settings that actually render on this platform. macOS-only rows +// ("Record system audio", "Hide dock icon") don't exist on Windows/Linux, so +// they're dropped from the index there — otherwise selecting one would jump to +// a tab where the row isn't shown (#405). +const AVAILABLE_SETTINGS = SETTINGS_INDEX.filter((s) => !s.macOnly || isMac); + /** * Global ⌘K search. Provides `open()` to descendants (the sidebar trigger) and * renders the overlay itself. Searches notes (title + summary) from any screen @@ -122,9 +148,9 @@ function CommandPalette({ onClose }: { onClose: () => void }) { const settingsResults = React.useMemo(() => { if (!isSettingsMode) return []; - if (!query.trim()) return SETTINGS_INDEX; + if (!query.trim()) return AVAILABLE_SETTINGS; const q = query.trim().toLowerCase(); - return SETTINGS_INDEX.filter( + return AVAILABLE_SETTINGS.filter( (s) => s.title.toLowerCase().includes(q) || s.sub.toLowerCase().includes(q), ); }, [isSettingsMode, query]); diff --git a/app/renderer/src/routes/Settings.tsx b/app/renderer/src/routes/Settings.tsx index 6fda0e64..eb33b272 100644 --- a/app/renderer/src/routes/Settings.tsx +++ b/app/renderer/src/routes/Settings.tsx @@ -136,7 +136,12 @@ export function Settings() { sidebar={ ` and the route→tab effect below switches + // the tab. Calling setTab directly instead would leave the URL's + // `?tab=` stale, so a later ⌘K search to that same tab would bail on + // router's unchanged-hash early-return and silently do nothing (#405). + onSelect={(id) => navigate(`/settings?tab=${id}`)} onBack={() => navigate(getLastNonSettingsRoute() || '/')} version={version.data?.version} /> diff --git a/e2e/specs/settings-cmdk-search.t1.spec.ts b/e2e/specs/settings-cmdk-search.t1.spec.ts index d94da180..63b79462 100644 --- a/e2e/specs/settings-cmdk-search.t1.spec.ts +++ b/e2e/specs/settings-cmdk-search.t1.spec.ts @@ -69,3 +69,63 @@ test('selecting a setting navigates to its tab and switches the visible tab', as await expect(page.locator(settingsPage)).toContainText('Launch on login'); await expect(page.locator(settingsPage)).not.toContainText('AI provider'); }); + +test('search still works after a manual nav-rail tab switch (#405 regression)', async ({ + launchApp, +}) => { + const { page } = await launchApp(launchOpts); + await openSettings(page); + + // 1. ⌘K → jump to a setting on the AI tab. This navigates `?tab=ai`. + await page.keyboard.press('ControlOrMeta+k'); + await page.locator(input).fill('ai provider'); + await expect(page.locator(result)).toHaveCount(1); + await page.keyboard.press('Enter'); + await expect(page.locator(palette)).toBeHidden(); + await expect(page.locator(settingsPage)).toContainText('AI provider'); + + // 2. Click a DIFFERENT tab (General) in the nav rail. Pre-fix this called + // setTab only and left the URL's `?tab=ai` stale; now it navigates + // `?tab=general`, keeping the route the single source of truth. + await page.locator('[data-settings-nav="general"]').click(); + await expect(page.locator(settingsPage)).toContainText('Launch on login'); + await expect(page.locator(settingsPage)).not.toContainText('AI provider'); + + // 3. ⌘K → jump to the AI-tab setting AGAIN. Pre-fix, navigate('?tab=ai') + // bailed on router's unchanged-hash early-return (the URL still said ai), + // the route effect never fired, and the visible tab stayed stuck on General. + await page.keyboard.press('ControlOrMeta+k'); + await page.locator(input).fill('ai provider'); + await expect(page.locator(result)).toHaveCount(1); + await page.keyboard.press('Enter'); + + // The visible tab must actually switch back to AI — not silently do nothing. + await expect(page.locator(palette)).toBeHidden(); + await expect.poll(() => page.evaluate(() => window.location.hash)).toContain('tab=ai'); + await expect(page.locator(settingsPage)).toContainText('AI provider'); + await expect(page.locator(settingsPage)).not.toContainText('Launch on login'); +}); + +// Drift guard: a few index titles must still match the labels their tabs +// actually render, so a renamed control can't leave a stale search entry that +// jumps to a tab where nothing matches. Uses only cross-platform settings. +test('index titles match the rendered setting labels', async ({ launchApp }) => { + const { page } = await launchApp(launchOpts); + await openSettings(page); + + const cases: Array<{ query: string; label: string }> = [ + { query: 'ai provider', label: 'AI provider' }, + { query: 'discord', label: 'Discord' }, + { query: 'launch on login', label: 'Launch on login' }, + ]; + + for (const { query, label } of cases) { + await page.keyboard.press('ControlOrMeta+k'); + await page.locator(input).fill(query); + await expect(page.locator(result).filter({ hasText: label })).toHaveCount(1); + await page.keyboard.press('Enter'); + await expect(page.locator(palette)).toBeHidden(); + // The tab the index entry points at actually renders that exact label. + await expect(page.locator(settingsPage)).toContainText(label); + } +});