From a3d952dc439be5481fc061e74687bf9e01f0ce26 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 5 Aug 2026 15:14:01 -0700 Subject: [PATCH] [eric] chat: smooth-text rAF parks when caught up instead of burning 60fps forever, idle renderer 50 percent to 0 --- .../pages/AgentChat/bubbles/useSmoothText.ts | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts index dddd420b..acd1f908 100644 --- a/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts +++ b/frontend/src/app/pages/AgentChat/bubbles/useSmoothText.ts @@ -49,6 +49,8 @@ export function useSmoothText( // Imperative-tail bookkeeping: which committedLen the DOM reflects, and the text node + its committed baseline that per-frame appends write into. const domLenRef = useRef(committedLen); + const rafRef = useRef(null); + const tickRef = useRef<((now: number) => void) | null>(null); const nodeRef = useRef(null); const baseRef = useRef(''); @@ -85,7 +87,6 @@ export function useSmoothText( return; } - let raf: number | null = null; const tick = (now: number) => { const full = targetRef.current.length; const dtRaw = lastRef.current ? (now - lastRef.current) / 1000 : 0.016; @@ -118,16 +119,34 @@ export function useSmoothText( } // else: a commit is mid-flight; skip this frame's append (≤1 frame). } - raf = requestAnimationFrame(tick); // keep running for the whole stream + // Fully revealed AND fully committed: park instead of burning 60fps forever; the growth effect below re-arms. + if (posRef.current >= full && committedRef.current >= full) { + rafRef.current = null; + lastRef.current = 0; + return; + } + rafRef.current = requestAnimationFrame(tick); }; + tickRef.current = tick; lastRef.current = 0; - raf = requestAnimationFrame(tick); + rafRef.current = requestAnimationFrame(tick); return () => { - if (raf != null) cancelAnimationFrame(raf); + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + rafRef.current = null; }; }, [enabled]); + // Re-arm a parked loop when new text lands. This never tears the running loop down (the churn the + // persistent-loop comment above warns about); it only restarts one that parked itself at idle. + useEffect(() => { + if (!enabled) return; + if (rafRef.current === null && tickRef.current && posRef.current < target.length) { + lastRef.current = 0; + rafRef.current = requestAnimationFrame(tickRef.current); + } + }, [target.length, enabled]); + // Target shrank (new turn / reset / branch switch): re-sync so we don't slice past the end of a shorter string and so a fresh turn starts from zero. useEffect(() => { if (posRef.current > target.length) {