diff --git a/CLAUDE.md b/CLAUDE.md index 1238ece0..8a4e0eea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,15 +79,17 @@ The classifier is deliberately asymmetric, and `test/interviewer-turn.test.mjs` Action suggestions are independent of transcripts - triggered by screenshot captures (up to `ACTION_SUGGESTION_MAX_CAPTURES` = 4 images per request). -**Professional mode** (`professionalMode` in ConfigStore, off by default) asks the backend for hints - a headline plus keyword bullets - instead of full sentences. Both suggestion services read the flag once at the top of `generateSuggestion` and send it as `mode` on the request; the backend defaults it to `normal`, so the field is safe to omit against an older deployment. +**Hint-only mode** (`hintOnlyMode` in ConfigStore, on by default) asks the backend for hints - a headline plus keyword bullets - instead of the full sentences of full-sentence mode. Both suggestion services read the flag once at the top of `generateSuggestion` and send it as `mode` on the request; the backend defaults it to `normal`, so the field is safe to omit against an older deployment. -The `NO_SUGGESTION_NEEDED` sentinel goes through `isNoSuggestionSentinel()` ([src/main/utils/suggestion-sentinel.ts](src/main/utils/suggestion-sentinel.ts)) rather than a direct comparison. It backs up the deterministic gate above for turns the lexicon cannot settle, and it is in-band by nature - a control decision travelling in the answer stream - which is why it is the fallback rather than the mechanism. It is prefix-matched because it runs on every streamed chunk, and it strips leading markdown first: the professional prompt asks for a bold headline on line 1, so a model that carries that format over emits `**NO_SUGGESTION_NEEDED**` and a bare match would leave the sentinel on screen as a card. Unicode format characters are stripped with the emphasis, and that is what makes the fallback hold in Arabic and Hebrew: a model writing right-to-left routinely opens on a directional mark, and U+200F is not whitespace, so it survives `\s` and leaves the comparison starting on a character the sentinel does not - putting `NO_SUGGESTION_NEEDED` on screen as the answer to a question the backend had just decided needed none. `test/suggestion-sentinel.test.mjs` pins both halves - the wrapped forms are suppressed, real answers are not, including a real Hebrew one opening on the same mark. +The setting was called `professionalMode` until it was renamed for the two modes it actually switches between. Two things survive that rename deliberately. `SuggestionMode`'s wire values are still `normal` / `professional`, because they are the backend's contract (`app/schemas/suggestion.py`) and it deploys separately - the TypeScript members are `FullSentence` / `HintOnly`, the strings are not. And the config store's migration reads the old key once to seed the new one before `scrubRetiredKey` removes it, so an upgrading install keeps the mode it was on rather than being moved onto the new default. -Both live modes render through `SafeMarkdown`, the same component the action panel uses. The normal-mode prompt asks for plain text *with light formatting*, so any bold or bullet the model reached for used to land on screen as literal asterisks. Prose is passed through `withHardBreaks()` ([src/renderer/lib/suggestions.ts](src/renderer/lib/suggestions.ts)) first: Markdown folds a single newline into a space, and the `whitespace-pre-wrap` rendering it replaced showed every newline the model emitted. +The `NO_SUGGESTION_NEEDED` sentinel goes through `isNoSuggestionSentinel()` ([src/main/utils/suggestion-sentinel.ts](src/main/utils/suggestion-sentinel.ts)) rather than a direct comparison. It backs up the deterministic gate above for turns the lexicon cannot settle, and it is in-band by nature - a control decision travelling in the answer stream - which is why it is the fallback rather than the mechanism. It is prefix-matched because it runs on every streamed chunk, and it strips leading markdown first: the hint-only prompt asks for a bold headline on line 1, so a model that carries that format over emits `**NO_SUGGESTION_NEEDED**` and a bare match would leave the sentinel on screen as a card. Unicode format characters are stripped with the emphasis, and that is what makes the fallback hold in Arabic and Hebrew: a model writing right-to-left routinely opens on a directional mark, and U+200F is not whitespace, so it survives `\s` and leaves the comparison starting on a character the sentinel does not - putting `NO_SUGGESTION_NEEDED` on screen as the answer to a question the backend had just decided needed none. `test/suggestion-sentinel.test.mjs` pins both halves - the wrapped forms are suppressed, real answers are not, including a real Hebrew one opening on the same mark. + +Both live modes render through `SafeMarkdown`, the same component the action panel uses. The full-sentence prompt asks for plain text *with light formatting*, so any bold or bullet the model reached for used to land on screen as literal asterisks. Prose is passed through `withHardBreaks()` ([src/renderer/lib/suggestions.ts](src/renderer/lib/suggestions.ts)) first: Markdown folds a single newline into a space, and the `whitespace-pre-wrap` rendering it replaced showed every newline the model emitted. The backend prompts now ask for inline emphasis on the words an answer turns on, in both modes, so `strong` and `em` are declared explicitly in `SafeMarkdown` rather than left to browser defaults - body copy is deliberately regular weight so that `strong` reads as emphasis against it. Live answers additionally go through `stripDanglingEmphasis()`: the panel re-renders on every streamed chunk, so each emphasized span exists for a few frames as an opening `**` with no closing pair, which Markdown renders as literal asterisks on the card the candidate is reading. It drops that one unmatched marker, leaving the text plain until the span closes. Live only - action suggestions carry code, where an asterisk is a dereference or a glob (`test/suggestion-emphasis.test.mjs` pins both halves, including that a `*` opening a list item is a block marker and never stripped). -Each `LiveSuggestion` still carries the `mode` it was *generated* under, and the panel keys off that rather than the current setting, so toggling mid-interview leaves cards already on screen alone. What the mode selects is the presentation around the Markdown: professional promotes the headline line, normal keeps the 🪄 marker in a column of its own - prepending it to the content instead would swallow whatever structure the answer opens with. +Each `LiveSuggestion` still carries the `mode` it was *generated* under, and the panel keys off that rather than the current setting, so toggling mid-interview leaves cards already on screen alone. What the mode selects is the presentation around the Markdown: hint-only promotes the headline line, full-sentence keeps the 🪄 marker in a column of its own - prepending it to the content instead would swallow whatever structure the answer opens with. ### Assistant lifecycle @@ -110,9 +112,9 @@ outside the `try` as an unhandled rejection. `useMediaDevices` reports `ready` alongside the device list because an empty list means two different things - `enumerateDevices()` has not answered yet, and this machine has none - and the -control panel renders a destructive badge and refuses Start on the second. Reading them as one -put a red `!` on a working microphone for the first frames after every launch, and refused a Start -pressed quickly with a message naming a device that was there all along. An unset +control panel renders a destructive badge and refuses the start on the second. Reading them as one +put a red `!` on a working microphone for the first frames after every launch, and refused a start +requested quickly with a message naming a device that was there all along. An unset `audioInputDeviceName` is a third state again, and also not "missing": `AudioGroup` is choosing the default at that moment, in an effect - never in the render body, where the store write re-enters React mid-commit and a failed IPC call rolls the value back into the same condition that @@ -121,12 +123,30 @@ triggered it, one write per frame. ### Saving the interview before it is lost The transcript and the suggestions live only in main-process memory. Nothing is written to disk -until an export, so the three actions that empty them - Clear, Start (which opens with -`clearAll()`), and closing the app - are the only paths in the app that destroy work with no way -back. All three now ask first, through one dialog: +until an export, so the actions that empty them - Clear, Start (which opens with `clearAll()`), +Stop, signing out, and closing the app - are the only paths in the app that destroy work with no +way back. All of them ask first, through one dialog: [save-history-dialog.tsx](src/renderer/components/custom/save-history-dialog.tsx), mounted once in -`MainFrame` because the three do not share a screen - the control panel is not rendered in stealth -mode, and the close prompt arrives from main with no component of its own. +`MainFrame` because they do not share a screen - the control panel is not rendered in stealth +mode, the close prompt arrives from main with no component of its own, and the stop prompt +outlives the screen that raised it. + +**Signing out destroys it too, and main is what actually drops it.** `authService.logout()` clears +the transcript, the suggestions and the mock session along with the token - including a mock +session still running, which `clearAll` will not touch unless the caller opts in. Without that, +the state stayed in memory and the close guard read `hasHistory` / `hasMockContent` straight off +it, so the next user to sign in on a shared machine was offered the previous user's interview to +export. Sign-out is also refused while a *mock* session is active: a mock deliberately leaves +`runningState` on Idle, so a check on that alone waved it through. + +**Stop is the one that is not a guard.** The other reasons are asked *before* the destructive act +and can be answered with "not now", which leaves the interview alone. `useEndLiveSession` +([use-end-live-session.ts](src/renderer/hooks/use-end-live-session.ts)) stops the assistant, asks, +then clears and goes home whatever the answer was - so the dialog drops its Cancel and refuses Esc +for that reason, because an Esc that read as backing out would silently be the discard. It is +deliberately not what the stop *hotkey* does: that one fires while the app is hidden mid-screen- +share, where a modal dialog and a navigation to the dashboard are the opposite of what was asked +for, and the next Start still asks about the transcript it left behind. **The question is only worth asking about a real interview, and length cannot tell you that.** `setPlaceholderState()` seeds the panels with one transcript and two suggestions so an empty app @@ -353,7 +373,15 @@ whatever claimed it. `test/navigation-guard.test.mjs` pins all three. ### Routing -Hash-based router (required for Electron `file://` protocol). Routes: `/` (index, redirects based on login state) -> `/auth/login`, `/auth/signup`, or `/auth/forgot-password` -> `/main` (interview UI) -> `/payment`. +Hash-based router (required for Electron `file://` protocol). Routes: `/` (the launch hub, and the only screen that redirects) -> `/auth/login`, `/auth/signup`, `/auth/forgot-password`, `/onboarding` -> `/main` (live assistant), `/mock-interview`, `/account`, `/configuration`, `/payment`, `/documentation`. + +**First-run setup.** `/` sends a signed-in user to `/onboarding` when the account's `onboardingCompleted` is false, and it waits for `interviewConfigLoaded` before acting: the flag lives on the account, so until that account has been read this session its value is the default rather than an answer, and acting sooner would flash the wizard at every user on launch and show it in full to anyone whose pull failed. The wizard writes the flag through `account:set-onboarding-completed`, and only after the backend confirms - an optimistic write would let a failed save look like a finished setup until the next launch put the wizard back. Sign-in lands on `/` rather than `/main` for this reason: `/main` is the one route the gate does not cover. + +The wizard also holds a session-local dismissal (`use-onboarding-dismissed.ts`) that the gate reads alongside the flag. Its write to the account resolves over one IPC message and the app state carrying the result arrives over another, with nothing ordering the two - so home re-rendered on the old value the instant the wizard navigated to it and sent the user straight back in, running the whole thing twice. The account flag is what makes setup done; the dismissal is what makes it done *now*. It is cleared on sign-out, or the next account to sign in during the same run would inherit it. + +`/onboarding` renders regardless of the flag, which is what lets Configuration offer *Run setup* and what makes Skip safe rather than final. The titlebar menu drops Home, Account and Configuration while it is open - Home would bounce straight back, and the other two are what the wizard is in the middle of collecting. + +**An absent `onboarding_completed` counts as done**, not as false (`AccountService.readsAsOnboarded`). A backend deployment that predates the field omits it, and reading that as "not done" would put every user of that deployment into the wizard with no way out - the only two exits from it, Finish and Skip, both write through an endpoint that deployment does not have either. Guessing wrong in that direction locks the app; guessing wrong in the other costs a screen nobody saw. `/auth/forgot-password` is a three-step wizard shaped like the signup one (email -> code -> password), and the reset is code-based rather than an emailed link because a link opens the system browser, which has no way to hand a token back without a registered deep-link protocol handler. diff --git a/README.md b/README.md index b4b2963b..e3dbf97c 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Stay on top of the conversation with live ASR: - Personalized responses based on CV and job description - Streaming responses in real time - Context-aware outputs -- Natural language responses +- Two styles: hint-only (a headline plus keyword bullets, the default) or full sentences #### Action Suggestions @@ -57,9 +57,10 @@ Stay on top of the conversation with live ASR: ### Smart Configuration -- Profile management (CV, job description, etc.), synced to your account across devices -- Audio device selection (local to each device) -- Language support (English) +- Guided setup on first launch, covering everything a first interview needs +- **Account**: profile (CV), job context and password, synced to your account across devices +- **Configuration**: microphone (with a live test), interview language, suggestion style, and + transcript panel visibility - local to each device - Persistent settings ### Staying Out of Sight diff --git a/SPEC.md b/SPEC.md index a5a6df0d..09b38d2f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -55,9 +55,32 @@ Streaming AI responses generated from the user's CV and job description, trigger Screenshot-based problem solving. Accepts up to 4 images, sends them to the LLM backend, returns syntax-highlighted code output. Service: [src/main/services/suggestion-action.service.ts](src/main/services/suggestion-action.service.ts). -### Professional Mode +### Leaving a Mock Interview + +Navigating away from `/mock-interview` mid-session ends it, scoring whatever was answered and dropping the rest, so a `useBlocker` guard asks first. A blocker rather than a check on each exit, because the exits are numerous and grow: Home and the two settings pages in the titlebar menu, the same entries in the command palette, and the palette's two Start actions. Signing out is not blocked - `isLoggedIn` going false is the backend saying the session is over - and closing the app is covered separately by the window-close guard. + +### Mock Interview Question Delivery + +The backend returns each question whole; the session screen writes it out word by word as the interviewer speaks it. The reveal is timed against the first audio chunk actually sounding rather than against the `Speaking` state, because that state begins before the first sentence has been synthesised - timing it against the state would put the words on screen during that silence. Paces at roughly twice speech so the last word lands before the sentence ends, gives up waiting for audio after 2.5s, and shows the whole question at once under `prefers-reduced-motion`. Component: [src/renderer/components/custom/panels/streaming-question.tsx](src/renderer/components/custom/panels/streaming-question.tsx). + +### Hint-Only Mode + +The default. Restructures both live and triggered suggestions into a bold one-line core answer plus one bullet per point, however many the answer needs - the same answer full-sentence mode would give, reorganised so the eye finds each point in one pass and stripped of its padding. Bullets stay full speakable sentences rather than keywords, so the candidate can read one out loud as it stands. Switched from the control panel, the configuration page, or with `Ctrl+Shift+F7`, which keeps it reachable in stealth mode. Persisted locally as `hintOnlyMode`; sent to the backend as `mode` on the suggestion request, whose wire values are still `normal` / `professional`. + +### First-Run Setup + +A user who has not been through setup is sent to `/onboarding` before they can reach anything else, and asked once for the seven things a first interview needs: profile, job context, language, microphone (with a live level test), suggestion style, interface size, and whether the transcript panel is docked. Each step renders the same component the account and configuration pages use. + +Gated on the account's `onboarding_completed`, written through `PATCH /api/users/me/onboarding` - on the account rather than on the machine, so it follows the user to a new device and a second account on a shared one gets its own run of it. The gate waits for `interviewConfigLoaded` as well as the flag, since before the account has been read the flag is a default rather than an answer. + +Nothing in it is a trap: Skip is on every step, every setting has a working default, and Configuration can re-run the whole thing (`/onboarding` renders regardless of the flag). The only step that blocks is the profile, because the start sequence refuses to run without a name and a CV - and it says which of the two is missing rather than only disabling the button. Page: [src/renderer/pages/onboarding/index.tsx](src/renderer/pages/onboarding/index.tsx). + +### Navigation + +`/` is a launch hub naming the five things a user comes to the app to do: start a mock interview, start the live assistant, open Account (`/account` - sign-in identity, profile, context, password), open Configuration (`/configuration` - microphone, language, suggestion style, interface size, transcript panel), or buy credits. + +**It is the only place a session begins.** Both launch buttons start one; neither implements starting one. Live hands off to `/main` through router state, because `/main`'s control panel owns the whole start sequence; mock hands off to `/mock-interview` with the setup its dialog collected. `/main` itself carries only Stop - it is the live assistant, not a place to choose one - and shows a way back to `/` on the rare idle visit (a start cancelled at the headphone notice, or the route opened directly). Stopping asks whether to save the interview, clears it, and returns to `/`. See [docs/ux-conventions.md](docs/ux-conventions.md) for where a new capability belongs. -Optional, off by default. Restructures both live and triggered suggestions into a bold one-line core answer plus one bullet per point, however many the answer needs - the same answer normal mode would give, reorganised so the eye finds each point in one pass and stripped of its padding. Bullets stay full speakable sentences rather than keywords, so the candidate can read one out loud as it stands. Toggled from the control panel or with `Ctrl+Shift+F7`, which keeps it reachable in stealth mode. Persisted locally as `professionalMode`; sent to the backend as `mode` on the suggestion request. ### Session Window Behaviour While the assistant is running - or while stealth mode is on - the window is pinned above other windows (`screen-saver` level, and visible over a fullscreen call on macOS) and drops its taskbar button and Dock icon. The two conditions are independent: switching stealth off mid-session leaves both in place until the session actually stops. macOS traffic lights stay visible outside stealth, since the window is still interactive. Service: [src/main/services/window-control.service.ts](src/main/services/window-control.service.ts). diff --git a/docs/ux-conventions.md b/docs/ux-conventions.md new file mode 100644 index 00000000..766fd3e9 --- /dev/null +++ b/docs/ux-conventions.md @@ -0,0 +1,124 @@ +# 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 the matching settings page — never a bar icon | +| **T2 — Configure-once** | Set rarely, persists across sessions | Account (`pages/account/index.tsx`) if it is *who the user is*, Configuration (`pages/configuration/index.tsx`) if it is *how the interview runs* — see §6 | +| **T3 — Power-user / hotkey** | Frequent during a live session but must not cost bar space | A hotkey (`lib/hotkeys.ts`), listed in the hotkey cheat-sheet (`hotkey-cheatsheet.tsx`) and, if it has a real renderer-callable action, the command palette (`command-palette.tsx`) — 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. + +A directional or parameterized hotkey (window placement, move, resize, zoom, panel scroll) has no +single "run it" action to put in the palette - those stay hotkey + cheat-sheet only, and that is +the correct, final placement for them, not a gap to fill later. + +## 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 Account/Configuration, the cheat-sheet, or the command palette for anything that isn't +part of the primary loop. + +## 3. Dialog vs. page + +A **transient, in-context action** (confirm a password change, a permission prompt, a save-before- +leaving guard) is a dialog. A **destination** — somewhere a user goes to do a batch of related +things, or that outlives the screen it was opened from — is a routed page, composing the shared +`components/custom/page-header.tsx`: a sticky header with a back button, content below. Use that +component rather than rebuilding the row — it also carries the `location.key === 'default'` +fallback that a reload otherwise turns into a dead back button. Account, Configuration, Documentation (`pages/documentation/index.tsx`) and the first-run +wizard (`pages/onboarding/index.tsx`) are pages for this reason; the hotkey cheat-sheet (`hotkey-cheatsheet.tsx`'s `HotkeyCheatsheetDialog`) +stays a dialog because it's a quick glance meant not to lose your place mid-session. + +The app has several independently hand-built dialogs (`change-password-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 polished 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. Account vs. Configuration + +The two settings destinations answer two different questions, and which one a setting belongs on +is decided by the question, not by convenience: + +- **Account** (`pages/account/index.tsx`) — *who the user is*: the sign-in identity, the profile + and job context every suggestion is written from, the password. It writes to the backend + account, so it has a Save button and a form that can be half-filled. +- **Configuration** (`pages/configuration/index.tsx`) — *how the interview runs*: microphone, + language, suggestion style, interface size, transcript panel. Every control persists as it is + changed, so there is no Save button and nothing can be half-applied. + +Each setting there is its own component under `components/custom/settings/`, and the account +fields share `hooks/use-account-form.ts`. That is not decoration: the first-run wizard +(`pages/onboarding/index.tsx`) renders the same components, so a control added to one surface +cannot end up looking or behaving differently on the other. + +## 6. A setting a new user must set belongs in onboarding + +If the app cannot do its job until a setting has a value — the profile it writes answers from, the +microphone it listens through — adding it to a settings page is not enough. It also goes in the +first-run wizard, as one step, rendering the same component. A setting a new user is expected to +discover on their own is one they will discover during their first real interview. + +Adding a step means adding an entry to `STEPS` in `pages/onboarding/index.tsx` and rendering the +existing field component for it. Do not write a wizard-only variant of a control. + +The wizard is not allowed to become a trap, and a new step must not make it one: + +- **Skip stays on every step**, and skipping keeps whatever the user already typed. +- **Only a setting the app genuinely cannot run without may block Continue** - today that is the + profile alone - and a blocked Continue must say what is missing, not merely be disabled. +- **Every step's setting must have a working default**, so skipping it leaves the app usable. +- **The step renders the same component its settings page does**, so what the wizard taught is + what the user finds later. + +## 7. Definition of done for a new feature/setting + +- [ ] Classified into a tier (§1) and placed on the tier's designated surface. +- [ ] If T2: placed on Account or Configuration per §5, as a component under + `components/custom/settings/`. +- [ ] If a new user must set it before their first interview: a step in the wizard (§6). +- [ ] If T3: added to `lib/hotkeys.ts` and appears in the hotkey cheat-sheet. +- [ ] New dialog/notice, if any, follows §3. +- [ ] New routed page composes `page-header.tsx` (§3). +- [ ] Meets every principle in §4. +- [ ] `pnpm lint` passes. diff --git a/package.json b/package.json index 9e7f3a4f..b7fec657 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,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 50808e94..e0275456 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,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) @@ -2284,6 +2287,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'} @@ -6303,6 +6312,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/main/api/users.ts b/src/main/api/users.ts index 802eec3e..331b40f8 100644 --- a/src/main/api/users.ts +++ b/src/main/api/users.ts @@ -4,7 +4,11 @@ * (full name, profile, context) */ -import { UpdateInterviewConfigRequest, UserAccount } from '../types/account.js'; +import { + UpdateInterviewConfigRequest, + UpdateOnboardingRequest, + UserAccount, +} from '../types/account.js'; import { ApiClient, ApiResponse } from './client.js'; // These carry the full profile/context payload, so allow well over a plain JSON round-trip, @@ -25,4 +29,15 @@ export class UsersApi extends ApiClient { async updateInterviewConfig(data: UpdateInterviewConfigRequest): Promise> { return this.patch('/api/users/me/interview-config', data, REQUEST_TIMEOUT_MS); } + + /** + * Record whether the client's first-run setup is done for this account. + * + * Carries no profile, so the long timeout above is not needed - but it is shared rather than + * tuned, because the only thing that makes this request slow is the same thing that makes the + * others slow, and one number is easier to keep honest than three. + */ + async updateOnboarding(data: UpdateOnboardingRequest): Promise> { + return this.patch('/api/users/me/onboarding', data, REQUEST_TIMEOUT_MS); + } } diff --git a/src/main/hotkeys.ts b/src/main/hotkeys.ts index eaa13b0b..9c8b8932 100644 --- a/src/main/hotkeys.ts +++ b/src/main/hotkeys.ts @@ -71,13 +71,14 @@ export function registerGlobalHotkeys(): void { if (w && !w.isDestroyed()) w.webContents.send('hotkey:toggle-transcript'); }); - // Toggle professional mode. A function key for the same reason as F8: it stays reachable in - // stealth mode, where the control panel carrying the button is hidden. Deliberately not P - - // globalShortcut claims accelerators system-wide, and Ctrl+Shift+P would take the command - // palette away from every editor on the machine for as long as this app runs. + // Switch between hint-only and full-sentence suggestions. A function key for the same reason + // as F8: it stays reachable in stealth mode, where the control panel carrying the button is + // hidden. Deliberately not P - globalShortcut claims accelerators system-wide, and + // Ctrl+Shift+P would take the command palette away from every editor on the machine for as + // long as this app runs. registerShortcut(`${BASE}+F7`, () => { const w = BrowserWindow.getAllWindows()[0]; - if (w && !w.isDestroyed()) w.webContents.send('hotkey:toggle-professional-mode'); + if (w && !w.isDestroyed()) w.webContents.send('hotkey:toggle-suggestion-mode'); }); // Zoom hotkeys @@ -206,7 +207,7 @@ export function registerGlobalHotkeys(): void { console.log(` ${mod}+Q : Stop assistant`); console.log(` ${mod}+M : Toggle stealth mode`); console.log(` ${mod}+N : Toggle opacity (stealth only)`); - console.log(` ${mod}+F7 : Toggle professional mode`); + console.log(` ${mod}+F7 : Switch hint-only / full-sentence suggestions`); console.log(` ${mod}+F8 : Toggle transcription dock`); console.log(` ${mod}+1-9 : Place window (numpad layout)`); console.log(' Ctrl+Alt+Shift+Arrow : Move window'); diff --git a/src/main/ipc/account.ts b/src/main/ipc/account.ts index 7d403568..44c293e6 100644 --- a/src/main/ipc/account.ts +++ b/src/main/ipc/account.ts @@ -21,4 +21,10 @@ export function registerAccountHandlers(): void { ipcMain.handle('account:get', async () => { return accountService.getEditableConfig(); }); + + // Records that the first-run wizard is finished or skipped. On the account rather than local + // config, so it follows the user across machines. + ipcMain.handle('account:set-onboarding-completed', async (_event, completed: boolean) => { + return accountService.setOnboardingCompleted(completed); + }); } diff --git a/src/main/ipc/suggestion-action.ts b/src/main/ipc/suggestion-action.ts index 06411c2c..7554efa8 100644 --- a/src/main/ipc/suggestion-action.ts +++ b/src/main/ipc/suggestion-action.ts @@ -9,4 +9,16 @@ export function registerActionSuggestionHandlers(): void { ipcMain.handle('action-suggestion:stop', async () => { await actionSuggestionService.stop(); }); + // Capture/clear-images/trigger previously reachable only via the Ctrl+Shift+F9-F11 hotkeys + // (src/main/hotkeys.ts) - exposed here so the control bar can call the same service methods + // instead of duplicating them. + ipcMain.handle('action-suggestion:capture', async () => { + await actionSuggestionService.captureScreenshot(); + }); + ipcMain.handle('action-suggestion:clear-images', async () => { + await actionSuggestionService.clearImages(); + }); + ipcMain.handle('action-suggestion:trigger', async () => { + await actionSuggestionService.startGenerateSuggestion(); + }); } diff --git a/src/main/ipc/window.ts b/src/main/ipc/window.ts index 89cf9433..af596277 100644 --- a/src/main/ipc/window.ts +++ b/src/main/ipc/window.ts @@ -46,6 +46,19 @@ export function registerWindowHandlers(): void { console.warn('zoom:out handler error', e); } }); + // Invoke rather than send, unlike the three above: the settings field that calls this waits + // for the applied factor so its readout cannot drift from the window when a value is clamped. + ipcMain.handle('zoom:set-factor', (_event, factor: number) => { + try { + if (typeof factor !== 'number' || !isFinite(factor)) return zoomService.getZoomFactor(); + zoomService.setZoomFactor(factor); + return zoomService.getZoomFactor(); + } catch (e) { + console.warn('zoom:set-factor handler error', e); + return 1; + } + }); + ipcMain.on('zoom:reset', () => { try { zoomService.resetZoom(); diff --git a/src/main/preload.cts b/src/main/preload.cts index 696bc7b4..6b022f73 100644 --- a/src/main/preload.cts +++ b/src/main/preload.cts @@ -44,10 +44,10 @@ const electronApi = { return () => ipcRenderer.removeListener('hotkey:toggle-transcript', handler); }, - onHotkeyToggleProfessionalMode: (callback: () => void) => { + onHotkeyToggleSuggestionMode: (callback: () => void) => { const handler = () => callback(); - ipcRenderer.on('hotkey:toggle-professional-mode', handler); - return () => ipcRenderer.removeListener('hotkey:toggle-professional-mode', handler); + ipcRenderer.on('hotkey:toggle-suggestion-mode', handler); + return () => ipcRenderer.removeListener('hotkey:toggle-suggestion-mode', handler); }, config: { @@ -78,6 +78,8 @@ const electronApi = { ipcRenderer.invoke('account:update', fullName, profileData, context), refresh: () => ipcRenderer.invoke('account:refresh'), get: () => ipcRenderer.invoke('account:get'), + setOnboardingCompleted: (completed: boolean) => + ipcRenderer.invoke('account:set-onboarding-completed', completed), }, payment: { @@ -124,6 +126,9 @@ const electronApi = { actionSuggestion: { clear: () => ipcRenderer.invoke('action-suggestion:clear'), stop: () => ipcRenderer.invoke('action-suggestion:stop'), + capture: () => ipcRenderer.invoke('action-suggestion:capture'), + clearImages: () => ipcRenderer.invoke('action-suggestion:clear-images'), + trigger: () => ipcRenderer.invoke('action-suggestion:trigger'), }, mockInterview: { @@ -190,6 +195,7 @@ const electronApi = { decrease: () => ipcRenderer.send('zoom:out'), reset: () => ipcRenderer.send('zoom:reset'), getFactor: () => ipcRenderer.invoke('zoom:get-factor'), + setFactor: (factor: number) => ipcRenderer.invoke('zoom:set-factor', factor), onChange: (callback: (percent: number) => void) => { const handler = (_event: Electron.IpcRendererEvent, percent: number) => callback(percent); ipcRenderer.on('zoom:level-changed', handler); diff --git a/src/main/services/account.service.ts b/src/main/services/account.service.ts index 5fcafe03..e2ad3e17 100644 --- a/src/main/services/account.service.ts +++ b/src/main/services/account.service.ts @@ -5,6 +5,7 @@ import { getLegacyInterviewConf, getLegacyInterviewConfOwner, } from '../store/config.store.js'; +import { UserAccount } from '../types/account.js'; import { InterviewConfig } from '../types/app-state.js'; import { appStateService } from './app-state.service.js'; @@ -40,6 +41,19 @@ export class AccountService { return true; } + /** + * Whether this account has been through the client's first-run setup, as the account reports it. + * + * **Absent counts as done.** A backend that predates the field omits it, and reading that as + * "not done" would put every user of that deployment into the wizard - with no way out, since + * the only exits from it write through an endpoint that deployment does not have either. + * Guessing wrong in this direction costs a screen nobody saw; wrong in the other direction + * locks the app. + */ + private static readsAsOnboarded(account: UserAccount): boolean { + return account.onboarding_completed !== false; + } + /** * Pull the authenticated user's persisted interview config from the backend * into app state. Called after login so a device shows the same config the @@ -56,6 +70,16 @@ export class AccountService { const account = response.data; const interviewConfig = account.interview_config; + // Applied here rather than alongside the config below, because the migration branch that + // follows returns early and the flag has nothing to do with what it is migrating. Guarded + // but not bumping: this is one field off a read, not a write that supersedes anything. + if (generation === this.generation) { + appStateService.updateState({ + onboardingCompleted: AccountService.readsAsOnboarded(account), + accountEmail: account.email ?? '', + }); + } + // Pre-sync builds kept this config on local disk only. If the account has none yet, // adopt the leftover local copy instead of presenting the user an empty profile. if (!interviewConfig) { @@ -224,8 +248,41 @@ export class AccountService { appStateService.updateState({ interviewConfig: { fullName: '', profileData: '', context: '' }, interviewConfigLoaded: false, + // Reset with the rest of the account. Left standing, the next user to sign in on this + // machine would inherit the previous one's answer and never be offered setup. + onboardingCompleted: false, + accountEmail: '', }); } + + /** + * Record that this account has finished (or deliberately skipped) the first-run wizard. + * + * Mirrored into app state only after the backend confirms the write. The renderer's gate reads + * that state, so an optimistic update would let a failed write look like a completed setup + * until the next launch pulled the account again and put the wizard back. + */ + async setOnboardingCompleted( + completed: boolean + ): Promise<{ success: boolean; error?: string }> { + try { + const response = await this.client.updateOnboarding({ completed }); + if (response.error) { + return { success: false, error: response.error.message || 'Failed to save your setup' }; + } + + // Bumped for the same reason `updateConfig` bumps it: a pull that started before this + // write is now stale, and letting it land afterwards would put the wizard back in front of + // a user who has just finished it. + this.generation++; + appStateService.updateState({ + onboardingCompleted: response.data?.onboarding_completed ?? completed, + }); + return { success: true }; + } catch { + return { success: false, error: 'Failed to save your setup' }; + } + } } export const accountService = new AccountService(); diff --git a/src/main/services/app-state.service.ts b/src/main/services/app-state.service.ts index 4b207fae..c0048e21 100644 --- a/src/main/services/app-state.service.ts +++ b/src/main/services/app-state.service.ts @@ -29,6 +29,8 @@ const DEFAULT_STATE: AppState = { providedLLMModel: undefined, interviewConfig: { fullName: '', profileData: '', context: '' }, interviewConfigLoaded: false, + onboardingCompleted: false, + accountEmail: '', hasHistory: false, mockInterview: null, hasMockContent: false, @@ -97,7 +99,7 @@ export class AppStateService { answer: 'Suggested answers will be here in real-time', state: SuggestionState.Success, error: '', - mode: SuggestionMode.Normal, + mode: SuggestionMode.FullSentence, }, ], actionSuggestions: [ diff --git a/src/main/services/auth.service.ts b/src/main/services/auth.service.ts index 19fbb049..2fe8e4ca 100644 --- a/src/main/services/auth.service.ts +++ b/src/main/services/auth.service.ts @@ -2,6 +2,7 @@ import { AuthApi } from '../api/auth.js'; import { configStore } from '../store/config.store.js'; import { accountService } from './account.service.js'; import { appStateService } from './app-state.service.js'; +import { toolsService } from './tools.service.js'; import { disableStealth } from './window-control.service.js'; /** @@ -155,6 +156,19 @@ export class AuthService { appStateService.updateState({ isLoggedIn: false }); accountService.clearState(); + // The interview goes with the session it belonged to. The transcript, the suggestions and + // the mock session all live in main-process memory and nothing else dropped them here, so + // on a shared machine the next user to sign in inherited the previous one's: the close + // guard reads `hasHistory` / `hasMockContent` off exactly this state, and would offer to + // export somebody else's interview to them. Never allowed to fail the sign-out itself - + // the session is over either way, and a stuck signed-in app is the worse outcome. + try { + await toolsService.clearAll({ includeActiveMockSession: true }); + await toolsService.setPlaceholderData(); + } catch (e) { + console.warn('Failed to clear interview state on sign-out:', e); + } + // clear credentials if remember me is not checked const config = configStore.getConfig(); if (!config.rememberMe) { diff --git a/src/main/services/mock-interview.service.ts b/src/main/services/mock-interview.service.ts index 619a6a96..71c42728 100644 --- a/src/main/services/mock-interview.service.ts +++ b/src/main/services/mock-interview.service.ts @@ -9,7 +9,13 @@ import { SUGGESTION_STALL_MS, } from '../consts.js'; import { configStore } from '../store/config.store.js'; -import { LiveSuggestion, RunningState, Speaker, SuggestionState, Transcript } from '../types/app-state.js'; +import { + LiveSuggestion, + RunningState, + Speaker, + SuggestionState, + Transcript, +} from '../types/app-state.js'; import { Language, TTS_LANGUAGES } from '../types/language.js'; import { GenerateLiveSuggestionRequest, RequestTurnVerdict, SuggestionMode } from '../types/llm.js'; import { @@ -41,7 +47,8 @@ import { appStateService } from './app-state.service.js'; */ function describeApiError(error: unknown): string { if (error instanceof ApiRequestError) { - const body = typeof error.content === 'string' ? error.content : JSON.stringify(error.content ?? ''); + const body = + typeof error.content === 'string' ? error.content : JSON.stringify(error.content ?? ''); const detail = body && body !== '""' ? ` - ${body.slice(0, 300)}` : ''; return `${error.status} ${error.message}${detail}`; } @@ -455,7 +462,10 @@ class MockInterviewService { * before the candidate has read it) is the bug this exists to avoid. */ answerReady(): void { - if (this.session.state !== MockInterviewState.Listening || this.session.currentQuestion?.hasAudio) { + if ( + this.session.state !== MockInterviewState.Listening || + this.session.currentQuestion?.hasAudio + ) { return; } this.armSilenceTimer(MOCK_LISTENING_SILENCE_MS); @@ -752,7 +762,7 @@ class MockInterviewService { const controller = new AbortController(); this.hintAbortController = controller; - const mode = conf.professionalMode ? SuggestionMode.Professional : SuggestionMode.Normal; + const mode = conf.hintOnlyMode ? SuggestionMode.HintOnly : SuggestionMode.FullSentence; const timestamp = Date.now(); const hint: LiveSuggestion = { timestamp, diff --git a/src/main/services/suggestion-action.service.ts b/src/main/services/suggestion-action.service.ts index 665a8e34..6f445881 100644 --- a/src/main/services/suggestion-action.service.ts +++ b/src/main/services/suggestion-action.service.ts @@ -249,7 +249,7 @@ export class ActionSuggestionService { context: interviewConfig.context, transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT), image_names: [...this.uploadedImageNames], - mode: conf.professionalMode ? SuggestionMode.Professional : SuggestionMode.Normal, + mode: conf.hintOnlyMode ? SuggestionMode.HintOnly : SuggestionMode.FullSentence, language: conf.language, }; diff --git a/src/main/services/suggestion-live.service.ts b/src/main/services/suggestion-live.service.ts index cb012f39..8eae781a 100644 --- a/src/main/services/suggestion-live.service.ts +++ b/src/main/services/suggestion-live.service.ts @@ -7,11 +7,7 @@ import { } from '../consts.js'; import { configStore } from '../store/config.store.js'; import { LiveSuggestion, Speaker, SuggestionState, Transcript } from '../types/app-state.js'; -import { - GenerateLiveSuggestionRequest, - RequestTurnVerdict, - SuggestionMode, -} from '../types/llm.js'; +import { GenerateLiveSuggestionRequest, RequestTurnVerdict, SuggestionMode } from '../types/llm.js'; import { DateTimeUtil } from '../utils/datetime.js'; import { getSuggestionErrorMessage } from '../utils/suggestion-error.js'; import { isNoSuggestionSentinel } from '../utils/suggestion-sentinel.js'; @@ -66,7 +62,7 @@ class LiveSuggestionService { // Read once, up front. The card and the request must agree on the mode even if the user // toggles while this stream is in flight, or the panel would render prose as Markdown. const conf = configStore.getConfig(); - const mode = conf.professionalMode ? SuggestionMode.Professional : SuggestionMode.Normal; + const mode = conf.hintOnlyMode ? SuggestionMode.HintOnly : SuggestionMode.FullSentence; const suggestion: LiveSuggestion = { timestamp, diff --git a/src/main/services/tools.service.ts b/src/main/services/tools.service.ts index c0f85281..76476528 100644 --- a/src/main/services/tools.service.ts +++ b/src/main/services/tools.service.ts @@ -137,7 +137,7 @@ class ToolsService { return filePath; } - async clearAll(): Promise { + async clearAll(options: { includeActiveMockSession?: boolean } = {}): Promise { // Clear in-memory state transcriptService.clear(); liveSuggestionService.clear(); @@ -148,11 +148,16 @@ class ToolsService { // about a session that had been cleared and the save dialog - which picks the mock export // whenever mock content is the only content - would have written a report for it. // - // Guarded on `isActive()` rather than unconditional. Every caller here (the Clear button, and - // `startAssistant` opening a live session) is reachable only from the live control bar, which - // a running mock session excludes, so this is belt: clearing a session that is still running - // would drop the interview out from under the screen showing it. - if (!mockInterviewService.isActive()) { + // Guarded on `isActive()` by default. The three in-app callers - the Clear button, + // `startAssistant` opening a live session, and `useEndLiveSession` dropping a finished one - + // only exist in a state a running mock session excludes, so for them this is belt: clearing + // a session that is still running would drop the interview out from under the screen showing + // it. + // + // Sign-out is the one caller for which that is not true, and it opts in: there is no screen + // left to drop it out from under, and leaving it is how the next user on the machine ends up + // being offered it. + if (options.includeActiveMockSession || !mockInterviewService.isActive()) { mockInterviewService.clear(); } } diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 51677d11..01ae25ca 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -29,17 +29,15 @@ export interface RuntimeConfig { // height the user dragged the dock to, in px; null leaves it on the automatic ratio transcriptDockHeight: number | null; - // suggestions come back as headline + keyword bullets instead of full sentences - professionalMode: boolean; + // hint-only mode: suggestions come back as a headline plus keyword bullets rather than full + // sentences. The default for a new install; full-sentence mode is the opt-out. + hintOnlyMode: boolean; // mock interview: also generate what the live assistant would have suggested for each // question. On by default - trying this out is one of the two reasons the feature exists. mockLiveSuggestionsEnabled: boolean; - // Which session the control bar's primary Start button launches directly, without going - // through the dropdown - whichever the candidate last actually started. Defaults to 'mock': - // a first-time user is far more likely to be trying the app out than walking into a real call. - lastSessionMode: 'live' | 'mock'; + } // Default runtime configuration @@ -59,13 +57,15 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { showTranscriptPanel: true, transcriptDockHeight: null, - // opt-in: prose is what every existing user already expects from the panel - professionalMode: false, + // On by default: a candidate reads a hint at a glance mid-question, where a paragraph of prose + // has to be scanned first. An existing install keeps whatever it was already on - see the + // migration below. + hintOnlyMode: true, // opt-out: showing what the live assistant would have said is the point of trying this mockLiveSuggestionsEnabled: true, - lastSessionMode: 'mock', + }; // interviewConf (full name, profile, context) used to be cached under `runtime`, but it's now @@ -261,15 +261,16 @@ export const configStore = new ConfigStore(); if (raw?.transcriptDockHeight === undefined) { migration.transcriptDockHeight = null; } - if (raw?.professionalMode === undefined) { - migration.professionalMode = false; + if (raw?.hintOnlyMode === undefined) { + // `professionalMode` is what this setting was called before it was renamed for the two modes + // it actually switches between. An install that carries it keeps the mode it was left on; + // one that does not is either new or predates the setting, and takes the new default. + const legacy = (raw as (StoredRuntime & Record) | undefined)?.professionalMode; + migration.hintOnlyMode = typeof legacy === 'boolean' ? legacy : true; } if (raw?.mockLiveSuggestionsEnabled === undefined) { migration.mockLiveSuggestionsEnabled = true; } - if (raw?.lastSessionMode === undefined) { - migration.lastSessionMode = 'mock'; - } // perform migration only if there are values to set if (Object.keys(migration).length > 0) { configStore.updateConfig(migration); @@ -282,7 +283,9 @@ export const configStore = new ConfigStore(); * stored object through, and nothing else strips a key TypeScript no longer knows about. */ function scrubRetiredKey(key: string): void { - const raw = configStore.getStoredRuntime() as (StoredRuntime & Record) | undefined; + const raw = configStore.getStoredRuntime() as + | (StoredRuntime & Record) + | undefined; if (raw && key in raw) { delete raw[key]; configStore.setStoredRuntime(raw); @@ -293,6 +296,21 @@ function scrubRetiredKey(key: string): void { // in plaintext - this one matters for more than tidiness. scrubRetiredKey('llmConf'); +// `onboardingCompleted` recorded whether the first-run wizard had run on this machine. It lives +// on the account now, which is the only place that can tell a user who has set up before from a +// second account on a shared machine - so the local copy is not merely unread, it is a second +// answer to a question that has one. +scrubRetiredKey('onboardingCompleted'); + +// `lastSessionMode` recorded which session the control bar's split Start button should launch +// by default. That button is gone - starting is a home-screen decision now, where both kinds are +// named outright - so nothing reads it and nothing should keep writing it. +scrubRetiredKey('lastSessionMode'); + +// `professionalMode` was renamed to `hintOnlyMode`, whose migration above reads it one last time +// to carry the user's choice across. Scrubbed after that, so the two can never disagree. +scrubRetiredKey('professionalMode'); + // `headphoneNoticeAcknowledged` was replaced by the mock-interview-aware `HeadphoneNoticeDialog` // variant, which no longer has a "do not show this again" option to acknowledge (see its own // docstring for why). Not sensitive, but left here for the same reason `llmConf` is: nothing else diff --git a/src/main/types/account.ts b/src/main/types/account.ts index fa2abaee..2db1fe0c 100644 --- a/src/main/types/account.ts +++ b/src/main/types/account.ts @@ -16,6 +16,16 @@ export interface UserAccount { status: string; credits: number; interview_config: InterviewConfig | null; + /** + * Whether the client's first-run setup has been finished or skipped for this account. + * + * Account-level rather than device-level, so it follows the user to a new machine and a second + * account on a shared one gets its own run of the wizard. + * + * Optional because a backend that predates the field omits it, which is a different answer + * from `false` and is treated as one - see `AccountService.readsAsOnboarded`. + */ + onboarding_completed?: boolean; created_at: number; updated_at: number | null; } @@ -25,3 +35,7 @@ export interface UpdateInterviewConfigRequest { profile_data: string; context: string; } + +export interface UpdateOnboardingRequest { + completed: boolean; +} diff --git a/src/main/types/app-state.ts b/src/main/types/app-state.ts index e26f9c18..69daccf7 100644 --- a/src/main/types/app-state.ts +++ b/src/main/types/app-state.ts @@ -102,6 +102,24 @@ export interface AppState { interviewConfig: InterviewConfig; /** False until the account's config has been read this session; editing is unsafe before then. */ interviewConfigLoaded: boolean; + /** + * Whether the signed-in account has finished or skipped the first-run wizard. + * + * Read off the account rather than local config, so it follows the user to a new machine and a + * second account on a shared one gets its own run of it. Only meaningful once + * `interviewConfigLoaded` is true - before that it is the default, not an answer, and the + * renderer's gate waits for both. + */ + onboardingCompleted: boolean; + /** + * The signed-in account's email, as the backend reports it. + * + * Not the same thing as `ConfigStore.email`, which is a *credential* the login form persists + * only when "remember me" is ticked - so it is deliberately blank for a user who declined + * that, and stale for the previous user until the next sign-in overwrites it. Anything that + * displays who is signed in reads this instead. + */ + accountEmail: string; /** * Whether there is an interview that saving would actually capture. * diff --git a/src/main/types/llm.ts b/src/main/types/llm.ts index b8c82547..0fe80839 100644 --- a/src/main/types/llm.ts +++ b/src/main/types/llm.ts @@ -12,12 +12,17 @@ export interface LLMRequest { /** * How much prose a suggestion should carry. * - * Normal is full spoken sentences. Professional is a headline plus keyword bullets, for reading - * at a glance mid-interview. Mirrors `SuggestionMode` in the backend's `app/schemas/suggestion.py`. + * Full-sentence mode is answers written out as they would be spoken. Hint-only mode is a headline + * plus keyword bullets, for reading at a glance mid-interview, and is the default. + * + * The wire values are deliberately left as they are. They are the backend's contract + * (`SuggestionMode` in its `app/schemas/suggestion.py`), which is deployed separately and still + * names these modes the way the client used to; renaming the members without renaming the strings + * keeps the client readable without requiring the two to ship together. */ export enum SuggestionMode { - Normal = 'normal', - Professional = 'professional', + FullSentence = 'normal', + HintOnly = 'professional', } /** diff --git a/src/main/utils/suggestion-sentinel.ts b/src/main/utils/suggestion-sentinel.ts index 1ab1c84d..7fb04390 100644 --- a/src/main/utils/suggestion-sentinel.ts +++ b/src/main/utils/suggestion-sentinel.ts @@ -7,7 +7,7 @@ import { LIVE_SUGGESTION_NO_SUGGESTION } from '../consts.js'; * Prefix-matched because it runs on every chunk: a sentinel that only matched once complete would * flash a half-written NO_SUGGESTION_NEEDED card into the panel first. * - * Markdown emphasis is stripped before the comparison. Professional mode asks the model for a bold + * Markdown emphasis is stripped before the comparison. Hint-only mode asks the model for a bold * headline on line 1, so a model that carries that format over to the sentinel emits * `**NO_SUGGESTION_NEEDED**`; a bare-string match would leave that sitting in the panel as a card. * The prompt asks for it bare, but the fallback costs one regex and the failure is visible diff --git a/src/renderer/components/custom/command-palette.tsx b/src/renderer/components/custom/command-palette.tsx new file mode 100644 index 00000000..7a608d21 --- /dev/null +++ b/src/renderer/components/custom/command-palette.tsx @@ -0,0 +1,207 @@ +import { + BookOpen, + Captions as TranscriptIcon, + CreditCard, + EyeOff, + Home, + Keyboard, + ListChecks, + LogOut, + Mic, + MonitorPlay, + Moon, + Play, + Route, + SettingsIcon, + Square, + Sun, + UserRound, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; + +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 useAuth from '@/hooks/use-auth'; +import { useCommandPaletteStore } from '@/hooks/use-command-palette'; +import { useEndLiveSession } from '@/hooks/use-end-live-session'; +import useIsStealthMode from '@/hooks/use-is-stealth-mode'; +import { useSuggestionMode } from '@/hooks/use-suggestion-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 endLiveSession = useEndLiveSession(); + const { logout } = useAuth(); + const { hintOnly, toggle: toggleSuggestionMode } = useSuggestionMode(); + const { visible: transcriptVisible, toggle: toggleTranscript } = useTranscriptPanel(); + const { isDark, toggleTheme } = useThemeStore(); + + 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'))}> + + Interview console + + run(() => navigate('/account'))}> + + Account + + run(() => navigate('/configuration'))}> + + Configuration + + run(() => navigate('/payment'))}> + + Buy Credits + + + + + + + {/* Both starts hand off to `/main` through router state rather than starting anything + here: the control panel there owns the whole start sequence, and the palette is + reachable from every route, including ones where none of it is mounted. Named the + way the home page names them, because they are the same two actions. */} + {!isRunning && ( + <> + + run(() => navigate('/', { state: { openMockSetup: true } })) + } + > + + Start mock interview + + + run(() => navigate('/main', { state: { autoStartLive: true } })) + } + > + + Start live assistant + + + )} + {isRunning && ( + run(() => void endLiveSession())}> + + Stop interview + + )} + run(toggleSuggestionMode)}> + {hintOnly ? : } + {hintOnly ? 'Switch to full-sentence mode' : 'Switch to hint-only mode'} + + run(toggleTranscript)}> + + {transcriptVisible ? 'Hide Transcript' : 'Show Transcript'} + + + + + + + run(() => navigate('/documentation'))}> + + 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/configuration-dialog.tsx b/src/renderer/components/custom/configuration-dialog.tsx deleted file mode 100644 index 4fb376fd..00000000 --- a/src/renderer/components/custom/configuration-dialog.tsx +++ /dev/null @@ -1,237 +0,0 @@ -import { useEffect, useState } from 'react'; -import { toast } from 'sonner'; - -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { getElectron } from '@/lib/utils'; - -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '../ui/dialog'; - -// Kept in sync with the backend's MAX_PROFILE_DATA_LENGTH / MAX_CONTEXT_LENGTH (app/cfg/llm.py) -const MAX_FIELD_LENGTH = 128_000; -// Kept in sync with the backend's MAX_USERNAME_LENGTH (app/cfg/llm.py) -const MAX_NAME_LENGTH = 1_000; - -/** - * How much of a long field's budget is left, once it is close enough to matter. - * - * `maxLength` on a textarea truncates a paste silently, which for these two fields means a CV or - * a job description arriving 2,000 characters shorter than the one the user copied, with nothing - * on screen having said so. Hidden below the threshold: a counter over an empty box is noise, - * and the limit is generous enough that most sessions never approach it. - */ -const LIMIT_NOTICE_RATIO = 0.9; - -function FieldLimitNotice({ value, max }: { value: string; max: number }) { - if (value.length < max * LIMIT_NOTICE_RATIO) return null; - - const atLimit = value.length >= max; - return ( -

