mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
fix(desktop): gate idle renderer loops
Signed-off-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com>
This commit is contained in:
parent
f8a554bced
commit
7a5d534f5b
9 changed files with 822 additions and 18 deletions
|
|
@ -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.
|
||||
|
|
|
|||
214
apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx
Normal file
214
apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx
Normal file
|
|
@ -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: () => <div data-testid="terminal-workspace" />
|
||||
}))
|
||||
|
||||
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<number, FrameRequestCallback>()
|
||||
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 (
|
||||
<>
|
||||
<TerminalSlot className="slot" />
|
||||
<PersistentTerminal onAddSelectionToChat={() => 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(<Harness />)
|
||||
|
||||
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(<Harness />)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof createRendererLoopPauseController> | 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)
|
||||
|
|
|
|||
190
apps/desktop/src/components/pet/pet-sprite.test.tsx
Normal file
190
apps/desktop/src/components/pet/pet-sprite.test.tsx
Normal file
|
|
@ -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<number, FrameRequestCallback>()
|
||||
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(<PetSprite info={INFO} />)
|
||||
|
||||
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(<PetSprite info={INFO} />)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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>($petState.get())
|
||||
const overrideRef = useRef<PetState | undefined>(stateOverride)
|
||||
const rowOverrideRef = useRef<string | undefined>(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<typeof createRendererLoopPauseController> | 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])
|
||||
|
|
|
|||
142
apps/desktop/src/components/pet/use-pet-roam.test.tsx
Normal file
142
apps/desktop/src/components/pet/use-pet-roam.test.tsx
Normal file
|
|
@ -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<HTMLDivElement | null>(null)
|
||||
|
||||
usePetRoam({
|
||||
commit: () => undefined,
|
||||
containerRef: ref as RefObject<HTMLDivElement | null>,
|
||||
enabled: true,
|
||||
isInteracting,
|
||||
loopMs: 1200,
|
||||
overlayOpen: false,
|
||||
petH: 64,
|
||||
petW: 64
|
||||
})
|
||||
|
||||
return <div ref={ref} />
|
||||
}
|
||||
|
||||
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(<RoamHarness />)
|
||||
|
||||
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(<RoamHarness />)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<typeof createRendererLoopPauseController> | 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.
|
||||
|
|
|
|||
2
apps/desktop/src/global.d.ts
vendored
2
apps/desktop/src/global.d.ts
vendored
|
|
@ -465,6 +465,8 @@ export interface HermesTitleBarTheme {
|
|||
|
||||
export interface HermesWindowState {
|
||||
isFullscreen: boolean
|
||||
isMinimized?: boolean
|
||||
isVisible?: boolean
|
||||
nativeOverlayWidth: number
|
||||
windowButtonPosition: { x: number; y: number } | null
|
||||
}
|
||||
|
|
|
|||
30
apps/desktop/src/lib/renderer-loop-pause.ts
Normal file
30
apps/desktop/src/lib/renderer-loop-pause.ts
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue