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 ( { ;(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 3175069216e21..88b9e3559bc74 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 } } From 8ea51653ef70409faa4bd1873e48c2c8c6ff9cd9 Mon Sep 17 00:00:00 2001 From: Andy <51783311+andyylin@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:44:44 +0800 Subject: [PATCH 4/4] fix(desktop): cover nested terminal layout changes --- .../terminal/persistent.test.tsx | 22 ++++++++++++++++++- .../app/right-sidebar/terminal/persistent.tsx | 6 +++-- 2 files changed, 25 insertions(+), 3 deletions(-) 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 638beaae1a8d1..cc6b15c1507f7 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx @@ -14,6 +14,7 @@ vi.mock('./workspace', () => ({ let resizeObserverCallback: ResizeObserverCallback | null = null let mutationObserverCallback: MutationCallback | null = null +let mutationObserveCalls: Array<{ options?: MutationObserverInit; target: Node }> = [] let root: Root | null = null let container: HTMLDivElement | null = null let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null @@ -128,6 +129,7 @@ describe('PersistentTerminal rect tracking', () => { installWindowStateBridge() resizeObserverCallback = null mutationObserverCallback = null + mutationObserveCalls = [] vi.stubGlobal( 'ResizeObserver', class { @@ -148,7 +150,9 @@ describe('PersistentTerminal rect tracking', () => { } disconnect = vi.fn() - observe = vi.fn() + observe = vi.fn((target: Node, options?: MutationObserverInit) => { + mutationObserveCalls.push({ options, target }) + }) takeRecords = vi.fn(() => []) } as unknown as typeof MutationObserver ) @@ -206,6 +210,8 @@ describe('PersistentTerminal rect tracking', () => { render() + expect(mutationObserveCalls.some(call => call.options?.subtree === true)).toBe(true) + act(() => { raf.runNext() }) @@ -287,4 +293,18 @@ describe('PersistentTerminal rect tracking', () => { }) expect(raf.pending()).toBe(0) }) + + it('does not schedule an initial frame when mounted while unfocused', () => { + const raf = installRaf() + vi.mocked(document.hasFocus).mockReturnValue(false) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100)) + + render() + + expect(raf.request).not.toHaveBeenCalled() + + act(() => window.dispatchEvent(new Event('focus'))) + + expect(raf.pending()).toBe(1) + }) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx index 5827ec0a87954..3fe2edb04a78b 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx @@ -161,6 +161,8 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP scheduleMeasure() }) + pauseController = createRendererLoopPauseController(handleVisibilityChange) + if (measure()) { scheduleMeasure() } @@ -171,13 +173,13 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP positionObserver?.observe(node, { attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-state'], attributes: true, - childList: true + childList: true, + subtree: true }) } window.addEventListener('resize', scheduleMeasure) window.addEventListener('scroll', scheduleMeasure, true) - pauseController = createRendererLoopPauseController(handleVisibilityChange) return () => { stopped = true