Skip to content
Merged
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
89 changes: 80 additions & 9 deletions apps/sim/hooks/use-auto-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,26 @@ import { useCallback, useEffect, useRef } from 'react'
const STICK_THRESHOLD = 30
/** User must scroll back to within this distance to re-engage auto-scroll. */
const REATTACH_THRESHOLD = 5
/**
* An upward keyboard scroll ({@link SCROLL_UP_KEYS}) only emits `scroll` events, so
* its detach is honored when it lands within this window of the `keydown`. Wheel and
* touch detach directly via their own handlers, and scrollbar drags are tracked
* through {@link pointerDownRef}, so neither feeds this window.
*
* The guard exists because virtualizers (react-virtual) programmatically move
* `scrollTop` to keep content stable when a measured row's size changes —
* including the transient height *shrinks* a streaming markdown renderer emits as
* it re-parses each token. Without it, that upward programmatic scroll is misread
* as the user scrolling away and auto-scroll detaches mid-stream.
*/
const USER_GESTURE_WINDOW = 250
/**
* Keys that scroll the viewport upward. Only these authorize a keyboard detach,
* mirroring the wheel handler's upward-only ({@link WheelEvent.deltaY} < 0) rule,
* so an unrelated keypress can't open the detach window. `Shift`+`Space` (handled
* in the listener) is the other upward shortcut; plain `Space` pages down.
*/
const SCROLL_UP_KEYS = new Set(['ArrowUp', 'PageUp', 'Home'])

interface UseAutoScrollOptions {
scrollOnMount?: boolean
Expand All @@ -13,9 +33,10 @@ interface UseAutoScrollOptions {
* Manages sticky auto-scroll for a streaming chat container.
*
* Stays pinned to the bottom while content streams in. Detaches immediately
* on any upward user gesture (wheel, touch, scrollbar drag). Once detached,
* the user must scroll back to within {@link REATTACH_THRESHOLD} of the
* bottom to re-engage.
* on any upward user gesture (wheel, touch, scrollbar drag, keyboard). Once
* detached, the user must scroll back to within {@link REATTACH_THRESHOLD} of
* the bottom to re-engage. Each streaming start re-seeds stickiness from the
* current scroll position, so a user who scrolled up beforehand stays put.
*
* Returns `ref` (callback ref for the scroll container) and `scrollToBottom`
* for imperative use after layout-changing events like panel expansion.
Expand All @@ -32,6 +53,18 @@ export function useAutoScroll(
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
* container itself rather than its content. Reset on teardown so a pointer held
* as one stream ends can't leak into the next session and authorize a detach.
*/
const pointerDownRef = useRef(false)
/**
* Timestamp of the last keyboard scroll, the only detach gesture that emits no
* wheel/touch/pointer signal. Gates {@link USER_GESTURE_WINDOW}; reset on teardown
* so a keypress near a stream's end can't carry into the next session.
*/
const lastUserGestureAtRef = useRef(Number.NEGATIVE_INFINITY)

const scrollToBottom = useCallback(() => {
const el = containerRef.current
Expand All @@ -49,7 +82,6 @@ export function useAutoScroll(
const el = containerRef.current
if (!el) return

// Don't jump if the user scrolled up — keep their position.
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
const isNearBottom = distanceFromBottom <= STICK_THRESHOLD
stickyRef.current = isNearBottom
Expand All @@ -75,15 +107,43 @@ export function useAutoScroll(
if (e.touches[0].clientY > touchStartYRef.current) detach()
}

/**
* A scrollbar press targets the scroll container itself; a press on message
* content targets a descendant. Only the former is a scroll gesture, so a
* text-selection drag on content can't authorize a detach.
*/
const onPointerDown = (e: PointerEvent) => {
if (e.target === el) pointerDownRef.current = true
}
Comment thread
waleedlatif1 marked this conversation as resolved.
const onPointerUp = () => {
pointerDownRef.current = false
}
const onKeyDown = (e: KeyboardEvent) => {
if (SCROLL_UP_KEYS.has(e.key) || (e.key === ' ' && e.shiftKey)) {
lastUserGestureAtRef.current = performance.now()
}
}
Comment thread
waleedlatif1 marked this conversation as resolved.

/**
* Re-engages when the user returns near the bottom, and detaches on an upward
* scroll — but only a genuine user scroll qualifies: an active scrollbar drag
* (pointer held) or a recent keyboard scroll. A programmatic upward scroll, e.g.
* a virtualizer re-pinning content on a row-size shrink, has neither and must not
* be mistaken for the user scrolling away.
*/
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const threshold = userDetachedRef.current ? REATTACH_THRESHOLD : STICK_THRESHOLD
const userDriven =
pointerDownRef.current ||
performance.now() - lastUserGestureAtRef.current < USER_GESTURE_WINDOW

if (distanceFromBottom <= threshold) {
stickyRef.current = true
userDetachedRef.current = false
} else if (
userDriven &&
scrollTop < prevScrollTopRef.current &&
scrollHeight <= prevScrollHeightRef.current
) {
Expand All @@ -105,11 +165,12 @@ export function useAutoScroll(
rafIdRef.current = requestAnimationFrame(guardedScroll)
}

// CSS-driven height animations (e.g. Radix Collapsible expanding
// mid-stream) grow scrollHeight without triggering MutationObserver,
// so auto-scroll stops following. When any animation starts in the
// container, follow rAF for a short window so the container stays
// pinned to the bottom while the animation runs.
/**
* CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
* grow scrollHeight without triggering MutationObserver, so auto-scroll stops
* following. When any animation starts in the container, follow rAF for a short
* window so the container stays pinned to the bottom while the animation runs.
*/
const onAnimationStart = () => {
if (!stickyRef.current) return
const until = performance.now() + 500
Expand All @@ -126,6 +187,10 @@ export function useAutoScroll(
el.addEventListener('touchmove', onTouchMove, { passive: true })
el.addEventListener('scroll', onScroll, { passive: true })
el.addEventListener('animationstart', onAnimationStart)
el.addEventListener('pointerdown', onPointerDown, { passive: true })
el.addEventListener('keydown', onKeyDown, { passive: true })
window.addEventListener('pointerup', onPointerUp, { passive: true })
window.addEventListener('pointercancel', onPointerUp, { passive: true })

const observer = new MutationObserver(onMutation)
observer.observe(el, { childList: true, subtree: true, characterData: true })
Expand All @@ -136,8 +201,14 @@ export function useAutoScroll(
el.removeEventListener('touchmove', onTouchMove)
el.removeEventListener('scroll', onScroll)
el.removeEventListener('animationstart', onAnimationStart)
el.removeEventListener('pointerdown', onPointerDown)
el.removeEventListener('keydown', onKeyDown)
window.removeEventListener('pointerup', onPointerUp)
window.removeEventListener('pointercancel', onPointerUp)
Comment thread
waleedlatif1 marked this conversation as resolved.
observer.disconnect()
cancelAnimationFrame(rafIdRef.current)
pointerDownRef.current = false
Comment thread
waleedlatif1 marked this conversation as resolved.
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
if (stickyRef.current) scrollToBottom()
Comment thread
waleedlatif1 marked this conversation as resolved.
}
}, [isStreaming, scrollToBottom])
Expand Down
Loading