Skip to content

Commit 276fee1

Browse files
improvement(chat): time-based word-at-a-time reveal pacing
1 parent 546addd commit 276fee1

1 file changed

Lines changed: 69 additions & 44 deletions

File tree

apps/sim/hooks/use-smooth-text.ts

Lines changed: 69 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,46 @@
11
import { 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
1316
const 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

Comments
 (0)