From 40affb83fe18d35e5c290ff8a4aba425baba6ac0 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Thu, 13 Aug 2026 21:13:26 +0800 Subject: [PATCH] fix(ruvocal): keep hidden-tab streams responsive --- .../src/lib/utils/messageUpdates.spec.ts | 24 +++++++++++++++++++ .../ruvocal/src/lib/utils/messageUpdates.ts | 12 ++++++++-- .../src/routes/conversation/[id]/+page.svelte | 11 +++++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/ruflo/src/ruvocal/src/lib/utils/messageUpdates.spec.ts b/ruflo/src/ruvocal/src/lib/utils/messageUpdates.spec.ts index 151f41bab1..8aa5102d88 100644 --- a/ruflo/src/ruvocal/src/lib/utils/messageUpdates.spec.ts +++ b/ruflo/src/ruvocal/src/lib/utils/messageUpdates.spec.ts @@ -129,6 +129,30 @@ describe("smoothStreamUpdates", () => { expect(delays[0]).toBeGreaterThanOrEqual(delays.at(-1) ?? 0); }); + it("drains stream chunks without timer pacing while the page is hidden", async () => { + const source: MessageUpdate[] = [ + { type: MessageUpdateType.Stream, token: "one two three four five " }, + ]; + const delays: number[] = []; + + const updates = await collect( + smoothStreamUpdates(fromArray(source), { + minDelayMs: 5, + maxDelayMs: 80, + _internal: { + sleep: async (ms: number) => { + delays.push(ms); + }, + isPageHidden: () => true, + detectChunk: (buffer) => /\S+\s+/.exec(buffer)?.[0] ?? null, + }, + }) + ); + + expect(streamText(updates)).toBe("one two three four five "); + expect(delays).toEqual([]); + }); + it("handles CJK text correctly", async () => { const source: MessageUpdate[] = [{ type: MessageUpdateType.Stream, token: "你好,世界!" }]; diff --git a/ruflo/src/ruvocal/src/lib/utils/messageUpdates.ts b/ruflo/src/ruvocal/src/lib/utils/messageUpdates.ts index 0b7fbc63a3..5f339db20c 100644 --- a/ruflo/src/ruvocal/src/lib/utils/messageUpdates.ts +++ b/ruflo/src/ruvocal/src/lib/utils/messageUpdates.ts @@ -38,6 +38,7 @@ type SmoothStreamConfig = { now?: () => number; sleep?: (ms: number) => Promise; detectChunk?: ChunkDetector; + isPageHidden?: () => boolean; }; }; @@ -168,7 +169,12 @@ export async function* smoothStreamUpdates( maxDelayMs = 80, minRateCharsPerMs = 0.3, maxBufferedMs = 400, - _internal: { now = () => performance.now(), sleep = defaultSleep, detectChunk } = {}, + _internal: { + now = () => performance.now(), + sleep = defaultSleep, + detectChunk, + isPageHidden = defaultIsPageHidden, + } = {}, }: SmoothStreamConfig = {} ): AsyncGenerator { const chunkDetector = detectChunk ?? createWordChunkDetector(); @@ -251,7 +257,7 @@ export async function* smoothStreamUpdates( const effectiveMinDelayMs = underBacklogPressure ? 0 : minDelayMs; const delayMs = Math.round(Math.max(effectiveMinDelayMs, Math.min(maxDelayMs, rawDelay))); - if (delayMs > 0) { + if (delayMs > 0 && !isPageHidden()) { await sleep(delayMs); } } @@ -318,6 +324,8 @@ export const isMessageToolProgressUpdate = ( const defaultSleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +const defaultIsPageHidden = (): boolean => + typeof document !== "undefined" && document.visibilityState === "hidden"; const waitForEvent = (eventTarget: EventTarget, eventName: string) => new Promise((resolve) => eventTarget.addEventListener(eventName, () => resolve(true), { once: true }) diff --git a/ruflo/src/ruvocal/src/routes/conversation/[id]/+page.svelte b/ruflo/src/ruvocal/src/routes/conversation/[id]/+page.svelte index c0c2ecfbe5..17b1d95eec 100644 --- a/ruflo/src/ruvocal/src/routes/conversation/[id]/+page.svelte +++ b/ruflo/src/ruvocal/src/routes/conversation/[id]/+page.svelte @@ -249,6 +249,7 @@ // Initialize lastUpdateTime outside the loop to persist between updates let lastUpdateTime = new Date(); let frameFlushScheduled = false; + let frameFlushTimeout: ReturnType | undefined; const flushBuffer = (currentTime: Date) => { if (buffer.length === 0) return; @@ -261,14 +262,20 @@ if (frameFlushScheduled) return; frameFlushScheduled = true; const flush = () => { + if (!frameFlushScheduled) return; frameFlushScheduled = false; + if (frameFlushTimeout !== undefined) { + clearTimeout(frameFlushTimeout); + frameFlushTimeout = undefined; + } flushBuffer(new Date()); }; if (typeof requestAnimationFrame === "function") { requestAnimationFrame(flush); - } else { - setTimeout(flush, 0); } + // requestAnimationFrame is suspended in background tabs. Keep a + // bounded fallback so buffered tokens still reach the DOM there. + frameFlushTimeout = setTimeout(flush, 300); }; for await (const update of messageUpdatesIterator) {