Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
3bbcf4d
feat(language): make the stubbed Language enum real
alpha5611331 Aug 24, 2026
d35527c
feat(suggestions): send the interview language on every request
alpha5611331 Aug 24, 2026
a52f210
feat(asr): open the transcription sockets on the session's language, …
alpha5611331 Aug 24, 2026
508d547
feat(ui): interview language picker on the control bar
alpha5611331 Aug 24, 2026
8ebcdc0
test(language): pin the default, the set, and the unknown-code fallback
alpha5611331 Aug 24, 2026
e9c7286
docs: record the two-speed language switch and the sockets it reconnects
alpha5611331 Aug 24, 2026
8c9b983
fix(asr): report the orphaned utterance on a language switch, and rec…
alpha5611331 Aug 24, 2026
2dfb35d
fix(audio): pick the default microphone in an effect, not during render
alpha5611331 Aug 24, 2026
adff2f8
fix(audio): stop reporting a working microphone as missing
alpha5611331 Aug 24, 2026
76fc2a8
fix(assistant): always land on a terminal running state when stopping
alpha5611331 Aug 24, 2026
b29ef7e
perf(main): stop remeasuring the panel layout on every streamed chunk
alpha5611331 Aug 24, 2026
2b4cce8
fix(suggestions): keep truncateMiddle inside the length it is given
alpha5611331 Aug 24, 2026
f358f41
fix(a11y): name every form field and announce every form error
alpha5611331 Aug 24, 2026
cc5d3a3
feat(config): say when a long field is about to truncate a paste
alpha5611331 Aug 24, 2026
eb2257d
docs: record the assistant lifecycle and device-readiness invariants
alpha5611331 Aug 24, 2026
02e478c
fix(export): refuse to summarize an interview that has not happened
alpha5611331 Aug 24, 2026
e7dfc94
fix(a11y): name the remaining icon-only controls
alpha5611331 Aug 24, 2026
54cdf2c
fix(health-check): back off instead of polling a down backend at 1 Hz
alpha5611331 Aug 24, 2026
db68743
fix(security): keep the app's window on the app, and links out of it
alpha5611331 Aug 24, 2026
838c36a
fix(security): make the navigation guard idempotent
alpha5611331 Aug 24, 2026
fd222e8
feat(language): widen the interview language set to 28
alpha5611331 Aug 25, 2026
296679f
feat(audio): switch the microphone mid-interview
alpha5611331 Aug 25, 2026
82ae906
docs: record the widened language set and the microphone swap
alpha5611331 Aug 25, 2026
fb4ebb0
docs(ui): surface the widened language set and the microphone swap
alpha5611331 Aug 25, 2026
13fb47b
docs: note the microphone is changeable mid-interview
alpha5611331 Aug 25, 2026
0d54fd8
fix(audio): close the swap race against a half-built audio graph
alpha5611331 Aug 26, 2026
6f7315e
fix(transcript): stop re-inserting the spaces the backend left out
alpha5611331 Aug 26, 2026
3b9d56d
fix(audio): keep the Select binding as it was, and correct a stale co…
alpha5611331 Aug 26, 2026
8c6bbbd
docs: stop naming AssemblyAI as something the backend might be
alpha5611331 Aug 26, 2026
0d4f27e
docs: point the CJK separator note at the file it moved to
alpha5611331 Aug 26, 2026
6e55941
fix(audio): let the last requested microphone win, not the last to open
alpha5611331 Aug 26, 2026
93b5620
feat(control-bar): say when only half a setting actually applied
alpha5611331 Aug 26, 2026
6c95ee8
fix(a11y): hide the language trigger's decorative icons from screen r…
alpha5611331 Aug 26, 2026
03abc08
fix(suggestions): stop dropping every question in a non-Latin script
alpha5611331 Aug 26, 2026
d041578
fix(language): stop two switches racing for the same channel socket
alpha5611331 Aug 26, 2026
969815a
fix(transcript): join each block in the language it was spoken in
alpha5611331 Aug 26, 2026
4d11e7a
fix(rtl): resolve text direction per block instead of inheriting LTR
alpha5611331 Aug 26, 2026
ae3f321
fix(export): write the report's own headings in the interview language
alpha5611331 Aug 26, 2026
34e933f
docs: record the language-aware merge, RTL and report wording
alpha5611331 Aug 26, 2026
e75e06c
fix(language): stop a superseded switch reporting over the one that r…
alpha5611331 Aug 26, 2026
c2aeb75
fix(docs-dialog): isolate each endonym so the list keeps its order
alpha5611331 Aug 26, 2026
5fc70ac
test(language): pin each picker row against its own enum member
alpha5611331 Aug 26, 2026
780ca6e
fix(gate): stop dropping a non-Latin question that opens on "OK"
alpha5611331 Aug 26, 2026
cb8badc
fix(sentinel): match NO_SUGGESTION_NEEDED behind a directional mark
alpha5611331 Aug 26, 2026
468b5aa
test(sentinel): write the directional marks as escapes, not as themse…
alpha5611331 Aug 26, 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
156 changes: 155 additions & 1 deletion CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ pnpm test:main # main-process checks
### Configuration

