Summary
The main process never delivers a single app:state-updated push to the renderer. The renderer runs forever on the one snapshot it takes when the first component mounts.
Reported symptom: the Start button does nothing - it re-toasts "Could not load your saved configuration. Reconnecting - try again in a moment." however many times you press it. That is one manifestation among several; a fresh login is also broken.
The defect
src/main/services/app-state.service.ts:
private notifyRenderer(): void {
if (this.broadcastTimer) return;
this.broadcastTimer = setTimeout(() => {
this.broadcastTimer = null; // handle cleared here...
this.flushRenderer(); // ...so flushRenderer's own guard trips
}, BROADCAST_COALESCE_MS);
}
flushRenderer(): void {
if (!this.broadcastTimer) return; // always true when called from the timer
clearTimeout(this.broadcastTimer);
this.broadcastTimer = null;
// win.webContents.send('app:state-updated', ...) <- never reached
}
The timer callback nulls the handle and then calls a method guarded on that handle being non-null.
flushRenderer() is the only caller of webContents.send('app:state-updated', ...) - the single hit across src/main/. Nothing in src/ calls flushRenderer() externally; only test/app-state.test.mjs does. So every real broadcast goes through the timer path, and the timer path is a no-op.
Introduced with the broadcast coalescing in f47e66f.
Why CI never caught it
test/app-state.test.mjs calls flushRenderer() synchronously after each updateState(), while the timer is still pending. That is the one arrangement in which the send works. No check ever lets the timer fire on its own, so the coalesced path - the only path production uses - was untested.
Why the renderer never recovers
use-app-state.tsx starts its 1-second polling fallback only when onAppStateUpdated is absent. It is present (preload.cts), so the renderer subscribes to a channel that never fires and never polls. It gets exactly one snapshot, from refreshState() at first subscribe, and is frozen from then on.
How that kills Start
checkCanStart() in control-panel/index.tsx gates on appState.interviewConfigLoaded, which is set to true only by accountService.pullFromBackend() / updateConfig() after login. That update lands in main and broadcasts into the void.
- Click Start ->
interviewConfigLoaded is still the false default -> toast.
onFail fires account.refresh(), which succeeds in main and broadcasts - to nobody.
- Click Start again -> identical toast. Forever.
Everything else the same defect breaks
Which symptom a user sees depends only on when their single snapshot was taken.
| Area |
Consequence |
| Fresh login |
LoginPage.submit does not navigate; AuthLayout navigates to /main only when isLoggedIn === true arrives over the broadcast. Login succeeds in main and the user stays on the form. |
| Snapshot during startup ping |
healthCheckService.start() sets isLoggedIn: null before its ping resolves. A snapshot in that window pins MainPage on "Authenticating…" permanently. |
| Start button (reported) |
Remembered session: the ping sets isLoggedIn: true before the snapshot, so /main renders, but interviewConfigLoaded never arrives. Toast loop. |
| Run state |
Idle -> Starting -> Running -> Idle never reaches the UI. Even a successful start leaves the button on "Start" and TransitionOverlay never appears. |
| Transcripts / live + action suggestions |
appendSuggestion and transcriptService.ingest update main only. Panels stay on the setPlaceholderState() content for the whole interview. |
| Credits, user role, backend-live |
ConnectingNotice, CreditsDisplay, TrialUserNotice, StatusPanel all frozen at their snapshot values. |
| Clear (Tools) |
3a7b660 fixed Clear to broadcast its reset. That fix is inert - the broadcast it added cannot send. |
Checked and cleared
Audited for the same class of defect across the client: every ipcMain channel has a matching preload binding and vice versa; all push channels main sends (hotkey:*, auto-updater:status, notification:push, window:stealth-changed, zoom:level-changed) have live preload listeners; the timers in action-lock.service.ts and the stall timers in suggestion-live.service.ts / suggestion-action.service.ts are correct - none repeats the clear-then-call-a-guarded-method shape.
Fix
Separate sending from scheduling: extract the send into a private sendToRenderer() that both the timer callback and flushRenderer() call, leaving the "is something pending" guard on flushRenderer() alone where it belongs.
Plus a regression check in test/app-state.test.mjs that lets the timer fire naturally with no explicit flush. It fails on main and passes with the fix.
Out of scope
Two pre-existing items found during the audit, neither the cause here:
audioInputDeviceNotFound is read in checkCanStart() (control-panel/index.tsx:56) but const-declared 74 lines later at line 130. Safe today only because the closure runs on click, after the render scope initializes. Fragile, not broken.
enumerateDevices() returns blank labels until mic permission is granted, so filterAudioDevices falls back to "Input Device N" names that can never match a saved audioInputDeviceName - an independent second source of a Start-blocking toast on a fresh machine.
Summary
The main process never delivers a single
app:state-updatedpush to the renderer. The renderer runs forever on the one snapshot it takes when the first component mounts.Reported symptom: the Start button does nothing - it re-toasts "Could not load your saved configuration. Reconnecting - try again in a moment." however many times you press it. That is one manifestation among several; a fresh login is also broken.
The defect
src/main/services/app-state.service.ts:The timer callback nulls the handle and then calls a method guarded on that handle being non-null.
flushRenderer()is the only caller ofwebContents.send('app:state-updated', ...)- the single hit acrosssrc/main/. Nothing insrc/callsflushRenderer()externally; onlytest/app-state.test.mjsdoes. So every real broadcast goes through the timer path, and the timer path is a no-op.Introduced with the broadcast coalescing in f47e66f.
Why CI never caught it
test/app-state.test.mjscallsflushRenderer()synchronously after eachupdateState(), while the timer is still pending. That is the one arrangement in which the send works. No check ever lets the timer fire on its own, so the coalesced path - the only path production uses - was untested.Why the renderer never recovers
use-app-state.tsxstarts its 1-second polling fallback only whenonAppStateUpdatedis absent. It is present (preload.cts), so the renderer subscribes to a channel that never fires and never polls. It gets exactly one snapshot, fromrefreshState()at first subscribe, and is frozen from then on.How that kills Start
checkCanStart()incontrol-panel/index.tsxgates onappState.interviewConfigLoaded, which is set totrueonly byaccountService.pullFromBackend()/updateConfig()after login. That update lands in main and broadcasts into the void.interviewConfigLoadedis still thefalsedefault -> toast.onFailfiresaccount.refresh(), which succeeds in main and broadcasts - to nobody.Everything else the same defect breaks
Which symptom a user sees depends only on when their single snapshot was taken.
LoginPage.submitdoes not navigate;AuthLayoutnavigates to/mainonly whenisLoggedIn === truearrives over the broadcast. Login succeeds in main and the user stays on the form.healthCheckService.start()setsisLoggedIn: nullbefore its ping resolves. A snapshot in that window pins MainPage on "Authenticating…" permanently.isLoggedIn: truebefore the snapshot, so/mainrenders, butinterviewConfigLoadednever arrives. Toast loop.TransitionOverlaynever appears.appendSuggestionandtranscriptService.ingestupdate main only. Panels stay on thesetPlaceholderState()content for the whole interview.ConnectingNotice,CreditsDisplay,TrialUserNotice,StatusPanelall frozen at their snapshot values.Checked and cleared
Audited for the same class of defect across the client: every
ipcMainchannel has a matching preload binding and vice versa; all push channels main sends (hotkey:*,auto-updater:status,notification:push,window:stealth-changed,zoom:level-changed) have live preload listeners; the timers inaction-lock.service.tsand the stall timers insuggestion-live.service.ts/suggestion-action.service.tsare correct - none repeats the clear-then-call-a-guarded-method shape.Fix
Separate sending from scheduling: extract the send into a private
sendToRenderer()that both the timer callback andflushRenderer()call, leaving the "is something pending" guard onflushRenderer()alone where it belongs.Plus a regression check in
test/app-state.test.mjsthat lets the timer fire naturally with no explicit flush. It fails onmainand passes with the fix.Out of scope
Two pre-existing items found during the audit, neither the cause here:
audioInputDeviceNotFoundis read incheckCanStart()(control-panel/index.tsx:56) butconst-declared 74 lines later at line 130. Safe today only because the closure runs on click, after the render scope initializes. Fragile, not broken.enumerateDevices()returns blanklabels until mic permission is granted, sofilterAudioDevicesfalls back to"Input Device N"names that can never match a savedaudioInputDeviceName- an independent second source of a Start-blocking toast on a fresh machine.