mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
Merge pull request #72388 from NousResearch/bb/desktop-render-salvage
perf(desktop): hold 60fps under load — salvage three render-churn fixes + finish the selector sweep
This commit is contained in:
commit
af1cc1c245
22 changed files with 1472 additions and 83 deletions
|
|
@ -377,9 +377,9 @@ if (IS_WINDOWS) {
|
|||
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)
|
||||
|
||||
// Keep the renderer running at full speed while the window is in the background
|
||||
// or occluded. The chat transcript streams to screen through a
|
||||
// requestAnimationFrame-gated flush; Chromium pauses rAF (and clamps timers)
|
||||
// for backgrounded/occluded renderers, so without these the live answer stalls
|
||||
// or occluded. The chat transcript streams to screen through a bounded timer
|
||||
// flush; Chromium clamps timers for backgrounded/occluded renderers, so without
|
||||
// these the live answer stalls
|
||||
// whenever the window loses focus (switching to your editor mid-turn, detached
|
||||
// devtools, another window covering it) and only paints on refocus or refresh.
|
||||
// `backgroundThrottling: false` on the BrowserWindow covers the blurred case;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -193,8 +193,8 @@ test('registry trims the session id before keying', () => {
|
|||
|
||||
test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => {
|
||||
// Regression: secondary session windows used to omit this flag, so a streamed
|
||||
// answer stalled until the window regained focus (Chromium pauses the
|
||||
// requestAnimationFrame-gated transcript flush for backgrounded windows).
|
||||
// answer stalled until the window regained focus (Chromium clamps the
|
||||
// transcript flush timer for backgrounded windows).
|
||||
const prefs = chatWindowWebPreferences('/tmp/preload.cjs')
|
||||
|
||||
assert.equal(prefs.backgroundThrottling, false)
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ const SESSION_WINDOW_MIN_HEIGHT = 620
|
|||
// false`, so a streamed answer stalled until the window regained focus.
|
||||
//
|
||||
// `backgroundThrottling: false` is load-bearing: the transcript streams to the
|
||||
// screen through a requestAnimationFrame-gated flush, which Chromium pauses for
|
||||
// blurred/occluded windows. A streaming chat app must keep painting in the
|
||||
// screen through a bounded timer flush, which Chromium clamps for blurred/
|
||||
// occluded windows. A streaming chat app must keep painting in the
|
||||
// background, so every chat window opts out. The preload path is injected
|
||||
// because it depends on the Electron entry's __dirname.
|
||||
function chatWindowWebPreferences(preloadPath: string) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
||||
|
||||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useModelControls } from '@/app/session/hooks/use-model-controls'
|
||||
|
|
@ -424,6 +424,40 @@ export function stackSessionTilesIntoMain(): void {
|
|||
}
|
||||
}
|
||||
|
||||
/** The three scalars the tab menu actually renders, derived from the stored
|
||||
* row. Subscribing to `$sessions` + `$projectTree` wholesale re-rendered
|
||||
* every tab's menu wrapper on ANY session-list or tree churn (polls, title
|
||||
* updates in other sessions) — for a context menu that's almost never open.
|
||||
* Same class as the TreeGroup fix (#72245): derive narrowly, bail out unless
|
||||
* the derived values change. */
|
||||
function useTileMenuRow(storedSessionId: string): { pinId: string; profile?: string; title: string } {
|
||||
const cache = useRef<{ key: string; value: { pinId: string; profile?: string; title: string } } | null>(null)
|
||||
|
||||
const subscribe = useCallback((onChange: () => void) => {
|
||||
const offSessions = $sessions.listen(onChange)
|
||||
const offTree = $projectTree.listen(onChange)
|
||||
|
||||
return () => {
|
||||
offSessions()
|
||||
offTree()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useSyncExternalStore(subscribe, () => {
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
const pinId = stored ? sessionPinId(stored) : storedSessionId
|
||||
const title = tileTitle(storedSessionId)
|
||||
const profile = stored?.profile
|
||||
const key = `${pinId}\u0000${title}\u0000${profile ?? ''}`
|
||||
|
||||
if (cache.current?.key !== key) {
|
||||
cache.current = { key, value: { pinId, profile, title } }
|
||||
}
|
||||
|
||||
return cache.current.value
|
||||
})
|
||||
}
|
||||
|
||||
/** A session TAB's context menu: the full session verb set (pin, copy id, new
|
||||
* window, branch, rename, archive, delete) — the SAME menu a sidebar row
|
||||
* gets, targeted through the tile delegate (whose verbs are generic over
|
||||
|
|
@ -445,13 +479,8 @@ export function SessionTabMenu({
|
|||
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
|
||||
tabPaneId: string
|
||||
}) {
|
||||
// Subscribe for reactivity; the row is read imperatively via tileStoredRow
|
||||
// (which spans both sources), so the values themselves are unused here.
|
||||
useStore($sessions)
|
||||
useStore($projectTree)
|
||||
const { pinId, profile, title } = useTileMenuRow(storedSessionId)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const stored = tileStoredRow(storedSessionId)
|
||||
const pinId = stored ? sessionPinId(stored) : storedSessionId
|
||||
const pinned = pinnedSessionIds.includes(pinId)
|
||||
|
||||
return (
|
||||
|
|
@ -464,11 +493,11 @@ export function SessionTabMenu({
|
|||
onHideTabBar={onHideTabBar}
|
||||
onPin={() => (pinned ? unpinSession(pinId) : pinSession(pinId))}
|
||||
pinned={pinned}
|
||||
profile={stored?.profile}
|
||||
profile={profile}
|
||||
sessionId={storedSessionId}
|
||||
surface="tab"
|
||||
tabPaneId={tabPaneId}
|
||||
title={tileTitle(storedSessionId)}
|
||||
title={title}
|
||||
>
|
||||
{children}
|
||||
</SessionContextMenu>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -433,7 +433,7 @@ export function PetOverlayApp() {
|
|||
<PetBubble />
|
||||
</div>
|
||||
<div style={{ lineHeight: 0, position: 'relative' }}>
|
||||
<PetSprite info={info} />
|
||||
<PetSprite info={info} pauseWhenUnfocused={false} />
|
||||
|
||||
{/* Hearts on the popped-out pet — identical to in-window. */}
|
||||
<PetHeartField
|
||||
|
|
|
|||
310
apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx
Normal file
310
apps/desktop/src/app/right-sidebar/terminal/persistent.test.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PersistentTerminal, TerminalSlot } from './persistent'
|
||||
|
||||
vi.mock('./terminals', () => ({
|
||||
ensureTerminal: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./workspace', () => ({
|
||||
TerminalWorkspace: () => <div data-testid="terminal-workspace" />
|
||||
}))
|
||||
|
||||
let resizeObserverCallback: ResizeObserverCallback | null = null
|
||||
let mutationObserverCallback: MutationCallback | null = null
|
||||
let mutationObserveCalls: Array<{ options?: MutationObserverInit; target: Node }> = []
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
|
||||
|
||||
function render(ui: ReactNode) {
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root!.render(ui)
|
||||
})
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
}
|
||||
|
||||
function setVisibility(hidden: boolean) {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' })
|
||||
}
|
||||
|
||||
function installWindowStateBridge() {
|
||||
windowStateCallback = null
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
|
||||
windowStateCallback = callback
|
||||
|
||||
return () => {
|
||||
if (windowStateCallback === callback) {
|
||||
windowStateCallback = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function rect(top: number, left: number, width: number, height: number): DOMRect {
|
||||
return {
|
||||
bottom: top + height,
|
||||
height,
|
||||
left,
|
||||
right: left + width,
|
||||
top,
|
||||
width,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect
|
||||
}
|
||||
|
||||
function installRaf() {
|
||||
let nextId = 1
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
const request = vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
frames.set(id, callback)
|
||||
|
||||
return id
|
||||
})
|
||||
const cancel = vi.fn((id: number) => {
|
||||
frames.delete(id)
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
|
||||
|
||||
return {
|
||||
cancel,
|
||||
pending: () => frames.size,
|
||||
request,
|
||||
runNext: () => {
|
||||
const next = frames.entries().next().value
|
||||
|
||||
if (!next) {
|
||||
throw new Error('No pending RAF')
|
||||
}
|
||||
|
||||
const [id, callback] = next
|
||||
frames.delete(id)
|
||||
callback(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Harness() {
|
||||
return (
|
||||
<>
|
||||
<TerminalSlot className="slot" />
|
||||
<PersistentTerminal onAddSelectionToChat={() => undefined} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe('PersistentTerminal rect tracking', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
setVisibility(false)
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
|
||||
installWindowStateBridge()
|
||||
resizeObserverCallback = null
|
||||
mutationObserverCallback = null
|
||||
mutationObserveCalls = []
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeObserverCallback = callback
|
||||
}
|
||||
|
||||
disconnect = vi.fn()
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
} as unknown as typeof ResizeObserver
|
||||
)
|
||||
vi.stubGlobal(
|
||||
'MutationObserver',
|
||||
class {
|
||||
constructor(callback: MutationCallback) {
|
||||
mutationObserverCallback = callback
|
||||
}
|
||||
|
||||
disconnect = vi.fn()
|
||||
observe = vi.fn((target: Node, options?: MutationObserverInit) => {
|
||||
mutationObserveCalls.push({ options, target })
|
||||
})
|
||||
takeRecords = vi.fn(() => [])
|
||||
} as unknown as typeof MutationObserver
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
setVisibility(false)
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('settles after rect changes instead of polling forever', () => {
|
||||
const raf = installRaf()
|
||||
let currentRect = rect(10, 20, 200, 100)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect)
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
currentRect = rect(12, 24, 220, 120)
|
||||
act(() => {
|
||||
resizeObserverCallback?.([], {} as ResizeObserver)
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(3)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(3)
|
||||
expect(raf.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('remeasures when layout moves the slot without resizing it', () => {
|
||||
const raf = installRaf()
|
||||
let currentRect = rect(10, 20, 200, 100)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => currentRect)
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
expect(mutationObserveCalls.some(call => call.options?.subtree === true)).toBe(true)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
const overlay = container!.lastElementChild as HTMLElement
|
||||
expect(overlay.style.top).toBe('10px')
|
||||
expect(overlay.style.left).toBe('20px')
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
currentRect = rect(32, 48, 200, 100)
|
||||
act(() => {
|
||||
mutationObserverCallback?.([], {} as MutationObserver)
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(2)
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
expect(overlay.style.top).toBe('32px')
|
||||
expect(overlay.style.left).toBe('48px')
|
||||
|
||||
act(() => {
|
||||
raf.runNext()
|
||||
})
|
||||
|
||||
expect(raf.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not schedule rect RAFs while the Electron window is paused, then resumes when visible', () => {
|
||||
const raf = installRaf()
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
windowStateCallback?.({ isMinimized: true, isVisible: false })
|
||||
})
|
||||
|
||||
expect(raf.cancel).toHaveBeenCalledTimes(1)
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
resizeObserverCallback?.([], {} as ResizeObserver)
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
windowStateCallback?.({ isMinimized: false, isVisible: true })
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('suspends while unfocused and cancels every callback on unmount', () => {
|
||||
const raf = installRaf()
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
render(<Harness />)
|
||||
expect(raf.pending()).toBe(1)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('blur')))
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
expect(raf.pending()).toBe(1)
|
||||
|
||||
cleanup()
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
resizeObserverCallback?.([], {} as ResizeObserver)
|
||||
mutationObserverCallback?.([], {} as MutationObserver)
|
||||
})
|
||||
expect(raf.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('does not schedule an initial frame when mounted while unfocused', () => {
|
||||
const raf = installRaf()
|
||||
vi.mocked(document.hasFocus).mockReturnValue(false)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect(10, 20, 200, 100))
|
||||
|
||||
render(<Harness />)
|
||||
|
||||
expect(raf.request).not.toHaveBeenCalled()
|
||||
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
|
||||
expect(raf.pending()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
|
|||
import { atom } from 'nanostores'
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
|
||||
|
||||
import { $terminalTakeover } from '../store'
|
||||
|
||||
import { ensureTerminal } from './terminals'
|
||||
|
|
@ -83,8 +85,23 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
|||
|
||||
let prev: Rect | null = null
|
||||
let frame = 0
|
||||
let stopped = false
|
||||
let pauseController: ReturnType<typeof createRendererLoopPauseController> | null = null
|
||||
|
||||
const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
|
||||
|
||||
const cancelFrame = () => {
|
||||
if (frame !== 0) {
|
||||
window.cancelAnimationFrame(frame)
|
||||
frame = 0
|
||||
}
|
||||
}
|
||||
|
||||
const measure = (): boolean => {
|
||||
if (rendererPaused()) {
|
||||
return false
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const r = slot.getBoundingClientRect()
|
||||
// floor top/left + ceil right/bottom: overlay always covers the slot's
|
||||
// full pixel footprint, so half-pixel rects can't leak page bg through.
|
||||
|
|
@ -99,14 +116,80 @@ export function PersistentTerminal({ onAddSelectionToChat }: PersistentTerminalP
|
|||
if (next.width > 0 && next.height > 0) {
|
||||
setReady(true)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
return false
|
||||
}
|
||||
|
||||
tick()
|
||||
const scheduleMeasure = () => {
|
||||
if (stopped || rendererPaused() || frame !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return () => cancelAnimationFrame(frame)
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
|
||||
if (measure()) {
|
||||
scheduleMeasure()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (rendererPaused()) {
|
||||
cancelFrame()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
scheduleMeasure()
|
||||
}
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(() => {
|
||||
scheduleMeasure()
|
||||
})
|
||||
|
||||
const positionObserver =
|
||||
typeof MutationObserver === 'undefined'
|
||||
? null
|
||||
: new MutationObserver(() => {
|
||||
scheduleMeasure()
|
||||
})
|
||||
|
||||
pauseController = createRendererLoopPauseController(handleVisibilityChange)
|
||||
|
||||
if (measure()) {
|
||||
scheduleMeasure()
|
||||
}
|
||||
|
||||
observer?.observe(slot)
|
||||
|
||||
for (let node: HTMLElement | null = slot; node; node = node.parentElement) {
|
||||
positionObserver?.observe(node, {
|
||||
attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'data-state'],
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('resize', scheduleMeasure)
|
||||
window.addEventListener('scroll', scheduleMeasure, true)
|
||||
|
||||
return () => {
|
||||
stopped = true
|
||||
cancelFrame()
|
||||
observer?.disconnect()
|
||||
positionObserver?.disconnect()
|
||||
window.removeEventListener('resize', scheduleMeasure)
|
||||
window.removeEventListener('scroll', scheduleMeasure, true)
|
||||
pauseController?.dispose()
|
||||
}
|
||||
}, [slot])
|
||||
|
||||
const visible = Boolean(rect && rect.width > 0 && rect.height > 0)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, cleanup, render } from '@testing-library/react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
|
||||
const SID = 'session-1'
|
||||
let appendAssistantDelta: ((sessionId: string, delta: string) => void) | null = null
|
||||
let states: Map<string, ClientSessionState>
|
||||
type UpdateSessionState = (
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
storedSessionId?: string | null
|
||||
) => ClientSessionState
|
||||
let updateSessionState: ReturnType<typeof vi.fn<UpdateSessionState>>
|
||||
|
||||
function Harness() {
|
||||
const activeSessionIdRef = useRef<string | null>(SID)
|
||||
const sessionStateByRuntimeIdRef = useRef(states)
|
||||
const queryClientRef = useRef(new QueryClient())
|
||||
|
||||
const stream = useMessageStream({
|
||||
activeSessionIdRef,
|
||||
hydrateFromStoredSession: vi.fn(async () => undefined),
|
||||
queryClient: queryClientRef.current,
|
||||
refreshHermesConfig: vi.fn(async () => undefined),
|
||||
refreshSessions: vi.fn(async () => undefined),
|
||||
sessionStateByRuntimeIdRef,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
appendAssistantDelta = stream.appendAssistantDelta
|
||||
}, [stream.appendAssistantDelta])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function mountStream() {
|
||||
render(<Harness />)
|
||||
expect(appendAssistantDelta).not.toBeNull()
|
||||
}
|
||||
|
||||
function assistantText() {
|
||||
const message = states.get(SID)?.messages.at(-1)
|
||||
const part = message?.parts.at(-1)
|
||||
|
||||
return part?.type === 'text' ? part.text : ''
|
||||
}
|
||||
|
||||
describe('useMessageStream delta flush scheduling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
appendAssistantDelta = null
|
||||
states = new Map()
|
||||
updateSessionState = vi.fn((sessionId: string, updater: (state: ClientSessionState) => ClientSessionState) => {
|
||||
const next = updater(states.get(sessionId) ?? createClientSessionState())
|
||||
states.set(sessionId, next)
|
||||
|
||||
return next
|
||||
})
|
||||
vi.spyOn(performance, 'now').mockReturnValue(100)
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1)
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined)
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('flushes streaming text on a bounded timer while the window is unfocused', async () => {
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'still streaming'))
|
||||
|
||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
||||
expect(assistantText()).toBe('')
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
expect(assistantText()).toBe('still streaming')
|
||||
})
|
||||
|
||||
it('cancels the pending timer on unmount and flushes exactly once', async () => {
|
||||
vi.mocked(performance.now).mockReturnValue(0)
|
||||
mountStream()
|
||||
|
||||
act(() => appendAssistantDelta!(SID, 'final delta'))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
cleanup()
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(assistantText()).toBe('final delta')
|
||||
const updatesAfterUnmount = updateSessionState.mock.calls.length
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
|
||||
expect(updateSessionState).toHaveBeenCalledTimes(updatesAfterUnmount)
|
||||
expect(window.requestAnimationFrame).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
125
apps/desktop/src/components/assistant-ui/thread-remount.test.tsx
Normal file
125
apps/desktop/src/components/assistant-ui/thread-remount.test.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Thread } from './thread'
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
|
||||
unobserve() {}
|
||||
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver)
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
|
||||
window.setTimeout(() => callback(performance.now()), 0)
|
||||
)
|
||||
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
|
||||
|
||||
Element.prototype.scrollTo = function scrollTo() {}
|
||||
|
||||
Element.prototype.animate = function animate() {
|
||||
return {
|
||||
cancel: () => {},
|
||||
finished: Promise.resolve()
|
||||
} as unknown as Animation
|
||||
}
|
||||
|
||||
// jsdom returns 0 for offset*; the virtualizer reads those to size its
|
||||
// viewport. Fall through to client* or a sane default so virtualized
|
||||
// items render (same stub as streaming.test.tsx).
|
||||
function stubOffsetDimension(
|
||||
prop: 'offsetHeight' | 'offsetWidth',
|
||||
clientProp: 'clientHeight' | 'clientWidth',
|
||||
fallback: number
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, prop, {
|
||||
configurable: true,
|
||||
get() {
|
||||
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
|
||||
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
|
||||
|
||||
const createdAt = new Date('2026-05-01T00:00:00.000Z')
|
||||
|
||||
const MESSAGES: ThreadMessage[] = [
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'hello from the user' }],
|
||||
attachments: [],
|
||||
createdAt,
|
||||
metadata: { custom: {} }
|
||||
} as ThreadMessage,
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'stable assistant reply' }],
|
||||
status: { type: 'complete', reason: 'stop' },
|
||||
createdAt,
|
||||
metadata: {
|
||||
unstable_state: null,
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
]
|
||||
|
||||
function Harness({
|
||||
onBranchInNewChat,
|
||||
onCancel
|
||||
}: {
|
||||
onBranchInNewChat: (messageId: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages: MESSAGES,
|
||||
isRunning: false,
|
||||
onNew: async () => {}
|
||||
})
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<Thread onBranchInNewChat={onBranchInNewChat} onCancel={onCancel} />
|
||||
</AssistantRuntimeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('thread message mount stability', () => {
|
||||
// Regression: the desktop controller re-renders every 15s (status
|
||||
// snapshot poll) and used to pass freshly-created callbacks down to
|
||||
// <Thread/>. Those callbacks were deps of the `messageComponents`
|
||||
// useMemo, so new component *types* were created each poll and React
|
||||
// unmounted/remounted every visible message — shiki re-highlighted
|
||||
// code blocks and the whole thread visibly jumped.
|
||||
it('keeps message DOM nodes mounted when callback props get new identities', async () => {
|
||||
const { rerender } = render(<Harness onBranchInNewChat={() => {}} onCancel={() => {}} />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('stable assistant reply')).toBeTruthy()
|
||||
expect(screen.getByText('hello from the user')).toBeTruthy()
|
||||
})
|
||||
|
||||
const assistantBefore = screen.getByText('stable assistant reply')
|
||||
const userBefore = screen.getByText('hello from the user')
|
||||
|
||||
// Same data, new callback identities — exactly what a parent
|
||||
// re-render driven by an unrelated state update produces.
|
||||
await act(async () => {
|
||||
rerender(<Harness onBranchInNewChat={() => {}} onCancel={() => {}} />)
|
||||
})
|
||||
|
||||
expect(screen.getByText('stable assistant reply')).toBe(assistantBefore)
|
||||
expect(screen.getByText('hello from the user')).toBe(userBefore)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { type FC, useCallback, useMemo, useState } from 'react'
|
||||
import { type FC, useCallback, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { AssistantMessage } from '@/components/assistant-ui/thread/assistant-message'
|
||||
import { ThreadMessageList } from '@/components/assistant-ui/thread/list'
|
||||
|
|
@ -67,21 +67,44 @@ export const Thread: FC<{
|
|||
setRestoreConfirmTarget({ messageId, ...target })
|
||||
}, [])
|
||||
|
||||
// The values in this map are component *types*: when their identity
|
||||
// changes, React unmounts and remounts every visible message — async
|
||||
// re-rendered parts (shiki code blocks) collapse and re-expand, so the
|
||||
// whole thread visibly jumps. Parents re-render on unrelated state
|
||||
// (e.g. the 15s status-snapshot poll in the desktop controller) and
|
||||
// can't be trusted to keep callback identities stable (see #38333), so
|
||||
// route the callbacks through a ref instead of listing them as memo
|
||||
// deps. Only their definedness stays a dep — it gates UI (the user
|
||||
// Stop button, the restore-confirm affordance). Assigned during render
|
||||
// (the useStoreSelector pattern) so the ref never lags a render.
|
||||
const callbacksRef = useRef({ onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage })
|
||||
callbacksRef.current = { onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage }
|
||||
|
||||
const hasBranchInNewChat = Boolean(onBranchInNewChat)
|
||||
const hasCancel = Boolean(onCancel)
|
||||
const hasDismissError = Boolean(onDismissError)
|
||||
const hasRestoreToMessage = Boolean(onRestoreToMessage)
|
||||
|
||||
const messageComponents = useMemo(
|
||||
() => ({
|
||||
AssistantMessage: () => (
|
||||
<AssistantMessage onBranchInNewChat={onBranchInNewChat} onDismissError={onDismissError} />
|
||||
<AssistantMessage
|
||||
onBranchInNewChat={
|
||||
hasBranchInNewChat ? messageId => callbacksRef.current.onBranchInNewChat?.(messageId) : undefined
|
||||
}
|
||||
onDismissError={hasDismissError ? messageId => callbacksRef.current.onDismissError?.(messageId) : undefined}
|
||||
/>
|
||||
),
|
||||
SystemMessage,
|
||||
UserEditComposer: () => <UserEditComposer cwd={cwd} gateway={gateway} sessionId={sessionId} />,
|
||||
UserMessage: () => (
|
||||
<UserMessage
|
||||
onCancel={onCancel}
|
||||
onRequestRestoreConfirm={onRestoreToMessage ? requestRestoreConfirm : undefined}
|
||||
onCancel={hasCancel ? () => callbacksRef.current.onCancel?.() : undefined}
|
||||
onRequestRestoreConfirm={hasRestoreToMessage ? requestRestoreConfirm : undefined}
|
||||
/>
|
||||
)
|
||||
}),
|
||||
[cwd, gateway, onBranchInNewChat, onCancel, onDismissError, onRestoreToMessage, requestRestoreConfirm, sessionId]
|
||||
[cwd, gateway, hasBranchInNewChat, hasCancel, hasDismissError, hasRestoreToMessage, requestRestoreConfirm, sessionId]
|
||||
)
|
||||
|
||||
const emptyPlaceholder = intro ? (
|
||||
|
|
|
|||
226
apps/desktop/src/components/pet/pet-sprite.test.tsx
Normal file
226
apps/desktop/src/components/pet/pet-sprite.test.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import { act, type ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/store/pet', () => {
|
||||
const listeners = new Set<(state: string) => void>()
|
||||
|
||||
return {
|
||||
$petState: {
|
||||
get: () => 'idle',
|
||||
listen: (callback: (state: string) => void) => {
|
||||
listeners.add(callback)
|
||||
|
||||
return () => {
|
||||
listeners.delete(callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { PetSprite } from './pet-sprite'
|
||||
|
||||
const INFO = {
|
||||
enabled: true,
|
||||
frameH: 16,
|
||||
frameW: 16,
|
||||
framesPerState: 2,
|
||||
loopMs: 120,
|
||||
scale: 1,
|
||||
spritesheetBase64: 'stub',
|
||||
stateRows: ['idle']
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
|
||||
|
||||
function render(ui: ReactNode) {
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root!.render(ui)
|
||||
})
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
}
|
||||
|
||||
function setVisibility(hidden: boolean) {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' })
|
||||
}
|
||||
|
||||
function installWindowStateBridge() {
|
||||
windowStateCallback = null
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
|
||||
windowStateCallback = callback
|
||||
|
||||
return () => {
|
||||
if (windowStateCallback === callback) {
|
||||
windowStateCallback = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function installRaf() {
|
||||
let nextId = 1
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
const request = vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
frames.set(id, callback)
|
||||
|
||||
return id
|
||||
})
|
||||
const cancel = vi.fn((id: number) => {
|
||||
frames.delete(id)
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
|
||||
|
||||
return {
|
||||
cancel,
|
||||
pending: () => frames.size,
|
||||
request,
|
||||
runNext: (now: number) => {
|
||||
const next = frames.entries().next().value
|
||||
|
||||
if (!next) {
|
||||
throw new Error('No pending RAF')
|
||||
}
|
||||
|
||||
const [id, callback] = next
|
||||
frames.delete(id)
|
||||
callback(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('PetSprite RAF scheduling', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.useFakeTimers()
|
||||
setVisibility(false)
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
|
||||
installWindowStateBridge()
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class extends EventTarget {
|
||||
complete = true
|
||||
naturalWidth = 16
|
||||
src = ''
|
||||
} as unknown as typeof Image
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
imageSmoothingEnabled: false
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
setVisibility(false)
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('sleeps between visible sprite frames instead of chaining RAFs', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<PetSprite info={INFO} />)
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
raf.runNext(0)
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
expect(raf.pending()).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(60)
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cancels pending RAF work while the Electron window is paused and resumes when visible', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<PetSprite info={INFO} />)
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
windowStateCallback?.({ isMinimized: true, isVisible: false })
|
||||
})
|
||||
|
||||
expect(raf.cancel).toHaveBeenCalledTimes(1)
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
windowStateCallback?.({ isMinimized: false, isVisible: true })
|
||||
})
|
||||
|
||||
expect(raf.request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('suspends while unfocused, resumes on focus, and leaves no work after unmount', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<PetSprite info={INFO} />)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('blur')))
|
||||
expect(raf.pending()).toBe(0)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
expect(raf.pending()).toBe(1)
|
||||
|
||||
act(() => raf.runNext(0))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
cleanup()
|
||||
expect(raf.pending()).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
expect(raf.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps the intentionally non-activating pop-out overlay animated while unfocused', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<PetSprite info={INFO} pauseWhenUnfocused={false} />)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('blur')))
|
||||
|
||||
expect(raf.pending()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { memo, useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
|
||||
import { $petState, type PetInfo, type PetState } from '@/store/pet'
|
||||
|
||||
const DEFAULT_FRAME_W = 192
|
||||
|
|
@ -91,6 +92,8 @@ export function roamWalkRow(dir: -1 | 0 | 1, stateRows?: string[]): { row?: stri
|
|||
|
||||
interface PetSpriteProps {
|
||||
info: PetInfo
|
||||
/** Keep animating in a deliberately non-activating visible window, such as the pop-out pet overlay. */
|
||||
pauseWhenUnfocused?: boolean
|
||||
/** On-screen scale multiplier applied on top of the pet's native scale. */
|
||||
zoom?: number
|
||||
/**
|
||||
|
|
@ -114,21 +117,24 @@ interface PetSpriteProps {
|
|||
* with `memo`, this component effectively never re-renders after mount until
|
||||
* the pet itself changes.
|
||||
*/
|
||||
function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSpriteProps) {
|
||||
function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride, pauseWhenUnfocused = true }: PetSpriteProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
const stateRef = useRef<PetState>($petState.get())
|
||||
const overrideRef = useRef<PetState | undefined>(stateOverride)
|
||||
const rowOverrideRef = useRef<string | undefined>(rowOverride)
|
||||
const kickAnimationRef = useRef<() => void>(() => undefined)
|
||||
|
||||
// Keep the override current without re-running the RAF setup effect.
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
overrideRef.current = stateOverride
|
||||
kickAnimationRef.current()
|
||||
}, [stateOverride])
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
||||
useEffect(() => {
|
||||
rowOverrideRef.current = rowOverride
|
||||
kickAnimationRef.current()
|
||||
}, [rowOverride])
|
||||
|
||||
const frameW = info.frameW ?? DEFAULT_FRAME_W
|
||||
|
|
@ -173,17 +179,75 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
|||
// Track state via subscription, not a prop — no re-render on activity ticks.
|
||||
stateRef.current = $petState.get()
|
||||
|
||||
const unsubState = $petState.listen(next => {
|
||||
stateRef.current = next
|
||||
})
|
||||
|
||||
let raf = 0
|
||||
let wakeTimer = 0
|
||||
let stopped = false
|
||||
let frame = 0
|
||||
let lastStep = performance.now()
|
||||
let drawnFrame = -1
|
||||
let drawnRow = -1
|
||||
let activeRow = -1
|
||||
let activeCount = -1
|
||||
let pauseController: ReturnType<typeof createRendererLoopPauseController> | null = null
|
||||
|
||||
const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
|
||||
|
||||
const cancelWakeTimer = () => {
|
||||
if (wakeTimer !== 0) {
|
||||
window.clearTimeout(wakeTimer)
|
||||
wakeTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
const cancelRaf = () => {
|
||||
if (raf !== 0) {
|
||||
window.cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
}
|
||||
}
|
||||
|
||||
const clearScheduled = () => {
|
||||
cancelWakeTimer()
|
||||
cancelRaf()
|
||||
}
|
||||
|
||||
const scheduleFrame = (delayMs = 0) => {
|
||||
if (stopped || rendererPaused() || raf !== 0 || wakeTimer !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (delayMs > 16) {
|
||||
wakeTimer = window.setTimeout(() => {
|
||||
wakeTimer = 0
|
||||
scheduleFrame()
|
||||
}, delayMs)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
raf = window.requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
const kickAnimation = () => {
|
||||
if (stopped || rendererPaused()) {
|
||||
return
|
||||
}
|
||||
|
||||
cancelWakeTimer()
|
||||
scheduleFrame()
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
clearScheduled()
|
||||
|
||||
if (rendererPaused()) {
|
||||
return
|
||||
}
|
||||
|
||||
lastStep = performance.now()
|
||||
drawnFrame = -1
|
||||
kickAnimation()
|
||||
}
|
||||
|
||||
const rowIndexForState = (s: PetState): number => {
|
||||
for (const key of STATE_ALIASES[s] ?? [s]) {
|
||||
|
|
@ -223,6 +287,12 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
|||
}
|
||||
|
||||
const render = (now: number) => {
|
||||
raf = 0
|
||||
|
||||
if (stopped || rendererPaused()) {
|
||||
return
|
||||
}
|
||||
|
||||
const forcedRow = rowOverrideRef.current
|
||||
const { row, count } = forcedRow ? resolveRow(forcedRow) : resolve(overrideRef.current ?? stateRef.current)
|
||||
|
||||
|
|
@ -245,10 +315,13 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
|||
|
||||
frame %= count
|
||||
|
||||
if (!image.complete || image.naturalWidth <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only touch the canvas when the visible cell actually changes. The RAF
|
||||
// ticks at ~60Hz but the sprite only steps ~5Hz, so this skips ~90% of
|
||||
// the clear+draw work and keeps the main thread free.
|
||||
if ((frame !== drawnFrame || row !== drawnRow) && image.complete && image.naturalWidth > 0) {
|
||||
// wakes when a sprite cell is due, so the idle path avoids a 60Hz loop.
|
||||
if (frame !== drawnFrame || row !== drawnRow) {
|
||||
const sx = frame * frameW
|
||||
const sy = row * frameH
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
|
@ -258,16 +331,29 @@ function PetSpriteImpl({ info, zoom = 1, stateOverride, rowOverride }: PetSprite
|
|||
drawnRow = row
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(render)
|
||||
scheduleFrame(Math.max(0, stepMs - (now - lastStep)))
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(render)
|
||||
kickAnimationRef.current = kickAnimation
|
||||
|
||||
const unsubState = $petState.listen(next => {
|
||||
stateRef.current = next
|
||||
kickAnimation()
|
||||
})
|
||||
|
||||
image.addEventListener('load', kickAnimation)
|
||||
pauseController = createRendererLoopPauseController(handleVisibilityChange, { pauseWhenUnfocused })
|
||||
scheduleFrame()
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
stopped = true
|
||||
kickAnimationRef.current = () => undefined
|
||||
clearScheduled()
|
||||
image.removeEventListener('load', kickAnimation)
|
||||
pauseController?.dispose()
|
||||
unsubState()
|
||||
}
|
||||
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows])
|
||||
}, [image, frameW, frameH, frames, framesByState, framesByRow, loopMs, drawW, drawH, rows, pauseWhenUnfocused])
|
||||
|
||||
return (
|
||||
<canvas
|
||||
|
|
|
|||
166
apps/desktop/src/components/pet/use-pet-roam.test.tsx
Normal file
166
apps/desktop/src/components/pet/use-pet-roam.test.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { act, type ReactNode, type RefObject, useRef } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/store/pet', () => ({
|
||||
$petMotion: { set: () => undefined },
|
||||
$petRoamDir: { set: () => undefined }
|
||||
}))
|
||||
|
||||
import { usePetRoam } from './use-pet-roam'
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
let windowStateCallback: ((payload: { isMinimized?: boolean; isVisible?: boolean }) => void) | null = null
|
||||
|
||||
function render(ui: ReactNode) {
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root!.render(ui)
|
||||
})
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
}
|
||||
|
||||
function setVisibility(hidden: boolean) {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, value: hidden })
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: hidden ? 'hidden' : 'visible' })
|
||||
}
|
||||
|
||||
function installWindowStateBridge() {
|
||||
windowStateCallback = null
|
||||
Object.defineProperty(window, 'hermesDesktop', {
|
||||
configurable: true,
|
||||
value: {
|
||||
onWindowStateChanged: vi.fn((callback: typeof windowStateCallback) => {
|
||||
windowStateCallback = callback
|
||||
|
||||
return () => {
|
||||
if (windowStateCallback === callback) {
|
||||
windowStateCallback = null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function installRaf() {
|
||||
const request = vi.fn((_callback: FrameRequestCallback) => 1)
|
||||
const cancel = vi.fn()
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
|
||||
|
||||
return { cancel, request }
|
||||
}
|
||||
|
||||
function RoamHarness({ isInteracting = () => false }: { isInteracting?: () => boolean }) {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
usePetRoam({
|
||||
commit: () => undefined,
|
||||
containerRef: ref as RefObject<HTMLDivElement | null>,
|
||||
enabled: true,
|
||||
isInteracting,
|
||||
loopMs: 1200,
|
||||
overlayOpen: false,
|
||||
petH: 64,
|
||||
petW: 64
|
||||
})
|
||||
|
||||
return <div ref={ref} />
|
||||
}
|
||||
|
||||
describe('usePetRoam RAF scheduling', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.useFakeTimers()
|
||||
setVisibility(false)
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
|
||||
installWindowStateBridge()
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
bottom: 164,
|
||||
height: 64,
|
||||
left: 100,
|
||||
right: 164,
|
||||
top: 100,
|
||||
width: 64,
|
||||
x: 100,
|
||||
y: 100,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
setVisibility(false)
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
it('uses a pause timer, not RAF, while dwelling at idle', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<RoamHarness />)
|
||||
|
||||
expect(raf.request).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('clears the pause wakeup while the Electron window is paused and restarts it when visible', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<RoamHarness />)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
windowStateCallback?.({ isMinimized: true, isVisible: false })
|
||||
|
||||
expect(raf.cancel).not.toHaveBeenCalled()
|
||||
expect(raf.request).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
windowStateCallback?.({ isMinimized: false, isVisible: true })
|
||||
|
||||
expect(raf.request).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('suspends idle movement while unfocused and cleans up its wake timer on unmount', () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render(<RoamHarness />)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('blur')))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
act(() => window.dispatchEvent(new Event('focus')))
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
cleanup()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
expect(raf.request).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { type RefObject, useEffect } from 'react'
|
||||
|
||||
import { createRendererLoopPauseController } from '@/lib/renderer-loop-pause'
|
||||
import { $petMotion, $petRoamDir, type PetState } from '@/store/pet'
|
||||
|
||||
import { chooseMove, dwellMs, PAUSE_DWELL, pickStrollTarget } from './roam-behavior'
|
||||
|
|
@ -33,6 +34,8 @@ const DROP_SETTLE_MS = 90
|
|||
const ARRIVE_EPS = 1.5
|
||||
// Cap dt so a backgrounded/throttled tab can't teleport the pet on resume.
|
||||
const MAX_DT_S = 0.05
|
||||
// While paused, wake rarely to notice drags/replans without burning a 60Hz RAF.
|
||||
const PAUSE_POLL_MS = 250
|
||||
|
||||
type Phase = 'pause' | 'walk' | 'fall' | 'jump'
|
||||
|
||||
|
|
@ -114,6 +117,9 @@ export function usePetRoam({
|
|||
let pauseUntil = performance.now() + rand(400, 1200)
|
||||
let last = performance.now()
|
||||
let raf = 0
|
||||
let pauseTimer = 0
|
||||
let stopped = false
|
||||
let pauseController: ReturnType<typeof createRendererLoopPauseController> | null = null
|
||||
|
||||
let walkTargetX = cur.x
|
||||
let curLedge: Ledge | null = null
|
||||
|
|
@ -137,6 +143,61 @@ export function usePetRoam({
|
|||
$petRoamDir.set(dir)
|
||||
}
|
||||
|
||||
const rendererPaused = () => pauseController?.isPaused() ?? document.visibilityState === 'hidden'
|
||||
|
||||
const cancelRaf = () => {
|
||||
if (raf !== 0) {
|
||||
window.cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPauseTimer = () => {
|
||||
if (pauseTimer !== 0) {
|
||||
window.clearTimeout(pauseTimer)
|
||||
pauseTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
const clearScheduled = () => {
|
||||
cancelRaf()
|
||||
cancelPauseTimer()
|
||||
}
|
||||
|
||||
const schedule = (now = performance.now()) => {
|
||||
if (stopped || rendererPaused() || raf !== 0 || pauseTimer !== 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'pause') {
|
||||
const delay = Math.max(0, pauseUntil - now)
|
||||
|
||||
if (delay > 0) {
|
||||
pauseTimer = window.setTimeout(() => {
|
||||
pauseTimer = 0
|
||||
step(performance.now())
|
||||
}, Math.min(delay, PAUSE_POLL_MS))
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
raf = window.requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
clearScheduled()
|
||||
last = performance.now()
|
||||
|
||||
if (rendererPaused()) {
|
||||
signal(null, 0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
schedule(last)
|
||||
}
|
||||
|
||||
const beginPause = (now: number) => {
|
||||
phase = 'pause'
|
||||
pauseUntil = now + dwellMs(PAUSE_DWELL)
|
||||
|
|
@ -209,6 +270,15 @@ export function usePetRoam({
|
|||
}
|
||||
|
||||
const step = (now: number) => {
|
||||
raf = 0
|
||||
pauseTimer = 0
|
||||
|
||||
if (stopped || rendererPaused()) {
|
||||
signal(null, 0)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const dt = Math.min(MAX_DT_S, (now - last) / 1000)
|
||||
last = now
|
||||
|
||||
|
|
@ -223,7 +293,7 @@ export function usePetRoam({
|
|||
// Short settle so the pet falls right after you drop it, not seconds later.
|
||||
pauseUntil = now + DROP_SETTLE_MS
|
||||
signal(null, 0)
|
||||
raf = requestAnimationFrame(step)
|
||||
schedule(now)
|
||||
|
||||
return
|
||||
}
|
||||
|
|
@ -300,13 +370,16 @@ export function usePetRoam({
|
|||
}
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(step)
|
||||
schedule(now)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(step)
|
||||
pauseController = createRendererLoopPauseController(handleVisibilityChange)
|
||||
schedule()
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
stopped = true
|
||||
clearScheduled()
|
||||
pauseController?.dispose()
|
||||
signal(null, 0)
|
||||
// Hand the final position back to React so its `style` matches the DOM once
|
||||
// the loop stops re-asserting it.
|
||||
|
|
|
|||
2
apps/desktop/src/global.d.ts
vendored
2
apps/desktop/src/global.d.ts
vendored
|
|
@ -465,6 +465,8 @@ export interface HermesTitleBarTheme {
|
|||
|
||||
export interface HermesWindowState {
|
||||
isFullscreen: boolean
|
||||
isMinimized?: boolean
|
||||
isVisible?: boolean
|
||||
nativeOverlayWidth: number
|
||||
windowButtonPosition: { x: number; y: number } | null
|
||||
}
|
||||
|
|
|
|||
50
apps/desktop/src/lib/renderer-loop-pause.ts
Normal file
50
apps/desktop/src/lib/renderer-loop-pause.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
interface WindowStatePayload {
|
||||
isMinimized?: boolean
|
||||
isVisible?: boolean
|
||||
}
|
||||
|
||||
export function createRendererLoopPauseController(onChange: () => void, { pauseWhenUnfocused = true } = {}) {
|
||||
let windowPaused = false
|
||||
let windowFocused = document.hasFocus()
|
||||
|
||||
const onVisibilityChange = () => onChange()
|
||||
|
||||
const onBlur = () => {
|
||||
if (windowFocused) {
|
||||
windowFocused = false
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
|
||||
const onFocus = () => {
|
||||
if (!windowFocused) {
|
||||
windowFocused = true
|
||||
onChange()
|
||||
}
|
||||
}
|
||||
|
||||
const offWindowState = window.hermesDesktop?.onWindowStateChanged?.((payload: WindowStatePayload) => {
|
||||
const next = payload?.isMinimized === true || payload?.isVisible === false
|
||||
|
||||
if (windowPaused === next) {
|
||||
return
|
||||
}
|
||||
|
||||
windowPaused = next
|
||||
onChange()
|
||||
})
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
window.addEventListener('blur', onBlur)
|
||||
window.addEventListener('focus', onFocus)
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
window.removeEventListener('blur', onBlur)
|
||||
window.removeEventListener('focus', onFocus)
|
||||
offWindowState?.()
|
||||
},
|
||||
isPaused: () => document.visibilityState === 'hidden' || (pauseWhenUnfocused && !windowFocused) || windowPaused
|
||||
}
|
||||
}
|
||||
|
|
@ -156,6 +156,39 @@ const goalToItem = (goal: { detail?: string; status: GoalStatus; title: string }
|
|||
})
|
||||
|
||||
// The single thing the stack reads: a typed, merged item list per session.
|
||||
//
|
||||
// Identity contract: this computed's inputs churn constantly during a turn (a
|
||||
// subagent tick, a 5s background poll, a todo update — in ANY session), but
|
||||
// the merged output for most sessions is unchanged. Rebuilding fresh arrays
|
||||
// and item objects every time handed every mounted composer stack a new
|
||||
// reference per recompute — cross-session churn × open tiles. Stabilize both
|
||||
// levels: an unchanged session keeps its previous array (and item objects),
|
||||
// and a fully-unchanged map keeps its previous reference so `computed` skips
|
||||
// the notify entirely ("preserve reference identity on no-ops").
|
||||
const sameStatusItem = (a: ComposerStatusItem, b: ComposerStatusItem) =>
|
||||
a.id === b.id &&
|
||||
a.type === b.type &&
|
||||
a.state === b.state &&
|
||||
a.title === b.title &&
|
||||
a.output === b.output &&
|
||||
a.exitCode === b.exitCode &&
|
||||
a.currentTool === b.currentTool &&
|
||||
a.goalStatus === b.goalStatus &&
|
||||
a.todoStatus === b.todoStatus &&
|
||||
a.sessionId === b.sessionId
|
||||
|
||||
const stabilizeItems = (prev: ComposerStatusItem[] | undefined, next: ComposerStatusItem[]): ComposerStatusItem[] => {
|
||||
if (!prev) {
|
||||
return next
|
||||
}
|
||||
|
||||
const merged = next.map((item, i) => (prev[i] && sameStatusItem(prev[i], item) ? prev[i] : item))
|
||||
|
||||
return merged.length === prev.length && merged.every((item, i) => item === prev[i]) ? prev : merged
|
||||
}
|
||||
|
||||
let prevStatusItems: Record<string, ComposerStatusItem[]> = {}
|
||||
|
||||
export const $statusItemsBySession = computed(
|
||||
[$goalsBySession, $subagentsBySession, $backgroundStatusBySession, $todosBySession],
|
||||
(goals, subs, background, todos) => {
|
||||
|
|
@ -183,7 +216,14 @@ export const $statusItemsBySession = computed(
|
|||
push(sid, list)
|
||||
}
|
||||
|
||||
return out
|
||||
let unchanged = Object.keys(prevStatusItems).length === Object.keys(out).length
|
||||
|
||||
for (const sid of Object.keys(out)) {
|
||||
out[sid] = stabilizeItems(prevStatusItems[sid], out[sid]!)
|
||||
unchanged &&= out[sid] === prevStatusItems[sid]
|
||||
}
|
||||
|
||||
return (prevStatusItems = unchanged ? prevStatusItems : out)
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue