From a2a1053862a6f0e36d4e3efdefcc51323e4a2f85 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:27:19 +0000 Subject: [PATCH 1/2] fix(web): preserve thread reading positions --- apps/web/src/components/ChatView.tsx | 31 +-- .../src/components/chat/MessagesTimeline.tsx | 205 ++++++++++++++++-- .../chat/timelineScrollAnchoring.test.tsx | 18 ++ .../chat/timelineScrollAnchoring.ts | 35 +++ 4 files changed, 251 insertions(+), 38 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6b372437f2ae..8a28ccf7d6f7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -136,6 +136,7 @@ import { type LegendListRef } from "@legendapp/list/react"; import { CHAT_TIMELINE_ANCHOR_OFFSET, getAnchoredTurnMetrics, + readTimelinePosition, timelineContentOverflowsViewport, type TimelineScrollMode, } from "./chat/timelineScrollAnchoring"; @@ -5320,21 +5321,6 @@ export default function ChatView(props: ChatViewProps) { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); - const displayedTimelineKeyRef = useRef(displayedTimeline.displayThreadKey); - useLayoutEffect(() => { - const displayKey = displayedTimeline.displayThreadKey; - if (displayKey === null || displayKey !== activeThreadKey) { - displayedTimelineKeyRef.current = displayKey; - return; - } - if (displayedTimelineKeyRef.current === displayKey) { - return; - } - displayedTimelineKeyRef.current = displayKey; - // Keep the list mounted across jumps; pin the newly displayed thread to - // its end the way a remount used to via initialScrollAtEnd. - scrollToEnd(); - }, [activeThreadKey, displayedTimeline.displayThreadKey, scrollToEnd]); useLayoutEffect(() => { if (timelineScrollModeRef.current !== "anchoring-new-turn") { return; @@ -5644,19 +5630,22 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { setPullRequestDialogState(null); - isAtEndRef.current = true; + const followEnd = readTimelinePosition(routeThreadKey)?.atEnd !== false; + isAtEndRef.current = followEnd; timelineScrollIntentRef.current = null; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - setTimelineLiveFollowEnabled(true); + timelineScrollModeRef.current = followEnd ? "following-end" : "free-scrolling"; + liveFollowUserScrollGenerationRef.current = followEnd + ? anchorUserScrollGenerationRef.current + : null; + setTimelineLiveFollowEnabled(followEnd); pendingTimelineAnchorRef.current = null; positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); + setShowScrollToBottom(!followEnd); // activeThreadRef resets transitively with the active thread. - }, [activeThread?.id]); + }, [activeThread?.id, routeThreadKey]); useEffect(() => { if (!activeThread?.id || terminalUiState.terminalOpen) return; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5807541c4cd2..a084e6e3ab4d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -151,6 +151,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesCard } from "./ChangedFilesTree"; import { CHAT_TIMELINE_ANCHOR_OFFSET, + readTimelinePosition, + rememberTimelinePosition, timelineContentOverflowsViewport, } from "./timelineScrollAnchoring"; import { MessageCopyButton } from "./MessageCopyButton"; @@ -514,16 +516,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ steerQueuedMessageShortcutLabel = null, onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, }: MessagesTimelineProps) { - const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); - const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); - // Preserve member disclosure state across virtualization. + const listIdentityKey = displayThreadKey ?? routeThreadKey; + const rememberedPosition = useMemo( + () => readTimelinePosition(listIdentityKey), + [listIdentityKey], + ); + const [expandedTurnIds, setExpandedTurnIds] = useState>( + () => rememberedPosition?.disclosures?.turns ?? new Set(), + ); + const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>( + () => rememberedPosition?.disclosures?.workGroups ?? new Set(), + ); const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState>( - new Set(), + () => rememberedPosition?.disclosures?.spawnEntries ?? new Set(), ); const [expandedReasoningMessageIds, setExpandedReasoningMessageIds] = useState< ReadonlySet - >(new Set()); - const listIdentityKey = displayThreadKey ?? routeThreadKey; + >(() => rememberedPosition?.disclosures?.reasoningMessages ?? new Set()); + const [positionedThreadKey, setPositionedThreadKey] = useState(() => + rememberedPosition?.atEnd === false ? null : listIdentityKey, + ); + const restoringThreadPosition = positionedThreadKey !== listIdentityKey; const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); const listIdentityRef = useRef(listIdentityKey); const previousLatestTurnRef = useRef(latestTurn); @@ -536,12 +549,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ let paintedExpandedReasoningMessageIds = expandedReasoningMessageIds; if (listIdentityRef.current !== listIdentityKey) { listIdentityRef.current = listIdentityKey; + setPositionedThreadKey(null); previousLatestTurnRef.current = latestTurn; setSettlingListIdentity(listIdentityKey); - paintedExpandedTurnIds = new Set(); - paintedExpandedWorkGroupIds = new Set(); - paintedExpandedSpawnEntryIds = new Set(); - paintedExpandedReasoningMessageIds = new Set(); + paintedExpandedTurnIds = rememberedPosition?.disclosures?.turns ?? new Set(); + paintedExpandedWorkGroupIds = rememberedPosition?.disclosures?.workGroups ?? new Set(); + paintedExpandedSpawnEntryIds = rememberedPosition?.disclosures?.spawnEntries ?? new Set(); + paintedExpandedReasoningMessageIds = + rememberedPosition?.disclosures?.reasoningMessages ?? new Set(); setExpandedTurnIds(paintedExpandedTurnIds); setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds); @@ -563,10 +578,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ current.has(turnId) ? current : new Set([...current, turnId]), ); }, []); - // Scroll/disclosure state outlives virtualized rows, but never the current thread. + // Nested tool state shares the bounded thread-position cache. const workGroupViewState = useMemo( - () => ({ scrollPositions: new Map(), expandedEntries: new Set() }), - [listIdentityKey], + () => + rememberedPosition?.disclosures?.workGroupState ?? { + scrollPositions: new Map(), + expandedEntries: new Set(), + }, + [listIdentityKey, rememberedPosition], ); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map()); @@ -784,6 +803,122 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ]); const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); + const restoreRowIndex = + restoringThreadPosition && rememberedPosition?.atEnd === false + ? rows.findIndex((row) => row.id === rememberedPosition.rowId) + : -1; + const restoringAlwaysRender = useMemo( + () => + restoringThreadPosition && restoreRowIndex >= 0 ? { indices: [restoreRowIndex] } : undefined, + [restoreRowIndex, restoringThreadPosition], + ); + useLayoutEffect(() => { + if (!restoringThreadPosition || rows.length === 0) return; + const list = listRef.current; + if (!list) return; + if (citationRequest !== null) { + setPositionedThreadKey(listIdentityKey); + return; + } + let cancelled = false; + let settleFrame: number | null = null; + const viewport: HTMLElement | null = list.getScrollableNode(); + const cancelForNavigation = () => { + if (cancelled) return; + cancelled = true; + if (settleFrame !== null) cancelAnimationFrame(settleFrame); + // Supersede any pending estimated-index scroll before the browser applies the gesture. + if (viewport) void list.scrollToOffset({ offset: viewport.scrollTop, animated: false }); + onManualNavigation(); + setPositionedThreadKey(listIdentityKey); + }; + const onScrollKey = (event: globalThis.KeyboardEvent) => { + if ( + ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key) && + !( + event.target instanceof Element && + event.target.closest("input, textarea, [contenteditable=true]") + ) + ) + cancelForNavigation(); + }; + viewport?.addEventListener("wheel", cancelForNavigation, { passive: true }); + viewport?.addEventListener("touchmove", cancelForNavigation, { passive: true }); + viewport?.addEventListener("pointerdown", cancelForNavigation, { passive: true }); + viewport?.ownerDocument.addEventListener("keydown", onScrollKey); + const position = rememberedPosition; + const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1; + if (position?.atEnd === false) onManualNavigation(); + const scrolling = + position?.atEnd === false + ? index >= 0 + ? list.scrollToIndex({ + index, + animated: false, + viewPosition: 0, + viewOffset: -position.offsetWithinRow, + }) + : list.scrollToOffset({ offset: position.scrollOffset, animated: false }) + : list.scrollToEnd({ animated: false }); + void Promise.resolve(scrolling).then(() => { + if (cancelled) return; + if (position?.atEnd !== false || index < 0) { + setPositionedThreadKey(listIdentityKey); + return; + } + // Index scrolling starts from estimates. Keep the saved row mounted + // until its measured position and the DOM agree for two layout frames. + let stableFrames = 0; + const reconcile = () => { + if (cancelled) return; + const state = list.getState(); + const rowIndex = state.indexByKey(position.rowId); + const row = rowIndex === undefined ? undefined : state.elementAtIndex(rowIndex); + const element = list.getScrollableNode(); + if (!row || !element) return; + const offset = Math.max( + 0, + Math.min( + element.scrollTop + + row.getBoundingClientRect().top - + element.getBoundingClientRect().top + + position.offsetWithinRow, + element.scrollHeight - element.clientHeight, + ), + ); + if (Math.abs(element.scrollTop - offset) > 1) { + stableFrames = 0; + void list.scrollToOffset({ offset, animated: false }).then(() => { + if (!cancelled) settleFrame = requestAnimationFrame(reconcile); + }); + return; + } + if (++stableFrames >= 2) { + setPositionedThreadKey(listIdentityKey); + } else { + settleFrame = requestAnimationFrame(reconcile); + } + }; + settleFrame = requestAnimationFrame(reconcile); + }); + return () => { + cancelled = true; + if (settleFrame !== null) cancelAnimationFrame(settleFrame); + viewport?.removeEventListener("wheel", cancelForNavigation); + viewport?.removeEventListener("touchmove", cancelForNavigation); + viewport?.removeEventListener("pointerdown", cancelForNavigation); + viewport?.ownerDocument.removeEventListener("keydown", onScrollKey); + }; + }, [ + citationRequest, + listIdentityKey, + listRef, + onManualNavigation, + rememberedPosition, + restoringThreadPosition, + rows, + ]); + const [timelineViewportElement, setTimelineViewportElement] = useState( null, ); @@ -804,6 +939,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onManualNavigation, }); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); + const alwaysRender = citationAlwaysRender ?? restoringAlwaysRender; const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); const [minimapCurrentIndex, setMinimapCurrentIndex] = useState(null); const handleAnchorReady = useCallback( @@ -866,7 +1002,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); + if (restoringThreadPosition || state?.data !== rows) return; const isAtEnd = resolveTimelineIsAtEnd(state); + const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined; + if (position && state && isAtEnd !== undefined) { + const index = state.indexByKey(position.rowId); + const row = index === undefined ? undefined : state.elementAtIndex(index); + const element = listRef.current?.getScrollableNode(); + if (row && element) { + rememberTimelinePosition(listIdentityKey, { + ...position, + // DOM geometry includes the header and the virtualizer's layout adjustment. + offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, + scrollOffset: element.scrollTop, + atEnd: isAtEnd, + disclosures: { + turns: paintedExpandedTurnIds, + workGroups: paintedExpandedWorkGroupIds, + spawnEntries: paintedExpandedSpawnEntryIds, + reasoningMessages: paintedExpandedReasoningMessageIds, + workGroupState: workGroupViewState, + }, + }); + } + } if (isAtEnd !== undefined && !citationPositioning) { onIsAtEndChange(isAtEnd); } @@ -907,6 +1066,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); }, [ citationPositioning, + paintedExpandedTurnIds, + paintedExpandedWorkGroupIds, + paintedExpandedSpawnEntryIds, + paintedExpandedReasoningMessageIds, + workGroupViewState, + rows, + listIdentityKey, + restoringThreadPosition, listRef, minimapItems, minimapStripMap, @@ -1099,15 +1266,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ getItemType={getItemType} renderItem={renderItem} estimatedItemSize={90} - initialScrollAtEnd={citationRequest === null} + initialScrollAtEnd={citationRequest === null && rememberedPosition?.atEnd !== false} // Legend needs a data refresh to mount new pins without a scroll event. - {...(readyCitationRequest ? { dataVersion: readyCitationRequest.key } : {})} - {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})} + dataVersion={readyCitationRequest?.key ?? listIdentityKey} + {...(alwaysRender ? { alwaysRender } : {})} onLoad={onCitationListLoad} {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} maintainScrollAtEnd={ citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) || anchoredEndSpace || !liveFollowEnabled || disclosureToggleSettling @@ -1117,7 +1285,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ : TIMELINE_MAINTAIN_SCROLL_AT_END } maintainVisibleContentPosition={ - citationPositioning ? false : maintainVisibleContentPosition + citationPositioning || + (restoringThreadPosition && rememberedPosition?.atEnd === false) + ? false + : maintainVisibleContentPosition } maintainScrollAtEndThreshold={1} onScroll={handleScroll} diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index e5a67d05e409..5f98bd0f12d8 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import { getAnchoredTurnMetrics, getRowBottom, + readTimelinePosition, + rememberTimelinePosition, timelineContentOverflowsViewport, } from "./timelineScrollAnchoring"; @@ -171,3 +173,19 @@ describe("timeline scroll anchoring", () => { expect(withComposer?.overflowsUsableViewport).toBe(true); }); }); + +describe("remembered timeline positions", () => { + it("keeps reading positions and end-follow independent across threads and environments", () => { + const reading = { rowId: "message-4", offsetWithinRow: 32, scrollOffset: 932, atEnd: false }; + const following = { rowId: "message-9", offsetWithinRow: 10, scrollOffset: 2010, atEnd: true }; + rememberTimelinePosition("scroll-test-a:thread-1", reading); + rememberTimelinePosition("scroll-test-a:thread-2", following); + rememberTimelinePosition("scroll-test-b:thread-1", following); + expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(reading); + expect(readTimelinePosition("scroll-test-a:thread-2")).toEqual(following); + expect(readTimelinePosition("scroll-test-b:thread-1")).toEqual(following); + expect(readTimelinePosition("scroll-test-a:unvisited")).toBeUndefined(); + rememberTimelinePosition("scroll-test-a:thread-1", following); + expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(following); + }); +}); diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 81d2c20fcb76..dabcd19dc4b5 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -1,3 +1,5 @@ +import type { TurnId } from "@t3tools/contracts"; + // Match the titlebar fade inset so draft promotion preserves the first row's position. export const CHAT_TIMELINE_ANCHOR_OFFSET = 24; @@ -104,3 +106,36 @@ export function getAnchoredTurnMetrics({ scrollDeltaToRevealEnd, }; } + +export interface RememberedTimelinePosition { + readonly rowId: string; + readonly offsetWithinRow: number; + readonly scrollOffset: number; + readonly atEnd: boolean; + readonly disclosures?: { + readonly turns: ReadonlySet; + readonly workGroups: ReadonlySet; + readonly spawnEntries: ReadonlySet; + readonly reasoningMessages: ReadonlySet; + readonly workGroupState: { + scrollPositions: Map; + expandedEntries: Set; + }; + }; +} + +// Scoped thread keys keep separate environments independent. Bound the session cache. +const rememberedTimelinePositions = new Map(); + +export function readTimelinePosition(threadKey: string) { + return rememberedTimelinePositions.get(threadKey); +} + +export function rememberTimelinePosition(threadKey: string, position: RememberedTimelinePosition) { + rememberedTimelinePositions.delete(threadKey); + rememberedTimelinePositions.set(threadKey, position); + if (rememberedTimelinePositions.size > 100) { + const oldest = rememberedTimelinePositions.keys().next().value; + if (oldest !== undefined) rememberedTimelinePositions.delete(oldest); + } +} From dc94cb83cbf4b7a473ed0bb5190b04bd00b79623 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:47:48 +0000 Subject: [PATCH 2/2] fix(web): let navigation cancel thread restoration --- apps/web/src/components/ChatView.tsx | 4 ++++ apps/web/src/components/chat/MessagesTimeline.tsx | 14 ++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8a28ccf7d6f7..bf958290382b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5223,10 +5223,12 @@ export default function ChatView(props: ChatViewProps) { const settledTimelineAnchorRef = useRef(null); const activeTimelineAnchorIndexRef = useRef(null); const anchorUserScrollGenerationRef = useRef(0); + const cancelPositionRestoreRef = useRef<(() => void) | null>(null); const liveFollowUserScrollGenerationRef = useRef(0); // Manual navigation stops live-follow without removing anchored end space. // Collapsing that space during a gesture clamps the viewport back to the end. const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { + cancelPositionRestoreRef.current?.(); anchorUserScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; @@ -5306,6 +5308,7 @@ export default function ChatView(props: ChatViewProps) { // Live-follow stays active after send/thread-open until an actual list scroll // gesture opts out. const scrollToEnd = useCallback((animated = false) => { + cancelPositionRestoreRef.current?.(); isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; @@ -9502,6 +9505,7 @@ export default function ChatView(props: ChatViewProps) { onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} + cancelPositionRestoreRef={cancelPositionRestoreRef} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} topFadeEnabled={!hasTimelineTopBanner} loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a084e6e3ab4d..c2539a8558e5 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -450,6 +450,7 @@ interface MessagesTimelineProps { onContentOverflowChange?: (overflows: boolean) => void; onToolOutputCollapsedAtEnd?: () => void; onManualNavigation: () => void; + cancelPositionRestoreRef?: React.RefObject<(() => void) | null>; hideEmptyPlaceholder?: boolean; topFadeEnabled?: boolean; /** Non-null when older turns exist beyond the loaded window. */ @@ -508,6 +509,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onContentOverflowChange, onToolOutputCollapsedAtEnd, onManualNavigation, + cancelPositionRestoreRef, hideEmptyPlaceholder = false, topFadeEnabled = false, loadEarlier = null, @@ -823,15 +825,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ let cancelled = false; let settleFrame: number | null = null; const viewport: HTMLElement | null = list.getScrollableNode(); - const cancelForNavigation = () => { + const cancelRestoration = () => { if (cancelled) return; cancelled = true; if (settleFrame !== null) cancelAnimationFrame(settleFrame); // Supersede any pending estimated-index scroll before the browser applies the gesture. if (viewport) void list.scrollToOffset({ offset: viewport.scrollTop, animated: false }); - onManualNavigation(); setPositionedThreadKey(listIdentityKey); }; + const cancelForNavigation = () => { + cancelRestoration(); + onManualNavigation(); + }; const onScrollKey = (event: globalThis.KeyboardEvent) => { if ( ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key) && @@ -849,6 +854,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const position = rememberedPosition; const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1; if (position?.atEnd === false) onManualNavigation(); + if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration; const scrolling = position?.atEnd === false ? index >= 0 @@ -903,6 +909,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }); return () => { cancelled = true; + if (cancelPositionRestoreRef?.current === cancelRestoration) { + cancelPositionRestoreRef.current = null; + } if (settleFrame !== null) cancelAnimationFrame(settleFrame); viewport?.removeEventListener("wheel", cancelForNavigation); viewport?.removeEventListener("touchmove", cancelForNavigation); @@ -911,6 +920,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ }; }, [ citationRequest, + cancelPositionRestoreRef, listIdentityKey, listRef, onManualNavigation,