From 651a313d2b8f9cf0732bea66a0cba46cc53175e8 Mon Sep 17 00:00:00 2001 From: Andy <51783311+andyylin@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:16:43 +0800 Subject: [PATCH] fix(desktop): suspend decorative work when inactive --- apps/desktop/electron/main.ts | 12 +- apps/desktop/electron/session-windows.test.ts | 4 +- apps/desktop/electron/session-windows.ts | 4 +- .../src/app/pet-overlay/pet-overlay-app.tsx | 2 +- .../terminal/persistent.test.tsx | 25 ++++ .../app/right-sidebar/terminal/persistent.tsx | 4 + .../use-message-stream/delta-flush.test.tsx | 111 ++++++++++++++++++ .../src/components/pet/pet-sprite.test.tsx | 36 ++++++ .../desktop/src/components/pet/pet-sprite.tsx | 8 +- .../src/components/pet/use-pet-roam.test.tsx | 24 ++++ apps/desktop/src/lib/renderer-loop-pause.ts | 24 +++- 11 files changed, 238 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/delta-flush.test.tsx diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d6fcb69d7f2d..e19ccf3db3ab 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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) }) diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index 5167593bfbf8..b5f9cc6a8bef 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -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) diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index 48597ee9e0ea..46871b5384ea 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -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) { diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx index 875ef0ca01fb..928294c4825c 100644 --- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -433,7 +433,7 @@ export function PetOverlayApp() {
- + {/* Hearts on the popped-out pet — identical to in-window. */} { 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() + 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) + }) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx index 4165449e959f..5827ec0a8795 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx @@ -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) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/delta-flush.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/delta-flush.test.tsx new file mode 100644 index 000000000000..1a572c329a52 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/delta-flush.test.tsx @@ -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 +type UpdateSessionState = ( + sessionId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: string | null +) => ClientSessionState +let updateSessionState: ReturnType> + +function Harness() { + const activeSessionIdRef = useRef(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() + 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() + }) +}) diff --git a/apps/desktop/src/components/pet/pet-sprite.test.tsx b/apps/desktop/src/components/pet/pet-sprite.test.tsx index bee16e2a9ba2..6da30fdd5f7d 100644 --- a/apps/desktop/src/components/pet/pet-sprite.test.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.test.tsx @@ -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() + + 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() + + act(() => window.dispatchEvent(new Event('blur'))) + + expect(raf.pending()).toBe(1) + }) }) diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx index bb00b54bf6d4..9e79dc9953ce 100644 --- a/apps/desktop/src/components/pet/pet-sprite.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -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(null) const stateRef = useRef($petState.get()) const overrideRef = useRef(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 ( { ;(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() + 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) + }) }) diff --git a/apps/desktop/src/lib/renderer-loop-pause.ts b/apps/desktop/src/lib/renderer-loop-pause.ts index 3175069216e2..88b9e3559bc7 100644 --- a/apps/desktop/src/lib/renderer-loop-pause.ts +++ b/apps/desktop/src/lib/renderer-loop-pause.ts @@ -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 } }