- Set profile (CV, job description)
- Select microphone
- Select microphone (changeable mid-interview, without interrupting transcription)
- Start assistant

## Use Cases
Expand Down
11 changes: 9 additions & 2 deletions src/main/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { app, BrowserWindow, Menu } from 'electron';
import path from 'path';
import { fileURLToPath } from 'url';
import { fileURLToPath, pathToFileURL } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand All @@ -22,6 +22,7 @@ import { registerLiveSuggestionHandlers } from './ipc/suggestion-live.js';
import { registerToolsHandlers } from './ipc/tools.js';
import { initializeAudioLoopback, registerTranscriptHandlers } from './ipc/transcript.js';
import { registerWindowHandlers } from './ipc/window.js';
import { installNavigationGuard } from './navigation-guard.js';
import { autoUpdaterService } from './services/auto-updater.service.js';
import { healthCheckService } from './services/health-check.service.js';
import { transcriptService } from './services/transcript.service.js';
Expand Down Expand Up @@ -172,14 +173,20 @@ async function createWindow() {
// Clear cache before loading
await win.webContents.session.clearCache();

// Installed before the load, so the guard is in place for the app's very first document.
// Idempotent, because this function runs again when the single-instance lock recovers a
// destroyed window and `web-contents-created` is an app-level event.
if (EnvUtil.isDev()) {
win.loadURL('http://localhost:15173');
const devUrl = 'http://localhost:15173';
installNavigationGuard(devUrl);
win.loadURL(devUrl);
win.webContents.openDevTools();
} else {
// Use app.getAppPath() for conventional path resolution
// This works correctly whether the app is packaged or not
const distPath = path.join(app.getAppPath(), 'dist', 'index.html');
console.log('Loading from:', distPath);
installNavigationGuard(pathToFileURL(distPath).href);
win.loadFile(distPath);
}
}
Expand Down
16 changes: 6 additions & 10 deletions src/main/ipc/external.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import { ipcMain, shell } from 'electron';

import { openExternally } from '../navigation-guard.js';

