perf(desktop): stretch backstop polls while on battery

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 23:56:16 -05:00
parent be7c4b8fe7
commit 044800e358
5 changed files with 55 additions and 2 deletions

View file

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

View file

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

View file

@ -256,6 +256,8 @@ declare global {
// reload. Wipe session lists (skeletons) and re-dial.
onConnectionApplied?: (callback: () => void) => () => void
onPowerResume?: (callback: () => void) => () => void
getOnBattery?: () => Promise<boolean>
onBatteryChanged?: (callback: (onBattery: boolean) => void) => () => void
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
getBootstrapState: () => Promise<DesktopBootstrapState>
continueBootstrapLocal: () => Promise<{ ok: boolean }>

View file

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

View file

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