Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
27a43eb
feat(headphones): remove "don't show again" from the headphone notice
alpha5611331 Aug 31, 2026
e70be98
refactor(audio): export AudioWsStream and lift resolveMicDeviceId
alpha5611331 Aug 31, 2026
72b8dbd
feat(mock-interview): add the state machine, API client, and IPC surface
alpha5611331 Aug 31, 2026
72af1e7
feat(mock-interview): wire guards - close prompt, mutual exclusion, a…
alpha5611331 Aug 31, 2026
c8085bd
feat(mock-interview): add mic-only capture, the transmit gate, and pl…
alpha5611331 Aug 31, 2026
27db8ba
feat(mock-interview): add setup/session/report screens and routing
alpha5611331 Aug 31, 2026
c5c8902
feat(mock-interview): add report export and fix the close-guard dispa…
alpha5611331 Aug 31, 2026
7c62e4f
test(mock-interview): cover the state machine, gate, isolation, and e…
alpha5611331 Aug 31, 2026
2b25117
fix(mock-interview): end stale sessions and arm the silence backstop
alpha5611331 Aug 31, 2026
55bd295
test(language): pin TTS_LANGUAGES against the voiced set
alpha5611331 Aug 31, 2026
c80cfdd
fix(mock-interview): UI/UX pass - a11y labels, motion, empty states
alpha5611331 Aug 31, 2026
b73887a
fix(mock-interview): correct the headphone notice for the mock flow
alpha5611331 Aug 31, 2026
3dbc299
fix(mock-interview): recover a dead microphone with no speech at all
alpha5611331 Aug 31, 2026
2694289
fix(mock-interview): give all three screens a heading structure
alpha5611331 Aug 31, 2026
3c792a2
fix(mock-interview): tell the user where to fix a missing audio device
alpha5611331 Aug 31, 2026
60dd9e0
fix(mock-interview): move focus to each screen on mount
alpha5611331 Aug 31, 2026
d7f0fad
fix(mock-interview): fix RTL rendering for the report's translated fi…
alpha5611331 Aug 31, 2026
1506749
feat(mock-interview): match the live control bar's UI, add optional l…
alpha5611331 Aug 31, 2026
23a93f7
feat(mock-interview): drop the Role field from the setup form
alpha5611331 Aug 31, 2026
3ebef30
fix(dialogs): restore button spacing on the headphone and permission-…
alpha5611331 Aug 31, 2026
94132cb
feat(control-panel): remember the last session mode, start it directly
alpha5611331 Aug 31, 2026
89baa3b
fix(mock-interview): six defects found reviewing the UI rework
alpha5611331 Sep 1, 2026
c11ea38
test(mock-interview): cover the live-hint lifecycle
alpha5611331 Sep 1, 2026
39d60ac
fix(mock-interview): stop the transcript showing each question twice
alpha5611331 Sep 1, 2026
fbb9e34
fix(mock-interview): confirm the report export the way every other on…
alpha5611331 Sep 1, 2026
bc99adf
test(config): pin that the default session mode is mock
alpha5611331 Sep 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-progress": "^1.1.16",
"@radix-ui/react-radio-group": "^1.4.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
Expand Down
338 changes: 338 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions src/main/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,61 @@ export class ApiClient {
return this.requestStream('POST', url, body, signal);
}

/**
* POST a JSON body and read back binary bytes rather than JSON.
*
* `request<T>` always calls `response.json()`, which a binary body cannot satisfy - this is
* the mock interview's `/speak` proxy, which answers audio bytes or a bare `204 No Content`.
* That 204 is a normal result (the language has no Aura voice), not an error, so it resolves
* to `null` rather than throwing - the caller falls back to a text-only question either way.
*/
async postArrayBuffer(
path: string,
body?: unknown,
timeoutMs?: number
): Promise<ArrayBuffer | null> {
const url = this.buildUrl(path);
try {
const sessionToken = configStore.getConfig().sessionToken;
if (sessionToken) {
this.setAuthToken(sessionToken);
}

const response = await fetch(url, {
method: 'POST',
headers: this.headers,
body: body ? JSON.stringify(body) : undefined,
signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined,
});

if (response.status === 204) return null;

if (!response.ok) {
const responseContent = await response.text().catch(() => '');
throw new ApiRequestError(
response.statusText || 'HTTP request failed',
response.status,
responseContent
);
}

return await response.arrayBuffer();
} catch (error: unknown) {
if (error instanceof ApiRequestError) throw error;

const timedOut = error instanceof Error && error.name === 'TimeoutError';
throw new ApiRequestError(
timedOut
? 'The request timed out'
: error instanceof Error
? error.message
: 'Network request failed',
0,
null
);
}
}

