| ... | @@ -153,6 +153,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; | ... | @@ -153,6 +153,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 153 | import { ChangedFilesCard } from "./ChangedFilesTree"; | 153 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 154 | import { | 154 | import { |
| 155 | CHAT_TIMELINE_ANCHOR_OFFSET, | 155 | CHAT_TIMELINE_ANCHOR_OFFSET, |
| | 156 | readTimelinePosition, |
| | 157 | rememberTimelinePosition, |
| 156 | timelineContentOverflowsViewport, | 158 | timelineContentOverflowsViewport, |
| 157 | } from "./timelineScrollAnchoring"; | 159 | } from "./timelineScrollAnchoring"; |
| 158 | import { MessageCopyButton } from "./MessageCopyButton"; | 160 | import { 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({ |
| 868 | | 1014 | |
| 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 | disclosureToggleSettling | 1293 | 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_END | 1297 | : TIMELINE_MAINTAIN_SCROLL_AT_END |
| 1120 | } | 1298 | } |
| 1121 | maintainVisibleContentPosition={ | 1299 | maintainVisibleContentPosition={ |
| 1122 | citationPositioning ? false : maintainVisibleContentPosition | 1300 | citationPositioning || |
| | 1301 | (restoringThreadPosition && rememberedPosition?.atEnd === false) |
| | 1302 | ? false |
| | 1303 | : maintainVisibleContentPosition |
| 1123 | } | 1304 | } |
| 1124 | maintainScrollAtEndThreshold={1} | 1305 | maintainScrollAtEndThreshold={1} |
| 1125 | onScroll={handleScroll} | 1306 | onScroll={handleScroll} |