Add built-in mock interview feature (client) - #120
Conversation
Shown before every session now, with no permanent silence option. CLAUDE.md's own justification for the notice being opt-out was that "whether the call is on speakers is a property of the machine and the meeting, not a setting, so it can change between sessions on the same install" - a permanent tick contradicted exactly that: the one fact the dialog exists to establish was the one fact a stale tick could no longer speak to. Drops headphoneNoticeAcknowledged entirely (main store, its migration backfill, and the renderer Config type) now that nothing reads it. This is a product-wide change to the live flow, done here because the mock interview feature (following) reuses this same dialog and would otherwise need to reason about a flag left over from a different feature's consent.
Additive only - the class and the function are unchanged, just no longer private to this module. Lets the mock interview's mic-only capture service compose AudioWsStream directly rather than duplicating it or adding a mode flag through LiveTranscriptionService itself, whose setLanguage/setStream race guards are pinned by source-level tests (language-switch.test.mjs, audio-device-switch.test.mjs) that assert on statement ordering inside those exact methods.
The main-process half of built-in mock interview: - Types mirroring the backend's app/schemas/mock_interview.py, the way llm.ts mirrors suggestion.py - same field names and enum string values, so requests need no translation layer at the boundary. isMockInterviewSessionActive() is exported from the shared shape so main (appStateService.getState().mockInterview) and the renderer ask the same "is a session actively running" question without duplicating it. - TTS_LANGUAGES in types/language.ts: a local fast-skip mirroring the backend's DEEPGRAM_TTS_VOICES, so main does not fire a /speak request it already knows will come back 204. The backend stays the authority either way. - speech-chunks.ts: sentence-level splitting for incremental TTS playback, language-aware because Japanese uses '。' rather than '.'. Short pieces (an abbreviation like "Mr.") are carried forward and combined with what follows rather than finalized as their own chunk - accumulating forward rather than checking backward against the previous finalized piece, which is what lets several short pieces in a row merge correctly instead of pairing off arbitrarily by position. Skipped entirely for CJK, whose sentence-final punctuation has no equivalent abbreviation ambiguity. - mock-interview.service.ts: the state machine itself. Idle -> Starting -> Generating -> Speaking/Listening -> Evaluating -> Scoring -> Finished, gated by a sessionSeq generation token so a response for a session that has since ended or restarted is discarded rather than applied - the same shape AudioWsStream's switchSeq gives the renderer. Failures degrade rather than end the session (a failed synthesis falls back to text-only, a failed turn decision fails forward to NEXT, a failed question generation retries once then scores what exists), and the one path that must affirmatively surface an error is the very first question: generateNextQuestion's fail-forward-to-Idle is correct behaviour mid-session, where a skip or "next" already has a report's worth of progress to fall back on, but at session start it would otherwise be Start doing nothing with no explanation. - ApiClient.postArrayBuffer: request<T> always calls response.json(), which a binary body cannot satisfy - this is what lets main read the TTS proxy's audio bytes (or its 204 for a language with no Aura voice, resolved to null rather than an error). - The mockInterview IPC namespace and preload surface, registered alongside the other fourteen in index.ts. Backend: PowerInterviewAI/backend#60.
…ction block - appStateService: mockInterview + a derived hasMockContent, following the exact withHistory() mechanism hasHistory already uses - stripped from incoming updates (renderer-settable would be a close-guard bypass) and recomputed from answers that carry real content, not from answers.length. A skipped question must not count, or skipping straight through a session would arm the close/export guards over nothing - the same reasoning as the zero-answer Scoring guard in mock-interview.service.ts. Kept independent of hasHistory/EXPORTABLE_KEYS rather than folded in: the two guard different sessions, and conflating them would let one mask the other. - use-app-state.tsx's normalize() is an allowlist - a field missing here is silently dropped from every subscriber regardless of what main broadcasts, which is why this is its own commit rather than assumed to follow from the type change alone. - window-close-guard.ts: widened to hasHistory || hasMockContent. - window-control.service.ts: toggleStealth() refuses during an active mock session - it needs no always-on-top and no screen-share hiding, and a click-through, non-focusable window would strand the session screen the user is looking straight at. - suggestion-action.service.ts: an explicit refuseDuringMockInterview() check on clearImages/captureScreenshot/startGenerateSuggestion, on top of the RunningState check they already have. That existing check already blocks these today, but only as a side effect of mock mode never setting RunningState.Running - emergent, not designed. The day mock interview reuses RunningState for its own surface-hiding purposes (a genuinely tempting refactor), the four global action-suggestion hotkeys go live during practice with nothing failing anywhere. This is the belt that survives that change. - hotkeys.ts: Ctrl+Shift+Q ends the mock session when one is active, instead of sending the live stop-assistant event to a session that was never started. There is no start hotkey for either mode, so this is the only routing decision this shortcut needs. - use-assistant-service.ts: startAssistant() refuses while a mock session is active - both want the microphone and an ASR socket, and running both would bill twice.
…ayback The client-side half of the acoustic-feedback problem this feature exists to solve: without it, the interviewer's own TTS voice would be transcribed as something the candidate said. - mock-transcription.service.ts: a sibling to LiveTranscriptionService composing the now-exported AudioWsStream, not a mode flag threaded through it. Captures only the microphone - never getDisplayMedia, never enableLoopbackAudio - and ingests through electron.mockInterview.ingestAnswer rather than transcription.ingest, so a mock session never touches transcriptService, never writes appState.transcripts, and never fires a live suggestion (and its cost) for a practice answer. - mock-tts.service.ts: MicGate mutes the mic MediaStreamTrack while TTS plays. Four independent layers make a stranded mute impossible - a `finally` around every play attempt, a watchdog (mirroring ACTION_LOCK_MAX_HOLD_MS) for an HTMLAudioElement that never fires `ended` or `error`, a generation token so a late release from a superseded utterance cannot reopen a newer one's gate, and forceReleaseNow() as the state-driven belt use-mock-interview.ts calls on every transition off Speaking. Chunks are fetched with a lookahead of one and cached per question, so Repeat costs no re-synthesis and no second Deepgram charge. - use-mock-interview.ts: reacts to the broadcast state rather than polling it - acquires the microphone and starts capture explicitly in startSession(), before asking main to generate the first question, so a denied permission is a cheap failure caught before any backend call; triggers playback on entering Speaking; and calls mockTtsService.stop() on every transition away from Speaking, which is what aborts a stray Speaking-driven playback the instant Skip or End interview moves main on to something else without touching a user-initiated Repeat (which never changes session.state and so never re-runs this effect). - use-mic-level.ts: a live 0-1 level written to a ref, not React state, so the meter that reads it can update every animation frame without re-rendering the session screen at that rate.
Three screens under /mock-interview, reachable from the titlebar menu (disabled while the live assistant is running): - Setup: role, seniority, a RadioGroup for difficulty (three options with the description that makes the choice, not a Select), question count labelled with an approximate duration, and the existing language picker's value shown with a Voice/Text-only badge from the new hasVoice field. A non-destructive Alert states the text-only fallback before the session starts, not as a mid-interview surprise. - Session: a thin progress bar that a follow-up deliberately does not advance (it belongs to the same question), a single indicator for whose turn it is (pulsing while Speaking, a live level ring while Listening, driven off use-mic-level.ts's ref without re-rendering per frame), and Repeat/Skip/Done answering/End interview. - Report: overall score, strengths/gaps, a per-question accordion with the model's stronger-answer rewrite rendered through the existing SafeMarkdown (the one part of the report that is model prose and may carry emphasis), and the same "Save as Word"/"Save as Markdown" wording the live save-history dialog already uses. Deliberately the opposite visual contract from the live control bar: a centred single column with generous whitespace, because here the user is looking at the app rather than at another person during a call. Adds five shadcn primitives this project did not have yet (progress, label, radio-group, alert, scroll-area) in the existing "new-york" style, matching the Radix-primitive-plus-cva pattern already used by checkbox.tsx and badge.tsx. scroll-area.tsx is plain overflow rather than a new Radix dependency - the same overflow-y-auto approach transcript-panel.tsx already uses. types/language.ts (renderer): each of the 28 entries gains hasVoice, mirroring the backend's DEEPGRAM_TTS_VOICES. Kept on `// prettier-ignore` and one entry per line - language.test.mjs parses this array with a per-line regex, and letting Prettier wrap the longer names onto several lines would silently drop them out of that check.
…tch bug - export-mock-markdown.ts: builds the mock report the same way export-markdown.ts builds the live one, and carries the same no-electron-import constraint so a .test.mjs can load it directly. Reads report.questions when scoring succeeded (it carries scores and stronger answers) and falls back to the raw answers when it did not - the transcript is still worth exporting even when the model failed to score it. - export-labels.ts: every language gains mockInterview/question/yourAnswer/score/ strengths/gaps/strongerAnswer, for the same reason the existing five fields are translated at all - the report is handed to someone who was not there and may not read English. - export-markdown.ts: generateExportFilename takes an optional prefix (defaulted so the existing caller is unchanged), so a mock report and a live interview export don't collide in a downloads folder under the same "report-<timestamp>" name. - tools.service.ts: exportMockReport(), guarded on the same "answers with real content" standard hasMockContent uses. - save-history-dialog.tsx: now dispatches to exportTranscript or exportMockReport by which subject actually has content. This is a real bug fix, not just plumbing - the close guard was already widened (a previous commit) to fire on hasHistory || hasMockContent, and this dialog would otherwise have called exportTranscript unconditionally, which throws "There is nothing to export yet" for a mock-only session.
…xport Six new files, plus edits to language.test.mjs (hasVoice) and app-state.test.mjs (hasMockContent), all passing alongside the existing suite: - mock-interview-state.test.mjs: drives mockInterviewService through a real session via a fake global fetch rather than a mocked class, so every failure mode is a real HTTP response shape. Pins the terminal-state invariant directly - zero answers never reaches Finished, a failed report still reaches Finished with the transcript intact, a follow-up does not advance the question counter, isActive() is false at both terminal values and true everywhere between them (the exact signal the mutual- exclusion and action-suggestion guards key off). - mock-interview-gate.test.mjs: source-level, the same reason audio-device-switch. test.mjs is - renderer code with no runtime harness here. Pins acquire-before-play, release inside playQuestion's own finally, the watchdog armed at acquire and cleared at release, and the generation-token check preceding the unmute. - mock-transcription-isolation.test.mjs: source-level - never opens loopback capture, never calls the live transcription ingest channel, constructs exactly one AudioWsStream on ch_1. - mock-action-suggestion-block.test.mjs: drives runningState to Running *and* a mock session active at once - the adversarial case the explicit guard exists for, not just the ordinary Idle case that would pass on the emergent behaviour alone and prove nothing about it. - speech-chunks.test.mjs, mock-export.test.mjs (every language has the full extended label set). Writing these caught four real bugs before they shipped, on top of the rate-limiting one in the backend PR: a chunking merge that grouped sentences by checking the wrong neighbour, a Japanese merge that inserted an English-style space, an export test whose own expectation ignored the merge threshold it was testing against, and generateNextQuestion's fail-forward silently discarding the first-question failure a user needs to see.
- The page's unmount cleanup closed over `session` from its initial (empty-deps) render, which is null before a session ever starts. Navigating away mid-interview never called endSession(), leaving the main-process state machine stuck outside Idle. Track the live session in a ref updated every render and read that in cleanup. - MOCK_ANSWER_SILENCE_MS was defined and documented but never wired up, so a candidate who stopped talking and forgot to click "Done answering" waited forever. Arm a timer on real speech in ingestAnswer that calls answerFinished() after the silence window, cleared on every path that leaves Listening. - Add a regression test pinning the silence-timeout behavior.
Three tables decide which languages Aura speaks and only two were pinned: the backend's DEEPGRAM_TTS_VOICES (test_tts_language.py) and the renderer's hasVoice picker metadata. TTS_LANGUAGES was unpinned and is the decisive one - installQuestion reads it to set hasAudio, which decides whether the session enters Speaking at all. The damaging drift direction is silent: a language the backend voices going missing here means the session never requests audio, so it runs text-only while the setup screen still shows the "Voice" badge. The other direction recovers on its own, since a /speak answering 204 falls through speechFailed to the same text-only path.
Reviewed the three mock-interview screens against the project's own Web Interface Guidelines. Icon aria-hidden and button spinner motion were already fine (Lucide icons default to aria-hidden; spinners match the ~16 others across the app that don't honor prefers-reduced-motion, so leaving these alone keeps them consistent rather than a one-off deviation). session.tsx: - The level ring's rAF loop wrote `transform` every frame while the element also carried `transition-transform`, so the browser interpolated toward each new value instead of applying it - the ring visibly lagged the voice it was meant to track. Removed the transition; a per-frame write and a CSS transition on the same property don't mix. - The same loop now stops under prefers-reduced-motion. Unlike the button spinners, this one runs continuously for the entire Listening state (potentially minutes) and is JS-driven, not a bounded CSS animation - the case the guideline is actually for. - Question text and status (state changes, new questions) now sit in an aria-live="polite" region so a screen reader user is told when the interviewer's turn changes. The live answer transcript is deliberately left out - it updates on every ASR partial and would spam. - wrap-break-word on both question and answer text, matching the convention already used in live-suggestions-panel.tsx and safe-markdown.tsx. setup.tsx: - Seniority and Questions are Radix Select triggers, which are buttons rather than form controls that `htmlFor` can reach. Associated via id + aria-labelledby, the exact pattern already documented and used in llm-group.tsx. Difficulty's RadioGroup labelled the same way. - Role input gained a name and the correct autocomplete token (organization-title - it's a job title field, not a value to suppress autofill on). - Math.round((n * 2.5) / 1) simplified to Math.round(n * 2.5) - dead division by 1. report.tsx: - MockReport.strengths/gaps carry no min_length, unlike questions (min_length=1), so a report scoring an interview with nothing notable in one direction rendered a blank card with a header and no content. Both now show "Nothing specific noted." when empty.
HeadphoneNoticeDialog is shared between the live control panel and
the mock interview setup screen, but its copy described only the
live mechanism: interviewer audio captured over loopback, echoing
into the mic, and skipDueToRecentSelf silently suppressing the live
suggestion for the question just asked.
None of that applies to a mock session. mock-transcription.service.ts
never captures loopback at all, and mock-tts.service.ts's mic gate
mutes the candidate's track for the AI's question plus a reverb tail
instead. The real risk on speakers is narrower - the tail of the
question's echo landing at the start of the transcribed answer - not
a suppressed suggestion with no error, which cannot happen here since
there is no suggestion to suppress.
Added a variant prop ('live' default, 'mock' for the setup screen)
so each caller shows copy that matches what it's actually protecting
against. The live control-panel call site is untouched.
A mic that fails before the candidate says a single word - unplugged, permission revoked mid-session, a device error - left the session waiting in Listening forever. The silence backstop added earlier this branch only armed from ingestAnswer(), which requires at least one transcript event to have already arrived; zero speech meant it never armed at all. The only recovery was the candidate noticing and clicking "Skip question" by hand. Generalized the same mechanism instead of adding a second one: armSilenceTimer() now takes the delay explicitly. Entering Listening with nothing said (installQuestion for a text-only question, speechFinished/speechFailed for a voiced one) arms it with the new MOCK_LISTENING_SILENCE_MS (60s) - long enough that normal think-time before answering never trips it. Real speech re-arms it with the existing MOCK_ANSWER_SILENCE_MS (8s) instead, handing off from one delay to the other. The deadline's own decision is shared: if finalAnswerText holds anything by then, treat it as "Done answering" was pressed (answerFinished()); if it is still empty, there is nothing to submit, so treat it as "Skip question" instead (skipQuestion()) rather than recording an empty answer.
None of the setup, session, or report screens had a single real heading element - CardTitle renders a <div>, and it was the only title-like element on any of them. A screen reader's heading navigation, one of the most-used ways to move around a page, found zero landmarks anywhere in this feature, while a comparable routed page (payment/index.tsx) already uses a real <h1> for its title. setup.tsx: CardTitle -> a real <h1> carrying the same classes CardTitle would have applied, since CardTitle has no `asChild` to promote it through. session.tsx: no title existed at all - the screen is deliberately chrome-free while a question is live, so the landmark is sr-only rather than adding visible chrome that wasn't wanted. report.tsx: same gap, plus the score card's "82" and "Strong" read as two unrelated lines to a screen reader with nothing pairing them - visually obvious from size and position, meaningless once read aloud in sequence. Added an sr-only page h1 and an sr-only "Overall score" h2, and promoted Strengths/Gaps/Per-question breakdown from CardTitle to real h2 siblings under it - a complete h1 -> h2 tree, not an isolated landmark. No visual change anywhere: every replacement carries CardTitle's own classes directly.
"Audio input device \"X\" is not found" stated the problem and stopped
there. The live control panel shows the same fact as a tooltip on the
picker that fixes it, which is its own next step; this screen has no
device picker at all, so the toast was the only place a next step
could live, and it didn't have one.
Matches this same screen's existing convention for the language
setting ("Change the interview language from the main screen's
language picker.") rather than inventing new copy.
Setup -> Session -> Report is three components swapped in by
conditional rendering, not real navigation, so nothing ever told the
browser where focus should go when one replaced another. Whatever was
focused on the outgoing screen unmounts and focus silently reverts to
<body> - a keyboard or screen-reader user loses their place and has
to find their way back into the app from the very top on every
transition.
Each screen focuses its own heading (added in the previous commit) on
mount via tabIndex={-1} + a ref, the standard SPA route-change focus
pattern. SessionScreen does this once, on mount, not per question -
within-session updates already go through the aria-live region added
earlier, and refocusing on every question would fight the candidate's
own focus mid-interaction with a control.
Verified via tsc, eslint, vite build, and the full test:main suite;
not verified against a live authenticated Electron session, which
this change would need to exercise end-to-end and which isn't set up
in this environment. ref.current?.focus() in a mount effect is
standard, well-established React/DOM behavior, not app-specific
logic, so this is a smaller residual gap than the runtime bugs fixed
elsewhere on this branch, all of which were verified against the real
services.
…elds
strengths, gaps and justification are explicitly translated per the
report prompt's own language directive in mock_interview_service.py
("a justification... a strength, a gap - is written in the target
language"), but none of the three carried dir="auto" - unlike
entry.question and entry.answer on the same screen, and unlike
stronger_answer, which gets it for free through SafeMarkdown.
Same failure this app's own RTL work already documents for the live-
suggestion panels: without a direction hint, an Arabic or Hebrew
bullet point renders left-aligned in an LTR container instead of
right-aligned, and score justifications read the same way.
Verified live: rendered a report with real Arabic content in a real
browser and read the computed `direction` style off the DOM (not the
markup) for a strength item, a gap item and the justification text -
all resolve to rtl. Screenshot confirms correct right-alignment and
bullet placement throughout the accordion.
…ive suggestions Reworks the three mock-interview screens to share the live assistant's own visual language instead of standing deliberately apart from it - a panel-based transcript feed, a slim status line instead of a big centred card, and a compact 32px control bar reusing the live bar's own icon-button tokens (bar.ts). Adds the live-suggestion hint flagged as a follow-up in #120: what the live assistant would have suggested for each question, generated the moment the question is installed (the text is already final - no ASR to wait for) and shown beside the transcript panel through the existing LiveSuggestionsPanel component. On by default (mockLiveSuggestionsEnabled) - trying it out is one of the two reasons this feature exists, alongside practising the interview - with a toggle on the control bar for practising without a hint. Writes to the mock session's own liveHints, never to AppState.liveSuggestions, so a mock session still never touches the live panels or hasHistory. The live control bar's Start button becomes a split button (chevron dropdown) with "Start live assistant" and "Start mock interview". The latter opens a setup dialog - the same role/seniority/difficulty/question-count fields as the full-page setup screen, factored into a shared hook and fields component rather than duplicated - using the same account profile and job context the live assistant already reads, never a copy gathered here. On start it hands the setup to /mock-interview through router state rather than calling startSession itself, so only one useMockInterview() instance is ever mounted while the session starts; starting it here too would leave two instances racing to react to the same Speaking transition. The now-redundant titlebar menu entry is removed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The account's job context almost always already names the role being practiced for, so asking for it a second time on the setup form was redundant. role is now optional on MockInterviewSetup and simply omitted from the start request; the backend falls back to framing the interview from the job context instead (PowerInterviewAI/backend#60). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Follow-up commit: dropped the Role text input from the setup form (both the full-page screen and the new dialog). The account's job context almost always already names the role being practiced for, so asking for it a second time was redundant data entry - |
…gate dialogs Both overrode DialogFooter's default gap-2 with "gap-2 sm:gap-0" - a copy-paste leftover that zeroes the gap at the sm breakpoint and up, which the app window always exceeds, so the buttons sat flush against each other with no space between them. Dropped the override; the default flex-col-reverse gap-2 sm:flex-row sm:justify-end already does the right thing unmodified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The split Start button's primary half now launches whichever session - live or mock - was last actually started (lastSessionMode, persisted in RuntimeConfig), rather than always defaulting to the live assistant. Defaults to 'mock' for a candidate who has never started either. The dropdown's two items are unaffected and always name a specific mode explicitly; the remembered mode only decides what the primary half of the button does on its own. Persisted at the point each flow commits to starting (post-validation, post- headphone-notice) rather than on the dropdown click itself, so opening the setup dialog or the notice and then cancelling out does not silently change what Start does next time. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Follow-up commit: the control bar's Start button now remembers which session (live or mock) was last actually started and the primary half of the split button launches that one directly - the dropdown's two items still let you pick the other one explicitly. Defaults to mock for a candidate who has never started either. Persisted at the point each flow actually commits (post headphone-notice), not on the dropdown click itself, so a cancelled attempt doesn't silently change what Start does next time. |
Closes #119.
Summary
Client half of the built-in Mock Interview feature. Backend: PowerInterviewAI/backend#60
(TTS proxy, question/turn/report generation, its own rate-limit budget).
The AI plays the interviewer: generates a question from the candidate's CV/job context,
speaks it via the backend's Deepgram TTS proxy, listens over the existing ASR pipeline,
adaptively decides whether to follow up or move on, and produces a scored report.
The two problems this design exists to solve
mock-transcription.service.tsis a sibling toLiveTranscriptionService, not a mode flag added to it - it never callsgetDisplayMediaorenableLoopbackAudio, so the interviewer's own TTS voice cannever be captured as "interviewer audio" in the first place.
mock-tts.service.ts'sMicGatemutes the candidate's microphone track while TTSplays. A stranded mute is made impossible by four independent layers: a
finallyaround every play attempt, a watchdog (mirroring
ACTION_LOCK_MAX_HOLD_MS), ageneration token so a late release can't reopen a newer acquisition's gate, and a
state-driven belt that force-releases on every transition away from
Speaking.State machine
mock-interview.service.ts:Idle -> Starting -> Generating -> Speaking/Listening -> Evaluating -> Scoring -> Finished, gated by a generation-token (sessionSeq) the sameshape
AudioWsStream'sswitchSeqalready uses. Mirrors the terminal-state invariantuse-assistant-service.tsdocuments forRunningState- whatever fails, the statealways lands on
IdleorFinished, and no control is left permanently disabled.Guards
blocked as an emergent side effect of mock mode not setting
RunningState.Running,which is fragile; this adds the explicit check that survives a future refactor.
hasHistory || hasMockContent, andCtrl+Shift+Qroutedto end a mock session when one is running.
hiding, and a click-through window would strand the screen the user is looking at.
A product-wide change while in the area
The headphone notice no longer has "don't show again".
CLAUDE.md's own stated reasonfor it being opt-out was that whether the call is on speakers "can change between
sessions on the same install" - a permanent tick contradicted that directly. Shown
before every session now;
headphoneNoticeAcknowledgedis removed entirely.UI: now matched to the live control bar, not opposed to it
Earlier revisions of this PR deliberately gave the mock screens the opposite visual
contract from the live control bar - a centred column with generous whitespace. That
turned out to be the wrong call: the point of practising is to feel like the real
thing, so the session screen has been reworked onto the live assistant's own visual
language instead.
MockTranscriptPanelrenders every question-and-answer turn the wayTranscriptPanelrenders the live transcript - a scrollable, speaker-labelled feed,not a single card that replaces its own content on every question.
the two things a mock interview is for, alongside practising the interview itself,
so
generateLiveHint()inmock-interview.service.tsasks the same/api/llm/live-suggestionendpoint the live path uses for what it would havesuggested - fired the moment a question is installed, since the text is already
final and there is no ASR to wait for. Rendered through the existing
LiveSuggestionsPanelcomponent, unchanged, beside the transcript panel. On bydefault (
mockLiveSuggestionsEnabled), with a toggle on the control bar forpractising without a hint. Writes to the session's own
liveHints, never toAppState.liveSuggestions- a mock session still never touches the live panels orhasHistory.row instead of a big centred card, and the question controls (repeat/skip/done/end)
are a compact 32px icon-button bar reusing the live control bar's own tokens
(
bar.ts:BAR_ICON_BUTTON,BAR_GHOST,BAR_ACTIVE).button - a chevron opens "Start live assistant" / "Start mock interview". The latter
opens a setup dialog (the same role/seniority/difficulty/question-count fields the
full-page setup screen has, factored into a shared hook and fields component rather
than duplicated) using the same account profile and job context the live assistant
already reads - nothing here asks for a CV a second time. On start it hands the
setup to
/mock-interviewthrough router state rather than callingstartSessionitself, so only one
useMockInterview()instance is ever mounted while the sessionstarts; starting it from both places would leave two instances racing to react to
the same
Speakingtransition and double up the question's audio. The now-redundanttitlebar menu entry into the full-page flow is removed in favour of this.
form and a results page respectively, with no live-mode analogue to match.
Five shadcn primitives added in the existing style (
progress,label,radio-group,alert,scroll-area).Bugs caught while building this
save-history-dialog.tsx: after widening the close guard to fireon mock content too, the dialog still called
exportTranscriptunconditionally,which throws for a mock-only session. Fixed to dispatch by subject.
previously finalized piece rather than accumulating forward), and joined merged
Japanese sentences with an English-style space. Both fixed, with a language-aware
test that would have caught them.
generateNextQuestion's fail-forward-to-Scoringis correct mid-session (a skip or "next" already has progress to fall back on), but
at session start it discarded the failure with no explanation -
start()nowsurfaces it as an error the setup screen can show.
Test plan
pnpm lint- cleanpnpm build(tsc + vite) - cleanpnpm electron:build-main- cleanpnpm test:main- all checks pass, including 6 new test fileson speakers, confirm the interviewer's spoken question never appears in the
candidate's answer transcript - this is the single most important manual check
before this ships, per the plan's own verification section.
dialog/split-start button have not been exercised in a running app (no backend
login available here) - verified by
pnpm lint, bothtscconfigs, andpnpm test:mainonly. Worth a manual pass before merge.Known gaps (flagged rather than silently shipped)
/mock-interviewroute mid-session (navigating away via the titlebar)currently ends the session outright rather than raising a confirmation dialog first,
unlike the window-close guard, which is fully wired and covers Alt+F4/taskbar
close/Cmd+Q. A "confirm before navigating away" dialog reusing the save-history
dialog's subject-aware dispatch is a reasonable follow-up.