perf(desktop): pause background UI work while unfocused
This commit is contained in:
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user