From d9af3d61f8d15ccb1de0e59d75f4bcc584dc0ee6 Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 4 Sep 2026 13:28:42 -0400 Subject: [PATCH 01/35] docs(ux): add UX conventions for capability placement Nothing in the repo currently says where a new setting or action should live, which is how core functionality ended up split across a 32px toolbar, disconnected menu items, and hotkeys with zero on-screen affordance. Establishes placement tiers, the control bar's space budget, and a mandatory design-principles checklist so future additions stay findable. --- docs/ux-conventions.md | 76 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/ux-conventions.md diff --git a/docs/ux-conventions.md b/docs/ux-conventions.md new file mode 100644 index 00000000..19b1107b --- /dev/null +++ b/docs/ux-conventions.md @@ -0,0 +1,76 @@ +# UX Conventions + +This app has no design-system package to enforce consistency mechanically — it's one Electron +renderer using shadcn/ui + Tailwind tokens directly. These conventions exist to do the job a +shared library would otherwise do: give every contributor the same answer to "where does this +go" and "how should this look," so the app stays learnable as it grows instead of drifting into +an accretion of one-off menus, dialogs, and hotkeys that only their author can find. + +## 1. Placement tiers + +Classify every new user-facing capability into exactly one tier before building it. The tier +tells you which surface it belongs on. + +| Tier | Definition | Where it lives | +|---|---|---| +| **T0 — Always-visible** | Part of the primary loop, or changed multiple times per session | The control bar (`components/custom/control-panel/*-group.tsx`, styled via `control-panel/bar.ts`) | +| **T1 — Discoverable-on-demand** | Used occasionally per session or per week, not moment-to-moment | The command palette and/or a consolidated Settings entry point — never a bar icon | +| **T2 — Configure-once** | Set rarely, persists across sessions | The Settings hub (`configuration-dialog.tsx`) | +| **T3 — Power-user / hotkey** | Frequent during a live session but must not cost bar space | A hotkey (`lib/hotkeys.ts`), but it must also be listed in the hotkey cheat-sheet and, once it exists, the command palette — hotkey-only with zero on-screen affordance anywhere is not an acceptable end state | + +If a PR adds a new setting or action, its description should say which tier it is and name the +file(s) touched per this table. A tier that doesn't fit cleanly is a signal to ask, not to guess. + +> **Status note:** the command palette referenced above (T1/T3 discovery surface) is planned but +> not yet built. Until it exists, T1 actions land in Settings and T3 actions rely on the hotkey +> cheat-sheet alone. Update this note when the palette ships. + +## 2. The control bar has a fixed budget + +The control bar is deliberately compact (every control is 32px tall, one radius, grouped by +spacing rather than dividing rules — see the comment in `control-panel/bar.ts`) because it +overlays the user's screen during a live interview and must not obstruct it. Space there is the +scarcest resource in the app. Adding a control to the bar is a T0 decision, not a default — +prefer Settings, the cheat-sheet, or (once it exists) the command palette for anything that +isn't part of the primary loop. + +## 3. Dialog and notice composition + +The app has several independently hand-built dialogs (`change-password-dialog.tsx`, +`configuration-dialog.tsx`, `documentation-dialog.tsx`, `headphone-notice-dialog.tsx`, +`mock-interview-setup-dialog.tsx`, `permission-gate-dialog.tsx`, `save-history-dialog.tsx`) and +notices (`connecting-notice.tsx`, `trial-user-notice.tsx`). Each composes `components/ui/dialog.tsx` +independently today, which is how small inconsistencies (header spacing, footer button order, +scroll behavior) drift in over time. + +New dialogs and notices should compose a shared wrapper once one exists in +`components/custom/app-dialog.tsx` / `app-notice.tsx` (tracked as follow-up work). Until then, +match the closest existing example rather than inventing a new layout, and call out in review if +the shape diverges. + +## 4. Design principles (mandatory) + +Fixing where something lives is necessary but not sufficient — a technically-discoverable +feature can still fail a non-developer if it doesn't read as professional and easy to use. Every +new or changed UI surface must: + +- **Use plain, user-facing language**, not internal identifiers — labels, menu items, and error + messages are written for the person using the app, not for the codebase (e.g. "Buy Credits", + not a raw enum or hotkey constant name). +- **Define an explicit state for empty, loading, and error** — no surface ships with only a + "happy path" and a blank area or unhandled exception for everything else. +- **Stay within the existing token system** (`src/renderer/index.css`: colors, radius, spacing) — + no ad hoc colors or one-off spacing values. +- **Meet the accessibility baseline already used elsewhere** — `aria-label`s on icon-only + controls, visible focus states, and keyboard operability, matching the existing control bar and + dialogs. +- **Keep tone and iconography consistent** with the rest of the app, so unrelated screens read as + one product. + +## 5. Definition of done for a new feature/setting + +- [ ] Classified into a tier (§1) and placed on the tier's designated surface. +- [ ] If T3: added to `lib/hotkeys.ts` and appears in the hotkey cheat-sheet. +- [ ] New dialog/notice, if any, follows §3. +- [ ] Meets every principle in §4. +- [ ] `pnpm lint` passes. From 2fbb6e31bceb2e3db4aa33938653641a8031dc30 Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 4 Sep 2026 13:33:44 -0400 Subject: [PATCH 02/35] feat(home): add a navigation-hub home page / used to just redirect straight into /main with no page of its own, dropping users into the dense control console with zero orientation. Renders a real landing page instead: a primary Start Interview action, an account/credits summary, and Settings/Documentation entry points - the T1/T2 discovery surface the UX conventions doc calls for, applied to the first thing a user sees. Logged-out users still redirect to /auth/login unchanged. --- src/renderer/pages/home/index.tsx | 110 ++++++++++++++++++++++++++++++ src/renderer/pages/index.tsx | 13 ++-- 2 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/renderer/pages/home/index.tsx diff --git a/src/renderer/pages/home/index.tsx b/src/renderer/pages/home/index.tsx new file mode 100644 index 00000000..251c2278 --- /dev/null +++ b/src/renderer/pages/home/index.tsx @@ -0,0 +1,110 @@ +import { BookOpen, CreditCard, Play, SettingsIcon } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import DocumentationDialog from '@/components/custom/documentation-dialog'; +import { Button } from '@/components/ui/button'; +import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { useAppState } from '@/hooks/use-app-state'; +import { useConfigStore } from '@/hooks/use-config-store'; +import { useConfigurationDialog } from '@/hooks/use-configuration-dialog'; + +export default function HomePage() { + const navigate = useNavigate(); + const { appState } = useAppState(); + const { config, isLoading: configLoading, loadConfig } = useConfigStore(); + const { openConfigurationDialog } = useConfigurationDialog(); + const [isDocsOpen, setIsDocsOpen] = useState(false); + + useEffect(() => { + loadConfig(); + }, [loadConfig]); + + const email = config?.email; + const credits = appState?.credits; + // appState starts null before the first IPC round-trip resolves, and configLoading covers the + // config fetch this page kicks off above - both need to settle before "no data" is trustworthy. + const accountReady = !configLoading && appState !== null; + const firstName = email?.split('@')[0]; + + return ( +
+
+
+