async put<T>(path: string, body?: unknown): Promise<ApiResponse<T>> {
const url = this.buildUrl(path);
return this.request<T>('PUT', url, body);
Expand Down
46 changes: 46 additions & 0 deletions src/main/api/mock-interview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Mock Interview API
* Handles calls to the backend's /api/mock-interview/* endpoints
*/

import {
EvaluateMockTurnRequest,
GenerateMockQuestionRequest,
GenerateMockReportRequest,
MockQuestion,
MockReport,
MockTurnDecision,
SpeakRequest,
} from '../types/mock-interview.js';
import { ApiClient, ApiResponse } from './client.js';

// Time to first byte. Generous relative to a live suggestion because this is a single
// non-streaming JSON reply rather than the first chunk of a stream.
const MOCK_QUESTION_TIMEOUT_MS = 30_000;
const MOCK_TURN_TIMEOUT_MS = 15_000;
const MOCK_REPORT_TIMEOUT_MS = 60_000;
const MOCK_SPEAK_TIMEOUT_MS = 20_000;

export class MockInterviewApi extends ApiClient {
async generateQuestion(data: GenerateMockQuestionRequest): Promise<ApiResponse<MockQuestion>> {
return this.post<MockQuestion>('/api/mock-interview/question', data, MOCK_QUESTION_TIMEOUT_MS);
}

async evaluateTurn(data: EvaluateMockTurnRequest): Promise<ApiResponse<MockTurnDecision>> {
return this.post<MockTurnDecision>('/api/mock-interview/turn', data, MOCK_TURN_TIMEOUT_MS);
}

async generateReport(data: GenerateMockReportRequest): Promise<ApiResponse<MockReport>> {
return this.post<MockReport>('/api/mock-interview/report', data, MOCK_REPORT_TIMEOUT_MS);
}

/**
* Synthesize speech for one question chunk.
*
* Resolves to `null` for a language with no Aura voice (a `204`, not an error) - the caller
* falls back to text-only for that question rather than treating it as a failure.
*/
async speak(data: SpeakRequest): Promise<ArrayBuffer | null> {
return this.postArrayBuffer('/api/mock-interview/speak', data, MOCK_SPEAK_TIMEOUT_MS);
}
}
38 changes: 38 additions & 0 deletions src/main/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,41 @@ export const OPACITY_DEFAULT = OPACITY_LEVELS[OPACITY_LEVELS.length - 2]; // 0.7
export const ZOOM_STEP = 0.1; // factor increment/decrement
export const ZOOM_MIN_FACTOR = 0.5;
export const ZOOM_MAX_FACTOR = 3.0;

// Mock interview constants
//
// How long the microphone stays gated after the interviewer's audio finishes playing, to cover
// room reverb and Deepgram's own lookahead. The gate itself is released the moment playback ends
// (or fails) - this is the tail added on top, not the gate's own duration.
export const MOCK_TTS_TAIL_MS = 600;

// Backstop only, the same role ACTION_LOCK_MAX_HOLD_MS plays for the action-suggestion lock. The
// gate is released explicitly on every path (ended, error, skip, end-interview); this exists so
// an `HTMLAudioElement` that never fires either event cannot leave the mic muted for the rest of
// the session. Well above the longest real question audio.
export const MOCK_TTS_GATE_MAX_HOLD_MS = 120_000;

// Sentence-chunk bounds for the question text sent to /speak, one chunk at a time so the first
// chunk starts playing before the whole question has synthesized. Minimum guards against a chunk
// like "Mr." registering as a full sentence; maximum guards a run-on sentence with no punctuation.
export const MOCK_SPEECH_CHUNK_MIN_CHARS = 40;
export const MOCK_SPEECH_CHUNK_MAX_CHARS = 240;

// A candidate who has already been probed on the same question twice should move to the next one
// regardless of how the answer reads - a third follow-up is a stalled interview, not a thorough
// one. Enforced client-side as a hard cap on top of the backend's own "next" bias.
export const MOCK_MAX_FOLLOW_UPS_PER_QUESTION = 2;

// Silence backstop for "Done answering" - a candidate who stops talking for this long without
// pressing the button is treated as finished. The button is the real mechanism; this only covers
// someone who forgets it exists. Deliberately generous: a pause to think must not auto-submit.
export const MOCK_ANSWER_SILENCE_MS = 8_000;

// Backstop for a Listening state that never receives a single word - a dead microphone (unplugged,
// permission revoked mid-session, a device error), not a candidate who is merely thinking. Far more
// generous than MOCK_ANSWER_SILENCE_MS because normal think-time before answering starts must never
// trigger it: this only fires when nothing at all - not one partial transcript - arrives while
// Listening. Without it, a mic that dies before the candidate says anything left the session
// waiting forever with no automatic recovery, recoverable only by the candidate noticing and
// clicking "Skip question" by hand.
export const MOCK_LISTENING_SILENCE_MS = 60_000;
13 changes: 12 additions & 1 deletion src/main/hotkeys.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { BrowserWindow, globalShortcut } from 'electron';

import { ZOOM_STEP } from './consts.js';
import { appStateService } from './services/app-state.service.js';
import { mockInterviewService } from './services/mock-interview.service.js';
import { actionSuggestionService } from './services/suggestion-action.service.js';
import {
moveWindowByArrow,
Expand All @@ -11,6 +13,7 @@ import {
WindowPosition,
} from './services/window-control.service.js';
import * as zoomService from './services/zoom.service.js';
import { isMockInterviewSessionActive } from './types/mock-interview.js';

const isMac = process.platform === 'darwin';

Expand Down Expand Up @@ -38,8 +41,16 @@ export function registerGlobalHotkeys(): void {
// Unregister existing hotkeys first
globalShortcut.unregisterAll();

// Stop assistant
// Stop assistant - or end the mock session, if one is running. There is deliberately no
// *start* hotkey for either, so this is the only routing decision this shortcut needs: without
// it, pressing it during a mock interview would run the live stop path against a session that
// was never started (close to a no-op, but it still walks `runningState` through `Stopping`)
// while doing nothing about the session actually on screen.
registerShortcut(`${BASE}+Q`, () => {
if (isMockInterviewSessionActive(appStateService.getState().mockInterview)) {
void mockInterviewService.endSession();
return;
}
const w = BrowserWindow.getAllWindows()[0];
if (w && !w.isDestroyed()) {
w.webContents.send('hotkey:stop-assistant');
Expand Down
2 changes: 2 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { registerAutoUpdaterHandlers } from './ipc/auto-updater.js';
import { registerConfigHandlers } from './ipc/config.js';
import { registerExternalHandlers } from './ipc/external.js';
import { registerLLMHandlers } from './ipc/llm.js';
import { registerMockInterviewHandlers } from './ipc/mock-interview.js';
import { registerPaymentHandlers } from './ipc/payment.js';
import { registerPermissionHandlers } from './ipc/permissions.js';
import { registerActionSuggestionHandlers } from './ipc/suggestion-action.js';
Expand Down Expand Up @@ -211,6 +212,7 @@ app.whenReady().then(async () => {
registerTranscriptHandlers();
registerLiveSuggestionHandlers();
registerActionSuggestionHandlers();
registerMockInterviewHandlers();
registerToolsHandlers();
registerAutoUpdaterHandlers();
registerExternalHandlers();
Expand Down
45 changes: 45 additions & 0 deletions src/main/ipc/mock-interview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { ipcMain } from 'electron';

import { mockInterviewService } from '../services/mock-interview.service.js';
import { MockInterviewSetup } from '../types/mock-interview.js';

export function registerMockInterviewHandlers(): void {
ipcMain.handle('mock-interview:start', async (_event, setup: MockInterviewSetup) => {
await mockInterviewService.start(setup);
});

ipcMain.handle('mock-interview:synthesize-chunk', async (_event, index: number) => {
return mockInterviewService.synthesizeChunk(index);
});

ipcMain.handle('mock-interview:speech-finished', async () => {
await mockInterviewService.speechFinished();
});

ipcMain.handle('mock-interview:speech-failed', async () => {
await mockInterviewService.speechFailed();
});

ipcMain.handle(
'mock-interview:ingest-answer',
async (_event, payload: { type: 'partial' | 'final'; text: string }) => {
mockInterviewService.ingestAnswer(payload.type, payload.text);
}
);

ipcMain.handle('mock-interview:answer-finished', async () => {
await mockInterviewService.answerFinished();
});

ipcMain.handle('mock-interview:skip-question', async () => {
await mockInterviewService.skipQuestion();
});

ipcMain.handle('mock-interview:end-session', async () => {
await mockInterviewService.endSession();
});

ipcMain.handle('mock-interview:clear', async () => {
mockInterviewService.clear();
});
}
3 changes: 3 additions & 0 deletions src/main/ipc/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export function registerToolsHandlers(): void {
ipcMain.handle('tools:export-transcript', async (_event, format: ExportFormat = 'docx') => {
return toolsService.exportTranscript(format);
});
ipcMain.handle('tools:export-mock-report', async (_event, format: ExportFormat = 'docx') => {
return toolsService.exportMockReport(format);
});
ipcMain.handle('tools:clear-all', async () => {
await toolsService.clearAll();
});
Expand Down
16 changes: 16 additions & 0 deletions src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@ const electronApi = {
stop: () => ipcRenderer.invoke('action-suggestion:stop'),
},

mockInterview: {
start: (setup: Record<string, unknown>) => ipcRenderer.invoke('mock-interview:start', setup),
synthesizeChunk: (index: number) =>
ipcRenderer.invoke('mock-interview:synthesize-chunk', index),
speechFinished: () => ipcRenderer.invoke('mock-interview:speech-finished'),
speechFailed: () => ipcRenderer.invoke('mock-interview:speech-failed'),
ingestAnswer: (payload: { type: 'partial' | 'final'; text: string }) =>
ipcRenderer.invoke('mock-interview:ingest-answer', payload),
answerFinished: () => ipcRenderer.invoke('mock-interview:answer-finished'),
skipQuestion: () => ipcRenderer.invoke('mock-interview:skip-question'),
endSession: () => ipcRenderer.invoke('mock-interview:end-session'),
clear: () => ipcRenderer.invoke('mock-interview:clear'),
},

onPushNotification: (callback: (notification: PushNotification) => void) => {
const handler = (_event: Electron.IpcRendererEvent, notification: PushNotification) =>
callback(notification);
Expand All @@ -142,6 +156,8 @@ const electronApi = {
tools: {
exportTranscript: (format: 'docx' | 'md') =>
ipcRenderer.invoke('tools:export-transcript', format),
exportMockReport: (format: 'docx' | 'md') =>
ipcRenderer.invoke('tools:export-mock-report', format),
clearAll: () => ipcRenderer.invoke('tools:clear-all'),
setPlaceholderData: () => ipcRenderer.invoke('tools:set-placeholder-data'),
saveImage: (opts: { filename: string; data: number[] }) =>
Expand Down
31 changes: 30 additions & 1 deletion src/main/services/app-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const DEFAULT_STATE: AppState = {
interviewConfig: { fullName: '', profileData: '', context: '' },
interviewConfigLoaded: false,
hasHistory: false,
mockInterview: null,
hasMockContent: false,
};

/** The three arrays a session fills, and that the placeholder seeds. */
Expand Down Expand Up @@ -178,8 +180,35 @@ export class AppStateService {
return next;
}

/**
* Fold a `mockInterview` write into `updates` so that `hasMockContent` follows it, the same
* mechanism `withHistory` is for the live transcript.
*
* Independent of `hasHistory` on purpose: a mock session and a live interview never run at the
* same time (mutual exclusion is enforced elsewhere), but they are still two separate things a
* close can destroy, and conflating them would let one mask the other in the guard that reads
* them.
*/
private withMockContent(updatesIn: Partial<AppState>): Partial<AppState> {
const next: Partial<AppState> = { ...updatesIn };
// Derived here and nowhere else, for the same reason `hasHistory` is stripped above: this
// crosses IPC inside a `Partial<AppState>` the renderer composes, and the close guard trusts
// it - a caller that set it directly would switch the save prompt off with no symptom.
delete next.hasMockContent;

if (next.mockInterview === undefined) return next;

const session = next.mockInterview;
// A skipped question is not content: `answer` is empty for those by construction, so the
// trim check already excludes them without needing to read the `skipped` flag directly.
next.hasMockContent =
session !== null && session.answers.some((a) => !a.skipped && a.answer.trim().length > 0);

return next;
}

updateState(updatesIn: Partial<AppState>): AppState {
const updates = this.withHistory(updatesIn);
const updates = this.withMockContent(this.withHistory(updatesIn));

// The health-check loops re-report identical values every 1-5s. Broadcasting those would
// re-render every subscriber for nothing, so only notify when something actually moved.
Expand Down
Loading