- {atLimit - ? `Character limit reached (${max.toLocaleString()}). Extra text was not added.` - : `${(max - value.length).toLocaleString()} characters left`} -

- ); -} - -interface ConfigurationDialogProps { - isOpen: boolean; - onOpenChange: (open: boolean) => void; -} - -export default function ConfigurationDialog({ isOpen, onOpenChange }: ConfigurationDialogProps) { - const [name, setName] = useState(''); - const [profileData, setProfileData] = useState(''); - const [context, setContext] = useState(''); - const [saving, setSaving] = useState(false); - const [loading, setLoading] = useState(false); - const [configLoaded, setConfigLoaded] = useState(false); - - // Load once per open, straight from main, rather than tracking app state. A save replaces - // the whole config, so this both refreshes what another device may have changed and keeps - // a late-arriving update from overwriting what the user is currently typing. - useEffect(() => { - if (!isOpen) return; - - let cancelled = false; - setLoading(true); - setConfigLoaded(false); - - void (async () => { - try { - const result = await getElectron()?.account?.get(); - if (cancelled) return; - - // Show whatever main has even when the refresh failed, so the user is not staring at a - // blank form - but only mark it loaded (and thus safe to save over) when it succeeded. - if (result?.data) { - setName(result.data.fullName); - setProfileData(result.data.profileData); - setContext(result.data.context); - } - setConfigLoaded(result?.success ?? false); - } catch (error) { - console.error('Failed to load configuration:', error); - if (!cancelled) setConfigLoaded(false); - } finally { - if (!cancelled) setLoading(false); - } - })(); - - return () => { - cancelled = true; - }; - }, [isOpen]); - - const handleSave = async () => { - setSaving(true); - try { - const electron = getElectron(); - if (!electron?.account) { - throw new Error('Electron API not available'); - } - - // Trimmed on the way out, not just validated. The Save button is already gated on the - // trimmed name being non-empty, so a name of pure whitespace could never be saved - but a - // name with a trailing space could, and it is the string the prompts address the candidate - // by. The same goes for a CV pasted with a leading blank line. - const result = await electron.account.update(name.trim(), profileData.trim(), context.trim()); - if (!result.success) { - throw new Error(result.error || 'Failed to save configuration'); - } - - // Account update pushes a fresh app-state broadcast, so no manual refresh needed here - onOpenChange(false); - } catch (error) { - console.error('Failed to save configuration:', error); - toast.error(error instanceof Error ? error.message : 'Failed to save configuration'); - } finally { - setSaving(false); - } - }; - - return ( - - - - Configuration - - Update your configuration: username, profile information (e.g. CV/resume) and interview - context (e.g. job description). - - - -
-
-
- - setName(e.target.value)} - placeholder="Enter your profile name" - className="text-sm" - maxLength={MAX_NAME_LENGTH} - /> -
- -
-
- - -
-