From 6eb2378f09766e50dd4ddfbd7ec6b26aae99497f Mon Sep 17 00:00:00 2001 From: ruzin Date: Thu, 23 Jul 2026 19:10:08 +0100 Subject: [PATCH 1/2] feat(settings): context-aware Cmd+K settings search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While the Settings page is open, the global Cmd+K palette switches to "Search settings…" mode: it filters an index of the app's settings and navigates to the tab each one lives on, instead of searching notes. Ported from @Vassista's PR #349 and adapted to the current nav-rail Settings design — the search index is retargeted to the current tab ids (transcription settings map to the AI tab; adds About), and Settings.tsx now re-derives its visible tab from the route param after mount so a selection switches tabs even when Settings is already open. Does not touch the Settings layout/look. Adds a T1 spec covering settings-mode search + the route-reactive tab switch. Co-authored-by: Vassista --- .../src/components/CommandPalette.tsx | 119 +++++++++++++++--- app/renderer/src/routes/Settings.tsx | 10 ++ e2e/specs/settings-cmdk-search.t1.spec.ts | 65 ++++++++++ 3 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 e2e/specs/settings-cmdk-search.t1.spec.ts diff --git a/app/renderer/src/components/CommandPalette.tsx b/app/renderer/src/components/CommandPalette.tsx index 4cee51d4..f301ce69 100644 --- a/app/renderer/src/components/CommandPalette.tsx +++ b/app/renderer/src/components/CommandPalette.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { Search } from 'lucide-react'; import { useMeetings, LIVE_SUMMARY_PREFIX } from '@/hooks/useMeetings'; import { searchNotes, snippet } from '@/lib/noteSearch'; -import { navigate } from '@/lib/router'; +import { navigate, useRoute } from '@/lib/router'; import type { Meeting } from '@/lib/ipc'; interface PaletteContextValue { @@ -29,6 +29,45 @@ function recencyMs(m: Meeting): number { return new Date(m.session_info.processed_at ?? m.session_info.updated_at ?? 0).getTime(); } +interface SettingsEntry { + id: string; + /** A deep-link tab id accepted by Settings.tsx (its DEEP_LINK_IDS). Selecting + * a row navigates to `/settings?tab=`. Keep these in sync with the + * current nav rail (SettingsNav) — a stale id would land on the General tab. */ + tab: string; + title: string; + sub: string; +} + +// 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. +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' }, + { id: 'general-calendar', tab: 'general', title: 'Connect calendar', sub: 'Google, Outlook, scheduled meetings' }, + { 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' }, + { 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-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: 'Run from the menu bar only' }, + { 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' }, + { id: 'ai-autonotes', tab: 'ai', title: 'Generate notes automatically', sub: 'Summarise after transcription' }, + { id: 'ai-provider', tab: 'ai', title: 'AI provider', sub: 'Local, private server, cloud, or organisation' }, + { id: 'templates', tab: 'templates', title: 'Templates', sub: 'Custom note formats' }, + { id: 'org', tab: 'organisation', title: 'Organisation', sub: 'Sign in and back up notes to your org' }, + { id: 'advanced-setup', tab: 'advanced', title: 'Setup wizard', sub: 'Re-run first-time setup' }, + { id: 'advanced-clear', tab: 'advanced', title: 'Clear recording state', sub: 'Reset a stuck recording' }, + { 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' }, +]; + /** * Global ⌘K search. Provides `open()` to descendants (the sidebar trigger) and * renders the overlay itself. Searches notes (title + summary) from any screen @@ -46,6 +85,10 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode } function CommandPalette({ onClose }: { onClose: () => void }) { + // Context-aware: while the Settings page is open, ⌘K searches settings and + // jumps to the tab each one lives on; everywhere else it searches notes. + const currentRoute = useRoute(); + const isSettingsMode = currentRoute.startsWith('/settings'); const meetings = useMeetings(); // One recency sort feeds both paths: empty-query recents and search results // (searchNotes preserves input order, so results stay newest-first). @@ -75,16 +118,28 @@ function CommandPalette({ onClose }: { onClose: () => void }) { return () => prev?.focus?.(); }, []); - const results = React.useMemo(() => { + const settingsResults = React.useMemo(() => { + if (!isSettingsMode) return []; + if (!query.trim()) return SETTINGS_INDEX; + const q = query.toLowerCase(); + return SETTINGS_INDEX.filter( + (s) => s.title.toLowerCase().includes(q) || s.sub.toLowerCase().includes(q), + ); + }, [isSettingsMode, query]); + + const noteResults = React.useMemo(() => { + if (isSettingsMode) return []; if (!query.trim()) return sorted.slice(0, RECENT_COUNT); return searchNotes(sorted, query).slice(0, MAX_RESULTS); - }, [sorted, query]); + }, [isSettingsMode, sorted, query]); + + const resultCount = isSettingsMode ? settingsResults.length : noteResults.length; // Keep selection within [0, len-1]; never let it stick at -1 once results // appear (ArrowDown on an empty list would otherwise leave it negative). React.useEffect(() => { - setSelected((s) => Math.max(0, Math.min(s, results.length - 1))); - }, [results.length]); + setSelected((s) => Math.max(0, Math.min(s, resultCount - 1))); + }, [resultCount]); // Scroll the active option into view as the keyboard selection moves. React.useEffect(() => { @@ -99,6 +154,12 @@ function CommandPalette({ onClose }: { onClose: () => void }) { onClose(); }; + const openSetting = (s: SettingsEntry | undefined) => { + if (!s) return; + navigate(`/settings?tab=${s.tab}`); + onClose(); + }; + const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); @@ -108,13 +169,14 @@ function CommandPalette({ onClose }: { onClose: () => void }) { onClose(); } else if (e.key === 'ArrowDown') { e.preventDefault(); - setSelected((s) => Math.min(s + 1, results.length - 1)); + setSelected((s) => Math.min(s + 1, resultCount - 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); setSelected((s) => Math.max(s - 1, 0)); } else if (e.key === 'Enter') { e.preventDefault(); - openMeeting(results[selected]); + if (isSettingsMode) openSetting(settingsResults[selected]); + else openMeeting(noteResults[selected]); } else if (e.key === 'Tab') { // The input is the only tab stop in the dialog; trap Tab so focus can't // escape behind the aria-modal overlay. @@ -122,7 +184,7 @@ function CommandPalette({ onClose }: { onClose: () => void }) { } }; - const activeId = results[selected] ? `cmdk-opt-${selected}` : undefined; + const activeId = resultCount > 0 ? `cmdk-opt-${selected}` : undefined; return (
void }) {
e.stopPropagation()} @@ -150,8 +212,8 @@ function CommandPalette({ onClose }: { onClose: () => void }) { data-testid="command-palette-input" className="w-full bg-transparent text-[14px] outline-none" style={{ color: 'var(--fg-1)', fontFamily: 'var(--font-sans)' }} - placeholder="Search notes…" - aria-label="Search notes" + placeholder={isSettingsMode ? 'Search settings…' : 'Search notes…'} + aria-label={isSettingsMode ? 'Search settings' : 'Search notes'} role="combobox" aria-expanded="true" aria-controls="cmdk-listbox" @@ -171,15 +233,44 @@ function CommandPalette({ onClose }: { onClose: () => void }) { aria-label="Search results" className="scrollbar-clean max-h-[50vh] overflow-auto py-1" > - {results.length === 0 ? ( + {resultCount === 0 ? (
  • - {query.trim() ? `No notes match “${query.trim()}”` : 'No notes yet'} + {query.trim() + ? `No ${isSettingsMode ? 'settings' : 'notes'} match “${query.trim()}”` + : isSettingsMode + ? 'No settings' + : 'No notes yet'}
  • + ) : isSettingsMode ? ( + settingsResults.map((s, i) => ( +
  • setSelected(i)} + onMouseDown={(e) => { + e.preventDefault(); + openSetting(s); + }} + > +
    + {s.title} +
    +
    + {s.sub} +
    +
  • + )) ) : ( - results.map((m, i) => { + noteResults.map((m, i) => { const title = m.session_info.name || 'Untitled Meeting'; const sub = snippet(m.summary, query); return ( diff --git a/app/renderer/src/routes/Settings.tsx b/app/renderer/src/routes/Settings.tsx index 279bc442..59afd6b9 100644 --- a/app/renderer/src/routes/Settings.tsx +++ b/app/renderer/src/routes/Settings.tsx @@ -84,6 +84,16 @@ export function Settings() { return 'general'; }, []); // Intentional — only consume the URL param on first mount. const [tab, setTab] = React.useState(initialTab); + // Keep the visible tab in sync when the `?tab=` param changes AFTER mount — + // e.g. the ⌘K settings search navigates to /settings?tab= while Settings + // is already open. initialTab (above) only consumes the param on first mount, + // so without this the hash would update but the tab wouldn't switch. + React.useEffect(() => { + const requested = getRouteParam(route, 'tab'); + if (requested && (DEEP_LINK_IDS as readonly string[]).includes(requested)) { + setTab(resolveTab(requested as DeepLinkId)); + } + }, [route]); const version = useAppVersion(); // Templates' own editor is a full-page takeover with its own header/back // button — while it's open, the outer "Templates" title/description/divider diff --git a/e2e/specs/settings-cmdk-search.t1.spec.ts b/e2e/specs/settings-cmdk-search.t1.spec.ts new file mode 100644 index 00000000..ce482dc5 --- /dev/null +++ b/e2e/specs/settings-cmdk-search.t1.spec.ts @@ -0,0 +1,65 @@ +import { test, expect } from '../fixtures/electron'; +import type { Page } from '@playwright/test'; + +/** + * T1 — renderer-only, mock IPC. Drives the context-aware ⌘K palette while the + * Settings page is open: it must switch to "Search settings…" mode, filter the + * settings index, and navigate to the tab the chosen setting lives on — and, + * critically, switch the VISIBLE tab even when Settings is already open (the + * route-reactive fix in Settings.tsx). Ported/adapted from @Vassista's PR #349. + */ + +const palette = '[data-testid="command-palette"]'; +const input = '[data-testid="command-palette-input"]'; +const result = '[data-testid="command-palette-result"]'; +const settingsPage = '[data-testid="settings-page"]'; + +const launchOpts = { mockIpc: true } as const; + +async function openSettings(page: Page, tab?: string) { + await page.evaluate((t) => { + window.location.hash = t ? `#/settings?tab=${t}` : '#/settings'; + }, tab); + await expect(page.locator(settingsPage)).toBeVisible(); +} + +test('⌘K in Settings searches settings, not notes', async ({ launchApp }) => { + const { page } = await launchApp(launchOpts); + await openSettings(page); + + await page.keyboard.press('ControlOrMeta+k'); + await expect(page.locator(palette)).toBeVisible(); + + // Context-aware: the palette is in settings mode. + await expect(page.locator(input)).toHaveAttribute('placeholder', 'Search settings…'); + await expect(page.locator(palette)).toContainText('Microphone'); + await expect(page.locator(palette)).toContainText('AI provider'); + + // Filtering narrows the settings index. + await page.locator(input).fill('provider'); + await expect(page.locator(result)).toHaveCount(1); + await expect(page.locator(result).nth(0)).toContainText('AI provider'); +}); + +test('selecting a setting navigates to its tab and switches the visible tab', async ({ + launchApp, +}) => { + const { page } = await launchApp(launchOpts); + // Start on the AI tab so the jump to a General-tab setting has to actually + // switch tabs — this exercises the route-reactive sync, not just first mount. + await openSettings(page, 'ai'); + await expect(page.locator(settingsPage)).toContainText('AI provider'); + + await page.keyboard.press('ControlOrMeta+k'); + await page.locator(input).fill('launch on login'); + await expect(page.locator(result)).toHaveCount(1); + await expect(page.locator(result).nth(0)).toContainText('Launch on login'); + await page.keyboard.press('Enter'); + + // Palette closes, the route carries the target tab, and the General tab is + // now the one rendered (its content is visible; the AI-only row is gone). + await expect(page.locator(palette)).toBeHidden(); + await expect.poll(() => page.evaluate(() => window.location.hash)).toContain('tab=general'); + await expect(page.locator(settingsPage)).toContainText('Launch on login'); + await expect(page.locator(settingsPage)).not.toContainText('AI provider'); +}); From dd2faaf5906ddcdd616cf3e9fca580ed3f2f113c Mon Sep 17 00:00:00 2001 From: ruzin Date: Thu, 23 Jul 2026 19:51:56 +0100 Subject: [PATCH 2/2] fix(settings-search): address cubic review - Match the real label 'Post meeting notifications' (no hyphen) so the displayed setting is findable (cubic P3, conf 10). - Trim the query before filtering settings so leading/trailing whitespace still matches (cubic P3, conf 10). - Guard aria-activedescendant against a transiently out-of-range selection after the list shrinks (cubic P3). - Add Storage location + Scheduled meetings to the index and surface the menu-bar/tray icon setting, so more visible settings are discoverable (cubic P2). - Reset templateEditorOpen when leaving the Templates tab (via nav or search) so a stale flag can't suppress the page header (cubic P2). - Extend the T1 spec to cover the trim + real-label match. --- app/renderer/src/components/CommandPalette.tsx | 18 +++++++++++++----- app/renderer/src/routes/Settings.tsx | 7 +++++++ e2e/specs/settings-cmdk-search.t1.spec.ts | 6 ++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/renderer/src/components/CommandPalette.tsx b/app/renderer/src/components/CommandPalette.tsx index f301ce69..21914e2c 100644 --- a/app/renderer/src/components/CommandPalette.tsx +++ b/app/renderer/src/components/CommandPalette.tsx @@ -46,14 +46,15 @@ interface SettingsEntry { 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' }, - { id: 'general-calendar', tab: 'general', title: 'Connect calendar', sub: 'Google, Outlook, scheduled meetings' }, + { id: 'general-calendar', tab: 'general', title: 'Connect calendar', sub: 'Google, Outlook' }, + { id: 'general-scheduled', tab: 'general', title: 'Scheduled meetings', sub: 'Upcoming calendar events' }, { 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' }, + { 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-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: 'Run from the menu bar only' }, + { id: 'general-dock', tab: 'general', title: 'Hide dock icon', sub: 'Menu bar / tray icon only' }, { 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' }, @@ -61,6 +62,7 @@ const SETTINGS_INDEX: SettingsEntry[] = [ { id: 'ai-provider', tab: 'ai', title: 'AI provider', sub: 'Local, private server, cloud, or organisation' }, { id: 'templates', tab: 'templates', title: 'Templates', sub: 'Custom note formats' }, { id: 'org', tab: 'organisation', title: 'Organisation', sub: 'Sign in and back up notes to your org' }, + { id: 'advanced-storage', tab: 'advanced', title: 'Storage location', sub: 'Where notes and recordings are saved' }, { id: 'advanced-setup', tab: 'advanced', title: 'Setup wizard', sub: 'Re-run first-time setup' }, { id: 'advanced-clear', tab: 'advanced', title: 'Clear recording state', sub: 'Reset a stuck recording' }, { id: 'advanced-analytics', tab: 'advanced', title: 'Anonymous usage analytics', sub: 'Opt in or out' }, @@ -121,7 +123,7 @@ function CommandPalette({ onClose }: { onClose: () => void }) { const settingsResults = React.useMemo(() => { if (!isSettingsMode) return []; if (!query.trim()) return SETTINGS_INDEX; - const q = query.toLowerCase(); + const q = query.trim().toLowerCase(); return SETTINGS_INDEX.filter( (s) => s.title.toLowerCase().includes(q) || s.sub.toLowerCase().includes(q), ); @@ -184,7 +186,13 @@ function CommandPalette({ onClose }: { onClose: () => void }) { } }; - const activeId = resultCount > 0 ? `cmdk-opt-${selected}` : undefined; + // Guard against `selected` briefly pointing past the list right after it + // shrinks (before the clamp effect runs) — only expose activedescendant when + // an option actually exists at that index, so aria never references a + // nonexistent id. + const activeId = (isSettingsMode ? settingsResults[selected] : noteResults[selected]) + ? `cmdk-opt-${selected}` + : undefined; return (
    { + if (tab !== 'templates' && templateEditorOpen) setTemplateEditorOpen(false); + }, [tab, templateEditorOpen]); const showPageHeader = !(tab === 'templates' && templateEditorOpen); // Supplies AppShell's recordingStatus/onToggleRecording props directly — diff --git a/e2e/specs/settings-cmdk-search.t1.spec.ts b/e2e/specs/settings-cmdk-search.t1.spec.ts index ce482dc5..d94da180 100644 --- a/e2e/specs/settings-cmdk-search.t1.spec.ts +++ b/e2e/specs/settings-cmdk-search.t1.spec.ts @@ -39,6 +39,12 @@ test('⌘K in Settings searches settings, not notes', async ({ launchApp }) => { await page.locator(input).fill('provider'); await expect(page.locator(result)).toHaveCount(1); await expect(page.locator(result).nth(0)).toContainText('AI provider'); + + // Query is trimmed, and the index title matches the real settings label + // ("Post meeting notifications", no hyphen) so searching the visible label + // works even with surrounding whitespace. + await page.locator(input).fill(' post meeting '); + await expect(page.locator(result).filter({ hasText: 'Post meeting notifications' })).toHaveCount(1); }); test('selecting a setting navigates to its tab and switches the visible tab', async ({