From 8ccb4c2cee14a3d0604b6df26adfe5ff2b3b9840 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 23:56:16 -0500 Subject: [PATCH 1/3] perf(desktop): scope background-throttling opt-out to live streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The process-wide disable-background-timer-throttling / disable-backgrounding-occluded-windows switches plus a static backgroundThrottling: false on every chat window pinned each renderer's document.visibilityState to 'visible' for the life of the window. Every visibility-gated backstop poll and clock tick in the renderer became an always-on timer: an idle, minimized Hermes burned ~20% CPU around the clock, on battery too. Throttling is now a runtime dial. A small controller (stream-throttle.ts) rides the merged hermes:active-work reports the quit guard already receives: while any turn is in flight every chat window gets setBackgroundThrottling(false) — a live answer keeps painting while blurred, occluded, or minimized, exactly as before — and once all turns settle (plus a 5s trailing window so the final flush lands at full cadence) Chromium's default throttling returns and hidden windows go quiet. disable-renderer-backgrounding stays: process priority only, no timer semantics, and it keeps hidden streaming fast. --- apps/desktop/electron/main.ts | 86 ++++++++-- apps/desktop/electron/session-windows.test.ts | 13 +- apps/desktop/electron/session-windows.ts | 21 ++- apps/desktop/electron/stream-throttle.test.ts | 152 ++++++++++++++++++ apps/desktop/electron/stream-throttle.ts | 119 ++++++++++++++ .../session/hooks/use-message-stream/index.ts | 20 +-- 6 files changed, 372 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/electron/stream-throttle.test.ts create mode 100644 apps/desktop/electron/stream-throttle.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index f485e5a5427..41686883f71 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -183,6 +183,7 @@ import { redactSecrets, SshConnection } from './ssh-connection' +import { createStreamThrottle } from './stream-throttle' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { resolveBehindCount, shouldCountCommits } from './update-count' import { waitForUpdateClearance } from './update-gate' @@ -421,18 +422,24 @@ if (IS_WINDOWS) { ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON) -// Keep the renderer running at full speed while the window is in the background -// or occluded. The chat transcript streams to screen through a bounded timer -// flush; Chromium clamps timers for backgrounded/occluded renderers, so without -// these the live answer stalls -// whenever the window loses focus (switching to your editor mid-turn, detached -// devtools, another window covering it) and only paints on refocus or refresh. -// `backgroundThrottling: false` on the BrowserWindow covers the blurred case; -// these process-level switches additionally stop Chromium from backgrounding or -// occlusion-throttling the renderer. Must run before app `ready`. +// Keep the renderer's PROCESS priority normal while its windows are hidden — +// a deprioritized renderer streams a live answer visibly slower once the +// window is minimized. This switch only affects scheduling priority; it does +// not exempt timers from throttling and costs nothing at idle. +// +// The timer/rAF throttling story is deliberately NOT handled here anymore. +// The old process-wide `disable-background-timer-throttling` / +// `disable-backgrounding-occluded-windows` switches (plus a static +// `backgroundThrottling: false` on every chat window) pinned every renderer's +// `document.visibilityState` to 'visible' forever — which silently turned all +// the renderer's visibility-gated backstop polls and clock ticks into +// always-on timers. A completely idle, minimized Hermes burned ~20% CPU +// around the clock. Throttling is now a runtime dial scoped to streaming: +// see createStreamThrottle() — chat windows are unthrottled while any turn is +// in flight (so a live answer keeps painting while blurred, occluded, or +// minimized, exactly as before) and return to Chromium's default throttling +// once the work settles. app.commandLine.appendSwitch('disable-renderer-backgrounding') -app.commandLine.appendSwitch('disable-backgrounding-occluded-windows') -app.commandLine.appendSwitch('disable-background-timer-throttling') const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') @@ -5188,6 +5195,31 @@ function sendPowerResume() { let powerResumeRegistered = false +// Mirror of powerMonitor's AC/battery state, broadcast to every window so +// renderer backstop polls can slow down on battery (see store/power.ts). +// `null` until the first powerMonitor read after app ready. +let onBatteryPower: boolean | null = null + +// Renderer-side battery gating seeds from this and stays current via the +// 'hermes:power-battery' push below. +ipcMain.handle('hermes:power-battery:get', () => onBatteryPower === true) + +function broadcastBatteryState(next: boolean) { + if (onBatteryPower === next) { + return + } + + onBatteryPower = next + + for (const win of BrowserWindow.getAllWindows()) { + const { webContents } = win + + if (webContents && !webContents.isDestroyed()) { + webContents.send('hermes:power-battery', next) + } + } +} + function registerPowerResumeListeners() { if (powerResumeRegistered) { return @@ -5200,6 +5232,9 @@ function registerPowerResumeListeners() { // full suspend. Either can drop an idle socket. powerMonitor.on('resume', sendPowerResume) powerMonitor.on('unlock-screen', sendPowerResume) + powerMonitor.on('on-battery', () => broadcastBatteryState(true)) + powerMonitor.on('on-ac', () => broadcastBatteryState(false)) + onBatteryPower = powerMonitor.isOnBatteryPower() } catch { // powerMonitor is unavailable before app 'ready' on some platforms; the // caller registers after 'ready', so this should not normally throw. @@ -8697,6 +8732,7 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch? win.on('enter-full-screen', () => sendWindowStateChanged(true)) win.on('leave-full-screen', () => sendWindowStateChanged(false)) + streamThrottle.register(win) wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) loadWindowUrl( @@ -8739,7 +8775,7 @@ function nextInstanceBounds() { } // Open a new full-chrome instance window. Mirrors createWindow()'s window -// options (shared chatWindowWebPreferences keeps backgroundThrottling:false so a +// options (shared chatWindowWebPreferences + streamThrottle registration so a // streamed answer never stalls in the background) but is a peer, not the // primary: it never overwrites the mainWindow global, doesn't start the backend // (the renderer's getConnection() joins the already-running one), and loads the @@ -8780,6 +8816,7 @@ function createInstanceWindow() { win.on('enter-full-screen', () => sendWindowStateChanged(true, win)) win.on('leave-full-screen', () => sendWindowStateChanged(false, win)) + streamThrottle.register(win) wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) win.on('closed', () => { @@ -9162,10 +9199,11 @@ function createWindow() { // material before the renderer paints the app theme. See createSessionWindow. show: false, backgroundColor: getWindowBackgroundColor(), - // Shared with the secondary session windows (chatWindowWebPreferences) so - // both keep `backgroundThrottling: false` — the chat transcript uses a - // bounded timer flush that Chromium clamps for blurred windows, stalling - // the live answer until refocus. See session-windows.ts. + // Shared with the secondary session windows (chatWindowWebPreferences); + // stream-aware throttling is applied per-window via streamThrottle so a + // live answer keeps painting while the window is blurred or minimized, + // without pinning visibilityState to 'visible' at idle. See + // session-windows.ts and stream-throttle.ts. webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) @@ -9258,6 +9296,7 @@ function createWindow() { } }) + streamThrottle.register(mainWindow) wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat')) mainWindow.webContents.on('render-process-gone', (_event, details) => { @@ -10427,14 +10466,27 @@ ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWat // merged picture. Keyed by webContents id so a closed window stops counting. const activeWorkByWebContents = new Map() +// The same merged picture drives background throttling: chat windows run +// unthrottled while any turn is in flight (streaming must paint while hidden) +// and fall back to Chromium's default throttling at idle. See stream-throttle.ts. +const streamThrottle = createStreamThrottle() + +function updateStreamThrottleFromActiveWork() { + streamThrottle.update(mergeActiveWork(activeWorkByWebContents.values()).count > 0) +} + ipcMain.on('hermes:active-work', (event, payload) => { const id = event.sender.id if (!activeWorkByWebContents.has(id)) { - event.sender.once('destroyed', () => activeWorkByWebContents.delete(id)) + event.sender.once('destroyed', () => { + activeWorkByWebContents.delete(id) + updateStreamThrottleFromActiveWork() + }) } activeWorkByWebContents.set(id, normalizeActiveWork(payload)) + updateStreamThrottleFromActiveWork() }) ipcMain.on('hermes:titlebar-theme', (_event, payload) => { diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index a60ebb37621..a3dbfc04e4a 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -191,13 +191,16 @@ 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 clamps the - // transcript flush timer for backgrounded windows). +test('chatWindowWebPreferences leaves background throttling to the runtime stream dial', () => { + // Regression (both directions): a static `backgroundThrottling: false` here + // pinned document.visibilityState to 'visible' forever, turning every + // visibility-gated poll into an always-on timer (~20% CPU at idle, + // minimized). Streaming's "paint while blurred" need is served by + // stream-throttle.ts flipping setBackgroundThrottling at turn boundaries — + // so the static flag must stay absent. const prefs = chatWindowWebPreferences('/tmp/preload.cjs') - assert.equal(prefs.backgroundThrottling, false) + assert.equal('backgroundThrottling' in prefs, false) }) test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => { diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index 312deb3d1e3..81736908fce 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -13,14 +13,20 @@ 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.ts used to be -// hand-copied, and the secondary windows silently lost `backgroundThrottling: -// false`, so a streamed answer stalled until the window regained focus. +// hand-copied, and the secondary windows silently drifted apart (a streamed +// answer stalled until the window regained focus because one of them lost the +// throttling opt-out). // -// `backgroundThrottling: false` is load-bearing: the transcript streams to the -// screen through a bounded timer flush, which Chromium clamps 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. +// Background throttling is deliberately NOT set here. It is managed at runtime +// by main.ts (`setBackgroundThrottling` driven by the merged `hermes:active-work` +// reports): while any turn is in flight every chat window is unthrottled so the +// transcript's bounded timer flush keeps painting while blurred, occluded, or +// minimized — and once all turns finish, Chromium's default throttling returns +// so an idle hidden window costs ~nothing. A static `backgroundThrottling: +// false` here would pin `document.visibilityState` to 'visible' forever, +// turning every visibility-gated poll in the renderer into an always-on timer +// (the "Hermes idles at 20% CPU while minimized" bug). The preload path is +// injected because it depends on the Electron entry's __dirname. // // `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice: // Chromium's default autoplay policy suspends audio (HTMLAudioElement.play() @@ -39,7 +45,6 @@ function chatWindowWebPreferences(preloadPath: string) { sandbox: true, nodeIntegration: false, devTools: true, - backgroundThrottling: false, autoplayPolicy: 'no-user-gesture-required' as const } } diff --git a/apps/desktop/electron/stream-throttle.test.ts b/apps/desktop/electron/stream-throttle.test.ts new file mode 100644 index 00000000000..fc41bcf1681 --- /dev/null +++ b/apps/desktop/electron/stream-throttle.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { createStreamThrottle, type ThrottleWindowLike } from './stream-throttle' + +function makeTimers() { + const pending = new Map void>() + let nextId = 1 + + return { + clearTimeout: (handle: unknown) => { + pending.delete(handle as number) + }, + fire() { + const jobs = [...pending.values()] + pending.clear() + + for (const job of jobs) { + job() + } + }, + get pendingCount() { + return pending.size + }, + setTimeout: (fn: () => void, _ms: number) => { + const id = nextId++ + pending.set(id, fn) + + return id + } + } +} + +function makeWindow() { + const calls: boolean[] = [] + const listeners = new Map void>() + let destroyed = false + + const win = { + calls, + close() { + destroyed = true + listeners.get('closed')?.() + }, + isDestroyed: () => destroyed, + on(event: string, fn: () => void) { + listeners.set(event, fn) + }, + webContents: { + isDestroyed: () => destroyed, + setBackgroundThrottling(allowed: boolean) { + calls.push(allowed) + } + } + } + + return win +} + +test('registering a window applies the current throttle state immediately', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const idle = makeWindow() + throttle.register(idle) + + // Idle default: throttling allowed. + assert.deepEqual(idle.calls, [true]) + + throttle.update(true) + const late = makeWindow() + throttle.register(late) + + // A window created mid-stream starts unthrottled. + assert.deepEqual(late.calls, [false]) +}) + +test('a turn in flight unthrottles every chat window; settling re-throttles after the trailing delay', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const win = makeWindow() + throttle.register(win) + + throttle.update(true) + assert.deepEqual(win.calls, [true, false]) + assert.equal(throttle.isUnthrottled(), true) + + // Turn ends: not re-throttled synchronously — the tail flush needs full + // cadence — only after the trailing timer fires. + throttle.update(false) + assert.deepEqual(win.calls, [true, false]) + assert.equal(throttle.isUnthrottled(), true) + + timers.fire() + assert.deepEqual(win.calls, [true, false, true]) + assert.equal(throttle.isUnthrottled(), false) +}) + +test('a new turn during the trailing window cancels the pending re-throttle', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const win = makeWindow() + throttle.register(win) + + throttle.update(true) + throttle.update(false) + assert.equal(timers.pendingCount, 1) + + // Busy again before the delay elapses: stay unthrottled, timer cancelled. + throttle.update(true) + assert.equal(timers.pendingCount, 0) + assert.equal(throttle.isUnthrottled(), true) + + // The cancelled timer firing late must be a no-op. + timers.fire() + assert.equal(throttle.isUnthrottled(), true) +}) + +test('repeated busy reports do not re-apply or stack timers', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const win = makeWindow() + throttle.register(win) + + throttle.update(true) + throttle.update(true) + throttle.update(true) + assert.deepEqual(win.calls, [true, false]) + + throttle.update(false) + throttle.update(false) + assert.equal(timers.pendingCount, 1) +}) + +test('closed and destroyed windows drop out without throwing', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const closedWin = makeWindow() + throttle.register(closedWin) + closedWin.close() + + const gone: ThrottleWindowLike & { on?: never } = { + isDestroyed: () => true, + webContents: null + } + + throttle.register(gone) + + throttle.update(true) + // Only the registration-time call landed; nothing after close. + assert.deepEqual(closedWin.calls, [true]) +}) diff --git a/apps/desktop/electron/stream-throttle.ts b/apps/desktop/electron/stream-throttle.ts new file mode 100644 index 00000000000..912b40b9167 --- /dev/null +++ b/apps/desktop/electron/stream-throttle.ts @@ -0,0 +1,119 @@ +// Stream-aware background throttling for chat windows. +// +// Chat windows must paint the live transcript while blurred, occluded, or +// minimized — but a static `backgroundThrottling: false` in webPreferences +// costs far more than that feature needs: it pins the renderer's +// `document.visibilityState` to 'visible' for the life of the window, which +// turns every visibility-gated poll and clock tick in the renderer into an +// always-on timer. An idle, hidden Hermes burned ~20% CPU forever. +// +// So throttling is a runtime dial instead: the renderers already report +// "which chats are mid-turn" for the quit guard (`hermes:active-work`), and +// this controller rides the merged edge of those reports. Any turn in flight → +// every registered chat window gets `setBackgroundThrottling(false)`, exactly +// the streaming behavior the static flag used to provide. All turns done → +// after a short trailing delay (so tail flushes land at full cadence) Chromium's +// default throttling returns and hidden windows go quiet. +// +// Pure and Electron-free (timers + the WebContents surface are injected) so it +// can be unit-tested, mirroring session-windows.ts. + +/** How long after the last turn ends before throttling is restored. Covers the + * stream queue's final coalesced flush and the settle writes that trail a + * turn's completion, so re-throttling never strands a visible delta. */ +const RETHROTTLE_DELAY_MS = 5_000 + +export interface ThrottleWindowLike { + isDestroyed(): boolean + webContents?: { + isDestroyed(): boolean + setBackgroundThrottling(allowed: boolean): void + } | null +} + +interface TimersLike { + clearTimeout(handle: unknown): void + setTimeout(fn: () => void, ms: number): unknown +} + +export interface StreamThrottle { + /** True while windows are currently unthrottled (streaming or trailing). */ + isUnthrottled(): boolean + /** Track a chat window; applies the current state immediately and stops + * tracking on close. */ + register(win: ThrottleWindowLike & { on?: (event: string, fn: () => void) => void }): void + /** Report whether any turn is in flight across all renderers. */ + update(busy: boolean): void +} + +export function createStreamThrottle( + timers: TimersLike = { clearTimeout: handle => clearTimeout(handle as never), setTimeout }, + delayMs: number = RETHROTTLE_DELAY_MS +): StreamThrottle { + const windows = new Set() + let unthrottled = false + let trailing: unknown = null + + function apply(win: ThrottleWindowLike) { + if (win.isDestroyed()) { + windows.delete(win) + + return + } + + const contents = win.webContents + + if (!contents || contents.isDestroyed()) { + return + } + + try { + contents.setBackgroundThrottling(!unthrottled) + } catch { + // A window mid-teardown can throw; it's about to leave the set anyway. + } + } + + function applyAll() { + for (const win of windows) { + apply(win) + } + } + + return { + isUnthrottled: () => unthrottled, + + register(win) { + windows.add(win) + win.on?.('closed', () => windows.delete(win)) + apply(win) + }, + + update(busy) { + if (busy) { + if (trailing !== null) { + timers.clearTimeout(trailing) + trailing = null + } + + if (!unthrottled) { + unthrottled = true + applyAll() + } + + return + } + + if (!unthrottled || trailing !== null) { + return + } + + // Trailing edge: keep full cadence briefly so the final flush paints. + trailing = timers.setTimeout(() => { + trailing = null + unthrottled = false + applyAll() + }, delayMs) + } + } +} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index d5ec67d384e..7aa89a8cf14 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -273,18 +273,20 @@ export function useMessageStream({ // Always a timer, never requestAnimationFrame. Chromium pauses rAF for a // renderer it considers hidden, and "hidden" is not something this code can - // verify: `backgroundThrottling: false` plus the process-level switches in - // electron/main.ts cover the blurred and occluded cases, but they don't - // cover a minimized window, a fully off-screen one, or a renderer the - // compositor has otherwise parked. In those states an rAF-gated flush never - // runs, so a finished answer sits in this queue until some later input or - // focus event happens to wake a frame — the reply looks stalled, then - // arrives all at once on refocus. + // verify: while a turn is in flight the main process unthrottles every chat + // window (stream-throttle.ts), but that doesn't guarantee frames for a + // minimized window, a fully off-screen one, or a renderer the compositor + // has otherwise parked. In those states an rAF-gated flush never runs, so a + // finished answer sits in this queue until some later input or focus event + // happens to wake a frame — the reply looks stalled, then arrives all at + // once on refocus. // // A timer keeps the same coalescing cadence (that's what the floor above is // for) while guaranteeing delivery without user interaction. Timers are - // clamped in background renderers rather than suspended, and - // disable-background-timer-throttling already opts out of that clamp. + // clamped in background renderers rather than suspended, and the + // stream-aware unthrottle lifts even that clamp for the life of the turn; + // in the worst case (a delta arriving before the unthrottle lands) the + // clamp only stretches one flush to ~1s in a window nobody can see. flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, adaptiveFloor - sinceLast)) }, [flushQueuedDeltas]) From be7c4b8fe744e5e3a38f39461db85073d524be40 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 23:56:16 -0500 Subject: [PATCH 2/3] perf(desktop): let the hidden link-title window throttle It loads arbitrary user-linked pages offscreen; unthrottled, a heavy page burns full CPU for the window's whole lifetime. Title resolution rides load events and main-process timers, which throttling doesn't touch. --- apps/desktop/electron/link-title-window.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/desktop/electron/link-title-window.ts b/apps/desktop/electron/link-title-window.ts index 0920976a528..226ac3ce60e 100644 --- a/apps/desktop/electron/link-title-window.ts +++ b/apps/desktop/electron/link-title-window.ts @@ -9,7 +9,13 @@ export function linkTitleWindowOptions(partitionSession) { width: 1280, height: 800, webPreferences: { - backgroundThrottling: false, + // Deliberately throttled: this hidden window loads arbitrary user-linked + // pages, and an unthrottled heavy page burns full CPU for the window's + // whole lifetime. Title resolution rides load events + // (page-title-updated / did-finish-load) plus main-process timers, none + // of which the renderer clamp touches — hidden-page throttling only + // slows the page's own timer-driven JS, and the grace window already + // absorbs that. contextIsolation: true, javascript: true, nodeIntegration: false, From 044800e3587601d8b6b8d0a78dccfcaafe479a02 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 23:56:16 -0500 Subject: [PATCH 3/3] perf(desktop): stretch backstop polls while on battery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit powerMonitor's AC/battery state is mirrored to the renderers (store/power.ts) and visiblePoll quadruples its cadence on battery. Only the safety-net refreshes slow down — event-driven refreshes and live streaming are untouched. --- apps/desktop/electron/preload.ts | 8 +++++ .../app/contrib/hooks/use-background-sync.ts | 15 ++++++++-- apps/desktop/src/global.d.ts | 2 ++ apps/desktop/src/main.tsx | 2 ++ apps/desktop/src/store/power.ts | 30 +++++++++++++++++++ 5 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/store/power.ts diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 8822efd2a99..8fb6d97c3fa 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -275,6 +275,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', { return () => ipcRenderer.removeListener('hermes:power-resume', listener) }, + // AC ↔ battery transitions; renderers slow their backstop polls on battery. + getOnBattery: () => ipcRenderer.invoke('hermes:power-battery:get'), + onBatteryChanged: callback => { + const listener = (_event, onBattery) => callback(Boolean(onBattery)) + ipcRenderer.on('hermes:power-battery', listener) + + return () => ipcRenderer.removeListener('hermes:power-battery', listener) + }, onBootProgress: callback => { const listener = (_event, payload) => callback(payload) ipcRenderer.on('hermes:boot-progress', listener) diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts index f176d7f7867..06dc49b56b4 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -3,6 +3,7 @@ import { useEffect } from 'react' import { createClientSessionState } from '@/lib/chat-runtime' import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync' +import { $onBattery, batteryPollInterval } from '@/store/power' import { refreshActiveProfile } from '@/store/profile' import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session' import { @@ -180,7 +181,10 @@ interface BackgroundSyncParams { } /** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab - * re-focus. Returns nothing — meant to live inside an effect. */ + * re-focus. On battery the cadence stretches (see store/power) — these are + * safety-net refreshes, not the live path, so they're the right thing to slow + * when the machine is spending its charge. Returns nothing — meant to live + * inside an effect. */ function visiblePoll(intervalMs: number, tick: () => void): () => void { const run = () => { if (document.visibilityState === 'visible') { @@ -188,10 +192,17 @@ function visiblePoll(intervalMs: number, tick: () => void): () => void { } } - const intervalId = window.setInterval(run, intervalMs) + let intervalId = window.setInterval(run, batteryPollInterval(intervalMs, $onBattery.get())) + + const unsubscribeBattery = $onBattery.listen(onBattery => { + window.clearInterval(intervalId) + intervalId = window.setInterval(run, batteryPollInterval(intervalMs, onBattery)) + }) + document.addEventListener('visibilitychange', run) return () => { + unsubscribeBattery() window.clearInterval(intervalId) document.removeEventListener('visibilitychange', run) } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index d2804298fa2..f266f13aff1 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -256,6 +256,8 @@ declare global { // reload. Wipe session lists (skeletons) and re-dial. onConnectionApplied?: (callback: () => void) => () => void onPowerResume?: (callback: () => void) => () => void + getOnBattery?: () => Promise + onBatteryChanged?: (callback: (onBattery: boolean) => void) => () => void onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void getBootstrapState: () => Promise continueBootstrapLocal: () => Promise<{ ok: boolean }> diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx index 9a14e26e457..f2546d0c01a 100644 --- a/apps/desktop/src/main.tsx +++ b/apps/desktop/src/main.tsx @@ -1,6 +1,8 @@ import './styles.css' // Side-effect: reports in-flight turns to the main process for the quit guard. import './store/active-work' +// Side-effect: mirrors the machine's AC/battery state for poll demotion. +import './store/power' // Side-effect: applies the persisted window translucency on load. import './store/translucency' // Dev-only render/state churn counters. MUST precede the `react-dom` import diff --git a/apps/desktop/src/store/power.ts b/apps/desktop/src/store/power.ts new file mode 100644 index 00000000000..4d6f5e2bb81 --- /dev/null +++ b/apps/desktop/src/store/power.ts @@ -0,0 +1,30 @@ +/** + * AC ↔ battery mirror of the main process's powerMonitor. + * + * Backstop polls are already event-demoted and visibility-gated; battery is + * the third gate — on battery their cadence stretches (×4) so a laptop + * running Hermes in the background isn't spending its charge on safety-net + * refreshes. Live streaming and event-driven refreshes are untouched: this + * only slows timers whose job is catching what a degraded socket missed. + * + * Imported for its side effect from `main.tsx` (same pattern as + * store/active-work). + */ + +import { atom } from 'nanostores' + +export const $onBattery = atom(false) + +/** Multiply a backstop poll interval by this on battery. */ +export const BATTERY_POLL_MULTIPLIER = 4 + +export function batteryPollInterval(intervalMs: number, onBattery: boolean): number { + return onBattery ? intervalMs * BATTERY_POLL_MULTIPLIER : intervalMs +} + +if (typeof window !== 'undefined') { + const desktop = window.hermesDesktop + + void desktop?.getOnBattery?.().then(onBattery => $onBattery.set(onBattery)) + desktop?.onBatteryChanged?.(onBattery => $onBattery.set(onBattery)) +}