fix(desktop): suspend decorative work when inactive

This commit is contained in:
Andy 2026-07-17 15:16:43 +08:00 committed by Brooklyn Nicholson
parent 5c8ac975e7
commit 651a313d2b
11 changed files with 238 additions and 16 deletions

View file

@ -377,9 +377,9 @@ if (IS_WINDOWS) {
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)
// Keep the renderer running at full speed while the window is in the background
// or occluded. The chat transcript streams to screen through a
// requestAnimationFrame-gated flush; Chromium pauses rAF (and clamps timers)
// for backgrounded/occluded renderers, so without these the live answer stalls
// or occluded. The chat transcript streams to screen through a bounded timer
// flush; Chromium clamps timers for backgrounded/occluded renderers, so without
// these the live answer stalls
// whenever the window loses focus (switching to your editor mid-turn, detached
// devtools, another window covering it) and only paints on refocus or refresh.
// `backgroundThrottling: false` on the BrowserWindow covers the blurred case;
@ -8900,9 +8900,9 @@ function createWindow() {
show: false,
backgroundColor: getWindowBackgroundColor(),
// Shared with the secondary session windows (chatWindowWebPreferences) so
// both keep `backgroundThrottling: false` — the chat transcript streams via
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
// windows, stalling the live answer until refocus. See session-windows.ts.
// both keep `backgroundThrottling: false` — the chat transcript uses a
// bounded timer flush that Chromium clamps for blurred windows, stalling
// the live answer until refocus. See session-windows.ts.
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
})

View file

