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
127 changes: 113 additions & 14 deletions app/renderer/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,6 +29,47 @@ 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=<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[] = [
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
{ 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' },
{ 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 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: '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' },
{ 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-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' },
{ 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
Expand All @@ -46,6 +87,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).
Expand Down Expand Up @@ -75,16 +120,28 @@ function CommandPalette({ onClose }: { onClose: () => void }) {
return () => prev?.focus?.();
}, []);

const results = React.useMemo<Meeting[]>(() => {
const settingsResults = React.useMemo<SettingsEntry[]>(() => {
if (!isSettingsMode) return [];
if (!query.trim()) return SETTINGS_INDEX;
const q = query.trim().toLowerCase();
return SETTINGS_INDEX.filter(
(s) => s.title.toLowerCase().includes(q) || s.sub.toLowerCase().includes(q),
);
}, [isSettingsMode, query]);

const noteResults = React.useMemo<Meeting[]>(() => {
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(() => {
Expand All @@ -99,6 +156,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();
Expand All @@ -108,21 +171,28 @@ 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.
e.preventDefault();
}
};

const activeId = results[selected] ? `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 (
<div
Expand All @@ -134,7 +204,7 @@ function CommandPalette({ onClose }: { onClose: () => void }) {
<div
role="dialog"
aria-modal="true"
aria-label="Search notes"
aria-label={isSettingsMode ? 'Search settings' : 'Search notes'}
className="relative mt-[12vh] w-[min(620px,92vw)] overflow-hidden rounded-xl shadow-[var(--shadow-md)]"
style={{ background: 'var(--surface-raised)', border: '1px solid hsl(var(--border))' }}
onMouseDown={(e) => e.stopPropagation()}
Expand All @@ -150,8 +220,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"
Expand All @@ -171,15 +241,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 ? (
<li
className="px-3.5 py-6 text-center text-[13px]"
style={{ color: 'var(--fg-muted)' }}
>
{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'}
</li>
) : isSettingsMode ? (
settingsResults.map((s, i) => (
<li
key={s.id}
id={`cmdk-opt-${i}`}
role="option"
aria-selected={i === selected}
data-index={i}
data-testid="command-palette-result"
className="mx-1 cursor-pointer rounded-md px-2.5 py-2"
style={i === selected ? { background: 'var(--surface-active)' } : undefined}
onMouseEnter={() => setSelected(i)}
onMouseDown={(e) => {
e.preventDefault();
openSetting(s);
}}
>
<div className="truncate text-[13.5px]" style={{ color: 'var(--fg-1)' }}>
{s.title}
</div>
<div className="truncate text-[12px]" style={{ color: 'var(--fg-muted)' }}>
{s.sub}
</div>
</li>
))
) : (
results.map((m, i) => {
noteResults.map((m, i) => {
const title = m.session_info.name || 'Untitled Meeting';
const sub = snippet(m.summary, query);
return (
Expand Down
17 changes: 17 additions & 0 deletions app/renderer/src/routes/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,30 @@
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
// 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));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
Comment on lines +93 to +95

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Changing the hash from a valid deep link such as /settings?tab=ai to /settings (or to an invalid tab) leaves the old tab visible because this effect has no fallback branch. The route-sync logic should also reset tab to 'general' when requested is absent or invalid, matching the mount behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/renderer/src/routes/Settings.tsx, line 93:

<comment>Changing the hash from a valid deep link such as `/settings?tab=ai` to `/settings` (or to an invalid tab) leaves the old tab visible because this effect has no fallback branch. The route-sync logic should also reset `tab` to `'general'` when `requested` is absent or invalid, matching the mount behavior.</comment>

<file context>
@@ -84,6 +84,16 @@ export function Settings() {
+  // 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));
+    }
</file context>
Suggested change
if (requested && (DEEP_LINK_IDS as readonly string[]).includes(requested)) {
setTab(resolveTab(requested as DeepLinkId));
}
if (requested && (DEEP_LINK_IDS as readonly string[]).includes(requested)) {
setTab(resolveTab(requested as DeepLinkId));
} else {
setTab('general');
}

}, [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
// would just be a leftover from the list view carried over on top of it.
const [templateEditorOpen, setTemplateEditorOpen] = React.useState(false);
// Leaving the Templates tab (via the nav rail OR the ⌘K settings search)
// unmounts TemplatesTab, but its editor-open flag lived on here — a stale
// `true` would suppress the page header when Templates is reopened. Reset it
// whenever the active tab isn't Templates.
React.useEffect(() => {
if (tab !== 'templates' && templateEditorOpen) setTemplateEditorOpen(false);
}, [tab, templateEditorOpen]);
const showPageHeader = !(tab === 'templates' && templateEditorOpen);

// Supplies AppShell's recordingStatus/onToggleRecording props directly —
Expand Down
71 changes: 71 additions & 0 deletions e2e/specs/settings-cmdk-search.t1.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
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');

// 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 ({
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');
});
Loading