Skip to content

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

Open
alpha5611331 wants to merge 21 commits into
mainfrom
feat/mock-interview-client
Open

Add built-in mock interview feature (client)#120
alpha5611331 wants to merge 21 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).

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 6 new test files
  • 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.

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