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 { taskChatHashAnchor, 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; } export interface ScrollingHistory { hasMore: boolean; loading: boolean; error?: boolean; load: () => unknown; } interface TaskMessageScrollerProps { scrollingHistory?: ScrollingHistory; 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, scrollingHistory }: TaskMessageScrollerProps) { const streamlined = useStreamlinedTaskChatPresentation(); const navigation = useTaskChatScrollNavigation(); const initialPositionApplied = useRef(false); const appliedNavigation = useRef({ key: navigation.key, hash: navigation.hash }); const pendingHashTarget = useRef(taskChatHashAnchor(navigation.hash ?? "")); const ref = useRef(null); const anchorRef = useRef(null); const pinnedRef = useRef(true); const easingRef = useRef(false); const clientHeightRef = useRef(null); const clientWidthRef = useRef(null); const scrollbarIdleTimerRef = useRef(null); const scrollbarIdleDelayRef = useRef(null); const [pillPhase, setPillPhase] = useState("hidden"); const previousScrollTop = useRef(0); const touchStartY = useRef(null); const requestingHistory = useRef(false); const upwardIntent = useRef(false); const requestEarlierHistory = useCallback(() => { upwardIntent.current = false; if (!initialPositionApplied.current || !navigation.ready || !scrollingHistory?.hasMore || scrollingHistory.loading || scrollingHistory.error || requestingHistory.current) return; requestingHistory.current = true; const el = ref.current; if (el?.clientHeight) { const rect = el.getBoundingClientRect(); pinnedRef.current = false; anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom); navigation.remember(el.scrollTop, anchorRef.current, false); } Promise.resolve().then(() => scrollingHistory.load()).catch(() => {}).finally(() => { requestingHistory.current = false; }); }, [navigation.key, navigation.hash, navigation.ready, scrollingHistory]); 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 previousClientWidth = clientWidthRef.current; const nextClientHeight = el.clientHeight; clientHeightRef.current = nextClientHeight; clientWidthRef.current = el.clientWidth; if (previousClientWidth != null && previousClientWidth !== el.clientWidth && !pinnedRef.current) { const delta = threadScrollAnchorDelta(el, anchorRef.current, el.getBoundingClientRect().top); if (delta) el.scrollTop += delta; return true; } if ( previousClientHeight == null || previousClientHeight <= 0 || previousClientHeight === nextClientHeight || !pinnedRef.current ) { return false; } scrollToBottom(); return true; }, [scrollToBottom]); const rememberAnchor = useCallback((capture = true) => { const el = ref.current; if (!el || el.clientHeight === 0) return; if (pinnedRef.current) { // Following the bottom needs only an offset, not a scan of every old row. anchorRef.current = null; } else if (capture) { const rect = el.getBoundingClientRect(); anchorRef.current = readThreadScrollAnchor(el, rect.top, rect.bottom); } if (initialPositionApplied.current) navigation.remember(el.scrollTop, anchorRef.current, pinnedRef.current); }, [navigation.key, navigation.hash, navigation.ready]); const reconcileContent = useCallback(() => { const el = ref.current; if (!el || el.clientHeight === 0) 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; } // Compensation preserves the existing reading anchor. Recapture only when // the reader scrolls or navigation selects a different position. rememberAnchor(false); previousScrollTop.current = el.scrollTop; }, [rememberAnchor, scrollToBottom, hidePill]); const handleScroll = useCallback(() => { if (!ref.current?.clientHeight) return; const top = ref.current.scrollTop; const scrollingUp = top < previousScrollTop.current; previousScrollTop.current = top; if ((scrollingUp || upwardIntent.current) && top <= 192 && !easingRef.current) requestEarlierHistory(); 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()) { rememberAnchor(false); if (pinnedRef.current) hidePill(); return; } const pinned = requestingHistory.current ? false : 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(); } rememberAnchor(); return; } pinnedRef.current = pinned; rememberAnchor(); if (pinned) hidePill(); else showPill(); }, [ rememberAnchor, followViewportResize, isPinned, hidePill, showPill, showScrollbarWhileScrolling, requestEarlierHistory, ]); const handleJumpToLatest = useCallback(() => { pendingHashTarget.current = null; 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; clientWidthRef.current = el.clientWidth; if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(() => { if (followViewportResize() && pinnedRef.current) 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. for (const child of el.children) observer.observe(child); 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; pendingHashTarget.current = taskChatHashAnchor(navigation.hash ?? ""); } const target = pendingHashTarget.current ? document.getElementById(pendingHashTarget.current) : null; if (el && el.clientHeight > 0 && navigation.ready && (!initialPositionApplied.current || (target && el.contains(target)))) { const top = navigation.initialPosition(el, el.getBoundingClientRect().top, el.scrollTop, { restore: !initialPositionApplied.current }); if (top !== null) { el.scrollTop = top; pinnedRef.current = isPinned(); rememberAnchor(); pendingHashTarget.current = null; } initialPositionApplied.current = true; } reconcileContent(); }, [contentKey, reconcileContent, navigation.key, navigation.hash, navigation.ready]); return (
{ pendingHashTarget.current = null; }} tabIndex={scrollingHistory ? 0 : undefined} role={scrollingHistory ? "region" : undefined} aria-label={scrollingHistory ? "Conversation messages" : undefined} onKeyDown={event => { if (["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End"].includes(event.key)) pendingHashTarget.current = null; if (["ArrowUp", "PageUp", "Home"].includes(event.key) && ref.current && ref.current.scrollTop <= 192) requestEarlierHistory(); }} onTouchStart={event => { pendingHashTarget.current = null; touchStartY.current = event.touches.length === 1 ? event.touches[0]!.clientY : null; }} onTouchMove={event => { const start = touchStartY.current; if (start !== null && event.touches.length === 1 && event.touches[0]!.clientY > start + 16 && ref.current && ref.current.scrollTop <= 192) { touchStartY.current = null; requestEarlierHistory(); } }} onTouchEnd={() => { touchStartY.current = null; }} onWheel={event => { pendingHashTarget.current = null; upwardIntent.current = event.deltaY < 0; if (event.deltaY < 0 && ref.current && ref.current.scrollTop <= 192) requestEarlierHistory(); }} // Keep the viewport tied to the flex-sized wrapper vertically — // percentage heights don't reliably resolve against flex-determined // block heights, which let the thread overflow the page. In the // streamlined shell, extend only the scroll box through the page's // right gutter; matching padding preserves the message column while // placing the scrollbar against the properties-panel boundary. className={cn( "task-chat-scroll-viewport scrollbar-while-scrolling absolute inset-y-0 left-0 overflow-y-auto", streamlined ? "-right-4 overflow-x-hidden pr-4 md:-right-6 md:pr-6" : "right-0", className, )} data-testid="task-chat-scroller" > {scrollingHistory ? <> {scrollingHistory && (scrollingHistory.hasMore || scrollingHistory.loading || scrollingHistory.error) ? (
{scrollingHistory.error ? <> Earlier messages couldn’t load. : scrollingHistory.loading ? "Loading earlier messages…" : "Scroll up for earlier messages"}
) : null} {children} : children}
{pillPhase !== "hidden" ? ( ) : null}
); }