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 (