Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions app/renderer/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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' },
Expand All @@ -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' },
Expand All @@ -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
Expand Down Expand Up @@ -122,9 +148,9 @@ function CommandPalette({ onClose }: { onClose: () => void }) {

const settingsResults = React.useMemo<SettingsEntry[]>(() => {
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]);
Expand Down
7 changes: 6 additions & 1 deletion app/renderer/src/routes/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
return resolveTab(requested as DeepLinkId);
}
return 'general';
}, []); // Intentional — only consume the URL param on first mount.

Check warning on line 85 in app/renderer/src/routes/Settings.tsx

View workflow job for this annotation

GitHub Actions / Lint (renderer)

React Hook React.useMemo has a missing dependency: 'route'. Either include it or remove the dependency array
const [tab, setTab] = React.useState<SettingsTabId>(initialTab);
// Keep the visible tab in sync when the `?tab=` param changes AFTER mount —
// e.g. the ⌘K settings search navigates to /settings?tab=<id> while Settings
Expand Down Expand Up @@ -136,7 +136,12 @@
sidebar={
<SettingsNav
activeTab={tab}
onSelect={setTab}
// The route is the single source of truth for the visible tab: a nav
// click navigates `?tab=<id>` 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}
/>
Expand Down
60 changes: 60 additions & 0 deletions e2e/specs/settings-cmdk-search.t1.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Loading