11import { useEffect , useRef , useState } from 'react'
22
33/**
4- * Paced reveal of a growing string, ported from opencode's `createPacedValue`
5- * (`packages/ui/src/components/message-part.tsx`). Instead of revealing a fixed
6- * number of characters per animation frame, it advances on a steady ~24ms timer
7- * in small tiered steps that SNAP to the next word/punctuation boundary — so
8- * text appears word-by-word at a calm, even cadence regardless of how bursty the
9- * upstream model deltas are. The boundary snapping is what keeps it from reading
10- * as "blocky": a reveal never stops mid-word.
4+ * Time-based paced reveal of a growing string. A per-frame loop earns a
5+ * character budget from elapsed time and releases text one word/punctuation
6+ * boundary at a time — so words appear individually, evenly spaced on the
7+ * timeline, instead of the old fixed-interval tick that dumped a multi-word
8+ * chunk every 24ms and read as blocky.
9+ *
10+ * The rate is a proportional controller: drain the current backlog over
11+ * {@link DRAIN_HORIZON_MS}. It therefore converges on the stream's real
12+ * arrival rate — a fast stream reveals fast, a slow one trickles — instead of
13+ * racing ahead at a fixed cap, emptying the backlog, and stalling until the
14+ * next network burst (the old burst–pause rhythm).
1115 */
12- const PACE_MS = 24
1316const SNAP = / [ \s . , ! ? ; : ) \] ] /
1417
15- /**
16- * Characters to advance per tick as a function of how far the reveal is behind.
17- * Small backlogs trickle (2–8 chars); large backlogs accelerate but stay capped
18- * so a burst is spread over several ticks rather than dumped at once.
19- */
20- function step ( remaining : number ) : number {
21- if ( remaining <= 12 ) return 2
22- if ( remaining <= 48 ) return 4
23- if ( remaining <= 96 ) return 8
24- return Math . min ( 24 , Math . ceil ( remaining / 8 ) )
18+ /** Reveal the backlog over roughly this horizon (a small jitter buffer). */
19+ const DRAIN_HORIZON_MS = 400
20+ /** Floor so a near-empty backlog still trickles out instead of freezing. */
21+ const MIN_CPS = 45
22+ /** Cap so a huge backlog (resume, giant paste) sweeps in over ~a second. */
23+ const MAX_CPS = 2400
24+
25+ /** Chars/second that drains ` remaining` over the horizon, clamped. */
26+ function drainRate ( remaining : number ) : number {
27+ return Math . min ( MAX_CPS , Math . max ( MIN_CPS , ( remaining * 1000 ) / DRAIN_HORIZON_MS ) )
2528}
2629
2730/**
28- * Advance from `start` by `step(...)`, then extend up to 8 more characters to
29- * land just past the next word/punctuation boundary so the reveal lands on a
30- * whole word rather than mid-token.
31+ * The furthest word/punctuation boundary within `start + budget`, or `start`
32+ * when the budget doesn't yet cover the next whole word (the budget carries
33+ * over to later frames). Words longer than the 24-char lookahead are released
34+ * whole once the budget covers the lookahead, so an unbroken token (a URL, a
35+ * long identifier) cannot dam the reveal.
3136 */
32- function nextIndex ( text : string , start : number ) : number {
33- const end = Math . min ( text . length , start + step ( text . length - start ) )
34- const max = Math . min ( text . length , end + 8 )
35- for ( let i = end ; i < max ; i ++ ) {
36- if ( SNAP . test ( text [ i ] ?? '' ) ) return i + 1
37+ function nextIndex ( text : string , start : number , budget : number ) : number {
38+ const limit = Math . min ( text . length , start + Math . floor ( budget ) )
39+ for ( let i = limit ; i > start ; i -- ) {
40+ if ( SNAP . test ( text [ i - 1 ] ?? '' ) ) return i
3741 }
38- return end
42+ if ( limit >= Math . min ( text . length , start + 24 ) ) return limit
43+ return start
3944}
4045
4146/**
@@ -74,13 +79,13 @@ interface SmoothTextOptions {
7479 *
7580 * @remarks
7681 * The re-arm effect runs on every committed render with a cheap
77- * `timeoutRef === null` guard instead of keying on a `hasBacklog` dependency.
78- * The tick chain self-terminates whenever the reveal catches up, and a chain
79- * keyed on the `hasBacklog` boolean could die for good: when the final tick 's
82+ * `rafRef === null` guard instead of keying on a `hasBacklog` dependency.
83+ * The frame chain self-terminates whenever the reveal catches up, and a chain
84+ * keyed on the `hasBacklog` boolean could die for good: when the final frame 's
8085 * `setRevealed` and a new chunk land in the same React commit, `hasBacklog`
8186 * stays `true` across commits, the effect never re-fires, and the reveal
8287 * freezes mid-stream until remount. Re-arming per render closes that
83- * interleaving while still avoiding per-chunk timer teardown (no cleanup on
88+ * interleaving while still avoiding per-chunk loop teardown (no cleanup on
8489 * content changes), so it cannot trip React's max-update-depth guard either.
8590 * If upstream sanitization rewrites earlier text and shrinks the string, the
8691 * cursor is pulled back to the new end so regrowth stays paced instead of
@@ -99,7 +104,10 @@ export function useSmoothText(
99104
100105 const contentRef = useRef ( content )
101106 const revealedRef = useRef ( revealed )
102- const timeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
107+ const rafRef = useRef < number | null > ( null )
108+ /** Fractional character budget carried between frames (see the frame loop). */
109+ const budgetRef = useRef ( 0 )
110+ const lastFrameAtRef = useRef ( 0 )
103111 const prevContentRef = useRef ( content )
104112 const prevIsStreamingRef = useRef ( isStreaming )
105113
@@ -142,36 +150,53 @@ export function useSmoothText(
142150 } , [ content , isStreaming ] )
143151
144152 useEffect ( ( ) => {
145- const run = ( ) => {
146- timeoutRef . current = null
153+ /**
154+ * Per-frame reveal: each frame earns `drainRate * dt` characters of budget
155+ * (fractional remainder carried in `budgetRef`), and the cursor advances to
156+ * the furthest word boundary the budget covers — releasing words one at a
157+ * time, evenly spaced in real time, rather than a fixed-size chunk per
158+ * tick. Frames whose budget doesn't yet cover the next word update nothing.
159+ */
160+ const run = ( now : number ) => {
161+ rafRef . current = null
147162 const text = contentRef . current
148163 const target = text . length
149164
150165 if ( revealedRef . current > target ) {
151166 revealedRef . current = target
167+ budgetRef . current = 0
152168 setRevealed ( target )
153169 }
154170 const current = revealedRef . current
155171 if ( current >= target ) return
156172
157- const next = nextIndex ( text , current )
158- revealedRef . current = next
159- setRevealed ( next )
160- if ( next < target ) {
161- timeoutRef . current = setTimeout ( run , PACE_MS )
173+ // Clamp dt so a background tab's paused rAF doesn't bank a giant budget.
174+ const dt = Math . min ( now - lastFrameAtRef . current , 100 )
175+ lastFrameAtRef . current = now
176+ budgetRef . current += ( drainRate ( target - current ) * dt ) / 1000
177+
178+ const next = nextIndex ( text , current , budgetRef . current )
179+ if ( next > current ) {
180+ budgetRef . current -= next - current
181+ revealedRef . current = next
182+ setRevealed ( next )
183+ }
184+ if ( revealedRef . current < target ) {
185+ rafRef . current = requestAnimationFrame ( run )
162186 }
163187 }
164188
165- if ( hasBacklog && timeoutRef . current === null ) {
166- timeoutRef . current = setTimeout ( run , PACE_MS )
189+ if ( hasBacklog && rafRef . current === null ) {
190+ lastFrameAtRef . current = performance . now ( )
191+ rafRef . current = requestAnimationFrame ( run )
167192 }
168193 } )
169194
170195 useEffect (
171196 ( ) => ( ) => {
172- if ( timeoutRef . current !== null ) {
173- clearTimeout ( timeoutRef . current )
174- timeoutRef . current = null
197+ if ( rafRef . current !== null ) {
198+ cancelAnimationFrame ( rafRef . current )
199+ rafRef . current = null
175200 }
176201 } ,
177202 [ ]
0 commit comments