diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 924a9fa846e3..2346936fa94f 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -251,12 +251,20 @@ export function useMessageStream({ flushQueuedDeltas() } - if (sinceLast >= STREAM_DELTA_FLUSH_MS && typeof window.requestAnimationFrame === 'function') { - flushHandleRef.current = window.requestAnimationFrame(runFlush) - - return - } - + // Always a timer, never requestAnimationFrame. Chromium pauses rAF for a + // renderer it considers hidden, and "hidden" is not something this code can + // verify: `backgroundThrottling: false` plus the process-level switches in + // electron/main.ts cover the blurred and occluded cases, but they don't + // cover a minimized window, a fully off-screen one, or a renderer the + // compositor has otherwise parked. In those states an rAF-gated flush never + // runs, so a finished answer sits in this queue until some later input or + // focus event happens to wake a frame — the reply looks stalled, then + // arrives all at once on refocus. + // + // A timer keeps the same coalescing cadence (that's what the floor above is + // for) while guaranteeing delivery without user interaction. Timers are + // clamped in background renderers rather than suspended, and + // disable-background-timer-throttling already opts out of that clamp. flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast)) }, [flushQueuedDeltas]) @@ -277,11 +285,7 @@ export function useMessageStream({ useEffect( () => () => { if (flushHandleRef.current !== null && typeof window !== 'undefined') { - if (typeof window.cancelAnimationFrame === 'function') { - window.cancelAnimationFrame(flushHandleRef.current) - } else { - window.clearTimeout(flushHandleRef.current) - } + window.clearTimeout(flushHandleRef.current) } flushHandleRef.current = null diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/stream-flush.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/stream-flush.test.tsx new file mode 100644 index 000000000000..427e62cf8adb --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/stream-flush.test.tsx @@ -0,0 +1,93 @@ +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 type { RpcEvent } from '@/types/hermes' + +import { STREAM_DELTA_FLUSH_MS } from './utils' + +import { useMessageStream } from './index' + +const SID = 'stream-session' + +let handleEvent: ((event: RpcEvent) => void) | null = null +let states: Map + +function Harness() { + const activeSessionIdRef = useRef(SID) + const sessionStateByRuntimeIdRef = useRef(new Map()) + 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: (sessionId, updater) => { + const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState() + const next = updater(current) + sessionStateByRuntimeIdRef.current.set(sessionId, next) + + return next + } + }) + + useEffect(() => { + handleEvent = stream.handleGatewayEvent + states = sessionStateByRuntimeIdRef.current + }, [stream.handleGatewayEvent]) + + return null +} + +describe('stream delta delivery', () => { + beforeEach(() => { + handleEvent = null + states = new Map() + }) + + afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('delivers a delta while animation frames are parked', async () => { + // An Electron renderer the compositor has parked: rAF accepts the callback + // but never runs it. A frame-gated flush strands the answer here until some + // later focus/input event wakes a frame. + const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 1) + vi.useFakeTimers() + + render() + await act(async () => { + await Promise.resolve() + }) + + // Reach the frame-gated branch: it is only taken once the coalescing floor + // has already elapsed since the previous flush. Send one delta, let it + // flush, then idle past the floor so the NEXT delta schedules immediately. + act(() => handleEvent?.({ payload: { text: 'first ' }, session_id: SID, type: 'message.delta' })) + await act(async () => { + await vi.advanceTimersByTimeAsync(STREAM_DELTA_FLUSH_MS) + }) + await act(async () => { + await vi.advanceTimersByTimeAsync(STREAM_DELTA_FLUSH_MS * 2) + }) + + act(() => handleEvent?.({ payload: { text: 'and the rest' }, session_id: SID, type: 'message.delta' })) + + await act(async () => { + await vi.advanceTimersByTimeAsync(STREAM_DELTA_FLUSH_MS) + }) + + expect(states.get(SID)?.messages.at(-1)?.parts).toEqual([{ type: 'text', text: 'first and the rest' }]) + // The flush must not have depended on a frame at all. + expect(rafSpy).not.toHaveBeenCalled() + }) +})