diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts index 4cb9f0bbc7..47a67e6be2 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts @@ -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 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 1776d64710..0be9bacb95 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -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) } } diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts index 1c05ac0582..ce3db7e829 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts @@ -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 diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts index d2b714904b..d9391dae20 100644 --- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts +++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts @@ -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 = (method: string, params?: Record) => Promise @@ -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) diff --git a/apps/desktop/src/components/chat/activity-timer.test.tsx b/apps/desktop/src/components/chat/activity-timer.test.tsx index 02be879856..203b74e5f6 100644 --- a/apps/desktop/src/components/chat/activity-timer.test.tsx +++ b/apps/desktop/src/components/chat/activity-timer.test.tsx @@ -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() + 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() + vi.mocked(document.hasFocus).mockReturnValue(false) + window.dispatchEvent(new Event('blur')) + + act(() => { + vi.advanceTimersByTime(5_000) + }) + probe.rerender() + + expect(screen.getByTestId('measured').textContent).toBe('5') + }) }) diff --git a/apps/desktop/src/components/chat/activity-timer.ts b/apps/desktop/src/components/chat/activity-timer.ts index 6eddba167f..cb040e7673 100644 --- a/apps/desktop/src/components/chat/activity-timer.ts +++ b/apps/desktop/src/components/chat/activity-timer.ts @@ -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]) diff --git a/apps/desktop/src/hooks/use-viewed-interval.ts b/apps/desktop/src/hooks/use-viewed-interval.ts new file mode 100644 index 0000000000..7b99422672 --- /dev/null +++ b/apps/desktop/src/hooks/use-viewed-interval.ts @@ -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]) +} diff --git a/apps/desktop/src/lib/statusbar.tsx b/apps/desktop/src/lib/statusbar.tsx index 0c0d22599c..01ca3b645a 100644 --- a/apps/desktop/src/lib/statusbar.tsx +++ b/apps/desktop/src/lib/statusbar.tsx @@ -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