d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
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
|
|
/** 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'])
|
|
/** How long to keep chasing the bottom while a CSS height animation plays. */
|
|
const ANIMATION_FOLLOW_WINDOW = 500
|
|
/**
|
|
* How long to keep chasing the bottom after streaming stops. End-of-turn content
|
|
* mounts just after `isStreaming` flips false — the suggested-follow-up options,
|
|
* the actions row (gated on `!isStreaming`), and the virtualizer's re-measure of
|
|
* the grown row — so a single final scroll fires before it lays out and leaves it
|
|
* clipped behind the input. Following for a short window pulls it into view.
|
|
*/
|
|
const POST_STREAM_SETTLE_WINDOW = 300
|
|
|
|
interface UseAutoScrollOptions {
|
|
scrollOnMount?: boolean
|
|
}
|
|
|
|
/**
|
|
* 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, 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.
|
|
*/
|
|
export function useAutoScroll(
|
|
isStreaming: boolean,
|
|
{ scrollOnMount = false }: UseAutoScrollOptions = {}
|
|
) {
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const stickyRef = useRef(true)
|
|
const userDetachedRef = useRef(false)
|
|
const prevScrollTopRef = useRef(0)
|
|
const prevScrollHeightRef = useRef(0)
|
|
const touchStartYRef = 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
|
|
if (!el) return
|
|
el.scrollTop = el.scrollHeight
|
|
}, [])
|
|
|
|
const callbackRef = useCallback((el: HTMLDivElement | null) => {
|
|
containerRef.current = el
|
|
if (el && scrollOnMountRef.current) el.scrollTop = el.scrollHeight
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!isStreaming) return
|
|
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) chase.kick()
|
|
|
|
const detach = () => {
|
|
stickyRef.current = false
|
|
userDetachedRef.current = true
|
|
}
|
|
|
|
const onWheel = (e: WheelEvent) => {
|
|
if (e.deltaY < 0) detach()
|
|
}
|
|
|
|
const onTouchStart = (e: TouchEvent) => {
|
|
touchStartYRef.current = e.touches[0].clientY
|
|
}
|
|
|
|
const onTouchMove = (e: TouchEvent) => {
|
|
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
|
|
}
|
|
const onPointerUp = () => {
|
|
pointerDownRef.current = false
|
|
}
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (SCROLL_UP_KEYS.has(e.key) || (e.key === ' ' && e.shiftKey)) {
|
|
lastUserGestureAtRef.current = performance.now()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
) {
|
|
detach()
|
|
}
|
|
|
|
prevScrollTopRef.current = scrollTop
|
|
prevScrollHeightRef.current = scrollHeight
|
|
}
|
|
|
|
const onMutation = () => {
|
|
prevScrollHeightRef.current = el.scrollHeight
|
|
if (!stickyRef.current) return
|
|
chase.kick()
|
|
}
|
|
|
|
/**
|
|
* 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: 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.
|
|
*/
|
|
const followToBottom = (durationMs: number) => {
|
|
if (!stickyRef.current) return
|
|
const until = performance.now() + durationMs
|
|
let lastTop = -1
|
|
const follow = () => {
|
|
if (performance.now() > until || !stickyRef.current) return
|
|
if (lastTop >= 0 && el.scrollTop < lastTop - 1) return
|
|
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)
|
|
}
|
|
requestAnimationFrame(follow)
|
|
}
|
|
|
|
/**
|
|
* CSS-driven height animations (e.g. Radix Collapsible expanding mid-stream)
|
|
* grow scrollHeight without triggering MutationObserver, so auto-scroll stops
|
|
* following. Follow for a short window so the container stays pinned while the
|
|
* animation runs.
|
|
*/
|
|
const onAnimationStart = () => followToBottom(ANIMATION_FOLLOW_WINDOW)
|
|
|
|
el.addEventListener('wheel', onWheel, { passive: true })
|
|
el.addEventListener('touchstart', onTouchStart, { passive: true })
|
|
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 })
|
|
|
|
return () => {
|
|
el.removeEventListener('wheel', onWheel)
|
|
el.removeEventListener('touchstart', onTouchStart)
|
|
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)
|
|
observer.disconnect()
|
|
chase.cancel()
|
|
pointerDownRef.current = false
|
|
lastUserGestureAtRef.current = Number.NEGATIVE_INFINITY
|
|
followToBottom(POST_STREAM_SETTLE_WINDOW)
|
|
}
|
|
}, [isStreaming, scrollToBottom])
|
|
|
|
return { ref: callbackRef, scrollToBottom }
|
|
}
|