diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 19096c61357..c71afae7cb8 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -28,6 +28,7 @@ const { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } = requ const { runBootstrap } = require('./bootstrap-runner.cjs') const { buildSessionWindowUrl, + chatWindowWebPreferences, createSessionWindowRegistry, SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_WIDTH @@ -5106,14 +5107,7 @@ function spawnSecondaryWindow({ sessionId, watch, newSession } = {}) { // themes/context.tsx, so the window appears already themed. show: false, backgroundColor: getWindowBackgroundColor(), - webPreferences: { - preload: path.join(__dirname, 'preload.cjs'), - contextIsolation: true, - webviewTag: true, - sandbox: true, - nodeIntegration: false, - devTools: true - } + webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs')) }) if (IS_MAC) { @@ -5180,23 +5174,11 @@ function createWindow() { // material before the renderer paints the app theme. See createSessionWindow. show: false, backgroundColor: getWindowBackgroundColor(), - webPreferences: { - preload: path.join(__dirname, 'preload.cjs'), - contextIsolation: true, - webviewTag: true, - sandbox: true, - nodeIntegration: false, - devTools: true, - // Keep timers + requestAnimationFrame running at full speed when the - // window is blurred/occluded. The chat transcript streams to the screen - // through a requestAnimationFrame-gated flush (useSessionStateCache), - // so with Chromium's default background throttling the live answer - // stalls whenever this window isn't focused (e.g. you switch to your - // editor mid-turn, or open detached devtools) and only appears once you - // refocus or refresh. A streaming chat app must render in the - // background, so opt out — matching the secondary windows above. - backgroundThrottling: false - } + // Shared with the secondary session windows (chatWindowWebPreferences) so + // both keep `backgroundThrottling: false` — the chat transcript streams via + // a requestAnimationFrame-gated flush that Chromium pauses for blurred + // windows, stalling the live answer until refocus. See session-windows.cjs. + webPreferences: chatWindowWebPreferences(path.join(__dirname, 'preload.cjs')) }) if (IS_MAC) { diff --git a/apps/desktop/electron/session-windows.cjs b/apps/desktop/electron/session-windows.cjs index 929bf3ea9ae..5e2f3d4c680 100644 --- a/apps/desktop/electron/session-windows.cjs +++ b/apps/desktop/electron/session-windows.cjs @@ -10,6 +10,29 @@ const { pathToFileURL } = require('node:url') const SESSION_WINDOW_MIN_WIDTH = 420 const SESSION_WINDOW_MIN_HEIGHT = 620 +// Shared webPreferences for every window that renders the chat transcript — the +// primary window AND the secondary session windows. Keeping it in one place is +// the whole point: the two BrowserWindow definitions in main.cjs used to be +// hand-copied, and the secondary windows silently lost `backgroundThrottling: +// false`, so a streamed answer stalled until the window regained focus. +// +// `backgroundThrottling: false` is load-bearing: the transcript streams to the +// screen through a requestAnimationFrame-gated flush, which Chromium pauses for +// blurred/occluded windows. A streaming chat app must keep painting in the +// background, so every chat window opts out. The preload path is injected +// because it depends on the Electron entry's __dirname. +function chatWindowWebPreferences(preloadPath) { + return { + preload: preloadPath, + contextIsolation: true, + webviewTag: true, + sandbox: true, + nodeIntegration: false, + devTools: true, + backgroundThrottling: false + } +} + // Build the renderer URL for a secondary window. The renderer uses a // HashRouter, so the session route lives after the '#'. The `?win=secondary` // flag MUST sit in the query string BEFORE the '#': anything after the '#' is @@ -94,6 +117,7 @@ function createSessionWindowRegistry() { module.exports = { buildSessionWindowUrl, + chatWindowWebPreferences, createSessionWindowRegistry, SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_WIDTH diff --git a/apps/desktop/electron/session-windows.test.cjs b/apps/desktop/electron/session-windows.test.cjs index 8261809dbf3..78f19b859e4 100644 --- a/apps/desktop/electron/session-windows.test.cjs +++ b/apps/desktop/electron/session-windows.test.cjs @@ -1,7 +1,11 @@ const assert = require('node:assert/strict') const test = require('node:test') -const { buildSessionWindowUrl, createSessionWindowRegistry } = require('./session-windows.cjs') +const { + buildSessionWindowUrl, + chatWindowWebPreferences, + createSessionWindowRegistry +} = require('./session-windows.cjs') // A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a // test fire the 'closed' event, mirroring the slice of the Electron API the @@ -175,3 +179,21 @@ test('registry trims the session id before keying', () => { assert.equal(registry.has('s1'), true) }) + +test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => { + // Regression: secondary session windows used to omit this flag, so a streamed + // answer stalled until the window regained focus (Chromium pauses the + // requestAnimationFrame-gated transcript flush for backgrounded windows). + const prefs = chatWindowWebPreferences('/tmp/preload.cjs') + + assert.equal(prefs.backgroundThrottling, false) +}) + +test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => { + const prefs = chatWindowWebPreferences('/some/preload.cjs') + + assert.equal(prefs.preload, '/some/preload.cjs') + assert.equal(prefs.contextIsolation, true) + assert.equal(prefs.sandbox, true) + assert.equal(prefs.nodeIntegration, false) +})