From 7a5d534f5befbca0f16fac94a652f878952666d6 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] 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 cf38981f1824..d6fcb69d7f2d 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -4939,6 +4939,8 @@ function getNativeOverlayWidth() {
function getWindowState(win = mainWindow) {
return {
isFullscreen: Boolean(win?.isFullScreen?.()),
+ isMinimized: Boolean(win?.isMinimized?.()),
+ isVisible: Boolean(win?.isVisible?.()),
nativeOverlayWidth: getNativeOverlayWidth(),
windowButtonPosition: getWindowButtonPosition()
}
@@ -8968,6 +8970,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 000000000000..04fb3b9cfa79
--- /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 e0ef66cedd85..4d1421f0be28 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
+ }
- return () => cancelAnimationFrame(frame)
+ frame = window.requestAnimationFrame(() => {
+ frame = 0
+
+ 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 000000000000..bee16e2a9ba2
--- /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 a1e91f3ece10..bb00b54bf6d4 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,16 +120,19 @@ 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.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
overrideRef.current = stateOverride
+ kickAnimationRef.current()
}, [stateOverride])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
rowOverrideRef.current = rowOverride
+ kickAnimationRef.current()
}, [rowOverride])
const frameW = info.frameW ?? DEFAULT_FRAME_W
@@ -173,17 +177,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]) {
@@ -223,6 +285,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)
@@ -245,10 +313,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)
@@ -258,13 +329,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 000000000000..007a1ba22c93
--- /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 84d7b5386afb..e4d1e9a801b1 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 c3405e404a2a..debae77f457b 100644
--- a/apps/desktop/src/global.d.ts
+++ b/apps/desktop/src/global.d.ts
@@ -465,6 +465,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 000000000000..3175069216e2
--- /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
+ }
+}