From e556de4324fdd0d8f5a2ce4c3627a34d3bddc82d Mon Sep 17 00:00:00 2001
From: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
Date: Wed, 8 Jul 2026 12:13:09 -0700
Subject: [PATCH 1/4] fix(desktop): gate idle renderer loops
Signed-off-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
---
apps/desktop/electron/main.ts | 6 +
.../terminal/persistent.test.tsx | 214 ++++++++++++++++++
.../app/right-sidebar/terminal/persistent.tsx | 71 +++++-
.../src/components/pet/pet-sprite.test.tsx | 190 ++++++++++++++++
.../desktop/src/components/pet/pet-sprite.tsx | 104 ++++++++-
.../src/components/pet/use-pet-roam.test.tsx | 142 ++++++++++++
.../src/components/pet/use-pet-roam.ts | 81 ++++++-
apps/desktop/src/global.d.ts | 2 +
apps/desktop/src/lib/renderer-loop-pause.ts | 30 +++
9 files changed, 822 insertions(+), 18 deletions(-)
create mode 100644 apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx
create mode 100644 apps/desktop/src/components/pet/pet-sprite.test.tsx
create mode 100644 apps/desktop/src/components/pet/use-pet-roam.test.tsx
create mode 100644 apps/desktop/src/lib/renderer-loop-pause.ts
diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts
index af25ae7712ab8..dcbffcb4bd5ed 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -4463,6 +4463,8 @@ function getNativeOverlayWidth() {
function getWindowState() {
return {
isFullscreen: Boolean(mainWindow?.isFullScreen?.()),
+ isMinimized: Boolean(mainWindow?.isMinimized?.()),
+ isVisible: Boolean(mainWindow?.isVisible?.()),
nativeOverlayWidth: getNativeOverlayWidth(),
windowButtonPosition: getWindowButtonPosition()
}
@@ -7323,6 +7325,10 @@ function createWindow() {
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false))
+ mainWindow.on('minimize', () => sendWindowStateChanged())
+ mainWindow.on('restore', () => sendWindowStateChanged())
+ mainWindow.on('hide', () => sendWindowStateChanged())
+ mainWindow.on('show', () => sendWindowStateChanged())
// Reopen where the user left off. resized/moved settle once per drag; close is
// the cross-platform backstop, flushed synchronously before the window is gone.
diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx
new file mode 100644
index 0000000000000..04fb3b9cfa790
--- /dev/null
+++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx
@@ -0,0 +1,214 @@
+import { act, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { PersistentTerminal, TerminalSlot } from './persistent'
+
+vi.mock('./terminals', () => ({
+ ensureTerminal: vi.fn()
+}))
+
+vi.mock('./workspace', () => ({
+ TerminalWorkspace: () =>
+}))
+
+let resizeObserverCallback: ResizeObserverCallback | null = null
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
+
+function render(ui: ReactNode) {
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+
+ act(() => {
+ root!.render(ui)
+ })
+}
+
+function cleanup() {
+ if (root) {
+ act(() => {
+ root!.unmount()
+ })
+ }
+
+ container?.remove()
+ root = null
+ container = null
+}
+
+function setVisibility(hidden: boolean) {
+ Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' })
+}
+
+function installWindowStateBridge() {
+ windowStateCallback = null
+ Object.defineProperty(window, 'hermesDesktop', {
+ configurable: true,
+ value: {
+ onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
+ windowStateCallback = callback
+
+ return () => {
+ if (windowStateCallback === callback) {
+ windowStateCallback = null
+ }
+ }
+ })
+ }
+ })
+}
+
+function rect(top: number, left: number, width: number, height: number): DOMRect {
+ return {
+ bottom: top + height,
+ height,
+ left,
+ right: left + width,
+ top,
+ width,
+ x: left,
+ y: top,
+ toJSON: () => ({})
+ } as DOMRect
+}
+
+function installRaf() {
+ let nextId = 1
+ const frames = new Map()
+ const request = vi.fn((callback: FrameRequestCallback) => {
+ const id = nextId++
+ frames.set(id, callback)
+
+ return id
+ })
+ const cancel = vi.fn((id: number) => {
+ frames.delete(id)
+ })
+
+ Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
+ Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
+
+ return {
+ cancel,
+ pending: () => frames.size,
+ request,
+ runNext: () => {
+ const next = frames.entries().next().value
+
+ if (!next) {
+ throw new Error('No pending RAF')
+ }
+
+ const [id, callback] = next
+ frames.delete(id)
+ callback(0)
+ }
+ }
+}
+
+function Harness() {
+ return (
+ <>
+
+ undefined} />
+ >
+ )
+}
+
+describe('PersistentTerminal rect tracking', () => {
+ beforeEach(() => {
+ ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ setVisibility(false)
+ installWindowStateBridge()
+ resizeObserverCallback = null
+ vi.stubGlobal(
+ 'ResizeObserver',
+ class {
+ constructor(callback: ResizeObserverCallback) {
+ resizeObserverCallback = callback
+ }
+
+ disconnect = vi.fn()
+ observe = vi.fn()
+ unobserve = vi.fn()
+ } as unknown as typeof ResizeObserver
+ )
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ setVisibility(false)
+ delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
+ })
+
+ it('settles after rect changes instead of polling forever', () => {
+ const raf = installRaf()
+ let currentRect = rect(10, 20, 200, 100)
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect)
+
+ render()
+
+ expect(raf.request).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ raf.runNext()
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(1)
+ expect(raf.pending()).toBe(0)
+
+ currentRect = rect(12, 24, 220, 120)
+ act(() => {
+ resizeObserverCallback?.([], {} as ResizeObserver)
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(2)
+
+ act(() => {
+ raf.runNext()
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(3)
+
+ act(() => {
+ raf.runNext()
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(3)
+ expect(raf.pending()).toBe(0)
+ })
+
+ it('does not schedule rect RAFs while the Electron window is paused, then resumes when visible', () => {
+ const raf = installRaf()
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
+
+ render()
+
+ expect(raf.request).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ windowStateCallback?.({ isMinimized: true, isVisible: false })
+ })
+
+ expect(raf.cancel).toHaveBeenCalledTimes(1)
+ expect(raf.pending()).toBe(0)
+
+ act(() => {
+ resizeObserverCallback?.([], {} as ResizeObserver)
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ windowStateCallback?.({ isMinimized: false, isVisible: true })
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
index e0ef66cedd85c..4d1421f0be280 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
+++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
+
import { $terminalTakeover } from '../store'
import { ensureTerminal } from './terminals'
@@ -83,8 +85,23 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
let prev: Rect | null = null
let frame = 0
+ let stopped = false
+ let pauseController: ReturnType | null = null
+
+ const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
+
+ const cancelFrame = () => {
+ if (frame !== 0) {
+ window.cancelAnimationFrame(frame)
+ frame = 0
+ }
+ }
+
+ const measure = (): boolean => {
+ if (rendererPaused()) {
+ return false
+ }
- const tick = () => {
const r = slot.getBoundingClientRect()
// floor top/left + ceil right/bottom: overlay always covers the slot's
// full pixel footprint, so half-pixel rects can't leak page bg through.
@@ -99,14 +116,60 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
if (next.width > 0 && next.height > 0) {
setReady(true)
}
+
+ return true
}
- frame = requestAnimationFrame(tick)
+ return false
}
- tick()
+ const scheduleMeasure = () => {
+ if (stopped || rendererPaused() || frame !== 0) {
+ return
+ }
+
+ frame = window.requestAnimationFrame(() => {
+ frame = 0
- return () => cancelAnimationFrame(frame)
+ if (measure()) {
+ scheduleMeasure()
+ }
+ })
+ }
+
+ const handleVisibilityChange = () => {
+ if (rendererPaused()) {
+ cancelFrame()
+
+ return
+ }
+
+ scheduleMeasure()
+ }
+
+ const observer =
+ typeof ResizeObserver === 'undefined'
+ ? null
+ : new ResizeObserver(() => {
+ scheduleMeasure()
+ })
+
+ if (measure()) {
+ scheduleMeasure()
+ }
+ observer?.observe(slot)
+ window.addEventListener('resize', scheduleMeasure)
+ window.addEventListener('scroll', scheduleMeasure, true)
+ pauseController = createRendererLoopPauseController(handleVisibilityChange)
+
+ return () => {
+ stopped = true
+ cancelFrame()
+ observer?.disconnect()
+ window.removeEventListener('resize', scheduleMeasure)
+ window.removeEventListener('scroll', scheduleMeasure, true)
+ pauseController?.dispose()
+ }
}, [slot])
const visible = Boolean(rect && rect.width > 0 && rect.height > 0)
diff --git a/apps/desktop/src/components/pet/pet-sprite.test.tsx b/apps/desktop/src/components/pet/pet-sprite.test.tsx
new file mode 100644
index 0000000000000..bee16e2a9ba25
--- /dev/null
+++ b/apps/desktop/src/components/pet/pet-sprite.test.tsx
@@ -0,0 +1,190 @@
+import { act, type ReactNode } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/store/pet', () => {
+ const listeners = new Set<(state: string) => void>()
+
+ return {
+ $petState: {
+ get: () => 'idle',
+ listen: (callback: (state: string) => void) => {
+ listeners.add(callback)
+
+ return () => {
+ listeners.delete(callback)
+ }
+ }
+ }
+ }
+})
+
+import { PetSprite } from './pet-sprite'
+
+const INFO = {
+ enabled: true,
+ frameH: 16,
+ frameW: 16,
+ framesPerState: 2,
+ loopMs: 120,
+ scale: 1,
+ spritesheetBase64: 'stub',
+ stateRows: ['idle']
+}
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
+
+function render(ui: ReactNode) {
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+
+ act(() => {
+ root!.render(ui)
+ })
+}
+
+function cleanup() {
+ if (root) {
+ act(() => {
+ root!.unmount()
+ })
+ }
+
+ container?.remove()
+ root = null
+ container = null
+}
+
+function setVisibility(hidden: boolean) {
+ Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' })
+}
+
+function installWindowStateBridge() {
+ windowStateCallback = null
+ Object.defineProperty(window, 'hermesDesktop', {
+ configurable: true,
+ value: {
+ onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
+ windowStateCallback = callback
+
+ return () => {
+ if (windowStateCallback === callback) {
+ windowStateCallback = null
+ }
+ }
+ })
+ }
+ })
+}
+
+function installRaf() {
+ let nextId = 1
+ const frames = new Map()
+ const request = vi.fn((callback: FrameRequestCallback) => {
+ const id = nextId++
+ frames.set(id, callback)
+
+ return id
+ })
+ const cancel = vi.fn((id: number) => {
+ frames.delete(id)
+ })
+
+ Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
+ Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
+
+ return {
+ cancel,
+ pending: () => frames.size,
+ request,
+ runNext: (now: number) => {
+ const next = frames.entries().next().value
+
+ if (!next) {
+ throw new Error('No pending RAF')
+ }
+
+ const [id, callback] = next
+ frames.delete(id)
+ callback(now)
+ }
+ }
+}
+
+describe('PetSprite RAF scheduling', () => {
+ beforeEach(() => {
+ ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.useFakeTimers()
+ setVisibility(false)
+ installWindowStateBridge()
+ vi.stubGlobal(
+ 'Image',
+ class extends EventTarget {
+ complete = true
+ naturalWidth = 16
+ src = ''
+ } as unknown as typeof Image
+ )
+ vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
+ clearRect: vi.fn(),
+ drawImage: vi.fn(),
+ imageSmoothingEnabled: false
+ } as unknown as CanvasRenderingContext2D)
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ setVisibility(false)
+ delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
+ })
+
+ it('sleeps between visible sprite frames instead of chaining RAFs', () => {
+ const raf = installRaf()
+
+ render()
+
+ expect(raf.request).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ raf.runNext(0)
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(1)
+ expect(raf.pending()).toBe(0)
+ expect(vi.getTimerCount()).toBe(1)
+
+ act(() => {
+ vi.advanceTimersByTime(60)
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(2)
+ })
+
+ it('cancels pending RAF work while the Electron window is paused and resumes when visible', () => {
+ const raf = installRaf()
+
+ render()
+
+ expect(raf.request).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ windowStateCallback?.({ isMinimized: true, isVisible: false })
+ })
+
+ expect(raf.cancel).toHaveBeenCalledTimes(1)
+ expect(raf.pending()).toBe(0)
+
+ act(() => {
+ windowStateCallback?.({ isMinimized: false, isVisible: true })
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(2)
+ })
+})
diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx
index b3a0fb66003b8..217a9e3b56b9e 100644
--- a/apps/desktop/src/components/pet/pet-sprite.tsx
+++ b/apps/desktop/src/components/pet/pet-sprite.tsx
@@ -1,5 +1,6 @@
import { memo, useEffect, useMemo, useRef } from 'react'
+import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
import { $petState, type PetInfo, type PetState } from '@/store/pet'
const DEFAULT_FRAME_W = 192
@@ -119,14 +120,17 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
const stateRef = useRef($petState.get())
const overrideRef = useRef(stateOverride)
const rowOverrideRef = useRef(rowOverride)
+ const kickAnimationRef = useRef<() => void>(() => undefined)
// Keep the override current without re-running the RAF setup effect.
useEffect(() => {
overrideRef.current = stateOverride
+ kickAnimationRef.current()
}, [stateOverride])
useEffect(() => {
rowOverrideRef.current = rowOverride
+ kickAnimationRef.current()
}, [rowOverride])
const frameW = info.frameW ?? DEFAULT_FRAME_W
@@ -170,17 +174,75 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
// Track state via subscription, not a prop — no re-render on activity ticks.
stateRef.current = $petState.get()
- const unsubState = $petState.listen(next => {
- stateRef.current = next
- })
-
let raf = 0
+ let wakeTimer = 0
+ let stopped = false
let frame = 0
let lastStep = performance.now()
let drawnFrame = -1
let drawnRow = -1
let activeRow = -1
let activeCount = -1
+ let pauseController: ReturnType | null = null
+
+ const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
+
+ const cancelWakeTimer = () => {
+ if (wakeTimer !== 0) {
+ window.clearTimeout(wakeTimer)
+ wakeTimer = 0
+ }
+ }
+
+ const cancelRaf = () => {
+ if (raf !== 0) {
+ window.cancelAnimationFrame(raf)
+ raf = 0
+ }
+ }
+
+ const clearScheduled = () => {
+ cancelWakeTimer()
+ cancelRaf()
+ }
+
+ const scheduleFrame = (delayMs = 0) => {
+ if (stopped || rendererPaused() || raf !== 0 || wakeTimer !== 0) {
+ return
+ }
+
+ if (delayMs > 16) {
+ wakeTimer = window.setTimeout(() => {
+ wakeTimer = 0
+ scheduleFrame()
+ }, delayMs)
+
+ return
+ }
+
+ raf = window.requestAnimationFrame(render)
+ }
+
+ const kickAnimation = () => {
+ if (stopped || rendererPaused()) {
+ return
+ }
+
+ cancelWakeTimer()
+ scheduleFrame()
+ }
+
+ const handleVisibilityChange = () => {
+ clearScheduled()
+
+ if (rendererPaused()) {
+ return
+ }
+
+ lastStep = performance.now()
+ drawnFrame = -1
+ kickAnimation()
+ }
const rowIndexForState = (s: PetState): number => {
for (const key of STATE_ALIASES[s] ?? [s]) {
@@ -220,6 +282,12 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
}
const render = (now: number) => {
+ raf = 0
+
+ if (stopped || rendererPaused()) {
+ return
+ }
+
const forcedRow = rowOverrideRef.current
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current)
@@ -242,10 +310,13 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
frame %= count
+ if (!image.complete || image.naturalWidth <= 0) {
+ return
+ }
+
// Only touch the canvas when the visible cell actually changes. The RAF
- // ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of
- // the clear+draw work and keeps the main thread free.
- if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) {
+ // wakes when a sprite cell is due, so the idle path avoids a 60Hz loop.
+ if (frame !== drawnFrame || row !== drawnRow) {
const sx = frame * frameW
const sy = row * frameH
ctx.clearRect(0, 0, canvas.width, canvas.height)
@@ -255,13 +326,26 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
drawnRow = row
}
- raf = requestAnimationFrame(render)
+ scheduleFrame(Math.max(0, stepMs - (now - lastStep)))
}
- raf = requestAnimationFrame(render)
+ kickAnimationRef.current = kickAnimation
+
+ const unsubState = $petState.listen(next => {
+ stateRef.current = next
+ kickAnimation()
+ })
+
+ image.addEventListener('load', kickAnimation)
+ pauseController = createRendererLoopPauseController(handleVisibilityChange)
+ scheduleFrame()
return () => {
- cancelAnimationFrame(raf)
+ stopped = true
+ kickAnimationRef.current = () => undefined
+ clearScheduled()
+ image.removeEventListener('load', kickAnimation)
+ pauseController?.dispose()
unsubState()
}
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows])
diff --git a/apps/desktop/src/components/pet/use-pet-roam.test.tsx b/apps/desktop/src/components/pet/use-pet-roam.test.tsx
new file mode 100644
index 0000000000000..007a1ba22c936
--- /dev/null
+++ b/apps/desktop/src/components/pet/use-pet-roam.test.tsx
@@ -0,0 +1,142 @@
+import { act, type ReactNode, type RefObject, useRef } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/store/pet', () => ({
+ $petMotion: { set: () => undefined },
+ $petRoamDir: { set: () => undefined }
+}))
+
+import { usePetRoam } from './use-pet-roam'
+
+let root: Root | null = null
+let container: HTMLDivElement | null = null
+let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
+
+function render(ui: ReactNode) {
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+
+ act(() => {
+ root!.render(ui)
+ })
+}
+
+function cleanup() {
+ if (root) {
+ act(() => {
+ root!.unmount()
+ })
+ }
+
+ container?.remove()
+ root = null
+ container = null
+}
+
+function setVisibility(hidden: boolean) {
+ Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' })
+}
+
+function installWindowStateBridge() {
+ windowStateCallback = null
+ Object.defineProperty(window, 'hermesDesktop', {
+ configurable: true,
+ value: {
+ onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
+ windowStateCallback = callback
+
+ return () => {
+ if (windowStateCallback === callback) {
+ windowStateCallback = null
+ }
+ }
+ })
+ }
+ })
+}
+
+function installRaf() {
+ const request = vi.fn((_callback: FrameRequestCallback) => 1)
+ const cancel = vi.fn()
+
+ Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
+ Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
+
+ return { cancel, request }
+}
+
+function RoamHarness({ isInteracting = () => false }: { isInteracting?: () => boolean }) {
+ const ref = useRef(null)
+
+ usePetRoam({
+ commit: () => undefined,
+ containerRef: ref as RefObject,
+ enabled: true,
+ isInteracting,
+ loopMs: 1200,
+ overlayOpen: false,
+ petH: 64,
+ petW: 64
+ })
+
+ return
+}
+
+describe('usePetRoam RAF scheduling', () => {
+ beforeEach(() => {
+ ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ vi.useFakeTimers()
+ setVisibility(false)
+ installWindowStateBridge()
+ vi.spyOn(Math, 'random').mockReturnValue(0)
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
+ bottom: 164,
+ height: 64,
+ left: 100,
+ right: 164,
+ top: 100,
+ width: 64,
+ x: 100,
+ y: 100,
+ toJSON: () => ({})
+ } as DOMRect)
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ setVisibility(false)
+ delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
+ })
+
+ it('uses a pause timer, not RAF, while dwelling at idle', () => {
+ const raf = installRaf()
+
+ render()
+
+ expect(raf.request).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(1)
+ })
+
+ it('clears the pause wakeup while the Electron window is paused and restarts it when visible', () => {
+ const raf = installRaf()
+
+ render()
+ expect(vi.getTimerCount()).toBe(1)
+
+ windowStateCallback?.({ isMinimized: true, isVisible: false })
+
+ expect(raf.cancel).not.toHaveBeenCalled()
+ expect(raf.request).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(0)
+
+ windowStateCallback?.({ isMinimized: false, isVisible: true })
+
+ expect(raf.request).not.toHaveBeenCalled()
+ expect(vi.getTimerCount()).toBe(1)
+ })
+})
diff --git a/apps/desktop/src/components/pet/use-pet-roam.ts b/apps/desktop/src/components/pet/use-pet-roam.ts
index 84d7b5386afb0..e4d1e9a801b11 100644
--- a/apps/desktop/src/components/pet/use-pet-roam.ts
+++ b/apps/desktop/src/components/pet/use-pet-roam.ts
@@ -1,5 +1,6 @@
import { type RefObject, useEffect } from 'react'
+import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
import { $petMotion, $petRoamDir, type PetState } from '@/store/pet'
import { chooseMove, dwellMs, PAUSE_DWELL, pickStrollTarget } from './roam-behavior'
@@ -33,6 +34,8 @@ const DROP_SETTLE_MS = 90
const ARRIVE_EPS = 1.5
// Cap dt so a backgrounded/throttled tab can't teleport the pet on resume.
const MAX_DT_S = 0.05
+// While paused, wake rarely to notice drags/replans without burning a 60Hz RAF.
+const PAUSE_POLL_MS = 250
type Phase = 'pause' | 'walk' | 'fall' | 'jump'
@@ -114,6 +117,9 @@ export function usePetRoam({
let pauseUntil = performance.now() + rand(400, 1200)
let last = performance.now()
let raf = 0
+ let pauseTimer = 0
+ let stopped = false
+ let pauseController: ReturnType | null = null
let walkTargetX = cur.x
let curLedge: Ledge | null = null
@@ -137,6 +143,61 @@ export function usePetRoam({
$petRoamDir.set(dir)
}
+ const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
+
+ const cancelRaf = () => {
+ if (raf !== 0) {
+ window.cancelAnimationFrame(raf)
+ raf = 0
+ }
+ }
+
+ const cancelPauseTimer = () => {
+ if (pauseTimer !== 0) {
+ window.clearTimeout(pauseTimer)
+ pauseTimer = 0
+ }
+ }
+
+ const clearScheduled = () => {
+ cancelRaf()
+ cancelPauseTimer()
+ }
+
+ const schedule = (now = performance.now()) => {
+ if (stopped || rendererPaused() || raf !== 0 || pauseTimer !== 0) {
+ return
+ }
+
+ if (phase === 'pause') {
+ const delay = Math.max(0, pauseUntil - now)
+
+ if (delay > 0) {
+ pauseTimer = window.setTimeout(() => {
+ pauseTimer = 0
+ step(performance.now())
+ }, Math.min(delay, PAUSE_POLL_MS))
+
+ return
+ }
+ }
+
+ raf = window.requestAnimationFrame(step)
+ }
+
+ const handleVisibilityChange = () => {
+ clearScheduled()
+ last = performance.now()
+
+ if (rendererPaused()) {
+ signal(null, 0)
+
+ return
+ }
+
+ schedule(last)
+ }
+
const beginPause = (now: number) => {
phase = 'pause'
pauseUntil = now + dwellMs(PAUSE_DWELL)
@@ -209,6 +270,15 @@ export function usePetRoam({
}
const step = (now: number) => {
+ raf = 0
+ pauseTimer = 0
+
+ if (stopped || rendererPaused()) {
+ signal(null, 0)
+
+ return
+ }
+
const dt = Math.min(MAX_DT_S, (now - last) / 1000)
last = now
@@ -223,7 +293,7 @@ export function usePetRoam({
// Short settle so the pet falls right after you drop it, not seconds later.
pauseUntil = now + DROP_SETTLE_MS
signal(null, 0)
- raf = requestAnimationFrame(step)
+ schedule(now)
return
}
@@ -300,13 +370,16 @@ export function usePetRoam({
}
}
- raf = requestAnimationFrame(step)
+ schedule(now)
}
- raf = requestAnimationFrame(step)
+ pauseController = createRendererLoopPauseController(handleVisibilityChange)
+ schedule()
return () => {
- cancelAnimationFrame(raf)
+ stopped = true
+ clearScheduled()
+ pauseController?.dispose()
signal(null, 0)
// Hand the final position back to React so its `style` matches the DOM once
// the loop stops re-asserting it.
diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts
index a37091ceeb49c..6bdbcd8adcd8c 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -404,6 +404,8 @@ export interface HermesTitleBarTheme {
export interface HermesWindowState {
isFullscreen: boolean
+ isMinimized?: boolean
+ isVisible?: boolean
nativeOverlayWidth: number
windowButtonPosition: { x: number; y: number } | null
}
diff --git a/apps/desktop/src/lib/renderer-loop-pause.ts b/apps/desktop/src/lib/renderer-loop-pause.ts
new file mode 100644
index 0000000000000..3175069216e21
--- /dev/null
+++ b/apps/desktop/src/lib/renderer-loop-pause.ts
@@ -0,0 +1,30 @@
+interface WindowStatePayload {
+ isMinimized?: boolean
+ isVisible?: boolean
+}
+
+export function createRendererLoopPauseController(onChange: () => void) {
+ let windowPaused = false
+
+ const onVisibilityChange = () => onChange()
+ const offWindowState = window.hermesDesktop?.onWindowStateChanged?.((payload: WindowStatePayload) => {
+ const next = payload?.isMinimized === true || payload?.isVisible === false
+
+ if (windowPaused === next) {
+ return
+ }
+
+ windowPaused = next
+ onChange()
+ })
+
+ document.addEventListener('visibilitychange', onVisibilityChange)
+
+ return {
+ dispose: () => {
+ document.removeEventListener('visibilitychange', onVisibilityChange)
+ offWindowState?.()
+ },
+ isPaused: () => document.visibilityState === 'hidden' || windowPaused
+ }
+}
From 824b6fe022705406269130e58c1bc16501bf830c Mon Sep 17 00:00:00 2001
From: Ho Lim
Date: Fri, 10 Jul 2026 08:47:08 -0700
Subject: [PATCH 2/4] fix(desktop): invalidate terminal overlay position on
layout mutations
Signed-off-by: Ho Lim
---
.../terminal/persistent.test.tsx | 51 +++++++++++++++++++
.../app/right-sidebar/terminal/persistent.tsx | 14 +++++
2 files changed, 65 insertions(+)
diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx
index 04fb3b9cfa790..97b672752668c 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx
+++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx
@@ -13,6 +13,7 @@ vi.mock('./workspace', () => ({
}))
let resizeObserverCallback: ResizeObserverCallback | null = null
+let mutationObserverCallback: MutationCallback | null = null
let root: Root | null = null
let container: HTMLDivElement | null = null
let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
@@ -125,6 +126,7 @@ describe('PersistentTerminal rect tracking', () => {
setVisibility(false)
installWindowStateBridge()
resizeObserverCallback = null
+ mutationObserverCallback = null
vi.stubGlobal(
'ResizeObserver',
class {
@@ -137,6 +139,18 @@ describe('PersistentTerminal rect tracking', () => {
unobserve = vi.fn()
} as unknown as typeof ResizeObserver
)
+ vi.stubGlobal(
+ 'MutationObserver',
+ class {
+ constructor(callback: MutationCallback) {
+ mutationObserverCallback = callback
+ }
+
+ disconnect = vi.fn()
+ observe = vi.fn()
+ takeRecords = vi.fn(() => [])
+ } as unknown as typeof MutationObserver
+ )
})
afterEach(() => {
@@ -184,6 +198,43 @@ describe('PersistentTerminal rect tracking', () => {
expect(raf.pending()).toBe(0)
})
+ it('remeasures when layout moves the slot without resizing it', () => {
+ const raf = installRaf()
+ let currentRect = rect(10, 20, 200, 100)
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect)
+
+ render()
+
+ act(() => {
+ raf.runNext()
+ })
+
+ const overlay = container!.lastElementChild as HTMLElement
+ expect(overlay.style.top).toBe('10px')
+ expect(overlay.style.left).toBe('20px')
+ expect(raf.pending()).toBe(0)
+
+ currentRect = rect(32, 48, 200, 100)
+ act(() => {
+ mutationObserverCallback?.([], {} as MutationObserver)
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(2)
+
+ act(() => {
+ raf.runNext()
+ })
+
+ expect(overlay.style.top).toBe('32px')
+ expect(overlay.style.left).toBe('48px')
+
+ act(() => {
+ raf.runNext()
+ })
+
+ expect(raf.pending()).toBe(0)
+ })
+
it('does not schedule rect RAFs while the Electron window is paused, then resumes when visible', () => {
const raf = installRaf()
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
index 4d1421f0be280..4165449e959fe 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
+++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
@@ -153,11 +153,24 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
: new ResizeObserver(() => {
scheduleMeasure()
})
+ const positionObserver =
+ typeof MutationObserver === 'undefined'
+ ? null
+ : new MutationObserver(() => {
+ scheduleMeasure()
+ })
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'],
+ attributes: true,
+ childList: true
+ })
+ }
window.addEventListener('resize', scheduleMeasure)
window.addEventListener('scroll', scheduleMeasure, true)
pauseController = createRendererLoopPauseController(handleVisibilityChange)
@@ -166,6 +179,7 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
stopped = true
cancelFrame()
observer?.disconnect()
+ positionObserver?.disconnect()
window.removeEventListener('resize', scheduleMeasure)
window.removeEventListener('scroll', scheduleMeasure, true)
pauseController?.dispose()
From 5d42b0089a4ceba5033e5085da05e7f8bd6c1a2d 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 3/4] 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 ++++++++++++++++++
.../session/hooks/use-message-stream/index.ts | 12 +-
.../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 +++-
12 files changed, 239 insertions(+), 27 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 dcbffcb4bd5ed..b388041ec2e71 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -204,9 +204,9 @@ if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) {
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;
@@ -7288,9 +7288,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 fcfca86807302..52c8b22c55359 100644
--- a/apps/desktop/electron/session-windows.test.ts
+++ b/apps/desktop/electron/session-windows.test.ts
@@ -182,8 +182,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 af55608b0f4e4..e7adbbaf01253 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 d635186e34ed4..11fa5ba698538 100644
--- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
+++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
@@ -430,7 +430,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 4165449e959fe..5827ec0a87954 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 0000000000000..1a572c329a52b
--- /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/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts
index 65a203a215ebc..ac6ce71dc1dcc 100644
--- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts
+++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts
@@ -201,12 +201,6 @@ export function useMessageStream({
flushQueuedDeltas()
}
- if (sinceLast >= STREAM_DELTA_FLUSH_MS && typeof window.requestAnimationFrame === 'function') {
- flushHandleRef.current = window.requestAnimationFrame(runFlush)
-
- return
- }
-
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast))
}, [flushQueuedDeltas])
@@ -227,11 +221,7 @@ export function useMessageStream({
useEffect(
() => () => {
if (flushHandleRef.current !== null && typeof window !== 'undefined') {
- if (typeof window.cancelAnimationFrame === 'function') {
- window.cancelAnimationFrame(flushHandleRef.current)
- } else {
- window.clearTimeout(flushHandleRef.current)
- }
+ window.clearTimeout(flushHandleRef.current)
}
flushHandleRef.current = null
diff --git a/apps/desktop/src/components/pet/pet-sprite.test.tsx b/apps/desktop/src/components/pet/pet-sprite.test.tsx
index bee16e2a9ba25..6da30fdd5f7d0 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 217a9e3b56b9e..68593634853ba 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)
@@ -337,7 +339,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
})
image.addEventListener('load', kickAnimation)
- pauseController = createRendererLoopPauseController(handleVisibilityChange)
+ pauseController = createRendererLoopPauseController(handleVisibilityChange, { pauseWhenUnfocused })
scheduleFrame()
return () => {
@@ -348,7 +350,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 (