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";
136136import {
137137 CHAT_TIMELINE_ANCHOR_OFFSET,
138138 getAnchoredTurnMetrics,
139 readTimelinePosition,
139140 timelineContentOverflowsViewport,
140141 type TimelineScrollMode,
141142} from "./chat/timelineScrollAnchoring";
......@@ -5260,10 +5261,12 @@ export default function ChatView(props: ChatViewProps) {
52605261 const settledTimelineAnchorRef = useRef<MessageId | null>(null);
52615262 const activeTimelineAnchorIndexRef = useRef<number | null>(null);
52625263 const anchorUserScrollGenerationRef = useRef(0);
5264 const cancelPositionRestoreRef = useRef<(() => void) | null>(null);
52635265 const liveFollowUserScrollGenerationRef = useRef<number | null>(0);
52645266 // Manual navigation stops live-follow without removing anchored end space.
52655267 // Collapsing that space during a gesture clamps the viewport back to the end.
52665268 const cancelTimelineLiveFollowForUserNavigation = useCallback(() => {
5269 cancelPositionRestoreRef.current?.();
52675270 anchorUserScrollGenerationRef.current += 1;
52685271 timelineScrollModeRef.current = "free-scrolling";
52695272 liveFollowUserScrollGenerationRef.current = null;
......@@ -5343,6 +5346,7 @@ export default function ChatView(props: ChatViewProps) {
53435346 // Live-follow stays active after send/thread-open until an actual list scroll
53445347 // gesture opts out.
53455348 const scrollToEnd = useCallback((animated = false) => {
5349 cancelPositionRestoreRef.current?.();
53465350 isAtEndRef.current = true;
53475351 timelineScrollModeRef.current = "following-end";
53485352 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
......@@ -5358,21 +5362,6 @@ export default function ChatView(props: ChatViewProps) {
53585362 void legendListRef.current?.scrollToEnd?.({ animated });
53595363 });
53605364 }, []);
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]);
53765365 useLayoutEffect(() => {
53775366 if (timelineScrollModeRef.current !== "anchoring-new-turn") {
53785367 return;
......@@ -5682,19 +5671,22 @@ export default function ChatView(props: ChatViewProps) {
56825671
56835672 useEffect(() => {
56845673 setPullRequestDialogState(null);
5685 isAtEndRef.current = true;
5674 const followEnd = readTimelinePosition(routeThreadKey)?.atEnd !== false;
5675 isAtEndRef.current = followEnd;
56865676 timelineScrollIntentRef.current = null;
5687 timelineScrollModeRef.current = "following-end";
5688 liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current;
5689 setTimelineLiveFollowEnabled(true);
5677 timelineScrollModeRef.current = followEnd ? "following-end" : "free-scrolling";
5678 liveFollowUserScrollGenerationRef.current = followEnd
5679 ? anchorUserScrollGenerationRef.current
5680 : null;
5681 setTimelineLiveFollowEnabled(followEnd);
56905682 pendingTimelineAnchorRef.current = null;
56915683 positionedTimelineAnchorRef.current = null;
56925684 settledTimelineAnchorRef.current = null;
56935685 activeTimelineAnchorIndexRef.current = null;
56945686 showScrollDebouncer.current.cancel();
5695 setShowScrollToBottom(false);
5687 setShowScrollToBottom(!followEnd);
56965688 // activeThreadRef resets transitively with the active thread.
5697 }, [activeThread?.id]);
5689 }, [activeThread?.id, routeThreadKey]);
56985690
56995691 useEffect(() => {
57005692 if (!activeThread?.id || terminalUiState.terminalOpen) return;
......@@ -9551,6 +9543,7 @@ export default function ChatView(props: ChatViewProps) {
95519543 onContentOverflowChange={setTimelineOverflows}
95529544 onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd}
95539545 onManualNavigation={cancelTimelineLiveFollowForUserNavigation}
9546 cancelPositionRestoreRef={cancelPositionRestoreRef}
95549547 hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading}
95559548 topFadeEnabled={!hasTimelineTopBanner}
95569549 loadEarlier={paintOnlyDisplayedTimeline ? null : loadEarlierTurns}
apps/web/src/components/chat/MessagesTimeline.tsx+198-17
......@@ -153,6 +153,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard";
153153import { ChangedFilesCard } from "./ChangedFilesTree";
154154import {
155155 CHAT_TIMELINE_ANCHOR_OFFSET,
156 readTimelinePosition,
157 rememberTimelinePosition,
156158 timelineContentOverflowsViewport,
157159} from "./timelineScrollAnchoring";
158160import { MessageCopyButton } from "./MessageCopyButton";
......@@ -452,6 +454,7 @@ interface MessagesTimelineProps {
452454 onContentOverflowChange?: (overflows: boolean) => void;
453455 onToolOutputCollapsedAtEnd?: () => void;
454456 onManualNavigation: () => void;
457 cancelPositionRestoreRef?: React.RefObject<(() => void) | null>;
455458 hideEmptyPlaceholder?: boolean;
456459 topFadeEnabled?: boolean;
457460 /** Non-null when older turns exist beyond the loaded window. */
......@@ -510,6 +513,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
510513 onContentOverflowChange,
511514 onToolOutputCollapsedAtEnd,
512515 onManualNavigation,
516 cancelPositionRestoreRef,
513517 hideEmptyPlaceholder = false,
514518 topFadeEnabled = false,
515519 loadEarlier = null,
......@@ -518,16 +522,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({
518522 steerQueuedMessageShortcutLabel = null,
519523 onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION,
520524}: MessagesTimelineProps) {
521 const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
522 const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
523 // Preserve member disclosure state across virtualization.
525 const listIdentityKey = displayThreadKey ?? routeThreadKey;
526 const rememberedPosition = useMemo(
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 );
524536 const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>(
525 new Set(),
537 () => rememberedPosition?.disclosures?.spawnEntries ?? new Set(),
526538 );
527539 const [expandedReasoningMessageIds, setExpandedReasoningMessageIds] = useState<
528540 ReadonlySet<string>
529 >(new Set());
530 const listIdentityKey = displayThreadKey ?? routeThreadKey;
541 >(() => rememberedPosition?.disclosures?.reasoningMessages ?? new Set());
542 const [positionedThreadKey, setPositionedThreadKey] = useState<string | null>(() =>
543 rememberedPosition?.atEnd === false ? null : listIdentityKey,
544 );
545 const restoringThreadPosition = positionedThreadKey !== listIdentityKey;
531546 const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
532547 const listIdentityRef = useRef(listIdentityKey);
533548 const previousLatestTurnRef = useRef(latestTurn);
......@@ -540,12 +555,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
540555 let paintedExpandedReasoningMessageIds = expandedReasoningMessageIds;
541556 if (listIdentityRef.current !== listIdentityKey) {
542557 listIdentityRef.current = listIdentityKey;
558 setPositionedThreadKey(null);
543559 previousLatestTurnRef.current = latestTurn;
544560 setSettlingListIdentity(listIdentityKey);
545 paintedExpandedTurnIds = new Set();
546 paintedExpandedWorkGroupIds = new Set();
547 paintedExpandedSpawnEntryIds = new Set();
548 paintedExpandedReasoningMessageIds = new Set();
561 paintedExpandedTurnIds = rememberedPosition?.disclosures?.turns ?? new Set();
562 paintedExpandedWorkGroupIds = rememberedPosition?.disclosures?.workGroups ?? new Set();
563 paintedExpandedSpawnEntryIds = rememberedPosition?.disclosures?.spawnEntries ?? new Set();
564 paintedExpandedReasoningMessageIds =
565 rememberedPosition?.disclosures?.reasoningMessages ?? new Set();
549566 setExpandedTurnIds(paintedExpandedTurnIds);
550567 setExpandedWorkGroupIds(paintedExpandedWorkGroupIds);
551568 setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds);
......@@ -567,10 +584,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
567584 current.has(turnId) ? current : new Set([...current, turnId]),
568585 );
569586 }, []);
570 // Scroll/disclosure state outlives virtualized rows, but never the current thread.
587 // Nested tool state shares the bounded thread-position cache.
571588 const workGroupViewState = useMemo<WorkGroupViewState>(
572 () => ({ scrollPositions: new Map(), expandedEntries: new Set() }),
573 [listIdentityKey],
589 () =>
590 rememberedPosition?.disclosures?.workGroupState ?? {
591 scrollPositions: new Map(),
592 expandedEntries: new Set(),
593 },
594 [listIdentityKey, rememberedPosition],
574595 );
575596 const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false);
576597 const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>());
......@@ -786,6 +807,130 @@ export const MessagesTimeline = memo(function MessagesTimeline({
786807 ]);
787808 const rows = useStableRows(rawRows, listIdentityKey);
788809 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
789934 const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>(
790935 null,
791936 );
......@@ -806,6 +951,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
806951 onManualNavigation,
807952 });
808953 const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false);
954 const alwaysRender = citationAlwaysRender ?? restoringAlwaysRender;
809955 const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0);
810956 const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null);
811957 const handleAnchorReady = useCallback(
......@@ -868,7 +1014,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({
8681014
8691015 const handleScroll = useCallback(() => {
8701016 const state = listRef.current?.getState?.();
1017 if (restoringThreadPosition || state?.data !== rows) return;
8711018 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 }
8721041 if (isAtEnd !== undefined && !citationPositioning) {
8731042 onIsAtEndChange(isAtEnd);
8741043 }
......@@ -909,6 +1078,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({
9091078 );
9101079 }, [
9111080 citationPositioning,
1081 paintedExpandedTurnIds,
1082 paintedExpandedWorkGroupIds,
1083 paintedExpandedSpawnEntryIds,
1084 paintedExpandedReasoningMessageIds,
1085 workGroupViewState,
1086 rows,
1087 listIdentityKey,
1088 restoringThreadPosition,
9121089 listRef,
9131090 minimapItems,
9141091 minimapStripMap,
......@@ -1101,15 +1278,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11011278 getItemType={getItemType}
11021279 renderItem={renderItem}
11031280 estimatedItemSize={90}
1104 initialScrollAtEnd={citationRequest === null}
1281 initialScrollAtEnd={citationRequest === null && rememberedPosition?.atEnd !== false}
11051282 // Legend needs a data refresh to mount new pins without a scroll event.
1106 {...(readyCitationRequest ? { dataVersion: readyCitationRequest.key } : {})}
1107 {...(citationAlwaysRender ? { alwaysRender: citationAlwaysRender } : {})}
1283 dataVersion={readyCitationRequest?.key ?? listIdentityKey}
1284 {...(alwaysRender ? { alwaysRender } : {})}
11081285 onLoad={onCitationListLoad}
11091286 {...(anchoredEndSpace ? { anchoredEndSpace } : {})}
11101287 contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0}
11111288 maintainScrollAtEnd={
11121289 citationPositioning ||
1290 (restoringThreadPosition && rememberedPosition?.atEnd === false) ||
11131291 anchoredEndSpace ||
11141292 !liveFollowEnabled ||
11151293 disclosureToggleSettling
......@@ -1119,7 +1297,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
11191297 : TIMELINE_MAINTAIN_SCROLL_AT_END
11201298 }
11211299 maintainVisibleContentPosition={
1122 citationPositioning ? false : maintainVisibleContentPosition
1300 citationPositioning ||
1301 (restoringThreadPosition && rememberedPosition?.atEnd === false)
1302 ? false
1303 : maintainVisibleContentPosition
11231304 }
11241305 maintainScrollAtEndThreshold={1}
11251306 onScroll={handleScroll}
apps/web/src/components/chat/timelineScrollAnchoring.test.tsx+18
......@@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test";
22import {
33 getAnchoredTurnMetrics,
44 getRowBottom,
5 readTimelinePosition,
6 rememberTimelinePosition,
57 timelineContentOverflowsViewport,
68} from "./timelineScrollAnchoring";
79
......@@ -171,3 +173,19 @@ describe("timeline scroll anchoring", () => {
171173 expect(withComposer?.overflowsUsableViewport).toBe(true);
172174 });
173175});
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 @@
1import type { TurnId } from "@t3tools/contracts";
2
13// Match the titlebar fade inset so draft promotion preserves the first row's position.
24export const CHAT_TIMELINE_ANCHOR_OFFSET = 24;
35
......@@ -104,3 +106,36 @@ export function getAnchoredTurnMetrics({
104106 scrollDeltaToRevealEnd,
105107 };
106108}
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}