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 ( ({ + $petMotion: { set: () => undefined }, + $petRoamDir: { set: () => undefined } +})) + +import { usePetRoam } from './use-pet-roam' + +let root: Root | null = null +let container: HTMLDivElement | null = null +let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null + +function render(ui: ReactNode) { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + + act(() => { + root!.render(ui) + }) +} + +function cleanup() { + if (root) { + act(() => { + root!.unmount() + }) + } + + container?.remove() + root = null + container = null +} + +function setVisibility(hidden: boolean) { + Object.defineProperty(document, 'hidden', { configurable: true, value: hidden }) + Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' }) +} + +function installWindowStateBridge() { + windowStateCallback = null + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => { + windowStateCallback = callback + + return () => { + if (windowStateCallback === callback) { + windowStateCallback = null + } + } + }) + } + }) +} + +function installRaf() { + const request = vi.fn((_callback: FrameRequestCallback) => 1) + const cancel = vi.fn() + + Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request }) + Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel }) + + return { cancel, request } +} + +function RoamHarness({ isInteracting = () => false }: { isInteracting?: () => boolean }) { + const ref = useRef(null) + + usePetRoam({ + commit: () => undefined, + containerRef: ref as RefObject, + enabled: true, + isInteracting, + loopMs: 1200, + overlayOpen: false, + petH: 64, + petW: 64 + }) + + return
+} + +describe('usePetRoam RAF scheduling', () => { + beforeEach(() => { + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + setVisibility(false) + 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() + + expect(raf.request).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + }) + + it('clears the pause wakeup while the Electron window is paused and restarts it when visible', () => { + const raf = installRaf() + + render() + expect(vi.getTimerCount()).toBe(1) + + windowStateCallback?.({ isMinimized: true, isVisible: false }) + + expect(raf.cancel).not.toHaveBeenCalled() + expect(raf.request).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + + windowStateCallback?.({ isMinimized: false, isVisible: true }) + + expect(raf.request).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + }) + + it('suspends idle movement while unfocused and cleans up its wake timer on unmount', () => { + const raf = installRaf() + + render() + expect(vi.getTimerCount()).toBe(1) + + act(() => window.dispatchEvent(new Event('blur'))) + expect(vi.getTimerCount()).toBe(0) + + act(() => window.dispatchEvent(new Event('focus'))) + expect(vi.getTimerCount()).toBe(1) + + cleanup() + expect(vi.getTimerCount()).toBe(0) + + act(() => { + vi.advanceTimersByTime(2000) + window.dispatchEvent(new Event('focus')) + }) + expect(raf.request).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/apps/desktop/src/components/pet/use-pet-roam.ts b/apps/desktop/src/components/pet/use-pet-roam.ts index 84d7b5386afb..e4d1e9a801b1 100644 --- a/apps/desktop/src/components/pet/use-pet-roam.ts +++ b/apps/desktop/src/components/pet/use-pet-roam.ts @@ -1,5 +1,6 @@ import { type RefObject, useEffect } from 'react' +import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause' import { $petMotion, $petRoamDir, type PetState } from '@/store/pet' import { chooseMove, dwellMs, PAUSE_DWELL, pickStrollTarget } from './roam-behavior' @@ -33,6 +34,8 @@ const DROP_SETTLE_MS = 90 const ARRIVE_EPS = 1.5 // Cap dt so a backgrounded/throttled tab can't teleport the pet on resume. const MAX_DT_S = 0.05 +// While paused, wake rarely to notice drags/replans without burning a 60Hz RAF. +const PAUSE_POLL_MS = 250 type Phase = 'pause' | 'walk' | 'fall' | 'jump' @@ -114,6 +117,9 @@ export function usePetRoam({ let pauseUntil = performance.now() + rand(400, 1200) let last = performance.now() let raf = 0 + let pauseTimer = 0 + let stopped = false + let pauseController: ReturnType | null = null let walkTargetX = cur.x let curLedge: Ledge | null = null @@ -137,6 +143,61 @@ export function usePetRoam({ $petRoamDir.set(dir) } + const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden' + + const cancelRaf = () => { + if (raf !== 0) { + window.cancelAnimationFrame(raf) + raf = 0 + } + } + + const cancelPauseTimer = () => { + if (pauseTimer !== 0) { + window.clearTimeout(pauseTimer) + pauseTimer = 0 + } + } + + const clearScheduled = () => { + cancelRaf() + cancelPauseTimer() + } + + const schedule = (now = performance.now()) => { + if (stopped || rendererPaused() || raf !== 0 || pauseTimer !== 0) { + return + } + + if (phase === 'pause') { + const delay = Math.max(0, pauseUntil - now) + + if (delay > 0) { + pauseTimer = window.setTimeout(() => { + pauseTimer = 0 + step(performance.now()) + }, Math.min(delay, PAUSE_POLL_MS)) + + return + } + } + + raf = window.requestAnimationFrame(step) + } + + const handleVisibilityChange = () => { + clearScheduled() + last = performance.now() + + if (rendererPaused()) { + signal(null, 0) + + return + } + + schedule(last) + } + const beginPause = (now: number) => { phase = 'pause' pauseUntil = now + dwellMs(PAUSE_DWELL) @@ -209,6 +270,15 @@ export function usePetRoam({ } const step = (now: number) => { + raf = 0 + pauseTimer = 0 + + if (stopped || rendererPaused()) { + signal(null, 0) + + return + } + const dt = Math.min(MAX_DT_S, (now - last) / 1000) last = now @@ -223,7 +293,7 @@ export function usePetRoam({ // Short settle so the pet falls right after you drop it, not seconds later. pauseUntil = now + DROP_SETTLE_MS signal(null, 0) - raf = requestAnimationFrame(step) + schedule(now) return } @@ -300,13 +370,16 @@ export function usePetRoam({ } } - raf = requestAnimationFrame(step) + schedule(now) } - raf = requestAnimationFrame(step) + pauseController = createRendererLoopPauseController(handleVisibilityChange) + schedule() return () => { - cancelAnimationFrame(raf) + stopped = true + clearScheduled() + pauseController?.dispose() signal(null, 0) // Hand the final position back to React so its `style` matches the DOM once // the loop stops re-asserting it. diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index c3405e404a2a..debae77f457b 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -465,6 +465,8 @@ export interface HermesTitleBarTheme { export interface HermesWindowState { isFullscreen: boolean + isMinimized?: boolean + isVisible?: boolean nativeOverlayWidth: number windowButtonPosition: { x: number; y: number } | null } diff --git a/apps/desktop/src/lib/renderer-loop-pause.ts b/apps/desktop/src/lib/renderer-loop-pause.ts new file mode 100644 index 000000000000..88b9e3559bc7 --- /dev/null +++ b/apps/desktop/src/lib/renderer-loop-pause.ts @@ -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 + } +} diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts index 5d3b890c2883..46152e076d32 100644 --- a/apps/desktop/src/store/composer-status.ts +++ b/apps/desktop/src/store/composer-status.ts @@ -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 = {} + 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) } ) diff --git a/apps/desktop/src/store/session-states.ts b/apps/desktop/src/store/session-states.ts index 949d56e140ff..53ad8ee89a78 100644 --- a/apps/desktop/src/store/session-states.ts +++ b/apps/desktop/src/store/session-states.ts @@ -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-id→state 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) }