export function registerExternalHandlers(): void {
ipcMain.handle('external:open', async (_event, url: string) => {
try {
if (!url || typeof url !== 'string') return { success: false, error: 'invalid-url' };
await shell.openExternal(url);
return { success: true };
} catch (err: unknown) {
console.warn('[ExternalHandlers] external:open error:', err);
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
});
// Shared with the window-open handler rather than calling shell.openExternal directly, so a
// link takes the same route and the same scheme check whichever way it arrives. openExternal
// hands the URL to the OS protocol handler, so `file:` launches what the path points at.
ipcMain.handle('external:open', async (_event, url: string) => openExternally(url));

ipcMain.handle('external:open-file', async (_event, filePath: string) => {
const err = await shell.openPath(filePath);
Expand Down
109 changes: 109 additions & 0 deletions src/main/navigation-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { app, shell } from 'electron';

/**
* Schemes `shell.openExternal` is allowed to hand to the operating system.
*
* openExternal delegates to the OS protocol handler, so `file:` launches whatever the path points
* at and a registered custom scheme runs whatever claimed it. Only the three that mean "show this
* to the user in their own application" are permitted.
*/
const OPENABLE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);

export function isOpenableExternally(url: string): boolean {
try {
return OPENABLE_PROTOCOLS.has(new URL(url).protocol);
} catch {
return false;
}
}

/**
* Open a URL in the user's own browser, or refuse it.
*
* Shared by the `external:open` IPC handler and the window-open handler below, so a link takes
* the same route whether the renderer asked for it explicitly or a `target="_blank"` anchor did.
*/
export async function openExternally(url: string): Promise<{ success: boolean; error?: string }> {
if (!url || typeof url !== 'string') return { success: false, error: 'invalid-url' };
if (!isOpenableExternally(url)) {
console.warn('[NavigationGuard] Refused to open a non-web URL:', url);
return { success: false, error: 'unsupported-scheme' };
}

try {
await shell.openExternal(url);
return { success: true };
} catch (err: unknown) {
console.warn('[NavigationGuard] openExternal error:', err);
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
}

/**
* Keep the app's own web contents on the app.
*
* The panels render Markdown that came from a language model, and `remark-gfm` autolinks bare
* URLs, so an anchor in this app is not necessarily one anybody wrote. Two things follow from
* that, and neither was covered before.
*
* A `target="_blank"` anchor asks Electron for a new window, and with no handler installed the
* default is to make one: a chromeless BrowserWindow, no address bar, showing a page the user did
* not choose. Every one of those is denied and handed to the real browser instead, which is both
* safer and what the user expected from a link.
*
* An anchor without a target navigates the frame it is in, and that frame is the app - carrying
* the preload bridge with it, since preload runs on whatever document loads next. A remote page
* inheriting `window.electronAPI` would have the session token through `config.get()` and the
* candidate's CV through `account.get()`. `will-navigate` pins the window to the app's own
* document; the dev server and the packaged `file://` bundle are the only origins it may hold.
*/
let installed = false;

export function installNavigationGuard(appUrl: string): void {
// `createWindow()` runs again when the single-instance lock recovers a destroyed window, and
// `web-contents-created` is an app-level event: without this the second call would stack a
// duplicate will-navigate listener on every web contents for the rest of the process.
if (installed) return;
installed = true;

let appOrigin: string;
try {
appOrigin = new URL(appUrl).origin;
} catch {
appOrigin = '';
}

app.on('web-contents-created', (_event, contents) => {
contents.setWindowOpenHandler(({ url }) => {
// setImmediate, per Electron's own guidance: openExternal must not run inside the handler.
if (isOpenableExternally(url)) {
setImmediate(() => void openExternally(url));
} else {
console.warn('[NavigationGuard] Blocked a window for:', url);
}
return { action: 'deny' };
});

contents.on('will-navigate', (event, navigationUrl) => {
let target: URL;
try {
target = new URL(navigationUrl);
} catch {
event.preventDefault();
return;
}

// `file:` origins serialize to "null", so the packaged build is matched on the document it
// is already showing rather than on an origin comparison that can never hold.
const sameDocument =
target.href === appUrl || (appOrigin !== '' && target.origin === appOrigin);
if (sameDocument) return;

event.preventDefault();
console.warn('[NavigationGuard] Blocked navigation to:', navigationUrl);
if (isOpenableExternally(navigationUrl)) {
setImmediate(() => void openExternally(navigationUrl));
}
});
});
}
2 changes: 2 additions & 0 deletions src/main/services/app-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Speaker,
SuggestionState,
} from '../types/app-state.js';
import { DEFAULT_LANGUAGE } from '../types/language.js';
import { SuggestionMode } from '../types/llm.js';
import { getWindowReference, refreshWindowSurfaces } from './window-control.service.js';

Expand Down Expand Up @@ -57,6 +58,7 @@ export class AppStateService {
speaker: Speaker.Other,
isFinal: false,
endTimestamp: tstampNow + 5000,
language: DEFAULT_LANGUAGE,
},
],
liveSuggestions: [
Expand Down
34 changes: 30 additions & 4 deletions src/main/services/health-check.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,19 @@ import { pushNotificationService } from './push-notification.service.js';
const SUCCESS_INTERVAL = 5 * 1000; // 5 seconds
const FAILURE_INTERVAL = 1 * 1000; // 1 second

// A backend that is down is usually down for longer than a second, and the first retry is the
// only one that benefits from being immediate. Without a ceiling the loop below polls at 1 Hz
// for as long as the app is open - a laptop left overnight on a dropped connection makes tens of
// thousands of failing requests, and every installed client comes back at the same rate the
// moment a real outage ends. Backoff is capped rather than unbounded so recovery is still
// noticed within half a minute, which is what the reconnect notice in the UI is waiting on.
const MAX_FAILURE_INTERVAL = 30 * 1000;
const FAILURE_BACKOFF_FACTOR = 2;

function nextFailureInterval(current: number): number {
return Math.min(current * FAILURE_BACKOFF_FACTOR, MAX_FAILURE_INTERVAL);
}

export class HealthCheckService {
private running = false;
private client = new HealthCheckApi();
Expand Down Expand Up @@ -64,6 +77,8 @@ export class HealthCheckService {
/** Backend ping loop */
private startBackendLoop(): void {
(async () => {
let failureInterval = FAILURE_INTERVAL;

while (this.running) {
let backendLive = false;
try {
Expand All @@ -74,13 +89,17 @@ export class HealthCheckService {
}

if (!backendLive) {
console.log('[HealthCheckService] Backend not live');
console.log(`[HealthCheckService] Backend not live, next check in ${failureInterval}ms`);
}

// Update app state
appStateService.updateState({ isBackendLive: backendLive });

const next = backendLive ? SUCCESS_INTERVAL : FAILURE_INTERVAL;
// Reset on the way back up, so one blip does not leave the app checking slowly for the
// rest of the session.
const next = backendLive ? SUCCESS_INTERVAL : failureInterval;
failureInterval = backendLive ? FAILURE_INTERVAL : nextFailureInterval(failureInterval);

await safeSleep(next);
}
})();
Expand All @@ -89,12 +108,17 @@ export class HealthCheckService {
/** Client ping loop */
private startClientLoop(): void {
(async () => {
let failureInterval = FAILURE_INTERVAL;

while (this.running) {
const state = appStateService.getState();

// skip if not logged in
// skip if not logged in. Kept at FAILURE_INTERVAL: it makes no request, so it costs a
// timer wake-up rather than traffic, and it is what decides how soon after a sign-in the
// credits and role reach the UI.
if (!state.isLoggedIn) {
await safeSleep(FAILURE_INTERVAL);
failureInterval = FAILURE_INTERVAL;
continue;
}

Expand All @@ -117,9 +141,11 @@ export class HealthCheckService {
userRole: res.data?.user_role,
});
}
failureInterval = FAILURE_INTERVAL;
} catch (error) {
console.error('[HealthCheckService] Client ping error:', error);
nextInterval = FAILURE_INTERVAL;
nextInterval = failureInterval;
failureInterval = nextFailureInterval(failureInterval);
}

await safeSleep(nextInterval);
Expand Down
1 change: 1 addition & 0 deletions src/main/services/suggestion-action.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export class ActionSuggestionService {
transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT),
image_names: [...this.uploadedImageNames],
mode: conf.professionalMode ? SuggestionMode.Professional : SuggestionMode.Normal,
language: conf.language,
};

const lastQuestion = this.getLastInterviewerQuestion(transcripts);
Expand Down
1 change: 1 addition & 0 deletions src/main/services/suggestion-live.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ class LiveSuggestionService {
transcripts: transcripts.slice(-TRANSCRIPT_UPLOAD_LIMIT),
mode,
turn_verdict: turnVerdict,
language: conf.language,
};

armStallTimer(LIVE_SUGGESTION_TTFB_MS);
Expand Down
17 changes: 16 additions & 1 deletion src/main/services/tools.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,23 @@ class ToolsService {
const transcripts = appStateService.getState().transcripts;
const suggestions = appStateService.getState().liveSuggestions;

// Checked before the request, not after. Summarizing an empty interview is a billed model
// call whose only possible output is invented, and it lands in a document the candidate is
// told is a record of their interview. The export button is live whenever the assistant is
// idle, which includes every launch before the first session.
if (transcripts.length === 0 && suggestions.length === 0) {
throw new Error('There is nothing to export yet. Run an interview first.');
}

// Call the API to generate the summary text
const conf = configStore.getConfig();
const response = await this.llmApi.generateSummary({
config: configStore.getConfig().llmConf,
config: conf.llmConf,
username,
transcripts,
// The exported report is written in the interview's language too. A Spanish interview
// summarised in English is a document the candidate cannot hand to anyone involved in it.
language: conf.language,
} as GenerateSummarizeRequest);
if (response.error) {
throw new Error(response.error.message);
Expand All @@ -36,6 +48,9 @@ class ToolsService {
summary: response.data ?? '',
transcripts,
suggestions,
// Same setting the summary was requested in, so the words this file adds around it are in
// the language the rest of the document is written in.
language: conf.language,
});

const isMarkdown = format === 'md';
Expand Down
Loading