Merge pull request #72388 from NousResearch/bb/desktop-render-salvage

perf(desktop): hold 60fps under load — salvage three render-churn fixes + finish the selector sweep
This commit is contained in:
brooklyn! 2026-07-26 21:44:51 -05:00 committed by GitHub
commit af1cc1c245
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1472 additions and 83 deletions

View file

@ -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;
@ -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()
}
@ -8898,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)
})
@ -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.

View file

@ -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)

View file

@ -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) {

View file

@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { useSessionSlice } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $billingBlock } from '@/store/billing-block'
import {
@ -84,17 +85,18 @@ interface ComposerStatusStackProps {
export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) {
const { t } = useI18n()
const navigate = useNavigate()
const itemsBySession = useStore($statusItemsBySession)
const previewsBySession = useStore($previewStatusBySession)
// Subscribe to THIS session's slice only. Both maps churn on other
// sessions' activity (subagent ticks, background polls, preview updates in
// any tile); a whole-map `useStore` re-rendered every mounted stack — one
// per open tile — on all of it. The per-key arrays are referentially stable
// across unrelated writes, so the slice hook bails out unless OUR session's
// items actually changed.
const items = useSessionSlice($statusItemsBySession, sessionId)
const previews = useSessionSlice($previewStatusBySession, sessionId)
const scrolledUp = useStore($threadScrolledUp)
const billing = useStore($billingBlock)
const groups = useMemo(
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
[itemsBySession, sessionId]
)
const previews = sessionId ? (previewsBySession[sessionId] ?? []) : []
const groups = useMemo(() => groupStatusItems(items), [items])
// Seed from the registry on session open; event-driven refreshes (terminal /
// process tool completions) live in use-message-stream.

View file

@ -17,7 +17,7 @@
import { useStore } from '@nanostores/react'
import { useQueryClient } from '@tanstack/react-query'
import { atom, computed } from 'nanostores'
import { useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useModelControls } from '@/app/session/hooks/use-model-controls'
@ -424,6 +424,40 @@ export function stackSessionTilesIntoMain(): void {
}
}
/** The three scalars the tab menu actually renders, derived from the stored
* row. Subscribing to `$sessions` + `$projectTree` wholesale re-rendered
* every tab's menu wrapper on ANY session-list or tree churn (polls, title
* updates in other sessions) for a context menu that's almost never open.
* Same class as the TreeGroup fix (#72245): derive narrowly, bail out unless
* the derived values change. */
function useTileMenuRow(storedSessionId: string): { pinId: string; profile?: string; title: string } {
const cache = useRef<{ key: string; value: { pinId: string; profile?: string; title: string } } | null>(null)
const subscribe = useCallback((onChange: () => void) => {
const offSessions = $sessions.listen(onChange)
const offTree = $projectTree.listen(onChange)
return () => {
offSessions()
offTree()
}
}, [])
return useSyncExternalStore(subscribe, () => {
const stored = tileStoredRow(storedSessionId)
const pinId = stored ? sessionPinId(stored) : storedSessionId
const title = tileTitle(storedSessionId)
const profile = stored?.profile
const key = `${pinId}\u0000${title}\u0000${profile ?? ''}`
if (cache.current?.key !== key) {
cache.current = { key, value: { pinId, profile, title } }
}
return cache.current.value
})
}
/** A session TAB's context menu: the full session verb set (pin, copy id, new
* window, branch, rename, archive, delete) the SAME menu a sidebar row
* gets, targeted through the tile delegate (whose verbs are generic over
@ -445,13 +479,8 @@ export function SessionTabMenu({
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
tabPaneId: string
}) {
// Subscribe for reactivity; the row is read imperatively via tileStoredRow
// (which spans both sources), so the values themselves are unused here.
useStore($sessions)
useStore($projectTree)
const { pinId, profile, title } = useTileMenuRow(storedSessionId)
const pinnedSessionIds = useStore($pinnedSessionIds)
const stored = tileStoredRow(storedSessionId)
const pinId = stored ? sessionPinId(stored) : storedSessionId
const pinned = pinnedSessionIds.includes(pinId)
return (
@ -464,11 +493,11 @@ export function SessionTabMenu({
onHideTabBar={onHideTabBar}
onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
pinned={pinned}
profile={stored?.profile}
profile={profile}
sessionId={storedSessionId}
surface="tab"
tabPaneId={tabPaneId}
title={tileTitle(storedSessionId)}
title={title}
>
{children}
</SessionContextMenu>

View file

@ -3,6 +3,7 @@ import { useStore } from '@nanostores/react'
import type { ModelSelection } from '@/app/shell/model-menu-panel'
import { ModelPickerDialog } from '@/components/model-picker'
import type { HermesGateway } from '@/hermes'
import { useStoreSelector } from '@/lib/use-session-slice'
import {
$activeSessionId,
$currentModel,
@ -24,15 +25,22 @@ export function ModelPickerOverlay({ gateway, onSelect, profile }: ModelPickerOv
const primaryModel = useStore($currentModel)
const primaryProvider = useStore($currentProvider)
const focusedRuntimeId = useStore($focusedRuntimeId)
const focusedState = useStore($focusedSessionState)
// `$focusedSessionState` is a projection of `$sessionStates`, republished on
// EVERY message delta — and this overlay is mounted app-wide. Only two
// fields are read off it, so subscribing to the whole object re-rendered
// this component (and the un-memoized closed dialog below) per token while
// the focused session streamed. Select each scalar so an unchanged
// model/provider bails out instead — same fix as the statusbar (#72163).
const focusedModel = useStoreSelector($focusedSessionState, state => state?.model ?? null)
const focusedProvider = useStoreSelector($focusedSessionState, state => state?.provider ?? null)
const gatewayOpen = useStore($gatewayState) === 'open'
const open = useStore($modelPickerOpen)
// Prefer the focused tile's runtime when the overlay opens from a tile that
// lacked a live menu (gateway closed → fallback path).
const sessionId = focusedRuntimeId ?? primarySessionId
const currentModel = focusedRuntimeId && focusedState ? focusedState.model : primaryModel
const currentProvider = focusedRuntimeId && focusedState ? focusedState.provider : primaryProvider
const currentModel = focusedRuntimeId && focusedModel !== null ? focusedModel : primaryModel
const currentProvider = focusedRuntimeId && focusedProvider !== null ? focusedProvider : primaryProvider
if (!gatewayOpen) {
return null

View file

@ -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

View file

@ -0,0 +1,310 @@
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 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
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)
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
installWindowStateBridge()
resizeObserverCallback = null
mutationObserverCallback = null
mutationObserveCalls = []
vi.stubGlobal(
'ResizeObserver',
class {
constructor(callback: ResizeObserverCallback) {
resizeObserverCallback = callback
}
disconnect = vi.fn()
observe = vi.fn()
unobserve = vi.fn()
} as unknown as typeof ResizeObserver
)
vi.stubGlobal(
'MutationObserver',
class {
constructor(callback: MutationCallback) {
mutationObserverCallback = callback
}
disconnect = vi.fn()
observe = vi.fn((target: Node, options?: MutationObserverInit) => {
mutationObserveCalls.push({ options, target })
})
takeRecords = vi.fn(() => [])
} as unknown as typeof MutationObserver
)
})
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('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(<Harness />)
expect(mutationObserveCalls.some(call => call.options?.subtree === true)).toBe(true)
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))
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)
})
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)
})
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(<Harness />)
expect(raf.request).not.toHaveBeenCalled()
act(() => window.dispatchEvent(new Event('focus')))
expect(raf.pending()).toBe(1)
})
})

View file

@ -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,80 @@ 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()
})
const positionObserver =
typeof MutationObserver === 'undefined'
? null
: new MutationObserver(() => {
scheduleMeasure()
})
pauseController = createRendererLoopPauseController(handleVisibilityChange)
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,
subtree: true
})
}
window.addEventListener('resize', scheduleMeasure)
window.addEventListener('scroll', scheduleMeasure, true)
return () => {
stopped = true
cancelFrame()
observer?.disconnect()
positionObserver?.disconnect()
window.removeEventListener('resize', scheduleMeasure)
window.removeEventListener('scroll', scheduleMeasure, true)
pauseController?.dispose()
}
}, [slot])
const visible = Boolean(rect && rect.width > 0 && rect.height > 0)

View file

@ -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()
})
})

View file

@ -7,8 +7,10 @@ import { createClientSessionState } from '@/lib/chat-runtime'
import { persistInFlightTurnState } from '@/lib/inflight-turn-journal'
import { setMutableRef } from '@/lib/mutable-ref'
import {
$activeSessionId,
$busy,
$messages,
setActiveSessionStoredIdRotation,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
@ -108,10 +110,23 @@ export function useSessionStateCache({
// rotates (e.g. auto-compression forks a continuation). Leaving the
// stale key lets getRuntimeIdForStoredSession resolve the old stored id
// to this runtime, which the compression route-follow logic relies on
// being absent. The rotation signal itself is emitted centrally from
// handleTransition (session-states.ts) off the published diff.
// being absent. The rotation signal was previously emitted centrally
// from handleTransition (session-states.ts), but updateSessionState
// now skips publishSessionState (and thus handleTransition) when the
// updater is a no-op — fire it here so the route-follow effect still
// tracks compression without needing a dummy state write.
if (existing.storedSessionId && existing.storedSessionId !== storedSessionId) {
runtimeIdByStoredSessionIdRef.current.delete(existing.storedSessionId)
// A rotation event needs a real next id — a null/cleared stored id
// is a detach, not a rotation the route-follow effect should chase.
if (storedSessionId && sessionId === $activeSessionId.get()) {
setActiveSessionStoredIdRotation({
nextStoredSessionId: storedSessionId,
previousStoredSessionId: existing.storedSessionId,
runtimeSessionId: sessionId
})
}
}
if (storedSessionId) {
@ -268,7 +283,22 @@ export function useSessionStateCache({
storedSessionId?: string | null
) => {
const previous = ensureSessionState(sessionId, storedSessionId)
const next = updater({ ...previous, messages: previous.messages })
// Give the updater the raw previous state so it can return the same
// reference when nothing changed (the caller sees a no-op). Previously
// the param was always a fresh spread, so every call looked like a
// change — including periodic ~1/s session.info heartbeats that churn
// $sessionStates and its computed atoms on every tick.
const next = updater(previous)
// If the updater returned the same reference, nothing changed for this
// session — skip the store write, publishSessionState, and view sync.
// The cache entry was already updated by ensureSessionState (if
// storedSessionId rotated); the caller gets its return value from the
// cache, so stale reads don't regress.
if (next === previous) {
return previous
}
sessionStateByRuntimeIdRef.current.set(sessionId, next)
// Crash-survivable turn progress: journal the running turn's visible
// tail (throttled localStorage write; cleared the moment the turn

View file

@ -104,7 +104,20 @@ export function useStatusbarItems({
const gatewayRestarting = useStore($gatewayRestarting)
const primarySessionStartedAt = useStore($sessionStartedAt)
const primaryTurnStartedAt = useStore($turnStartedAt)
const subagentsBySession = useStore($subagentsBySession)
// The indicator must speak the same scope as the Spawn-tree panel it opens:
// every session's subagents, never background system actions. Only two
// COUNTS are read, so select scalars — a whole-map `useStore` re-ran this
// hook (rebuilding all ~9 statusbar items) on every subagent progress tick
// in ANY session, including background ones.
const subagentsRunning = useStoreSelector($subagentsBySession, bySession =>
Object.values(bySession).reduce((sum, items) => sum + activeSubagentCount(items), 0)
)
const subagentsFailed = useStoreSelector($subagentsBySession, bySession =>
Object.values(bySession).reduce((sum, items) => sum + failedSubagentCount(items), 0)
)
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
const backendUpdateStatus = useStore($backendUpdateStatus)
@ -130,7 +143,6 @@ export function useStatusbarItems({
// reports new usage — far rarer than a delta — so its reference is a valid
// bail-out key on its own.
const focusedUsage = useStoreSelector($focusedSessionState, state => state?.usage ?? null)
const sessions = useStore($sessions)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const primaryFocused = !focusedStoredSessionId || focusedStoredSessionId === selectedStoredSessionId
@ -144,15 +156,19 @@ export function useStatusbarItems({
const turnStartedAt = primaryFocused ? primaryTurnStartedAt : focusedTurnStartedAt
// A tile's session-start comes from its stored row (the cache only knows
// runtime state); seconds → ms.
const focusedRow = focusedStoredSessionId
? sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId))
: null
// runtime state); seconds → ms. Only this ONE scalar is read off
// `$sessions`, so select it — a whole-list `useStore` re-ran the hook on
// every session-list write (title updates, poll refreshes, archives).
const focusedRowStartedAt = useStoreSelector($sessions, sessions =>
focusedStoredSessionId
? (sessions.find(s => sessionMatchesStoredId(s, focusedStoredSessionId))?.started_at ?? null)
: null
)
const sessionStartedAt = primaryFocused
? primarySessionStartedAt
: focusedRow?.started_at
? focusedRow.started_at * 1000
: focusedRowStartedAt
? focusedRowStartedAt * 1000
: null
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
@ -180,18 +196,6 @@ export function useStatusbarItems({
[gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot]
)
// The indicator must speak the same scope as the Spawn-tree panel it opens:
// every session's subagents, never background system actions (gateway
// restarts, toolset installs) which surface in their own panels.
const { subagentsFailed, subagentsRunning } = useMemo(() => {
const lists = Object.values(subagentsBySession)
return {
subagentsFailed: lists.reduce((sum, items) => sum + failedSubagentCount(items), 0),
subagentsRunning: lists.reduce((sum, items) => sum + activeSubagentCount(items), 0)
}
}, [subagentsBySession])
const gatewayOpen = gatewayState === 'open'
const gatewayConnecting = gatewayState === 'connecting'
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true

View file

@ -0,0 +1,125 @@
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { act, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Thread } from './thread'
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
Element.prototype.animate = function animate() {
return {
cancel: () => {},
finished: Promise.resolve()
} as unknown as Animation
}
// jsdom returns 0 for offset*; the virtualizer reads those to size its
// viewport. Fall through to client* or a sane default so virtualized
// items render (same stub as streaming.test.tsx).
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
const createdAt = new Date('2026-05-01T00:00:00.000Z')
const MESSAGES: ThreadMessage[] = [
{
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'hello from the user' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage,
{
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'stable assistant reply' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
]
function Harness({
onBranchInNewChat,
onCancel
}: {
onBranchInNewChat: (messageId: string) => void
onCancel: () => void
}) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: MESSAGES,
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread onBranchInNewChat={onBranchInNewChat} onCancel={onCancel} />
</AssistantRuntimeProvider>
)
}
describe('thread message mount stability', () => {
// Regression: the desktop controller re-renders every 15s (status
// snapshot poll) and used to pass freshly-created callbacks down to
// <Thread/>. Those callbacks were deps of the `messageComponents`
// useMemo, so new component *types* were created each poll and React
// unmounted/remounted every visible message — shiki re-highlighted
// code blocks and the whole thread visibly jumped.
it('keeps message DOM nodes mounted when callback props get new identities', async () => {
const { rerender } = render(<Harness onBranchInNewChat={() => {}} onCancel={() => {}} />)
await waitFor(() => {
expect(screen.getByText('stable assistant reply')).toBeTruthy()
expect(screen.getByText('hello from the user')).toBeTruthy()
})
const assistantBefore = screen.getByText('stable assistant reply')
const userBefore = screen.getByText('hello from the user')
// Same data, new callback identities — exactly what a parent
// re-render driven by an unrelated state update produces.
await act(async () => {
rerender(<Harness onBranchInNewChat={() => {}} onCancel={() => {}} />)
})
expect(screen.getByText('stable assistant reply')).toBe(assistantBefore)
expect(screen.getByText('hello from the user')).toBe(userBefore)
})
})

View file

@ -1,4 +1,4 @@
import { type FC, useCallback, useMemo, useState } from 'react'
import { type FC, useCallback, useMemo, useRef, useState } from 'react'
import { AssistantMessage } from '@/components/assistant-ui/thread/assistant-message'
import { ThreadMessageList } from '@/components/assistant-ui/thread/list'
@ -67,21 +67,44 @@ export const Thread: FC<{
setRestoreConfirmTarget({ messageId, ...target })
}, [])
// The values in this map are component *types*: when their identity
// changes, React unmounts and remounts every visible message — async
// re-rendered parts (shiki code blocks) collapse and re-expand, so the
// whole thread visibly jumps. Parents re-render on unrelated state
// (e.g. the 15s status-snapshot poll in the desktop controller) and
// can't be trusted to keep callback identities stable (see #38333), so
// route the callbacks through a ref instead of listing them as memo
// deps. Only their definedness stays a dep — it gates UI (the user
// Stop button, the restore-confirm affordance). Assigned during render
// (the useStoreSelector pattern) so the ref never lags a render.
const callbacksRef = useRef({ onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage })
callbacksRef.current = { onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage }
const hasBranchInNewChat = Boolean(onBranchInNewChat)
const hasCancel = Boolean(onCancel)
const hasDismissError = Boolean(onDismissError)
const hasRestoreToMessage = Boolean(onRestoreToMessage)
const messageComponents = useMemo(
() => ({
AssistantMessage: () => (
<AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />
<AssistantMessage
onBranchInNewChat={
hasBranchInNewChat ? messageId => callbacksRef.current.onBranchInNewChat?.(messageId) : undefined
}
onDismissError={hasDismissError ? messageId => callbacksRef.current.onDismissError?.(messageId) : undefined}
/>
),
SystemMessage,
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
UserMessage: () => (
<UserMessage
onCancel={onCancel}
onRequestRestoreConfirm={onRestoreToMessage ? requestRestoreConfirm : undefined}
onCancel={hasCancel ? () => callbacksRef.current.onCancel?.() : undefined}
onRequestRestoreConfirm={hasRestoreToMessage ? requestRestoreConfirm : undefined}
/>
)
}),
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, requestRestoreConfirm, sessionId]
[cwd, gateway, hasBranchInNewChat, hasCancel, hasDismissError, hasRestoreToMessage, requestRestoreConfirm, sessionId]
)
const emptyPlaceholder = intro ? (

View file

@ -0,0 +1,226 @@
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)
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
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)
})
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)
})
})

View file

@ -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
@ -91,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
/**
@ -114,21 +117,24 @@ 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)
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 +179,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 +287,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 +315,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,16 +331,29 @@ 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, { pauseWhenUnfocused })
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])
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows, pauseWhenUnfocused])
return (
<canvas

View file

@ -0,0 +1,166 @@
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)
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
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)
})
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)
})
})

View file

@ -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.

View file

@ -465,6 +465,8 @@ export interface HermesTitleBarTheme {
export interface HermesWindowState {
isFullscreen: boolean
isMinimized?: boolean
isVisible?: boolean
nativeOverlayWidth: number
windowButtonPosition: { x: number; y: number } | null
}

View file

@ -0,0 +1,50 @@
interface WindowStatePayload {
isMinimized?: boolean
isVisible?: boolean
}
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
if (windowPaused === next) {
return
}
windowPaused = next
onChange()
})
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' || (pauseWhenUnfocused && !windowFocused) || windowPaused
}
}

View file

@ -156,6 +156,39 @@ const goalToItem = (goal: { detail?: string; status: GoalStatus; title: string }
})
// The single thing the stack reads: a typed, merged item list per session.
//
// Identity contract: this computed's inputs churn constantly during a turn (a
// subagent tick, a 5s background poll, a todo update — in ANY session), but
// the merged output for most sessions is unchanged. Rebuilding fresh arrays
// and item objects every time handed every mounted composer stack a new
// reference per recompute — cross-session churn × open tiles. Stabilize both
// levels: an unchanged session keeps its previous array (and item objects),
// and a fully-unchanged map keeps its previous reference so `computed` skips
// the notify entirely ("preserve reference identity on no-ops").
const sameStatusItem = (a: ComposerStatusItem, b: ComposerStatusItem) =>
a.id === b.id &&
a.type === b.type &&
a.state === b.state &&
a.title === b.title &&
a.output === b.output &&
a.exitCode === b.exitCode &&
a.currentTool === b.currentTool &&
a.goalStatus === b.goalStatus &&
a.todoStatus === b.todoStatus &&
a.sessionId === b.sessionId
const stabilizeItems = (prev: ComposerStatusItem[] | undefined, next: ComposerStatusItem[]): ComposerStatusItem[] => {
if (!prev) {
return next
}
const merged = next.map((item, i) => (prev[i] && sameStatusItem(prev[i], item) ? prev[i] : item))
return merged.length === prev.length && merged.every((item, i) => item === prev[i]) ? prev : merged
}
let prevStatusItems: Record<string, ComposerStatusItem[]> = {}
export const $statusItemsBySession = computed(
[$goalsBySession, $subagentsBySession, $backgroundStatusBySession, $todosBySession],
(goals, subs, background, todos) => {
@ -183,7 +216,14 @@ export const $statusItemsBySession = computed(
push(sid, list)
}
return out
let unchanged = Object.keys(prevStatusItems).length === Object.keys(out).length
for (const sid of Object.keys(out)) {
out[sid] = stabilizeItems(prevStatusItems[sid], out[sid]!)
unchanged &&= out[sid] === prevStatusItems[sid]
}
return (prevStatusItems = unchanged ? prevStatusItems : out)
}
)

View file

@ -184,10 +184,25 @@ function handleTransition(previous: ClientSessionState | null, next: ClientSessi
/** Publish one session's state. Automatically fires transition side-effects
* (watchdog arm/disarm, settle grace, unread marker, compression id rotation)
* by diffing previous vs next callers never need to manually call a
* transition handler. */
* transition handler.
*
* Skips the publish when the new state is identical to the existing one
* (same reference) to avoid churning `$sessionStates` on periodic
* `session.info` heartbeats that carry no change otherwise every ~1/s
* heartbeat creates a new Record spread, triggering computed atoms
* ($workingSessionIds, $attentionSessionIds) and their subscribers
* unnecessarily. The runtime-idstate cache (sessionStateByRuntimeIdRef)
* is updated independently by the caller, so the visual path stays live
* without the store churn. */
export function publishSessionState(runtimeId: string, state: ClientSessionState) {
const prev = $sessionStates.get()[runtimeId] ?? null
$sessionStates.set({ ...$sessionStates.get(), [runtimeId]: state })
const current = $sessionStates.get()
const prev = current[runtimeId] ?? null
if (prev === state) {
return
}
$sessionStates.set({ ...current, [runtimeId]: state })
handleTransition(prev, state, runtimeId)
}