authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-16 23:55:52-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 23:55:52-03:00
logf17165a76b1a782831eb6a05a4383b27283df15d
treef83e576706f1191e4e4d0686236ea54266f38d71
parent71d12d8c4a05bc50344d6e3fac1b967c6daaff84
signature Signed by PGP key B5690EEEBB952194

fix(web): preserve thread reading positions (#12144)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

4 files changed, 265 insertions(+), 38 deletions(-)

apps/web/src/components/ChatView.tsx+14-21
...@@ -136,6 +136,7 @@ import { type LegendListRef } from "@legendapp/list/react";...@@ -136,6 +136,7 @@ import { type LegendListRef } from "@legendapp/list/react";
136import {136import {
137 CHAT_TIMELINE_ANCHOR_OFFSET,137 CHAT_TIMELINE_ANCHOR_OFFSET,
138 getAnchoredTurnMetrics,138 getAnchoredTurnMetrics,
139 readTimelinePosition,
139 timelineContentOverflowsViewport,140 timelineContentOverflowsViewport,
140 type TimelineScrollMode,141 type TimelineScrollMode,
141} from "./chat/timelineScrollAnchoring";142} from "./chat/timelineScrollAnchoring";
...@@ -5260,10 +5261,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -5260,10 +5261,12 @@ export default function ChatView(props: ChatViewProps) {
5260 const settledTimelineAnchorRef = useRef<MessageId | null>(null);5261 const settledTimelineAnchorRef = useRef<MessageId | null>(null);
5261 const activeTimelineAnchorIndexRef = useRef<number | null>(null);5262 const activeTimelineAnchorIndexRef = useRef<number | null>(null);
5262 const anchorUserScrollGenerationRef = useRef(0);5263 const anchorUserScrollGenerationRef = useRef(0);
5264 const cancelPositionRestoreRef = useRef<(() => void) | null>(null);
5263 const liveFollowUserScrollGenerationRef = useRef<number | null>(0);5265 const liveFollowUserScrollGenerationRef = useRef<number | null>(0);
5264 // Manual navigation stops live-follow without removing anchored end space.5266 // Manual navigation stops live-follow without removing anchored end space.
5265 // Collapsing that space during a gesture clamps the viewport back to the end.5267 // Collapsing that space during a gesture clamps the viewport back to the end.
5266 const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {5268 const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {
5269 cancelPositionRestoreRef.current?.();
5267 anchorUserScrollGenerationRef.current += 1;5270 anchorUserScrollGenerationRef.current += 1;
5268 timelineScrollModeRef.current = "free-scrolling";5271 timelineScrollModeRef.current = "free-scrolling";
5269 liveFollowUserScrollGenerationRef.current = null;5272 liveFollowUserScrollGenerationRef.current = null;
...@@ -5343,6 +5346,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5343,6 +5346,7 @@ export default function ChatView(props: ChatViewProps) {
5343 // Live-follow stays active after send/thread-open until an actual list scroll5346 // Live-follow stays active after send/thread-open until an actual list scroll
5344 // gesture opts out.5347 // gesture opts out.
5345 const scrollToEnd = useCallback((animated = false) => {5348 const scrollToEnd = useCallback((animated = false) => {
5349 cancelPositionRestoreRef.current?.();
5346 isAtEndRef.current = true;5350 isAtEndRef.current = true;
5347 timelineScrollModeRef.current = "following-end";5351 timelineScrollModeRef.current = "following-end";
5348 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;5352 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
...@@ -5358,21 +5362,6 @@ export default function ChatView(props: ChatViewProps) {...@@ -5358,21 +5362,6 @@ export default function ChatView(props: ChatViewProps) {
5358 void legendListRef.current?.scrollToEnd?.({ animated });5362 void legendListRef.current?.scrollToEnd?.({ animated });
5359 });5363 });
5360 }, []);5364 }, []);
5361 const displayedTimelineKeyRef = useRef(displayedTimeline.displayThreadKey);
5362 useLayoutEffect(() => {
5363 const displayKey = displayedTimeline.displayThreadKey;
5364 if (displayKey === null || displayKey !== activeThreadKey) {
5365 displayedTimelineKeyRef.current = displayKey;
5366 return;
5367 }
5368 if (displayedTimelineKeyRef.current === displayKey) {
5369 return;
5370 }
5371 displayedTimelineKeyRef.current = displayKey;
5372 // Keep the list mounted across jumps; pin the newly displayed thread to
5373 // its end the way a remount used to via initialScrollAtEnd.
5374 scrollToEnd();
5375 }, [activeThreadKey, displayedTimeline.displayThreadKey, scrollToEnd]);
5376 useLayoutEffect(() => {5365 useLayoutEffect(() => {
5377 if (timelineScrollModeRef.current !== "anchoring-new-turn") {5366 if (timelineScrollModeRef.current !== "anchoring-new-turn") {
5378 return;5367 return;
...@@ -5682,19 +5671,22 @@ export default function ChatView(props: ChatViewProps) {...@@ -5682,19 +5671,22 @@ export default function ChatView(props: ChatViewProps) {
56825671
5683 useEffect(() => {5672 useEffect(() => {
5684 setPullRequestDialogState(null);5673 setPullRequestDialogState(null);
5685 isAtEndRef.current = true;5674 const followEnd = readTimelinePosition(routeThreadKey)?.atEnd !== false;
5675 isAtEndRef.current = followEnd;
5686 timelineScrollIntentRef.current = null;5676 timelineScrollIntentRef.current = null;
5687 timelineScrollModeRef.current = "following-end";5677 timelineScrollModeRef.current = followEnd ? "following-end" : "free-scrolling";
5688 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;5678 liveFollowUserScrollGenerationRef.current = followEnd
5689 setTimelineLiveFollowEnabled(true);5679 ? anchorUserScrollGenerationRef.current
5680 : null;
5681 setTimelineLiveFollowEnabled(followEnd);
5690 pendingTimelineAnchorRef.current = null;5682 pendingTimelineAnchorRef.current = null;
5691 positionedTimelineAnchorRef.current = null;5683 positionedTimelineAnchorRef.current = null;
5692 settledTimelineAnchorRef.current = null;5684 settledTimelineAnchorRef.current = null;
5693 activeTimelineAnchorIndexRef.current = null;5685 activeTimelineAnchorIndexRef.current = null;
5694 showScrollDebouncer.current.cancel();5686 showScrollDebouncer.current.cancel();
5695 setShowScrollToBottom(false);5687 setShowScrollToBottom(!followEnd);
5696 // activeThreadRef resets transitively with the active thread.5688 // activeThreadRef resets transitively with the active thread.
5697 }, [activeThread?.id]);5689 }, [activeThread?.id, routeThreadKey]);
56985690
5699 useEffect(() => {5691 useEffect(() => {
5700 if (!activeThread?.id || terminalUiState.terminalOpen) return;5692 if (!activeThread?.id || terminalUiState.terminalOpen) return;
...@@ -9551,6 +9543,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -9551,6 +9543,7 @@ export default function ChatView(props: ChatViewProps) {
9551 onContentOverflowChange={setTimelineOverflows}9543 onContentOverflowChange={setTimelineOverflows}
9552 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}9544 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}
9553 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}9545 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
9546 cancelPositionRestoreRef={cancelPositionRestoreRef}
9554 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}9547 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
9555 topFadeEnabled={!hasTimelineTopBanner}9548 topFadeEnabled={!hasTimelineTopBanner}
9556 loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns}9549 loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns}
apps/web/src/components/chat/MessagesTimeline.tsx+198-17
...@@ -153,6 +153,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard";...@@ -153,6 +153,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard";
153import { ChangedFilesCard } from "./ChangedFilesTree";153import { ChangedFilesCard } from "./ChangedFilesTree";
154import {154import {
155 CHAT_TIMELINE_ANCHOR_OFFSET,155 CHAT_TIMELINE_ANCHOR_OFFSET,
156 readTimelinePosition,
157 rememberTimelinePosition,
156 timelineContentOverflowsViewport,158 timelineContentOverflowsViewport,
157} from "./timelineScrollAnchoring";159} from "./timelineScrollAnchoring";
158import { MessageCopyButton } from "./MessageCopyButton";160import { MessageCopyButton } from "./MessageCopyButton";
...@@ -452,6 +454,7 @@ interface MessagesTimelineProps {...@@ -452,6 +454,7 @@ interface MessagesTimelineProps {
452 onContentOverflowChange?: (overflows: boolean) => void;454 onContentOverflowChange?: (overflows: boolean) => void;
453 onToolOutputCollapsedAtEnd?: () => void;455 onToolOutputCollapsedAtEnd?: () => void;
454 onManualNavigation: () => void;456 onManualNavigation: () => void;
457 cancelPositionRestoreRef?: React.RefObject<(() => void) | null>;
455 hideEmptyPlaceholder?: boolean;458 hideEmptyPlaceholder?: boolean;
456 topFadeEnabled?: boolean;459 topFadeEnabled?: boolean;
457 /** Non-null when older turns exist beyond the loaded window. */460 /** Non-null when older turns exist beyond the loaded window. */
...@@ -510,6 +513,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -510,6 +513,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
510 onContentOverflowChange,513 onContentOverflowChange,
511 onToolOutputCollapsedAtEnd,514 onToolOutputCollapsedAtEnd,
512 onManualNavigation,515 onManualNavigation,
516 cancelPositionRestoreRef,
513 hideEmptyPlaceholder = false,517 hideEmptyPlaceholder = false,
514 topFadeEnabled = false,518 topFadeEnabled = false,
515 loadEarlier = null,519 loadEarlier = null,
...@@ -518,16 +522,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -518,16 +522,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({
518 steerQueuedMessageShortcutLabel = null,522 steerQueuedMessageShortcutLabel = null,
519 onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,523 onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,
520}: MessagesTimelineProps) {524}: MessagesTimelineProps) {
521 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());525 const listIdentityKey = displayThreadKey ?? routeThreadKey;
522 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());526 const rememberedPosition = useMemo(
523 // Preserve member disclosure state across virtualization.527 () => readTimelinePosition(listIdentityKey),
528 [listIdentityKey],
529 );
530 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(
531 () => rememberedPosition?.disclosures?.turns ?? new Set(),
532 );
533 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(
534 () => rememberedPosition?.disclosures?.workGroups ?? new Set(),
535 );
524 const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>(536 const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>(
525 new Set(),537 () => rememberedPosition?.disclosures?.spawnEntries ?? new Set(),
526 );538 );
527 const [expandedReasoningMessageIds, setExpandedReasoningMessageIds] = useState<539 const [expandedReasoningMessageIds, setExpandedReasoningMessageIds] = useState<
528 ReadonlySet<string>540 ReadonlySet<string>
529 >(new Set());541 >(() => rememberedPosition?.disclosures?.reasoningMessages ?? new Set());
530 const listIdentityKey = displayThreadKey ?? routeThreadKey;542 const [positionedThreadKey, setPositionedThreadKey] = useState<string | null>(() =>
543 rememberedPosition?.atEnd === false ? null : listIdentityKey,
544 );
545 const restoringThreadPosition = positionedThreadKey !== listIdentityKey;
531 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");546 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
532 const listIdentityRef = useRef(listIdentityKey);547 const listIdentityRef = useRef(listIdentityKey);
533 const previousLatestTurnRef = useRef(latestTurn);548 const previousLatestTurnRef = useRef(latestTurn);
...@@ -540,12 +555,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -540,12 +555,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
540 let paintedExpandedReasoningMessageIds = expandedReasoningMessageIds;555 let paintedExpandedReasoningMessageIds = expandedReasoningMessageIds;
541 if (listIdentityRef.current !== listIdentityKey) {556 if (listIdentityRef.current !== listIdentityKey) {
542 listIdentityRef.current = listIdentityKey;557 listIdentityRef.current = listIdentityKey;
558 setPositionedThreadKey(null);
543 previousLatestTurnRef.current = latestTurn;559 previousLatestTurnRef.current = latestTurn;
544 setSettlingListIdentity(listIdentityKey);560 setSettlingListIdentity(listIdentityKey);
545 paintedExpandedTurnIds = new Set();561 paintedExpandedTurnIds = rememberedPosition?.disclosures?.turns ?? new Set();
546 paintedExpandedWorkGroupIds = new Set();562 paintedExpandedWorkGroupIds = rememberedPosition?.disclosures?.workGroups ?? new Set();
547 paintedExpandedSpawnEntryIds = new Set();563 paintedExpandedSpawnEntryIds = rememberedPosition?.disclosures?.spawnEntries ?? new Set();
548 paintedExpandedReasoningMessageIds = new Set();564 paintedExpandedReasoningMessageIds =
565 rememberedPosition?.disclosures?.reasoningMessages ?? new Set();
549 setExpandedTurnIds(paintedExpandedTurnIds);566 setExpandedTurnIds(paintedExpandedTurnIds);
550 setExpandedWorkGroupIds(paintedExpandedWorkGroupIds);567 setExpandedWorkGroupIds(paintedExpandedWorkGroupIds);
551 setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds);568 setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds);
...@@ -567,10 +584,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -567,10 +584,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
567 current.has(turnId) ? current : new Set([...current, turnId]),584 current.has(turnId) ? current : new Set([...current, turnId]),
568 );585 );
569 }, []);586 }, []);
570 // Scroll/disclosure state outlives virtualized rows, but never the current thread.587 // Nested tool state shares the bounded thread-position cache.
571 const workGroupViewState = useMemo<WorkGroupViewState>(588 const workGroupViewState = useMemo<WorkGroupViewState>(
572 () => ({ scrollPositions: new Map(), expandedEntries: new Set() }),589 () =>
573 [listIdentityKey],590 rememberedPosition?.disclosures?.workGroupState ?? {
591 scrollPositions: new Map(),
592 expandedEntries: new Set(),
593 },
594 [listIdentityKey, rememberedPosition],
574 );595 );
575 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);596 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
576 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());597 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
...@@ -786,6 +807,130 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -786,6 +807,130 @@ export const MessagesTimeline = memo(function MessagesTimeline({
786 ]);807 ]);
787 const rows = useStableRows(rawRows, listIdentityKey);808 const rows = useStableRows(rawRows, listIdentityKey);
788 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);809 const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]);
810 const restoreRowIndex =
811 restoringThreadPosition && rememberedPosition?.atEnd === false
812 ? rows.findIndex((row) => row.id === rememberedPosition.rowId)
813 : -1;
814 const restoringAlwaysRender = useMemo(
815 () =>
816 restoringThreadPosition && restoreRowIndex >= 0 ? { indices: [restoreRowIndex] } : undefined,
817 [restoreRowIndex, restoringThreadPosition],
818 );
819 useLayoutEffect(() => {
820 if (!restoringThreadPosition || rows.length === 0) return;
821 const list = listRef.current;
822 if (!list) return;
823 if (citationRequest !== null) {
824 setPositionedThreadKey(listIdentityKey);
825 return;
826 }
827 let cancelled = false;
828 let settleFrame: number | null = null;
829 const viewport: HTMLElement | null = list.getScrollableNode();
830 const cancelRestoration = () => {
831 if (cancelled) return;
832 cancelled = true;
833 if (settleFrame !== null) cancelAnimationFrame(settleFrame);
834 // Supersede any pending estimated-index scroll before the browser applies the gesture.
835 if (viewport) void list.scrollToOffset({ offset: viewport.scrollTop, animated: false });
836 setPositionedThreadKey(listIdentityKey);
837 };
838 const cancelForNavigation = () => {
839 cancelRestoration();
840 onManualNavigation();
841 };
842 const onScrollKey = (event: globalThis.KeyboardEvent) => {
843 if (
844 ["ArrowUp", "ArrowDown", "PageUp", "PageDown", "Home", "End", " "].includes(event.key) &&
845 !(
846 event.target instanceof Element &&
847 event.target.closest("input, textarea, [contenteditable=true]")
848 )
849 )
850 cancelForNavigation();
851 };
852 viewport?.addEventListener("wheel", cancelForNavigation, { passive: true });
853 viewport?.addEventListener("touchmove", cancelForNavigation, { passive: true });
854 viewport?.addEventListener("pointerdown", cancelForNavigation, { passive: true });
855 viewport?.ownerDocument.addEventListener("keydown", onScrollKey);
856 const position = rememberedPosition;
857 const index = position ? rows.findIndex((row) => row.id === position.rowId) : -1;
858 if (position?.atEnd === false) onManualNavigation();
859 if (cancelPositionRestoreRef) cancelPositionRestoreRef.current = cancelRestoration;
860 const scrolling =
861 position?.atEnd === false
862 ? index >= 0
863 ? list.scrollToIndex({
864 index,
865 animated: false,
866 viewPosition: 0,
867 viewOffset: -position.offsetWithinRow,
868 })
869 : list.scrollToOffset({ offset: position.scrollOffset, animated: false })
870 : list.scrollToEnd({ animated: false });
871 void Promise.resolve(scrolling).then(() => {
872 if (cancelled) return;
873 if (position?.atEnd !== false || index < 0) {
874 setPositionedThreadKey(listIdentityKey);
875 return;
876 }
877 // Index scrolling starts from estimates. Keep the saved row mounted
878 // until its measured position and the DOM agree for two layout frames.
879 let stableFrames = 0;
880 const reconcile = () => {
881 if (cancelled) return;
882 const state = list.getState();
883 const rowIndex = state.indexByKey(position.rowId);
884 const row = rowIndex === undefined ? undefined : state.elementAtIndex(rowIndex);
885 const element = list.getScrollableNode();
886 if (!row || !element) return;
887 const offset = Math.max(
888 0,
889 Math.min(
890 element.scrollTop +
891 row.getBoundingClientRect().top -
892 element.getBoundingClientRect().top +
893 position.offsetWithinRow,
894 element.scrollHeight - element.clientHeight,
895 ),
896 );
897 if (Math.abs(element.scrollTop - offset) > 1) {
898 stableFrames = 0;
899 void list.scrollToOffset({ offset, animated: false }).then(() => {
900 if (!cancelled) settleFrame = requestAnimationFrame(reconcile);
901 });
902 return;
903 }
904 if (++stableFrames >= 2) {
905 setPositionedThreadKey(listIdentityKey);
906 } else {
907 settleFrame = requestAnimationFrame(reconcile);
908 }
909 };
910 settleFrame = requestAnimationFrame(reconcile);
911 });
912 return () => {
913 cancelled = true;
914 if (cancelPositionRestoreRef?.current === cancelRestoration) {
915 cancelPositionRestoreRef.current = null;
916 }
917 if (settleFrame !== null) cancelAnimationFrame(settleFrame);
918 viewport?.removeEventListener("wheel", cancelForNavigation);
919 viewport?.removeEventListener("touchmove", cancelForNavigation);
920 viewport?.removeEventListener("pointerdown", cancelForNavigation);
921 viewport?.ownerDocument.removeEventListener("keydown", onScrollKey);
922 };
923 }, [
924 citationRequest,
925 cancelPositionRestoreRef,
926 listIdentityKey,
927 listRef,
928 onManualNavigation,
929 rememberedPosition,
930 restoringThreadPosition,
931 rows,
932 ]);
933
789 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(934 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
790 null,935 null,
791 );936 );
...@@ -806,6 +951,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -806,6 +951,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
806 onManualNavigation,951 onManualNavigation,
807 });952 });
808 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);953 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
954 const alwaysRender = citationAlwaysRender ?? restoringAlwaysRender;
809 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);955 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
810 const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null);956 const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null);
811 const handleAnchorReady = useCallback(957 const handleAnchorReady = useCallback(
...@@ -868,7 +1014,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -868,7 +1014,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({
8681014
869 const handleScroll = useCallback(() => {1015 const handleScroll = useCallback(() => {
870 const state = listRef.current?.getState?.();1016 const state = listRef.current?.getState?.();
1017 if (restoringThreadPosition || state?.data !== rows) return;
871 const isAtEnd = resolveTimelineIsAtEnd(state);1018 const isAtEnd = resolveTimelineIsAtEnd(state);
1019 const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined;
1020 if (position && state && isAtEnd !== undefined) {
1021 const index = state.indexByKey(position.rowId);
1022 const row = index === undefined ? undefined : state.elementAtIndex(index);
1023 const element = listRef.current?.getScrollableNode();
1024 if (row && element) {
1025 rememberTimelinePosition(listIdentityKey, {
1026 ...position,
1027 // DOM geometry includes the header and the virtualizer's layout adjustment.
1028 offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top,
1029 scrollOffset: element.scrollTop,
1030 atEnd: isAtEnd,
1031 disclosures: {
1032 turns: paintedExpandedTurnIds,
1033 workGroups: paintedExpandedWorkGroupIds,
1034 spawnEntries: paintedExpandedSpawnEntryIds,
1035 reasoningMessages: paintedExpandedReasoningMessageIds,
1036 workGroupState: workGroupViewState,
1037 },
1038 });
1039 }
1040 }
872 if (isAtEnd !== undefined && !citationPositioning) {1041 if (isAtEnd !== undefined && !citationPositioning) {
873 onIsAtEndChange(isAtEnd);1042 onIsAtEndChange(isAtEnd);
874 }1043 }
...@@ -909,6 +1078,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -909,6 +1078,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
909 );1078 );
910 }, [1079 }, [
911 citationPositioning,1080 citationPositioning,
1081 paintedExpandedTurnIds,
1082 paintedExpandedWorkGroupIds,
1083 paintedExpandedSpawnEntryIds,
1084 paintedExpandedReasoningMessageIds,
1085 workGroupViewState,
1086 rows,
1087 listIdentityKey,
1088 restoringThreadPosition,
912 listRef,1089 listRef,
913 minimapItems,1090 minimapItems,
914 minimapStripMap,1091 minimapStripMap,
...@@ -1101,15 +1278,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1101,15 +1278,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1101 getItemType={getItemType}1278 getItemType={getItemType}
1102 renderItem={renderItem}1279 renderItem={renderItem}
1103 estimatedItemSize={90}1280 estimatedItemSize={90}
1104 initialScrollAtEnd={citationRequest === null}1281 initialScrollAtEnd={citationRequest === null && rememberedPosition?.atEnd !== false}
1105 // Legend needs a data refresh to mount new pins without a scroll event.1282 // Legend needs a data refresh to mount new pins without a scroll event.
1106 {...(readyCitationRequest ? { dataVersion: readyCitationRequest.key } : {})}1283 dataVersion={readyCitationRequest?.key ?? listIdentityKey}
1107 {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})}1284 {...(alwaysRender ? { alwaysRender } : {})}
1108 onLoad={onCitationListLoad}1285 onLoad={onCitationListLoad}
1109 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}1286 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
1110 contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0}1287 contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0}
1111 maintainScrollAtEnd={1288 maintainScrollAtEnd={
1112 citationPositioning ||1289 citationPositioning ||
1290 (restoringThreadPosition && rememberedPosition?.atEnd === false) ||
1113 anchoredEndSpace ||1291 anchoredEndSpace ||
1114 !liveFollowEnabled ||1292 !liveFollowEnabled ||
1115 disclosureToggleSettling1293 disclosureToggleSettling
...@@ -1119,7 +1297,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -1119,7 +1297,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
1119 : TIMELINE_MAINTAIN_SCROLL_AT_END1297 : TIMELINE_MAINTAIN_SCROLL_AT_END
1120 }1298 }
1121 maintainVisibleContentPosition={1299 maintainVisibleContentPosition={
1122 citationPositioning ? false : maintainVisibleContentPosition1300 citationPositioning ||
1301 (restoringThreadPosition && rememberedPosition?.atEnd === false)
1302 ? false
1303 : maintainVisibleContentPosition
1123 }1304 }
1124 maintainScrollAtEndThreshold={1}1305 maintainScrollAtEndThreshold={1}
1125 onScroll={handleScroll}1306 onScroll={handleScroll}
apps/web/src/components/chat/timelineScrollAnchoring.test.tsx+18
...@@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test";...@@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test";
2import {2import {
3 getAnchoredTurnMetrics,3 getAnchoredTurnMetrics,
4 getRowBottom,4 getRowBottom,
5 readTimelinePosition,
6 rememberTimelinePosition,
5 timelineContentOverflowsViewport,7 timelineContentOverflowsViewport,
6} from "./timelineScrollAnchoring";8} from "./timelineScrollAnchoring";
79
...@@ -171,3 +173,19 @@ describe("timeline scroll anchoring", () => {...@@ -171,3 +173,19 @@ describe("timeline scroll anchoring", () => {
171 expect(withComposer?.overflowsUsableViewport).toBe(true);173 expect(withComposer?.overflowsUsableViewport).toBe(true);
172 });174 });
173});175});
176
177describe("remembered timeline positions", () => {
178 it("keeps reading positions and end-follow independent across threads and environments", () => {
179 const reading = { rowId: "message-4", offsetWithinRow: 32, scrollOffset: 932, atEnd: false };
180 const following = { rowId: "message-9", offsetWithinRow: 10, scrollOffset: 2010, atEnd: true };
181 rememberTimelinePosition("scroll-test-a:thread-1", reading);
182 rememberTimelinePosition("scroll-test-a:thread-2", following);
183 rememberTimelinePosition("scroll-test-b:thread-1", following);
184 expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(reading);
185 expect(readTimelinePosition("scroll-test-a:thread-2")).toEqual(following);
186 expect(readTimelinePosition("scroll-test-b:thread-1")).toEqual(following);
187 expect(readTimelinePosition("scroll-test-a:unvisited")).toBeUndefined();
188 rememberTimelinePosition("scroll-test-a:thread-1", following);
189 expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(following);
190 });
191});
apps/web/src/components/chat/timelineScrollAnchoring.ts+35
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import type { TurnId } from "@t3tools/contracts";
2
1// Match the titlebar fade inset so draft promotion preserves the first row's position.3// Match the titlebar fade inset so draft promotion preserves the first row's position.
2export const CHAT_TIMELINE_ANCHOR_OFFSET = 24;4export const CHAT_TIMELINE_ANCHOR_OFFSET = 24;
35
...@@ -104,3 +106,36 @@ export function getAnchoredTurnMetrics({...@@ -104,3 +106,36 @@ export function getAnchoredTurnMetrics({
104 scrollDeltaToRevealEnd,106 scrollDeltaToRevealEnd,
105 };107 };
106}108}
109
110export interface RememberedTimelinePosition {
111 readonly rowId: string;
112 readonly offsetWithinRow: number;
113 readonly scrollOffset: number;
114 readonly atEnd: boolean;
115 readonly disclosures?: {
116 readonly turns: ReadonlySet<TurnId>;
117 readonly workGroups: ReadonlySet<string>;
118 readonly spawnEntries: ReadonlySet<string>;
119 readonly reasoningMessages: ReadonlySet<string>;
120 readonly workGroupState: {
121 scrollPositions: Map<string, { readonly entryId: string; readonly offset: number }>;
122 expandedEntries: Set<string>;
123 };
124 };
125}
126
127// Scoped thread keys keep separate environments independent. Bound the session cache.
128const rememberedTimelinePositions = new Map<string, RememberedTimelinePosition>();
129
130export function readTimelinePosition(threadKey: string) {
131 return rememberedTimelinePositions.get(threadKey);
132}
133
134export function rememberTimelinePosition(threadKey: string, position: RememberedTimelinePosition) {
135 rememberedTimelinePositions.delete(threadKey);
136 rememberedTimelinePositions.set(threadKey, position);
137 if (rememberedTimelinePositions.size > 100) {
138 const oldest = rememberedTimelinePositions.keys().next().value;
139 if (oldest !== undefined) rememberedTimelinePositions.delete(oldest);
140 }
141}