From 55a36998804dc97365bb498f8bafcdecdbc2e3d9 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:25:56 -0400 Subject: [PATCH 1/3] fix(workspaces): drop the assistant status row once the reply renders The "still working" mark held a transcript row for the whole reply, so it had to be removed when the reply landed. That shrink moved the pinned turn. The arriving text is its own progress indicator, so nothing needs to hold a row past the first token; "Thinking..." and "Recovering response..." still do, because no assistant row exists yet at that point. Covered by a test asserting no status row survives the first token. --- .../use-message-scroller.ts | 442 ++++++++++++++++++ .../ai-chat/AiChatAssistantPending.tsx | 20 +- .../ai-chat/ai-chat-display-state.test.ts | 29 ++ .../ai-chat/ai-chat-display-state.ts | 30 +- 4 files changed, 490 insertions(+), 31 deletions(-) create mode 100644 src/components/ui/message-scroller-primitive/use-message-scroller.ts 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/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 (
- {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/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..68878133f 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/AiChatMessageList.tsx b/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx index 6d0e53e6e..5f926cb71 100644 --- a/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx +++ b/src/features/workspaces/components/ai-chat/AiChatMessageList.tsx @@ -129,11 +129,7 @@ export default function AiChatMessageList({ return (
- + {showEmptyState ? (
From 5a5e289fd160c104a8e4b2dfaf7bd9c620b32f29 Mon Sep 17 00:00:00 2001 From: Urjit Chakraborty <135136842+urjitc@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:44:59 -0400 Subject: [PATCH 3/3] docs(ui): name the positions the scroller actually restores The comment justifying the scroll-anchoring opt-out still cited prepend preservation and follow-bottom, both of which this branch deleted. --- src/components/ui/message-scroller.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/message-scroller.tsx b/src/components/ui/message-scroller.tsx index 68878133f..7f32e128f 100644 --- a/src/components/ui/message-scroller.tsx +++ b/src/components/ui/message-scroller.tsx @@ -35,8 +35,8 @@ function MessageScrollerViewport({ // Native scroll anchoring re-pins a bottom-parked transcript when the // viewport shrinks — growing the composer by a chip row slides every // message up by that height. Every position this scroller cares about is - // restored in JS (prepends, the anchored turn, follow-bottom), so opt out - // and let those own the scroll offset. + // restored in JS (the opening scroll, the anchored turn, the reading + // anchor), so opt out and let those own the scroll offset. "size-full min-h-0 min-w-0 scroll-fade scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content [overflow-anchor:none] data-autoscrolling:scrollbar-none", className, )}