fix(ruvocal): keep hidden-tab streams responsive

This commit is contained in:
dajiaohuang authored and ruv committed 2026-08-14 17:10:34 -04:00
1 parent 6b8f0d0b5a
commit 40affb83fe
3 files changed
+43 -4

No files matched your search

@@ -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: "你好,世界!" }];
@@ -38,6 +38,7 @@ type SmoothStreamConfig = {
now?: () => number;
sleep?: (ms: number) => Promise<void>;
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<MessageUpdate> {
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<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
const defaultIsPageHidden = (): boolean =>
typeof document !== "undefined" && document.visibilityState === "hidden";
const waitForEvent = (eventTarget: EventTarget, eventName: string) =>
new Promise<boolean>((resolve) =>
eventTarget.addEventListener(eventName, () => resolve(true), { once: true })
@@ -249,6 +249,7 @@
// Initialize lastUpdateTime outside the loop to persist between updates
let lastUpdateTime = new Date();
let frameFlushScheduled = false;
let frameFlushTimeout: ReturnType<typeof setTimeout> | 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) {