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