diff --git a/desktop/frontend/src/lib/rafBatch.ts b/desktop/frontend/src/lib/rafBatch.ts new file mode 100644 index 000000000..f114d8f39 --- /dev/null +++ b/desktop/frontend/src/lib/rafBatch.ts @@ -0,0 +1,49 @@ +// Coalesces text/reasoning stream deltas into one flush per animation frame. +// Non-text events must drain() first so causal ordering is preserved. + +type Flush = (batch: T[]) => void; + +interface BatchHandle { + push: (item: T) => void; + drain: () => void; + size: () => number; +} + +export function createRafBatch(flush: Flush): BatchHandle { + let buffer: T[] = []; + let scheduled: number | null = null; + + const run = () => { + scheduled = null; + // Snapshot + clear before flushing so a re-entrant push() lands next frame. + const out = buffer; + buffer = []; + if (out.length > 0) flush(out); + }; + + const handle: BatchHandle = { + push(item: T) { + buffer.push(item); + if (scheduled === null && typeof requestAnimationFrame !== "undefined") { + scheduled = requestAnimationFrame(run); + } else if (scheduled === null) { + // No rAF (SSR / JSDOM) — fall back to a microtask. + scheduled = 1; + Promise.resolve().then(run); + } + }, + drain() { + if (scheduled !== null) { + if (typeof cancelAnimationFrame !== "undefined" && scheduled !== 1) { + cancelAnimationFrame(scheduled); + } + scheduled = null; + } + run(); + }, + size() { + return buffer.length; + }, + }; + return handle; +} diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index cb96201a0..0089c1d06 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { asArray } from "./array"; import { app, onEvent, onReady } from "./bridge"; +import { createRafBatch } from "./rafBatch"; import { t } from "./i18n"; import type { BalanceInfo, @@ -561,10 +562,18 @@ export function useController() { }, [dispatchTo, loadSessionDataForTab]); useEffect(() => { + const textBatch = createRafBatch<{ tabId: string; e: WireEvent }>((batch) => { + for (const { tabId, e } of batch) dispatchTo(tabId, { type: "event", e }); + }); const off = onEvent((e) => { const targetTabId = e.tabId || activeTabIdRef.current; if (!targetTabId) return; - dispatchTo(targetTabId, { type: "event", e }); + if (e.kind === "text" || e.kind === "reasoning") { + textBatch.push({ tabId: targetTabId, e }); + } else { + textBatch.drain(); + dispatchTo(targetTabId, { type: "event", e }); + } if (e.kind === "turn_done") { app .ContextUsageForTab(targetTabId) @@ -590,7 +599,7 @@ export function useController() { void syncActiveTabFromBackend(); - return () => { off(); offReady(); }; + return () => { textBatch.drain(); off(); offReady(); }; }, [dispatchTo, loadSessionDataForTab, refreshCheckpoints, syncActiveTabFromBackend]); const send = useCallback((displayText: string, submitText = displayText) => {