Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
68 changes: 68 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,74 @@ the default at that moment, in an effect - never in the render body, where the s
re-enters React mid-commit and a failed IPC call rolls the value back into the same condition that
triggered it, one write per frame.

### Saving the interview before it is lost

The transcript and the suggestions live only in main-process memory. Nothing is written to disk
until an export, so the three actions that empty them - Clear, Start (which opens with
`clearAll()`), and closing the app - are the only paths in the app that destroy work with no way
back. All three now ask first, through one dialog:
[save-history-dialog.tsx](src/renderer/components/custom/save-history-dialog.tsx), mounted once in
`MainFrame` because the three do not share a screen - the control panel is not rendered in stealth
mode, and the close prompt arrives from main with no component of its own.

**The question is only worth asking about a real interview, and length cannot tell you that.**
`setPlaceholderState()` seeds the panels with one transcript and two suggestions so an empty app
has something to show, and it runs on launch and again after every Clear - so
`transcripts.length === 0` is never true and the old guard let the placeholder through. That was
already a live defect on the export path: Export on a machine that had never run an interview
passed the length check and billed a summarize call on "Transcripts will be here", then wrote the
model's answer into a document titled as a record of the candidate's interview.

`AppState.hasHistory` is the replacement, derived in `withHistory()` and never set by a caller -
`updateState` strips it off incoming updates, because it arrives inside a `Partial<AppState>` the
renderer composes and the close guard trusts it. Only the transcript and suggestion services
write the three history keys and they only ever write real content, so a write to any of them
retires the placeholder and the flag is recomputed from what the write leaves behind. The
untouched arrays are emptied in the same write: `clearAll()` runs before every session so mixed
state is not reachable today, but a real transcript sitting beside two lines of sample suggestion
copy is the one shape that would put placeholder text into an exported report.

**The flag is read from the transcripts and live suggestions only, not from all three.** Those are
what `exportTranscript` builds the report out of; action suggestions have never been in it. So a
session whose only content is a screenshot has nothing a save could capture, and counting it would
both offer to save what the save cannot contain and let the export guard through on an empty
transcript - the billed summarize call over nothing that the guard exists to stop, reached through
a different door. The export guard and `nothingToExport` both read the flag now, and
`test/save-history.test.mjs` pins it.

**Closing is the one that cannot ask on its own behalf.** Clear and Start are renderer-initiated
and confirm before they act; a close is decided in main - the window button, Cmd+Q, `app.quit()` -
and the renderer would hear about it too late to matter. So
[window-close-guard.ts](src/main/window-close-guard.ts) vetoes the close, sends
`app:save-history-prompt`, and the renderer closes the window itself by replying. Exactly one of
`window:close-confirmed` / `window:close-cancelled` has to come back or the app cannot be closed
at all, which is why the guard gives up on a renderer that is destroyed or crashed rather than
holding the window open with nobody to ask.

Three pieces of state, each for a failure the others do not cover. `closeConfirmed` lets the
answered close through instead of re-prompting on it. `prompting` stops a second close - the
window button pressed while the dialog is up - stacking another prompt. And `quitting`, set from
`before-quit`, is what makes Cmd+Q work: vetoing the close *cancels the quit*, so confirming has
to call `app.quit()` again rather than `win.close()`, or the app would sit there with one window
fewer. Cancelling resets it, or the next Cmd+Q would take that branch for a session the user just
chose to keep.

A save that the user cancels at the system save dialog leaves the prompt open rather than reading
as a decision to discard, and so does a failed export - going ahead there would destroy the
interview on the one path where keeping it did not work.

**Installing an update is a quit the guard must not veto.** `quitAndInstall()` launches the
installer - on macOS `shell.openPath` has already opened the .dmg - and requests the quit
*afterwards*, so a veto there does not cancel the update. It leaves an installer running against
an app that refuses to exit, which on Windows ends with the installer killing it: the interview is
lost anyway, and the prompt asking about it was on screen for a second. So the updater IPC handler
calls `allowNextClose()` before it installs, and `rearmCloseGuard()` if nothing was launched
(`quitAndInstall` returns whether it committed to quitting, which the macOS "no downloaded file"
path does not). The question is asked one layer up instead, in `update-notification.tsx`, in front
of the install rather than behind it. `confirmDiscard` is held in a ref there: it closes over the
app state, so it is a new function on every broadcast from main, and naming it as a dependency
would re-run the update-status effect several times a second during an interview.

### Interview language

One setting decides three things: which speech model transcribes the call, what language suggestions come back in, and the language of the exported report. `Language` is mirrored across the processes the way `SuggestionMode` is - [src/main/types/language.ts](src/main/types/language.ts) for the request bodies, [src/renderer/types/language.ts](src/renderer/types/language.ts) for the same enum plus the display metadata the picker needs. 28 languages, which is exactly what the backend's Deepgram Nova-3 ASR streams: offering one the ASR cannot hear would not degrade, it would answer a question that was never asked.
Expand Down
6 changes: 6 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { restoreWindow, setWindowReference } from './services/window-control.ser
import { setWindowReference as setZoomWindowReference } from './services/zoom.service.js';
import { configStore } from './store/config.store.js';
import { EnvUtil } from './utils/env.js';
import { installCloseGuard, registerCloseGuardHandlers } from './window-close-guard.js';

