11import { useEffect , useRef , useState } from 'react'
22
33/**
4- * Per-frame reveal speed is proportional to how far behind the display is, so a
5- * large burst drains quickly while a trickle reveals gently. `target / DIVISOR`
6- * gives an ease-out feel; the clamps keep it from stalling or jumping.
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.
711 */
8- const REVEAL_DIVISOR = 6
9- const MIN_STEP = 1
10- const MAX_STEP = 400
12+ const PACE_MS = 24
13+ const SNAP = / [ \s . , ! ? ; : ) \] ] /
14+
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 ) )
25+ }
26+
27+ /**
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+ */
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+ }
38+ return end
39+ }
1140
1241/**
1342 * Content already longer than this at mount is assumed to be an in-progress
@@ -29,13 +58,25 @@ interface SmoothTextOptions {
2958}
3059
3160/**
32- * Paces a growing string so it reveals at a steady cadence regardless of how
33- * bursty the upstream stream is — the client-side analogue of the AI SDK's
34- * `smoothStream`. Returns the portion of `content` that should be displayed now.
61+ * Paces a growing string so it reveals word-by-word at a steady cadence
62+ * regardless of how bursty the upstream stream is — a React port of opencode's
63+ * paced text rendering. Returns the portion of `content` that should be
64+ * displayed now.
65+ *
66+ * Content that is already complete at mount (history, or a resume past
67+ * {@link RESUME_SKIP_THRESHOLD}) is returned in full and never animates. When a
68+ * live stream ends mid-reveal the remaining tail keeps draining at the paced
69+ * cadence rather than snapping — so the reveal stays smooth right to the end and
70+ * the caller can hold its streaming render until `useSmoothText` reports the
71+ * full string, avoiding a flash on the streaming→static handoff.
3572 *
36- * While `isStreaming` is false the full string is returned unchanged (history
37- * and completed turns never animate). When streaming ends mid-reveal the
38- * remaining tail is shown immediately so nothing is left hidden.
73+ * @remarks
74+ * The reveal loop keys on `hasBacklog` rather than `content` so a new chunk on
75+ * every render does not re-subscribe the timer (and trip React's
76+ * max-update-depth guard); the running tick reads the latest value from a ref.
77+ * If upstream sanitization rewrites earlier text and shrinks the string, the
78+ * cursor is pulled back to the new end so regrowth stays paced instead of
79+ * jumping past it.
3980 */
4081export function useSmoothText (
4182 content : string ,
@@ -49,15 +90,10 @@ export function useSmoothText(
4990 )
5091
5192 const contentRef = useRef ( content )
52- const streamingRef = useRef ( isStreaming )
5393 const revealedRef = useRef ( revealed )
54- const frameRef = useRef < number | null > ( null )
94+ const timeoutRef = useRef < ReturnType < typeof setTimeout > | null > ( null )
5595 const prevContentRef = useRef ( content )
5696
57- // A non-append rewrite (e.g. a patch replacing earlier text) must be shown in
58- // full at once — re-revealing a prefix of rewritten content would look like
59- // the document is retyping itself. Adjust during render so the slice below
60- // never flashes a stale prefix.
6197 let effectiveRevealed = revealed
6298 if (
6399 snapOnNonAppend &&
@@ -72,72 +108,42 @@ export function useSmoothText(
72108 prevContentRef . current = content
73109
74110 contentRef . current = content
75- streamingRef . current = isStreaming
76-
77- // Key the reveal loop to streaming + remaining backlog, NOT to `content`:
78- // `content` changes on every streamed chunk, and re-subscribing an rAF + setState
79- // loop on each change is the "a dependency changes on every render" pattern that
80- // trips React's max-update-depth guard. The running tick reads the latest content
81- // from `contentRef`, so new chunks are absorbed without per-chunk teardown;
82- // `hasBacklog` only flips when the reveal falls behind or catches up.
83- if ( ! isStreaming && effectiveRevealed !== content . length ) {
84- effectiveRevealed = content . length
85- revealedRef . current = content . length
86- }
87111
88112 const hasBacklog = effectiveRevealed < content . length
89113
90114 useEffect ( ( ) => {
91- if ( ! isStreaming ) {
92- revealedRef . current = contentRef . current . length
93- setRevealed ( contentRef . current . length )
94- return
95- }
115+ const run = ( ) => {
116+ timeoutRef . current = null
117+ const text = contentRef . current
118+ const target = text . length
96119
97- const tick = ( ) => {
98- const target = contentRef . current . length
99- // Upstream sanitization can rewrite earlier text and shrink the string;
100- // pull the cursor back to the new end so regrowth stays paced rather than
101- // jumping past it.
102120 if ( revealedRef . current > target ) {
103121 revealedRef . current = target
104122 setRevealed ( target )
105123 }
106124 const current = revealedRef . current
125+ if ( current >= target ) return
107126
108- if ( ! streamingRef . current ) {
109- revealedRef . current = target
110- setRevealed ( target )
111- frameRef . current = null
112- return
113- }
114- if ( current >= target ) {
115- frameRef . current = null
116- return
117- }
118-
119- const backlog = target - current
120- const step = Math . min ( MAX_STEP , Math . max ( MIN_STEP , Math . ceil ( backlog / REVEAL_DIVISOR ) ) )
121- const next = current + step
127+ const next = nextIndex ( text , current )
122128 revealedRef . current = next
123129 setRevealed ( next )
124- frameRef . current = window . requestAnimationFrame ( tick )
130+ if ( next < target ) {
131+ timeoutRef . current = setTimeout ( run , PACE_MS )
132+ }
125133 }
126134
127- if ( hasBacklog && frameRef . current === null ) {
128- frameRef . current = window . requestAnimationFrame ( tick )
135+ if ( hasBacklog && timeoutRef . current === null ) {
136+ timeoutRef . current = setTimeout ( run , PACE_MS )
129137 }
130138
131139 return ( ) => {
132- if ( frameRef . current !== null ) {
133- window . cancelAnimationFrame ( frameRef . current )
134- frameRef . current = null
140+ if ( timeoutRef . current !== null ) {
141+ clearTimeout ( timeoutRef . current )
142+ timeoutRef . current = null
135143 }
136144 }
137- } , [ isStreaming , hasBacklog ] )
145+ } , [ hasBacklog ] )
138146
139- // Content can shrink when upstream sanitization rewrites earlier text; never
140- // hand back a slice index past the current end.
141147 if ( effectiveRevealed >= content . length ) return content
142148 return content . slice ( 0 , effectiveRevealed )
143149}
0 commit comments