From 5f1e4371caedbf3b70cfb99638fd342a39d7cbc9 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:48:17 +0200 Subject: [PATCH 01/16] Pause live output follow while reading --- apps/web/src/components/ChatView.tsx | 53 +++++-------------- .../components/chat/MessagesTimeline.logic.ts | 20 ++++++- .../components/chat/MessagesTimeline.test.tsx | 36 +++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 25 +++++++-- 4 files changed, 87 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..30f34473751 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3155,6 +3155,7 @@ function ChatViewContent(props: ChatViewProps) { const showScrollDebouncer = useRef( new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); + const [timelineAutoFollowEnabled, setTimelineAutoFollowEnabled] = useState(true); const timelineScrollModeRef = useRef("following-end"); const pendingTimelineAnchorRef = useRef(null); const positionedTimelineAnchorRef = useRef(null); @@ -3170,6 +3171,7 @@ function ChatViewContent(props: ChatViewProps) { const anchorScrollRestoreFrameRef = useRef(null); const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { anchorUserScrollGenerationRef.current += 1; + setTimelineAutoFollowEnabled(false); timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; pendingTimelineAnchorRef.current = null; @@ -3182,13 +3184,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; @@ -3241,6 +3236,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; @@ -3249,38 +3245,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; @@ -3382,14 +3346,17 @@ function ChatViewContent(props: ChatViewProps) { setShowScrollToBottom(false); return; } - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; if (isAtEnd) { + 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(); @@ -3465,6 +3432,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; + setTimelineAutoFollowEnabled(true); timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = null; @@ -4030,6 +3998,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; @@ -4467,6 +4436,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; @@ -5099,6 +5069,7 @@ function ChatViewContent(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} + autoFollowEnabled={timelineAutoFollowEnabled} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} /> diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c6e277cce08..2dcfeb3b9e6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -16,13 +16,31 @@ 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 function shouldPauseTimelineAutoFollow({ + isAtEnd, + previousScrollOffset, + scrollOffset, +}: { + readonly isAtEnd: boolean | undefined; + readonly previousScrollOffset: number | null; + readonly scrollOffset: number | null; +}) { + return ( + isAtEnd === false && + scrollOffset !== null && + previousScrollOffset !== null && + scrollOffset < previousScrollOffset - 1 + ); +} + 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 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 0957e025311..6a2ee36c86f 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: () => {}, }; @@ -226,12 +227,34 @@ describe("MessagesTimeline", () => { resolveTimelineMinimapHeightStyle, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapTopPercent, + shouldPauseTimelineAutoFollow, } = 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( + shouldPauseTimelineAutoFollow({ + isAtEnd: false, + previousScrollOffset: 400, + scrollOffset: 250, + }), + ).toBe(true); + expect( + shouldPauseTimelineAutoFollow({ + isAtEnd: true, + previousScrollOffset: 400, + scrollOffset: 399.5, + }), + ).toBe(false); expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); @@ -338,6 +361,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 1a4dc6b6895..629261fd079 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -70,6 +70,7 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + shouldPauseTimelineAutoFollow, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, @@ -177,6 +178,7 @@ interface MessagesTimelineProps { onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; + autoFollowEnabled: boolean; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; } @@ -210,6 +212,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onAnchorReady, onAnchorSizeChanged, contentInsetEndAdjustment, + autoFollowEnabled, onIsAtEndChange, onManualNavigation, }: MessagesTimelineProps) { @@ -322,6 +325,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); + const previousScrollOffsetRef = useRef(null); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -350,6 +354,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state); + const scrollTop = state?.scroll ?? null; + if ( + shouldPauseTimelineAutoFollow({ + isAtEnd, + previousScrollOffset: previousScrollOffsetRef.current, + scrollOffset: scrollTop, + }) + ) { + onManualNavigation(); + } + previousScrollOffsetRef.current = scrollTop; if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } @@ -357,8 +372,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return; } - const scrollTop = state.scroll ?? 0; - const scrollBottom = scrollTop + (state.scrollLength ?? 0); + const resolvedScrollTop = scrollTop ?? 0; + const scrollBottom = resolvedScrollTop + (state.scrollLength ?? 0); for (const item of minimapItems) { const strip = minimapStripMap.get(item.id); @@ -371,11 +386,11 @@ 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, minimapItems, minimapStripMap, onIsAtEndChange, onManualNavigation]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); @@ -482,7 +497,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace + anchoredEndSpace || !autoFollowEnabled ? false : { animated: false, From aaa53b016cf1ea9a746b0347f7f71e0de2d2b71a Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:02:28 +0200 Subject: [PATCH 02/16] Fix timeline scroll intent tracking --- .../components/chat/MessagesTimeline.logic.ts | 51 +++++++++++-- .../components/chat/MessagesTimeline.test.tsx | 72 ++++++++++++++----- .../src/components/chat/MessagesTimeline.tsx | 27 ++++--- 3 files changed, 116 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 2dcfeb3b9e6..20cecb08267 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -16,21 +16,58 @@ 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 function shouldPauseTimelineAutoFollow({ +export interface TimelineAutoFollowScrollState { + readonly timelineKey: string; + readonly anchorScrollOffset: number | null; + readonly isAtEnd: boolean | undefined; +} + +export function updateTimelineAutoFollowScrollState({ + state, + timelineKey, isAtEnd, - previousScrollOffset, scrollOffset, }: { + readonly state: TimelineAutoFollowScrollState; + readonly timelineKey: string; readonly isAtEnd: boolean | undefined; - readonly previousScrollOffset: number | null; readonly scrollOffset: number | null; }) { - return ( + if (state.timelineKey !== timelineKey) { + return { + state: { + timelineKey, + anchorScrollOffset: isAtEnd === true ? scrollOffset : null, + isAtEnd, + }, + shouldPause: false, + } as const; + } + + const shouldPause = isAtEnd === false && scrollOffset !== null && - previousScrollOffset !== null && - scrollOffset < previousScrollOffset - 1 - ); + state.anchorScrollOffset !== null && + scrollOffset < state.anchorScrollOffset - 1; + + let anchorScrollOffset = shouldPause ? null : state.anchorScrollOffset; + if ( + !shouldPause && + isAtEnd === true && + scrollOffset !== null && + (state.isAtEnd !== true || anchorScrollOffset === null || scrollOffset > anchorScrollOffset) + ) { + anchorScrollOffset = scrollOffset; + } + + return { + state: { + timelineKey, + anchorScrollOffset, + isAtEnd, + }, + shouldPause, + } as const; } export interface TimelineEndState { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 6a2ee36c86f..ed0641ec906 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -227,7 +227,6 @@ describe("MessagesTimeline", () => { resolveTimelineMinimapHeightStyle, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapTopPercent, - shouldPauseTimelineAutoFollow, } = await import("./MessagesTimeline.logic"); expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true); @@ -241,21 +240,6 @@ describe("MessagesTimeline", () => { expect(resolveTimelineIsAtEnd({ isNearEnd: false, isAtEnd: true })).toBe(false); expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true); expect(resolveTimelineIsAtEnd(undefined)).toBeUndefined(); - expect( - shouldPauseTimelineAutoFollow({ - isAtEnd: false, - previousScrollOffset: 400, - scrollOffset: 250, - }), - ).toBe(true); - expect( - shouldPauseTimelineAutoFollow({ - isAtEnd: true, - previousScrollOffset: 400, - scrollOffset: 399.5, - }), - ).toBe(false); - expect(resolveTimelineMinimapHeightStyle(5)).toBe("min(32px, calc(100vh - 18rem))"); expect(resolveTimelineMinimapTopPercent(2, 5)).toBe(50); expect( @@ -279,6 +263,62 @@ describe("MessagesTimeline", () => { expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); }); + it("accumulates slow upward scrolling and resets tracking between timelines", async () => { + const { updateTimelineAutoFollowScrollState } = await import("./MessagesTimeline.logic"); + let result = updateTimelineAutoFollowScrollState({ + state: { + timelineKey: "thread-a", + anchorScrollOffset: null, + isAtEnd: undefined, + }, + timelineKey: "thread-a", + isAtEnd: true, + scrollOffset: 400, + }); + + result = updateTimelineAutoFollowScrollState({ + state: result.state, + timelineKey: "thread-a", + isAtEnd: true, + scrollOffset: 399.6, + }); + expect(result.shouldPause).toBe(false); + expect(result.state.anchorScrollOffset).toBe(400); + + result = updateTimelineAutoFollowScrollState({ + state: result.state, + timelineKey: "thread-a", + isAtEnd: false, + scrollOffset: 399.2, + }); + expect(result.shouldPause).toBe(false); + + result = updateTimelineAutoFollowScrollState({ + state: result.state, + timelineKey: "thread-a", + isAtEnd: false, + scrollOffset: 398.8, + }); + expect(result.shouldPause).toBe(true); + + result = updateTimelineAutoFollowScrollState({ + state: { + timelineKey: "thread-a", + anchorScrollOffset: 1_000, + isAtEnd: true, + }, + timelineKey: "thread-b", + isAtEnd: false, + scrollOffset: 100, + }); + expect(result.shouldPause).toBe(false); + expect(result.state).toEqual({ + timelineKey: "thread-b", + anchorScrollOffset: null, + isAtEnd: false, + }); + }); + it("anchors a sent attachment message using its measured height", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const onAnchorReady = vi.fn(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 629261fd079..51be736ccc7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -70,12 +70,13 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, - shouldPauseTimelineAutoFollow, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapTopPercent, + type TimelineAutoFollowScrollState, + updateTimelineAutoFollowScrollState, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -325,7 +326,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); - const previousScrollOffsetRef = useRef(null); + const autoFollowScrollStateRef = useRef({ + timelineKey: routeThreadKey, + anchorScrollOffset: null, + isAtEnd: undefined, + }); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -355,16 +360,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state); const scrollTop = state?.scroll ?? null; - if ( - shouldPauseTimelineAutoFollow({ - isAtEnd, - previousScrollOffset: previousScrollOffsetRef.current, - scrollOffset: scrollTop, - }) - ) { + const autoFollowUpdate = updateTimelineAutoFollowScrollState({ + state: autoFollowScrollStateRef.current, + timelineKey: routeThreadKey, + isAtEnd, + scrollOffset: scrollTop, + }); + autoFollowScrollStateRef.current = autoFollowUpdate.state; + if (autoFollowUpdate.shouldPause) { onManualNavigation(); } - previousScrollOffsetRef.current = scrollTop; if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); } @@ -390,7 +395,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange, onManualNavigation]); + }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange, onManualNavigation, routeThreadKey]); useEffect(() => { const frame = requestAnimationFrame(handleScroll); From 16e8cbb4240ff82c1dca8f1ffcbd13665531be05 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:22:02 +0200 Subject: [PATCH 03/16] Use explicit input for timeline scroll intent --- apps/web/src/components/ChatView.tsx | 6 + .../components/chat/MessagesTimeline.logic.ts | 71 ++++----- .../components/chat/MessagesTimeline.test.tsx | 76 +++------- .../src/components/chat/MessagesTimeline.tsx | 136 +++++++++++++++--- 4 files changed, 172 insertions(+), 117 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 30f34473751..5239d8987f8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3170,6 +3170,12 @@ 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; setTimelineAutoFollowEnabled(false); timelineScrollModeRef.current = "free-scrolling"; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 20cecb08267..b764c8af2bd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -16,58 +16,37 @@ 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 interface TimelineAutoFollowScrollState { - readonly timelineKey: string; - readonly anchorScrollOffset: number | null; - readonly isAtEnd: boolean | undefined; +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 function updateTimelineAutoFollowScrollState({ - state, - timelineKey, - isAtEnd, +export function timelineUserScrollRequestsPause({ + inputScrollOffset, scrollOffset, }: { - readonly state: TimelineAutoFollowScrollState; - readonly timelineKey: string; - readonly isAtEnd: boolean | undefined; + readonly inputScrollOffset: number | null; readonly scrollOffset: number | null; }) { - if (state.timelineKey !== timelineKey) { - return { - state: { - timelineKey, - anchorScrollOffset: isAtEnd === true ? scrollOffset : null, - isAtEnd, - }, - shouldPause: false, - } as const; - } - - const shouldPause = - isAtEnd === false && - scrollOffset !== null && - state.anchorScrollOffset !== null && - scrollOffset < state.anchorScrollOffset - 1; - - let anchorScrollOffset = shouldPause ? null : state.anchorScrollOffset; - if ( - !shouldPause && - isAtEnd === true && - scrollOffset !== null && - (state.isAtEnd !== true || anchorScrollOffset === null || scrollOffset > anchorScrollOffset) - ) { - anchorScrollOffset = scrollOffset; - } - - return { - state: { - timelineKey, - anchorScrollOffset, - isAtEnd, - }, - shouldPause, - } as const; + return inputScrollOffset !== null && scrollOffset !== null && scrollOffset < inputScrollOffset; } export interface TimelineEndState { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index ed0641ec906..16a968deffd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -263,60 +263,28 @@ describe("MessagesTimeline", () => { expect(resolveTimelineMinimapHasPersistentGutter(864)).toBe(true); }); - it("accumulates slow upward scrolling and resets tracking between timelines", async () => { - const { updateTimelineAutoFollowScrollState } = await import("./MessagesTimeline.logic"); - let result = updateTimelineAutoFollowScrollState({ - state: { - timelineKey: "thread-a", - anchorScrollOffset: null, - isAtEnd: undefined, - }, - timelineKey: "thread-a", - isAtEnd: true, - scrollOffset: 400, - }); - - result = updateTimelineAutoFollowScrollState({ - state: result.state, - timelineKey: "thread-a", - isAtEnd: true, - scrollOffset: 399.6, - }); - expect(result.shouldPause).toBe(false); - expect(result.state.anchorScrollOffset).toBe(400); - - result = updateTimelineAutoFollowScrollState({ - state: result.state, - timelineKey: "thread-a", - isAtEnd: false, - scrollOffset: 399.2, - }); - expect(result.shouldPause).toBe(false); - - result = updateTimelineAutoFollowScrollState({ - state: result.state, - timelineKey: "thread-a", - isAtEnd: false, - scrollOffset: 398.8, - }); - expect(result.shouldPause).toBe(true); - - result = updateTimelineAutoFollowScrollState({ - state: { - timelineKey: "thread-a", - anchorScrollOffset: 1_000, - isAtEnd: true, - }, - timelineKey: "thread-b", - isAtEnd: false, - scrollOffset: 100, - }); - expect(result.shouldPause).toBe(false); - expect(result.state).toEqual({ - timelineKey: "thread-b", - anchorScrollOffset: null, - isAtEnd: false, - }); + it("recognizes directional user input without inferring intent from layout offsets", async () => { + const { timelineNavigationInputMovesTowardHistory, timelineUserScrollRequestsPause } = + 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(timelineUserScrollRequestsPause({ inputScrollOffset: 400, scrollOffset: 399.9 })).toBe( + true, + ); + expect(timelineUserScrollRequestsPause({ inputScrollOffset: 400, scrollOffset: 400 })).toBe( + false, + ); }); it("anchors a sent attachment message using its measured height", async () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 51be736ccc7..33e5fbf7091 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"; @@ -75,8 +78,8 @@ import { resolveTimelineMinimapHeightStyle, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapTopPercent, - type TimelineAutoFollowScrollState, - updateTimelineAutoFollowScrollState, + timelineNavigationInputMovesTowardHistory, + timelineUserScrollRequestsPause, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -326,11 +329,12 @@ export const MessagesTimeline = memo(function MessagesTimeline({ null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); - const autoFollowScrollStateRef = useRef({ - timelineKey: routeThreadKey, - anchorScrollOffset: null, - isAtEnd: undefined, - }); + const timelineTouchYRef = useRef(null); + const pendingTimelineNavigationRef = useRef<{ + readonly timelineKey: string; + readonly scrollOffset: number; + readonly expiresAt: number; + } | null>(null); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -360,15 +364,26 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const state = listRef.current?.getState?.(); const isAtEnd = resolveTimelineIsAtEnd(state); const scrollTop = state?.scroll ?? null; - const autoFollowUpdate = updateTimelineAutoFollowScrollState({ - state: autoFollowScrollStateRef.current, - timelineKey: routeThreadKey, - isAtEnd, - scrollOffset: scrollTop, - }); - autoFollowScrollStateRef.current = autoFollowUpdate.state; - if (autoFollowUpdate.shouldPause) { - onManualNavigation(); + const pendingNavigation = pendingTimelineNavigationRef.current; + if ( + pendingNavigation && + (pendingNavigation.timelineKey !== routeThreadKey || pendingNavigation.expiresAt < Date.now()) + ) { + pendingTimelineNavigationRef.current = null; + } else if ( + pendingNavigation && + scrollTop !== null && + scrollTop !== pendingNavigation.scrollOffset + ) { + pendingTimelineNavigationRef.current = null; + if ( + timelineUserScrollRequestsPause({ + inputScrollOffset: pendingNavigation.scrollOffset, + scrollOffset: scrollTop, + }) + ) { + onManualNavigation(); + } } if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); @@ -397,6 +412,83 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange, onManualNavigation, routeThreadKey]); + const armTimelineNavigation = useCallback(() => { + const scrollOffset = listRef.current?.getState?.().scroll; + if (typeof scrollOffset !== "number") { + return; + } + pendingTimelineNavigationRef.current = { + timelineKey: routeThreadKey, + scrollOffset, + expiresAt: Date.now() + 500, + }; + }, [listRef, routeThreadKey]); + + const handleTimelineWheelCapture = useCallback( + (event: WheelEvent) => { + if (timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: event.deltaY })) { + armTimelineNavigation(); + } + }, + [armTimelineNavigation], + ); + const handleTimelineTouchStartCapture = useCallback((event: TouchEvent) => { + timelineTouchYRef.current = event.touches[0]?.clientY ?? null; + }, []); + const handleTimelineTouchMoveCapture = useCallback( + (event: TouchEvent) => { + const currentY = event.touches[0]?.clientY ?? null; + if ( + timelineNavigationInputMovesTowardHistory({ + type: "touch", + previousY: timelineTouchYRef.current, + currentY, + }) + ) { + armTimelineNavigation(); + } + timelineTouchYRef.current = currentY; + }, + [armTimelineNavigation], + ); + const resetTimelineTouchTracking = useCallback(() => { + timelineTouchYRef.current = null; + }, []); + const handleTimelineKeyDownCapture = useCallback( + (event: KeyboardEvent) => { + const target = event.target instanceof HTMLElement ? event.target : null; + if (target?.closest("input, textarea, [contenteditable='true']")) { + return; + } + if ( + timelineNavigationInputMovesTowardHistory({ + type: "keyboard", + key: event.key, + shiftKey: event.shiftKey, + }) + ) { + armTimelineNavigation(); + } + }, + [armTimelineNavigation], + ); + 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 + ) { + armTimelineNavigation(); + } + }, + [armTimelineNavigation, listRef], + ); + useEffect(() => { const frame = requestAnimationFrame(handleScroll); return () => cancelAnimationFrame(frame); @@ -490,7 +582,17 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return ( -
+
ref={listRef} data={rows} From 422a3596c9693ac1b24eb9ce21e3d714a4348955 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:58:22 +0200 Subject: [PATCH 04/16] Simplify timeline manual navigation --- .../components/chat/MessagesTimeline.logic.ts | 21 +++-- .../components/chat/MessagesTimeline.test.tsx | 22 +++-- .../src/components/chat/MessagesTimeline.tsx | 81 ++++++++----------- 3 files changed, 62 insertions(+), 62 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index b764c8af2bd..41529c70b31 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -39,14 +39,19 @@ export function timelineNavigationInputMovesTowardHistory(input: TimelineNavigat } } -export function timelineUserScrollRequestsPause({ - inputScrollOffset, - scrollOffset, -}: { - readonly inputScrollOffset: number | null; - readonly scrollOffset: number | null; -}) { - return inputScrollOffset !== null && scrollOffset !== null && scrollOffset < inputScrollOffset; +export interface TimelineScrollableNodeState { + readonly clientHeight: number; + readonly scrollHeight: number; + readonly scrollTop: number; +} + +export function resolveTimelineScrollableNodeIsAtEnd( + scrollNode: TimelineScrollableNodeState | null | undefined, +): boolean | undefined { + if (!scrollNode) { + return undefined; + } + return scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= 0; } export interface TimelineEndState { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 16a968deffd..fcb2f6e20d6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -264,7 +264,7 @@ describe("MessagesTimeline", () => { }); it("recognizes directional user input without inferring intent from layout offsets", async () => { - const { timelineNavigationInputMovesTowardHistory, timelineUserScrollRequestsPause } = + const { resolveTimelineScrollableNodeIsAtEnd, timelineNavigationInputMovesTowardHistory } = await import("./MessagesTimeline.logic"); expect(timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: -0.1 })).toBe(true); @@ -279,12 +279,20 @@ describe("MessagesTimeline", () => { shiftKey: false, }), ).toBe(true); - expect(timelineUserScrollRequestsPause({ inputScrollOffset: 400, scrollOffset: 399.9 })).toBe( - true, - ); - expect(timelineUserScrollRequestsPause({ inputScrollOffset: 400, scrollOffset: 400 })).toBe( - false, - ); + expect( + resolveTimelineScrollableNodeIsAtEnd({ + clientHeight: 400, + scrollHeight: 800, + scrollTop: 399.9, + }), + ).toBe(false); + expect( + resolveTimelineScrollableNodeIsAtEnd({ + clientHeight: 400, + scrollHeight: 800, + scrollTop: 400, + }), + ).toBe(true); }); it("anchors a sent attachment message using its measured height", async () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 33e5fbf7091..5c53e1c1f0d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -78,8 +78,8 @@ import { resolveTimelineMinimapHeightStyle, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapTopPercent, + resolveTimelineScrollableNodeIsAtEnd, timelineNavigationInputMovesTowardHistory, - timelineUserScrollRequestsPause, type StableMessagesTimelineRowsState, type MessagesTimelineRow, TIMELINE_MINIMAP_MIN_ITEMS, @@ -330,11 +330,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const timelineTouchYRef = useRef(null); - const pendingTimelineNavigationRef = useRef<{ - readonly timelineKey: string; - readonly scrollOffset: number; - readonly expiresAt: number; - } | null>(null); + const manualNavigationTimelineKeyRef = useRef(null); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -362,28 +358,19 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); - const isAtEnd = resolveTimelineIsAtEnd(state); - const scrollTop = state?.scroll ?? null; - const pendingNavigation = pendingTimelineNavigationRef.current; if ( - pendingNavigation && - (pendingNavigation.timelineKey !== routeThreadKey || pendingNavigation.expiresAt < Date.now()) - ) { - pendingTimelineNavigationRef.current = null; - } else if ( - pendingNavigation && - scrollTop !== null && - scrollTop !== pendingNavigation.scrollOffset + manualNavigationTimelineKeyRef.current !== null && + manualNavigationTimelineKeyRef.current !== routeThreadKey ) { - pendingTimelineNavigationRef.current = null; - if ( - timelineUserScrollRequestsPause({ - inputScrollOffset: pendingNavigation.scrollOffset, - scrollOffset: scrollTop, - }) - ) { - onManualNavigation(); - } + manualNavigationTimelineKeyRef.current = null; + } + const manualNavigationActive = manualNavigationTimelineKeyRef.current === routeThreadKey; + const isAtEnd = manualNavigationActive + ? (resolveTimelineScrollableNodeIsAtEnd(listRef.current?.getScrollableNode()) ?? false) + : resolveTimelineIsAtEnd(state); + const scrollTop = state?.scroll ?? null; + if (manualNavigationActive && isAtEnd) { + manualNavigationTimelineKeyRef.current = null; } if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); @@ -410,27 +397,20 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange, onManualNavigation, routeThreadKey]); + }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange, routeThreadKey]); - const armTimelineNavigation = useCallback(() => { - const scrollOffset = listRef.current?.getState?.().scroll; - if (typeof scrollOffset !== "number") { - return; - } - pendingTimelineNavigationRef.current = { - timelineKey: routeThreadKey, - scrollOffset, - expiresAt: Date.now() + 500, - }; - }, [listRef, routeThreadKey]); + const markTimelineManualNavigation = useCallback(() => { + manualNavigationTimelineKeyRef.current = routeThreadKey; + onManualNavigation(); + }, [onManualNavigation, routeThreadKey]); const handleTimelineWheelCapture = useCallback( (event: WheelEvent) => { if (timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: event.deltaY })) { - armTimelineNavigation(); + markTimelineManualNavigation(); } }, - [armTimelineNavigation], + [markTimelineManualNavigation], ); const handleTimelineTouchStartCapture = useCallback((event: TouchEvent) => { timelineTouchYRef.current = event.touches[0]?.clientY ?? null; @@ -445,11 +425,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ currentY, }) ) { - armTimelineNavigation(); + markTimelineManualNavigation(); } timelineTouchYRef.current = currentY; }, - [armTimelineNavigation], + [markTimelineManualNavigation], ); const resetTimelineTouchTracking = useCallback(() => { timelineTouchYRef.current = null; @@ -467,10 +447,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ shiftKey: event.shiftKey, }) ) { - armTimelineNavigation(); + markTimelineManualNavigation(); } }, - [armTimelineNavigation], + [markTimelineManualNavigation], ); const handleTimelinePointerDownCapture = useCallback( (event: PointerEvent) => { @@ -481,14 +461,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const scrollbarWidth = scrollNode.offsetWidth - scrollNode.clientWidth; if ( scrollbarWidth > 0 && - event.clientX >= scrollNode.getBoundingClientRect().right - scrollbarWidth + event.clientX < scrollNode.getBoundingClientRect().right - scrollbarWidth ) { - armTimelineNavigation(); + return; } + markTimelineManualNavigation(); }, - [armTimelineNavigation, listRef], + [listRef, markTimelineManualNavigation], ); + useEffect(() => { + if (autoFollowEnabled) { + manualNavigationTimelineKeyRef.current = null; + } + }, [autoFollowEnabled, routeThreadKey]); + useEffect(() => { const frame = requestAnimationFrame(handleScroll); return () => cancelAnimationFrame(frame); From 27a84b32e890644561c4d4db9c76d6959254010f Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:12:22 +0200 Subject: [PATCH 05/16] Handle timeline resume and overlay scrollbars --- .../components/chat/MessagesTimeline.logic.ts | 14 ++- .../components/chat/MessagesTimeline.test.tsx | 30 ++++- .../src/components/chat/MessagesTimeline.tsx | 105 ++++++++++++++---- 3 files changed, 121 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 41529c70b31..95998d79a45 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -51,7 +51,19 @@ export function resolveTimelineScrollableNodeIsAtEnd( if (!scrollNode) { return undefined; } - return scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= 0; + return scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= 1; +} + +export function timelineManualNavigationReachedEnd({ + previousScrollTop, + scrollTop, + isAtEnd, +}: { + readonly previousScrollTop: number; + readonly scrollTop: number; + readonly isAtEnd: boolean; +}) { + return isAtEnd && scrollTop > previousScrollTop; } export interface TimelineEndState { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index fcb2f6e20d6..490d9d35693 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -264,8 +264,11 @@ describe("MessagesTimeline", () => { }); it("recognizes directional user input without inferring intent from layout offsets", async () => { - const { resolveTimelineScrollableNodeIsAtEnd, timelineNavigationInputMovesTowardHistory } = - await import("./MessagesTimeline.logic"); + const { + resolveTimelineScrollableNodeIsAtEnd, + timelineManualNavigationReachedEnd, + timelineNavigationInputMovesTowardHistory, + } = await import("./MessagesTimeline.logic"); expect(timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: -0.1 })).toBe(true); expect(timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: 0.1 })).toBe(false); @@ -283,9 +286,16 @@ describe("MessagesTimeline", () => { resolveTimelineScrollableNodeIsAtEnd({ clientHeight: 400, scrollHeight: 800, - scrollTop: 399.9, + scrollTop: 398.9, }), ).toBe(false); + expect( + resolveTimelineScrollableNodeIsAtEnd({ + clientHeight: 400, + scrollHeight: 800, + scrollTop: 399.9, + }), + ).toBe(true); expect( resolveTimelineScrollableNodeIsAtEnd({ clientHeight: 400, @@ -293,6 +303,20 @@ describe("MessagesTimeline", () => { 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 () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5c53e1c1f0d..3e7069e4119 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -79,6 +79,7 @@ import { resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapTopPercent, resolveTimelineScrollableNodeIsAtEnd, + timelineManualNavigationReachedEnd, timelineNavigationInputMovesTowardHistory, type StableMessagesTimelineRowsState, type MessagesTimelineRow, @@ -330,7 +331,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const timelineTouchYRef = useRef(null); - const manualNavigationTimelineKeyRef = useRef(null); + const manualNavigationRef = useRef<{ + readonly timelineKey: string; + scrollTop: number; + } | null>(null); + const pointerNavigationRef = useRef<{ + readonly pointerId: number; + readonly timelineKey: string; + scrollTop: number; + } | null>(null); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -356,21 +365,49 @@ export const MessagesTimeline = memo(function MessagesTimeline({ : undefined; }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]); + const markTimelineManualNavigation = useCallback(() => { + if (manualNavigationRef.current?.timelineKey !== routeThreadKey) { + const scrollTop = listRef.current?.getScrollableNode()?.scrollTop; + if (typeof scrollTop !== "number") { + return; + } + manualNavigationRef.current = { timelineKey: routeThreadKey, scrollTop }; + } + onManualNavigation(); + }, [listRef, onManualNavigation, routeThreadKey]); + const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); - if ( - manualNavigationTimelineKeyRef.current !== null && - manualNavigationTimelineKeyRef.current !== routeThreadKey - ) { - manualNavigationTimelineKeyRef.current = null; + const scrollNode = listRef.current?.getScrollableNode(); + const scrollTop = scrollNode?.scrollTop ?? state?.scroll ?? null; + if (manualNavigationRef.current?.timelineKey !== routeThreadKey) { + manualNavigationRef.current = null; } - const manualNavigationActive = manualNavigationTimelineKeyRef.current === routeThreadKey; - const isAtEnd = manualNavigationActive - ? (resolveTimelineScrollableNodeIsAtEnd(listRef.current?.getScrollableNode()) ?? false) - : resolveTimelineIsAtEnd(state); - const scrollTop = state?.scroll ?? null; - if (manualNavigationActive && isAtEnd) { - manualNavigationTimelineKeyRef.current = null; + if (pointerNavigationRef.current?.timelineKey !== routeThreadKey) { + pointerNavigationRef.current = null; + } + 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 = resolveTimelineScrollableNodeIsAtEnd(scrollNode) ?? false; + const isAtEnd = + manualNavigation && scrollTop !== null + ? timelineManualNavigationReachedEnd({ + previousScrollTop: manualNavigation.scrollTop, + scrollTop, + isAtEnd: scrollNodeIsAtEnd, + }) + : resolveTimelineIsAtEnd(state); + if (manualNavigation && scrollTop !== null) { + manualNavigation.scrollTop = scrollTop; + if (isAtEnd) { + manualNavigationRef.current = null; + } } if (isAtEnd !== undefined) { onIsAtEndChange(isAtEnd); @@ -379,7 +416,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ return; } - const resolvedScrollTop = scrollTop ?? 0; + const resolvedScrollTop = state?.scroll ?? 0; const scrollBottom = resolvedScrollTop + (state.scrollLength ?? 0); for (const item of minimapItems) { @@ -397,12 +434,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ strip.dataset.inView = inView ? "true" : "false"; } - }, [listRef, minimapItems, minimapStripMap, onIsAtEndChange, routeThreadKey]); - - const markTimelineManualNavigation = useCallback(() => { - manualNavigationTimelineKeyRef.current = routeThreadKey; - onManualNavigation(); - }, [onManualNavigation, routeThreadKey]); + }, [ + listRef, + markTimelineManualNavigation, + minimapItems, + minimapStripMap, + onIsAtEndChange, + routeThreadKey, + ]); const handleTimelineWheelCapture = useCallback( (event: WheelEvent) => { @@ -465,17 +504,35 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ) { return; } - markTimelineManualNavigation(); + pointerNavigationRef.current = { + pointerId: event.pointerId, + timelineKey: routeThreadKey, + scrollTop: scrollNode.scrollTop, + }; }, - [listRef, markTimelineManualNavigation], + [listRef, routeThreadKey], ); - useEffect(() => { if (autoFollowEnabled) { - manualNavigationTimelineKeyRef.current = null; + manualNavigationRef.current = null; } + pointerNavigationRef.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); return () => cancelAnimationFrame(frame); From 9e4da8c5dc7dcd36d6c50ec19432ab843928d812 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:21:11 +0200 Subject: [PATCH 06/16] Keep timeline follow on no-op navigation --- .../components/chat/MessagesTimeline.logic.ts | 8 +++++++ .../components/chat/MessagesTimeline.test.tsx | 22 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 18 ++++++++++----- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 95998d79a45..2d2b49f3843 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -45,6 +45,14 @@ export interface TimelineScrollableNodeState { readonly scrollTop: number; } +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 { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 490d9d35693..83de2d3f260 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -268,6 +268,7 @@ describe("MessagesTimeline", () => { resolveTimelineScrollableNodeIsAtEnd, timelineManualNavigationReachedEnd, timelineNavigationInputMovesTowardHistory, + timelineScrollableNodeCanNavigateTowardHistory, } = await import("./MessagesTimeline.logic"); expect(timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: -0.1 })).toBe(true); @@ -282,6 +283,27 @@ describe("MessagesTimeline", () => { 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, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3e7069e4119..16e3370e97f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -79,6 +79,7 @@ import { resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapTopPercent, resolveTimelineScrollableNodeIsAtEnd, + timelineScrollableNodeCanNavigateTowardHistory, timelineManualNavigationReachedEnd, timelineNavigationInputMovesTowardHistory, type StableMessagesTimelineRowsState, @@ -445,11 +446,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleTimelineWheelCapture = useCallback( (event: WheelEvent) => { - if (timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: event.deltaY })) { + if ( + timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: event.deltaY }) && + timelineScrollableNodeCanNavigateTowardHistory(listRef.current?.getScrollableNode()) + ) { markTimelineManualNavigation(); } }, - [markTimelineManualNavigation], + [listRef, markTimelineManualNavigation], ); const handleTimelineTouchStartCapture = useCallback((event: TouchEvent) => { timelineTouchYRef.current = event.touches[0]?.clientY ?? null; @@ -462,13 +466,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ type: "touch", previousY: timelineTouchYRef.current, currentY, - }) + }) && + timelineScrollableNodeCanNavigateTowardHistory(listRef.current?.getScrollableNode()) ) { markTimelineManualNavigation(); } timelineTouchYRef.current = currentY; }, - [markTimelineManualNavigation], + [listRef, markTimelineManualNavigation], ); const resetTimelineTouchTracking = useCallback(() => { timelineTouchYRef.current = null; @@ -484,12 +489,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ type: "keyboard", key: event.key, shiftKey: event.shiftKey, - }) + }) && + timelineScrollableNodeCanNavigateTowardHistory(listRef.current?.getScrollableNode()) ) { markTimelineManualNavigation(); } }, - [markTimelineManualNavigation], + [listRef, markTimelineManualNavigation], ); const handleTimelinePointerDownCapture = useCallback( (event: PointerEvent) => { From cbe6e4af1d85ed650716c5756449e1fd054af786 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:27:47 +0200 Subject: [PATCH 07/16] Ignore timeline shortcuts from controls --- .../src/components/chat/MessagesTimeline.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 16e3370e97f..a1df4e49633 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -154,6 +154,27 @@ const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; +const TIMELINE_KEYBOARD_INTERACTIVE_SELECTOR = [ + "button", + "a[href]", + "input", + "select", + "summary", + "textarea", + '[contenteditable]:not([contenteditable="false"])', + '[role="button"]', + '[role="checkbox"]', + '[role="combobox"]', + '[role="link"]', + '[role="menuitem"]', + '[role="option"]', + '[role="radio"]', + '[role="slider"]', + '[role="spinbutton"]', + '[role="switch"]', + '[role="tab"]', + '[role="textbox"]', +].join(","); // --------------------------------------------------------------------------- // Props (public API) @@ -481,7 +502,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleTimelineKeyDownCapture = useCallback( (event: KeyboardEvent) => { const target = event.target instanceof HTMLElement ? event.target : null; - if (target?.closest("input, textarea, [contenteditable='true']")) { + if (target?.closest(TIMELINE_KEYBOARD_INTERACTIVE_SELECTOR)) { return; } if ( From 1ac3f532480c27a4635b44d2d18a06df1cdee30a Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:37:50 +0200 Subject: [PATCH 08/16] Preserve timeline keyboard scrolling --- .../src/components/chat/MessagesTimeline.tsx | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a1df4e49633..6fbc05c7165 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -154,26 +154,29 @@ const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; -const TIMELINE_KEYBOARD_INTERACTIVE_SELECTOR = [ - "button", - "a[href]", +const TIMELINE_KEYBOARD_INPUT_SELECTOR = [ "input", "select", - "summary", "textarea", '[contenteditable]:not([contenteditable="false"])', + '[role="combobox"]', + '[role="listbox"]', + '[role="option"]', + '[role="slider"]', + '[role="spinbutton"]', + '[role="textbox"]', +].join(","); +const TIMELINE_KEYBOARD_ACTIVATION_SELECTOR = [ + "button", + "a[href]", + "summary", '[role="button"]', '[role="checkbox"]', - '[role="combobox"]', '[role="link"]', '[role="menuitem"]', - '[role="option"]', '[role="radio"]', - '[role="slider"]', - '[role="spinbutton"]', '[role="switch"]', '[role="tab"]', - '[role="textbox"]', ].join(","); // --------------------------------------------------------------------------- @@ -502,7 +505,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleTimelineKeyDownCapture = useCallback( (event: KeyboardEvent) => { const target = event.target instanceof HTMLElement ? event.target : null; - if (target?.closest(TIMELINE_KEYBOARD_INTERACTIVE_SELECTOR)) { + if ( + target?.closest(TIMELINE_KEYBOARD_INPUT_SELECTOR) || + (event.key === " " && target?.closest(TIMELINE_KEYBOARD_ACTIVATION_SELECTOR)) + ) { return; } if ( From 3b934065876c4f31fb2822fbb2c040a64d9afa94 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:48:43 +0200 Subject: [PATCH 09/16] Confirm keyboard timeline navigation --- .../src/components/chat/MessagesTimeline.tsx | 63 +++++++++---------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6fbc05c7165..20029e7daa9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -154,30 +154,6 @@ const TimelineRowActivityCtx = createContext(null!); const TIMELINE_LIST_HEADER =
; const TIMELINE_LIST_FOOTER =
; const EMPTY_TIMELINE_SKILLS: ReadonlyArray> = []; -const TIMELINE_KEYBOARD_INPUT_SELECTOR = [ - "input", - "select", - "textarea", - '[contenteditable]:not([contenteditable="false"])', - '[role="combobox"]', - '[role="listbox"]', - '[role="option"]', - '[role="slider"]', - '[role="spinbutton"]', - '[role="textbox"]', -].join(","); -const TIMELINE_KEYBOARD_ACTIVATION_SELECTOR = [ - "button", - "a[href]", - "summary", - '[role="button"]', - '[role="checkbox"]', - '[role="link"]', - '[role="menuitem"]', - '[role="radio"]', - '[role="switch"]', - '[role="tab"]', -].join(","); // --------------------------------------------------------------------------- // Props (public API) @@ -365,6 +341,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ readonly timelineKey: string; scrollTop: number; } | null>(null); + const keyboardNavigationFrameRef = useRef(null); const handleAnchorReady = useCallback( (info: { anchorIndex: number | undefined }) => { if (anchorMessageId !== null && info.anchorIndex !== undefined) { @@ -504,23 +481,29 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }, []); const handleTimelineKeyDownCapture = useCallback( (event: KeyboardEvent) => { - const target = event.target instanceof HTMLElement ? event.target : null; if ( - target?.closest(TIMELINE_KEYBOARD_INPUT_SELECTOR) || - (event.key === " " && target?.closest(TIMELINE_KEYBOARD_ACTIVATION_SELECTOR)) - ) { - return; - } - if ( - timelineNavigationInputMovesTowardHistory({ + !timelineNavigationInputMovesTowardHistory({ type: "keyboard", key: event.key, shiftKey: event.shiftKey, - }) && - timelineScrollableNodeCanNavigateTowardHistory(listRef.current?.getScrollableNode()) + }) ) { - markTimelineManualNavigation(); + return; } + const scrollNode = listRef.current?.getScrollableNode(); + if (!scrollNode || !timelineScrollableNodeCanNavigateTowardHistory(scrollNode)) { + return; + } + if (keyboardNavigationFrameRef.current !== null) { + cancelAnimationFrame(keyboardNavigationFrameRef.current); + } + const scrollTop = scrollNode.scrollTop; + keyboardNavigationFrameRef.current = requestAnimationFrame(() => { + keyboardNavigationFrameRef.current = null; + if (scrollNode.scrollTop < scrollTop) { + markTimelineManualNavigation(); + } + }); }, [listRef, markTimelineManualNavigation], ); @@ -550,6 +533,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ manualNavigationRef.current = null; } pointerNavigationRef.current = null; + if (keyboardNavigationFrameRef.current !== null) { + cancelAnimationFrame(keyboardNavigationFrameRef.current); + keyboardNavigationFrameRef.current = null; + } + return () => { + if (keyboardNavigationFrameRef.current !== null) { + cancelAnimationFrame(keyboardNavigationFrameRef.current); + keyboardNavigationFrameRef.current = null; + } + }; }, [autoFollowEnabled, routeThreadKey]); useEffect(() => { From 0e32afa66927610431cd4f56a0cc949d117acc16 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:03:24 +0200 Subject: [PATCH 10/16] Suspend timeline follow for keyboard intent --- apps/web/src/components/chat/MessagesTimeline.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 20029e7daa9..89b136002a0 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -331,6 +331,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); + const [keyboardNavigationTimelineKey, setKeyboardNavigationTimelineKey] = useState( + null, + ); const timelineTouchYRef = useRef(null); const manualNavigationRef = useRef<{ readonly timelineKey: string; @@ -498,14 +501,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ cancelAnimationFrame(keyboardNavigationFrameRef.current); } const scrollTop = scrollNode.scrollTop; + flushSync(() => setKeyboardNavigationTimelineKey(routeThreadKey)); keyboardNavigationFrameRef.current = requestAnimationFrame(() => { keyboardNavigationFrameRef.current = null; if (scrollNode.scrollTop < scrollTop) { markTimelineManualNavigation(); } + setKeyboardNavigationTimelineKey((timelineKey) => + timelineKey === routeThreadKey ? null : timelineKey, + ); }); }, - [listRef, markTimelineManualNavigation], + [listRef, markTimelineManualNavigation, routeThreadKey], ); const handleTimelinePointerDownCapture = useCallback( (event: PointerEvent) => { @@ -537,6 +544,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ cancelAnimationFrame(keyboardNavigationFrameRef.current); keyboardNavigationFrameRef.current = null; } + setKeyboardNavigationTimelineKey(null); return () => { if (keyboardNavigationFrameRef.current !== null) { cancelAnimationFrame(keyboardNavigationFrameRef.current); @@ -674,7 +682,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} maintainScrollAtEnd={ - anchoredEndSpace || !autoFollowEnabled + anchoredEndSpace || + !autoFollowEnabled || + keyboardNavigationTimelineKey === routeThreadKey ? false : { animated: false, From d4c9f70d4819a95a2264ebdede577d13186b8c64 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:03:15 +0200 Subject: [PATCH 11/16] Guard timeline follow resume --- apps/web/src/components/ChatView.tsx | 74 ++++++++++++------- .../src/components/chat/MessagesTimeline.tsx | 24 ++++-- .../chat/timelineScrollAnchoring.test.tsx | 40 +++++++++- .../chat/timelineScrollAnchoring.ts | 18 +++++ 4 files changed, 121 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 69b7038d90a..95a21ea0a55 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -86,7 +86,11 @@ import { isLatestTurnSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; +import { + getAnchoredTurnMetrics, + shouldResumeTimelineAutoFollow, + type TimelineScrollMode, +} from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -3491,31 +3495,49 @@ function ChatViewContent(props: ChatViewProps) { }); }, []); - const onIsAtEndChange = useCallback((isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - return; - } - if (isAtEnd) { - 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(); - } - }, []); + 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) { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3c5ce33645..62c7e0363ec 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -185,7 +185,10 @@ interface MessagesTimelineProps { onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; autoFollowEnabled: boolean; - onIsAtEndChange: (isAtEnd: boolean) => void; + onIsAtEndChange: (change: { + readonly isAtEnd: boolean; + readonly manualNavigationReachedEnd: boolean; + }) => void; onManualNavigation: () => void; hideEmptyPlaceholder?: boolean; } @@ -402,13 +405,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } const manualNavigation = manualNavigationRef.current; const scrollNodeIsAtEnd = resolveTimelineScrollableNodeIsAtEnd(scrollNode) ?? false; + const manualNavigationReachedEnd = Boolean( + manualNavigation && + scrollTop !== null && + timelineManualNavigationReachedEnd({ + previousScrollTop: manualNavigation.scrollTop, + scrollTop, + isAtEnd: scrollNodeIsAtEnd, + }), + ); const isAtEnd = manualNavigation && scrollTop !== null - ? timelineManualNavigationReachedEnd({ - previousScrollTop: manualNavigation.scrollTop, - scrollTop, - isAtEnd: scrollNodeIsAtEnd, - }) + ? manualNavigationReachedEnd : resolveTimelineIsAtEnd(state); if (manualNavigation && scrollTop !== null) { manualNavigation.scrollTop = scrollTop; @@ -417,7 +425,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } } if (isAtEnd !== undefined) { - onIsAtEndChange(isAtEnd); + onIsAtEndChange({ isAtEnd, manualNavigationReachedEnd }); } if (!state || minimapItems.length === 0) { return; @@ -715,7 +723,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hasPersistentGutter={minimapHasPersistentGutter} stripMap={minimapStripMap} onSelect={(item) => { - onManualNavigation(); + markTimelineManualNavigation(); void listRef.current?.scrollToIndex({ index: item.rowIndex, animated: true, diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index 1bf82c47a61..0ffcc959b42 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -1,5 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring"; +import { + getAnchoredTurnMetrics, + getRowBottom, + shouldResumeTimelineAutoFollow, +} from "./timelineScrollAnchoring"; function buildState({ positions, @@ -22,6 +26,40 @@ function buildState({ } describe("timeline scroll anchoring", () => { + 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..8ac8093ca21 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -1,5 +1,23 @@ export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; +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; From c396bd28efabe9e259d146a616ec6a3699996f9a Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:40:00 +0200 Subject: [PATCH 12/16] Confirm timeline movement before pausing follow --- .../components/chat/MessagesTimeline.logic.ts | 17 ++- .../components/chat/MessagesTimeline.test.tsx | 39 ++++++ .../src/components/chat/MessagesTimeline.tsx | 127 +++++++++++++----- 3 files changed, 148 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 0d7516328f1..1e9adefb017 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -55,11 +55,26 @@ export function timelineScrollableNodeCanNavigateTowardHistory( export function resolveTimelineScrollableNodeIsAtEnd( scrollNode: TimelineScrollableNodeState | null | undefined, + contentInsetEndAdjustment = 0, ): boolean | undefined { if (!scrollNode) { return undefined; } - return scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= 1; + const endThreshold = Math.max( + 1, + Number.isFinite(contentInsetEndAdjustment) ? contentInsetEndAdjustment : 0, + ); + return scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= endThreshold; +} + +export function timelineManualNavigationMovedTowardHistory({ + initialScrollTop, + scrollTop, +}: { + readonly initialScrollTop: number; + readonly scrollTop: number; +}) { + return scrollTop < initialScrollTop; } export function timelineManualNavigationReachedEnd({ diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index e52f800a215..7e099913877 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -290,6 +290,7 @@ describe("MessagesTimeline", () => { it("recognizes directional user input without inferring intent from layout offsets", async () => { const { resolveTimelineScrollableNodeIsAtEnd, + timelineManualNavigationMovedTowardHistory, timelineManualNavigationReachedEnd, timelineNavigationInputMovesTowardHistory, timelineScrollableNodeCanNavigateTowardHistory, @@ -342,6 +343,44 @@ describe("MessagesTimeline", () => { scrollTop: 399.9, }), ).toBe(true); + expect( + resolveTimelineScrollableNodeIsAtEnd( + { + clientHeight: 400, + scrollHeight: 800, + scrollTop: 256, + }, + 144, + ), + ).toBe(true); + expect( + resolveTimelineScrollableNodeIsAtEnd( + { + clientHeight: 400, + scrollHeight: 800, + scrollTop: 255.9, + }, + 144, + ), + ).toBe(false); + 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, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e33a438f983..1ba1c05a2eb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -82,6 +82,7 @@ import { resolveTimelineMinimapTopPercent, resolveTimelineScrollableNodeIsAtEnd, timelineScrollableNodeCanNavigateTowardHistory, + timelineManualNavigationMovedTowardHistory, timelineManualNavigationReachedEnd, timelineNavigationInputMovesTowardHistory, type StableMessagesTimelineRowsState, @@ -338,9 +339,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ null, ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); - const [keyboardNavigationTimelineKey, setKeyboardNavigationTimelineKey] = useState( - null, - ); + const [navigationProbeTimelineKey, setNavigationProbeTimelineKey] = useState(null); const timelineTouchYRef = useRef(null); const manualNavigationRef = useRef<{ readonly timelineKey: string; @@ -351,7 +350,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ readonly timelineKey: string; scrollTop: number; } | null>(null); - const keyboardNavigationFrameRef = useRef(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 }) => { @@ -389,6 +392,49 @@ export const MessagesTimeline = memo(function MessagesTimeline({ 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 scrollNode = listRef.current?.getScrollableNode(); @@ -399,6 +445,24 @@ export const MessagesTimeline = memo(function MessagesTimeline({ 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(); @@ -407,7 +471,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ pointerNavigation.scrollTop = scrollTop; } const manualNavigation = manualNavigationRef.current; - const scrollNodeIsAtEnd = resolveTimelineScrollableNodeIsAtEnd(scrollNode) ?? false; + const scrollNodeIsAtEnd = + resolveTimelineScrollableNodeIsAtEnd(scrollNode, contentInsetEndAdjustment) ?? false; const manualNavigationReachedEnd = Boolean( manualNavigation && scrollTop !== null && @@ -454,6 +519,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } }, [ listRef, + contentInsetEndAdjustment, + finishTimelineNavigationProbe, markTimelineManualNavigation, minimapItems, minimapStripMap, @@ -463,14 +530,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleTimelineWheelCapture = useCallback( (event: WheelEvent) => { + const scrollNode = listRef.current?.getScrollableNode(); if ( timelineNavigationInputMovesTowardHistory({ type: "wheel", deltaY: event.deltaY }) && - timelineScrollableNodeCanNavigateTowardHistory(listRef.current?.getScrollableNode()) + scrollNode && + timelineScrollableNodeCanNavigateTowardHistory(scrollNode) ) { - markTimelineManualNavigation(); + beginTimelineNavigationProbe(scrollNode); } }, - [listRef, markTimelineManualNavigation], + [beginTimelineNavigationProbe, listRef], ); const handleTimelineTouchStartCapture = useCallback((event: TouchEvent) => { timelineTouchYRef.current = event.touches[0]?.clientY ?? null; @@ -478,19 +547,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ 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, }) && - timelineScrollableNodeCanNavigateTowardHistory(listRef.current?.getScrollableNode()) + scrollNode && + timelineScrollableNodeCanNavigateTowardHistory(scrollNode) ) { - markTimelineManualNavigation(); + beginTimelineNavigationProbe(scrollNode); } timelineTouchYRef.current = currentY; }, - [listRef, markTimelineManualNavigation], + [beginTimelineNavigationProbe, listRef], ); const resetTimelineTouchTracking = useCallback(() => { timelineTouchYRef.current = null; @@ -510,22 +581,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (!scrollNode || !timelineScrollableNodeCanNavigateTowardHistory(scrollNode)) { return; } - if (keyboardNavigationFrameRef.current !== null) { - cancelAnimationFrame(keyboardNavigationFrameRef.current); - } - const scrollTop = scrollNode.scrollTop; - flushSync(() => setKeyboardNavigationTimelineKey(routeThreadKey)); - keyboardNavigationFrameRef.current = requestAnimationFrame(() => { - keyboardNavigationFrameRef.current = null; - if (scrollNode.scrollTop < scrollTop) { - markTimelineManualNavigation(); - } - setKeyboardNavigationTimelineKey((timelineKey) => - timelineKey === routeThreadKey ? null : timelineKey, - ); - }); + beginTimelineNavigationProbe(scrollNode); }, - [listRef, markTimelineManualNavigation, routeThreadKey], + [beginTimelineNavigationProbe, listRef], ); const handleTimelinePointerDownCapture = useCallback( (event: PointerEvent) => { @@ -553,15 +611,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ manualNavigationRef.current = null; } pointerNavigationRef.current = null; - if (keyboardNavigationFrameRef.current !== null) { - cancelAnimationFrame(keyboardNavigationFrameRef.current); - keyboardNavigationFrameRef.current = null; + navigationProbeRef.current = null; + if (navigationProbeFrameRef.current !== null) { + cancelAnimationFrame(navigationProbeFrameRef.current); + navigationProbeFrameRef.current = null; } - setKeyboardNavigationTimelineKey(null); + setNavigationProbeTimelineKey(null); return () => { - if (keyboardNavigationFrameRef.current !== null) { - cancelAnimationFrame(keyboardNavigationFrameRef.current); - keyboardNavigationFrameRef.current = null; + if (navigationProbeFrameRef.current !== null) { + cancelAnimationFrame(navigationProbeFrameRef.current); + navigationProbeFrameRef.current = null; } }; }, [autoFollowEnabled, routeThreadKey]); @@ -701,7 +760,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ maintainScrollAtEnd={ anchoredEndSpace || !autoFollowEnabled || - keyboardNavigationTimelineKey === routeThreadKey + navigationProbeTimelineKey === routeThreadKey ? false : { animated: false, From f7cb6952f1afec621db6f2783416ce02f299b04a Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:48:24 +0200 Subject: [PATCH 13/16] Use exact list end state for follow resume --- .../components/chat/MessagesTimeline.logic.ts | 14 ++++++++------ .../components/chat/MessagesTimeline.test.tsx | 18 +++++++++++++----- .../src/components/chat/MessagesTimeline.tsx | 6 ++---- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 1e9adefb017..1a8f3a1edfc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -55,16 +55,11 @@ export function timelineScrollableNodeCanNavigateTowardHistory( export function resolveTimelineScrollableNodeIsAtEnd( scrollNode: TimelineScrollableNodeState | null | undefined, - contentInsetEndAdjustment = 0, ): boolean | undefined { if (!scrollNode) { return undefined; } - const endThreshold = Math.max( - 1, - Number.isFinite(contentInsetEndAdjustment) ? contentInsetEndAdjustment : 0, - ); - return scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= endThreshold; + return scrollNode.scrollHeight - scrollNode.scrollTop - scrollNode.clientHeight <= 1; } export function timelineManualNavigationMovedTowardHistory({ @@ -99,6 +94,13 @@ export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boo 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 { const naturalHeight = Math.max(1, (itemCount - 1) * TIMELINE_MINIMAP_ITEM_SPACING); return `min(${naturalHeight}px, ${TIMELINE_MINIMAP_MAX_HEIGHT_CSS})`; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 7e099913877..a16e8f656a7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -290,6 +290,7 @@ describe("MessagesTimeline", () => { it("recognizes directional user input without inferring intent from layout offsets", async () => { const { resolveTimelineScrollableNodeIsAtEnd, + resolveTimelineManualNavigationIsAtEnd, timelineManualNavigationMovedTowardHistory, timelineManualNavigationReachedEnd, timelineNavigationInputMovesTowardHistory, @@ -344,25 +345,32 @@ describe("MessagesTimeline", () => { }), ).toBe(true); expect( - resolveTimelineScrollableNodeIsAtEnd( + resolveTimelineManualNavigationIsAtEnd( + { isAtEnd: true, isWithinMaintainScrollAtEndThreshold: false }, { clientHeight: 400, scrollHeight: 800, scrollTop: 256, }, - 144, ), ).toBe(true); expect( - resolveTimelineScrollableNodeIsAtEnd( + resolveTimelineManualNavigationIsAtEnd( + { isAtEnd: false, isWithinMaintainScrollAtEndThreshold: true }, { clientHeight: 400, scrollHeight: 800, - scrollTop: 255.9, + scrollTop: 400, }, - 144, ), ).toBe(false); + expect( + resolveTimelineManualNavigationIsAtEnd(undefined, { + clientHeight: 400, + scrollHeight: 800, + scrollTop: 400, + }), + ).toBe(true); expect( timelineManualNavigationMovedTowardHistory({ initialScrollTop: 400, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1ba1c05a2eb..b11569100dc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -74,13 +74,13 @@ import { normalizeCompactToolLabel, resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, + resolveTimelineManualNavigationIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, resolveTimelineMinimapIndexFromPointer, resolveTimelineMinimapInteractiveWidth, resolveTimelineMinimapTopPercent, - resolveTimelineScrollableNodeIsAtEnd, timelineScrollableNodeCanNavigateTowardHistory, timelineManualNavigationMovedTowardHistory, timelineManualNavigationReachedEnd, @@ -471,8 +471,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ pointerNavigation.scrollTop = scrollTop; } const manualNavigation = manualNavigationRef.current; - const scrollNodeIsAtEnd = - resolveTimelineScrollableNodeIsAtEnd(scrollNode, contentInsetEndAdjustment) ?? false; + const scrollNodeIsAtEnd = resolveTimelineManualNavigationIsAtEnd(state, scrollNode); const manualNavigationReachedEnd = Boolean( manualNavigation && scrollTop !== null && @@ -519,7 +518,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } }, [ listRef, - contentInsetEndAdjustment, finishTimelineNavigationProbe, markTimelineManualNavigation, minimapItems, From 40c0a4e73f7af203bc867c077959245aacb15d29 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:02:59 +0200 Subject: [PATCH 14/16] Keep timeline recovery available after navigation --- apps/web/src/components/ChatView.tsx | 3 +++ .../src/components/chat/MessagesTimeline.logic.ts | 12 ++++++++++++ .../src/components/chat/MessagesTimeline.test.tsx | 4 ++++ apps/web/src/components/chat/MessagesTimeline.tsx | 15 +++++++++------ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 95a21ea0a55..8c18d5a0667 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3329,9 +3329,12 @@ function ChatViewContent(props: ChatViewProps) { 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; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 1a8f3a1edfc..8983bf73f2d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -45,6 +45,18 @@ export interface TimelineScrollableNodeState { 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 { diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index e5b2cc4566e..793f6f54b0e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -343,6 +343,7 @@ describe("MessagesTimeline", () => { const { resolveTimelineScrollableNodeIsAtEnd, resolveTimelineManualNavigationIsAtEnd, + resolveTimelineManualNavigationScrollTop, timelineManualNavigationMovedTowardHistory, timelineManualNavigationReachedEnd, timelineNavigationInputMovesTowardHistory, @@ -423,6 +424,9 @@ describe("MessagesTimeline", () => { 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, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6871ebf60e3..f4cf90c700b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -78,6 +78,7 @@ import { resolveAssistantMessageCopyState, resolveTimelineIsAtEnd, resolveTimelineManualNavigationIsAtEnd, + resolveTimelineManualNavigationScrollTop, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, resolveTimelineMinimapHitStripWidth, @@ -346,7 +347,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const timelineTouchYRef = useRef(null); const manualNavigationRef = useRef<{ readonly timelineKey: string; - scrollTop: number; + scrollTop: number | null; } | null>(null); const pointerNavigationRef = useRef<{ readonly pointerId: number; @@ -386,10 +387,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const markTimelineManualNavigation = useCallback(() => { if (manualNavigationRef.current?.timelineKey !== routeThreadKey) { - const scrollTop = listRef.current?.getScrollableNode()?.scrollTop; - if (typeof scrollTop !== "number") { - return; - } + const list = listRef.current; + const scrollTop = resolveTimelineManualNavigationScrollTop( + list?.getScrollableNode(), + list?.getState?.(), + ); manualNavigationRef.current = { timelineKey: routeThreadKey, scrollTop }; } onManualNavigation(); @@ -441,7 +443,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); const scrollNode = listRef.current?.getScrollableNode(); - const scrollTop = scrollNode?.scrollTop ?? state?.scroll ?? null; + const scrollTop = resolveTimelineManualNavigationScrollTop(scrollNode, state); if (manualNavigationRef.current?.timelineKey !== routeThreadKey) { manualNavigationRef.current = null; } @@ -477,6 +479,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const scrollNodeIsAtEnd = resolveTimelineManualNavigationIsAtEnd(state, scrollNode); const manualNavigationReachedEnd = Boolean( manualNavigation && + manualNavigation.scrollTop !== null && scrollTop !== null && timelineManualNavigationReachedEnd({ previousScrollTop: manualNavigation.scrollTop, From 4c3da130ee2f421d9994f5616b606b889b45af1e Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:13:58 +0200 Subject: [PATCH 15/16] Harden timeline navigation transitions --- apps/web/src/components/ChatView.tsx | 12 +++++++++-- .../src/components/chat/MessagesTimeline.tsx | 21 ++++++++++++------- .../chat/timelineScrollAnchoring.test.tsx | 18 ++++++++++++++++ .../chat/timelineScrollAnchoring.ts | 12 +++++++++++ 4 files changed, 53 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8c18d5a0667..28c0f4e0e5e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -88,6 +88,7 @@ import { import { type LegendListRef } from "@legendapp/list/react"; import { getAnchoredTurnMetrics, + resolveTimelineAutoFollowEnabledForRoute, shouldResumeTimelineAutoFollow, type TimelineScrollMode, } from "./chat/timelineScrollAnchoring"; @@ -3308,6 +3309,12 @@ function ChatViewContent(props: ChatViewProps) { 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); @@ -3611,6 +3618,7 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; + timelineAutoFollowRouteKeyRef.current = routeThreadKey; setTimelineAutoFollowEnabled(true); timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; @@ -3628,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). @@ -5280,7 +5288,7 @@ function ChatViewContent(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={composerOverlayHeight} - autoFollowEnabled={timelineAutoFollowEnabled} + autoFollowEnabled={timelineAutoFollowEnabledForRoute} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} hideEmptyPlaceholder={isDraftHeroState} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f4cf90c700b..0758c563071 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -610,6 +610,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }, [listRef, routeThreadKey], ); + const handleTimelineMinimapSelect = useCallback( + (item: TimelineMinimapItem) => { + flushSync(() => setNavigationProbeTimelineKey(routeThreadKey)); + markTimelineManualNavigation(); + void listRef.current?.scrollToIndex({ + index: item.rowIndex, + animated: true, + viewOffset: 24, + }); + }, + [listRef, markTimelineManualNavigation, routeThreadKey], + ); useEffect(() => { if (autoFollowEnabled) { manualNavigationRef.current = null; @@ -790,14 +802,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hasPersistentGutter={minimapHasPersistentGutter} hitStripWidth={minimapHitStripWidth} stripMap={minimapStripMap} - onSelect={(item) => { - markTimelineManualNavigation(); - 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 0ffcc959b42..23018d126eb 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { getAnchoredTurnMetrics, getRowBottom, + resolveTimelineAutoFollowEnabledForRoute, shouldResumeTimelineAutoFollow, } from "./timelineScrollAnchoring"; @@ -26,6 +27,23 @@ 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({ diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 8ac8093ca21..f68c5267149 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -1,5 +1,17 @@ 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, From 7be974225c95dc8e9c05a82875b42709ceecfea1 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:21:13 +0200 Subject: [PATCH 16/16] Clear temporary minimap follow guard --- apps/web/src/components/chat/MessagesTimeline.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 0758c563071..d88b401ca47 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -619,6 +619,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ animated: true, viewOffset: 24, }); + setNavigationProbeTimelineKey((timelineKey) => + timelineKey === routeThreadKey ? null : timelineKey, + ); }, [listRef, markTimelineManualNavigation, routeThreadKey], );