fix(desktop): keep streaming painting in unfocused secondary chat windows (#47919)

* fix(desktop): keep streaming painting in unfocused secondary chat windows

The chat transcript streams to screen through a requestAnimationFrame-gated
flush, which Chromium pauses for blurred/occluded windows. The primary window
opted out with `backgroundThrottling: false`, but the secondary "session
windows" (cmd-click pop-out, new-session, subagent-watch) hand-copied their
webPreferences and silently lost that flag — so a streamed answer in one of them
stalled until the window regained focus (reported on Windows 11). The primary
window's own comment even claimed it was "matching the secondary windows," which
was no longer true.

Hoist the chat-window webPreferences into a single shared factory
(`chatWindowWebPreferences`) in session-windows.cjs and use it for BOTH windows,
so they can never drift on this flag again.

* test(desktop): assert chat windows disable background throttling

Cover chatWindowWebPreferences: it must set backgroundThrottling=false (so the
streaming transcript paints while the window is blurred) and pass the preload
path through while keeping the hardened defaults (contextIsolation, sandbox,
nodeIntegration=false).
This commit is contained in:
xxxigm 2026-06-18 01:40:13 +07:00 committed by GitHub
parent 1e6c4ba74f
commit b07b7894ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 54 additions and 26 deletions

View file

@ -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) {

View file

@ -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

View file

@ -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)
})