@ -193,8 +193,8 @@ test('registry trims the session id before keying', () => {
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
// Regression: secondary session windows used to omit this flag, so a streamed
// answer stalled until the window regained focus (Chromium pauses the
// requestAnimationFrame-gated transcript flush for backgrounded windows).
// answer stalled until the window regained focus (Chromium clamps the
// transcript flush timer for backgrounded windows).
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
assert.equal(prefs.backgroundThrottling, false)

View file

@ -17,8 +17,8 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// false`, so a streamed answer stalled until the window regained focus.
//
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
// blurred/occluded windows. A streaming chat app must keep painting in the
// screen through a bounded timer flush, which Chromium clamps for blurred/
// occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath: string) {

View file

@ -433,7 +433,7 @@ export function PetOverlayApp() {
<PetBubble />
</div>
<div style={{ lineHeight: 0, position: 'relative' }}>
<PetSprite info={info} />
<PetSprite info={info} pauseWhenUnfocused={false} />
{/* Hearts on the popped-out pet — identical to in-window. */}
<PetHeartField

View file

@ -124,6 +124,7 @@ describe('PersistentTerminal rect tracking', () => {
beforeEach(() => {
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
setVisibility(false)
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
installWindowStateBridge()
resizeObserverCallback = null
mutationObserverCallback = null
@ -262,4 +263,28 @@ describe('PersistentTerminal rect tracking', () => {
expect(raf.request).toHaveBeenCalledTimes(2)
})
it('suspends while unfocused and cancels every callback on unmount', () => {
const raf = installRaf()
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
render(<Harness />)
expect(raf.pending()).toBe(1)
act(() => window.dispatchEvent(new Event('blur')))
expect(raf.pending()).toBe(0)
act(() => window.dispatchEvent(new Event('focus')))
expect(raf.pending()).toBe(1)
cleanup()
expect(raf.pending()).toBe(0)
act(() => {
window.dispatchEvent(new Event('focus'))
resizeObserverCallback?.([], {} as ResizeObserver)
mutationObserverCallback?.([], {} as MutationObserver)
})
expect(raf.pending()).toBe(0)
})
})

View file

@ -153,6 +153,7 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
: new ResizeObserver(() => {
scheduleMeasure()
})
const positionObserver =
typeof MutationObserver === 'undefined'
? null
@ -163,7 +164,9 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
if (measure()) {
scheduleMeasure()
}
observer?.observe(slot)
for (let node: HTMLElement | null = slot; node; node = node.parentElement) {
positionObserver?.observe(node, {
attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-state'],
@ -171,6 +174,7 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
childList: true
})
}
window.addEventListener('resize', scheduleMeasure)
window.addEventListener('scroll', scheduleMeasure, true)
pauseController = createRendererLoopPauseController(handleVisibilityChange)

View file

@ -0,0 +1,111 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { useMessageStream } from './index'
const SID = 'session-1'
let appendAssistantDelta: ((sessionId: string, delta: string) => void) | null = null
let states: Map<string, ClientSessionState>
type UpdateSessionState = (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
let updateSessionState: ReturnType<typeof vi.fn<UpdateSessionState>>
function Harness() {
const activeSessionIdRef = useRef<string | null>(SID)
const sessionStateByRuntimeIdRef = useRef(states)
const queryClientRef = useRef(new QueryClient())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient: queryClientRef.current,
refreshHermesConfig: vi.fn(async () => undefined),
refreshSessions: vi.fn(async () => undefined),
sessionStateByRuntimeIdRef,
updateSessionState
})
useEffect(() => {
appendAssistantDelta = stream.appendAssistantDelta
}, [stream.appendAssistantDelta])
return null
}
function mountStream() {
render(<Harness />)
expect(appendAssistantDelta).not.toBeNull()
}
function assistantText() {
const message = states.get(SID)?.messages.at(-1)
const part = message?.parts.at(-1)
return part?.type === 'text' ? part.text : ''
}
describe('useMessageStream delta flush scheduling', () => {
beforeEach(() => {
vi.useFakeTimers()
appendAssistantDelta = null
states = new Map()
updateSessionState = vi.fn((sessionId: string, updater: (state: ClientSessionState) => ClientSessionState) => {
const next = updater(states.get(sessionId) ?? createClientSessionState())
states.set(sessionId, next)
return next
})
vi.spyOn(performance, 'now').mockReturnValue(100)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined)
vi.spyOn(document, 'hasFocus').mockReturnValue(false)
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('flushes streaming text on a bounded timer while the window is unfocused', async () => {
mountStream()
act(() => appendAssistantDelta!(SID, 'still streaming'))
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
expect(assistantText()).toBe('')
await act(async () => {
await vi.advanceTimersByTimeAsync(0)
})
expect(assistantText()).toBe('still streaming')
})
it('cancels the pending timer on unmount and flushes exactly once', async () => {
vi.mocked(performance.now).mockReturnValue(0)
mountStream()
act(() => appendAssistantDelta!(SID, 'final delta'))
expect(vi.getTimerCount()).toBe(1)
cleanup()
expect(vi.getTimerCount()).toBe(0)
expect(assistantText()).toBe('final delta')
const updatesAfterUnmount = updateSessionState.mock.calls.length
await vi.advanceTimersByTimeAsync(100)
expect(updateSessionState).toHaveBeenCalledTimes(updatesAfterUnmount)
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
})
})

View file

@ -120,6 +120,7 @@ describe('PetSprite RAF scheduling', () => {
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers()
setVisibility(false)
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
installWindowStateBridge()
vi.stubGlobal(
'Image',
@ -187,4 +188,39 @@ describe('PetSprite RAF scheduling', () => {
expect(raf.request).toHaveBeenCalledTimes(2)
})
it('suspends while unfocused, resumes on focus, and leaves no work after unmount', () => {
const raf = installRaf()
render(<PetSprite info={INFO} />)
act(() => window.dispatchEvent(new Event('blur')))
expect(raf.pending()).toBe(0)
act(() => window.dispatchEvent(new Event('focus')))
expect(raf.pending()).toBe(1)
act(() => raf.runNext(0))
expect(vi.getTimerCount()).toBe(1)
cleanup()
expect(raf.pending()).toBe(0)
expect(vi.getTimerCount()).toBe(0)
act(() => {
vi.advanceTimersByTime(500)
window.dispatchEvent(new Event('focus'))
})
expect(raf.pending()).toBe(0)
})
it('keeps the intentionally non-activating pop-out overlay animated while unfocused', () => {
const raf = installRaf()
render(<PetSprite info={INFO} pauseWhenUnfocused={false} />)
act(() => window.dispatchEvent(new Event('blur')))
expect(raf.pending()).toBe(1)
})
})

View file

@ -92,6 +92,8 @@ export function roamWalkRow(dir: -1 | 0 | 1, stateRows?: string[]): { row?: stri
interface PetSpriteProps {
info: PetInfo
/** Keep animating in a deliberately non-activating visible window, such as the pop-out pet overlay. */
pauseWhenUnfocused?: boolean
/** On-screen scale multiplier applied on top of the pet's native scale. */
zoom?: number
/**
@ -115,7 +117,7 @@ interface PetSpriteProps {
* with `memo`, this component effectively never re-renders after mount until
* the pet itself changes.
*/
function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSpriteProps) {
function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride, pauseWhenUnfocused = true }: PetSpriteProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const stateRef = useRef<PetState>($petState.get())
const overrideRef = useRef<PetState | undefined>(stateOverride)
@ -340,7 +342,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
})
image.addEventListener('load', kickAnimation)
pauseController = createRendererLoopPauseController(handleVisibilityChange)
pauseController = createRendererLoopPauseController(handleVisibilityChange, { pauseWhenUnfocused })
scheduleFrame()
return () => {
@ -351,7 +353,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
pauseController?.dispose()
unsubState()
}
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows])
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows, pauseWhenUnfocused])
return (
<canvas

View file

@ -90,6 +90,7 @@ describe('usePetRoam RAF scheduling', () => {
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
vi.useFakeTimers()
setVisibility(false)
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
installWindowStateBridge()
vi.spyOn(Math, 'random').mockReturnValue(0)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
@ -139,4 +140,27 @@ describe('usePetRoam RAF scheduling', () => {
expect(raf.request).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(1)
})
it('suspends idle movement while unfocused and cleans up its wake timer on unmount', () => {
const raf = installRaf()
render(<RoamHarness />)
expect(vi.getTimerCount()).toBe(1)
act(() => window.dispatchEvent(new Event('blur')))
expect(vi.getTimerCount()).toBe(0)
act(() => window.dispatchEvent(new Event('focus')))
expect(vi.getTimerCount()).toBe(1)
cleanup()
expect(vi.getTimerCount()).toBe(0)
act(() => {
vi.advanceTimersByTime(2000)
window.dispatchEvent(new Event('focus'))
})
expect(raf.request).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
})

View file

@ -3,10 +3,26 @@ interface WindowStatePayload {
isVisible?: boolean
}
export function createRendererLoopPauseController(onChange: () => void) {
export function createRendererLoopPauseController(onChange: () => void, { pauseWhenUnfocused = true } = {}) {
let windowPaused = false
let windowFocused = document.hasFocus()
const onVisibilityChange = () => onChange()
const onBlur = () => {
if (windowFocused) {
windowFocused = false
onChange()
}
}
const onFocus = () => {
if (!windowFocused) {
windowFocused = true
onChange()
}
}
const offWindowState = window.hermesDesktop?.onWindowStateChanged?.((payload: WindowStatePayload) => {
const next = payload?.isMinimized === true || payload?.isVisible === false
@ -19,12 +35,16 @@ export function createRendererLoopPauseController(onChange: () => void) {
})
document.addEventListener('visibilitychange', onVisibilityChange)
window.addEventListener('blur', onBlur)
window.addEventListener('focus', onFocus)
return {
dispose: () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
window.removeEventListener('blur', onBlur)
window.removeEventListener('focus', onFocus)
offWindowState?.()
},
isPaused: () => document.visibilityState === 'hidden' || windowPaused
isPaused: () => document.visibilityState === 'hidden' || (pauseWhenUnfocused && !windowFocused) || windowPaused
}
}