From 894a4b7e235cabfef93c77a8296f9681c056b505 Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 1 Jun 2026 11:55:27 -0700 Subject: [PATCH] [eric] chat: rewrite stream reveal as a buffered constant-velocity controller to kill the pump-pump stutter --- .../pages/AgentChat/bubbles/useSmoothText.ts | 107 +++++++++++------- 1 file changed, 69 insertions(+), 38 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts index b3d603bd..425e8955 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts +++ b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts @@ -6,26 +6,28 @@ import { useEffect, useRef, useState } from 'react'; * claude.ai does, so generated text reads like it's being typed rather than * dumped in clumps. * - * Zero dependencies. Zero added TTFT: the first characters reveal on the very next - * animation frame after the first delta (same frame budget as painting it directly). - * The reveal rate is ADAPTIVE — it accelerates as the backlog grows, so display - * never falls meaningfully behind the model and never reads as laggy. The rAF loop - * runs ONLY while there's a backlog to drain and parks itself at zero cost once - * caught up, so it adds no idle-frame churn. + * Why the old "reveal backlog/4, floor 3 chars/frame" version felt like + * "pump pump pump": that floor (~180 chars/sec) is FASTER than a model + * generates (~90 chars/sec), so the display kept sprinting to catch up, then + * FROZE waiting for the next token. Freeze-sprint-freeze at token frequency is + * the choppiness. + * + * This version is a buffered constant-velocity controller: + * - It deliberately stays ~TARGET_LAG seconds BEHIND the latest text, so there + * is always a buffer to reveal and it never runs dry between tokens. + * - Reveal is TIME-based (chars = rate * elapsed), so it's frame-rate + * independent and survives a dropped frame without a visible jump. + * - The reveal RATE is EMA-smoothed, so a burst ramps the speed up gently and + * a lull ramps it down gently; the rate never steps, so the flow never pulses. + * The rAF loop runs only while there's a backlog and parks at zero cost once + * caught up. Zero added TTFT: the first characters still reveal in-render on the + * very first frame content exists. */ -/** Pure pacing step (exported for testing): chars to reveal this frame. */ -export function smoothStep(shown: number, full: number): number { - if (shown >= full) return full; - const backlog = full - shown; - // Floor of 3 chars/frame (~180 chars/sec at 60fps) for a calm typing feel, - // and drain ~1/4 of any backlog on top of that so bursts catch up fast. The - // /4 keeps mid-stream lag small (a few words at most), so when the live bubble - // hands off to the final message at stream end there's no visible jump. Never - // overshoots `full`. - const step = Math.max(3, Math.ceil(backlog / 4)); - return Math.min(full, shown + step); -} +const TARGET_LAG_S = 0.35; // stay this far behind = the buffer that prevents stalls +const RATE_SMOOTH_S = 0.25; // how fast the reveal speed eases toward its target +const MAX_CPS = 1000; // cap so a huge paste/burst still reveals smoothly, not instantly +const MAX_DT_S = 0.05; // clamp elapsed after a frame drop / tab switch so we don't leap export function useSmoothText(target: string, enabled: boolean): string { const [shownLen, setShownLen] = useState(enabled ? 0 : target.length); @@ -33,40 +35,69 @@ export function useSmoothText(target: string, enabled: boolean): string { const targetRef = useRef(target); targetRef.current = target; + // Controller state lives in refs so the rAF loop reads the latest without the + // effect re-subscribing every character. + const posRef = useRef(enabled ? 0 : target.length); // float reveal position + const cpsRef = useRef(0); // current reveal speed + const lastRef = useRef(0); // last frame timestamp + const shownRef = useRef(shownLen); + shownRef.current = shownLen; + + // ONE persistent loop, keyed only on `enabled`. It must NOT restart per token: + // an effect that depends on target.length tears the rAF down and rebuilds it on + // every delta, and that churn is what stalls the reveal. So the loop runs every + // frame for the life of the stream, reads the latest text from a ref, and just + // advances by 0 when it happens to be caught up (cheap, no stall, no parking). useEffect(() => { - // Disabled (historical message, or smoothing turned off): show all, stop loop. if (!enabled) { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } + posRef.current = targetRef.current.length; setShownLen(targetRef.current.length); return; } - const tick = () => { - rafRef.current = null; - setShownLen((cur) => { - const next = smoothStep(cur, targetRef.current.length); - if (next < targetRef.current.length) rafRef.current = requestAnimationFrame(tick); - return next; - }); + + const tick = (now: number) => { + const full = targetRef.current.length; + const dtRaw = lastRef.current ? (now - lastRef.current) / 1000 : 0.016; + lastRef.current = now; + const dt = dtRaw > MAX_DT_S ? MAX_DT_S : dtRaw; + + const backlog = Math.max(0, full - posRef.current); + const desired = backlog / TARGET_LAG_S; // speed that holds the lag steady (0 when caught up) + const k = Math.min(1, dt / RATE_SMOOTH_S); + let cps = cpsRef.current + (desired - cpsRef.current) * k; // EMA-smooth the speed itself, both up and down + if (cps > MAX_CPS) cps = MAX_CPS; + if (cps < 0) cps = 0; + cpsRef.current = cps; + + if (backlog > 0) { + posRef.current = Math.min(full, posRef.current + cps * dt); + const nextLen = Math.floor(posRef.current); + if (nextLen !== shownRef.current) setShownLen(nextLen); + } + rafRef.current = requestAnimationFrame(tick); // keep running for the whole stream }; - // Start a drain only if we're behind and no loop is already running. - if (rafRef.current == null && shownLen < target.length) { - rafRef.current = requestAnimationFrame(tick); - } + + lastRef.current = 0; + rafRef.current = requestAnimationFrame(tick); return () => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } }; - }, [enabled, target.length, shownLen]); + }, [enabled]); // Target shrank (new turn / reset / branch switch): re-sync so we don't slice - // past the end of a shorter string. + // past the end of a shorter string and so a fresh turn starts from zero. useEffect(() => { - if (shownLen > target.length) setShownLen(enabled ? 0 : target.length); - }, [target.length, shownLen, enabled]); + if (posRef.current > target.length) { + posRef.current = enabled ? 0 : target.length; + cpsRef.current = 0; + lastRef.current = 0; + setShownLen(enabled ? 0 : target.length); + } + }, [target.length, enabled]); - // ZERO added TTFT: on the very first frame content exists (shownLen still 0), - // reveal the floor immediately in-render instead of waiting a frame for the rAF - // tick. Pure derivation, no extra render — so first visible text lands on the - // exact same frame it would have without smoothing. State catches up next frame. + // ZERO added TTFT: on the very first frame content exists, reveal a few chars + // in-render instead of waiting a frame for the first rAF tick. if (!enabled) return target; const effectiveShown = (shownLen === 0 && target.length > 0) ? Math.min(3, target.length)