Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 38 additions & 16 deletions apps/sim/hooks/use-auto-scroll.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { useCallback, useEffect, useRef } from 'react'
import {
CHASE_REST_GAP,
createSmoothBottomChase,
SMOOTH_CHASE_RATE,
} from '@/lib/core/utils/smooth-bottom-chase'

/** Tolerance for keeping stickiness during programmatic auto-scroll. */
const STICK_THRESHOLD = 30
Expand Down Expand Up @@ -61,7 +66,6 @@ export function useAutoScroll(
const prevScrollTopRef = useRef(0)
const prevScrollHeightRef = useRef(0)
const touchStartYRef = useRef(0)
const rafIdRef = useRef(0)
const scrollOnMountRef = useRef(scrollOnMount)
/**
* Whether the user is actively dragging the scrollbar — a pointer press on the
Expand Down Expand Up @@ -92,13 +96,33 @@ export function useAutoScroll(
const el = containerRef.current
if (!el) return

/**
* Eased bottom-chase shared by the mutation observer and the seed below —
* the same glide the subagent viewport uses, instead of snapping to
* `scrollHeight` on every content mutation. Chase writes only ever move
* `scrollTop` down, so the detach logic in `onScroll` (which requires an
* upward move) never mistakes the glide for a user scroll; the helper's
* own upward-move interrupt and the per-frame sticky check are extra
* layers of the same guarantee.
*/
const chase = createSmoothBottomChase(
{
getTop: () => el.scrollTop,
getBottomTop: () => el.scrollHeight - el.clientHeight,
setTop: (top) => {
el.scrollTop = top
},
},
() => stickyRef.current
)

const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const isNearBottom = distanceFromBottom <= STICK_THRESHOLD
stickyRef.current = isNearBottom
userDetachedRef.current = !isNearBottom
prevScrollTopRef.current = el.scrollTop
prevScrollHeightRef.current = el.scrollHeight
if (isNearBottom) scrollToBottom()
if (isNearBottom) chase.kick()

const detach = () => {
stickyRef.current = false
Expand Down Expand Up @@ -164,25 +188,20 @@ export function useAutoScroll(
prevScrollHeightRef.current = scrollHeight
}

const guardedScroll = () => {
if (stickyRef.current) scrollToBottom()
}

const onMutation = () => {
prevScrollHeightRef.current = el.scrollHeight
if (!stickyRef.current) return
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = requestAnimationFrame(guardedScroll)
chase.kick()
}

/**
* Chase the bottom every frame for `durationMs`. Catches height growth that
* arrives over several frames with no observed DOM mutation — a CSS height
* animation, or end-of-turn content and the virtualizer's re-measure settling
* after streaming stops.
* Chase the bottom every frame for `durationMs` with the same eased step.
* Catches height growth that arrives over several frames with no observed
* DOM mutation — a CSS height animation, or end-of-turn content and the
* virtualizer's re-measure settling after streaming stops.
*
* Self-interrupting: height growth leaves `scrollTop` exactly where we last
* put it, whereas a user scroll moves it up from there — so the moment
* Self-interrupting: our eased writes leave `scrollTop` exactly where we
* last put it, whereas a user scroll moves it up from there — so the moment
* `scrollTop` drops below our last write, we stop and never fight a real
* scroll, even with the gesture listeners already torn down.
*/
Expand All @@ -193,7 +212,10 @@ export function useAutoScroll(
const follow = () => {
if (performance.now() > until || !stickyRef.current) return
if (lastTop >= 0 && el.scrollTop < lastTop - 1) return
scrollToBottom()
const gap = el.scrollHeight - el.clientHeight - el.scrollTop
if (gap > CHASE_REST_GAP) {
el.scrollTop = el.scrollTop + Math.max(1, gap * SMOOTH_CHASE_RATE)
}
lastTop = el.scrollTop
requestAnimationFrame(follow)
}
Expand Down Expand Up @@ -232,7 +254,7 @@ export function useAutoScroll(
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerUp)
observer.disconnect()
cancelAnimationFrame(rafIdRef.current)
chase.cancel()
pointerDownRef.current = false
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
followToBottom(POST_STREAM_SETTLE_WINDOW)
Expand Down
113 changes: 69 additions & 44 deletions apps/sim/hooks/use-smooth-text.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,46 @@
import { useEffect, useRef, useState } from 'react'

/**
* Paced reveal of a growing string, ported from opencode's `createPacedValue`
* (`packages/ui/src/components/message-part.tsx`). Instead of revealing a fixed
* number of characters per animation frame, it advances on a steady ~24ms timer
* in small tiered steps that SNAP to the next word/punctuation boundary — so
* text appears word-by-word at a calm, even cadence regardless of how bursty the
* upstream model deltas are. The boundary snapping is what keeps it from reading
* as "blocky": a reveal never stops mid-word.
* Time-based paced reveal of a growing string. A per-frame loop earns a
* character budget from elapsed time and releases text one word/punctuation
* boundary at a time — so words appear individually, evenly spaced on the
* timeline, instead of the old fixed-interval tick that dumped a multi-word
* chunk every 24ms and read as blocky.
*
* The rate is a proportional controller: drain the current backlog over
* {@link DRAIN_HORIZON_MS}. It therefore converges on the stream's real
* arrival rate — a fast stream reveals fast, a slow one trickles — instead of
* racing ahead at a fixed cap, emptying the backlog, and stalling until the
* next network burst (the old burst–pause rhythm).
*/
const PACE_MS = 24
const SNAP = /[\s.,!?;:)\]]/

/**
* Characters to advance per tick as a function of how far the reveal is behind.
* Small backlogs trickle (2–8 chars); large backlogs accelerate but stay capped
* so a burst is spread over several ticks rather than dumped at once.
*/
function step(remaining: number): number {
if (remaining <= 12) return 2
if (remaining <= 48) return 4
if (remaining <= 96) return 8
return Math.min(24, Math.ceil(remaining / 8))
/** Reveal the backlog over roughly this horizon (a small jitter buffer). */
const DRAIN_HORIZON_MS = 400
/** Floor so a near-empty backlog still trickles out instead of freezing. */
const MIN_CPS = 45
/** Cap so a huge backlog (resume, giant paste) sweeps in over ~a second. */
const MAX_CPS = 2400

/** Chars/second that drains `remaining` over the horizon, clamped. */
function drainRate(remaining: number): number {
return Math.min(MAX_CPS, Math.max(MIN_CPS, (remaining * 1000) / DRAIN_HORIZON_MS))
}

/**
* Advance from `start` by `step(...)`, then extend up to 8 more characters to
* land just past the next word/punctuation boundary so the reveal lands on a
* whole word rather than mid-token.
* The furthest word/punctuation boundary within `start + budget`, or `start`
* when the budget doesn't yet cover the next whole word (the budget carries
* over to later frames). Words longer than the 24-char lookahead are released
* whole once the budget covers the lookahead, so an unbroken token (a URL, a
* long identifier) cannot dam the reveal.
*/
function nextIndex(text: string, start: number): number {
const end = Math.min(text.length, start + step(text.length - start))
const max = Math.min(text.length, end + 8)
for (let i = end; i < max; i++) {
if (SNAP.test(text[i] ?? '')) return i + 1
function nextIndex(text: string, start: number, budget: number): number {
const limit = Math.min(text.length, start + Math.floor(budget))
for (let i = limit; i > start; i--) {
if (SNAP.test(text[i - 1] ?? '')) return i
}
return end
if (limit >= Math.min(text.length, start + 24)) return limit
return start
}

/**
Expand Down Expand Up @@ -74,13 +79,13 @@ interface SmoothTextOptions {
*
* @remarks
* The re-arm effect runs on every committed render with a cheap
* `timeoutRef === null` guard instead of keying on a `hasBacklog` dependency.
* The tick chain self-terminates whenever the reveal catches up, and a chain
* keyed on the `hasBacklog` boolean could die for good: when the final tick's
* `rafRef === null` guard instead of keying on a `hasBacklog` dependency.
* The frame chain self-terminates whenever the reveal catches up, and a chain
* keyed on the `hasBacklog` boolean could die for good: when the final frame's
* `setRevealed` and a new chunk land in the same React commit, `hasBacklog`
* stays `true` across commits, the effect never re-fires, and the reveal
* freezes mid-stream until remount. Re-arming per render closes that
* interleaving while still avoiding per-chunk timer teardown (no cleanup on
* interleaving while still avoiding per-chunk loop teardown (no cleanup on
* content changes), so it cannot trip React's max-update-depth guard either.
* If upstream sanitization rewrites earlier text and shrinks the string, the
* cursor is pulled back to the new end so regrowth stays paced instead of
Expand All @@ -99,7 +104,10 @@ export function useSmoothText(

const contentRef = useRef(content)
const revealedRef = useRef(revealed)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const rafRef = useRef<number | null>(null)
/** Fractional character budget carried between frames (see the frame loop). */
const budgetRef = useRef(0)
const lastFrameAtRef = useRef(0)
const prevContentRef = useRef(content)
const prevIsStreamingRef = useRef(isStreaming)

Expand Down Expand Up @@ -142,36 +150,53 @@ export function useSmoothText(
}, [content, isStreaming])

useEffect(() => {
const run = () => {
timeoutRef.current = null
/**
* Per-frame reveal: each frame earns `drainRate * dt` characters of budget
* (fractional remainder carried in `budgetRef`), and the cursor advances to
* the furthest word boundary the budget covers — releasing words one at a
* time, evenly spaced in real time, rather than a fixed-size chunk per
* tick. Frames whose budget doesn't yet cover the next word update nothing.
*/
const run = (now: number) => {
rafRef.current = null
const text = contentRef.current
const target = text.length

if (revealedRef.current > target) {
revealedRef.current = target
budgetRef.current = 0
setRevealed(target)
}
const current = revealedRef.current
if (current >= target) return

const next = nextIndex(text, current)
revealedRef.current = next
setRevealed(next)
if (next < target) {
timeoutRef.current = setTimeout(run, PACE_MS)
// Clamp dt so a background tab's paused rAF doesn't bank a giant budget.
const dt = Math.min(now - lastFrameAtRef.current, 100)
lastFrameAtRef.current = now
budgetRef.current += (drainRate(target - current) * dt) / 1000

const next = nextIndex(text, current, budgetRef.current)
if (next > current) {
budgetRef.current -= next - current
revealedRef.current = next
setRevealed(next)
}
if (revealedRef.current < target) {
rafRef.current = requestAnimationFrame(run)
}
}

if (hasBacklog && timeoutRef.current === null) {
timeoutRef.current = setTimeout(run, PACE_MS)
if (hasBacklog && rafRef.current === null) {
lastFrameAtRef.current = performance.now()
rafRef.current = requestAnimationFrame(run)
}
})

useEffect(
() => () => {
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
},
[]
Expand Down
92 changes: 92 additions & 0 deletions apps/sim/lib/core/utils/smooth-bottom-chase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* Fraction of the remaining gap to close per frame while chasing the bottom —
* an exponential glide (originating in the subagent viewport's stick-to-bottom,
* see BoundedViewport in agent-group.tsx) instead of snapping `scrollTop` to
* `scrollHeight` on every content append. Closes ~90% of any gap within ~18
* frames (~300ms) — deliberately lazier than the subagent viewport's 0.18 so a
* large content burst reads as a calm upward drift of the transcript rather
* than a lurch.
*/
export const SMOOTH_CHASE_RATE = 0.12

/** Gap (px) below which the chase parks until new growth reopens it. */
export const CHASE_REST_GAP = 0.5

export interface SmoothBottomChaseTarget {
/** Current scroll offset. */
getTop: () => number
/** Scroll offset at which the viewport bottom meets the content bottom. */
getBottomTop: () => number
/** Apply a new scroll offset. */
setTop: (top: number) => void
}

export interface SmoothBottomChaseHandle {
/** True while a chase frame is scheduled (gap still closing). */
isActive: () => boolean
/** Start the loop if parked. Call after content growth. */
kick: () => void
cancel: () => void
}

/**
* Eased stick-to-bottom chase over any scrollable target (a DOM element or an
* editor API like Monaco's). Each frame closes {@link SMOOTH_CHASE_RATE} of the
* remaining gap and self-parks at {@link CHASE_REST_GAP}; content growth
* restarts it via `kick()`.
*
* Self-interrupting: chase writes only ever move the offset down, and content
* growth leaves it where the last write put it — so an offset that moved UP
* since the last write can only be a user scrolling away, and the loop parks
* instead of fighting them. `shouldContinue` layers any caller-owned stickiness
* on top (checked every frame).
*/
export function createSmoothBottomChase(
target: SmoothBottomChaseTarget,
shouldContinue: () => boolean = () => true
): SmoothBottomChaseHandle {
let raf: number | null = null
let lastTop: number | null = null

const park = () => {
if (raf !== null) cancelAnimationFrame(raf)
raf = null
lastTop = null
}

const step = () => {
// `raf` deliberately keeps this (already-fired) frame's id while the step
// body runs: canceling a fired handle is a no-op, and a non-null `raf`
// means `isActive()` stays true and a reentrant `kick()` — e.g. from a
// target whose `setTop` fires synchronous scroll listeners, like Monaco's
// onDidScrollChange — cannot start a second parallel chain.
if (!shouldContinue()) {
park()
return
}
const top = target.getTop()
if (lastTop !== null && top < lastTop - 1) {
park()
return
}
const gap = target.getBottomTop() - top
if (gap <= CHASE_REST_GAP) {
park()
return
}
target.setTop(top + Math.max(1, gap * SMOOTH_CHASE_RATE))
// A synchronous side-effect of `setTop` may have called `cancel()`; honor
// it instead of re-queuing over it.
if (raf === null) return
lastTop = target.getTop()
raf = requestAnimationFrame(step)
}
Comment thread
TheodoreSpeaks marked this conversation as resolved.

return {
isActive: () => raf !== null,
kick: () => {
if (raf === null) raf = requestAnimationFrame(step)
},
cancel: park,
}
}
Loading