From 546addd66b1e737de073569e0d7a06af08fc59d3 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 12:56:37 -0700 Subject: [PATCH 1/3] improvement(chat): eased stick-to-bottom glide for streaming chat --- apps/sim/hooks/use-auto-scroll.ts | 54 ++++++++---- .../sim/lib/core/utils/smooth-bottom-chase.ts | 87 +++++++++++++++++++ 2 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 apps/sim/lib/core/utils/smooth-bottom-chase.ts diff --git a/apps/sim/hooks/use-auto-scroll.ts b/apps/sim/hooks/use-auto-scroll.ts index cfc2c5c5529..19b3a47a9ed 100644 --- a/apps/sim/hooks/use-auto-scroll.ts +++ b/apps/sim/hooks/use-auto-scroll.ts @@ -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 @@ -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 @@ -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 @@ -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. */ @@ -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) } @@ -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) diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts new file mode 100644 index 00000000000..8f11660e13c --- /dev/null +++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts @@ -0,0 +1,87 @@ +/** + * 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 = () => { + raf = null + lastTop = null + } + + const step = () => { + raf = null + 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)) + lastTop = target.getTop() + raf = requestAnimationFrame(step) + } + + return { + isActive: () => raf !== null, + kick: () => { + if (raf === null) raf = requestAnimationFrame(step) + }, + cancel: () => { + if (raf !== null) cancelAnimationFrame(raf) + park() + }, + } +} From 276fee15921aaa1fec71f0163070b87045f32cda Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 13:27:00 -0700 Subject: [PATCH 2/3] improvement(chat): time-based word-at-a-time reveal pacing --- apps/sim/hooks/use-smooth-text.ts | 113 ++++++++++++++++++------------ 1 file changed, 69 insertions(+), 44 deletions(-) diff --git a/apps/sim/hooks/use-smooth-text.ts b/apps/sim/hooks/use-smooth-text.ts index b366d839954..0411e8365d8 100644 --- a/apps/sim/hooks/use-smooth-text.ts +++ b/apps/sim/hooks/use-smooth-text.ts @@ -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 } /** @@ -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 @@ -99,7 +104,10 @@ export function useSmoothText( const contentRef = useRef(content) const revealedRef = useRef(revealed) - const timeoutRef = useRef | null>(null) + const rafRef = useRef(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) @@ -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 } }, [] From d43a39cbea8b6f661d29ad3d430578d85a57496b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 4 Jul 2026 15:48:53 -0700 Subject: [PATCH 3/3] improvement(chat): harden smooth-chase against reentrant kick/cancel during a step --- apps/sim/lib/core/utils/smooth-bottom-chase.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/core/utils/smooth-bottom-chase.ts b/apps/sim/lib/core/utils/smooth-bottom-chase.ts index 8f11660e13c..1f6dea0af16 100644 --- a/apps/sim/lib/core/utils/smooth-bottom-chase.ts +++ b/apps/sim/lib/core/utils/smooth-bottom-chase.ts @@ -49,12 +49,17 @@ export function createSmoothBottomChase( let lastTop: number | null = null const park = () => { + if (raf !== null) cancelAnimationFrame(raf) raf = null lastTop = null } const step = () => { - raf = null + // `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 @@ -70,6 +75,9 @@ export function createSmoothBottomChase( 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) } @@ -79,9 +87,6 @@ export function createSmoothBottomChase( kick: () => { if (raf === null) raf = requestAnimationFrame(step) }, - cancel: () => { - if (raf !== null) cancelAnimationFrame(raf) - park() - }, + cancel: park, } }