diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts
index cf38981f1824..e19ccf3db3ab 100644
--- a/apps/desktop/electron/main.ts
+++ b/apps/desktop/electron/main.ts
@@ -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.
diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts
index 5167593bfbf8..b5f9cc6a8bef 100644
--- a/apps/desktop/electron/session-windows.test.ts
+++ b/apps/desktop/electron/session-windows.test.ts
@@ -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)
diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts
index 48597ee9e0ea..46871b5384ea 100644
--- a/apps/desktop/electron/session-windows.ts
+++ b/apps/desktop/electron/session-windows.ts
@@ -17,8 +17,8 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
// false`, so a streamed answer stalled until the window regained focus.
//
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
-// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
-// blurred/occluded windows. A streaming chat app must keep painting in the
+// screen through a bounded timer flush, which Chromium clamps for blurred/
+// occluded windows. A streaming chat app must keep painting in the
// background, so every chat window opts out. The preload path is injected
// because it depends on the Electron entry's __dirname.
function chatWindowWebPreferences(preloadPath: string) {
diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx
index a3d1563262d2..fbbd2b94d5aa 100644
--- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx
+++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx
@@ -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.
diff --git a/apps/desktop/src/app/chat/session-tile.tsx b/apps/desktop/src/app/chat/session-tile.tsx
index 6ca29e70ade9..dde25d3d2019 100644
--- a/apps/desktop/src/app/chat/session-tile.tsx
+++ b/apps/desktop/src/app/chat/session-tile.tsx
@@ -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}
diff --git a/apps/desktop/src/app/model-picker-overlay.tsx b/apps/desktop/src/app/model-picker-overlay.tsx
index e51a0e4de1be..b6599a14fb2a 100644
--- a/apps/desktop/src/app/model-picker-overlay.tsx
+++ b/apps/desktop/src/app/model-picker-overlay.tsx
@@ -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
diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
index 875ef0ca01fb..928294c4825c 100644
--- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
+++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx
@@ -433,7 +433,7 @@ export function PetOverlayApp() {
-
+
{/* Hearts on the popped-out pet — identical to in-window. */}
({
+ ensureTerminal: vi.fn()
+}))
+
+vi.mock('./workspace', () => ({
+ TerminalWorkspace: () =>
+}))
+
+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()
+ const request = vi.fn((callback: FrameRequestCallback) => {
+ const id = nextId++
+ frames.set(id, callback)
+
+ return id
+ })
+ const cancel = vi.fn((id: number) => {
+ frames.delete(id)
+ })
+
+ Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
+ Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
+
+ return {
+ cancel,
+ pending: () => frames.size,
+ request,
+ runNext: () => {
+ const next = frames.entries().next().value
+
+ if (!next) {
+ throw new Error('No pending RAF')
+ }
+
+ const [id, callback] = next
+ frames.delete(id)
+ callback(0)
+ }
+ }
+}
+
+function Harness() {
+ return (
+ <>
+
+ undefined} />
+ >
+ )
+}
+
+describe('PersistentTerminal rect tracking', () => {
+ beforeEach(() => {
+ ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+ setVisibility(false)
+ 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()
+
+ 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()
+
+ 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()
+
+ 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()
+ 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()
+
+ expect(raf.request).not.toHaveBeenCalled()
+
+ act(() => window.dispatchEvent(new Event('focus')))
+
+ expect(raf.pending()).toBe(1)
+ })
+})
diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
index e0ef66cedd85..3fe2edb04a78 100644
--- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
+++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
+
import { $terminalTakeover } from '../store'
import { ensureTerminal } from './terminals'
@@ -83,8 +85,23 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
let prev: Rect | null = null
let frame = 0
+ let stopped = false
+ let pauseController: ReturnType | null = null
+
+ const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
+
+ const cancelFrame = () => {
+ if (frame !== 0) {
+ window.cancelAnimationFrame(frame)
+ frame = 0
+ }
+ }
+
+ const measure = (): boolean => {
+ if (rendererPaused()) {
+ return false
+ }
- const tick = () => {
const r = slot.getBoundingClientRect()
// floor top/left + ceil right/bottom: overlay always covers the slot's
// full pixel footprint, so half-pixel rects can't leak page bg through.
@@ -99,14 +116,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)
diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/delta-flush.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/delta-flush.test.tsx
new file mode 100644
index 000000000000..1a572c329a52
--- /dev/null
+++ b/apps/desktop/src/app/session/hooks/use-message-stream/delta-flush.test.tsx
@@ -0,0 +1,111 @@
+import { QueryClient } from '@tanstack/react-query'
+import { act, cleanup, render } from '@testing-library/react'
+import { useEffect, useRef } from 'react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { ClientSessionState } from '@/app/types'
+import { createClientSessionState } from '@/lib/chat-runtime'
+
+import { useMessageStream } from './index'
+
+const SID = 'session-1'
+let appendAssistantDelta: ((sessionId: string, delta: string) => void) | null = null
+let states: Map
+type UpdateSessionState = (
+ sessionId: string,
+ updater: (state: ClientSessionState) => ClientSessionState,
+ storedSessionId?: string | null
+) => ClientSessionState
+let updateSessionState: ReturnType>
+
+function Harness() {
+ const activeSessionIdRef = useRef(SID)
+ const sessionStateByRuntimeIdRef = useRef(states)
+ const queryClientRef = useRef(new QueryClient())
+
+ const stream = useMessageStream({
+ activeSessionIdRef,
+ hydrateFromStoredSession: vi.fn(async () => undefined),
+ queryClient: queryClientRef.current,
+ refreshHermesConfig: vi.fn(async () => undefined),
+ refreshSessions: vi.fn(async () => undefined),
+ sessionStateByRuntimeIdRef,
+ updateSessionState
+ })
+
+ useEffect(() => {
+ appendAssistantDelta = stream.appendAssistantDelta
+ }, [stream.appendAssistantDelta])
+
+ return null
+}
+
+function mountStream() {
+ render()
+ expect(appendAssistantDelta).not.toBeNull()
+}
+
+function assistantText() {
+ const message = states.get(SID)?.messages.at(-1)
+ const part = message?.parts.at(-1)
+
+ return part?.type === 'text' ? part.text : ''
+}
+
+describe('useMessageStream delta flush scheduling', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ appendAssistantDelta = null
+ states = new Map()
+ updateSessionState = vi.fn((sessionId: string, updater: (state: ClientSessionState) => ClientSessionState) => {
+ const next = updater(states.get(sessionId) ?? createClientSessionState())
+ states.set(sessionId, next)
+
+ return next
+ })
+ vi.spyOn(performance, 'now').mockReturnValue(100)
+ vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
+ vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined)
+ vi.spyOn(document, 'hasFocus').mockReturnValue(false)
+ })
+
+ afterEach(() => {
+ cleanup()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('flushes streaming text on a bounded timer while the window is unfocused', async () => {
+ mountStream()
+
+ act(() => appendAssistantDelta!(SID, 'still streaming'))
+
+ expect(window.requestAnimationFrame).not.toHaveBeenCalled()
+ expect(assistantText()).toBe('')
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(0)
+ })
+
+ expect(assistantText()).toBe('still streaming')
+ })
+
+ it('cancels the pending timer on unmount and flushes exactly once', async () => {
+ vi.mocked(performance.now).mockReturnValue(0)
+ mountStream()
+
+ act(() => appendAssistantDelta!(SID, 'final delta'))
+ expect(vi.getTimerCount()).toBe(1)
+
+ cleanup()
+
+ expect(vi.getTimerCount()).toBe(0)
+ expect(assistantText()).toBe('final delta')
+ const updatesAfterUnmount = updateSessionState.mock.calls.length
+
+ await vi.advanceTimersByTimeAsync(100)
+
+ expect(updateSessionState).toHaveBeenCalledTimes(updatesAfterUnmount)
+ expect(window.requestAnimationFrame).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts
index 1b55e9c69156..04fe08513e57 100644
--- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts
+++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts
@@ -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
diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
index b365a97e722a..05d3e9717dce 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -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
diff --git a/apps/desktop/src/components/assistant-ui/thread-remount.test.tsx b/apps/desktop/src/components/assistant-ui/thread-remount.test.tsx
new file mode 100644
index 000000000000..88454751e1b2
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/thread-remount.test.tsx
@@ -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({
+ messages: MESSAGES,
+ isRunning: false,
+ onNew: async () => {}
+ })
+
+ return (
+
+
+
+ )
+}
+
+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
+ // . 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( {}} 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( {}} onCancel={() => {}} />)
+ })
+
+ expect(screen.getByText('stable assistant reply')).toBe(assistantBefore)
+ expect(screen.getByText('hello from the user')).toBe(userBefore)
+ })
+})
diff --git a/apps/desktop/src/components/assistant-ui/thread/index.tsx b/apps/desktop/src/components/assistant-ui/thread/index.tsx
index 984b33e0e7e5..a5263e137101 100644
--- a/apps/desktop/src/components/assistant-ui/thread/index.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/index.tsx
@@ -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: () => (
-
+ callbacksRef.current.onBranchInNewChat?.(messageId) : undefined
+ }
+ onDismissError={hasDismissError ? messageId => callbacksRef.current.onDismissError?.(messageId) : undefined}
+ />
),
SystemMessage,
UserEditComposer: () => ,
UserMessage: () => (
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 ? (
diff --git a/apps/desktop/src/components/pet/pet-sprite.test.tsx b/apps/desktop/src/components/pet/pet-sprite.test.tsx
new file mode 100644
index 000000000000..6da30fdd5f7d
--- /dev/null
+++ b/apps/desktop/src/components/pet/pet-sprite.test.tsx
@@ -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()
+ 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()
+
+ expect(raf.request).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ raf.runNext(0)
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(1)
+ expect(raf.pending()).toBe(0)
+ expect(vi.getTimerCount()).toBe(1)
+
+ act(() => {
+ vi.advanceTimersByTime(60)
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(2)
+ })
+
+ it('cancels pending RAF work while the Electron window is paused and resumes when visible', () => {
+ const raf = installRaf()
+
+ render()
+
+ expect(raf.request).toHaveBeenCalledTimes(1)
+
+ act(() => {
+ windowStateCallback?.({ isMinimized: true, isVisible: false })
+ })
+
+ expect(raf.cancel).toHaveBeenCalledTimes(1)
+ expect(raf.pending()).toBe(0)
+
+ act(() => {
+ windowStateCallback?.({ isMinimized: false, isVisible: true })
+ })
+
+ expect(raf.request).toHaveBeenCalledTimes(2)
+ })
+
+ it('suspends while unfocused, resumes on focus, and leaves no work after unmount', () => {
+ const raf = installRaf()
+
+ render()
+
+ act(() => window.dispatchEvent(new Event('blur')))
+ expect(raf.pending()).toBe(0)
+
+ act(() => window.dispatchEvent(new Event('focus')))
+ expect(raf.pending()).toBe(1)
+
+ act(() => raf.runNext(0))
+ expect(vi.getTimerCount()).toBe(1)
+
+ cleanup()
+ expect(raf.pending()).toBe(0)
+ expect(vi.getTimerCount()).toBe(0)
+
+ act(() => {
+ vi.advanceTimersByTime(500)
+ window.dispatchEvent(new Event('focus'))
+ })
+ expect(raf.pending()).toBe(0)
+ })
+
+ it('keeps the intentionally non-activating pop-out overlay animated while unfocused', () => {
+ const raf = installRaf()
+
+ render()
+
+ act(() => window.dispatchEvent(new Event('blur')))
+
+ expect(raf.pending()).toBe(1)
+ })
+})
diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx
index a1e91f3ece10..9e79dc9953ce 100644
--- a/apps/desktop/src/components/pet/pet-sprite.tsx
+++ b/apps/desktop/src/components/pet/pet-sprite.tsx
@@ -1,5 +1,6 @@
import { memo, useEffect, useMemo, useRef } from 'react'
+import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
import { $petState, type PetInfo, type PetState } from '@/store/pet'
const DEFAULT_FRAME_W = 192
@@ -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(null)
const stateRef = useRef($petState.get())
const overrideRef = useRef(stateOverride)
const rowOverrideRef = useRef(rowOverride)
+ const kickAnimationRef = useRef<() => void>(() => undefined)
// Keep the override current without re-running the RAF setup effect.
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
overrideRef.current = stateOverride
+ kickAnimationRef.current()
}, [stateOverride])
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
useEffect(() => {
rowOverrideRef.current = rowOverride
+ kickAnimationRef.current()
}, [rowOverride])
const frameW = info.frameW ?? DEFAULT_FRAME_W
@@ -173,17 +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 | 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 (