diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066..28c0f4e0e5e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -86,7 +86,12 @@ import { isLatestTurnSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; +import { + getAnchoredTurnMetrics, + resolveTimelineAutoFollowEnabledForRoute, + shouldResumeTimelineAutoFollow, + type TimelineScrollMode, +} from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -3303,6 +3308,13 @@ function ChatViewContent(props: ChatViewProps) { const showScrollDebouncer = useRef( new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); + const [timelineAutoFollowEnabled, setTimelineAutoFollowEnabled] = useState(true); + const timelineAutoFollowRouteKeyRef = useRef(routeThreadKey); + const timelineAutoFollowEnabledForRoute = resolveTimelineAutoFollowEnabledForRoute({ + autoFollowRouteKey: timelineAutoFollowRouteKeyRef.current, + routeKey: routeThreadKey, + autoFollowEnabled: timelineAutoFollowEnabled, + }); const timelineScrollModeRef = useRef("following-end"); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); @@ -3317,9 +3329,19 @@ function ChatViewContent(props: ChatViewProps) { } | null>(null); const anchorScrollRestoreFrameRef = useRef(null); const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { + if ( + liveFollowUserScrollGenerationRef.current === null && + timelineScrollModeRef.current === "free-scrolling" + ) { + return; + } anchorUserScrollGenerationRef.current += 1; + isAtEndRef.current = false; + setTimelineAutoFollowEnabled(false); timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(true); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; @@ -3330,13 +3352,6 @@ function ChatViewContent(props: ChatViewProps) { anchorScrollRestoreFrameRef.current = null; } }, []); - const cancelTimelineLiveFollowForUserNavigationRef = useRef( - cancelTimelineLiveFollowForUserNavigation, - ); - useEffect(() => { - cancelTimelineLiveFollowForUserNavigationRef.current = - cancelTimelineLiveFollowForUserNavigation; - }, [cancelTimelineLiveFollowForUserNavigation]); const getActiveTimelineTurnMetrics = useCallback( (list?: LegendListRef | null) => { const resolvedList = list ?? legendListRef.current; @@ -3389,6 +3404,7 @@ function ChatViewContent(props: ChatViewProps) { // gesture opts out. const scrollToEnd = useCallback((animated = false) => { isAtEndRef.current = true; + setTimelineAutoFollowEnabled(true); timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = null; @@ -3397,38 +3413,6 @@ function ChatViewContent(props: ChatViewProps) { setShowScrollToBottom(false); void legendListRef.current?.scrollToEnd?.({ animated }); }, []); - useEffect(() => { - let removeListeners: (() => void) | null = null; - const frame = requestAnimationFrame(() => { - const scrollNode = legendListRef.current?.getScrollableNode(); - if (!scrollNode) { - return; - } - const handleManualNavigation = () => { - cancelTimelineLiveFollowForUserNavigationRef.current(); - }; - scrollNode.addEventListener("wheel", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handleManualNavigation, { - passive: true, - }); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleManualNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handleManualNavigation); - }; - }); - - return () => { - cancelAnimationFrame(frame); - removeListeners?.(); - }; - }, [activeThread?.id]); - const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { if (pendingTimelineAnchorRef.current === messageId) { pendingTimelineAnchorRef.current = null; @@ -3521,28 +3505,49 @@ function ChatViewContent(props: ChatViewProps) { }); }, []); - const onIsAtEndChange = useCallback((isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - return; - } - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - } else { - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - showScrollDebouncer.current.maybeExecute(); - } - }, []); + const onIsAtEndChange = useCallback( + ({ + isAtEnd, + manualNavigationReachedEnd, + }: { + readonly isAtEnd: boolean; + readonly manualNavigationReachedEnd: boolean; + }) => { + if ( + !isAtEnd && + liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current + ) { + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + return; + } + if (isAtEnd) { + if ( + !shouldResumeTimelineAutoFollow({ + scrollMode: timelineScrollModeRef.current, + isAtEnd, + manualNavigationReachedEnd, + }) + ) { + return; + } + isAtEndRef.current = true; + setTimelineAutoFollowEnabled(true); + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + } else { + if (!isAtEndRef.current) return; + isAtEndRef.current = false; + setTimelineAutoFollowEnabled(false); + timelineScrollModeRef.current = "free-scrolling"; + liveFollowUserScrollGenerationRef.current = null; + showScrollDebouncer.current.maybeExecute(); + } + }, + [], + ); useEffect(() => { if (!activeThread?.id) { @@ -3613,6 +3618,8 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; + timelineAutoFollowRouteKeyRef.current = routeThreadKey; + setTimelineAutoFollowEnabled(true); timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = null; @@ -3629,7 +3636,7 @@ function ChatViewContent(props: ChatViewProps) { } planSidebarDismissedForTurnRef.current = null; // activeThreadRef resets transitively with the active thread. - }, [activeThread?.id]); + }, [activeThread?.id, routeThreadKey]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). @@ -4202,6 +4209,7 @@ function ChatViewContent(props: ChatViewProps) { // anchored end-space target so it lands near the top while the response // streams into the reserved space below it. isAtEndRef.current = true; + setTimelineAutoFollowEnabled(true); timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = messageIdForSend; @@ -4642,6 +4650,7 @@ function ChatViewContent(props: ChatViewProps) { // Position this sent row once LegendList has measured the anchored tail. isAtEndRef.current = true; + setTimelineAutoFollowEnabled(true); timelineScrollModeRef.current = "anchoring-new-turn"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = messageIdForSend; @@ -5279,6 +5288,7 @@ function ChatViewContent(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} + autoFollowEnabled={timelineAutoFollowEnabledForRoute} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 3227bac2413..8983bf73f2d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -16,13 +16,101 @@ export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; export const TIMELINE_CONTENT_MAX_WIDTH = 768; export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; +export type TimelineNavigationInput = + | { readonly type: "wheel"; readonly deltaY: number } + | { readonly type: "touch"; readonly previousY: number | null; readonly currentY: number | null } + | { readonly type: "keyboard"; readonly key: string; readonly shiftKey: boolean }; + +export function timelineNavigationInputMovesTowardHistory(input: TimelineNavigationInput) { + switch (input.type) { + case "wheel": + return input.deltaY < 0; + case "touch": + return ( + input.previousY !== null && input.currentY !== null && input.currentY > input.previousY + ); + case "keyboard": + return ( + input.key === "ArrowUp" || + input.key === "PageUp" || + input.key === "Home" || + (input.key === " " && input.shiftKey) + ); + } +} + +export interface TimelineScrollableNodeState { + readonly clientHeight: number; + readonly scrollHeight: number; + readonly scrollTop: number; +} + +export interface TimelineScrollOffsetState { + readonly scroll?: number; +} + +export function resolveTimelineManualNavigationScrollTop( + scrollNode: Pick | null | undefined, + state: TimelineScrollOffsetState | null | undefined, +): number | null { + const scrollTop = scrollNode?.scrollTop ?? state?.scroll; + return typeof scrollTop === "number" && Number.isFinite(scrollTop) ? scrollTop : null; +} + +export function timelineScrollableNodeCanNavigateTowardHistory( + scrollNode: TimelineScrollableNodeState | null | undefined, +): boolean { + return Boolean( + scrollNode && scrollNode.scrollHeight > scrollNode.clientHeight && scrollNode.scrollTop > 0, + ); +} + +export function resolveTimelineScrollableNodeIsAtEnd( + scrollNode: TimelineScrollableNodeState | null | undefined, +): boolean | undefined { + if (!scrollNode) { + return undefined; + } + return scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= 1; +} + +export function timelineManualNavigationMovedTowardHistory({ + initialScrollTop, + scrollTop, +}: { + readonly initialScrollTop: number; + readonly scrollTop: number; +}) { + return scrollTop < initialScrollTop; +} + +export function timelineManualNavigationReachedEnd({ + previousScrollTop, + scrollTop, + isAtEnd, +}: { + readonly previousScrollTop: number; + readonly scrollTop: number; + readonly isAtEnd: boolean; +}) { + return isAtEnd && scrollTop > previousScrollTop; +} + export interface TimelineEndState { readonly isAtEnd?: boolean; readonly isNearEnd?: boolean; + readonly isWithinMaintainScrollAtEndThreshold?: boolean; } export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { - return state?.isNearEnd ?? state?.isAtEnd; + return state?.isWithinMaintainScrollAtEndThreshold ?? state?.isNearEnd ?? state?.isAtEnd; +} + +export function resolveTimelineManualNavigationIsAtEnd( + state: TimelineEndState | undefined, + scrollNode: TimelineScrollableNodeState | null | undefined, +): boolean { + return state?.isAtEnd ?? resolveTimelineScrollableNodeIsAtEnd(scrollNode) ?? false; } export function resolveTimelineMinimapHeightStyle(itemCount: number): string { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index b340a248fbe..793f6f54b0e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -190,6 +190,7 @@ function buildProps() { onAnchorReady: () => {}, onAnchorSizeChanged: () => {}, contentInsetEndAdjustment: 0, + autoFollowEnabled: true, onIsAtEndChange: () => {}, onManualNavigation: () => {}, }; @@ -283,10 +284,16 @@ describe("MessagesTimeline", () => { } = await import("./MessagesTimeline.logic"); expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true); + expect( + resolveTimelineIsAtEnd({ + isWithinMaintainScrollAtEndThreshold: false, + isNearEnd: true, + isAtEnd: true, + }), + ).toBe(false); expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false); expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); - expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); expect( @@ -332,6 +339,135 @@ describe("MessagesTimeline", () => { expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); }); + it("recognizes directional user input without inferring intent from layout offsets", async () => { + const { + resolveTimelineScrollableNodeIsAtEnd, + resolveTimelineManualNavigationIsAtEnd, + resolveTimelineManualNavigationScrollTop, + timelineManualNavigationMovedTowardHistory, + timelineManualNavigationReachedEnd, + timelineNavigationInputMovesTowardHistory, + timelineScrollableNodeCanNavigateTowardHistory, + } = await import("./MessagesTimeline.logic"); + + expect(timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: -0.1 })).toBe(true); + expect(timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: 0.1 })).toBe(false); + expect( + timelineNavigationInputMovesTowardHistory({ type: "touch", previousY: 100, currentY: 100.1 }), + ).toBe(true); + expect( + timelineNavigationInputMovesTowardHistory({ + type: "keyboard", + key: "PageUp", + shiftKey: false, + }), + ).toBe(true); + expect( + timelineScrollableNodeCanNavigateTowardHistory({ + clientHeight: 400, + scrollHeight: 400, + scrollTop: 0, + }), + ).toBe(false); + expect( + timelineScrollableNodeCanNavigateTowardHistory({ + clientHeight: 400, + scrollHeight: 800, + scrollTop: 0, + }), + ).toBe(false); + expect( + timelineScrollableNodeCanNavigateTowardHistory({ + clientHeight: 400, + scrollHeight: 800, + scrollTop: 400, + }), + ).toBe(true); + expect( + resolveTimelineScrollableNodeIsAtEnd({ + clientHeight: 400, + scrollHeight: 800, + scrollTop: 398.9, + }), + ).toBe(false); + expect( + resolveTimelineScrollableNodeIsAtEnd({ + clientHeight: 400, + scrollHeight: 800, + scrollTop: 399.9, + }), + ).toBe(true); + expect( + resolveTimelineManualNavigationIsAtEnd( + { isAtEnd: true, isWithinMaintainScrollAtEndThreshold: false }, + { + clientHeight: 400, + scrollHeight: 800, + scrollTop: 256, + }, + ), + ).toBe(true); + expect( + resolveTimelineManualNavigationIsAtEnd( + { isAtEnd: false, isWithinMaintainScrollAtEndThreshold: true }, + { + clientHeight: 400, + scrollHeight: 800, + scrollTop: 400, + }, + ), + ).toBe(false); + expect( + resolveTimelineManualNavigationIsAtEnd(undefined, { + clientHeight: 400, + scrollHeight: 800, + scrollTop: 400, + }), + ).toBe(true); + expect(resolveTimelineManualNavigationScrollTop({ scrollTop: 240 }, { scroll: 180 })).toBe(240); + expect(resolveTimelineManualNavigationScrollTop(undefined, { scroll: 180 })).toBe(180); + expect(resolveTimelineManualNavigationScrollTop(undefined, undefined)).toBeNull(); + expect( + timelineManualNavigationMovedTowardHistory({ + initialScrollTop: 400, + scrollTop: 400, + }), + ).toBe(false); + expect( + timelineManualNavigationMovedTowardHistory({ + initialScrollTop: 400, + scrollTop: 401, + }), + ).toBe(false); + expect( + timelineManualNavigationMovedTowardHistory({ + initialScrollTop: 400, + scrollTop: 399.9, + }), + ).toBe(true); + expect( + resolveTimelineScrollableNodeIsAtEnd({ + clientHeight: 400, + scrollHeight: 800, + scrollTop: 400, + }), + ).toBe(true); + expect( + timelineManualNavigationReachedEnd({ + previousScrollTop: 400, + scrollTop: 399.9, + isAtEnd: true, + }), + ).toBe(false); + expect( + timelineManualNavigationReachedEnd({ + previousScrollTop: 399.9, + scrollTop: 400, + isAtEnd: true, + }), + ).toBe(true); + }); + it("anchors a sent attachment message using its measured height", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const onAnchorReady = vi.fn(); @@ -414,6 +550,19 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-collapsible="false"'); }); + it("disables LegendList auto-follow while the reader is away from the live edge", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); + }); + it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f759aa150be..d88b401ca47 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -19,7 +19,10 @@ import { useState, type KeyboardEvent, type MouseEvent, + type PointerEvent, type ReactNode, + type TouchEvent, + type WheelEvent, } from "react"; import { flushSync } from "react-dom"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; @@ -74,12 +77,18 @@ import { normalizeCompactToolLabel, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, + resolveTimelineManualNavigationIsAtEnd, + resolveTimelineManualNavigationScrollTop, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, + timelineScrollableNodeCanNavigateTowardHistory, + timelineManualNavigationMovedTowardHistory, + timelineManualNavigationReachedEnd, + timelineNavigationInputMovesTowardHistory, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -182,7 +191,11 @@ interface MessagesTimelineProps { onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; - onIsAtEndChange: (isAtEnd: boolean) => void; + autoFollowEnabled: boolean; + onIsAtEndChange: (change: { + readonly isAtEnd: boolean; + readonly manualNavigationReachedEnd: boolean; + }) => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; } @@ -216,6 +229,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, + autoFollowEnabled, onIsAtEndChange, onManualNavigation, hideEmptyPlaceholder = false, @@ -329,6 +343,22 @@ export const MessagesTimeline = memo(function MessagesTimeline({ null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); + const [navigationProbeTimelineKey, setNavigationProbeTimelineKey] = useState(null); + const timelineTouchYRef = useRef(null); + const manualNavigationRef = useRef<{ + readonly timelineKey: string; + scrollTop: number | null; + } | null>(null); + const pointerNavigationRef = useRef<{ + readonly pointerId: number; + readonly timelineKey: string; + scrollTop: number; + } | null>(null); + const navigationProbeRef = useRef<{ + readonly timelineKey: string; + readonly initialScrollTop: number; + } | null>(null); + const navigationProbeFrameRef = useRef(null); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { @@ -355,18 +385,127 @@ export const MessagesTimeline = memo(function MessagesTimeline({ : undefined; }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]); + const markTimelineManualNavigation = useCallback(() => { + if (manualNavigationRef.current?.timelineKey !== routeThreadKey) { + const list = listRef.current; + const scrollTop = resolveTimelineManualNavigationScrollTop( + list?.getScrollableNode(), + list?.getState?.(), + ); + manualNavigationRef.current = { timelineKey: routeThreadKey, scrollTop }; + } + onManualNavigation(); + }, [listRef, onManualNavigation, routeThreadKey]); + + const finishTimelineNavigationProbe = useCallback( + (scrollNode: HTMLElement | null | undefined) => { + const probe = navigationProbeRef.current; + if (!probe || probe.timelineKey !== routeThreadKey) { + return; + } + navigationProbeRef.current = null; + setNavigationProbeTimelineKey((timelineKey) => + timelineKey === routeThreadKey ? null : timelineKey, + ); + if ( + scrollNode && + timelineManualNavigationMovedTowardHistory({ + initialScrollTop: probe.initialScrollTop, + scrollTop: scrollNode.scrollTop, + }) + ) { + markTimelineManualNavigation(); + } + }, + [markTimelineManualNavigation, routeThreadKey], + ); + + const beginTimelineNavigationProbe = useCallback( + (scrollNode: HTMLElement) => { + if (navigationProbeRef.current?.timelineKey !== routeThreadKey) { + navigationProbeRef.current = { + timelineKey: routeThreadKey, + initialScrollTop: scrollNode.scrollTop, + }; + } + if (navigationProbeFrameRef.current !== null) { + cancelAnimationFrame(navigationProbeFrameRef.current); + } + flushSync(() => setNavigationProbeTimelineKey(routeThreadKey)); + navigationProbeFrameRef.current = requestAnimationFrame(() => { + navigationProbeFrameRef.current = null; + finishTimelineNavigationProbe(listRef.current?.getScrollableNode()); + }); + }, + [finishTimelineNavigationProbe, listRef, routeThreadKey], + ); + const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); - const isAtEnd = resolveTimelineIsAtEnd(state); + const scrollNode = listRef.current?.getScrollableNode(); + const scrollTop = resolveTimelineManualNavigationScrollTop(scrollNode, state); + if (manualNavigationRef.current?.timelineKey !== routeThreadKey) { + manualNavigationRef.current = null; + } + if (pointerNavigationRef.current?.timelineKey !== routeThreadKey) { + pointerNavigationRef.current = null; + } + if (navigationProbeRef.current?.timelineKey !== routeThreadKey) { + navigationProbeRef.current = null; + } + const navigationProbe = navigationProbeRef.current; + if ( + navigationProbe && + scrollNode && + timelineManualNavigationMovedTowardHistory({ + initialScrollTop: navigationProbe.initialScrollTop, + scrollTop: scrollNode.scrollTop, + }) + ) { + if (navigationProbeFrameRef.current !== null) { + cancelAnimationFrame(navigationProbeFrameRef.current); + navigationProbeFrameRef.current = null; + } + finishTimelineNavigationProbe(scrollNode); + } + const pointerNavigation = pointerNavigationRef.current; + if (pointerNavigation && scrollTop !== null && scrollTop < pointerNavigation.scrollTop) { + markTimelineManualNavigation(); + } + if (pointerNavigation && scrollTop !== null) { + pointerNavigation.scrollTop = scrollTop; + } + const manualNavigation = manualNavigationRef.current; + const scrollNodeIsAtEnd = resolveTimelineManualNavigationIsAtEnd(state, scrollNode); + const manualNavigationReachedEnd = Boolean( + manualNavigation && + manualNavigation.scrollTop !== null && + scrollTop !== null && + timelineManualNavigationReachedEnd({ + previousScrollTop: manualNavigation.scrollTop, + scrollTop, + isAtEnd: scrollNodeIsAtEnd, + }), + ); + const isAtEnd = + manualNavigation && scrollTop !== null + ? manualNavigationReachedEnd + : resolveTimelineIsAtEnd(state); + if (manualNavigation && scrollTop !== null) { + manualNavigation.scrollTop = scrollTop; + if (isAtEnd) { + manualNavigationRef.current = null; + } + } if (isAtEnd !== undefined) { - onIsAtEndChange(isAtEnd); + onIsAtEndChange({ isAtEnd, manualNavigationReachedEnd }); } if (!state || minimapItems.length === 0) { return; } - const scrollTop = state.scroll ?? 0; - const scrollBottom = scrollTop + (state.scrollLength ?? 0); + const resolvedScrollTop = state?.scroll ?? 0; + const scrollBottom = resolvedScrollTop + (state.scrollLength ?? 0); for (const item of minimapItems) { const strip = minimapStripMap.get(item.id); @@ -379,11 +518,145 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const inView = rowTop !== null && rowTop < scrollBottom && - rowTop + Math.max(1, rowHeight ?? 1) > scrollTop; + rowTop + Math.max(1, rowHeight ?? 1) > resolvedScrollTop; strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange]); + }, [ + listRef, + finishTimelineNavigationProbe, + markTimelineManualNavigation, + minimapItems, + minimapStripMap, + onIsAtEndChange, + routeThreadKey, + ]); + + const handleTimelineWheelCapture = useCallback( + (event: WheelEvent) => { + const scrollNode = listRef.current?.getScrollableNode(); + if ( + timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: event.deltaY }) && + scrollNode && + timelineScrollableNodeCanNavigateTowardHistory(scrollNode) + ) { + beginTimelineNavigationProbe(scrollNode); + } + }, + [beginTimelineNavigationProbe, listRef], + ); + const handleTimelineTouchStartCapture = useCallback((event: TouchEvent) => { + timelineTouchYRef.current = event.touches[0]?.clientY ?? null; + }, []); + const handleTimelineTouchMoveCapture = useCallback( + (event: TouchEvent) => { + const currentY = event.touches[0]?.clientY ?? null; + const scrollNode = listRef.current?.getScrollableNode(); + if ( + timelineNavigationInputMovesTowardHistory({ + type: "touch", + previousY: timelineTouchYRef.current, + currentY, + }) && + scrollNode && + timelineScrollableNodeCanNavigateTowardHistory(scrollNode) + ) { + beginTimelineNavigationProbe(scrollNode); + } + timelineTouchYRef.current = currentY; + }, + [beginTimelineNavigationProbe, listRef], + ); + const resetTimelineTouchTracking = useCallback(() => { + timelineTouchYRef.current = null; + }, []); + const handleTimelineKeyDownCapture = useCallback( + (event: KeyboardEvent) => { + if ( + !timelineNavigationInputMovesTowardHistory({ + type: "keyboard", + key: event.key, + shiftKey: event.shiftKey, + }) + ) { + return; + } + const scrollNode = listRef.current?.getScrollableNode(); + if (!scrollNode || !timelineScrollableNodeCanNavigateTowardHistory(scrollNode)) { + return; + } + beginTimelineNavigationProbe(scrollNode); + }, + [beginTimelineNavigationProbe, listRef], + ); + const handleTimelinePointerDownCapture = useCallback( + (event: PointerEvent) => { + const scrollNode = listRef.current?.getScrollableNode(); + if (!scrollNode || event.target !== scrollNode) { + return; + } + const scrollbarWidth = scrollNode.offsetWidth - scrollNode.clientWidth; + if ( + scrollbarWidth > 0 && + event.clientX < scrollNode.getBoundingClientRect().right - scrollbarWidth + ) { + return; + } + pointerNavigationRef.current = { + pointerId: event.pointerId, + timelineKey: routeThreadKey, + scrollTop: scrollNode.scrollTop, + }; + }, + [listRef, routeThreadKey], + ); + const handleTimelineMinimapSelect = useCallback( + (item: TimelineMinimapItem) => { + flushSync(() => setNavigationProbeTimelineKey(routeThreadKey)); + markTimelineManualNavigation(); + void listRef.current?.scrollToIndex({ + index: item.rowIndex, + animated: true, + viewOffset: 24, + }); + setNavigationProbeTimelineKey((timelineKey) => + timelineKey === routeThreadKey ? null : timelineKey, + ); + }, + [listRef, markTimelineManualNavigation, routeThreadKey], + ); + useEffect(() => { + if (autoFollowEnabled) { + manualNavigationRef.current = null; + } + pointerNavigationRef.current = null; + navigationProbeRef.current = null; + if (navigationProbeFrameRef.current !== null) { + cancelAnimationFrame(navigationProbeFrameRef.current); + navigationProbeFrameRef.current = null; + } + setNavigationProbeTimelineKey(null); + return () => { + if (navigationProbeFrameRef.current !== null) { + cancelAnimationFrame(navigationProbeFrameRef.current); + navigationProbeFrameRef.current = null; + } + }; + }, [autoFollowEnabled, routeThreadKey]); + + useEffect(() => { + const resetTimelinePointerTracking = (event: globalThis.PointerEvent) => { + if (pointerNavigationRef.current?.pointerId === event.pointerId) { + pointerNavigationRef.current = null; + } + }; + window.addEventListener("pointercancel", resetTimelinePointerTracking, true); + window.addEventListener("pointerup", resetTimelinePointerTracking, true); + return () => { + window.removeEventListener("pointercancel", resetTimelinePointerTracking, true); + window.removeEventListener("pointerup", resetTimelinePointerTracking, true); + }; + }, []); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -482,7 +755,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( -
+
ref={listRef} data={rows} @@ -494,7 +777,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace + anchoredEndSpace || + !autoFollowEnabled || + navigationProbeTimelineKey === routeThreadKey ? false : { animated: false, @@ -520,14 +805,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hasPersistentGutter={minimapHasPersistentGutter} hitStripWidth={minimapHitStripWidth} stripMap={minimapStripMap} - onSelect={(item) => { - onManualNavigation(); - void listRef.current?.scrollToIndex({ - index: item.rowIndex, - animated: true, - viewOffset: 24, - }); - }} + onSelect={handleTimelineMinimapSelect} />
diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index 1bf82c47a61..23018d126eb 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -1,5 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring"; +import { + getAnchoredTurnMetrics, + getRowBottom, + resolveTimelineAutoFollowEnabledForRoute, + shouldResumeTimelineAutoFollow, +} from "./timelineScrollAnchoring"; function buildState({ positions, @@ -22,6 +27,57 @@ function buildState({ } describe("timeline scroll anchoring", () => { + it("starts a newly selected route with auto-follow enabled", () => { + expect( + resolveTimelineAutoFollowEnabledForRoute({ + autoFollowRouteKey: "thread-1", + routeKey: "thread-1", + autoFollowEnabled: false, + }), + ).toBe(false); + expect( + resolveTimelineAutoFollowEnabledForRoute({ + autoFollowRouteKey: "thread-1", + routeKey: "thread-2", + autoFollowEnabled: false, + }), + ).toBe(true); + }); + + it("only resumes detached follow after confirmed manual navigation reaches the end", () => { + expect( + shouldResumeTimelineAutoFollow({ + scrollMode: "free-scrolling", + isAtEnd: true, + manualNavigationReachedEnd: false, + }), + ).toBe(false); + expect( + shouldResumeTimelineAutoFollow({ + scrollMode: "free-scrolling", + isAtEnd: true, + manualNavigationReachedEnd: true, + }), + ).toBe(true); + }); + + it("does not let end observations collapse new-turn anchoring", () => { + expect( + shouldResumeTimelineAutoFollow({ + scrollMode: "anchoring-new-turn", + isAtEnd: true, + manualNavigationReachedEnd: false, + }), + ).toBe(false); + expect( + shouldResumeTimelineAutoFollow({ + scrollMode: "following-end", + isAtEnd: true, + manualNavigationReachedEnd: false, + }), + ).toBe(true); + }); + it("measures row bottoms from LegendList row position and size", () => { const state = buildState({ positions: [0, 120], diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 48d3fc7542d..f68c5267149 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -1,5 +1,35 @@ export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; +export function resolveTimelineAutoFollowEnabledForRoute({ + autoFollowRouteKey, + routeKey, + autoFollowEnabled, +}: { + readonly autoFollowRouteKey: string; + readonly routeKey: string; + readonly autoFollowEnabled: boolean; +}): boolean { + return autoFollowRouteKey === routeKey ? autoFollowEnabled : true; +} + +export function shouldResumeTimelineAutoFollow({ + scrollMode, + isAtEnd, + manualNavigationReachedEnd, +}: { + readonly scrollMode: TimelineScrollMode; + readonly isAtEnd: boolean; + readonly manualNavigationReachedEnd: boolean; +}): boolean { + if (!isAtEnd) { + return false; + } + if (scrollMode === "following-end") { + return true; + } + return scrollMode === "free-scrolling" && manualNavigationReachedEnd; +} + export interface TimelineListMeasurementState { readonly data: readonly unknown[]; readonly scroll: number;