diff --git a/src/components/ui/message-scroller-primitive/components.tsx b/src/components/ui/message-scroller-primitive/components.tsx index 0ef475be9..f5276c3c6 100644 --- a/src/components/ui/message-scroller-primitive/components.tsx +++ b/src/components/ui/message-scroller-primitive/components.tsx @@ -10,8 +10,7 @@ import type { MessageScrollerProviderProps, MessageScrollerViewportProps, } from "./types"; -import { useMessageScrollerController } from "./use-message-scroller-controller"; -import { composeRefs, useLatest } from "./utils"; +import { useMessageScroller } from "./use-message-scroller"; const MessageScrollerContext = React.createContext(null); @@ -25,22 +24,22 @@ function useMessageScrollerContext() { return context; } +function applyRef(ref: React.Ref | undefined, value: T | null) { + if (typeof ref === "function") { + ref(value); + } else if (ref) { + ref.current = value; + } +} + function MessageScrollerProvider({ appendedAnchorScrollBehavior, - autoScroll = false, children, - defaultScrollPosition = "end", - scrollEdgeThreshold, scrollPreviousItemPeek, - scrollMargin, }: MessageScrollerProviderProps) { - const { context } = useMessageScrollerController({ + const context = useMessageScroller({ appendedAnchorScrollBehavior, - autoScroll, - defaultScrollPosition, - scrollEdgeThreshold, scrollPreviousItemPeek, - scrollMargin, }); return ( @@ -48,21 +47,8 @@ function MessageScrollerProvider({ ); } -function MessageScroller({ children, ref, ...props }: MessageScrollerProps) { - const { setRootElement } = useMessageScrollerContext(); - const setRootRef = React.useCallback( - (element: HTMLDivElement | null) => { - setRootElement(element); - composeRefs(ref)?.(element); - }, - [ref, setRootElement], - ); - - return ( -
- {children} -
- ); +function MessageScroller(props: MessageScrollerProps) { + return
; } function MessageScrollerViewport({ @@ -73,30 +59,18 @@ function MessageScrollerViewport({ onScroll, onTouchMove, onWheel, - preserveScrollOnPrepend = true, ref, role, tabIndex, ...props }: MessageScrollerViewportProps) { - const { - handleResize, - preserveScrollOnPrependRef, - setViewportElement, - syncAfterScroll, - userScrollIntent, - viewportRef, - } = useMessageScrollerContext(); + const { handleResize, setViewportElement, syncAfterScroll, userScrollIntent, viewportRef } = + useMessageScrollerContext(); const pointerScrollIntentRef = React.useRef(false); - - React.useLayoutEffect(() => { - preserveScrollOnPrependRef.current = preserveScrollOnPrepend; - }, [preserveScrollOnPrepend, preserveScrollOnPrependRef]); - const setViewportRef = React.useCallback( (element: HTMLDivElement | null) => { setViewportElement(element); - composeRefs(ref)?.(element); + applyRef(ref, element); }, [ref, setViewportElement], ); @@ -181,12 +155,11 @@ function MessageScrollerContent({ const { handleContentChange, handleResize, setContentElement, setSpacerElement } = useMessageScrollerContext(); const contentRef = React.useRef(null); - const setContentRef = React.useCallback( (element: HTMLDivElement | null) => { contentRef.current = element; setContentElement(element); - composeRefs(ref)?.(element); + applyRef(ref, element); }, [ref, setContentElement], ); @@ -204,9 +177,7 @@ function MessageScrollerContent({ return; } - const observer = new MutationObserver(() => { - handleContentChange(); - }); + const observer = new MutationObserver(handleContentChange); observer.observe(content, { childList: true }); @@ -265,46 +236,33 @@ function MessageScrollerItem({ function MessageScrollerButton({ behavior = "smooth", children, - direction = "end", onClick, tabIndex, type = "button", ...props }: MessageScrollerButtonProps) { - const { scrollToEnd, scrollToStart, stateStore } = useMessageScrollerContext(); - const onClickRef = useLatest(onClick); - const subscribe = React.useCallback( - (listener: () => void) => stateStore.subscribe(listener), - [stateStore], - ); - const getSnapshot = React.useCallback(() => { - const state = stateStore.getSnapshot(); - - return direction === "start" ? state.start : state.end; - }, [direction, stateStore]); - const isActive = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); - - const handleClick = React.useCallback( - (event: React.MouseEvent) => { - if (!isActive) { - return; - } - - onClickRef.current?.(event); - - if (!event.defaultPrevented) { - event.currentTarget.blur(); - - if (direction === "start") { - scrollToStart({ behavior }); - } else { - scrollToEnd({ behavior }); - } - } - }, - [behavior, direction, isActive, onClickRef, scrollToEnd, scrollToStart], + const { getCanScrollToEnd, scrollToEnd, subscribeCanScrollToEnd } = useMessageScrollerContext(); + const isActive = React.useSyncExternalStore( + subscribeCanScrollToEnd, + getCanScrollToEnd, + getCanScrollToEnd, ); + function handleClick(event: React.MouseEvent) { + if (!isActive) { + return; + } + + onClick?.(event); + + if (event.defaultPrevented) { + return; + } + + event.currentTarget.blur(); + scrollToEnd({ behavior }); + } + return ( ); } diff --git a/src/components/ui/message-scroller-primitive/geometry.ts b/src/components/ui/message-scroller-primitive/geometry.ts index 9c289ef0e..35e2f3851 100644 --- a/src/components/ui/message-scroller-primitive/geometry.ts +++ b/src/components/ui/message-scroller-primitive/geometry.ts @@ -1,27 +1,23 @@ -import { EMPTY_MESSAGE_SCROLLER_SCROLLABLE } from "./types"; -import type { MessageScrollerScrollable, MessageScrollerScrollAlign } from "./types"; +import { SCROLL_EDGE_THRESHOLD } from "./types"; -function getMessageScrollerScrollable({ +// Whether content is still hidden below the viewport. Measured from the rows +// rather than scrollHeight so the tail spacer does not read as content. +function canScrollToEnd({ content, - scrollEdgeThreshold, spacer, viewport, }: { content: HTMLElement | null; - scrollEdgeThreshold: number; spacer: HTMLElement | null; viewport: HTMLElement | null; -}): MessageScrollerScrollable { +}) { if (!viewport || !content) { - return EMPTY_MESSAGE_SCROLLER_SCROLLABLE; + return false; } const contentBottom = getContentBottom({ content, spacer, viewport }); - return { - start: viewport.scrollTop > scrollEdgeThreshold, - end: contentBottom - viewport.scrollTop - viewport.clientHeight > scrollEdgeThreshold, - }; + return contentBottom - viewport.scrollTop - viewport.clientHeight > SCROLL_EDGE_THRESHOLD; } function getMessageScrollerItems(content: HTMLElement, spacer: HTMLElement | null) { @@ -30,38 +26,6 @@ function getMessageScrollerItems(content: HTMLElement, spacer: HTMLElement | nul ); } -function getNewScrollAnchor(items: HTMLElement[], previousItemCount: number) { - for (let index = previousItemCount; index < items.length; index++) { - const item = items[index]; - - if (item?.dataset.scrollAnchor === "true") { - return item; - } - } - - return null; -} - -function hasMultipleNewScrollAnchors(items: HTMLElement[], previousItemCount: number) { - let count = 0; - - for (let index = previousItemCount; index < items.length; index++) { - const item = items[index]; - - if (item?.dataset.scrollAnchor !== "true") { - continue; - } - - count += 1; - - if (count > 1) { - return true; - } - } - - return false; -} - function getLastScrollAnchor(items: HTMLElement[]) { for (let index = items.length - 1; index >= 0; index--) { const item = items[index]; @@ -74,6 +38,9 @@ function getLastScrollAnchor(items: HTMLElement[]) { return null; } +// ponytail: linear scan from the top of the transcript. Costs one rect read per +// row above the reader, so it is at its worst parked at the bottom of a long +// thread. Index the rows if that ever shows up in a profile. function getFirstVisibleMessageItem({ content, spacer, @@ -100,66 +67,32 @@ function getFirstVisibleMessageItem({ return null; } -function getElementScrollTop({ - align, +// scrollTop that puts a row at the top of the viewport, less the peek that keeps +// the tail of the previous row in view. +function getRowScrollTop({ + content, element, - scrollMargin, - spacer, + peek, viewport, }: { - align: MessageScrollerScrollAlign; + content: HTMLElement; element: HTMLElement; - scrollMargin: number; - spacer: HTMLElement | null; + peek: number; viewport: HTMLElement; }) { - const elementTop = getElementTop(element, viewport); - const elementHeight = element.getBoundingClientRect().height; - const contentPadding = getContentBlockPadding(spacer); - - if (align === "center") { - const insetHeight = Math.max( - 0, - viewport.clientHeight - contentPadding.start - contentPadding.end, - ); - - return elementTop - contentPadding.start - (insetHeight - elementHeight) / 2 - scrollMargin; - } - - if (align === "end") { - return elementTop - viewport.clientHeight + elementHeight + contentPadding.end + scrollMargin; - } - - if (align === "nearest") { - const elementBottom = elementTop + elementHeight; - const viewportTop = viewport.scrollTop + contentPadding.start; - const viewportBottom = viewport.scrollTop + viewport.clientHeight - contentPadding.end; - - if (elementTop >= viewportTop && elementBottom <= viewportBottom) { - return viewport.scrollTop; - } - - if (elementTop < viewportTop) { - return elementTop - contentPadding.start - scrollMargin; - } - - return elementBottom - viewport.clientHeight + contentPadding.end + scrollMargin; - } - - return elementTop - contentPadding.start - scrollMargin; -} - -function getElementTop(element: HTMLElement, viewport: HTMLElement) { const elementRect = element.getBoundingClientRect(); const viewportRect = viewport.getBoundingClientRect(); + const elementTop = elementRect.top - viewportRect.top + viewport.scrollTop; - return elementRect.top - viewportRect.top + viewport.scrollTop; + return elementTop - getBlockPadding(content).start - peek; } function getElementViewportTop(element: HTMLElement, viewport: HTMLElement) { return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top; } +// Height the tail spacer needs so the row placed at scrollTop can sit at the top +// of the viewport with nothing below it to scroll into. function getTailSpacerHeight({ content, scrollTop, @@ -171,11 +104,11 @@ function getTailSpacerHeight({ spacer: HTMLElement | null; viewport: HTMLElement; }) { - const contentBottom = getContentBottom({ content, spacer, viewport }); - - return scrollTop + viewport.clientHeight - contentBottom; + return scrollTop + viewport.clientHeight - getContentBottom({ content, spacer, viewport }); } +// Where the rows end, ignoring the tail spacer. The rows are a flex column, so +// the last one is the lowest — no need to measure the rest. function getContentBottom({ content, spacer, @@ -187,20 +120,19 @@ function getContentBottom({ }) { const items = getMessageScrollerItems(content, spacer); const padding = getBlockPadding(content); - const viewportRect = viewport.getBoundingClientRect(); - const scrollTop = viewport.scrollTop; - let contentBottom = padding.start + padding.end; - - for (const item of items) { - const rect = item.getBoundingClientRect(); + const lastItem = items[items.length - 1]; - contentBottom = Math.max( - contentBottom, - rect.bottom - viewportRect.top + scrollTop + padding.end, - ); + if (!lastItem) { + return padding.start + padding.end; } - return contentBottom; + const bottom = + lastItem.getBoundingClientRect().bottom - + viewport.getBoundingClientRect().top + + viewport.scrollTop + + padding.end; + + return Math.max(bottom, padding.start + padding.end); } function getMaxScrollTop(viewport: HTMLElement) { @@ -216,19 +148,6 @@ function getBlockPadding(element: HTMLElement) { }; } -function getContentBlockPadding(spacer: HTMLElement | null) { - const content = spacer?.parentElement; - - if (!content) { - return { - end: 0, - start: 0, - }; - } - - return getBlockPadding(content); -} - function getFlexGap(element: HTMLElement | null) { if (!element) { return 0; @@ -251,18 +170,13 @@ function readCssPixel(value: string | undefined) { } export { - getContentBlockPadding, - getContentBottom, - getElementScrollTop, - getElementTop, + canScrollToEnd, getElementViewportTop, getFirstVisibleMessageItem, getFlexGap, getLastScrollAnchor, getMaxScrollTop, getMessageScrollerItems, - getMessageScrollerScrollable, - getNewScrollAnchor, + getRowScrollTop, getTailSpacerHeight, - hasMultipleNewScrollAnchors, }; diff --git a/src/components/ui/message-scroller-primitive/index.ts b/src/components/ui/message-scroller-primitive/index.ts index a79a45fd8..9f24f89f8 100644 --- a/src/components/ui/message-scroller-primitive/index.ts +++ b/src/components/ui/message-scroller-primitive/index.ts @@ -15,10 +15,3 @@ export const MessageScroller = { Item, Button, }; - -export type { - MessageScrollerDefaultScrollPosition, - MessageScrollerScrollAlign, - MessageScrollerScrollOptions, - MessageScrollerScrollable, -} from "./types"; diff --git a/src/components/ui/message-scroller-primitive/stores.ts b/src/components/ui/message-scroller-primitive/stores.ts deleted file mode 100644 index 6202a9339..000000000 --- a/src/components/ui/message-scroller-primitive/stores.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { MessageScrollerScrollable, MessageScrollerStore } from "./types"; - -// Generic useSyncExternalStore backing: a stable snapshot (referentially equal -// while isEqual holds, so subscribers only re-render on real transitions). -function createExternalStore(initialSnapshot: T, isEqual: (a: T, b: T) => boolean) { - let snapshot = initialSnapshot; - const listeners = new Set<() => void>(); - - return { - getSnapshot: () => snapshot, - setSnapshot: (nextSnapshot: T) => { - if (isEqual(snapshot, nextSnapshot)) { - return; - } - - snapshot = nextSnapshot; - listeners.forEach((listener) => listener()); - }, - subscribe: (listener: () => void) => { - listeners.add(listener); - - return () => { - listeners.delete(listener); - }; - }, - }; -} - -function createMessageScrollerStore( - initialSnapshot: T, - isEqual: (a: T, b: T) => boolean, -): MessageScrollerStore { - return createExternalStore(initialSnapshot, isEqual); -} - -function areScrollStatesEqual(current: MessageScrollerScrollable, next: MessageScrollerScrollable) { - return current.start === next.start && current.end === next.end; -} - -export { areScrollStatesEqual, createMessageScrollerStore }; diff --git a/src/components/ui/message-scroller-primitive/types.ts b/src/components/ui/message-scroller-primitive/types.ts index bc9b8a0b7..89360e604 100644 --- a/src/components/ui/message-scroller-primitive/types.ts +++ b/src/components/ui/message-scroller-primitive/types.ts @@ -1,16 +1,13 @@ import * as React from "react"; -// Default scrollEdgeThreshold. Sub-pixel tolerance so edge detection does not -// flicker across engines that round scrollTop differently. -const DEFAULT_SCROLL_EDGE_THRESHOLD = 8; +// Distance from an edge that still counts as at-bottom. Sub-pixel tolerance so +// edge detection does not flicker across engines that round scrollTop differently. +const SCROLL_EDGE_THRESHOLD = 8; // Default scrollPreviousItemPeek. Pixels of the previous item kept visible above // a newly anchored row. const DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK = 64; -// Default scrollMargin for programmatic targets. -const DEFAULT_SCROLL_MARGIN = 0; - // Two fractional scrollTop values within this range are treated as equal, to // absorb zoom and HiDPI rounding drift. const SCROLL_POSITION_EPSILON = 0.5; @@ -19,7 +16,7 @@ const SCROLL_POSITION_EPSILON = 0.5; // before clearing. const AUTOSCROLLING_CLEAR_DELAY = 180; -// Viewport keys that count as deliberate scroll intent and release follow-bottom. +// Viewport keys that count as deliberate scroll intent and release the anchor. const USER_SCROLL_KEYS = new Set([ "ArrowDown", "ArrowUp", @@ -30,68 +27,23 @@ const USER_SCROLL_KEYS = new Set([ " ", // Space key. ]); -// Internal scroll mode. Derived from intent and commands; decides how the -// viewport reacts to content and resize. -type MessageScrollerMode = - | "following-bottom" // autoScroll on, pinned to the latest message. - | "free-scrolling" // reader scrolled away; position left alone (prepends still preserved). - | "anchored-to-message" // holding a turn at the reading line while it streams. - | "settling-jump"; // a programmatic jump is animating; intent detection suppressed until it settles. - -// Where a saved transcript opens on the first non-empty render. -type MessageScrollerDefaultScrollPosition = "start" | "end" | "last-anchor"; - -// Which transcript edge MessageScrollerButton scrolls toward. -type MessageScrollerButtonDirection = "start" | "end"; - -// Viewport alignment for programmatic jumps. -type MessageScrollerScrollAlign = "start" | "center" | "end" | "nearest"; - -// Options for scrollToEnd and scrollToStart. -type MessageScrollerScrollOptions = { - // Viewport edge or center to align the target to. - align?: MessageScrollerScrollAlign; - // Native scroll behavior. - behavior?: ScrollBehavior; - // Margin on the aligned edge, in pixels. Defaults to the provider scrollMargin. - scrollMargin?: number; -}; - -// Scroll snapshot for which edges the viewport can still scroll toward. -type MessageScrollerScrollable = { - // The viewport can scroll toward the start (content is hidden above). - start: boolean; - // The viewport can scroll toward the end (content is hidden below). - end: boolean; -}; - // Headless provider for a chat transcript scroller. Owns scroll behavior and // state; renders no DOM. type MessageScrollerProviderProps = { children?: React.ReactNode; - // Follow new content at the bottom while the viewport is already at the end. - autoScroll?: boolean; // Scroll behavior for newly appended anchor rows. Initial restore stays instant. appendedAnchorScrollBehavior?: ScrollBehavior; - // Opening position on the first non-empty render, applied once. - defaultScrollPosition?: MessageScrollerDefaultScrollPosition; - // Distance from an edge that still counts as at-top/at-bottom. Defaults to 8. - scrollEdgeThreshold?: number; - // Extra top margin for a newly anchored row, added to scrollMargin. Defaults to 64. + // Top margin for a newly anchored row, sized to keep this much of the previous + // row visible above it. Defaults to 64. scrollPreviousItemPeek?: number; - // Default margin on the aligned edge for commands. Defaults to 0. - scrollMargin?: number; }; // Frame container for a chat transcript scroller. Must render inside a // MessageScrollerProvider. type MessageScrollerProps = React.ComponentProps<"div">; -// Scrollable viewport. Owns native scroll events and prepend preservation. -type MessageScrollerViewportProps = React.ComponentProps<"div"> & { - // Keep the first visible messageId row stable on prepend. Defaults to true. - preserveScrollOnPrepend?: boolean; -}; +// Scrollable viewport. Owns native scroll events and scroll intent. +type MessageScrollerViewportProps = React.ComponentProps<"div">; // Transcript row container. Every direct child should be a MessageScrollerItem. type MessageScrollerContentProps = React.ComponentProps<"div"> & { @@ -101,74 +53,47 @@ type MessageScrollerContentProps = React.ComponentProps<"div"> & { // One transcript row: a message, marker, typing row, separator, or load-more row. type MessageScrollerItemProps = React.ComponentProps<"div"> & { - // Stable row id for prepend preservation. + // Stable row id. Marks a row as a candidate for the reading anchor. messageId?: string; - // Marks a turn boundary that newly appended anchors and last-anchor restore use. + // Marks a turn boundary that newly appended anchors and the opening restore use. scrollAnchor?: boolean; }; -// Scroll control for the start or end of the transcript. +// Scroll-to-end control. type MessageScrollerButtonProps = React.ComponentProps<"button"> & { // Native scroll behavior when clicked. Defaults to "smooth". behavior?: ScrollBehavior; - // Transcript edge to scroll toward. Defaults to "end". - direction?: MessageScrollerButtonDirection; -}; - -// Minimal external store backing the scroll button state. -type MessageScrollerStore = { - getSnapshot: () => T; - setSnapshot: (nextSnapshot: T) => void; - subscribe: (listener: () => void) => () => void; }; // Internal context wiring the parts together. Not part of the public API. type MessageScrollerContextValue = { handleContentChange: () => void; handleResize: () => void; - preserveScrollOnPrependRef: React.RefObject; - scrollToEnd: (options?: MessageScrollerScrollOptions) => boolean; - scrollToStart: (options?: MessageScrollerScrollOptions) => boolean; + scrollToEnd: (options?: { behavior?: ScrollBehavior }) => boolean; setContentElement: (element: HTMLDivElement | null) => void; - setRootElement: (element: HTMLDivElement | null) => void; setSpacerElement: (element: HTMLDivElement | null) => void; setViewportElement: (element: HTMLDivElement | null) => void; - stateStore: MessageScrollerStore; + subscribeCanScrollToEnd: (listener: () => void) => () => void; + getCanScrollToEnd: () => boolean; syncAfterScroll: (options?: { userIntent?: boolean }) => void; userScrollIntent: () => void; viewportRef: React.RefObject; }; -// Initial MessageScrollerScrollable before measurement. Stable reference for the -// server and first-render snapshot. -const EMPTY_MESSAGE_SCROLLER_SCROLLABLE: MessageScrollerScrollable = { - start: false, - end: false, -}; - export { AUTOSCROLLING_CLEAR_DELAY, - DEFAULT_SCROLL_EDGE_THRESHOLD, - DEFAULT_SCROLL_MARGIN, DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK, - EMPTY_MESSAGE_SCROLLER_SCROLLABLE, + SCROLL_EDGE_THRESHOLD, SCROLL_POSITION_EPSILON, USER_SCROLL_KEYS, }; export type { - MessageScrollerButtonDirection, MessageScrollerButtonProps, MessageScrollerContentProps, MessageScrollerContextValue, - MessageScrollerDefaultScrollPosition, MessageScrollerItemProps, - MessageScrollerMode, MessageScrollerProps, MessageScrollerProviderProps, - MessageScrollerScrollAlign, - MessageScrollerScrollOptions, - MessageScrollerScrollable, - MessageScrollerStore, MessageScrollerViewportProps, }; diff --git a/src/components/ui/message-scroller-primitive/use-message-scroller-commands.ts b/src/components/ui/message-scroller-primitive/use-message-scroller-commands.ts deleted file mode 100644 index 8fc27e06d..000000000 --- a/src/components/ui/message-scroller-primitive/use-message-scroller-commands.ts +++ /dev/null @@ -1,259 +0,0 @@ -import * as React from "react"; - -import { - getElementScrollTop, - getElementViewportTop, - getMaxScrollTop, - getTailSpacerHeight, -} from "./geometry"; -import { AUTOSCROLLING_CLEAR_DELAY, SCROLL_POSITION_EPSILON } from "./types"; -import type { MessageScrollerScrollOptions } from "./types"; -import type { MessageScrollerRefs } from "./use-message-scroller-refs"; - -// Imperative scroll primitives, split from the controller so the move mechanics -// live apart from the policy that decides when to run them. Each command resolves -// a target scrollTop and returns false when the viewport is not mounted yet. -function useMessageScrollerCommands({ - refs, - commitScrollState, - scheduleStateCommit, -}: { - refs: MessageScrollerRefs; - commitScrollState: () => void; - scheduleStateCommit: () => void; -}) { - const { - anchoredMessageRef, - autoScrollRef, - autoscrollingRef, - autoscrollingTimeoutRef, - contentRef, - modeRef, - prependRestoreRef, - scrollMarginRef, - scrollPreviousItemPeekRef, - spacerGapRef, - spacerHeightRef, - spacerRef, - viewportRef, - } = refs; - - const setAutoScrolling = React.useCallback( - (autoscrolling: boolean) => { - if (autoscrollingTimeoutRef.current !== null) { - window.clearTimeout(autoscrollingTimeoutRef.current); - autoscrollingTimeoutRef.current = null; - } - - if (autoscrollingRef.current !== autoscrolling) { - autoscrollingRef.current = autoscrolling; - commitScrollState(); - } - - if (autoscrolling) { - autoscrollingTimeoutRef.current = window.setTimeout(() => { - autoscrollingTimeoutRef.current = null; - autoscrollingRef.current = false; - commitScrollState(); - }, AUTOSCROLLING_CLEAR_DELAY); - } - }, - [autoscrollingRef, autoscrollingTimeoutRef, commitScrollState], - ); - - const setTailSpacerHeight = React.useCallback( - (height: number) => { - const spacer = spacerRef.current; - - if (!spacer) { - return; - } - - const nextHeight = Math.max(0, Math.ceil(height)); - - if (spacerHeightRef.current === nextHeight) { - return; - } - - spacerHeightRef.current = nextHeight; - spacer.hidden = nextHeight === 0; - spacer.style.height = `${nextHeight}px`; - spacer.style.marginTop = nextHeight > 0 ? `${-spacerGapRef.current}px` : ""; - }, - [spacerGapRef, spacerHeightRef, spacerRef], - ); - - const scrollToPosition = React.useCallback( - ( - scrollTop: number, - { - behavior = "auto", - autoscrolling = false, - }: { - behavior?: ScrollBehavior; - autoscrolling?: boolean; - } = {}, - ) => { - const viewport = viewportRef.current; - - if (!viewport) { - return; - } - - const nextScrollTop = Math.max(0, scrollTop); - - if (Math.abs(viewport.scrollTop - nextScrollTop) <= SCROLL_POSITION_EPSILON) { - viewport.scrollTop = nextScrollTop; - commitScrollState(); - return; - } - - if (autoscrolling) { - setAutoScrolling(true); - } - - viewport.scrollTo({ - top: nextScrollTop, - behavior, - }); - scheduleStateCommit(); - }, - [commitScrollState, scheduleStateCommit, setAutoScrolling, viewportRef], - ); - - const scrollToStart = React.useCallback( - ({ behavior = "auto" }: MessageScrollerScrollOptions = {}) => { - if (!viewportRef.current) { - return false; - } - - setTailSpacerHeight(0); - anchoredMessageRef.current = null; - modeRef.current = "free-scrolling"; - scrollToPosition(0, { behavior }); - - return true; - }, - [anchoredMessageRef, modeRef, scrollToPosition, setTailSpacerHeight, viewportRef], - ); - - const scrollToEnd = React.useCallback( - ({ behavior = "auto" }: MessageScrollerScrollOptions = {}) => { - const viewport = viewportRef.current; - - if (!viewport) { - return false; - } - - setTailSpacerHeight(0); - anchoredMessageRef.current = null; - modeRef.current = autoScrollRef.current ? "following-bottom" : "free-scrolling"; - scrollToPosition(getMaxScrollTop(viewport), { - autoscrolling: behavior === "smooth", - behavior, - }); - - return true; - }, - [ - anchoredMessageRef, - autoScrollRef, - modeRef, - scrollToPosition, - setTailSpacerHeight, - viewportRef, - ], - ); - - const scrollToElement = React.useCallback( - ( - element: HTMLElement, - { - align = "start", - behavior = "auto", - scrollMargin = scrollMarginRef.current, - }: MessageScrollerScrollOptions = {}, - { - keepPreviousPeek = false, - }: { - keepPreviousPeek?: boolean; - } = {}, - ) => { - const content = contentRef.current; - const viewport = viewportRef.current; - - if (!content || !viewport || !content.contains(element)) { - return false; - } - - const scrollTop = getElementScrollTop({ - align, - element, - scrollMargin: keepPreviousPeek - ? scrollMargin + scrollPreviousItemPeekRef.current - : scrollMargin, - spacer: spacerRef.current, - viewport, - }); - - const nextSpacerHeight = getTailSpacerHeight({ - content, - scrollTop, - spacer: spacerRef.current, - viewport, - }); - - setTailSpacerHeight(nextSpacerHeight); - // Seed the prepend anchor with the jump target so a prepend that lands - // during a programmatic jump still preserves the jumped-to row. - prependRestoreRef.current = { - element, - viewportTop: getElementViewportTop(element, viewport), - }; - - modeRef.current = keepPreviousPeek ? "anchored-to-message" : "settling-jump"; - anchoredMessageRef.current = keepPreviousPeek ? { behavior, element } : null; - - scrollToPosition(scrollTop, { behavior }); - - return true; - }, - [ - anchoredMessageRef, - contentRef, - modeRef, - prependRestoreRef, - scrollMarginRef, - scrollPreviousItemPeekRef, - scrollToPosition, - setTailSpacerHeight, - spacerRef, - viewportRef, - ], - ); - - const reanchorToAnchoredMessage = React.useCallback(() => { - const anchor = anchoredMessageRef.current; - - if (!anchor || !anchor.element.isConnected || modeRef.current !== "anchored-to-message") { - return false; - } - - // Re-run the placement so the tail spacer is recomputed for the new content - // height and the turn is held at the reading line. - return scrollToElement( - anchor.element, - { align: "start", behavior: anchor.behavior }, - { keepPreviousPeek: true }, - ); - }, [anchoredMessageRef, modeRef, scrollToElement]); - - return { - reanchorToAnchoredMessage, - scrollToElement, - scrollToEnd, - scrollToStart, - }; -} - -export { useMessageScrollerCommands }; diff --git a/src/components/ui/message-scroller-primitive/use-message-scroller-controller.ts b/src/components/ui/message-scroller-primitive/use-message-scroller-controller.ts deleted file mode 100644 index cc0097076..000000000 --- a/src/components/ui/message-scroller-primitive/use-message-scroller-controller.ts +++ /dev/null @@ -1,528 +0,0 @@ -import * as React from "react"; - -import { - getElementViewportTop, - getFirstVisibleMessageItem, - getFlexGap, - getLastScrollAnchor, - getMessageScrollerItems, - getMessageScrollerScrollable, - getNewScrollAnchor, - hasMultipleNewScrollAnchors, -} from "./geometry"; -import { - DEFAULT_SCROLL_EDGE_THRESHOLD, - DEFAULT_SCROLL_MARGIN, - DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK, - SCROLL_POSITION_EPSILON, -} from "./types"; -import type { - MessageScrollerContextValue, - MessageScrollerProviderProps, - MessageScrollerScrollable, -} from "./types"; -import { useMessageScrollerCommands } from "./use-message-scroller-commands"; -import { useMessageScrollerRefs } from "./use-message-scroller-refs"; - -// Builds a ref callback that stores the node and runs onMount once it attaches. -function useElementRef(elementRef: React.RefObject, onMount: () => void) { - return React.useCallback( - (element: HTMLDivElement | null) => { - elementRef.current = element; - - if (element) { - onMount(); - } - }, - [elementRef, onMount], - ); -} - -// Orchestrator hook. Decides when to scroll and delegates the moves to -// useMessageScrollerCommands; state commits are coalesced on a requestAnimationFrame -// and torn down on cleanup for StrictMode safety. -function useMessageScrollerController({ - appendedAnchorScrollBehavior = "auto", - autoScroll = false, - defaultScrollPosition = "end", - scrollEdgeThreshold = DEFAULT_SCROLL_EDGE_THRESHOLD, - scrollPreviousItemPeek = DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK, - scrollMargin = DEFAULT_SCROLL_MARGIN, -}: MessageScrollerProviderProps) { - const refs = useMessageScrollerRefs({ - autoScroll, - scrollEdgeThreshold, - scrollMargin, - scrollPreviousItemPeek, - }); - - const { - anchoredMessageRef, - autoScrollRef, - autoscrollingRef, - autoscrollingTimeoutRef, - contentRef, - defaultScrollPositionAppliedRef, - firstItemRef, - itemCountRef, - modeRef, - prependRestoreRef, - preserveScrollOnPrependRef, - rootRef, - scrollEdgeThresholdRef, - spacerGapRef, - spacerRef, - stateFrameRef, - stateStore, - viewportRef, - } = refs; - - const previousDefaultScrollPositionRef = React.useRef(defaultScrollPosition); - const resizeFrameRef = React.useRef(null); - - React.useLayoutEffect(() => { - if (previousDefaultScrollPositionRef.current !== defaultScrollPosition) { - previousDefaultScrollPositionRef.current = defaultScrollPosition; - defaultScrollPositionAppliedRef.current = false; - } - }, [defaultScrollPosition, defaultScrollPositionAppliedRef]); - - const writeStateAttributes = React.useCallback( - (state: MessageScrollerScrollable) => { - const root = rootRef.current; - const viewport = viewportRef.current; - const scrollable = [state.start && "start", state.end && "end"].filter(Boolean).join(" "); - const autoScrolling = autoscrollingRef.current; - - for (const element of [root, viewport]) { - if (!element) { - continue; - } - - if (scrollable) { - element.setAttribute("data-scrollable", scrollable); - } else { - element.removeAttribute("data-scrollable"); - } - - element.toggleAttribute("data-autoscrolling", autoScrolling); - } - }, - [autoscrollingRef, rootRef, viewportRef], - ); - - // Owns the one follow-bottom transition: arm at the bottom, release on any - // scroll away (including a scrollbar drag), suppressed during a programmatic - // scroll so the auto-scroll animation cannot release itself. - const reconcileFollowMode = React.useCallback( - (scrollable: MessageScrollerScrollable) => { - if (autoScrollRef.current && !scrollable.end && modeRef.current === "free-scrolling") { - modeRef.current = "following-bottom"; - } else if ( - modeRef.current === "following-bottom" && - scrollable.end && - !autoscrollingRef.current - ) { - modeRef.current = "free-scrolling"; - } - }, - [autoScrollRef, autoscrollingRef, modeRef], - ); - - const commitScrollState = React.useCallback(() => { - const nextState = getMessageScrollerScrollable({ - content: contentRef.current, - scrollEdgeThreshold: scrollEdgeThresholdRef.current, - spacer: spacerRef.current, - viewport: viewportRef.current, - }); - - reconcileFollowMode(nextState); - writeStateAttributes(nextState); - stateStore.setSnapshot(nextState); - }, [ - contentRef, - reconcileFollowMode, - scrollEdgeThresholdRef, - spacerRef, - stateStore, - viewportRef, - writeStateAttributes, - ]); - - const scheduleStateCommit = React.useCallback(() => { - if (stateFrameRef.current !== null) { - return; - } - - stateFrameRef.current = window.requestAnimationFrame(() => { - stateFrameRef.current = null; - commitScrollState(); - }); - }, [commitScrollState, stateFrameRef]); - - const { reanchorToAnchoredMessage, scrollToElement, scrollToEnd, scrollToStart } = - useMessageScrollerCommands({ - refs, - commitScrollState, - scheduleStateCommit, - }); - - const restorePrependedAnchor = React.useCallback(() => { - const anchor = prependRestoreRef.current; - const viewport = viewportRef.current; - - if (!anchor || !viewport || !anchor.element.isConnected) { - return false; - } - - // Compare the anchor relative to the viewport, not to the content. Native - // scroll anchoring leaves the viewport-relative position unchanged, so this - // is a no-op where the browser already handled the prepend and only corrects - // the scroll where it did not (e.g. Safari) — without trusting a capability - // flag, which some engines report incorrectly. - const nextViewportTop = getElementViewportTop(anchor.element, viewport); - const delta = nextViewportTop - anchor.viewportTop; - - if (Math.abs(delta) <= SCROLL_POSITION_EPSILON) { - return false; - } - - viewport.scrollTop += delta; - anchor.viewportTop = getElementViewportTop(anchor.element, viewport); - scheduleStateCommit(); - - return true; - }, [prependRestoreRef, scheduleStateCommit, viewportRef]); - - const capturePrependAnchor = React.useCallback(() => { - const content = contentRef.current; - const viewport = viewportRef.current; - - if (!content || !viewport) { - prependRestoreRef.current = null; - return; - } - - const anchor = getFirstVisibleMessageItem({ - content, - spacer: spacerRef.current, - viewport, - }); - - prependRestoreRef.current = anchor - ? { - element: anchor, - viewportTop: getElementViewportTop(anchor, viewport), - } - : null; - }, [contentRef, prependRestoreRef, spacerRef, viewportRef]); - - const applyDefaultScrollPosition = React.useCallback(() => { - if ( - !defaultScrollPosition || - defaultScrollPositionAppliedRef.current || - itemCountRef.current === 0 - ) { - return false; - } - - let handled = false; - - if (defaultScrollPosition === "last-anchor") { - const content = contentRef.current; - const viewport = viewportRef.current; - const anchor = - content && viewport - ? getLastScrollAnchor(getMessageScrollerItems(content, spacerRef.current)) - : null; - - if (!content || !viewport || !anchor) { - handled = scrollToEnd({ behavior: "auto" }); - } else { - handled = scrollToElement( - anchor, - { align: "start", behavior: "auto" }, - { keepPreviousPeek: true }, - ); - } - } else { - handled = - defaultScrollPosition === "end" - ? scrollToEnd({ behavior: "auto" }) - : scrollToStart({ behavior: "auto" }); - } - - if (!handled) { - return false; - } - - defaultScrollPositionAppliedRef.current = true; - - return true; - }, [ - contentRef, - defaultScrollPosition, - defaultScrollPositionAppliedRef, - itemCountRef, - scrollToElement, - scrollToEnd, - scrollToStart, - spacerRef, - viewportRef, - ]); - - const handleContentChange = React.useCallback(() => { - const content = contentRef.current; - - if (!content) { - return; - } - - const items = getMessageScrollerItems(content, spacerRef.current); - const previousItemCount = itemCountRef.current; - const previousFirstItem = firstItemRef.current; - - itemCountRef.current = items.length; - firstItemRef.current = items[0] ?? null; - - // Reconcile the scroll position with the new content. Every path re-captures - // the prepend anchor afterward, so each branch just returns. - // - // Branch order is load-bearing: first-content, prepended, appended, updated. - const reconcileScrollPosition = () => { - if (previousItemCount === 0) { - if (applyDefaultScrollPosition()) { - return; - } - - if (items.length > 0 && autoScrollRef.current && scrollToEnd({ behavior: "auto" })) { - return; - } - - commitScrollState(); - return; - } - - const previousFirstItemIndex = previousFirstItem ? items.indexOf(previousFirstItem) : -1; - const didPrepend = preserveScrollOnPrependRef.current && previousFirstItemIndex > 0; - - if (didPrepend) { - // Prepended rows are not new appends. Restore the prior scroll position. - // The restore is a no-op where native scroll anchoring already did it. - if (!restorePrependedAnchor()) { - commitScrollState(); - } - return; - } - - if (items.length > previousItemCount) { - const anchor = getNewScrollAnchor(items, previousItemCount); - - if (anchor) { - // While the reader is following the live end, a batch of several - // anchored turns arriving at once should keep following the end — not - // yank back to anchor the first turn of the batch. A single new anchor - // still moves to the top as usual. - if ( - autoScrollRef.current && - modeRef.current === "following-bottom" && - hasMultipleNewScrollAnchors(items, previousItemCount) - ) { - scrollToEnd({ behavior: "auto" }); - return; - } - - scrollToElement( - anchor, - { align: "start", behavior: appendedAnchorScrollBehavior }, - { keepPreviousPeek: true }, - ); - return; - } - } - - // Appends with no new anchor (and content-only updates) fall through here: - // keep following the end if we still are, otherwise just recommit state. - if (modeRef.current === "following-bottom" && autoScrollRef.current) { - scrollToEnd({ behavior: "auto" }); - } else { - commitScrollState(); - } - }; - - reconcileScrollPosition(); - capturePrependAnchor(); - }, [ - applyDefaultScrollPosition, - capturePrependAnchor, - commitScrollState, - appendedAnchorScrollBehavior, - autoScrollRef, - contentRef, - firstItemRef, - itemCountRef, - modeRef, - preserveScrollOnPrependRef, - restorePrependedAnchor, - scrollToElement, - scrollToEnd, - spacerRef, - ]); - - const reconcileResize = React.useCallback(() => { - if (modeRef.current === "following-bottom" && autoScrollRef.current) { - scrollToEnd({ behavior: "auto" }); - return; - } - - // Hold the anchored turn in place as content below it resizes (a reply - // streaming in, or a transient marker collapsing) — otherwise the shrinking - // content lets the browser clamp scrollTop and the turn drops. - if (reanchorToAnchoredMessage()) { - return; - } - - commitScrollState(); - }, [autoScrollRef, commitScrollState, modeRef, reanchorToAnchoredMessage, scrollToEnd]); - - const handleResize = React.useCallback(() => { - if (resizeFrameRef.current !== null) { - return; - } - - resizeFrameRef.current = window.requestAnimationFrame(() => { - resizeFrameRef.current = null; - reconcileResize(); - }); - }, [reconcileResize]); - - const userScrollIntent = React.useCallback(() => { - if ( - modeRef.current === "following-bottom" || - modeRef.current === "anchored-to-message" || - modeRef.current === "settling-jump" - ) { - // A deliberate gesture releases auto-follow, turn-anchoring, and an in-flight - // programmatic jump so re-pinning (and re-arming) never fights the reader. - const viewport = viewportRef.current; - viewport?.scrollTo({ top: viewport.scrollTop, behavior: "auto" }); - anchoredMessageRef.current = null; - modeRef.current = "free-scrolling"; - } - }, [anchoredMessageRef, modeRef, viewportRef]); - - const mirrorStateAttributes = React.useCallback( - () => writeStateAttributes(stateStore.getSnapshot()), - [stateStore, writeStateAttributes], - ); - - const setRootElement = useElementRef(rootRef, mirrorStateAttributes); - const setViewportElement = useElementRef(viewportRef, mirrorStateAttributes); - - const setContentElement = React.useCallback( - (element: HTMLDivElement | null) => { - contentRef.current = element; - }, - [contentRef], - ); - - const setSpacerElement = React.useCallback( - (element: HTMLDivElement | null) => { - spacerRef.current = element; - spacerGapRef.current = getFlexGap(element?.parentElement ?? null); - }, - [spacerGapRef, spacerRef], - ); - - const syncAfterScroll = React.useCallback( - ({ userIntent = false }: { userIntent?: boolean } = {}) => { - if (userIntent) { - userScrollIntent(); - } - - commitScrollState(); - - if (modeRef.current === "anchored-to-message" || modeRef.current === "settling-jump") { - return; - } - - capturePrependAnchor(); - }, - [capturePrependAnchor, commitScrollState, modeRef, userScrollIntent], - ); - - const context = React.useMemo( - () => ({ - handleContentChange, - handleResize, - preserveScrollOnPrependRef, - scrollToEnd, - scrollToStart, - setContentElement, - setRootElement, - setSpacerElement, - setViewportElement, - stateStore, - syncAfterScroll, - userScrollIntent, - viewportRef, - }), - [ - handleContentChange, - handleResize, - scrollToEnd, - scrollToStart, - setContentElement, - setRootElement, - setSpacerElement, - setViewportElement, - stateStore, - syncAfterScroll, - userScrollIntent, - preserveScrollOnPrependRef, - viewportRef, - ], - ); - - React.useLayoutEffect(() => { - applyDefaultScrollPosition(); - }, [applyDefaultScrollPosition]); - - React.useEffect(() => { - return () => { - // Reset every ref after cancelling. StrictMode replays effects on the same - // refs (unmount then remount), so a frame id left non-null here makes the - // scheduler on remount think a frame is still pending and never reschedule. - if (stateFrameRef.current !== null) { - window.cancelAnimationFrame(stateFrameRef.current); - stateFrameRef.current = null; - } - - if (resizeFrameRef.current !== null) { - window.cancelAnimationFrame(resizeFrameRef.current); - resizeFrameRef.current = null; - } - - if (autoscrollingTimeoutRef.current !== null) { - window.clearTimeout(autoscrollingTimeoutRef.current); - autoscrollingTimeoutRef.current = null; - } - }; - }, [autoscrollingTimeoutRef, stateFrameRef]); - - React.useLayoutEffect(() => { - if (autoScroll && modeRef.current === "following-bottom" && itemCountRef.current > 0) { - scrollToEnd({ behavior: "auto" }); - return; - } - - commitScrollState(); - }, [autoScroll, commitScrollState, itemCountRef, modeRef, scrollToEnd]); - - return { - context, - }; -} - -export { useMessageScrollerController }; diff --git a/src/components/ui/message-scroller-primitive/use-message-scroller-refs.ts b/src/components/ui/message-scroller-primitive/use-message-scroller-refs.ts deleted file mode 100644 index 40a62ba1b..000000000 --- a/src/components/ui/message-scroller-primitive/use-message-scroller-refs.ts +++ /dev/null @@ -1,123 +0,0 @@ -import * as React from "react"; - -import { areScrollStatesEqual, createMessageScrollerStore } from "./stores"; -import { EMPTY_MESSAGE_SCROLLER_SCROLLABLE } from "./types"; -import type { MessageScrollerMode, MessageScrollerScrollable, MessageScrollerStore } from "./types"; - -type AnchoredMessage = { - behavior: ScrollBehavior; - element: HTMLElement; -}; - -// Shared mutable ref bag for one MessageScroller, closed over by both the -// controller and the commands so writes are visible across them without prop -// threading. stateStore fans scrollability changes out to the button. -type MessageScrollerRefs = { - anchoredMessageRef: React.RefObject; - autoScrollRef: React.RefObject; - autoscrollingRef: React.RefObject; - autoscrollingTimeoutRef: React.RefObject; - contentRef: React.RefObject; - defaultScrollPositionAppliedRef: React.RefObject; - firstItemRef: React.RefObject; - itemCountRef: React.RefObject; - modeRef: React.RefObject; - prependRestoreRef: React.RefObject<{ - element: HTMLElement; - viewportTop: number; - } | null>; - preserveScrollOnPrependRef: React.RefObject; - rootRef: React.RefObject; - scrollEdgeThresholdRef: React.RefObject; - scrollMarginRef: React.RefObject; - scrollPreviousItemPeekRef: React.RefObject; - spacerGapRef: React.RefObject; - spacerHeightRef: React.RefObject; - spacerRef: React.RefObject; - stateFrameRef: React.RefObject; - stateStore: MessageScrollerStore; - viewportRef: React.RefObject; -}; - -// Builds the per-instance ref bag: the external store is constructed once, and -// the latest prop values are mirrored onto refs so callbacks stay stable. -function useMessageScrollerRefs({ - autoScroll, - scrollEdgeThreshold, - scrollMargin, - scrollPreviousItemPeek, -}: { - autoScroll: boolean; - scrollEdgeThreshold: number; - scrollMargin: number; - scrollPreviousItemPeek: number; -}): MessageScrollerRefs { - // The message held at the reading line, together with the transition chosen - // when it became the active anchor. - const anchoredMessageRef = React.useRef(null); - const autoScrollRef = React.useRef(autoScroll); - const autoscrollingRef = React.useRef(false); - const contentRef = React.useRef(null); - const defaultScrollPositionAppliedRef = React.useRef(false); - const scrollEdgeThresholdRef = React.useRef(scrollEdgeThreshold); - const itemCountRef = React.useRef(0); - const firstItemRef = React.useRef(null); - const modeRef = React.useRef( - autoScroll ? "following-bottom" : "free-scrolling", - ); - // The row to hold steady on the next prepend: the first visible row, or a jump - // target seeded by scrollToElement. restorePrependedAnchor reads only this. - const prependRestoreRef = React.useRef<{ - element: HTMLElement; - viewportTop: number; - } | null>(null); - const scrollPreviousItemPeekRef = React.useRef(scrollPreviousItemPeek); - const preserveScrollOnPrependRef = React.useRef(true); - const rootRef = React.useRef(null); - const scrollMarginRef = React.useRef(scrollMargin); - const spacerGapRef = React.useRef(0); - const spacerHeightRef = React.useRef(0); - const spacerRef = React.useRef(null); - const stateFrameRef = React.useRef(null); - const autoscrollingTimeoutRef = React.useRef(null); - const viewportRef = React.useRef(null); - const [stateStore] = React.useState(() => - createMessageScrollerStore(EMPTY_MESSAGE_SCROLLER_SCROLLABLE, areScrollStatesEqual), - ); - - // Track the latest prop values on every render so callbacks read fresh values - // without being recreated (the useLatest pattern). - React.useLayoutEffect(() => { - autoScrollRef.current = autoScroll; - scrollEdgeThresholdRef.current = scrollEdgeThreshold; - scrollMarginRef.current = scrollMargin; - scrollPreviousItemPeekRef.current = scrollPreviousItemPeek; - }, [autoScroll, scrollEdgeThreshold, scrollMargin, scrollPreviousItemPeek]); - - return { - anchoredMessageRef, - autoScrollRef, - autoscrollingRef, - autoscrollingTimeoutRef, - contentRef, - defaultScrollPositionAppliedRef, - firstItemRef, - itemCountRef, - modeRef, - prependRestoreRef, - preserveScrollOnPrependRef, - rootRef, - scrollEdgeThresholdRef, - scrollMarginRef, - scrollPreviousItemPeekRef, - spacerGapRef, - spacerHeightRef, - spacerRef, - stateFrameRef, - stateStore, - viewportRef, - }; -} - -export { useMessageScrollerRefs }; -export type { MessageScrollerRefs }; diff --git a/src/components/ui/message-scroller-primitive/use-message-scroller.ts b/src/components/ui/message-scroller-primitive/use-message-scroller.ts new file mode 100644 index 000000000..4db56262a --- /dev/null +++ b/src/components/ui/message-scroller-primitive/use-message-scroller.ts @@ -0,0 +1,442 @@ +import * as React from "react"; + +import { + canScrollToEnd, + getElementViewportTop, + getFirstVisibleMessageItem, + getFlexGap, + getLastScrollAnchor, + getMaxScrollTop, + getMessageScrollerItems, + getRowScrollTop, + getTailSpacerHeight, +} from "./geometry"; +import { + AUTOSCROLLING_CLEAR_DELAY, + DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK, + SCROLL_POSITION_EPSILON, +} from "./types"; +import type { MessageScrollerContextValue, MessageScrollerProviderProps } from "./types"; + +// Minimal external store for the scroll-to-end button, so scrolling does not +// re-render the transcript. +function createCanScrollToEndStore() { + let snapshot = false; + const listeners = new Set<() => void>(); + + return { + get: () => snapshot, + set: (next: boolean) => { + if (snapshot === next) { + return; + } + + snapshot = next; + + for (const listener of listeners) { + listener(); + } + }, + subscribe: (listener: () => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }, + }; +} + +// The scroller has one piece of mode state: the turn held at the reading line. +// While a row is anchored, resizes re-run its placement. Once the reader scrolls +// away the anchor is dropped and their position is preserved as-is. +function useMessageScroller({ + appendedAnchorScrollBehavior = "auto", + scrollPreviousItemPeek = DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK, +}: MessageScrollerProviderProps) { + const contentRef = React.useRef(null); + const spacerRef = React.useRef(null); + const viewportRef = React.useRef(null); + + // The turn pinned at the reading line, or null while free-scrolling, together + // with the transition it was placed with — re-pinning replays it so a send that + // glided into place keeps gliding as the reply resizes the content beneath it. + const anchoredRowRef = React.useRef<{ behavior: ScrollBehavior; element: HTMLElement } | null>( + null, + ); + // Where the reader's first visible row sits, restored when content above it + // resizes. The viewport opts out of native scroll anchoring, so this is the + // only thing holding their place. + const readingAnchorRef = React.useRef<{ + element: HTMLElement; + viewportTop: number; + } | null>(null); + + // Turns already accounted for, by message id. Not an index: rows are added and + // removed in the same commit (an error row clearing as the next turn and its + // pending row arrive), so a count-based diff misses the turn that was sent. Not + // node identity either, so that React recreating a row's DOM node cannot read + // as a brand new turn and yank the reader back to an old one. + const seenRowIdsRef = React.useRef(new Set()); + const openingScrollAppliedRef = React.useRef(false); + const spacerGapRef = React.useRef(0); + const spacerHeightRef = React.useRef(0); + + const autoscrollingTimeoutRef = React.useRef(null); + const resizeFrameRef = React.useRef(null); + const stateFrameRef = React.useRef(null); + + // Latest prop values, so the callbacks wired to observers stay stable. + const appendedBehaviorRef = React.useRef(appendedAnchorScrollBehavior); + const peekRef = React.useRef(scrollPreviousItemPeek); + + React.useLayoutEffect(() => { + appendedBehaviorRef.current = appendedAnchorScrollBehavior; + peekRef.current = scrollPreviousItemPeek; + }, [appendedAnchorScrollBehavior, scrollPreviousItemPeek]); + + const [store] = React.useState(createCanScrollToEndStore); + + const commitScrollState = React.useCallback(() => { + store.set( + canScrollToEnd({ + content: contentRef.current, + spacer: spacerRef.current, + viewport: viewportRef.current, + }), + ); + }, [store]); + + const scheduleStateCommit = React.useCallback(() => { + if (stateFrameRef.current !== null) { + return; + } + + stateFrameRef.current = window.requestAnimationFrame(() => { + stateFrameRef.current = null; + commitScrollState(); + }); + }, [commitScrollState]); + + // Hides the scrollbar for the length of a programmatic smooth scroll. + const markAutoScrolling = React.useCallback(() => { + if (autoscrollingTimeoutRef.current !== null) { + window.clearTimeout(autoscrollingTimeoutRef.current); + } + + viewportRef.current?.setAttribute("data-autoscrolling", ""); + autoscrollingTimeoutRef.current = window.setTimeout(() => { + autoscrollingTimeoutRef.current = null; + viewportRef.current?.removeAttribute("data-autoscrolling"); + }, AUTOSCROLLING_CLEAR_DELAY); + }, []); + + const setTailSpacerHeight = React.useCallback((height: number) => { + const spacer = spacerRef.current; + const nextHeight = Math.max(0, Math.ceil(height)); + + if (!spacer || spacerHeightRef.current === nextHeight) { + return; + } + + spacerHeightRef.current = nextHeight; + spacer.hidden = nextHeight === 0; + spacer.style.height = `${nextHeight}px`; + spacer.style.marginTop = nextHeight > 0 ? `${-spacerGapRef.current}px` : ""; + }, []); + + const scrollToPosition = React.useCallback( + (scrollTop: number, behavior: ScrollBehavior) => { + const viewport = viewportRef.current; + + if (!viewport) { + return; + } + + const nextScrollTop = Math.max(0, scrollTop); + + if (Math.abs(viewport.scrollTop - nextScrollTop) <= SCROLL_POSITION_EPSILON) { + viewport.scrollTop = nextScrollTop; + commitScrollState(); + return; + } + + viewport.scrollTo({ top: nextScrollTop, behavior }); + scheduleStateCommit(); + }, + [commitScrollState, scheduleStateCommit], + ); + + const scrollToEnd = React.useCallback( + ({ behavior = "auto" }: { behavior?: ScrollBehavior } = {}) => { + const viewport = viewportRef.current; + + if (!viewport) { + return false; + } + + setTailSpacerHeight(0); + anchoredRowRef.current = null; + + if (behavior === "smooth") { + markAutoScrolling(); + } + + scrollToPosition(getMaxScrollTop(viewport), behavior); + + return true; + }, + [markAutoScrolling, scrollToPosition, setTailSpacerHeight], + ); + + // Pins a row to the reading line: size the tail spacer so it can reach the top, + // then scroll it there. + const anchorRow = React.useCallback( + (element: HTMLElement, behavior: ScrollBehavior) => { + const content = contentRef.current; + const viewport = viewportRef.current; + + if (!content || !viewport || !content.contains(element)) { + return false; + } + + const scrollTop = getRowScrollTop({ + content, + element, + peek: peekRef.current, + viewport, + }); + + setTailSpacerHeight( + getTailSpacerHeight({ content, scrollTop, spacer: spacerRef.current, viewport }), + ); + anchoredRowRef.current = { behavior, element }; + scrollToPosition(scrollTop, behavior); + + return true; + }, + [scrollToPosition, setTailSpacerHeight], + ); + + const captureReadingAnchor = React.useCallback(() => { + const content = contentRef.current; + const viewport = viewportRef.current; + + if (!content || !viewport) { + readingAnchorRef.current = null; + return; + } + + const anchor = getFirstVisibleMessageItem({ content, spacer: spacerRef.current, viewport }); + + readingAnchorRef.current = anchor + ? { element: anchor, viewportTop: getElementViewportTop(anchor, viewport) } + : null; + }, []); + + const restoreReadingAnchor = React.useCallback(() => { + const anchor = readingAnchorRef.current; + const viewport = viewportRef.current; + + if (!anchor || !viewport || !anchor.element.isConnected) { + return false; + } + + const delta = getElementViewportTop(anchor.element, viewport) - anchor.viewportTop; + + if (Math.abs(delta) <= SCROLL_POSITION_EPSILON) { + return false; + } + + viewport.scrollTop += delta; + anchor.viewportTop = getElementViewportTop(anchor.element, viewport); + scheduleStateCommit(); + + return true; + }, [scheduleStateCommit]); + + // Opens a saved transcript on its last turn, applied once. + const applyOpeningScroll = React.useCallback( + (items: HTMLElement[]) => { + if (openingScrollAppliedRef.current || items.length === 0) { + return false; + } + + const lastAnchor = getLastScrollAnchor(items); + const handled = lastAnchor + ? anchorRow(lastAnchor, "auto") + : scrollToEnd({ behavior: "auto" }); + + openingScrollAppliedRef.current = handled; + + return handled; + }, + [anchorRow, scrollToEnd], + ); + + const handleContentChange = React.useCallback(() => { + const content = contentRef.current; + + if (!content) { + return; + } + + const items = getMessageScrollerItems(content, spacerRef.current); + const seenRowIds = seenRowIdsRef.current; + let newAnchor: HTMLElement | null = null; + + for (const item of items) { + const messageId = item.dataset.messageId; + + // Rows without an id are transient (typing, errors) and never anchors. + if (!messageId || seenRowIds.has(messageId)) { + continue; + } + + seenRowIds.add(messageId); + + if (item.dataset.scrollAnchor === "true") { + newAnchor = item; + } + } + + // The opening restore claims the first non-empty render, so the rows it just + // marked as seen do not also read as newly sent turns. + if (!applyOpeningScroll(items)) { + if (newAnchor) { + anchorRow(newAnchor, appendedBehaviorRef.current); + } else { + commitScrollState(); + } + } + + captureReadingAnchor(); + }, [anchorRow, applyOpeningScroll, captureReadingAnchor, commitScrollState]); + + const reconcileResize = React.useCallback(() => { + const anchoredRow = anchoredRowRef.current; + + // Hold the anchored turn in place as content below it resizes (a reply + // streaming in, or a transient marker collapsing) — otherwise the shrinking + // content lets the browser clamp scrollTop and the turn drops. Failing that, + // hold the reader's first visible row where they left it as rows above it + // resize (an image loading, math laying out) — the viewport opts out of + // native scroll anchoring, so nothing else would. + const held = + (anchoredRow?.element.isConnected === true && + anchorRow(anchoredRow.element, anchoredRow.behavior)) || + restoreReadingAnchor(); + + if (!held) { + commitScrollState(); + } + }, [anchorRow, commitScrollState, restoreReadingAnchor]); + + const handleResize = React.useCallback(() => { + if (resizeFrameRef.current !== null) { + return; + } + + resizeFrameRef.current = window.requestAnimationFrame(() => { + resizeFrameRef.current = null; + reconcileResize(); + }); + }, [reconcileResize]); + + const userScrollIntent = React.useCallback(() => { + const viewport = viewportRef.current; + + if (!anchoredRowRef.current || !viewport) { + return; + } + + // A deliberate gesture releases the anchor, and stops an in-flight smooth + // scroll so re-pinning never fights the reader. + viewport.scrollTo({ top: viewport.scrollTop, behavior: "auto" }); + anchoredRowRef.current = null; + }, []); + + const syncAfterScroll = React.useCallback( + ({ userIntent = false }: { userIntent?: boolean } = {}) => { + if (userIntent) { + userScrollIntent(); + } + + commitScrollState(); + + if (!anchoredRowRef.current) { + captureReadingAnchor(); + } + }, + [captureReadingAnchor, commitScrollState, userScrollIntent], + ); + + const setContentElement = React.useCallback((element: HTMLDivElement | null) => { + contentRef.current = element; + }, []); + + const setViewportElement = React.useCallback((element: HTMLDivElement | null) => { + viewportRef.current = element; + }, []); + + const setSpacerElement = React.useCallback((element: HTMLDivElement | null) => { + spacerRef.current = element; + spacerGapRef.current = getFlexGap(element?.parentElement ?? null); + }, []); + + // Re-run once the whole tree is mounted. MessageScrollerContent's own layout + // effect fires before the viewport's ref is attached (React commits child + // refs and effects first), so on mount the opening scroll has no viewport to + // measure and defers to here. + React.useLayoutEffect(() => { + handleContentChange(); + }, [handleContentChange]); + + React.useEffect(() => { + return () => { + // Reset the handle after cancelling. StrictMode replays effects on the same + // refs, so an id left non-null makes the scheduler on remount think a frame + // is still pending and never reschedule. + for (const frameRef of [resizeFrameRef, stateFrameRef]) { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + } + + if (autoscrollingTimeoutRef.current !== null) { + window.clearTimeout(autoscrollingTimeoutRef.current); + autoscrollingTimeoutRef.current = null; + } + }; + }, []); + + return React.useMemo( + () => ({ + getCanScrollToEnd: store.get, + handleContentChange, + handleResize, + scrollToEnd, + setContentElement, + setSpacerElement, + setViewportElement, + subscribeCanScrollToEnd: store.subscribe, + syncAfterScroll, + userScrollIntent, + viewportRef, + }), + [ + handleContentChange, + handleResize, + scrollToEnd, + setContentElement, + setSpacerElement, + setViewportElement, + store, + syncAfterScroll, + userScrollIntent, + ], + ); +} + +export { useMessageScroller }; diff --git a/src/components/ui/message-scroller-primitive/utils.ts b/src/components/ui/message-scroller-primitive/utils.ts deleted file mode 100644 index a16e20db6..000000000 --- a/src/components/ui/message-scroller-primitive/utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as React from "react"; - -function useLatest(value: T) { - const ref = React.useRef(value); - - React.useLayoutEffect(() => { - ref.current = value; - }, [value]); - - return ref; -} - -function composeRefs( - ...refs: Array | undefined> -): React.RefCallback | undefined { - const validRefs = refs.filter(Boolean); - - if (validRefs.length === 0) { - return undefined; - } - - return (value) => { - for (const ref of validRefs) { - if (typeof ref === "function") { - ref(value); - } else if (ref) { - ref.current = value; - } - } - }; -} - -export { composeRefs, useLatest }; diff --git a/src/components/ui/message-scroller.tsx b/src/components/ui/message-scroller.tsx index a8506753f..7f32e128f 100644 --- a/src/components/ui/message-scroller.tsx +++ b/src/components/ui/message-scroller.tsx @@ -6,11 +6,7 @@ import { buttonVariants } from "#/components/ui/button.tsx"; import { ArrowDownIcon } from "lucide-react"; import type { VariantProps } from "class-variance-authority"; -function MessageScrollerProvider( - props: React.ComponentProps, -) { - return ; -} +const MessageScrollerProvider = MessageScrollerPrimitive.Provider; function MessageScroller({ className, @@ -36,7 +32,12 @@ function MessageScrollerViewport({ - - {direction === "end" ? "Scroll to end" : "Scroll to start"} - + Scroll to end )} diff --git a/src/features/workspaces/components/AiChatPanel.tsx b/src/features/workspaces/components/AiChatPanel.tsx index 7bbb87b3d..28e57ae8b 100644 --- a/src/features/workspaces/components/AiChatPanel.tsx +++ b/src/features/workspaces/components/AiChatPanel.tsx @@ -1,11 +1,4 @@ import { Suspense, useState } from "react"; -import { - MessageScroller, - MessageScrollerContent, - MessageScrollerItem, - MessageScrollerProvider, - MessageScrollerViewport, -} from "#/components/ui/message-scroller"; import { AiChatAttachmentDropProvider, useAiChatAttachmentDrop, @@ -14,13 +7,11 @@ import AiChatPanelToolbar from "#/features/workspaces/components/ai-chat/AiChatP import AiChatThreadSkeleton from "#/features/workspaces/components/ai-chat/AiChatThreadSkeleton"; import AiChatThreadView from "#/features/workspaces/components/ai-chat/AiChatThreadView"; import AiChatTranscriptRail from "#/features/workspaces/components/ai-chat/AiChatTranscriptRail"; -import { - aiChatMessageScrollerContentClassName, - aiChatMessageScrollerViewportClassName, -} from "#/features/workspaces/components/ai-chat/ai-chat-layout"; +import { aiChatMessageScrollerContentClassName } from "#/features/workspaces/components/ai-chat/ai-chat-layout"; import { useAiChatPanelController } from "#/features/workspaces/components/ai-chat/useAiChatPanelController"; import { WorkspaceFileDropOverlay } from "#/features/workspaces/components/WorkspaceFileDropOverlay"; import type { WorkspaceAiContextScope } from "#/features/workspaces/model/workspace-ai-context-types"; +import { cn } from "#/lib/utils"; interface AiChatPanelProps { context: WorkspaceAiContextScope; @@ -94,20 +85,14 @@ function AiChatPanelLayout({ context }: AiChatPanelProps) { ); } +// A skeleton never scrolls, so it borrows the transcript's spacing rather than +// its scroller. function AiChatPanelLoading() { return ( - - - - - - - - - - - - - +
+ + + +
); } diff --git a/src/features/workspaces/components/ai-chat/AiChatAssistantPending.tsx b/src/features/workspaces/components/ai-chat/AiChatAssistantPending.tsx index ee409e7a9..d4818ed94 100644 --- a/src/features/workspaces/components/ai-chat/AiChatAssistantPending.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatAssistantPending.tsx @@ -34,10 +34,6 @@ function AiChatAssistantPendingBody({ pending }: { pending: AssistantPendingKind ); } - if (pending === "working") { - return ; - } - return ; } @@ -52,23 +48,11 @@ function AiChatThinkingLoader() { ); } -function AiChatWorkingLoader() { - return ( - - - - - - ); -} - -export function ThinkExThinkingMark({ className }: { className?: string }) { +function ThinkExThinkingMark() { return (
- + {showEmptyState ? (
diff --git a/src/features/workspaces/components/ai-chat/ai-chat-display-state.test.ts b/src/features/workspaces/components/ai-chat/ai-chat-display-state.test.ts index c91201d08..0f5942dc8 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-display-state.test.ts +++ b/src/features/workspaces/components/ai-chat/ai-chat-display-state.test.ts @@ -165,6 +165,35 @@ describe("interrupted tool receipts", () => { }); }); +describe("tail status row", () => { + // A status row that outlives the first token has to be removed when the reply + // lands, and that shrink drags the pinned transcript. Nothing may hold a row + // once there is text to read. + it("drops the status row as soon as the reply renders", () => { + const message = createMessage([{ type: "text", text: "Hello", state: "streaming" }]); + const presentation = deriveAiChatPresentation([message], "streaming", { + isRecovering: false, + isServerStreaming: false, + isStreaming: true, + isToolContinuation: false, + }); + + expect(presentation.tailPending).toBeNull(); + }); + + it("keeps the status row while the reply is still empty", () => { + const message = createMessage([]); + const presentation = deriveAiChatPresentation([message], "submitted", { + isRecovering: false, + isServerStreaming: false, + isStreaming: false, + isToolContinuation: false, + }); + + expect(presentation.tailPending).toBe("thinking"); + }); +}); + function createMessage(parts: unknown[]) { return { id: "assistant-1", diff --git a/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts b/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts index 9bfd771d2..c97887661 100644 --- a/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts +++ b/src/features/workspaces/components/ai-chat/ai-chat-display-state.ts @@ -21,7 +21,7 @@ import { type AiChatToolReceiptSegment, } from "#/features/workspaces/components/ai-chat/ai-chat-tool-receipts"; -export type AssistantPendingKind = "thinking" | "working" | "recovering"; +export type AssistantPendingKind = "thinking" | "recovering"; /** * Every `orchestrate` call in a message collapses into one row: the header @@ -42,7 +42,11 @@ export function isAiChatToolGroupPart(part: AiChatRenderablePart): part is AiCha } export type AssistantRowDisplay = - | { interruptUnfinishedTools: boolean; kind: "content"; parts: AiChatRenderablePart[] } + | { + interruptUnfinishedTools: boolean; + kind: "content"; + parts: AiChatRenderablePart[]; + } | { kind: "empty-terminal"; canRegenerate: boolean } | { kind: "hidden" }; @@ -91,17 +95,17 @@ export function deriveAiChatPresentation( const assistantTailIsEmpty = lastMessage?.role === "assistant" && getDisplayableParts(lastMessage).length === 0; const hasVisibleAssistantTail = hasAssistantTail && !assistantTailIsEmpty; - const tailPending = isRecovering - ? hasVisibleAssistantTail - ? "working" - : "recovering" - : awaitingFirstToken - ? "thinking" - : !isBusy - ? null - : !hasVisibleAssistantTail - ? "thinking" - : "working"; + // Once the reply is actually rendering, the status row goes away — the text + // arriving is its own progress indicator. Keeping a row up for the length of + // the reply also means removing it at the end, and that shrink shifts the + // transcript no matter how promptly the tail spacer compensates. + const tailPending = hasVisibleAssistantTail + ? null + : isRecovering + ? "recovering" + : isBusy || awaitingFirstToken + ? "thinking" + : null; return { isBusy,