Upgrade the client app UX: launch hub, first-run setup, Account/Configuration split, hint-only default - #125
Merged
Merged
Conversation
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.
/ 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.
…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.
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).
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.
…buttons Capture Screenshot, Clear Captures, and Generate Suggestion were only reachable via their hotkeys (Ctrl+Shift+F9/F10/F11) - the service methods behind them (suggestion-action.service.ts) were fully implemented, but nothing on screen pointed at them: no button, no IPC handler for the renderer to call them through at all. Adds the three missing ipcMain handlers (thin wrappers around the existing service methods - no new business logic), exposes them via preload, and adds icon buttons to tools-group.tsx next to the other session tools. Window placement/move/resize/zoom/scroll stay hotkey+palette+cheat-sheet only, correctly: those are directional, not single-shot actions, and don't map to a one-click button the way capture/clear/trigger do.
Both existed only as forward references when the doc was first written; points at the real files now that command-palette.tsx and hotkey-cheatsheet.tsx exist, and states directly that directional hotkeys (window placement/move/resize/zoom/scroll) are correctly hotkey+cheat-sheet-only rather than a gap the palette should fill.
Both were modals stacked on top of whatever route the user opened
them from. Converts them to routed pages (/settings, /documentation)
following the pattern pages/payment/index.tsx already established -
sticky header with a back button, content below - since they're
destinations a user goes to do a batch of related things, not
transient in-context actions.
Removes configuration-dialog.tsx, documentation-dialog.tsx, and the
use-configuration-dialog.tsx context (openConfigurationDialog is now
just navigate('/settings')); updates every call site (titlebar menu,
command palette, home page, control panel's start-checks). The
hotkey cheat-sheet stays a dialog - it's a quick glance meant not to
lose your place mid-session, not a destination.
docs/ux-conventions.md gets a new "dialog vs. page" rule so the next
addition doesn't have to guess which one it should be.
Resolves conflicts between the mock-interview feature (merged via main)
and this branch's settings/documentation dialog-to-page refactor:
- control-panel/index.tsx and titlebar.tsx: keep both branches' additions
(command palette, mock-interview setup) and drop the dead
useConfigurationDialog/config-for-llmModel usages main's side no longer
needs.
- use-mock-interview-setup-form.ts (new on main) called the deleted
useConfigurationDialog hook; switched it to navigate('/settings') to
match this branch's dialog-to-page refactor.
- test/rtl-rendering.test.mjs still pointed at the removed
documentation-dialog.tsx; repointed at pages/documentation/index.tsx.
Verified with tsc -b and pnpm test:main.
…entence "Professional mode" named neither of the two things it switches between, so the control bar's tooltip, the status badge and the settings row all had to explain the setting rather than label it. The two modes now carry their own names: hint-only (a headline plus keyword bullets, read at a glance) and full-sentence (the answer written out as it would be spoken). Hint-only becomes the default for a new install. Reading a paragraph of prose while an interviewer waits is the thing candidates cannot actually do, and the mode built for that should not be the one nobody finds. An existing install keeps the mode it was already on. `professionalMode` is read one last time by the migration to seed `hintOnlyMode`, then scrubbed, so the rename cannot silently change what current users see in the panel. `SuggestionMode`'s wire values stay `normal`/`professional`: they are the backend's contract, it deploys separately, and renaming the members without renaming the strings keeps the client readable without shipping the two together. Both mirrors say so. The hotkey keeps Ctrl+Shift/Ctrl+Opt+F7; only its IPC channel, title and description change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One tabbed Settings screen answered two unrelated questions - "change something about my account" and "change how the interview runs" - and made the user pick the right tab before they could tell which one they were in. They are now two destinations named after what they hold, which is what lets the home page offer them as two buttons instead of one word that could mean either. The split follows a real difference, not just a taxonomy. Account has a Save button because it writes three fields to the backend as one unit and a half-written profile is a worse state than an unedited one. Configuration has none, because every control on it persists as it is changed and several are meant to be changed mid-interview. Configuration also gains what the old Session tab never had: a microphone picker with a live test. Choosing a device from a list said nothing about whether the machine was listening to it, which is the failure this app survives worst - it presents as silence, ten minutes into a real call, not as an error. Each setting is now a component under `components/custom/settings/`, and the three account fields share `use-account-form.ts`. Both exist so the first-run wizard can render the same controls rather than a second, drifting copy. Also lifts the sticky header every routed page had its own copy of into `page-header.tsx`, including the `location.key === 'default'` back-button fallback that a reload otherwise turns into a dead button, and adopts it on Documentation and Buy Credits as well. The Shortcuts tab becomes a button to the cheat-sheet dialog it always rendered inline; Billing becomes the Buy Credits page it already linked to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A new account landed on a screen that assumed the six things it needed had already been set, and said nothing about any of them. The first sign that the profile was empty was Start refusing to run; the first sign the wrong microphone was selected was silence during a real interview. `/onboarding` asks for them once, one per step, in the order a first interview needs them: profile, job context, language, microphone, suggestion style, transcript panel. Every step renders the same component the account and configuration pages use, so the wizard cannot teach a control that then looks different everywhere else. Only the profile step gates progress - the name and CV are what the control bar checks before it will start anything, so letting the wizard past them would only move the failure later. Skip is available throughout and records the same completion, because nagging someone who has decided is worse than the state it would protect them from. The account fields are written on the way out of the context step rather than at the end: they are the only thing here the user typed, and a window closed halfway through should not cost them a pasted CV. Gated on `onboardingCompleted`, which is local rather than account-level because half of what the wizard sets - the microphone above all - is a property of the machine, not the account. An install that predates the wizard is migrated straight to completed: those users have already configured the app the long way round. The gate reads the config the previous commit moved into `MainFrame`, so it sees a real value rather than the undefined a route-local fetch would leave it with for the first frames - which would flash the wizard at every signed-in user on launch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The home page listed one way to start and sent everything else to a single "Settings" button. It now names all five things a user comes here to do: start a mock interview, start the live assistant, open their account, open their configuration, or buy credits. Neither launch button starts a session on this page. Live hands off to `/main` through router state, because `/main`'s control panel owns the whole start sequence - the config and microphone checks, the headphone notice, the macOS permission gate and the save-history guard - and a second copy of that here is how the two would drift apart. Mock opens the same setup dialog the control bar uses and hands the result to `/mock-interview`, exactly as the control bar does. That handoff is deferred rather than fired on arrival: `checkCanStart` reads the account config and the enumerated microphones, neither of which has resolved in the first frames after a route change, so starting immediately would greet the user with "could not load your saved configuration" for a config that was about to arrive. If those never resolve, nothing happens and the user is looking at the Start button, which is the honest outcome. The greeting prefers the name the user actually gave over the local part of their email address. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ux-conventions.md` was written against a single Settings page and now points at the two that replaced it, with the rule for deciding between them: Account is who the user is, Configuration is how the interview runs, and the Save button follows from that rather than the other way round. Adds one convention the last two commits argue for. A setting the app cannot work without - the profile it writes answers from, the microphone it listens through - is not finished when it appears on a settings page, because a user expected to discover it on their own will discover it during their first real interview. It goes in the wizard too, as a step rendering the same component. `CLAUDE.md` and `SPEC.md` follow the mode rename, and record the two things that deliberately did not move with it: the wire values, which are the backend's contract, and the migration that carries an existing user's choice onto the renamed key before the old one is scrubbed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… failed finish Three holes found reviewing the flow end to end. Signing in redirected to `/main`, which is the one route the first-run gate does not cover - so the users the wizard exists for were the users who never saw it. It now lands on home, which is also the more honest destination for someone who has just signed in: `/main` assumes everything is already configured. Finishing the wizard navigated home whether or not the completion flag was written. Home gates on that flag, so a failed write bounced the user straight back into the wizard with no explanation - a loop, not a degraded state. A failed write now keeps them on the step and says why. The deferred live-assistant start could fire twice: StrictMode runs the effect twice on mount with no render in between, so the recomputed condition was not what stopped the second run. The ref is now re-checked inside the effect. Also documents the shared-machine cost of a local completion flag where the flag is declared, and adds two sections to the in-app documentation - what the two suggestion styles are, and which of the two settings pages holds what. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Skip abandoned everything, including the CV that had just been pasted into step one - the single most expensive thing to re-enter in the whole app, and gone with no warning. It now saves the account fields first when they are complete, and warns rather than blocking if that write fails: refusing to let someone leave is the one thing Skip must never do. The same fields are also written on the way out of the profile step, not only the context step, so a window closed on step four does not cost them either. Going back and forward costs one idempotent write; the alternative cost a CV. README follows the two settings surfaces and the two suggestion styles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e work "Start Interview" named one action for what the home page now correctly presents as two. The palette offers the same two, in the same words, and routes them the same way - through `/main`'s router state, because the control panel there owns the whole start sequence and the palette is reachable from routes where none of it is mounted. `/main` itself stays in "Go to" as the interview console, which is what it is. That exposed a real bug in the handoff the previous commit added. The arrival intent was consumed once per mount, which was correct while every request arrived with a route change and wrong the moment one could arrive without one: firing Start from the palette while already on `/main` is the same mount, so the second request was silently swallowed. Guarded per history entry instead - each navigate makes a new key, the replace clears the state so Back finds nothing to re-trigger, and the key still stops StrictMode's double effect from acting twice before that replace lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The live assistant and a mock interview are mutually exclusive - both want the microphone and an ASR socket, and the main process refuses the second one. The control bar has always expressed that by offering the mock start only while idle; home did not, so during a live session it still offered "Start mock interview" and routed the user to a screen whose only content was the refusal. The mock card now names the reason and leaves the tab order, and the live card becomes the way back to the interview it would otherwise offer to start again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stop only stopped. The transcript stayed in main-process memory with nothing on screen saying it was still there and nothing saying it was about to go, and the first question about saving it arrived at the *start* of the next session - a question about an interview the candidate had finished with an hour earlier, asked at the moment they are trying to begin a new one. Stop now ends the session, asks whether to keep it, drops the buffers and returns to home. Asking while it is still the thing they were just doing is the only moment the question has an obvious answer, and going home is the only honest destination once the console behind the dialog is empty. `stop` is the first reason in `save-history-dialog.tsx` that is not a guard. The other four are asked before the destructive act and can be answered with "not now", which leaves the interview alone; this one is asked after, so it drops its Cancel and refuses Esc - an Esc that read as backing out would silently be the discard. Deliberately not what the stop hotkey does. That one is the stealth-mode escape: it fires while the app is hidden mid-screen-share, where a modal dialog and a navigation to a dashboard are the opposite of what was asked for. It still stops, and the next Start still asks about what it left behind. The control bar's Stop and the command palette's both route through the one hook, so the two cannot drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dialog whose entire job is configuring a session showed the language as a read-only row and told the user to go and change it on another screen - the one setting there that could not be set where it was being read. It is now the same `LanguageField` the configuration page and the first-run wizard use, editing the same stored setting, because there is only one: a mock session reads the language the live assistant does. Sharing the component rather than adding a second picker is what keeps that true. `showVoice` is new on that field and only the mock passes it. The live assistant never speaks, so whether a language has a Deepgram voice is a fact about mock interviews alone - it marks the voiceless languages in the list itself, where it is needed while choosing, and keeps the existing explanation of what happens when one is picked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha5611331
force-pushed
the
feat/ux-upgrade
branch
from
September 6, 2026 21:35
913bd52 to
63a1614
Compare
Starting is a home-screen decision now - both kinds of session are named and described there, and both launch buttons hand off to the surface that runs them. The control bar's split Start button was a third way to do the same thing, with its own dropdown and its own stored memory of which mode you last used, all to answer a question the home screen now asks outright. So `/main` carries Stop and nothing else. Idle is a transient state on that route rather than its resting one - the start was cancelled at the headphone notice or the permission gate, or the route was opened directly - and the primary slot holds a way back to the home screen for it, because an idle console with an empty primary position is a dead end. Three things follow from the removal rather than being separate changes: - The mock setup dialog moves off the control bar to the home screen, which is now its only caller. "Practise again" on the report and the palette's mock start hand off there instead of to `/main`, and the mock route's other two exits follow them home. - `lastSessionMode` existed solely to decide what the split button did by default. Nothing reads it any more, so it is removed and scrubbed from disk rather than left as a key the app still writes and never consults. - `/main`'s router-state handoff narrows to the live start, which is the only thing that still arrives that way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha5611331
force-pushed
the
feat/ux-upgrade
branch
from
September 6, 2026 21:37
63a1614 to
0330adc
Compare
Signing out was reachable only from the titlebar menu and the command palette - a strange place for the one action that ends everything else the screen offers, and one a user looking for it has no reason to open. It sits with Documentation at the foot of the home screen now, held to the right so it does not read as another thing to go and do. Refused while a session is running, for the same reason the titlebar menu refuses it and with the reason on the control: signing out tears down the credentials the running assistant is streaming on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s Start Comment-only. Six docstrings and inline notes still named a split Start button on the control bar, a mock setup dialog opened from it, and a Start the user would be left looking at on an idle console - none of which exist now. Two of them carried real reasoning worth keeping accurate rather than deleting: why the mock form is split out of its dialog, and why `clearAll` can guard on `isActive()` rather than checking harder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was a local config key, which cannot answer the question it was asked. Two cases it got wrong, in opposite directions: a user who set up on their laptop was walked through the wizard again on their desktop, and a second account signing in on a machine the first had already set up never saw it at all - and reached Start with an empty profile. The flag is a property of the account, so it lives there (`PATCH /api/users/me/onboarding`, PowerInterviewAI/backend#62). The local key is scrubbed rather than merely ignored: two answers to a one-answer question is how the wizard ends up deciding by whichever was read last. The gate waits for `interviewConfigLoaded` as well as the flag. Until the account has actually been read this session the flag is a default rather than an answer - acting on it sooner would flash the wizard at every signed-in user on launch, and show it in full to anyone whose pull failed. For the same reason the write is mirrored into app state only after the backend confirms it: an optimistic write would let a failed save look like a finished setup until the next launch put the wizard back. Signing out clears it along with the rest of the account, or the next user on the machine would inherit the previous one's answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Six things that made a first-run screen worse than no first-run screen. **A disabled Continue said nothing.** The commonest way a wizard strands someone: they can see they cannot go on and cannot see what to do about it. It now names what is missing - the name, the profile, or an account it could not reach - and the unreachable case gets a Retry rather than an instruction to restart the app. **Typing while the account loaded lost what was typed.** The fetch runs to thirty seconds, the fields render immediately, and filling in a form that looks empty is the obvious thing to do. Both halves are closed: the fields are disabled until the account lands, and the response no longer overwrites fields the user has already edited. **Enter did nothing.** The step is a form now, so Enter advances from any single-line field. The two long fields are textareas and keep Enter for newlines. **Nothing said where you were or where you were going.** The screen names itself, the progress line names the current step rather than only counting it, and completed steps are distinguishable from the current one. The heading takes focus on every step change - this is the one navigation in the app where a button press replaces the whole page, so a keyboard or screen-reader user was told nothing about it. **The titlebar offered three ways to leave that all looked broken.** Home bounced straight back, and Account and Configuration are what the wizard is in the middle of collecting. They are hidden while it is open; Skip is the way out and it is on the screen, where it can say what skipping costs. **Skipping looked final.** It is not - Configuration now offers *Run setup*, and `/onboarding` renders regardless of the flag - so the button says "Skip for now", and finishing says "You're all set" rather than the screen simply vanishing with nothing to confirm it took. Also stops the footer jumping between a step with two textareas and a step with one checkbox, and stops the microphone step telling a user with no device plugged in to reopen a screen that already updates itself when one appears. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… flag Two holes found re-reading the gate. A deployment that predates `onboarding_completed` omits it, and the client read that as false - so every user of that deployment would be sent to the wizard with no way out, because Finish and Skip both write through an endpoint that deployment does not have either. Absent now counts as done. Guessing wrong in that direction locks the app; guessing wrong in the other costs a screen nobody saw. And a pull that started before Finish could land after it, reverting the flag and putting the wizard back in front of someone who had just completed it. The write bumps the generation now, the same way saving a profile does, so the stale read is discarded rather than applied. The flag is also read out of the account response before the legacy-migration branch, which returns early - a user migrating a pre-sync CV was getting the wizard regardless of what their account said. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**The wizard ran twice, every time.** 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, decided setup was still pending, and sent the user straight back in. A session-local dismissal now says "done *now*" while the account flag says "done"; the gate reads both, and it is cleared on sign-out so the next account is not handed a dismissal that was never theirs. That also gives Skip a way out that does not depend on the network. Skip means "let me out", so it leaves whether or not the durable write landed, warning that setup may be offered again. Finish still stays put on a failure - the user has just answered six questions and should not be asked all six again. **Zoom joins Configuration and the wizard.** It was a control-bar button and a hotkey, which is no use to the person who needs it: this window is small on purpose so it does not cover the call, and a candidate squinting at a suggestion mid-question is not going to go hunting for a zoom control. Asked during setup, while there is time to answer it. `zoom:set-factor` is new and is an invoke rather than a send, unlike the three beside it, so the field's readout cannot drift from the window when a value is clamped. The field otherwise reads the same `zoom:level-changed` broadcast the control bar does, so the two and the hotkeys cannot disagree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s it A whole paragraph appearing at once, followed a second or two later by a voice starting to read it, does not read as someone asking a question. It reads as a page loading and then a recording playing - which is the wrong impression for the one screen in the app that is pretending to be an interview. The question is now written out word by word, and the reveal is timed against the *sound* rather than the state change. `Speaking` begins before there is any audio, because the first sentence still has to be synthesised, so timing the reveal against the state would have put the words up during that silence and made the gap worse rather than better. `mockTtsService` announces the moment its first chunk actually starts playing, and the reveal starts there. Presentation only: the backend returns a question whole, the text is complete in state throughout, and only how much of it is on screen changes. It paces at roughly twice speech so the last word is up well before the sentence ends - at speech pace the candidate could never read ahead, which is worse than not animating at all - and a long question speeds up rather than taking proportionally longer. Three ways it declines to get in the way: `prefers-reduced-motion` shows the whole question at once, the wait for audio gives up after 2.5s so a slow or failed synthesis cannot leave an empty row, and the full text is always in the DOM for assistive technology with only the unrevealed part hidden from sight. Once it is the candidate's turn the whole question stays on screen to be read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…eveal Three things the last four commits changed that the docs still described the old way: setup now has seven steps rather than six, Configuration holds interface size, and the gate reads a session-local dismissal alongside the account flag - which is worth writing down where it is, because it looks redundant with the flag until you know the two IPC messages are unordered. Adds a short section on how a mock question reaches the screen, since "the backend streams it" is the obvious wrong conclusion to draw from watching it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…st run Configuration's *Run setup* lands on the same screen as a first launch, and the screen did not know the difference. It called itself first-time setup, offered "Skip for now" as the only way out of something the user had chosen to open, and the titlebar hid Home, Account and Configuration - so a user who opened the guide to change one setting had to press Skip to leave the app's own settings page. The compulsory run keeps all of that, because there it is honest: setup is not finished, Home does bounce straight back, and Skip does have a cost worth naming. A voluntary run is an ordinary page - "Setup guide", "Close", and the navigation left where it was. Also adds `currentQuestion.hasAudio` to the mock transcript's turn memo, which now reads it. `speechFailed` moves it for the question already on screen, and it currently rides along on a state change in the same broadcast - which is exactly why leaving it out would stay invisible until the day it did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two defects the mock-interview feature introduced, found reviewing everything since v1.7.1. **The next user on a shared machine inherited the previous one's interview.** Sign-out cleared the session token and the account config and nothing else, so the transcript, the suggestions and the whole mock session stayed in main-process memory - and the close guard reads `hasHistory` /`hasMockContent` off exactly that state. A second user signing in and closing the app was offered "Save this interview before closing?" and would have exported the first user's questions and spoken answers to a file. Sign-out now drops all of it, including a mock session still running, which `clearAll` will not touch by default and which this caller opts into. **Sign-out was only guarded against a live session.** A mock session deliberately leaves `runningState` on Idle - it hides no window surfaces and holds no ASR socket - so the existing check waved it through, and the candidate could sign out from under an interviewer that was mid-question, tearing down the token its next request needs and leaving the microphone gate to whatever happened to release it. Both sign-out controls now treat a mock session as a running one, as does the Account entry beside them. And sign-out asks first. It destroys an interview that has not been written to disk unless it was exported, which is the definition the save-history guard already applies to Clear, Start, Stop and closing the app - it was simply the one path that did it silently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The session-local dismissal was cleared from the index route, which only sees the sign-outs that happen while it is mounted. Sign out from Configuration - or have a token expire on any other screen - and it stayed set, so the next account to sign in during that run of the app was never offered setup at all. Exactly the hole the reset existed to close, left open on every route but one. Watched from `MainFrame` now, which is mounted for every route and so sees every sign-out, including the ones main declares itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Leaving `/mock-interview` mid-session ends it outright, scoring whatever was answered and dropping the rest. That was defensible when the route had one way out. It is not now: this branch added Home and the two settings pages to the titlebar menu, the same entries to the command palette, and the palette's own two Start actions - so a live interview became a single click from over, with nothing asked and nothing said. Guarded with `useBlocker` rather than a check on each of those, because that list is exactly the kind that grows without anyone remembering it exists. This catches the next one too. Signing out is deliberately not blocked: `isLoggedIn` going false is main saying the session is over, usually an expired token, and there is nothing to stay for. The report screen's own two exits are unaffected - a finished session is not an active one - and closing the app stays with the window-close guard, which is what covers Alt+F4, the taskbar button and Cmd+Q. Closes the follow-up the module docstring has been carrying. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Home and the account page both reported the signed-in user from `ConfigStore.email`. That is not an identity, it is a credential: the login form writes it only when "remember me" is ticked, and writes an empty string when it is not. So a user who declined "remember me" - a reasonable thing to do on a shared machine, which is exactly where it matters - was shown "Not signed in" on the home screen of an app they were signed into, and "Loading..." forever on their own account page. The account's own email is now carried in app state beside the rest of what the account read establishes, and everything that displays who is signed in reads that. It is cleared with the rest on sign-out, for the same reason the profile and the onboarding flag are: on a shared machine the next user would otherwise be shown the previous one's address. `test/account.test.mjs` pins that pull-then-clear cycle for all three fields, and pins that an absent `onboarding_completed` counts as done - the one that would lock every user of an older deployment into a wizard with no way out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`startAssistant` reads the session token, the microphone and the language straight off the config store, which is undefined until `config:get` resolves. A hand-pressed Start was always well clear of that. A start that fires on arrival - which is what the home screen's launch button now does - is not, and the failure it lands on is an ASR socket opened with an empty token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are the kind of rule that reads as an arbitrary restriction to whoever meets it next and gets removed: why sign-out clears main-process state, why it is refused during a mock session that leaves `runningState` on Idle, and why leaving the mock route asks before it does what it has always done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #124. Supersedes #121, which GitHub closed when this branch was renamed.
The problem
Everything the app can do was reachable, and almost none of it was findable.
/redirected into the control console; the console's capabilities lived in a 32px icon row; one "Settings" page answered two unrelated questions; and nothing anywhere told a new user what they had to set before their first interview. The two most expensive failures in this app — an empty profile and the wrong microphone — both present as silence rather than as errors, in the middle of a real call.What changed
Twenty-seven commits, each buildable and testable on its own.
1.
refactor(suggestions)— hint-only / full-sentence"Professional mode" named neither of the two things it switched between, so the tooltip, the status badge and the settings row all had to explain the setting rather than label it. The modes now carry their own names: hint-only (a headline plus keyword bullets, read at a glance) and full-sentence (the answer written out as it would be spoken).
Hint-only becomes the default for a new install. Reading a paragraph of prose while an interviewer waits is the thing candidates cannot actually do, and the mode built for that should not be the one nobody finds.
Two things deliberately did not move with the rename:
normal/professional. They are the backend's contract (app/schemas/suggestion.py), it deploys separately, and renaming the TypeScript members without renaming the strings keeps the client readable without shipping the two together. Both mirrors say so.professionalModeone last time to seedhintOnlyMode, thenscrubRetiredKeyremoves it — so the rename cannot silently change what current users see in the panel.test/config-store.test.mjspins both halves.The hotkey keeps
Ctrl+Shift/Ctrl+Opt+F7; only its IPC channel, title and description change.2.
refactor(settings)— Account and Configuration, not "Settings"The split follows a real difference, not a taxonomy:
Configuration gains what the old Session tab never had: a microphone picker with a live level test. Choosing a device from a list said nothing about whether the machine was listening to it.
Each setting is now a component under
components/custom/settings/, and the three account fields shareuse-account-form.ts. Both exist so the wizard renders the same controls rather than a second, drifting copy.Also lifts the sticky header each routed page had its own copy of into
page-header.tsx— including thelocation.key === 'default'fallback that a reload otherwise turns into a dead back button — and adopts it on Documentation and Buy Credits too.3.
feat(onboarding)— first-run setup/onboardingasks once for the six things a first interview needs, one per step, in the order it needs them: profile, job context, language, microphone, suggestion style, transcript panel. Every step renders the same component the two settings pages use.onboardingCompleted, local rather than account-level because half of what the wizard sets — the microphone above all — is a property of the machine. An install that predates the wizard is migrated straight past it.4.
feat(home)— the launch hub/now names all five things a user comes here to do: start a mock interview, start the live assistant, Account, Configuration, Buy Credits.Both buttons start a session — neither implements starting one. Live hands off to
/mainthrough router state, because/main's control panel owns the whole start sequence — the config and microphone checks, the headphone notice, the macOS permission gate, the save-history guard — and a second copy of that here is how the two would drift apart. Mock opens the same setup dialog the control bar uses, which collects the session's shape and hands the result to/mock-interview. In both cases the click ends in a running session, not on a console with a Start button waiting to be pressed again.That handoff is deferred, not fired on arrival:
checkCanStartreads the account config and the enumerated microphones, neither of which has resolved in the first frames after a route change, so starting immediately would greet the user with "could not load your saved configuration" for a config that was about to arrive.5.
feat(session)— stopping offers to save, then goes homeStop only stopped. The transcript stayed in main-process memory with nothing on screen saying it was still there and nothing saying it was about to go, and the first question about saving it arrived at the start of the next session — a question about an interview the candidate had finished with an hour earlier, asked at the moment they are trying to begin a new one.
Stop now ends the session, asks whether to keep it, drops the buffers and returns home.
stopis the first reason insave-history-dialog.tsxthat is not a guard: the other four are asked before the destructive act and can be answered with "not now", which leaves the interview alone. This one is asked after, so it drops its Cancel and refuses Esc — an Esc that read as backing out would silently be the discard.Deliberately not what the stop hotkey does. That one is the stealth-mode escape: it fires while the app is hidden mid-screen-share, where a modal dialog and a navigation to a dashboard are the opposite of what was asked for. It still stops, and the next Start still asks about what it left behind.
The control bar's Stop and the command palette's both route through one hook, so the two cannot drift.
6.
feat(mock-interview)— the language is set where it is readThe dialog whose entire job is configuring a session showed the language as a read-only row and told the user to change it on another screen — the one setting there that could not be set where it was being read. It is now the same
LanguageFieldthe configuration page and the wizard use, editing the same stored setting, because there is only one: a mock session reads the language the live assistant does.showVoiceis new on that field and only the mock passes it. The live assistant never speaks, so whether a language has a Deepgram voice is a fact about mock interviews alone — it marks the voiceless languages in the list itself, where it is needed while choosing.7.
refactor(control-bar)—/mainonly stopsStarting is a home-screen decision now, so the control bar's split Start button was a third way to do the same thing — with its own dropdown, and its own stored memory of which mode you last used, to answer a question the home screen asks outright.
/maincarries Stop and nothing else. Idle is a transient state on that route rather than its resting one (a start cancelled at the headphone notice or the permission gate, or the route opened directly), and the primary slot holds a way back home for it, because an idle console with an empty primary position is a dead end.Three things follow from the removal rather than being separate changes:
/main, and the mock route's other two exits follow them home.lastSessionModeis removed and scrubbed from disk. It existed solely to decide what the split button did by default; leaving a key the app still writes and never reads is how config rots./main's router-state handoff narrows to the live start, the only thing that still arrives that way.8.
feat(home)— Sign outReachable only from the titlebar menu and the command palette before — a strange place for the one action that ends everything else the screen offers. Refused while a session is running, with the reason on the control.
9.
refactor(onboarding)+fix(onboarding)— the flag moves to the account, and the wizard becomes answerableThe completion flag started as a local config key, which cannot answer the question it was asked: a user who set up on their laptop was walked through it again on their desktop, and a second account on a shared machine never saw it at all. It lives on the account now (backend#62, merged), and the local key is scrubbed rather than merely ignored.
Then a UX pass on the wizard itself, which had six things wrong with it:
And the wizard ran twice, every time. 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. A session-local dismissal now says "done now" while the account flag says "done"; the gate reads both.
10.
fix(onboarding)— interface size joins Configuration and setupZoom was a control-bar button and a hotkey, which is no use to the person who needs it: this window is small on purpose so it does not cover the call, and a candidate squinting at a suggestion mid-question is not going to go hunting for a zoom control.
zoom:set-factoris new — an invoke rather than a send, unlike the three beside it, so the field's readout cannot drift from the window when a value is clamped.11.
feat(mock-interview)— the question is written out as it is spokenA whole paragraph appearing at once, followed a second or two later by a voice starting to read it, does not read as someone asking a question. The reveal is timed against the first audio chunk actually sounding rather than against the
Speakingstate — that state begins before the first sentence has been synthesised, so timing against it would have put the words up during the silence and made the gap worse.Presentation only: the backend returns the question whole. It paces at roughly twice speech so the last word lands before the sentence ends, gives up waiting for audio after 2.5s so a failed synthesis cannot leave an empty row, shows everything at once under
prefers-reduced-motion, and keeps the full text in the DOM for assistive technology throughout.12. A review pass over everything since v1.7.1
The last pass reviewed this branch against the release rather than against itself, and found five
defects — four of them cross-account, on a machine two people share.
nothing else, so the transcript, the suggestions and the whole mock session survived it — and
the close guard reads
hasHistory/hasMockContentstraight off that state. A second usersigning in and closing the app was offered "Save this interview before closing?" and would
have exported the first user's questions and spoken answers to a file.
runningStateon Idle, so the check waved it through and the candidate could sign out fromunder an interviewer that was mid-question.
the definition the save-history guard already applies to Clear, Start, Stop and closing the app.
happen while it is mounted. Sign out from Configuration and the next account to sign in was
never offered setup at all — the exact hole the reset existed to close, open on every route but
one.
ConfigStore.emailis a credential the login form writes only under "remember me", so a user who declined it — on
a shared machine, where declining is the reasonable choice — was shown "Not signed in" on the
home screen of an app they were signed into. The account's own email is carried in app state
now, and cleared with the rest on sign-out.
Two more from the same pass, both introduced by this branch:
one-click ways to leave (titlebar Home and the two settings pages, the same in the palette, and
the palette's two Start actions). Guarded with
useBlockerrather than a check on each, becausethat list grows.
/main's config had loaded, opening the ASRsocket with an empty session token. A hand-pressed Start was always well clear of that.
test/account.test.mjsgains ten assertions covering the pull-then-clear cycle for all threeaccount fields, and the absent-flag rule.
13–27. Docs and earlier review fixes
ux-conventions.mdnow points at the two settings pages with the rule for choosing between them, and gains one convention the work argues for: a setting the app cannot work without is not finished when it appears on a settings page — it goes in the wizard too, rendering the same component.CLAUDE.md,SPEC.md, the in-app Documentation page and the README follow.The remaining commits are fixes found reviewing the flow end to end — see "What review caught" below.
Judgment calls
'professional'to'hint_only'would have required the backend to deploy in lockstep with the client for a cosmetic gain. The TypeScript members areHintOnly/FullSentence; the strings are the contract.onboardingCompletedis local, not account-scoped. The cost is a second account signing in on a machine where the first has already been through the wizard: it does not run again, and that user reaches Start with an empty profile. They are not stranded — the control bar names the missing field and sends them to Account — and scoping the flag to an account trades this for a worse failure: re-running the wizard for the same person on every new machine. Documented where the flag is declared./mainkeeps no Start at all, not even a disabled one. The idle slot holds a Home button instead: a disabled Start would imply the screen could start something, and an empty slot would strand anyone who cancelled out of the headphone notice.falseand are offered the wizard once, prefilled and skippable. A backfill would spare them that screen and cost a migration plus a startup query on every boot — and this release changes the default suggestion mode and adds a microphone test, which makes showing them once the better half of that trade.What review caught
Five holes, each fixed in its own commit rather than folded back:
/main— the one route the first-run gate does not cover. The users the wizard exists for were the users who would never have seen it. Sign-in now lands on home./mainis the same mount. Guarded per history entry now.onboarding_completedomits it, and the client read that as false — sending every user of that deployment into a wizard whose only two exits write through an endpoint it does not have either. Absent counts as done now: guessing wrong that way costs a screen nobody saw, the other way locks the app.test/mock-session-scroll.test.mjscaught the mock route's redirect target when it moved, which is what that assertion is there for.Test plan
pnpm lintclean across the projecttsc -b(renderer) andtsc -p tsconfig.electron.json(main) both cleanpnpm test:main— all checks pass, including new config-store assertions for the rename migration, the key scrub, and the onboarding migrationpnpm run buildsucceeds end to endCtrl+Shift+F7and the control-bar toggle mid-session → Stop (save prompt in both formats, and discard) landing back on home with an empty console → the stop hotkey in stealth mode, confirming it does not prompt or navigate → a mock interview, confirming the question is written out in step with the voice and that a text-only language reveals immediately → Run setup from Configuration, confirming it reads as a page rather than a first run → an upgrade over an existing install, confirming the previous suggestion style survives and the wizard does not appear