Skip to content

Commit f456ed9

Browse files
improvement(chat): smooth streaming — eased stick-to-bottom glide and time-based reveal pacing (#5417)
* improvement(chat): eased stick-to-bottom glide for streaming chat * improvement(chat): time-based word-at-a-time reveal pacing * improvement(chat): harden smooth-chase against reentrant kick/cancel during a step
1 parent 4b13398 commit f456ed9

3 files changed

Lines changed: 199 additions & 60 deletions

File tree

apps/sim/hooks/use-auto-scroll.ts

Lines changed: 38 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
import { useCallback, useEffect, useRef } from 'react'
2+
import {
3+
CHASE_REST_GAP,
4+
createSmoothBottomChase,
5+
SMOOTH_CHASE_RATE,
6+
} from '@/lib/core/utils/smooth-bottom-chase'
27

38
/** Tolerance for keeping stickiness during programmatic auto-scroll. */
49
const STICK_THRESHOLD = 30
@@ -61,7 +66,6 @@ export function useAutoScroll(
6166
const prevScrollTopRef = useRef(0)
6267
const prevScrollHeightRef = useRef(0)
6368
const touchStartYRef = useRef(0)
64-
const rafIdRef = useRef(0)
6569
const scrollOnMountRef = useRef(scrollOnMount)
6670
/**
6771
* Whether the user is actively dragging the scrollbar — a pointer press on the
@@ -92,13 +96,33 @@ export function useAutoScroll(
9296
const el = containerRef.current
9397
if (!el) return
9498

99+
/**
100+
* Eased bottom-chase shared by the mutation observer and the seed below —
101+
* the same glide the subagent viewport uses, instead of snapping to
102+
* `scrollHeight` on every content mutation. Chase writes only ever move
103+
* `scrollTop` down, so the detach logic in `onScroll` (which requires an
104+
* upward move) never mistakes the glide for a user scroll; the helper's
105+
* own upward-move interrupt and the per-frame sticky check are extra
106+
* layers of the same guarantee.
107+
*/
108+
const chase = createSmoothBottomChase(
109+
{
110+
getTop: () => el.scrollTop,
111+
getBottomTop: () => el.scrollHeight - el.clientHeight,
112+
setTop: (top) => {
113+
el.scrollTop = top
114+
},
115+
},
116+
() => stickyRef.current
117+
)
118+
95119
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
96120
const isNearBottom = distanceFromBottom <= STICK_THRESHOLD
97121
stickyRef.current = isNearBottom
98122
userDetachedRef.current = !isNearBottom
99123
prevScrollTopRef.current = el.scrollTop
100124
prevScrollHeightRef.current = el.scrollHeight
101-
if (isNearBottom) scrollToBottom()
125+
if (isNearBottom) chase.kick()
102126

103127
const detach = () => {
104128
stickyRef.current = false
@@ -164,25 +188,20 @@ export function useAutoScroll(
164188
prevScrollHeightRef.current = scrollHeight
165189
}
166190

167-
const guardedScroll = () => {
168-
if (stickyRef.current) scrollToBottom()
169-
}
170-
171191
const onMutation = () => {
172192
prevScrollHeightRef.current = el.scrollHeight
173193
if (!stickyRef.current) return
174-
cancelAnimationFrame(rafIdRef.current)
175-
rafIdRef.current = requestAnimationFrame(guardedScroll)
194+
chase.kick()
176195
}
177196

178197
/**
179-
* Chase the bottom every frame for `durationMs`. Catches height growth that
180-
* arrives over several frames with no observed DOM mutation — a CSS height
181-
* animation, or end-of-turn content and the virtualizer's re-measure settling
182-
* after streaming stops.
198+
* Chase the bottom every frame for `durationMs` with the same eased step.
199+
* Catches height growth that arrives over several frames with no observed
200+
* DOM mutation — a CSS height animation, or end-of-turn content and the
201+
* virtualizer's re-measure settling after streaming stops.
183202
*
184-
* Self-interrupting: height growth leaves `scrollTop` exactly where we last
185-
* put it, whereas a user scroll moves it up from there — so the moment
203+
* Self-interrupting: our eased writes leave `scrollTop` exactly where we
204+
* last put it, whereas a user scroll moves it up from there — so the moment
186205
* `scrollTop` drops below our last write, we stop and never fight a real
187206
* scroll, even with the gesture listeners already torn down.
188207
*/
@@ -193,7 +212,10 @@ export function useAutoScroll(
193212
const follow = () => {
194213
if (performance.now() > until || !stickyRef.current) return
195214
if (lastTop >= 0 && el.scrollTop < lastTop - 1) return
196-
scrollToBottom()
215+
const gap = el.scrollHeight - el.clientHeight - el.scrollTop
216+
if (gap > CHASE_REST_GAP) {
217+
el.scrollTop = el.scrollTop + Math.max(1, gap * SMOOTH_CHASE_RATE)
218+
}
197219
lastTop = el.scrollTop
198220
requestAnimationFrame(follow)
199221
}
@@ -232,7 +254,7 @@ export function useAutoScroll(
232254
window.removeEventListener('pointerup', onPointerUp)
233255
window.removeEventListener('pointercancel', onPointerUp)
234256
observer.disconnect()
235-
cancelAnimationFrame(rafIdRef.current)
257+
chase.cancel()
236258
pointerDownRef.current = false
237259
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
238260
followToBottom(POST_STREAM_SETTLE_WINDOW)

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
[]
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Fraction of the remaining gap to close per frame while chasing the bottom —
3+
* an exponential glide (originating in the subagent viewport's stick-to-bottom,
4+
* see BoundedViewport in agent-group.tsx) instead of snapping `scrollTop` to
5+
* `scrollHeight` on every content append. Closes ~90% of any gap within ~18
6+
* frames (~300ms) — deliberately lazier than the subagent viewport's 0.18 so a
7+
* large content burst reads as a calm upward drift of the transcript rather
8+
* than a lurch.
9+
*/
10+
export const SMOOTH_CHASE_RATE = 0.12
11+
12+
/** Gap (px) below which the chase parks until new growth reopens it. */
13+
export const CHASE_REST_GAP = 0.5
14+
15+
export interface SmoothBottomChaseTarget {
16+
/** Current scroll offset. */
17+
getTop: () => number
18+
/** Scroll offset at which the viewport bottom meets the content bottom. */
19+
getBottomTop: () => number
20+
/** Apply a new scroll offset. */
21+
setTop: (top: number) => void
22+
}
23+
24+
export interface SmoothBottomChaseHandle {
25+
/** True while a chase frame is scheduled (gap still closing). */
26+
isActive: () => boolean
27+
/** Start the loop if parked. Call after content growth. */
28+
kick: () => void
29+
cancel: () => void
30+
}
31+
32+
/**
33+
* Eased stick-to-bottom chase over any scrollable target (a DOM element or an
34+
* editor API like Monaco's). Each frame closes {@link SMOOTH_CHASE_RATE} of the
35+
* remaining gap and self-parks at {@link CHASE_REST_GAP}; content growth
36+
* restarts it via `kick()`.
37+
*
38+
* Self-interrupting: chase writes only ever move the offset down, and content
39+
* growth leaves it where the last write put it — so an offset that moved UP
40+
* since the last write can only be a user scrolling away, and the loop parks
41+
* instead of fighting them. `shouldContinue` layers any caller-owned stickiness
42+
* on top (checked every frame).
43+
*/
44+
export function createSmoothBottomChase(
45+
target: SmoothBottomChaseTarget,
46+
shouldContinue: () => boolean = () => true
47+
): SmoothBottomChaseHandle {
48+
let raf: number | null = null
49+
let lastTop: number | null = null
50+
51+
const park = () => {
52+
if (raf !== null) cancelAnimationFrame(raf)
53+
raf = null
54+
lastTop = null
55+
}
56+
57+
const step = () => {
58+
// `raf` deliberately keeps this (already-fired) frame's id while the step
59+
// body runs: canceling a fired handle is a no-op, and a non-null `raf`
60+
// means `isActive()` stays true and a reentrant `kick()` — e.g. from a
61+
// target whose `setTop` fires synchronous scroll listeners, like Monaco's
62+
// onDidScrollChange — cannot start a second parallel chain.
63+
if (!shouldContinue()) {
64+
park()
65+
return
66+
}
67+
const top = target.getTop()
68+
if (lastTop !== null && top < lastTop - 1) {
69+
park()
70+
return
71+
}
72+
const gap = target.getBottomTop() - top
73+
if (gap <= CHASE_REST_GAP) {
74+
park()
75+
return
76+
}
77+
target.setTop(top + Math.max(1, gap * SMOOTH_CHASE_RATE))
78+
// A synchronous side-effect of `setTop` may have called `cancel()`; honor
79+
// it instead of re-queuing over it.
80+
if (raf === null) return
81+
lastTop = target.getTop()
82+
raf = requestAnimationFrame(step)
83+
}
84+
85+
return {
86+
isActive: () => raf !== null,
87+
kick: () => {
88+
if (raf === null) raf = requestAnimationFrame(step)
89+
},
90+
cancel: park,
91+
}
92+
}

0 commit comments

Comments
 (0)