Skip to content

Add built-in mock interview feature (client) - #120

Merged
alpha5611331 merged 35 commits into
mainfrom
feat/mock-interview-client
Sep 5, 2026
Merged

Add built-in mock interview feature (client)#120
alpha5611331 merged 35 commits into
mainfrom
feat/mock-interview-client

Conversation

@alpha5611331

@alpha5611331 alpha5611331 commented Aug 31, 2026

Copy link
Copy Markdown
Member

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

  1. Loopback must not be captured. mock-transcription.service.ts is a sibling to
    LiveTranscriptionService, not a mode flag added to it - it never calls
    getDisplayMedia or enableLoopbackAudio, so the interviewer's own TTS voice can
    never be captured as "interviewer audio" in the first place.
  2. The one remaining route - speakers into the mic - is closed by a transmit gate.
    mock-tts.service.ts's MicGate mutes the candidate's microphone track while TTS
    plays. A stranded mute is made impossible by four independent layers: a finally
    around every play attempt, a watchdog (mirroring ACTION_LOCK_MAX_HOLD_MS), a
    generation 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 same
shape AudioWsStream's switchSeq already uses. Mirrors the terminal-state invariant
use-assistant-service.ts documents for RunningState - whatever fails, the state
always lands on Idle or Finished, and no control is left permanently disabled.

Guards

  • Mutual exclusion with the live assistant (both want the mic and an ASR socket).
  • Action suggestions explicitly blocked during a mock session - they were already
    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.
  • The close guard widened to hasHistory || hasMockContent, and Ctrl+Shift+Q routed
    to end a mock session when one is running.
  • Stealth refuses during a mock session - it needs no always-on-top or screen-share
    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 reason
for 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; headphoneNoticeAcknowledged is 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.

  • MockTranscriptPanel renders every question-and-answer turn the way
    TranscriptPanel renders the live transcript - a scrollable, speaker-labelled feed,
    not a single card that replaces its own content on every question.
  • Optional live suggestions. Trying out the live assistant's suggestions is one of
    the two things a mock interview is for, alongside practising the interview itself,
    so generateLiveHint() in mock-interview.service.ts asks the same
    /api/llm/live-suggestion endpoint the live path uses for what it would have
    suggested - 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
    LiveSuggestionsPanel component, unchanged, beside the transcript panel. On by
    default (mockLiveSuggestionsEnabled), with a toggle on the control bar for
    practising without a hint. Writes to the session's own liveHints, never to
    AppState.liveSuggestions - a mock session still never touches the live panels or
    hasHistory.
  • The status line (question audio state, the "I'm ready" gate) is now a single slim
    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).
  • Reachable from the live control bar itself. The Start button is now a split
    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-interview through router state rather than calling startSession
    itself, so only one useMockInterview() instance is ever mounted while the session
    starts; starting it from both places would leave two instances racing to react to
    the same Speaking transition and double up the question's audio. The now-redundant
    titlebar menu entry into the full-page flow is removed in favour of this.
  • The full-page setup screen and report screen are otherwise unchanged - they are a
    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).

  • The Start button remembers which session you ran last, and its primary half
    launches that one directly rather than always the live assistant
    (lastSessionMode, persisted). It defaults to mock: a first-time user is far
    likelier 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.
  • The setup form no longer asks for a role. The account's job context already
    names it in almost every real case, so it was duplicate data entry.
    MockInterviewSetup.role is now optional on the wire and the backend frames the
    interview 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 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. The
field 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:

  • 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 reverb tail never applied. release() schedules MOCK_TTS_TAIL_MS before
    reopening the mic; 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 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.
  • 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 the reset.
  • 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 - what a dead microphone looks like from here - reset to
the setup form with no explanation; it now carries a reason, and session.error is
rendered there at all, having been written on two paths and read on none. Repeat question 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.

Each of these was in the pushed branch and none of them fails a type check, a lint or
any existing test:

  • The transcript showed every question twice. currentQuestion outlives the turn
    it belongs to - main folds the finished turn into answers and only replaces
    currentQuestion when 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: Speaking and Listening.
  • Every exported report was titled "Mock interview - undefined", once the role
    stopped being collected but the Markdown builder kept interpolating it - in the one
    artifact of this feature that leaves the machine.
  • "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 waited forever for a start that had
    already happened.
  • 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 transcript panel re-armed a smooth scroll on every streamed hint token. The
    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.
  • The setup dialog stacked a second modal over itself (the headphone notice) 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.
  • Smaller: Stopping had neither spinner nor status line; an answered-but-empty turn
    rendered 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" went
    unanswered; and both the headphone and permission-gate dialogs overrode
    DialogFooter's gap with sm:gap-0, leaving their buttons flush against each other
    at every window size this app runs at.

Bugs caught while building this

  • A real bug in save-history-dialog.tsx: after widening the close guard to fire
    on mock content too, the dialog still called exportTranscript unconditionally,
    which throws for a mock-only session. Fixed to dispatch by subject.
  • A chunking bug: the sentence-merge logic checked the wrong neighbour (the
    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.
  • A silent first-question failure: generateNextQuestion's fail-forward-to-Scoring
    is 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() now
    surfaces it as an error the setup screen can show.

Test plan

  • pnpm lint - clean
  • pnpm build (tsc + vite) - clean
  • pnpm electron:build-main - clean
  • pnpm test:main - all checks pass, including 8 new test files. Two were
    added for this rework: mock-live-hint.test.mjs drives the hint through a fake
    global fetch and pins the request shape (ends on the interviewer turn carrying the
    question, turn_verdict: answer, prior turns included), that a superseded hint
    lands 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.mjs pins the duplicate-turn guard source-level, since
    it is a state test that compiles and reads fine when removed
  • Not run in this environment (no audio hardware / real Deepgram key available):
    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.
  • Not run in this environment: the reworked session screen and the new setup
    dialog/split-start button have not been exercised in a running app (no backend
    login available here) - verified by pnpm lint, both tsc configs, and
    pnpm test:main only. Worth a manual pass before merge.

Known gaps (flagged rather than silently shipped)

  • Leaving the /mock-interview route 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.

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.
@gitar-bot

gitar-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

- 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.

@anton-karlovskiy anton-karlovskiy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kevinkamto kevinkamto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@chmm195 chmm195 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

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.
alpha5611331 and others added 2 commits August 31, 2026 14:22
…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>
@alpha5611331

Copy link
Copy Markdown
Member Author

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 - role is now optional and simply omitted from the start request. Backend falls back to framing the interview from the job context instead when it's absent: PowerInterviewAI/backend#60.

alpha5611331 and others added 2 commits August 31, 2026 16:46
…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>
@alpha5611331

Copy link
Copy Markdown
Member Author

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.

alpha5611331 and others added 5 commits August 31, 2026 23:49
- 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>

@kevinkamto kevinkamto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

alpha5611331 and others added 7 commits September 1, 2026 09:29
…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>

@chmm195 chmm195 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@anton-karlovskiy anton-karlovskiy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@chmm195 chmm195 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kevinkamto kevinkamto left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

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.

@anton-karlovskiy anton-karlovskiy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

…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.
@alpha5611331
alpha5611331 merged commit 0894aec into main Sep 5, 2026
1 check passed
@alpha5611331
alpha5611331 deleted the feat/mock-interview-client branch September 5, 2026 02:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add built-in mock interview feature (client)

5 participants