mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
fix(desktop): suspend decorative work when inactive
This commit is contained in:
parent
5c8ac975e7
commit
651a313d2b
11 changed files with 238 additions and 16 deletions
|
|
@ -377,9 +377,9 @@ if (IS_WINDOWS) {
|
|||
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)
|
||||
|
||||
// Keep the renderer running at full speed while the window is in the background
|
||||
// or occluded. The chat transcript streams to screen through a
|
||||
// requestAnimationFrame-gated flush; Chromium pauses rAF (and clamps timers)
|
||||
// for backgrounded/occluded renderers, so without these the live answer stalls
|
||||
// or occluded. The chat transcript streams to screen through a bounded timer
|
||||
// flush; Chromium clamps timers for backgrounded/occluded renderers, so without
|
||||
// these the live answer stalls
|
||||
// whenever the window loses focus (switching to your editor mid-turn, detached
|
||||
// devtools, another window covering it) and only paints on refocus or refresh.
|
||||
// `backgroundThrottling: false` on the BrowserWindow covers the blurred case;
|
||||
|
|
@ -8900,9 +8900,9 @@ function createWindow() {
|
|||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
// Shared with the secondary session windows (chatWindowWebPreferences) so
|
||||
// both keep `backgroundThrottling: false` — the chat transcript streams via
|
||||
// a requestAnimationFrame-gated flush that Chromium pauses for blurred
|
||||
// windows, stalling the live answer until refocus. See session-windows.ts.
|
||||
// both keep `backgroundThrottling: false` — the chat transcript uses a
|
||||
// bounded timer flush that Chromium clamps for blurred windows, stalling
|
||||
// the live answer until refocus. See session-windows.ts.
|
||||
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -193,8 +193,8 @@ test('registry trims the session id before keying', () => {
|
|||
|
||||
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
|
||||
// Regression: secondary session windows used to omit this flag, so a streamed
|
||||
// answer stalled until the window regained focus (Chromium pauses the
|
||||
// requestAnimationFrame-gated transcript flush for backgrounded windows).
|
||||
// answer stalled until the window regained focus (Chromium clamps the
|
||||
// transcript flush timer for backgrounded windows).
|
||||
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
|
||||
|
||||
assert.equal(prefs.backgroundThrottling, false)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -433,7 +433,7 @@ export function PetOverlayApp() {
|
|||
<PetBubble />
|
||||
</div>
|
||||
<div style={{ lineHeight: 0, position: 'relative' }}>
|
||||
<PetSprite info={info} />
|
||||
<PetSprite info={info} pauseWhenUnfocused={false} />
|
||||
|
||||
{/* Hearts on the popped-out pet — identical to in-window. */}
|
||||
<PetHeartField
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ describe('PersistentTerminal rect tracking', () => {
|
|||
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(<Harness />)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<string, ClientSessionState>
|
||||
type UpdateSessionState = (
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
storedSessionId?: string | null
|
||||
) => ClientSessionState
|
||||
let updateSessionState: ReturnType<typeof vi.fn<UpdateSessionState>>
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(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(<Harness />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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(<PetSprite info={INFO} />)
|
||||
|
||||
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(<PetSprite info={INFO} pauseWhenUnfocused={false} />)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('blur')))
|
||||
|
||||
expect(raf.pending()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<HTMLCanvasElement | null>(null)
|
||||
const stateRef = useRef<PetState>($petState.get())
|
||||
const overrideRef = useRef<PetState | undefined>(stateOverride)
|
||||
|
|
@ -340,7 +342,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
|||
})
|
||||
|
||||
image.addEventListener('load', kickAnimation)
|
||||
pauseController = createRendererLoopPauseController(handleVisibilityChange)
|
||||
pauseController = createRendererLoopPauseController(handleVisibilityChange, { pauseWhenUnfocused })
|
||||
scheduleFrame()
|
||||
|
||||
return () => {
|
||||
|
|
@ -351,7 +353,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
|||
pauseController?.dispose()
|
||||
unsubState()
|
||||
}
|
||||
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows])
|
||||
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows, pauseWhenUnfocused])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ describe('usePetRoam 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.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(<RoamHarness />)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue