| ... | ... | @@ -153,6 +153,8 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 153 | 153 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 154 | 154 | import { |
| 155 | 155 | CHAT_TIMELINE_ANCHOR_OFFSET, |
| 156 | readTimelinePosition, |
| 157 | rememberTimelinePosition, |
| 156 | 158 | timelineContentOverflowsViewport, |
| 157 | 159 | } from "./timelineScrollAnchoring"; |
| 158 | 160 | import { MessageCopyButton } from "./MessageCopyButton"; |
| ... | ... | @@ -452,6 +454,7 @@ interface MessagesTimelineProps { |
| 452 | 454 | onContentOverflowChange?: (overflows: boolean) => void; |
| 453 | 455 | onToolOutputCollapsedAtEnd?: () => void; |
| 454 | 456 | onManualNavigation: () => void; |
| 457 | cancelPositionRestoreRef?: React.RefObject<(() => void) | null>; |
| 455 | 458 | hideEmptyPlaceholder?: boolean; |
| 456 | 459 | topFadeEnabled?: boolean; |
| 457 | 460 | /** Non-null when older turns exist beyond the loaded window. */ |
| ... | ... | @@ -510,6 +513,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 510 | 513 | onContentOverflowChange, |
| 511 | 514 | onToolOutputCollapsedAtEnd, |
| 512 | 515 | onManualNavigation, |
| 516 | cancelPositionRestoreRef, |
| 513 | 517 | hideEmptyPlaceholder = false, |
| 514 | 518 | topFadeEnabled = false, |
| 515 | 519 | loadEarlier = null, |
| ... | ... | @@ -518,16 +522,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 518 | 522 | steerQueuedMessageShortcutLabel = null, |
| 519 | 523 | onRemoveQueuedMessage = NOOP_QUEUED_MESSAGE_ACTION, |
| 520 | 524 | }: 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 | ); |
| 524 | 536 | const [expandedSpawnEntryIds, setExpandedSpawnEntryIds] = useState<ReadonlySet<string>>( |
| 525 | | new Set(), |
| 537 | () => rememberedPosition?.disclosures?.spawnEntries ?? new Set(), |
| 526 | 538 | ); |
| 527 | 539 | const [expandedReasoningMessageIds, setExpandedReasoningMessageIds] = useState< |
| 528 | 540 | 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; |
| 531 | 546 | const prefersReducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)"); |
| 532 | 547 | const listIdentityRef = useRef(listIdentityKey); |
| 533 | 548 | const previousLatestTurnRef = useRef(latestTurn); |
| ... | ... | @@ -540,12 +555,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 540 | 555 | let paintedExpandedReasoningMessageIds = expandedReasoningMessageIds; |
| 541 | 556 | if (listIdentityRef.current !== listIdentityKey) { |
| 542 | 557 | listIdentityRef.current = listIdentityKey; |
| 558 | setPositionedThreadKey(null); |
| 543 | 559 | previousLatestTurnRef.current = latestTurn; |
| 544 | 560 | 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(); |
| 549 | 566 | setExpandedTurnIds(paintedExpandedTurnIds); |
| 550 | 567 | setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); |
| 551 | 568 | setExpandedSpawnEntryIds(paintedExpandedSpawnEntryIds); |
| ... | ... | @@ -567,10 +584,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 567 | 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 | 588 | 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], |
| 574 | 595 | ); |
| 575 | 596 | const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); |
| 576 | 597 | const [minimapStripMap] = useState(() => new Map<string, HTMLSpanElement>()); |
| ... | ... | @@ -786,6 +807,130 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 786 | 807 | ]); |
| 787 | 808 | const rows = useStableRows(rawRows, listIdentityKey); |
| 788 | 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 | 934 | const [timelineViewportElement, setTimelineViewportElement] = useState<HTMLDivElement | null>( |
| 790 | 935 | null, |
| 791 | 936 | ); |
| ... | ... | @@ -806,6 +951,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 806 | 951 | onManualNavigation, |
| 807 | 952 | }); |
| 808 | 953 | const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); |
| 954 | const alwaysRender = citationAlwaysRender ?? restoringAlwaysRender; |
| 809 | 955 | const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); |
| 810 | 956 | const [minimapCurrentIndex, setMinimapCurrentIndex] = useState<number | null>(null); |
| 811 | 957 | const handleAnchorReady = useCallback( |
| ... | ... | @@ -868,7 +1014,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 868 | 1014 | |
| 869 | 1015 | const handleScroll = useCallback(() => { |
| 870 | 1016 | const state = listRef.current?.getState?.(); |
| 1017 | if (restoringThreadPosition || state?.data !== rows) return; |
| 871 | 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 | 1041 | if (isAtEnd !== undefined && !citationPositioning) { |
| 873 | 1042 | onIsAtEndChange(isAtEnd); |
| 874 | 1043 | } |
| ... | ... | @@ -909,6 +1078,14 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 909 | 1078 | ); |
| 910 | 1079 | }, [ |
| 911 | 1080 | citationPositioning, |
| 1081 | paintedExpandedTurnIds, |
| 1082 | paintedExpandedWorkGroupIds, |
| 1083 | paintedExpandedSpawnEntryIds, |
| 1084 | paintedExpandedReasoningMessageIds, |
| 1085 | workGroupViewState, |
| 1086 | rows, |
| 1087 | listIdentityKey, |
| 1088 | restoringThreadPosition, |
| 912 | 1089 | listRef, |
| 913 | 1090 | minimapItems, |
| 914 | 1091 | minimapStripMap, |
| ... | ... | @@ -1101,15 +1278,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1101 | 1278 | getItemType={getItemType} |
| 1102 | 1279 | renderItem={renderItem} |
| 1103 | 1280 | estimatedItemSize={90} |
| 1104 | | initialScrollAtEnd={citationRequest === null} |
| 1281 | initialScrollAtEnd={citationRequest === null && rememberedPosition?.atEnd !== false} |
| 1105 | 1282 | // 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 } : {})} |
| 1108 | 1285 | onLoad={onCitationListLoad} |
| 1109 | 1286 | {...(anchoredEndSpace ? { anchoredEndSpace } : {})} |
| 1110 | 1287 | contentInsetEndAdjustment={anchoredEndSpace ? contentInsetEndAdjustment : 0} |
| 1111 | 1288 | maintainScrollAtEnd={ |
| 1112 | 1289 | citationPositioning || |
| 1290 | (restoringThreadPosition && rememberedPosition?.atEnd === false) || |
| 1113 | 1291 | anchoredEndSpace || |
| 1114 | 1292 | !liveFollowEnabled || |
| 1115 | 1293 | disclosureToggleSettling |
| ... | ... | @@ -1119,7 +1297,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 1119 | 1297 | : TIMELINE_MAINTAIN_SCROLL_AT_END |
| 1120 | 1298 | } |
| 1121 | 1299 | maintainVisibleContentPosition={ |
| 1122 | | citationPositioning ? false : maintainVisibleContentPosition |
| 1300 | citationPositioning || |
| 1301 | (restoringThreadPosition && rememberedPosition?.atEnd === false) |
| 1302 | ? false |
| 1303 | : maintainVisibleContentPosition |
| 1123 | 1304 | } |
| 1124 | 1305 | maintainScrollAtEndThreshold={1} |
| 1125 | 1306 | onScroll={handleScroll} |