perf(desktop): pause background UI work while unfocused

This commit is contained in:
AlexDev_
2026-08-08 19:51:16 +02:00
committed by Teknium
parent 5bceb3e84b
commit bee0d45ddf
8 changed files with 173 additions and 36 deletions
@@ -17,7 +17,8 @@ import {
reconcileActiveTranscript,
rehydrateLiveSessionStatuses,
resolveActiveTranscriptSession,
useBackgroundSync
useBackgroundSync,
windowIsActivelyViewed
} from './use-background-sync'
vi.mock('@/hermes', async importOriginal => ({
@@ -305,6 +306,14 @@ describe('reconcileActiveTranscript', () => {
})
})
describe('windowIsActivelyViewed', () => {
it('requires both DOM visibility and keyboard focus', () => {
expect(windowIsActivelyViewed({ focused: true, visibilityState: 'visible' })).toBe(true)
expect(windowIsActivelyViewed({ focused: false, visibilityState: 'visible' })).toBe(false)
expect(windowIsActivelyViewed({ focused: true, visibilityState: 'hidden' })).toBe(false)
})
})
describe('rehydrateLiveSessionStatuses', () => {
it('restores running sessions after reconnect without opening them', () => {
const now = 1_800_000_000_000
@@ -296,9 +296,24 @@ interface BackgroundSyncParams {
* 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. */
export function windowIsActivelyViewed({
focused,
visibilityState
}: {
focused: boolean
visibilityState: DocumentVisibilityState
}): boolean {
return visibilityState === 'visible' && focused
}
function visiblePoll(intervalMs: number, tick: () => void): () => void {
const run = () => {
if (document.visibilityState === 'visible') {
// On macOS an unfocused or app-hidden BrowserWindow commonly remains
// `visibilityState === "visible"`. Visibility alone therefore kept every
// safety-net gateway poll alive while the user was in another app. These
// are stale-data backstops, not the live event path, so pause them until
// the window is actually being viewed and catch up immediately on focus.
if (windowIsActivelyViewed({ focused: document.hasFocus(), visibilityState: document.visibilityState })) {
tick()
}
}
@@ -311,11 +326,13 @@ function visiblePoll(intervalMs: number, tick: () => void): () => void {
})
document.addEventListener('visibilitychange', run)
window.addEventListener('focus', run)
return () => {
unsubscribeBattery()
window.clearInterval(intervalId)
document.removeEventListener('visibilitychange', run)
window.removeEventListener('focus', run)
}
}
@@ -31,6 +31,7 @@ async function flushAsync() {
beforeEach(() => {
vi.useFakeTimers()
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
vi.mocked(getStatus)
.mockReset()
.mockResolvedValue({} as never)
@@ -38,10 +39,34 @@ beforeEach(() => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('useStatusSnapshot', () => {
it('pauses status RPCs while visible but unfocused, then catches up on focus', async () => {
vi.mocked(document.hasFocus).mockReturnValue(false)
const requestGateway = vi.fn().mockResolvedValue({}) as unknown as GatewayRequester
renderHook(() => useStatusSnapshot('open', requestGateway))
await flushAsync()
expect(getStatus).not.toHaveBeenCalled()
expect(requestGateway).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
expect(getStatus).not.toHaveBeenCalled()
vi.mocked(document.hasFocus).mockReturnValue(true)
window.dispatchEvent(new Event('focus'))
await flushAsync()
expect(getStatus).toHaveBeenCalledOnce()
expect(requestGateway).toHaveBeenCalledTimes(2)
})
it('keeps the last authoritative readiness through a transient RPC failure', async () => {
let refresh = 0
@@ -5,9 +5,8 @@ import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/run
import type { StatusResponse } from '@/types/hermes'
// Statusbar health is ambient chrome, not live data — nothing the user acts on
// within seconds. 60s + a hidden-tab skip keeps it honest at a quarter of the
// old traffic; the visibility listener refreshes immediately on return so a
// backgrounded window never shows stale health after re-focus.
// within seconds. 60s + an actively-viewed check keeps traffic low; focus and
// visibility listeners refresh immediately on return.
const REFRESH_MS = 60_000
type GatewayRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
@@ -34,9 +33,10 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
}
const refresh = async () => {
// Hidden window: skip the round-trips, keep the timer alive; the
// visibilitychange listener repaints immediately on return.
if (document.visibilityState !== 'visible') {
// macOS commonly leaves an occluded BrowserWindow `visible`; focus is
// the missing signal that prevents status + readiness RPCs while the
// user is working in another app.
if (document.visibilityState !== 'visible' || !document.hasFocus()) {
scheduleRefresh()
return
@@ -79,8 +79,8 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
}
}
const onVisible = () => {
if (document.visibilityState === 'visible' && !cancelled) {
const onReturn = () => {
if (document.visibilityState === 'visible' && document.hasFocus() && !cancelled) {
if (timer !== undefined) {
window.clearTimeout(timer)
}
@@ -89,12 +89,14 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
}
}
document.addEventListener('visibilitychange', onVisible)
document.addEventListener('visibilitychange', onReturn)
window.addEventListener('focus', onReturn)
void refresh()
return () => {
cancelled = true
document.removeEventListener('visibilitychange', onVisible)
document.removeEventListener('visibilitychange', onReturn)
window.removeEventListener('focus', onReturn)
if (timer !== undefined) {
window.clearTimeout(timer)
@@ -19,10 +19,12 @@ describe('useElapsedSeconds', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
__resetElapsedTimerRegistryForTests()
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
__resetElapsedTimerRegistryForTests()
})
@@ -72,16 +74,33 @@ describe('useElapsedSeconds', () => {
expect(screen.getByTestId('elapsed').textContent).toBe('0')
})
it('pauses UI ticks without focus and catches up immediately on return', () => {
render(<Probe active timerKey="tool:background" />)
vi.mocked(document.hasFocus).mockReturnValue(false)
window.dispatchEvent(new Event('blur'))
act(() => {
vi.advanceTimersByTime(5_000)
})
expect(screen.getByTestId('elapsed').textContent).toBe('0')
vi.mocked(document.hasFocus).mockReturnValue(true)
act(() => window.dispatchEvent(new Event('focus')))
expect(screen.getByTestId('elapsed').textContent).toBe('5')
})
})
describe('useMeasuredDuration', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'))
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
__resetElapsedTimerRegistryForTests()
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
__resetElapsedTimerRegistryForTests()
})
@@ -153,4 +172,17 @@ describe('useMeasuredDuration', () => {
expect(screen.getByTestId('measured').textContent).toBe('2')
})
it('records the real finish time even if the UI clock was paused', () => {
const probe = render(<DurationProbe active timerKey="reasoning:background" />)
vi.mocked(document.hasFocus).mockReturnValue(false)
window.dispatchEvent(new Event('blur'))
act(() => {
vi.advanceTimersByTime(5_000)
})
probe.rerender(<DurationProbe active={false} timerKey="reasoning:background" />)
expect(screen.getByTestId('measured').textContent).toBe('5')
})
})
@@ -1,5 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useViewedInterval } from '@/hooks/use-viewed-interval'
// Module-level registry so timers survive component unmount/remount (e.g.
// when a tool row scrolls out and back). Keyed by caller-supplied timerKey;
// anonymous timers (no key) start fresh each mount.
@@ -53,25 +55,25 @@ export function useElapsedSeconds(active = true, timerKey?: string, since?: numb
lastKey.current = timerKey
}
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
// eslint-disable-next-line no-restricted-syntax -- timer origin is imperative state, not an atom mirror
useEffect(() => {
if (!active) {
return
}
if (since !== undefined) {
start.current = since
} else if (timerKey) {
start.current = startedAt(timerKey)
}
const tick = () => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
tick()
const id = window.setInterval(tick, 1000)
return () => window.clearInterval(id)
if (active) {
setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000)))
}
}, [active, since, timerKey])
useViewedInterval(
() => setElapsed(Math.max(0, Math.floor((Date.now() - start.current) / 1000))),
1000,
active
)
return elapsed
}
@@ -100,9 +102,11 @@ export function useMeasuredDuration(active: boolean, timerKey: string): null | n
if (active) {
setWatching(true)
} else if (watching) {
const finalElapsed = Math.max(elapsed, Math.floor((Date.now() - startedAt(timerKey)) / 1000))
setWatching(false)
durationByKey.set(timerKey, elapsed)
setMeasured(elapsed)
durationByKey.set(timerKey, finalElapsed)
setMeasured(finalElapsed)
}
}, [active, elapsed, timerKey, watching])
@@ -0,0 +1,57 @@
import { useEffect, useRef } from 'react'
/** Run a UI-only clock while this document is actually being viewed.
*
* macOS can leave an occluded BrowserWindow `visible`, and active streaming
* deliberately disables Chromium's background timer throttling. Pairing focus
* with visibility avoids waking React for elapsed labels nobody can see while
* a leading tick on return catches the UI up immediately.
*/
export function useViewedInterval(callback: () => void, intervalMs: number, enabled = true): void {
const callbackRef = useRef(callback)
// eslint-disable-next-line no-restricted-syntax -- latest-callback ref avoids restarting the interval each render
useEffect(() => {
callbackRef.current = callback
}, [callback])
useEffect(() => {
if (!enabled) {
return
}
let intervalId: null | number = null
const stop = () => {
if (intervalId !== null) {
window.clearInterval(intervalId)
intervalId = null
}
}
const sync = () => {
const viewed = document.visibilityState === 'visible' && document.hasFocus()
if (!viewed) {
stop()
return
}
if (intervalId === null) {
callbackRef.current()
intervalId = window.setInterval(() => callbackRef.current(), intervalMs)
}
}
window.addEventListener('focus', sync)
window.addEventListener('blur', sync)
document.addEventListener('visibilitychange', sync)
sync()
return () => {
stop()
window.removeEventListener('focus', sync)
window.removeEventListener('blur', sync)
document.removeEventListener('visibilitychange', sync)
}
}, [enabled, intervalMs])
}
+3 -12
View File
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { StableText } from '@/components/chat/stable-text'
import { useViewedInterval } from '@/hooks/use-viewed-interval'
import { compactNumber } from '@/lib/format'
import type { UsageStats } from '@/types/hermes'
@@ -61,17 +62,7 @@ export function contextBarLabel(usage: UsageStats): string {
export function LiveDuration({ since }: { since: number | null | undefined }) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!since) {
return
}
const tick = () => setNow(Date.now())
tick()
const timer = window.setInterval(tick, 1000)
return () => window.clearInterval(timer)
}, [since])
useViewedInterval(() => setNow(Date.now()), 1000, Boolean(since))
if (!since) {
return null