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 index 1a572c329a52..85ca9af13e83 100644 --- 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 @@ -90,6 +90,42 @@ describe('useMessageStream delta flush scheduling', () => { expect(assistantText()).toBe('still streaming') }) + it('flushes queued text immediately when a hidden window becomes visible', () => { + vi.mocked(performance.now).mockReturnValue(0) + mountStream() + + act(() => appendAssistantDelta!(SID, 'caught up on focus')) + expect(assistantText()).toBe('') + expect(vi.getTimerCount()).toBe(1) + + Object.defineProperty(globalThis.document, 'visibilityState', { + configurable: true, + value: 'visible' + }) + + act(() => globalThis.document.dispatchEvent(new Event('visibilitychange'))) + + expect(assistantText()).toBe('caught up on focus') + expect(vi.getTimerCount()).toBe(0) + }) + + it('flushes queued text on focus when visibility remains visible', () => { + vi.mocked(performance.now).mockReturnValue(0) + Object.defineProperty(globalThis.document, 'visibilityState', { + configurable: true, + value: 'visible' + }) + mountStream() + + act(() => appendAssistantDelta!(SID, 'focused without visibility change')) + expect(assistantText()).toBe('') + expect(vi.getTimerCount()).toBe(1) + + act(() => globalThis.window.dispatchEvent(new Event('focus'))) + + expect(assistantText()).toBe('focused without visibility change') + }) + it('cancels the pending timer on unmount and flushes exactly once', async () => { vi.mocked(performance.now).mockReturnValue(0) mountStream() 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 7aa89a8cf14f..bdf86fc64942 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 @@ -316,6 +316,34 @@ export function useMessageStream({ [flushQueuedDeltas] ) + // Page Visibility does not report every Windows/Linux focus transition. + // Flush queued deltas on both signals so returning to a chat cannot leave a + // completed chunk waiting for the next throttled timer. + useEffect(() => { + const flushPendingDeltas = () => { + if (flushHandleRef.current !== null) { + window.clearTimeout(flushHandleRef.current) + flushHandleRef.current = null + } + + flushQueuedDeltas() + } + + const flushWhenVisible = () => { + if (document.visibilityState === 'visible') { + flushPendingDeltas() + } + } + + document.addEventListener('visibilitychange', flushWhenVisible) + window.addEventListener('focus', flushPendingDeltas) + + return () => { + document.removeEventListener('visibilitychange', flushWhenVisible) + window.removeEventListener('focus', flushPendingDeltas) + } + }, [flushQueuedDeltas]) + const appendAssistantDelta = useCallback( (sessionId: string, delta: string) => { if (!delta) {