+ {firstName ? `Welcome back, ${firstName}` : 'Welcome back'} +

+

+ Start an interview, or jump to your account below. +

+
+ + navigate('/main')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + navigate('/main'); + } + }} + className="mb-6 cursor-pointer outline-none transition-colors hover:border-primary focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-ring/50" + > + +
+
+
+
+ Start Interview + + Transcribe and get live suggestions during your interview. + +
+
+
+
+ + +
+
+

Account

+

+ {accountReady ? (email ?? 'Not signed in') : 'Loading…'} +

+
+
+

Credits

+

+ {accountReady ? (credits ?? 'Unavailable') : 'Loading…'} +

+
+ +
+
+ +
+ + +
+
+ + +
+ ); +} diff --git a/src/renderer/pages/index.tsx b/src/renderer/pages/index.tsx index 0ebbc356..7e520551 100644 --- a/src/renderer/pages/index.tsx +++ b/src/renderer/pages/index.tsx @@ -1,22 +1,21 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { LoadingPage } from '@/components/custom/loading'; import { useAppState } from '@/hooks/use-app-state'; +import HomePage from '@/pages/home'; export default function IndexPage() { const { appState } = useAppState(); const navigate = useNavigate(); useEffect(() => { - // Redirect to main page if backend is live and logged in if (appState?.isLoggedIn === false) { navigate('/auth/login', { replace: true }); - } else { - navigate('/main', { replace: true }); } - }, [appState?.isBackendLive, appState?.isLoggedIn, navigate]); + }, [appState?.isLoggedIn, navigate]); - // Optionally, render a loading state while checking - return ; + // Logged-out users are redirected above. Everyone else - including the brief window before + // appState has loaded - sees the home dashboard directly; HomePage owns its own loading state + // for that window instead of this route showing a separate spinner first. + return ; } From 702b87acc5bcad5b6ca516e1e655a0449b3d6faf Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 4 Sep 2026 13:38:18 -0400 Subject: [PATCH 03/35] fix(hotkeys): make the shortcut cheat-sheet click-to-open, not hover-only status-panel.tsx's "Show Hotkeys" control only ever revealed the list on hover - nothing visible without pointing at it first, the exact antipattern the rest of the control bar has. Converts it to a proper dialog opened by click or a new '?' shortcut (local to this window, not a registered global hotkey - it only needs focus, not stealth-mode reach). Extracts the hotkey list itself into hotkey-cheatsheet.tsx so status-panel.tsx and documentation-dialog.tsx render the same data instead of two independently-drifting copies. --- .../custom/documentation-dialog.tsx | 33 +------- .../components/custom/hotkey-cheatsheet.tsx | 75 +++++++++++++++++ .../components/custom/status-panel.tsx | 80 ++++++++----------- 3 files changed, 110 insertions(+), 78 deletions(-) create mode 100644 src/renderer/components/custom/hotkey-cheatsheet.tsx diff --git a/src/renderer/components/custom/documentation-dialog.tsx b/src/renderer/components/custom/documentation-dialog.tsx index ba185737..1e0b5c41 100644 --- a/src/renderer/components/custom/documentation-dialog.tsx +++ b/src/renderer/components/custom/documentation-dialog.tsx @@ -16,11 +16,10 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { APP_NAME } from '@/lib/consts'; -import { Hotkey, HOTKEY_GROUPS, HOTKEYS } from '@/lib/hotkeys'; -import { cn } from '@/lib/utils'; import { LANGUAGES } from '@/types/language'; import ExternalLink from './external-link'; +import { HotkeyCheatsheet } from './hotkey-cheatsheet'; interface DocumentationDialogProps { open: boolean; @@ -143,35 +142,7 @@ export default function DocumentationDialog({ open, onOpenChange }: Documentatio Hotkeys - {HOTKEY_GROUPS.map((group) => ( -
-

{group.label}

-
- {group.keys.map((hk) => { - const info = HOTKEYS[hk]; - return ( - -
-
- {info.combo} -
-
-
{info.description}
-
- ); - })} -
-
- ))} +
diff --git a/src/renderer/components/custom/hotkey-cheatsheet.tsx b/src/renderer/components/custom/hotkey-cheatsheet.tsx new file mode 100644 index 00000000..4e65e7bc --- /dev/null +++ b/src/renderer/components/custom/hotkey-cheatsheet.tsx @@ -0,0 +1,75 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Hotkey, HOTKEY_GROUPS, HOTKEYS } from '@/lib/hotkeys'; +import { cn } from '@/lib/utils'; + +const comboClass = (hk: Hotkey) => + cn( + 'shrink-0 px-2 py-1 rounded text-[11px] font-semibold whitespace-nowrap', + hk === Hotkey.StopAll + ? 'bg-destructive/80 text-destructive-foreground' + : hk === Hotkey.ToggleStealth + ? 'bg-primary/80 text-primary-foreground' + : 'bg-muted text-foreground' + ); + +/** + * The full hotkey reference, grouped and described. Shared so the control bar's status panel and + * the documentation dialog can't drift out of sync the way the two independent copies they + * replace already had. + */ +export function HotkeyCheatsheet() { + return ( +
+ {HOTKEY_GROUPS.map((group) => ( +
+

+ {group.label} +

+
+ {group.keys.map((hk) => { + const info = HOTKEYS[hk]; + return ( +
+
{info.combo}
+
+

{info.title}

+

+ {info.description} +

+
+
+ ); + })} +
+
+ ))} +
+ ); +} + +interface HotkeyCheatsheetDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function HotkeyCheatsheetDialog({ open, onOpenChange }: HotkeyCheatsheetDialogProps) { + return ( + + + + Keyboard Shortcuts + Press ? anytime to reopen this list. + +
+ +
+
+
+ ); +} diff --git a/src/renderer/components/custom/status-panel.tsx b/src/renderer/components/custom/status-panel.tsx index dea729c1..a3b6356d 100644 --- a/src/renderer/components/custom/status-panel.tsx +++ b/src/renderer/components/custom/status-panel.tsx @@ -1,15 +1,36 @@ import { Captions, CaptionsOff, Keyboard, ListChecks, Route } from 'lucide-react'; +import { useEffect, useState } from 'react'; import CreditsDisplay from '@/components/custom/credits-display'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useProfessionalMode } from '@/hooks/use-professional-mode'; import { useTranscriptPanel } from '@/hooks/use-transcript-panel'; -import { Hotkey, HOTKEY_GROUPS, HOTKEYS } from '@/lib/hotkeys'; +import { Hotkey, HOTKEYS } from '@/lib/hotkeys'; import { cn } from '@/lib/utils'; import { RunningState, UserRole } from '@/types/app-state'; +import { HotkeyCheatsheetDialog } from './hotkey-cheatsheet'; import { RunningIndicator } from './running-indicator'; +/** + * Renderer-local, not a registered Hotkey: it only needs to work while this window has focus, + * unlike the globalShortcut-backed ones in lib/hotkeys.ts that must also fire in stealth mode. + */ +function useHotkeyCheatsheetShortcut(onOpen: () => void) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== '?' || e.metaKey || e.ctrlKey || e.altKey) return; + const target = e.target as HTMLElement | null; + const tag = target?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || target?.isContentEditable) return; + e.preventDefault(); + onOpen(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [onOpen]); +} + interface StatusPanelProps { runningState: RunningState; credits: number; @@ -34,6 +55,8 @@ export default function StatusPanel({ // calculate and formatting handled by CreditsDisplay component const { enabled: professionalMode } = useProfessionalMode(); const { visible: transcriptVisible } = useTranscriptPanel(); + const [hotkeysOpen, setHotkeysOpen] = useState(false); + useHotkeyCheatsheetShortcut(() => setHotkeysOpen(true)); return (
@@ -79,52 +102,15 @@ export default function StatusPanel({
- - - - - -
- {HOTKEY_GROUPS.map((group) => ( -
-
- {group.label} -
-
- {group.keys.map((hk) => { - const info = HOTKEYS[hk]; - return ( -
-
- {info.combo} -
-
- {info.title} -
-
- ); - })} -
-
- ))} -
-
-
+ +
); } From 35e89fa9fd25529d04d27f3f8e121234c94e30d4 Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 4 Sep 2026 13:47:34 -0400 Subject: [PATCH 04/35] feat(command-palette): add a searchable command palette The centerpiece fix for capabilities that were T1 (occasional) or T3 (hotkey-only) with nothing to point at: a Cmd/Ctrl+K palette listing navigation, session toggles, and app actions (Settings, Documentation, Keyboard Shortcuts, theme, sign out), plus a visible titlebar button so the entry point isn't itself hotkey-only. The shortcut is a renderer-local keydown listener, not a registered global hotkey - Cmd/Ctrl+K is common enough elsewhere that grabbing it system-wide via Electron's globalShortcut would hijack it in every other running app. Inert during stealth mode for the same reason the control bar is: popping a dialog over a screen share defeats the point of stealth. Only wires actions that already have a real, renderer-callable handler (Zustand-store-backed toggles, existing IPC calls) - hotkeys with no renderer entry point (window placement/move/resize, capture) stay reference-only via the Keyboard Shortcuts entry rather than faking an action that doesn't exist. Adds cmdk + a shadcn command.tsx wrapper (new dependency). --- package.json | 1 + pnpm-lock.yaml | 22 +++ .../components/custom/command-palette.tsx | 181 ++++++++++++++++++ src/renderer/components/custom/main-frame.tsx | 4 + src/renderer/components/custom/titlebar.tsx | 21 ++ src/renderer/components/ui/command.tsx | 167 ++++++++++++++++ src/renderer/hooks/use-command-palette.ts | 17 ++ 7 files changed, 413 insertions(+) create mode 100644 src/renderer/components/custom/command-palette.tsx create mode 100644 src/renderer/components/ui/command.tsx create mode 100644 src/renderer/hooks/use-command-palette.ts diff --git a/package.json b/package.json index 4cdd9a37..f6d0404a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "axios": "^1.13.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "electron-audio-loopback": "^1.0.6", "electron-store": "^11.0.2", "electron-updater": "^6.7.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1275b3c6..cb9d0d4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) electron-audio-loopback: specifier: ^1.0.6 version: 1.0.6(electron@40.9.1) @@ -2098,6 +2101,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2381,6 +2390,7 @@ packages: eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -5965,6 +5975,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.10)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@2.0.1: dependencies: color-name: 1.1.4 diff --git a/src/renderer/components/custom/command-palette.tsx b/src/renderer/components/custom/command-palette.tsx new file mode 100644 index 00000000..75900a75 --- /dev/null +++ b/src/renderer/components/custom/command-palette.tsx @@ -0,0 +1,181 @@ +import { + BookOpen, + Captions as TranscriptIcon, + CreditCard, + EyeOff, + Home, + Keyboard, + ListChecks, + LogOut, + Moon, + Play, + Route, + SettingsIcon, + Square, + Sun, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +import DocumentationDialog from '@/components/custom/documentation-dialog'; +import { HotkeyCheatsheetDialog } from '@/components/custom/hotkey-cheatsheet'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; +import { useAppState } from '@/hooks/use-app-state'; +import { useAssistantService } from '@/hooks/use-assistant-service'; +import useAuth from '@/hooks/use-auth'; +import { useCommandPaletteStore } from '@/hooks/use-command-palette'; +import { useConfigurationDialog } from '@/hooks/use-configuration-dialog'; +import useIsStealthMode from '@/hooks/use-is-stealth-mode'; +import { useProfessionalMode } from '@/hooks/use-professional-mode'; +import { useThemeStore } from '@/hooks/use-theme-store'; +import { useTranscriptPanel } from '@/hooks/use-transcript-panel'; +import { isMac } from '@/lib/consts'; +import { getElectron } from '@/lib/utils'; +import { RunningState } from '@/types/app-state'; + +/** + * Not a registered Hotkey (lib/hotkeys.ts): like the cheat-sheet's `?`, this only needs the + * window focused, not a system-wide binding via Electron's globalShortcut. Registering Cmd/Ctrl+K + * globally would steal that combo from every other app on the machine whenever this one is merely + * running - the exact opposite of what a command palette should do. + */ +function useCommandPaletteHotkey() { + const toggle = useCommandPaletteStore((s) => s.toggle); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const mod = isMac ? e.metaKey : e.ctrlKey; + if (!mod || e.key.toLowerCase() !== 'k') return; + e.preventDefault(); + toggle(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [toggle]); +} + +export function CommandPalette() { + useCommandPaletteHotkey(); + + const isStealth = useIsStealthMode(); + const navigate = useNavigate(); + const open = useCommandPaletteStore((s) => s.open); + const setOpen = useCommandPaletteStore((s) => s.setOpen); + + const { appState, runningState } = useAppState(); + const { stopAssistant } = useAssistantService(); + const { logout } = useAuth(); + const { openConfigurationDialog } = useConfigurationDialog(); + const { enabled: professionalMode, toggle: toggleProfessionalMode } = useProfessionalMode(); + const { visible: transcriptVisible, toggle: toggleTranscript } = useTranscriptPanel(); + const { isDark, toggleTheme } = useThemeStore(); + + const [isDocsOpen, setIsDocsOpen] = useState(false); + const [isHotkeysOpen, setIsHotkeysOpen] = useState(false); + + // Stealth mode hides the app's visible surface during screen share - a palette popping up + // over that would defeat the point, so it stays fully inert (including the hotkey) while active. + if (isStealth) return null; + + const isLoggedIn = appState?.isLoggedIn ?? false; + const isRunning = runningState === RunningState.Running || runningState === RunningState.Starting; + + const run = (action: () => void) => { + setOpen(false); + action(); + }; + + return ( + <> + + + + No matching action. + + + run(() => navigate('/'))}> + + Home + + run(() => navigate('/main'))}> + + Start Interview + + run(() => navigate('/payment'))}> + + Buy Credits + + + + + + + {isRunning && ( + run(() => void stopAssistant())}> + + Stop Interview + + )} + run(toggleProfessionalMode)}> + {professionalMode ? : } + {professionalMode ? 'Switch to Normal Suggestions' : 'Switch to Professional Mode'} + + run(toggleTranscript)}> + + {transcriptVisible ? 'Hide Transcript' : 'Show Transcript'} + + + + + + + run(() => openConfigurationDialog())}> + + Settings + + run(() => setIsDocsOpen(true))}> + + Documentation + + run(() => setIsHotkeysOpen(true))}> + + Keyboard Shortcuts + + run(toggleTheme)}> + {isDark ? : } + {isDark ? 'Switch to Light Mode' : 'Switch to Dark Mode'} + + {isLoggedIn && ( + run(() => getElectron()?.toggleStealth())}> + + Toggle Stealth Mode + + )} + {isLoggedIn && ( + run(() => void logout())}> + + Sign Out + + )} + + + + + + + + ); +} diff --git a/src/renderer/components/custom/main-frame.tsx b/src/renderer/components/custom/main-frame.tsx index 3ce86ba4..d8e65ae3 100644 --- a/src/renderer/components/custom/main-frame.tsx +++ b/src/renderer/components/custom/main-frame.tsx @@ -6,6 +6,7 @@ import { MainContainerContext } from '@/hooks/use-main-container'; import usePointerLockGuard from '@/hooks/use-pointer-lock-guard'; import type { PushNotification } from '@/types/push-notification'; +import { CommandPalette } from './command-palette'; import ConfigurationDialog from './configuration-dialog'; import SaveHistoryDialog from './save-history-dialog'; import Titlebar from './titlebar'; @@ -64,6 +65,9 @@ export default function MainFrame({ children }: { children: React.ReactNode }) { {/* Mounted here rather than on the interview page: it also answers a close prompt from main, which can arrive while the user is on the login or payment route. */} + {/* Mounted once at the app shell so Cmd/Ctrl+K and the titlebar button work from any + route, not just /main. */} + ); diff --git a/src/renderer/components/custom/titlebar.tsx b/src/renderer/components/custom/titlebar.tsx index 77d5b795..6f411c79 100644 --- a/src/renderer/components/custom/titlebar.tsx +++ b/src/renderer/components/custom/titlebar.tsx @@ -1,10 +1,13 @@ +import { Search } from 'lucide-react'; import React, { useEffect, useState } from 'react'; import faviconSvg from '/favicon.svg'; import CreditsDisplay from '@/components/custom/credits-display'; import TitlebarMenu from '@/components/custom/titlebar-menu'; +import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useAppState } from '@/hooks/use-app-state'; +import { useCommandPaletteStore } from '@/hooks/use-command-palette'; import { useConfigStore } from '@/hooks/use-config-store'; import useIsStealthMode from '@/hooks/use-is-stealth-mode'; import { APP_NAME, isMac } from '@/lib/consts'; @@ -43,6 +46,7 @@ export default function Titlebar() { const { appState } = useAppState(); const { config } = useConfigStore(); + const openCommandPalette = useCommandPaletteStore((s) => s.setOpen); if (isStealth) return null; @@ -80,6 +84,23 @@ export default function Titlebar() { )} + + + + + +

Search actions ({isMac ? '⌘' : 'Ctrl+'}K)

+
+
+ {!isMac && ( diff --git a/src/renderer/components/ui/command.tsx b/src/renderer/components/ui/command.tsx new file mode 100644 index 00000000..faa99ace --- /dev/null +++ b/src/renderer/components/ui/command.tsx @@ -0,0 +1,167 @@ +import { Command as CommandPrimitive } from 'cmdk'; +import { SearchIcon } from 'lucide-react'; +import * as React from 'react'; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; + +function Command({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +interface CommandDialogProps extends React.ComponentProps { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +} + +function CommandDialog({ + title = 'Command Palette', + description = 'Search for a command to run...', + children, + className, + showCloseButton = true, + ...props +}: CommandDialogProps) { + return ( + + + + {title} + {description} + + + {children} + + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ ...props }: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, + CommandShortcut, +}; diff --git a/src/renderer/hooks/use-command-palette.ts b/src/renderer/hooks/use-command-palette.ts new file mode 100644 index 00000000..defe6314 --- /dev/null +++ b/src/renderer/hooks/use-command-palette.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand'; + +interface CommandPaletteStore { + open: boolean; + setOpen: (open: boolean) => void; + toggle: () => void; +} + +/** + * Global, not route-scoped: the palette is meant to work from anywhere in the app, and a hotkey + * listener living outside any one page's component tree needs a store it can reach imperatively. + */ +export const useCommandPaletteStore = create((set) => ({ + open: false, + setOpen: (open) => set({ open }), + toggle: () => set((state) => ({ open: !state.open })), +})); From 6a31265d1ecffb3d659fb11fc6990847bc318c7d Mon Sep 17 00:00:00 2001 From: alpha Date: Fri, 4 Sep 2026 13:50:14 -0400 Subject: [PATCH 05/35] feat(settings): consolidate account, session, shortcuts, and billing Configuration, Change password, and Buy Credits were three separate, unrelated-looking rows in the titlebar menu, plus a fourth settings surface (control-bar toggles) with no relationship to any of them. Turns the settings dialog into a tabbed hub instead: - Account: existing profile fields, plus a Change Password action (folds change-password-dialog.tsx in as a nested dialog rather than a separate menu item) - Session: professional mode and transcript panel defaults - Shortcuts: the same hotkey-cheatsheet.tsx list used elsewhere - Billing: credits balance and a Buy Credits action Titlebar menu trims to one guessable "Settings" entry instead of three - the underlying actions haven't gone anywhere, just moved somewhere a person only has to learn once. --- .../custom/configuration-dialog.tsx | 300 +++++++++++++----- .../components/custom/titlebar-menu.tsx | 65 +--- 2 files changed, 219 insertions(+), 146 deletions(-) diff --git a/src/renderer/components/custom/configuration-dialog.tsx b/src/renderer/components/custom/configuration-dialog.tsx index 4fb376fd..8d60ff0f 100644 --- a/src/renderer/components/custom/configuration-dialog.tsx +++ b/src/renderer/components/custom/configuration-dialog.tsx @@ -1,9 +1,18 @@ import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; +import { ChangePasswordDialog } from '@/components/custom/change-password-dialog'; +import { HotkeyCheatsheet } from '@/components/custom/hotkey-cheatsheet'; import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Textarea } from '@/components/ui/textarea'; +import { useAppState } from '@/hooks/use-app-state'; +import useAuth from '@/hooks/use-auth'; +import { useProfessionalMode } from '@/hooks/use-professional-mode'; +import { useTranscriptPanel } from '@/hooks/use-transcript-panel'; import { getElectron } from '@/lib/utils'; import { @@ -53,7 +62,18 @@ interface ConfigurationDialogProps { onOpenChange: (open: boolean) => void; } +type ConfigTab = 'account' | 'session' | 'shortcuts' | 'billing'; + export default function ConfigurationDialog({ isOpen, onOpenChange }: ConfigurationDialogProps) { + const navigate = useNavigate(); + const { appState } = useAppState(); + const { changePassword, loading: authLoading, error: authError, setError } = useAuth(); + const { enabled: professionalMode, toggle: toggleProfessionalMode } = useProfessionalMode(); + const { visible: transcriptVisible, toggle: toggleTranscriptVisible } = useTranscriptPanel(); + + const [activeTab, setActiveTab] = useState('account'); + const [isChangePasswordOpen, setIsChangePasswordOpen] = useState(false); + const [name, setName] = useState(''); const [profileData, setProfileData] = useState(''); const [context, setContext] = useState(''); @@ -67,6 +87,8 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat useEffect(() => { if (!isOpen) return; + setActiveTab('account'); + let cancelled = false; setLoading(true); setConfigLoaded(false); @@ -124,114 +146,218 @@ export default function ConfigurationDialog({ isOpen, onOpenChange }: Configurat } }; + const handleChangePassword = async ( + currentPassword: string, + newPassword: string + ): Promise => { + try { + return await changePassword(currentPassword, newPassword); + } catch (err) { + console.error('Password change failed:', err); + return false; + } + }; + + const handleBuyCredits = () => { + onOpenChange(false); + navigate('/payment'); + }; + return ( - + - Configuration + Settings - Update your configuration: username, profile information (e.g. CV/resume) and interview - context (e.g. job description). + Your profile, session defaults, shortcuts, and billing - all in one place. -
-
-
- - setName(e.target.value)} - placeholder="Enter your profile name" - className="text-sm" - maxLength={MAX_NAME_LENGTH} - /> -
+ setActiveTab(value as ConfigTab)} + className="flex-1 overflow-hidden flex flex-col" + > + + Account + Session + Shortcuts + Billing + -
-
+
+ +
- + setName(e.target.value)} + placeholder="Enter your profile name" + className="text-sm" + maxLength={MAX_NAME_LENGTH} + />
-