import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { readThreadScrollAnchor, threadScrollAnchorDelta, type ThreadScrollAnchor } from "./scroll-anchor"; import { cn } from "@/lib/utils"; import { useStreamlinedTaskChatPresentation } from "./presentation-mode"; import { ArrowDown } from "lucide-react"; import { parseCssTimeMs } from "./motion-tokens"; import { useTaskChatScrollNavigation } from "./scroll-navigation"; const PIN_THRESHOLD_PX = 48; /** Visibility lifecycle of the scroll-to-latest pill. */ type PillPhase = "hidden" | "in" | "out"; /** * True when animations are disabled (prefers-reduced-motion, or environments * without matchMedia such as jsdom). In that case the pill's exit animation * never fires animationend, so we must unmount immediately. */ function motionDisabled(): boolean { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true; return window.matchMedia("(prefers-reduced-motion: reduce)").matches; } interface TaskMessageScrollerProps { children: ReactNode; /** Value that changes whenever content that could grow the thread updates. */ contentKey: unknown; className?: string; } /** * Scroll container that owns the redesign's auto-follow vs. hold-position rule. * * Explicit rule: while the viewport is pinned to the bottom (within a small * threshold) new content auto-follows with INSTANT scroll; the moment the user * scrolls up we hold their position and surface a scroll-to-latest pill * instead of yanking them down. * * Re-follow is easing-aware: clicking the pill glides down smoothly, and while * that glide is in flight (`easingRef`) the scroll handler must not unpin — * the smooth scroll fires intermediate scroll events that would otherwise * re-show the pill mid-glide (the known stick-to-bottom failure mode). Arrival * within the pin threshold re-pins and hides the pill; a wheel/touch during * the glide cancels it and treats the user as unpinned. Content-driven follow * while pinned stays instant, so no reflow/jump happens during streaming. */ export function TaskMessageScroller({ children, contentKey, className }: TaskMessageScrollerProps) { const streamlined = useStreamlinedTaskChatPresentation(); const navigation = useTaskChatScrollNavigation(); const initialPositionApplied = useRef(false); const appliedNavigation = useRef({ key: navigation.key, hash: navigation.hash }); const ref = useRef(null); const anchorRef = useRef(null); const pinnedRef = useRef(true); const easingRef = useRef(false); const clientHeightRef = useRef(null); const scrollbarIdleTimerRef = useRef(null); const scrollbarIdleDelayRef = useRef(null); const [pillPhase, setPillPhase] = useState("hidden"); const showScrollbarWhileScrolling = useCallback(() => { const el = ref.current; if (!el) return; el.dataset.scrollActive = "true"; if (scrollbarIdleTimerRef.current !== null) { window.clearTimeout(scrollbarIdleTimerRef.current); } const idleDelay = scrollbarIdleDelayRef.current ?? parseCssTimeMs( getComputedStyle(document.documentElement).getPropertyValue("--motion-scrollbar-idle-delay"), ); scrollbarIdleDelayRef.current = idleDelay; scrollbarIdleTimerRef.current = window.setTimeout(() => { delete el.dataset.scrollActive; scrollbarIdleTimerRef.current = null; scrollbarIdleDelayRef.current = null; }, idleDelay); }, []); const showPill = useCallback(() => { setPillPhase("in"); }, []); const hidePill = useCallback(() => { // Keep rendering through the exit animation; unmount on animationend. // Without animations (reduced motion / no matchMedia) unmount immediately. setPillPhase((phase) => (phase === "hidden" ? phase : motionDisabled() ? "hidden" : "out")); }, []); const isPinned = useCallback(() => { const el = ref.current; if (!el) return true; return el.scrollHeight - el.scrollTop - el.clientHeight <= PIN_THRESHOLD_PX; }, []); const scrollToBottom = useCallback(() => { const el = ref.current; if (!el) return; el.scrollTop = el.scrollHeight; // instant, never smooth }, []); const followViewportResize = useCallback(() => { const el = ref.current; if (!el) return false; const previousClientHeight = clientHeightRef.current; const nextClientHeight = el.clientHeight; clientHeightRef.current = nextClientHeight; if ( previousClientHeight == null || previousClientHeight <= 0 || previousClientHeight === nextClientHeight || !pinnedRef.current ) { return false; } scrollToBottom(); return true; }, [scrollToBottom]); const rememberAnchor = useCallback(() => { const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom); if (initialPositionApplied.current) navigation.remember(el.scrollTop, anchorRef.current); }, [navigation.key, navigation.hash, navigation.ready]); const reconcileContent = useCallback(() => { const el = ref.current; if (!el) return; // Clicking latest is an explicit follow intent. If content or the composer // changes during its glide, finish at the new bottom instead of restoring // the old reading anchor and cancelling the browser's smooth scroll. if (easingRef.current) { easingRef.current = false; pinnedRef.current = true; hidePill(); } if (pinnedRef.current) scrollToBottom(); else { const delta = threadScrollAnchorDelta(el, anchorRef.current, el.getBoundingClientRect().top); if (delta) el.scrollTop += delta; } rememberAnchor(); }, [rememberAnchor, scrollToBottom, hidePill]); const handleScroll = useCallback(() => { rememberAnchor(); showScrollbarWhileScrolling(); // A growing composer shrinks this viewport. Some browsers dispatch the // resulting scroll event before ResizeObserver, so preserve the previous // pinned state here instead of mistaking the layout change for a user // scroll away from the bottom. if (followViewportResize()) { hidePill(); return; } const pinned = isPinned(); if (easingRef.current) { // Smooth re-follow in flight: intermediate scroll events must not // unpin/re-show the pill. Only act once we arrive within the threshold. if (pinned) { easingRef.current = false; pinnedRef.current = true; hidePill(); } return; } pinnedRef.current = pinned; if (pinned) hidePill(); else showPill(); }, [ rememberAnchor, followViewportResize, isPinned, hidePill, showPill, showScrollbarWhileScrolling, ]); const handleJumpToLatest = useCallback(() => { const el = ref.current; if (!el) return; if (isPinned()) { // Already at (or within threshold of) the bottom: no glide needed. pinnedRef.current = true; hidePill(); return; } easingRef.current = true; if (typeof el.scrollTo === "function" && !motionDisabled()) { el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); } else { // Environments without scrollTo (older jsdom): fall back to instant. el.scrollTop = el.scrollHeight; easingRef.current = false; pinnedRef.current = true; hidePill(); } }, [isPinned, hidePill]); // A user gesture during the smooth glide cancels the re-follow: stop // treating scroll events as easing and consider the user unpinned. useEffect(() => { const el = ref.current; if (!el) return; const cancelEasing = () => { if (!easingRef.current) return; easingRef.current = false; pinnedRef.current = false; showPill(); }; el.addEventListener("wheel", cancelEasing, { passive: true }); el.addEventListener("touchstart", cancelEasing, { passive: true }); return () => { el.removeEventListener("wheel", cancelEasing); el.removeEventListener("touchstart", cancelEasing); }; }, [showPill]); useEffect(() => () => { if (scrollbarIdleTimerRef.current !== null) { window.clearTimeout(scrollbarIdleTimerRef.current); } }, []); useLayoutEffect(() => { const el = ref.current; if (!el) return; clientHeightRef.current = el.clientHeight; if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { if (followViewportResize()) hidePill(); reconcileContent(); }); observer.observe(el); // The viewport itself does not resize when an image or historical row // grows. Observe the content box too, before the browser paints it. if (el.firstElementChild) observer.observe(el.firstElementChild); return () => observer.disconnect(); }, [followViewportResize, hidePill, reconcileContent]); // Follow new content only when already pinned; otherwise hold position. useLayoutEffect(() => { const el = ref.current; if (appliedNavigation.current.key !== navigation.key || appliedNavigation.current.hash !== navigation.hash) { appliedNavigation.current = { key: navigation.key, hash: navigation.hash }; initialPositionApplied.current = false; } if (el && navigation.ready && !initialPositionApplied.current) { const top = navigation.initialPosition(el, el.getBoundingClientRect().top, el.scrollTop); if (top !== null) { el.scrollTop = top; pinnedRef.current = isPinned(); rememberAnchor(); } initialPositionApplied.current = true; } reconcileContent(); }, [contentKey, reconcileContent, navigation.key, navigation.hash, navigation.ready]); return (
{children}
{pillPhase !== "hidden" ? ( ) : null}
); }