let win: BrowserWindow | null = null;

Expand Down Expand Up @@ -170,6 +171,10 @@ async function createWindow() {
}
});

// After the bounds listener, so a close the guard vetoes has still recorded where the window
// was - the user is about to be asked a question, not to have their layout forgotten.
installCloseGuard(win);

// Clear cache before loading
await win.webContents.session.clearCache();

Expand Down Expand Up @@ -209,6 +214,7 @@ app.whenReady().then(async () => {
registerToolsHandlers();
registerAutoUpdaterHandlers();
registerExternalHandlers();
registerCloseGuardHandlers();

// Create window
await createWindow();
Expand Down
13 changes: 11 additions & 2 deletions src/main/ipc/auto-updater.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ipcMain } from 'electron';

import { autoUpdaterService } from '../services/auto-updater.service.js';
import { allowNextClose, rearmCloseGuard } from '../window-close-guard.js';

export function registerAutoUpdaterHandlers(): void {
ipcMain.handle('auto-updater:check-for-updates', async () => {
Expand All @@ -17,11 +18,19 @@ export function registerAutoUpdaterHandlers(): void {
});

ipcMain.handle('auto-updater:quit-and-install', async () => {
// Armed before the call rather than after it: the installer is launched and the quit
// requested inside quitAndInstall, so a guard still active at that moment vetoes a quit the
// update has already committed to. The renderer asks about an unsaved interview before it
// invokes this.
allowNextClose();
try {
await autoUpdaterService.quitAndInstall();
return { success: true };
const quitting = await autoUpdaterService.quitAndInstall();
// Nothing was launched, so the app is staying and the guard goes back on.
if (!quitting) rearmCloseGuard();
return { success: quitting };
} catch (error) {
console.error('[IPC] Failed to quit and install:', error);
rearmCloseGuard();
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
Expand Down
10 changes: 10 additions & 0 deletions src/main/preload.cts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ const electronApi = {
minimize: () => ipcRenderer.send('window:minimize'),
maximize: () => ipcRenderer.send('window:maximize'),

// Main vetoes a close that would drop an unsaved interview and asks here instead. The reply
// is what actually closes the window, so exactly one of these two has to be sent back.
onSaveHistoryPrompt: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on('app:save-history-prompt', handler);
return () => ipcRenderer.removeListener('app:save-history-prompt', handler);
},
confirmClose: () => ipcRenderer.send('window:close-confirmed'),
cancelClose: () => ipcRenderer.send('window:close-cancelled'),

zoom: {
increase: () => ipcRenderer.send('zoom:in'),
decrease: () => ipcRenderer.send('zoom:out'),
Expand Down
76 changes: 71 additions & 5 deletions src/main/services/app-state.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,26 @@ const DEFAULT_STATE: AppState = {
providedLLMModel: undefined,
interviewConfig: { fullName: '', profileData: '', context: '' },
interviewConfigLoaded: false,
hasHistory: false,
};

/** The three arrays a session fills, and that the placeholder seeds. */
const HISTORY_KEYS = ['transcripts', 'liveSuggestions', 'actionSuggestions'] as const;

/**
* The two an export actually reads.
*
* `hasHistory` drives the save prompt and the export guard, and for both it has to mean "there
* is an interview that saving would capture". `exportTranscript` builds the report from
* transcripts and live suggestions alone - action suggestions have never been in it - so
* counting them would offer to save something the save cannot contain, and would let the export
* guard through on a session whose only content is a screenshot: a billed summarize call over
* an empty transcript, writing a report out of nothing. That is the exact failure the guard
* exists to stop, reached through a different door.
*/
const EXPORTABLE_KEYS = ['transcripts', 'liveSuggestions'] as const;
type ExportableKey = (typeof EXPORTABLE_KEYS)[number];

// Every broadcast structured-clones the whole renderer state, and they fire on each streamed
// token and each ASR partial - roughly 20/second across two channels, against a transcript array
// that grows for the whole interview. Coalescing bounds that cost per unit time instead of per
Expand All @@ -41,12 +59,21 @@ export class AppStateService {
private state: AppState;
private broadcastTimer: ReturnType<typeof setTimeout> | null = null;

/**
* Whether the history arrays currently hold the placeholder copy.
*
* Tracked here rather than inferred from the contents, because the placeholder is
* indistinguishable from a one-line interview by shape and only this class ever writes it.
*/
private placeholderActive = false;

constructor() {
this.state = { ...DEFAULT_STATE };
this.setPlaceholderState();
}

setPlaceholderState() {
this.placeholderActive = true;
const tstampNow = Date.now();
this.state = {
...this.state,
Expand Down Expand Up @@ -82,6 +109,9 @@ export class AppStateService {
error: '',
},
],
// Sample copy is not an interview. Everything that destroys history asks to save it
// first, and this is the state a freshly launched app sits in.
hasHistory: false,
};
// Clear reaches main and resets the state here, but the renderer only ever learns about
// state through this broadcast - it does not poll while the push API exists. Without this
Expand Down Expand Up @@ -112,7 +142,45 @@ export class AppStateService {
};
}

updateState(updates: Partial<AppState>): AppState {
/**
* Fold a write into `updates` so that `hasHistory` follows it.
*
* Only the transcript and suggestion services write the history keys, and they only ever
* write real interview content - the placeholder comes from here alone. So a write to any
* one of them retires the placeholder, and the flag is recomputed from what the write leaves
* behind rather than tracked by each caller.
*
* The untouched arrays are emptied along with it. `clearAll` runs before every session so
* that mixed state is not reachable in practice, but a real transcript sitting beside two
* lines of sample suggestion copy is the one shape that would put placeholder text into an
* exported report.
*/
private withHistory(updatesIn: Partial<AppState>): Partial<AppState> {
// Derived here and nowhere else. It crosses IPC inside a `Partial<AppState>` the renderer
// composes, and the close guard trusts it, so a caller that set it - by mistake, or by
// echoing back state it was sent - would switch the save prompt off with no symptom.
const next: Partial<AppState> = { ...updatesIn };
delete next.hasHistory;

const touched = HISTORY_KEYS.filter((key) => next[key] !== undefined);
if (touched.length === 0) return next;

if (this.placeholderActive) {
for (const key of HISTORY_KEYS) {
if (!touched.includes(key)) next[key] = [] as never;
}
this.placeholderActive = false;
}

next.hasHistory = EXPORTABLE_KEYS.some(
(key: ExportableKey) => (next[key] ?? this.state[key]).length > 0
);
return next;
}

updateState(updatesIn: Partial<AppState>): AppState {
const updates = 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.
const changed = (Object.keys(updates) as (keyof AppState)[]).some(
Expand Down Expand Up @@ -184,13 +252,11 @@ export class AppStateService {
}

addLiveSuggestion(s: LiveSuggestion): void {
this.state = { ...this.state, liveSuggestions: [...this.state.liveSuggestions, s] };
this.notifyRenderer();
this.updateState({ liveSuggestions: [...this.state.liveSuggestions, s] });
}

addActionSuggestion(s: ActionSuggestion): void {
this.state = { ...this.state, actionSuggestions: [...this.state.actionSuggestions, s] };
this.notifyRenderer();
this.updateState({ actionSuggestions: [...this.state.actionSuggestions, s] });
}
}

Expand Down
11 changes: 8 additions & 3 deletions src/main/services/auto-updater.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,17 +277,22 @@ class AutoUpdaterService {
}
}

async quitAndInstall(): Promise<void> {
/**
* @returns whether the app is now on its way out. False means nothing was launched and
* nothing was quit, which the close guard has to know so it can re-arm.
*/
async quitAndInstall(): Promise<boolean> {
if (process.platform === 'darwin') {
if (!this.macDownloadedFilePath) {
return;
return false;
}
await shell.openPath(this.macDownloadedFilePath);
app.quit();
return;
return true;
}

autoUpdater.quitAndInstall(false, true);
return true;
}

getCurrentVersion(): string {
Expand Down
7 changes: 6 additions & 1 deletion src/main/services/tools.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ class ToolsService {
// 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) {
//
// On `hasHistory` rather than on the array lengths: those are never zero, because the panels
// are seeded with placeholder copy on launch and again after every Clear. So the length
// check passed on a machine that had never run an interview, and the document it produced
// was a model's summary of "Transcripts will be here".
if (!appStateService.getState().hasHistory) {
throw new Error('There is nothing to export yet. Run an interview first.');
}

Expand Down
14 changes: 14 additions & 0 deletions src/main/types/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,20 @@ export interface AppState {
interviewConfig: InterviewConfig;
/** False until the account's config has been read this session; editing is unsafe before then. */
interviewConfigLoaded: boolean;
/**
* Whether there is an interview that saving would actually capture.
*
* Derived, never set by a caller - `updateState` strips it off incoming updates. The panels
* are seeded with one transcript and two suggestions so an empty app has something to show,
* so a length check cannot tell an interview worth saving from the sample text, and
* everything that destroys history asks before it does - a question nobody should be asked
* about placeholder copy.
*
* Read from the transcripts and live suggestions only, because those are what the export
* writes. Action suggestions are not in the report, so a session holding nothing else has
* nothing to save.
*/
hasHistory: boolean;
}

/** The app state as sent to the renderer, with the interview config reduced to a summary. */
Expand Down
Loading