From 93a18c5cf51eb7ba9847b9b048b89e5a0ed3ca5e Mon Sep 17 00:00:00 2001 From: HUQIANTAO Date: Thu, 4 Jun 2026 20:15:52 +0800 Subject: [PATCH 1/2] perf(desktop): coalesce streaming text/reasoning deltas per animation frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent stream pushes a text/reasoning delta per Webview event; at 200 tok/s that is one state update every ~5ms, while rAF is 16ms — so the reducer was running 3-4 times per visible frame and the React tree was re-rendering the entire Message list each time. Add lib/rafBatch.ts: a tiny rAF-coalescing queue with a synchronous drain() so non-text events (tool_dispatch, usage, notice, turn_started/ done, message) flush any pending deltas first to keep causal ordering intact. Drain is also called on unmount so a turn-end that races the teardown doesn't strand the last few tokens in the buffer. tsc --noEmit passes; Go build ./desktop/... is clean. --- desktop/frontend/src/lib/rafBatch.ts | 69 +++++++++++++++++++++++ desktop/frontend/src/lib/useController.ts | 30 +++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 desktop/frontend/src/lib/rafBatch.ts diff --git a/desktop/frontend/src/lib/rafBatch.ts b/desktop/frontend/src/lib/rafBatch.ts new file mode 100644 index 000000000..2a49b59b6 --- /dev/null +++ b/desktop/frontend/src/lib/rafBatch.ts @@ -0,0 +1,69 @@ +// rafBatch coalesces repeated calls into one execution per animation frame. The +// desktop agent stream pushes a text/reasoning delta per Webview event; at 200 +// tok/s that's a state update every ~5 ms, so a 16 ms rAF window can absorb 3–4 +// deltas into a single React render. Non-text events (tool dispatch, usage, +// notice, turn boundaries) must NOT be batched — they have ordering with the +// text deltas (a "tool_dispatch" should appear after the text that announced +// it), and the controller flushes the buffer first to keep the causal order +// correct. +// +// We don't use a single "apply all buffered events" reducer action because the +// controller's reducer is pure and deterministic; instead we accept a flusher +// function that receives the accumulated list and emits a single dispatch. The +// flush happens synchronously at the start of the rAF callback, then the batch +// is cleared, so the next frame's deltas land in a fresh window. + +type Flush = (batch: T[]) => void; + +interface BatchHandle { + push: (item: T) => void; + drain: () => void; + size: () => number; +} + +export function createRafBatch(flush: Flush): BatchHandle { + // The pending buffer and the scheduled rAF id are kept in a closure rather than + // refs so a single createRafBatch() can be shared by many callers without + // each owning their own React-bound state. In the desktop frontend the + // controller creates one batch and the event listener uses push() freely. + let buffer: T[] = []; + let scheduled: number | null = null; + + const run = () => { + scheduled = null; + // Snapshot then clear BEFORE invoking the flusher, so any re-entrant push() + // (e.g. a useController dispatch that triggers another event) lands in the + // next frame rather than the current one. + 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 in this environment (e.g. SSR or a test using JSDOM without + // the polyfill) — flush on a microtask so the controller still sees a + // single dispatch per tick. + 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 e751c7095..15144c4bb 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -7,6 +7,7 @@ import { useCallback, useEffect, useReducer, useRef } from "react"; import { app, onEvent, onReady } from "./bridge"; +import { createRafBatch } from "./rafBatch"; import type { BalanceInfo, ContextInfo, @@ -476,8 +477,28 @@ export function useController() { }, []); useEffect(() => { + // Stream batching: text/reasoning deltas pile up faster than the display can + // repaint (200 tok/s ≈ one delta per 5 ms; rAF is 16 ms). We coalesce those + // into a single reducer pass per frame. Non-text events (tool_dispatch, + // usage, notice, turn_started/done, message) break the batch first so their + // ordering against earlier text is preserved — a tool call that follows + // "Reading foo.ts…" should appear after that text, not interleaved. + // + // The flusher walks the deltas in order, applying each through the same + // reducer path as the live wire event; that's cheaper than a special-case + // bulk reducer and keeps the state shape identical to the un-batched case. + const textBatch = createRafBatch((batch) => { + for (const e of batch) dispatch({ type: "event", e }); + }); const off = onEvent((e) => { - dispatch({ type: "event", e }); + if (e.kind === "text" || e.kind === "reasoning") { + textBatch.push(e); + } else { + // Ordering: flush any queued deltas BEFORE the structural event, so the + // dispatch order in the reducer matches the order the kernel emitted. + textBatch.drain(); + dispatch({ type: "event", e }); + } // The gauge's denominator (window) and post-turn prompt size come from the // kernel, not the stream — refresh once a turn settles. The wallet balance // moves with spend, so refresh it on the same boundary. @@ -504,6 +525,13 @@ export function useController() { .catch(() => {}); } }); + // On unmount, drain any in-flight text so a turn-end that races the + // teardown doesn't strand the last few tokens in the buffer (the user + // would see the bubble frozen at the penultimate delta). + return () => { + textBatch.drain(); + off(); + }; // When boot.Build completes asynchronously, the Go side emits agent:ready. // Re-fetch session data so the UI reflects the now-available controller. From 8e9282111712beaeecd392724f0fa8667bd9ba6c Mon Sep 17 00:00:00 2001 From: HUQIANTAO Date: Fri, 5 Jun 2026 09:21:07 +0800 Subject: [PATCH 2/2] perf(desktop): coalesce streaming text/reasoning deltas per animation frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent stream pushes a text/reasoning delta per Webview event; at 200 tok/s that is one state update every ~5ms, while rAF is 16ms — so the reducer was running 3-4 times per visible frame and the React tree was re-rendering the entire Message list each time. Add lib/rafBatch.ts: a tiny rAF-coalescing queue with a synchronous drain() so non-text events (tool_dispatch, usage, notice, turn_started/ done, message) flush any pending deltas first to keep causal ordering intact. Drain is also called on unmount so a turn-end that races the teardown doesn't strand the last few tokens in the buffer. tsc --noEmit passes; Go build ./desktop/... is clean. --- desktop/frontend/src/lib/useController.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 15144c4bb..7cd31cfc4 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -477,16 +477,6 @@ export function useController() { }, []); useEffect(() => { - // Stream batching: text/reasoning deltas pile up faster than the display can - // repaint (200 tok/s ≈ one delta per 5 ms; rAF is 16 ms). We coalesce those - // into a single reducer pass per frame. Non-text events (tool_dispatch, - // usage, notice, turn_started/done, message) break the batch first so their - // ordering against earlier text is preserved — a tool call that follows - // "Reading foo.ts…" should appear after that text, not interleaved. - // - // The flusher walks the deltas in order, applying each through the same - // reducer path as the live wire event; that's cheaper than a special-case - // bulk reducer and keeps the state shape identical to the un-batched case. const textBatch = createRafBatch((batch) => { for (const e of batch) dispatch({ type: "event", e }); }); @@ -494,8 +484,6 @@ export function useController() { if (e.kind === "text" || e.kind === "reasoning") { textBatch.push(e); } else { - // Ordering: flush any queued deltas BEFORE the structural event, so the - // dispatch order in the reducer matches the order the kernel emitted. textBatch.drain(); dispatch({ type: "event", e }); }