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. |
- The export titled every report "Mock interview - undefined". The setup form stopped collecting a role, but the Markdown builder still interpolated it unconditionally, in the one artifact of this feature that leaves the machine. Now conditional, with the absent and blank cases pinned by tests. - "Practise again" hung on a permanent loading screen. The handed-off setup stays in the router state for the life of the route and the request flag is one-shot, so deriving "a start is in flight" from those two left the render waiting forever for a start that had already happened. Tracked as its own state now, cleared when the session actually leaves Idle (not when the IPC call resolves, which can land a frame ahead of the state broadcast). - lastSessionMode was persisted before the two points that still abort a live start - the save-history prompt and the macOS permission gate - so cancelling either silently changed what Start does next time. Moved into doStart, the one funnel both routes into a live session pass through. - Turning live suggestions off mid-session left the in-flight hint streaming and writing into the session state for a panel no longer on screen. The supersede now happens before the toggle is read. - The transcript panel rebuilt its turns and re-armed a smooth scroll on every broadcast, which during a question is every streamed hint token. Keyed on values now: the whole state is structure-cloned across IPC, so every array and object in it arrives with a fresh identity regardless of what changed. It also gained the live dock's own auto-scroll control and preference, so re-reading an earlier answer mid-answer is possible at all. - The setup dialog stacked a second modal (the headphone notice) over itself and unmounted both in the same commit as a route change - the shape that already left this app unclickable once with menus. Handed off instead; the form state survives because only `open` is computed. Also: "Ending the interview…" for the Stopping state, which had neither spinner nor line, and the mock control bar now matches the live bar's layout exactly (primary action first, zoom held right by ml-auto). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The most intricate thing the UI rework added had no coverage. Driven through a fake global fetch like mock-interview-state.test.mjs, since the hint is a real streaming request built inside the service and what has to hold is the shape that goes out and the state the card lands in. Four failures it pins, each silent in production: a request that does not end on the interviewer turn carrying the question (the backend answers the wrong turn), one that drops the prior turns (every hint answers as if it were the first question), a superseded hint left `loading` forever as a spinner on a question the candidate has moved past, and a toggle that merely discards the result instead of not making the request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`currentQuestion` outlives the turn it belongs to. Main folds a finished turn into `answers` and only replaces `currentQuestion` when the next question is installed, so through Evaluating, Generating, Scoring and Stopping - every gap between questions - the same question sat in both places and the panel rendered both. The answer duplicated with it during Evaluating, which is reached on every single turn. The question is live only while the candidate can still act on it, which is Speaking and Listening; everywhere else it is already in `answers`. Two smaller things in the same feed. The in-progress answer row waited on the state rather than on text, so entering Listening printed the candidate's name above an empty line for as long as they were thinking. And an answered-but-empty turn - "Done answering" pressed with nothing transcribed, or the silence backstop firing on a dead microphone - rendered as that same blank line, which reads as a rendering fault rather than as what happened; it says "No answer" now, next to the "Skipped" that was already there. Pinned source-level, like the other renderer checks here: the guard is a state test that compiles and reads fine without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e does showExportSuccessToast exists, in its own words, so that "where did that go" does not depend on which export the user reached for - a save dialog's path is gone the moment it closes. The report screen was the one export of the three not using it, answering with a plain toast that names no file and offers no way to open it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which session the control bar's primary Start button launches without going through its dropdown is a product decision, not a convenience default: a first-time user is likelier to be trying the app out than walking into a real call. A later edit could flip it with no symptom other than Start quietly doing the other thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ckend The live failure first. Dropping `role` from the request broke the rule this codebase already follows for `language` and `SuggestionMode` - a client ahead of its backend degrades one session, it does not break the feature. A deployment predating the optional-role change still declares the field required, so an absent one is a 422 on every question: "Failed to generate the first question", every time, which is what the running dev server answers today. The field is now always sent, empty, which that backend accepts and the new one treats as absent; it is required in the client's own types so it cannot be quietly dropped again. Four defects in the TTS path, none of which fail a type check or a test: - **Every follow-up spoke the previous question's audio.** The chunk cache is keyed by index and `resetCache()` was skipped for follow-ups - which replace `currentQuestion` with different text under the same indices. The screen showed the follow-up while the interviewer read out the question before it. - **The reverb tail never applied.** `release()` schedules MOCK_TTS_TAIL_MS before reopening the mic, covering room reverb and Deepgram's lookahead; the state-driven belt then called `stop()` on the *normal* Speaking -> Listening transition, and its force-release cleared that timer. Every question, leaving exactly the failure the mock headphone notice warns about unmitigated. That transition is the one main only reaches by playback reporting completion, so the belt now skips it - as its own docstring always said it should. - **The lookahead could poison the next question's cache.** Nothing awaits it and it carried no generation, so a fetch begun for the old question landed after `resetCache()` and refilled index n with the previous question's audio. - **`stop()` left `playBlob`'s promise unsettled** - `pause()` fires neither `ended` nor `error` - leaking an object URL and a parked async frame per stop. And two dead ends that said nothing. A session the silence backstop skipped to the end of, with nothing ever transcribed, reset to the setup form with no explanation - the shape a dead microphone takes. It now carries a reason, and `session.error` is rendered there at all: main had been writing it on both this path and a failed first question, and nothing ever read it. `Repeat question` also never told main, so an answer-silence deadline armed before it kept counting while the replay gated the microphone, and could submit a half-finished answer mid-question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Repeat question` is offered while the question is still being read - `canControl` covers `Speaking` - and a second `playQuestion` only bumped `playSeq`. That stops the older *loop* at its next check, which is after the chunk it is already playing has finished, so both elements sounded at once and the first, no longer `this.audio`, was unreachable by any later `stop()`. Worse in combination with the `Listening` carve-out added alongside it: if the replay then failed, `speechFailed` moved main to `Listening`, the belt skipped `stop()`, and the orphan played on while the gate reopened - the interviewer's own voice into the candidate's transcript, which is the one failure this whole path exists to prevent. `playQuestion` now supersedes the element itself, through a teardown split out of `stop()` rather than `stop()` itself: the two callers want opposite things from the microphone, and the gate has to stay shut across a handover between two questions of the interviewer's speech. Pinned source-level, and the pins are mutation-checked: removing the teardown, routing it through `stop()`, restoring the follow-up cache-reset skip, or dropping the lookahead's generation guard each fail a named check. Also corrects the file header, which still described the belt as running on every transition off `Speaking` - the one place left denying the exemption the effect below it exists to document. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…equest The backend asks the model to mark the last question as `closing` and could not say which one that was - `history` counts turns, so it runs ahead wherever a follow-up was asked. Without it a session never ended on a closing question, it stopped when this side's count ran out. Optional on the wire, so a backend that predates it ignores the field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t reach - **An update install destroyed a mock session without asking.** The close guard was widened to `hasHistory || hasMockContent`, but installing an update goes *past* that guard by design (`allowNextClose()`), and the prompt in front of it still checked `hasHistory` alone - so a mock interview with no live session behind it went to the installer unasked. - **A failed mock start held the microphone for the life of the app.** `mockTranscriptionService.start()` acquires `getUserMedia` before opening the ASR socket, and a throw from the socket escaped with the stream still live - before `useMockInterview` had armed the teardown that would have stopped it. Every retry took another device handle. The service releases what it acquired now, which is the layer that knows it has one. - **A silent audio element stopped the session, not just the microphone.** The gate's watchdog covers a `HTMLAudioElement` that fires neither `ended` nor `error` - that is what it is for - but only the microphone. The promise never settled, so `speechFinished` was never sent, main stayed in `Speaking`, and `ingestAnswer` discarded every word the candidate said because the state was wrong. `Speaking` is also the one state main arms no silence backstop in, so nothing recovered it. Playback is bounded now and falls through `speechFailed`. - **"End interview" was disabled exactly when it was needed.** It was gated on any action being in flight, and `withBusy` holds that for the whole main-side transition - an evaluate plus a generate, each with a retry and a timeout, or a full replay. The one way out of a session that has stopped responding was unavailable while it looked frozen. Main's `sessionSeq` is what makes ending mid-transition safe. - **A slow chunk was synthesized twice, and it cascaded.** A request became visible to the next caller only once it had resolved into the cache, so the play loop asked for the chunk the lookahead was already fetching - a second billed synthesis of the same sentence, whose own lookahead did it again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The playback timeout added to stop a silent element hanging the session had two gaps of its own: - **It abandoned the element without stopping it.** `cleanup()` dropped the last reference and rejected, but never paused, so a stalled element that later un-stalled would read the question out over a reopened microphone and the next question's audio, reachable by neither `stop()` nor `abortPlayback()`. Every exit stops the element now, not just the ones that pause on the way in. - **A late event from an old element wiped the current playback's settler.** `cleanup()` guarded `this.audio` by identity but nulled `settleStoppedPlayback` unconditionally, so a stray `ended` left the next `stop()` unable to settle the playback it interrupted: parked promise, gate `finally` never run, and the control bar's `busy` never released. Guarded by identity too. And three older ones: - **A spoken turn could be recorded as skipped.** `ingestAnswer` armed the short 8s deadline on *partials*, while the deadline decides submit-vs-skip on `finalAnswerText` alone - so a partial that never finalised (dropped socket, discarded utterance) demoted the 60s think-time backstop to 8s and then took the skip branch, throwing away the words already on screen. Partials re-arm the long backstop instead: evidence of talking is a reason to keep waiting. - **Ending during scoring billed a second report.** End is deliberately reachable while an action is in flight, so this was reachable, and it discarded the report already running only to start another for the same session. It abandons that report now and lands on Finished with the reason, which is what `reportError` already means everywhere else - the answers stay on screen and exportable. - **Repeat was offered for a question with no audio.** `speechFailed()` clears `hasAudio` and keeps `chunks`, so after a synthesis failure Repeat took the microphone for the length of another failing attempt while the status line still said the candidate was being heard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng sessions
The failure a candidate actually meets was the one path in this service that
recorded nothing: `generateNextQuestion` caught with a bare `catch {}`, so a
question that could not be generated - a rejected request, an expired session, a
provider error - reached the screen as "please try again" with nothing anywhere
saying what to try differently, and nothing in the log to read afterwards. Both
attempts now log, and the reason is carried into the error the setup screen
shows. `ApiRequestError.message` alone is just the status text, so the status and
a bounded slice of the body come with it - the body is what names the field a
validation error is about.
Three ways a mock session could be destroyed that a live one could not:
- **"Clear" did not clear it.** `clearAll()` emptied the transcript and both
suggestion services and left the mock session, so `hasMockContent` stayed true
after the user cleared: the close guard kept asking about a session that was
gone, and the save dialog - which picks the mock export whenever mock content
is the only content - would have written a report for it.
- **"End interview" threw away the answer in progress.** `finalAnswerText` is
only folded into `answers` by `answerFinished`, so ending part-way through the
first answer left `hasRealAnswers()` false and reset the whole session: no
report, nothing to export, and the setup form back with no explanation. The
silence backstop already treats that same text as "Done answering".
- **The report screen's own two exits asked nothing.** "Practise again" and
"Done" discard a finished report outright, while the identical content is
guarded on window close and in front of an update install - so whether it was
protected depended on which way the user happened to leave. `confirmDiscard`
now covers both subjects, which is also what makes it correct for Clear and
Start now that those empty the mock session too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…crollbar Setup only ever happens through the control bar's dialog now - the full-page form was reachable only as a stale fallback nobody chose to reach. Removed, and the Idle branch that used to render it redirects to /main instead, which is where Start (and the dialog) actually live. "Practise again" reaches that path most often, since clearing a finished session is the whole of what it does - it now navigates there directly, and hands a flag through router state that ControlPanel reads once to reopen the dialog itself, so restarting still costs one click rather than two. That redirect could itself misfire on the path that matters most - the very handoff it exists to replace. `<Navigate>` fires its own mount effect, and React runs a child's effects before its parent's later ones, so a first render that reached Idle - true on *every* fresh navigation with a setup pending, since `session` has not caught up to the broadcast yet - would send the candidate straight back to /main before this page's own effect ever started the session. `autoStarting`'s initial state now seeds from `pendingSetup` synchronously rather than being set true only by that later effect, which is what keeps that first render on the loading screen instead. Separately: the transcript and live-hint panels never showed a scrollbar during a session. Two divs on the route set neither `overflow` nor `min-h-0`, and a flex item's default min-height is its content's - only an element whose own `overflow` is not `visible` gets that floor reset to zero automatically. The panels' `overflow-y-auto` had nothing to overflow against; their ancestor had already grown to fit them, and the excess was absorbed by MainFrame's own outer container instead (`overflow-auto hide-scrollbar`) - so the whole page scrolled with no visible scrollbar, and neither panel ever got one of its own. The live control bar sidesteps this differently, by measuring pixel heights in JS; this route has no draggable dock to justify that, so it leans on the CSS chain being complete instead. Both pinned source-level - a run-order property and a missing-class regression, neither of which a type checker or a lint can see. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves three textual conflicts from the BYOK removal (main/index.ts, electron-api.d.ts, config-store.test.mjs) - in each case kept this branch's mock-interview additions and dropped the removed LLM-config imports/declarations, and in the test file kept both sides' new checks (lastSessionMode from this branch, the llmConf disk-scrub tests from main) since they don't overlap. Also fixes silent breaks the merge didn't flag as conflicts because only this branch had touched the files: - mock-interview.service.ts sent `config: conf.llmConf` on all four of its request bodies (question/turn/report/live-suggestion) - that field no longer exists on RuntimeConfig or on the request types after the BYOK removal. Dropped it, and removed the three `configStore.getConfig()` calls that existed only to supply it (confirmed dead via eslint's no-unused-vars, not by inspection alone). - mock-interview-setup-fields.tsx had a comment pointing at llm-group.tsx as the reference example for the id+aria-labelledby pattern; that file is deleted, repointed at audio-group.tsx, which uses the identical pattern. Verified after resolution: full lint, tsc (renderer + electron), production build, electron:build-main, and test:main all clean.
…ew feature Two real data-integrity bugs, both from `finalAnswerText` staying populated past the point it was consumed: - answerFinished() folds the answer into `answers`, then awaits evaluateTurn - but never cleared `finalAnswerText`, so a candidate clicking "End interview" during that round trip hit endSession()'s "resurrect a pending answer" branch, which saw the same text and `currentQuestion` still there and pushed the identical answer a second time (double-counted, double-scored in the report). - skipQuestion() had the same gap: a 'final' ASR segment spoken but never submitted before Skip survived into the next question's Generating window, where the same End-interview race resurrected it as a phantom answer for the question that was just skipped. Both now clear `finalAnswerText` synchronously, before their first await. Regression tests added in mock-interview-state.test.mjs, reproducing each race by calling the two functions unawaited and racing endSession() against them. Other findings fixed: - Mock report export used the live app language instead of the language frozen at session start, so a finished session whose language was changed before export produced a report with headings in one language wrapped around content in another. Added MockInterviewService.getLanguage() and pointed exportMockReport at it. - A no-audio question armed its 60s silence backstop the instant it was installed, before the candidate had read it or clicked "I'm ready" - a long question could auto-skip before they'd even started. Added answerReady() (IPC-wired) to arm it when they actually confirm readiness instead, mirroring speechFinished/speechFailed's timing for a voiced question. - Starting a mock session never checked whether the live assistant was running - only the reverse direction was guarded. Both directions of the mutual exclusion are enforced now. - headphoneNoticeAcknowledged was removed from RuntimeConfig with no disk scrub, unlike llmConf's; generalized the llmConf scrub into a shared scrubRetiredKey() and applied it to both. CLAUDE.md's description of the retired opt-out mechanism updated to match the dialog's actual (always-shown, no acknowledgment) behavior. - The control bar's manual "Export Interview" button never read hasMockContent, so a finished mock session viewed from `/main` without clearing it first found "nothing to export" over a real, unsaved report. Now dispatches by subject the same way save-history-dialog.tsx already does. - use-mic-level's FFT analysis loop ran continuously for the whole session; now only wired to a real stream during Listening, the only state anything reads it for. - withMockContent rescanned and trimmed every answer on every broadcast, including the ~20/second ones from ASR partials during ingestAnswer - added a reference-equality fast path, since `answers` is only ever reassigned when a turn actually completes. - Three call sites hand-rolled the exact "is a session active" check isMockInterviewSessionActive() exists to centralize; all three (plus the service's own isActive()) now call it instead. - speechFinished/speechFailed's shared tail extracted into enterListeningAfterSpeech(). - MicGate's watchdog docstring corrected (it doesn't cover a dead audio element - playBlob's own timeout already does; it covers a hung synthesizeChunk IPC call before any audio element exists). - Two stale comments fixed: mock-interview-setup-dialog.tsx referenced a full-page setup screen removed earlier in this branch, and a progress-bar clamp expression rewritten for readability. Verified: full lint, tsc (renderer + electron), production build, electron:build-main, and test:main all clean.
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).launches that one directly rather than always the live assistant
(
lastSessionMode, persisted). It defaults to mock: a first-time user is farlikelier to be trying the app out than walking into a real call. The dropdown is
what names a specific mode, and the remembered mode is written at the point each
flow actually commits to starting - not on the dropdown click - so backing out of
the save-history prompt, the headphone notice or the macOS permission gate does not
quietly change what Start does next time.
names it in almost every real case, so it was duplicate data entry.
MockInterviewSetup.roleis now optional on the wire and the backend frames theinterview from that context when it is absent (PowerInterviewAI/backend#60).
Defects found and fixed while reviewing this
The one that reached a running app first: dropping
rolefrom the request broke therule this codebase already follows for
languageandSuggestionMode- a client aheadof its backend degrades one session, it does not break the feature. A deployment
predating the optional-
rolechange still declares the field required, so an absent oneis a 422 on every question:
Failed to generate the first question, every time. Thefield is now always sent, empty, which that backend accepts and the new one treats as
absent, and it is required in the client's own types so it cannot be dropped again.
Four more in the TTS path, which had no test coverage of any kind:
index and
resetCache()was skipped for follow-ups, which replacecurrentQuestionwith different text under the same indices.
release()schedulesMOCK_TTS_TAIL_MSbeforereopening the mic; the state-driven belt then called
stop()on the normalSpeaking -> Listening transition and its force-release cleared that timer - every
question, leaving exactly the failure the mock headphone notice warns about with
nothing mitigating it. That transition is the one main only reaches by playback
reporting its own completion, so the belt now skips it, as its docstring always said.
carried no generation, so a fetch begun for the old question landed after the reset.
stop()leftplayBlob's promise unsettled -pause()fires neitherendednorerror- leaking an object URL and a parked async frame per stop.And two dead ends that said nothing. A session the silence backstop skipped to the end of
with nothing ever transcribed - what a dead microphone looks like from here - reset to
the setup form with no explanation; it now carries a reason, and
session.errorisrendered there at all, having been written on two paths and read on none.
Repeat questionnever told main, so an answer-silence deadline armed before it kept countingwhile the replay gated the microphone and could submit a half-finished answer.
Each of these was in the pushed branch and none of them fails a type check, a lint or
any existing test:
currentQuestionoutlives the turnit belongs to - main folds the finished turn into
answersand only replacescurrentQuestionwhen the next question is installed - so through Evaluating,Generating, Scoring and Stopping the same question sat in both places and the panel
rendered both. The answer duplicated with it during Evaluating, which is reached on
every single turn. The question is live only while the candidate can still act on
it:
SpeakingandListening.stopped being collected but the Markdown builder kept interpolating it - in the one
artifact of this feature that leaves the machine.
in the router state for the life of the route and the request flag is one-shot, so
deriving "a start is in flight" from those two waited forever for a start that had
already happened.
writing into the session state, for a panel no longer on screen.
whole app state is structure-cloned across IPC, so every array and object in it
arrives with a fresh identity however little changed, and memoising on any of them
is a no-op. It also gained the live dock's own auto-scroll control and preference,
without which every ASR partial pulled the panel back to the bottom.
unmounted both in the same commit as a route change - the shape that already left
this app unclickable once with menus. Handed off instead; the form state survives.
Stoppinghad neither spinner nor status line; an answered-but-empty turnrendered as a blank row rather than "No answer"; the report export was the one
export of three not using
showExportSuccessToast, so "where did that go" wentunanswered; and both the headphone and permission-gate dialogs overrode
DialogFooter's gap withsm:gap-0, leaving their buttons flush against each otherat every window size this app runs at.
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 8 new test files. Two wereadded for this rework:
mock-live-hint.test.mjsdrives the hint through a fakeglobal fetch and pins the request shape (ends on the interviewer turn carrying the
question,
turn_verdict: answer, prior turns included), that a superseded hintlands on a terminal state rather than spinning forever, and that the toggle stops
the request being made at all rather than discarding its result;
mock-transcript-turns.test.mjspins the duplicate-turn guard source-level, sinceit is a state test that compiles and reads fine when removed
on 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.