From 571b75792c26438039b47521fd359c9351088908 Mon Sep 17 00:00:00 2001 From: webtecnica Date: Tue, 21 Jul 2026 22:37:21 -0300 Subject: [PATCH 01/11] fix(desktop): stop renderer OOM from session.info heartbeat churn on $sessionStates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer OOMs every ~60s because periodic ~1/s session.info heartbeats churn the entire $sessionStates store on every tick even when nothing changed. Each heartbeat: 1. Called updateSessionState with the running-test updater, which always returned a new spread object — even when busy state hadn't changed — because the updater param was already a fresh spread. 2. publishSessionState then spread the full $sessionStates record and set it, firing every computed atom ($workingSessionIds, $attentionSessionIds) and their subscribers on every heartbeat. Over 60s × ~1/s heartbeat this continuous store churn creates millions of short-lived objects, amplifies React re-renders, and starves the GC, sending the renderer working set from ~350 MB to 1.2-5 GB before the OOM crash. Fix (two changes): 1. updateSessionState: pass the raw previous state (not a spread) to the updater so it can return the same reference on no-op. Skip the store write, publishSessionState, and syncSessionStateToView when the updater returned the same reference. The rotation signal from storedSessionId changes is now emitted directly from ensureSessionState since publishSessionState (and thus handleTransition) is skipped on no-op. 2. publishSessionState: guard with `prev === state` reference check (belt-and-suspenders for any other caller). Fixes #69016 --- .../session/hooks/use-session-state-cache.ts | 34 +++++++++++++++++-- apps/desktop/src/store/session-states.ts | 21 ++++++++++-- 2 files changed, 49 insertions(+), 6 deletions(-) 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..952c90be799e 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,21 @@ 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) + + if (sessionId === $activeSessionId.get()) { + setActiveSessionStoredIdRotation({ + nextStoredSessionId: storedSessionId, + previousStoredSessionId: existing.storedSessionId, + runtimeSessionId: sessionId + }) + } } if (storedSessionId) { @@ -268,7 +281,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/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) } From f8a554bced3122c10b220c0f847fca968eb88cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=92=8B=E6=96=B9=E6=98=8E?= Date: Fri, 12 Jun 2026 20:05:58 +0800 Subject: [PATCH 02/11] fix(desktop): keep message component types stable across Thread re-renders The component map Thread passes to the virtualizer listed the onBranchInNewChat / onCancel callbacks as useMemo deps. Whenever a parent re-render handed down a fresh callback identity, the memo rebuilt the map and produced new component *types*, so React unmounted and remounted every visible message. Async-rendered parts (shiki code blocks) collapsed and re-expanded on each remount, making the whole thread visibly jump. That is exactly what shipped in v0.15.1: the desktop controller passed an inline arrow for onBranchInNewChat, and the 15s status-snapshot poll re-rendered the controller, so threads with code blocks jumped every 15 seconds (layout-shift scores of 0.39 + 0.47 per cycle, measured via CDP). arrow away from regressing. Route the callbacks through a ref so the component types survive any parent re-render; only the callbacks' definedness stays a dep, because it gates UI (the user-message Stop button). Add a regression test that fails on the old code by asserting message DOM nodes keep their identity when callback props change identity. Tested on macOS arm64 (vitest + rebuilt app, CDP layout-shift instrumentation confirms zero shifts over multiple poll cycles). Co-Authored-By: Claude Fable 5 --- .../assistant-ui/thread-remount.test.tsx | 125 ++++++++++++++++++ .../components/assistant-ui/thread/index.tsx | 33 ++++- 2 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/thread-remount.test.tsx 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 ? ( From 7a5d534f5befbca0f16fac94a652f878952666d6 Mon Sep 17 00:00:00 2001 From: Ho Lim <166576253+HOYALIM@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:13:09 -0700 Subject: [PATCH 03/11] fix(desktop): gate idle renderer loops Signed-off-by: Ho Lim <166576253+HOYALIM@users.noreply.github.com> --- apps/desktop/electron/main.ts | 6 + .../terminal/persistent.test.tsx | 214 ++++++++++++++++++ .../app/right-sidebar/terminal/persistent.tsx | 71 +++++- .../src/components/pet/pet-sprite.test.tsx | 190 ++++++++++++++++ .../desktop/src/components/pet/pet-sprite.tsx | 104 ++++++++- .../src/components/pet/use-pet-roam.test.tsx | 142 ++++++++++++ .../src/components/pet/use-pet-roam.ts | 81 ++++++- apps/desktop/src/global.d.ts | 2 + apps/desktop/src/lib/renderer-loop-pause.ts | 30 +++ 9 files changed, 822 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx create mode 100644 apps/desktop/src/components/pet/pet-sprite.test.tsx create mode 100644 apps/desktop/src/components/pet/use-pet-roam.test.tsx create mode 100644 apps/desktop/src/lib/renderer-loop-pause.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index cf38981f1824..d6fcb69d7f2d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -4939,6 +4939,8 @@ function getNativeOverlayWidth() { function getWindowState(win = mainWindow) { return { isFullscreen: Boolean(win?.isFullScreen?.()), + isMinimized: Boolean(win?.isMinimized?.()), + isVisible: Boolean(win?.isVisible?.()), nativeOverlayWidth: getNativeOverlayWidth(), windowButtonPosition: getWindowButtonPosition() } @@ -8968,6 +8970,10 @@ function createWindow() { mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) mainWindow.on('leave-full-screen', () => sendWindowStateChanged(false)) + mainWindow.on('minimize', () => sendWindowStateChanged()) + mainWindow.on('restore', () => sendWindowStateChanged()) + mainWindow.on('hide', () => sendWindowStateChanged()) + mainWindow.on('show', () => sendWindowStateChanged()) // Reopen where the user left off. resized/moved settle once per drag; close is // the cross-platform backstop, flushed synchronously before the window is gone. diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx new file mode 100644 index 000000000000..04fb3b9cfa79 --- /dev/null +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx @@ -0,0 +1,214 @@ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { PersistentTerminal, TerminalSlot } from './persistent' + +vi.mock('./terminals', () => ({ + ensureTerminal: vi.fn() +})) + +vi.mock('./workspace', () => ({ + TerminalWorkspace: () =>
+})) + +let resizeObserverCallback: ResizeObserverCallback | null = null +let root: Root | null = null +let container: HTMLDivElement | null = null +let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null + +function render(ui: ReactNode) { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + + act(() => { + root!.render(ui) + }) +} + +function cleanup() { + if (root) { + act(() => { + root!.unmount() + }) + } + + container?.remove() + root = null + container = null +} + +function setVisibility(hidden: boolean) { + Object.defineProperty(document, 'hidden', { configurable: true, value: hidden }) + Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' }) +} + +function installWindowStateBridge() { + windowStateCallback = null + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => { + windowStateCallback = callback + + return () => { + if (windowStateCallback === callback) { + windowStateCallback = null + } + } + }) + } + }) +} + +function rect(top: number, left: number, width: number, height: number): DOMRect { + return { + bottom: top + height, + height, + left, + right: left + width, + top, + width, + x: left, + y: top, + toJSON: () => ({}) + } as DOMRect +} + +function installRaf() { + let nextId = 1 + const frames = new Map() + 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) + installWindowStateBridge() + resizeObserverCallback = null + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(callback: ResizeObserverCallback) { + resizeObserverCallback = callback + } + + disconnect = vi.fn() + observe = vi.fn() + unobserve = vi.fn() + } as unknown as typeof ResizeObserver + ) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.restoreAllMocks() + setVisibility(false) + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + }) + + it('settles after rect changes instead of polling forever', () => { + const raf = installRaf() + let currentRect = rect(10, 20, 200, 100) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect) + + render() + + expect(raf.request).toHaveBeenCalledTimes(1) + + act(() => { + raf.runNext() + }) + + expect(raf.request).toHaveBeenCalledTimes(1) + expect(raf.pending()).toBe(0) + + currentRect = rect(12, 24, 220, 120) + act(() => { + resizeObserverCallback?.([], {} as ResizeObserver) + }) + + expect(raf.request).toHaveBeenCalledTimes(2) + + act(() => { + raf.runNext() + }) + + expect(raf.request).toHaveBeenCalledTimes(3) + + act(() => { + raf.runNext() + }) + + expect(raf.request).toHaveBeenCalledTimes(3) + expect(raf.pending()).toBe(0) + }) + + it('does not schedule rect RAFs while the Electron window is paused, then resumes when visible', () => { + const raf = installRaf() + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100)) + + render() + + 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) + }) +}) diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx index e0ef66cedd85..4d1421f0be28 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,60 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP if (next.width > 0 && next.height > 0) { setReady(true) } + + return true } - frame = requestAnimationFrame(tick) + return false } - tick() + const scheduleMeasure = () => { + if (stopped || rendererPaused() || frame !== 0) { + return + } - return () => cancelAnimationFrame(frame) + frame = window.requestAnimationFrame(() => { + frame = 0 + + if (measure()) { + scheduleMeasure() + } + }) + } + + const handleVisibilityChange = () => { + if (rendererPaused()) { + cancelFrame() + + return + } + + scheduleMeasure() + } + + const observer = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => { + scheduleMeasure() + }) + + if (measure()) { + scheduleMeasure() + } + observer?.observe(slot) + window.addEventListener('resize', scheduleMeasure) + window.addEventListener('scroll', scheduleMeasure, true) + pauseController = createRendererLoopPauseController(handleVisibilityChange) + + return () => { + stopped = true + cancelFrame() + observer?.disconnect() + window.removeEventListener('resize', scheduleMeasure) + window.removeEventListener('scroll', scheduleMeasure, true) + pauseController?.dispose() + } }, [slot]) const visible = Boolean(rect && rect.width > 0 && rect.height > 0) 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..bee16e2a9ba2 --- /dev/null +++ b/apps/desktop/src/components/pet/pet-sprite.test.tsx @@ -0,0 +1,190 @@ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/store/pet', () => { + const listeners = new Set<(state: string) => void>() + + return { + $petState: { + get: () => 'idle', + listen: (callback: (state: string) => void) => { + listeners.add(callback) + + return () => { + listeners.delete(callback) + } + } + } + } +}) + +import { PetSprite } from './pet-sprite' + +const INFO = { + enabled: true, + frameH: 16, + frameW: 16, + framesPerState: 2, + loopMs: 120, + scale: 1, + spritesheetBase64: 'stub', + stateRows: ['idle'] +} + +let root: Root | null = null +let container: HTMLDivElement | null = null +let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null + +function render(ui: ReactNode) { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + + act(() => { + root!.render(ui) + }) +} + +function cleanup() { + if (root) { + act(() => { + root!.unmount() + }) + } + + container?.remove() + root = null + container = null +} + +function setVisibility(hidden: boolean) { + Object.defineProperty(document, 'hidden', { configurable: true, value: hidden }) + Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' }) +} + +function installWindowStateBridge() { + windowStateCallback = null + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => { + windowStateCallback = callback + + return () => { + if (windowStateCallback === callback) { + windowStateCallback = null + } + } + }) + } + }) +} + +function installRaf() { + let nextId = 1 + const frames = new Map() + const request = vi.fn((callback: FrameRequestCallback) => { + const id = nextId++ + frames.set(id, callback) + + return id + }) + const cancel = vi.fn((id: number) => { + frames.delete(id) + }) + + Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request }) + Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel }) + + return { + cancel, + pending: () => frames.size, + request, + runNext: (now: number) => { + const next = frames.entries().next().value + + if (!next) { + throw new Error('No pending RAF') + } + + const [id, callback] = next + frames.delete(id) + callback(now) + } + } +} + +describe('PetSprite RAF scheduling', () => { + beforeEach(() => { + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.useFakeTimers() + setVisibility(false) + installWindowStateBridge() + vi.stubGlobal( + 'Image', + class extends EventTarget { + complete = true + naturalWidth = 16 + src = '' + } as unknown as typeof Image + ) + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + clearRect: vi.fn(), + drawImage: vi.fn(), + imageSmoothingEnabled: false + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() + setVisibility(false) + delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop + }) + + it('sleeps between visible sprite frames instead of chaining RAFs', () => { + const raf = installRaf() + + render() + + 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) + }) +}) diff --git a/apps/desktop/src/components/pet/pet-sprite.tsx b/apps/desktop/src/components/pet/pet-sprite.tsx index a1e91f3ece10..bb00b54bf6d4 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 @@ -119,16 +120,19 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite 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 +177,75 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite // Track state via subscription, not a prop — no re-render on activity ticks. stateRef.current = $petState.get() - const unsubState = $petState.listen(next => { - stateRef.current = next - }) - let raf = 0 + let wakeTimer = 0 + let stopped = false let frame = 0 let lastStep = performance.now() let drawnFrame = -1 let drawnRow = -1 let activeRow = -1 let activeCount = -1 + let pauseController: ReturnType | null = null + + const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden' + + const cancelWakeTimer = () => { + if (wakeTimer !== 0) { + window.clearTimeout(wakeTimer) + wakeTimer = 0 + } + } + + const cancelRaf = () => { + if (raf !== 0) { + window.cancelAnimationFrame(raf) + raf = 0 + } + } + + const clearScheduled = () => { + cancelWakeTimer() + cancelRaf() + } + + const scheduleFrame = (delayMs = 0) => { + if (stopped || rendererPaused() || raf !== 0 || wakeTimer !== 0) { + return + } + + if (delayMs > 16) { + wakeTimer = window.setTimeout(() => { + wakeTimer = 0 + scheduleFrame() + }, delayMs) + + return + } + + raf = window.requestAnimationFrame(render) + } + + const kickAnimation = () => { + if (stopped || rendererPaused()) { + return + } + + cancelWakeTimer() + scheduleFrame() + } + + const handleVisibilityChange = () => { + clearScheduled() + + if (rendererPaused()) { + return + } + + lastStep = performance.now() + drawnFrame = -1 + kickAnimation() + } const rowIndexForState = (s: PetState): number => { for (const key of STATE_ALIASES[s] ?? [s]) { @@ -223,6 +285,12 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite } const render = (now: number) => { + raf = 0 + + if (stopped || rendererPaused()) { + return + } + const forcedRow = rowOverrideRef.current const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current) @@ -245,10 +313,13 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite frame %= count + if (!image.complete || image.naturalWidth <= 0) { + return + } + // Only touch the canvas when the visible cell actually changes. The RAF - // ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of - // the clear+draw work and keeps the main thread free. - if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) { + // wakes when a sprite cell is due, so the idle path avoids a 60Hz loop. + if (frame !== drawnFrame || row !== drawnRow) { const sx = frame * frameW const sy = row * frameH ctx.clearRect(0, 0, canvas.width, canvas.height) @@ -258,13 +329,26 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite drawnRow = row } - raf = requestAnimationFrame(render) + scheduleFrame(Math.max(0, stepMs - (now - lastStep))) } - raf = requestAnimationFrame(render) + kickAnimationRef.current = kickAnimation + + const unsubState = $petState.listen(next => { + stateRef.current = next + kickAnimation() + }) + + image.addEventListener('load', kickAnimation) + pauseController = createRendererLoopPauseController(handleVisibilityChange) + scheduleFrame() return () => { - cancelAnimationFrame(raf) + stopped = true + kickAnimationRef.current = () => undefined + clearScheduled() + image.removeEventListener('load', kickAnimation) + pauseController?.dispose() unsubState() } }, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows]) diff --git a/apps/desktop/src/components/pet/use-pet-roam.test.tsx b/apps/desktop/src/components/pet/use-pet-roam.test.tsx new file mode 100644 index 000000000000..007a1ba22c93 --- /dev/null +++ b/apps/desktop/src/components/pet/use-pet-roam.test.tsx @@ -0,0 +1,142 @@ +import { act, type ReactNode, type RefObject, useRef } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/store/pet', () => ({ + $petMotion: { set: () => undefined }, + $petRoamDir: { set: () => undefined } +})) + +import { usePetRoam } from './use-pet-roam' + +let root: Root | null = null +let container: HTMLDivElement | null = null +let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null + +function render(ui: ReactNode) { + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) + + act(() => { + root!.render(ui) + }) +} + +function cleanup() { + if (root) { + act(() => { + root!.unmount() + }) + } + + container?.remove() + root = null + container = null +} + +function setVisibility(hidden: boolean) { + Object.defineProperty(document, 'hidden', { configurable: true, value: hidden }) + Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' }) +} + +function installWindowStateBridge() { + windowStateCallback = null + Object.defineProperty(window, 'hermesDesktop', { + configurable: true, + value: { + onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => { + windowStateCallback = callback + + return () => { + if (windowStateCallback === callback) { + windowStateCallback = null + } + } + }) + } + }) +} + +function installRaf() { + const request = vi.fn((_callback: FrameRequestCallback) => 1) + const cancel = vi.fn() + + Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request }) + Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel }) + + return { cancel, request } +} + +function RoamHarness({ isInteracting = () => false }: { isInteracting?: () => boolean }) { + const ref = useRef(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) + 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) + }) +}) 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..3175069216e2 --- /dev/null +++ b/apps/desktop/src/lib/renderer-loop-pause.ts @@ -0,0 +1,30 @@ +interface WindowStatePayload { + isMinimized?: boolean + isVisible?: boolean +} + +export function createRendererLoopPauseController(onChange: () => void) { + let windowPaused = false + + const onVisibilityChange = () => onChange() + const offWindowState = window.hermesDesktop?.onWindowStateChanged?.((payload: WindowStatePayload) => { + const next = payload?.isMinimized === true || payload?.isVisible === false + + if (windowPaused === next) { + return + } + + windowPaused = next + onChange() + }) + + document.addEventListener('visibilitychange', onVisibilityChange) + + return { + dispose: () => { + document.removeEventListener('visibilitychange', onVisibilityChange) + offWindowState?.() + }, + isPaused: () => document.visibilityState === 'hidden' || windowPaused + } +} From 5c8ac975e753edec98db8c0809ad37366714f62c Mon Sep 17 00:00:00 2001 From: Ho Lim Date: Fri, 10 Jul 2026 08:47:08 -0700 Subject: [PATCH 04/11] fix(desktop): invalidate terminal overlay position on layout mutations Signed-off-by: Ho Lim --- .../terminal/persistent.test.tsx | 51 +++++++++++++++++++ .../app/right-sidebar/terminal/persistent.tsx | 14 +++++ 2 files changed, 65 insertions(+) diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx index 04fb3b9cfa79..97b672752668 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx @@ -13,6 +13,7 @@ vi.mock('./workspace', () => ({ })) let resizeObserverCallback: ResizeObserverCallback | null = null +let mutationObserverCallback: MutationCallback | null = null let root: Root | null = null let container: HTMLDivElement | null = null let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null @@ -125,6 +126,7 @@ describe('PersistentTerminal rect tracking', () => { setVisibility(false) installWindowStateBridge() resizeObserverCallback = null + mutationObserverCallback = null vi.stubGlobal( 'ResizeObserver', class { @@ -137,6 +139,18 @@ describe('PersistentTerminal rect tracking', () => { unobserve = vi.fn() } as unknown as typeof ResizeObserver ) + vi.stubGlobal( + 'MutationObserver', + class { + constructor(callback: MutationCallback) { + mutationObserverCallback = callback + } + + disconnect = vi.fn() + observe = vi.fn() + takeRecords = vi.fn(() => []) + } as unknown as typeof MutationObserver + ) }) afterEach(() => { @@ -184,6 +198,43 @@ describe('PersistentTerminal rect tracking', () => { 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() + + 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)) diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx index 4d1421f0be28..4165449e959f 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx @@ -153,11 +153,24 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP : new ResizeObserver(() => { scheduleMeasure() }) + const positionObserver = + typeof MutationObserver === 'undefined' + ? null + : new MutationObserver(() => { + scheduleMeasure() + }) 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 + }) + } window.addEventListener('resize', scheduleMeasure) window.addEventListener('scroll', scheduleMeasure, true) pauseController = createRendererLoopPauseController(handleVisibilityChange) @@ -166,6 +179,7 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP stopped = true cancelFrame() observer?.disconnect() + positionObserver?.disconnect() window.removeEventListener('resize', scheduleMeasure) window.removeEventListener('scroll', scheduleMeasure, true) pauseController?.dispose() From 651a313d2b8f9cf0732bea66a0cba46cc53175e8 Mon Sep 17 00:00:00 2001 From: Andy <51783311+andyylin@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:16:43 +0800 Subject: [PATCH 05/11] fix(desktop): suspend decorative work when inactive --- apps/desktop/electron/main.ts | 12 +- apps/desktop/electron/session-windows.test.ts | 4 +- apps/desktop/electron/session-windows.ts | 4 +- .../src/app/pet-overlay/pet-overlay-app.tsx | 2 +- .../terminal/persistent.test.tsx | 25 ++++ .../app/right-sidebar/terminal/persistent.tsx | 4 + .../use-message-stream/delta-flush.test.tsx | 111 ++++++++++++++++++ .../src/components/pet/pet-sprite.test.tsx | 36 ++++++ .../desktop/src/components/pet/pet-sprite.tsx | 8 +- .../src/components/pet/use-pet-roam.test.tsx | 24 ++++ apps/desktop/src/lib/renderer-loop-pause.ts | 24 +++- 11 files changed, 238 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/delta-flush.test.tsx diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index d6fcb69d7f2d..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; @@ -8900,9 +8900,9 @@ function createWindow() { show: false, backgroundColor: getWindowBackgroundColor(), // Shared with the secondary session windows (chatWindowWebPreferences) so - // both keep `backgroundThrottling: false` — the chat transcript streams via - // a requestAnimationFrame-gated flush that Chromium pauses for blurred - // windows, stalling the live answer until refocus. See session-windows.ts. + // both keep `backgroundThrottling: false` — the chat transcript uses a + // bounded timer flush that Chromium clamps for blurred windows, stalling + // the live answer until refocus. See session-windows.ts. webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) 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/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. */} { beforeEach(() => { ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true setVisibility(false) + vi.spyOn(document, 'hasFocus').mockReturnValue(true) installWindowStateBridge() resizeObserverCallback = null mutationObserverCallback = null @@ -262,4 +263,28 @@ describe('PersistentTerminal rect tracking', () => { expect(raf.request).toHaveBeenCalledTimes(2) }) + + it('suspends while unfocused and cancels every callback on unmount', () => { + const raf = installRaf() + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100)) + + render() + 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) + }) }) diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx index 4165449e959f..5827ec0a8795 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx @@ -153,6 +153,7 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP : new ResizeObserver(() => { scheduleMeasure() }) + const positionObserver = typeof MutationObserver === 'undefined' ? null @@ -163,7 +164,9 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP if (measure()) { scheduleMeasure() } + observer?.observe(slot) + for (let node: HTMLElement | null = slot; node; node = node.parentElement) { positionObserver?.observe(node, { attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-state'], @@ -171,6 +174,7 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP childList: true }) } + window.addEventListener('resize', scheduleMeasure) window.addEventListener('scroll', scheduleMeasure, true) pauseController = createRendererLoopPauseController(handleVisibilityChange) 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/components/pet/pet-sprite.test.tsx b/apps/desktop/src/components/pet/pet-sprite.test.tsx index bee16e2a9ba2..6da30fdd5f7d 100644 --- a/apps/desktop/src/components/pet/pet-sprite.test.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.test.tsx @@ -120,6 +120,7 @@ describe('PetSprite RAF scheduling', () => { ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true vi.useFakeTimers() setVisibility(false) + vi.spyOn(document, 'hasFocus').mockReturnValue(true) installWindowStateBridge() vi.stubGlobal( 'Image', @@ -187,4 +188,39 @@ describe('PetSprite RAF scheduling', () => { expect(raf.request).toHaveBeenCalledTimes(2) }) + + it('suspends while unfocused, resumes on focus, and leaves no work after unmount', () => { + const raf = installRaf() + + render() + + 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 bb00b54bf6d4..9e79dc9953ce 100644 --- a/apps/desktop/src/components/pet/pet-sprite.tsx +++ b/apps/desktop/src/components/pet/pet-sprite.tsx @@ -92,6 +92,8 @@ export function roamWalkRow(dir: -1 | 0 | 1, stateRows?: string[]): { row?: stri interface PetSpriteProps { info: PetInfo + /** Keep animating in a deliberately non-activating visible window, such as the pop-out pet overlay. */ + pauseWhenUnfocused?: boolean /** On-screen scale multiplier applied on top of the pet's native scale. */ zoom?: number /** @@ -115,7 +117,7 @@ interface PetSpriteProps { * with `memo`, this component effectively never re-renders after mount until * the pet itself changes. */ -function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSpriteProps) { +function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride, pauseWhenUnfocused = true }: PetSpriteProps) { const canvasRef = useRef(null) const stateRef = useRef($petState.get()) const overrideRef = useRef(stateOverride) @@ -340,7 +342,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite }) image.addEventListener('load', kickAnimation) - pauseController = createRendererLoopPauseController(handleVisibilityChange) + pauseController = createRendererLoopPauseController(handleVisibilityChange, { pauseWhenUnfocused }) scheduleFrame() return () => { @@ -351,7 +353,7 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite pauseController?.dispose() unsubState() } - }, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows]) + }, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows, pauseWhenUnfocused]) return ( { ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true vi.useFakeTimers() setVisibility(false) + vi.spyOn(document, 'hasFocus').mockReturnValue(true) installWindowStateBridge() vi.spyOn(Math, 'random').mockReturnValue(0) vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ @@ -139,4 +140,27 @@ describe('usePetRoam RAF scheduling', () => { expect(raf.request).not.toHaveBeenCalled() expect(vi.getTimerCount()).toBe(1) }) + + it('suspends idle movement while unfocused and cleans up its wake timer on unmount', () => { + const raf = installRaf() + + render() + 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/lib/renderer-loop-pause.ts b/apps/desktop/src/lib/renderer-loop-pause.ts index 3175069216e2..88b9e3559bc7 100644 --- a/apps/desktop/src/lib/renderer-loop-pause.ts +++ b/apps/desktop/src/lib/renderer-loop-pause.ts @@ -3,10 +3,26 @@ interface WindowStatePayload { isVisible?: boolean } -export function createRendererLoopPauseController(onChange: () => void) { +export function createRendererLoopPauseController(onChange: () => void, { pauseWhenUnfocused = true } = {}) { let windowPaused = false + let windowFocused = document.hasFocus() const onVisibilityChange = () => onChange() + + const onBlur = () => { + if (windowFocused) { + windowFocused = false + onChange() + } + } + + const onFocus = () => { + if (!windowFocused) { + windowFocused = true + onChange() + } + } + const offWindowState = window.hermesDesktop?.onWindowStateChanged?.((payload: WindowStatePayload) => { const next = payload?.isMinimized === true || payload?.isVisible === false @@ -19,12 +35,16 @@ export function createRendererLoopPauseController(onChange: () => void) { }) document.addEventListener('visibilitychange', onVisibilityChange) + window.addEventListener('blur', onBlur) + window.addEventListener('focus', onFocus) return { dispose: () => { document.removeEventListener('visibilitychange', onVisibilityChange) + window.removeEventListener('blur', onBlur) + window.removeEventListener('focus', onFocus) offWindowState?.() }, - isPaused: () => document.visibilityState === 'hidden' || windowPaused + isPaused: () => document.visibilityState === 'hidden' || (pauseWhenUnfocused && !windowFocused) || windowPaused } } From 9aaabdcbf40e2f1afa84130cda1b76acdb6b0994 Mon Sep 17 00:00:00 2001 From: Andy <51783311+andyylin@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:44:44 +0800 Subject: [PATCH 06/11] fix(desktop): cover nested terminal layout changes --- .../terminal/persistent.test.tsx | 22 ++++++++++++++++++- .../app/right-sidebar/terminal/persistent.tsx | 6 +++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx index 638beaae1a8d..cc6b15c1507f 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx @@ -14,6 +14,7 @@ vi.mock('./workspace', () => ({ let resizeObserverCallback: ResizeObserverCallback | null = null let mutationObserverCallback: MutationCallback | null = null +let mutationObserveCalls: Array<{ options?: MutationObserverInit; target: Node }> = [] let root: Root | null = null let container: HTMLDivElement | null = null let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null @@ -128,6 +129,7 @@ describe('PersistentTerminal rect tracking', () => { installWindowStateBridge() resizeObserverCallback = null mutationObserverCallback = null + mutationObserveCalls = [] vi.stubGlobal( 'ResizeObserver', class { @@ -148,7 +150,9 @@ describe('PersistentTerminal rect tracking', () => { } disconnect = vi.fn() - observe = vi.fn() + observe = vi.fn((target: Node, options?: MutationObserverInit) => { + mutationObserveCalls.push({ options, target }) + }) takeRecords = vi.fn(() => []) } as unknown as typeof MutationObserver ) @@ -206,6 +210,8 @@ describe('PersistentTerminal rect tracking', () => { render() + expect(mutationObserveCalls.some(call => call.options?.subtree === true)).toBe(true) + act(() => { raf.runNext() }) @@ -287,4 +293,18 @@ describe('PersistentTerminal rect tracking', () => { }) 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 5827ec0a8795..3fe2edb04a78 100644 --- a/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx +++ b/apps/desktop/src/app/right-sidebar/terminal/persistent.tsx @@ -161,6 +161,8 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP scheduleMeasure() }) + pauseController = createRendererLoopPauseController(handleVisibilityChange) + if (measure()) { scheduleMeasure() } @@ -171,13 +173,13 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP positionObserver?.observe(node, { attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-state'], attributes: true, - childList: true + childList: true, + subtree: true }) } window.addEventListener('resize', scheduleMeasure) window.addEventListener('scroll', scheduleMeasure, true) - pauseController = createRendererLoopPauseController(handleVisibilityChange) return () => { stopped = true From c74f48b62e7005ef6894f2740afbbf0743b78432 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 21:03:27 -0500 Subject: [PATCH 07/11] fix(desktop): null-guard the rotation signal fired from ensureSessionState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The salvaged no-op-publish guard moved the compression-rotation signal into ensureSessionState, where storedSessionId is string|null. A cleared stored id is a detach, not a rotation — firing the event with a null next id would send the route-follow effect chasing nothing (and tsc rejects it). Guard on a real next id. --- apps/desktop/src/app/session/hooks/use-session-state-cache.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 952c90be799e..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 @@ -118,7 +118,9 @@ export function useSessionStateCache({ if (existing.storedSessionId && existing.storedSessionId !== storedSessionId) { runtimeIdByStoredSessionIdRef.current.delete(existing.storedSessionId) - if (sessionId === $activeSessionId.get()) { + // 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, From 610762472773efa11bae533b3fe6983395c497f4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 21:03:27 -0500 Subject: [PATCH 08/11] perf(desktop): stop the model-picker overlay re-rendering per streaming token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModelPickerOverlay subscribed to $focusedSessionState whole — a projection of $sessionStates, republished on every message delta — to read two fields that essentially never change (model, provider). The overlay is mounted app-wide and unconditionally renders the un-memoized ModelPickerDialog (closed), so the focused session's stream re-ran the dialog's full hook body ~30x/s. Same defect class and same fix as the statusbar (#72163): select each scalar through useStoreSelector so unchanged values bail out. --- apps/desktop/src/app/model-picker-overlay.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 From fc3af6095f8be5524c01f0166a39ed7bb0606e5c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 21:03:28 -0500 Subject: [PATCH 09/11] perf(desktop): stop cross-session churn re-rendering every composer status stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $statusItemsBySession rebuilt its whole output map — fresh arrays, fresh item objects — on every recompute, and its inputs churn constantly (subagent ticks, 5s background polls, todo updates, in ANY session). A whole-map useStore in ComposerStatusStack then re-rendered every mounted stack — one per open tile — on all of it, and the fresh item objects defeated row memoization downstream. Two halves, per the documented slice contract (use-session-slice.ts): - producer: stabilize $statusItemsBySession per key — 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'). - consumer: the stack subscribes to its OWN session's slice via useSessionSlice instead of the whole map. --- .../app/chat/composer/status-stack/index.tsx | 18 ++++---- apps/desktop/src/store/composer-status.ts | 42 ++++++++++++++++++- 2 files changed, 51 insertions(+), 9 deletions(-) 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/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) } ) From 132654cdecf13f2f4d5ece81bfd3c9c79e41321a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 21:03:43 -0500 Subject: [PATCH 10/11] perf(desktop): finish narrowing the statusbar's store subscriptions #72163 narrowed $focusedSessionState but left two whole-store reads in the same hook paying the same price: - $subagentsBySession: only two COUNTS are rendered, but the whole-map subscription re-ran the hook (rebuilding all ~9 statusbar items) on every subagent progress tick in any session. Select the two scalars. - $sessions: only one row's started_at is read, but any session-list write (title update, poll refresh, archive) re-ran the hook. Select the one scalar. --- .../app/shell/hooks/use-statusbar-items.tsx | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) 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 From 3d6c32b061d54185958c39f92b11e6ca50728599 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 21:03:43 -0500 Subject: [PATCH 11/11] perf(desktop): derive the tab menu's row narrowly instead of subscribing wholesale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionTabMenu subscribed to $sessions + $projectTree for values it never rendered (the row was re-read imperatively), so every tab of every tile re-rendered its menu wrapper on any session-list or project-tree churn — for a context menu that is almost never open. Same class as the TreeGroup fix (#72245): derive the three scalars the menu actually shows (pinId, title, profile) behind a keyed bail-out, so the wrapper only re-renders when one of them changes. --- apps/desktop/src/app/chat/session-tile.tsx | 47 +++++++++++++++++----- 1 file changed, 38 insertions(+), 9 deletions(-) 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}