| author | |
| committer | |
| log | 83fbbc18f8718b0254e696f02b8e381386fd6c7b |
| tree | a3d6ad71a2b17ff55b0544c0a9ddf18c0e4e15f7 |
| parent | 2c8e85e6626d62d06a3dd13583f3b8c1b05c1895 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 198 insertions(+), 20 deletions(-)
apps/web/src/components/ChatView.tsx+1| ... | ... | @@ -11548,6 +11548,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11548 | 11548 | } |
| 11549 | 11549 | routeThreadKey={displayedTimelineKey} |
| 11550 | 11550 | displayThreadKey={displayedTimelineKey} |
| 11551 | paintOnly={paintOnlyDisplayedTimeline} | |
| 11551 | 11552 | onOpenTurnDiff={paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiff} |
| 11552 | 11553 | onOpenThread={onOpenRelatedThread} |
| 11553 | 11554 | parentThreadLink={paintOnlyDisplayedTimeline ? null : parentThreadLink} |
apps/web/src/components/chat/MessagesTimeline.test.tsx+153| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | act, |
| 14 | 14 | createRef, |
| 15 | 15 | useLayoutEffect, |
| 16 | useState, | |
| 16 | 17 | type ReactNode, |
| 17 | 18 | type Ref, |
| 18 | 19 | type ReactElement, |
| ... | ... | @@ -87,6 +88,11 @@ beforeEach(() => { |
| 87 | 88 | activityTestState.expandedRuns = false; |
| 88 | 89 | }); |
| 89 | 90 | |
| 91 | const legendListTestState = vi.hoisted(() => ({ | |
| 92 | data: undefined as unknown, | |
| 93 | onScroll: undefined as (() => void) | undefined, | |
| 94 | })); | |
| 95 | ||
| 90 | 96 | vi.mock("@legendapp/list/react", async () => { |
| 91 | 97 | const legendListTestId = "legend-list"; |
| 92 | 98 | |
| ... | ... | @@ -122,8 +128,11 @@ vi.mock("@legendapp/list/react", async () => { |
| 122 | 128 | }; |
| 123 | 129 | className?: string; |
| 124 | 130 | contentInsetEndAdjustment?: number; |
| 131 | onScroll?: () => void; | |
| 125 | 132 | ref?: Ref<LegendListRef>; |
| 126 | 133 | }) => { |
| 134 | legendListTestState.data = props.data; | |
| 135 | legendListTestState.onScroll = props.onScroll; | |
| 127 | 136 | if (props.anchoredEndSpace) { |
| 128 | 137 | props.anchoredEndSpace.onSizeChanged?.(240); |
| 129 | 138 | props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); |
| ... | ... | @@ -741,6 +750,150 @@ describe("MessagesTimeline", () => { |
| 741 | 750 | }, |
| 742 | 751 | ); |
| 743 | 752 | |
| 753 | function stubFollowTimeline(routeThreadKey: string) { | |
| 754 | const frames = new Map<number, FrameRequestCallback>(); | |
| 755 | let nextFrame = 0; | |
| 756 | vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { | |
| 757 | frames.set(++nextFrame, callback); | |
| 758 | return nextFrame; | |
| 759 | }); | |
| 760 | vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); | |
| 761 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 762 | const props = { ...buildProps(), routeThreadKey }; | |
| 763 | const timeline = { | |
| 764 | props, | |
| 765 | scrollTop: 1400, | |
| 766 | flushFrame: () => | |
| 767 | act(() => { | |
| 768 | const callbacks = [...frames.values()]; | |
| 769 | frames.clear(); | |
| 770 | callbacks.forEach((callback) => callback(0)); | |
| 771 | }), | |
| 772 | }; | |
| 773 | const viewport = { | |
| 774 | get scrollTop() { | |
| 775 | return timeline.scrollTop; | |
| 776 | }, | |
| 777 | getBoundingClientRect: () => ({ top: 0 }), | |
| 778 | }; | |
| 779 | props.listRef.current = { | |
| 780 | getScrollableNode: () => viewport, | |
| 781 | getState: () => ({ | |
| 782 | data: legendListTestState.data, | |
| 783 | scroll: timeline.scrollTop, | |
| 784 | scrollLength: 600, | |
| 785 | contentLength: 2000, | |
| 786 | indexByKey: () => 0, | |
| 787 | positionAtIndex: () => 0, | |
| 788 | sizeAtIndex: () => 2000, | |
| 789 | elementAtIndex: () => ({ getBoundingClientRect: () => ({ top: -timeline.scrollTop }) }), | |
| 790 | }), | |
| 791 | } as unknown as LegendListRef; | |
| 792 | return timeline; | |
| 793 | } | |
| 794 | ||
| 795 | it("remembers a manual reading position when the chat closes before the next frame", async () => { | |
| 796 | const { readTimelinePosition } = await import("./timelineScrollAnchoring"); | |
| 797 | const timeline = stubFollowTimeline("environment-local:manual-departure"); | |
| 798 | const { props } = timeline; | |
| 799 | let navigateManually = () => {}; | |
| 800 | function ChatProbe() { | |
| 801 | const [liveFollowEnabled, setLiveFollowEnabled] = useState(true); | |
| 802 | navigateManually = () => setLiveFollowEnabled(false); | |
| 803 | return ( | |
| 804 | <MessagesTimeline | |
| 805 | {...props} | |
| 806 | timelineEntries={[buildUserTimelineEntry("First prompt")]} | |
| 807 | liveFollowEnabled={liveFollowEnabled} | |
| 808 | onManualNavigation={navigateManually} | |
| 809 | /> | |
| 810 | ); | |
| 811 | } | |
| 812 | let renderer: ReactTestRenderer | undefined; | |
| 813 | try { | |
| 814 | await act(() => { | |
| 815 | renderer = create(<ChatProbe />); | |
| 816 | }); | |
| 817 | await timeline.flushFrame(); | |
| 818 | expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true); | |
| 819 | ||
| 820 | // The scroll event lands before the follow opt-out renders. | |
| 821 | timeline.scrollTop = 1300; | |
| 822 | await act(() => { | |
| 823 | navigateManually(); | |
| 824 | legendListTestState.onScroll?.(); | |
| 825 | }); | |
| 826 | await act(() => renderer?.unmount()); | |
| 827 | renderer = undefined; | |
| 828 | ||
| 829 | expect(readTimelinePosition(props.routeThreadKey)).toMatchObject({ | |
| 830 | atEnd: false, | |
| 831 | scrollOffset: 1300, | |
| 832 | }); | |
| 833 | } finally { | |
| 834 | await act(() => renderer?.unmount()); | |
| 835 | } | |
| 836 | }); | |
| 837 | ||
| 838 | it("keeps a following thread's intent while its timeline is held for the next thread", async () => { | |
| 839 | const { readTimelinePosition } = await import("./timelineScrollAnchoring"); | |
| 840 | const timeline = stubFollowTimeline("environment-local:held-departure"); | |
| 841 | const { props } = timeline; | |
| 842 | // Smooth follow is still catching up with the stream. | |
| 843 | timeline.scrollTop = 1300; | |
| 844 | const render = (held: boolean) => ( | |
| 845 | <MessagesTimeline | |
| 846 | {...props} | |
| 847 | timelineEntries={[buildUserTimelineEntry("First prompt")]} | |
| 848 | liveFollowEnabled={!held} | |
| 849 | paintOnly={held} | |
| 850 | /> | |
| 851 | ); | |
| 852 | let renderer: ReactTestRenderer | undefined; | |
| 853 | try { | |
| 854 | await act(() => { | |
| 855 | renderer = create(render(false)); | |
| 856 | }); | |
| 857 | await timeline.flushFrame(); | |
| 858 | expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true); | |
| 859 | ||
| 860 | await act(() => renderer?.update(render(true))); | |
| 861 | await timeline.flushFrame(); | |
| 862 | ||
| 863 | expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true); | |
| 864 | } finally { | |
| 865 | await act(() => renderer?.unmount()); | |
| 866 | } | |
| 867 | }); | |
| 868 | ||
| 869 | it("remembers a first send held near the top as a reading position", async () => { | |
| 870 | const { readTimelinePosition } = await import("./timelineScrollAnchoring"); | |
| 871 | const timeline = stubFollowTimeline("environment-local:anchored-first-send"); | |
| 872 | const { props } = timeline; | |
| 873 | timeline.scrollTop = 1300; | |
| 874 | let renderer: ReactTestRenderer | undefined; | |
| 875 | try { | |
| 876 | await act(() => { | |
| 877 | renderer = create( | |
| 878 | <MessagesTimeline | |
| 879 | {...props} | |
| 880 | timelineEntries={[buildUserTimelineEntry("First prompt")]} | |
| 881 | anchorMessageId={MessageId.make("message-1")} | |
| 882 | liveFollowEnabled | |
| 883 | />, | |
| 884 | ); | |
| 885 | }); | |
| 886 | await timeline.flushFrame(); | |
| 887 | ||
| 888 | expect(readTimelinePosition(props.routeThreadKey)).toMatchObject({ | |
| 889 | atEnd: false, | |
| 890 | scrollOffset: 1300, | |
| 891 | }); | |
| 892 | } finally { | |
| 893 | await act(() => renderer?.unmount()); | |
| 894 | } | |
| 895 | }); | |
| 896 | ||
| 744 | 897 | it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { |
| 745 | 898 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| 746 | 899 | const markup = renderToStaticMarkup( |
apps/web/src/components/chat/MessagesTimeline.tsx+31-14| ... | ... | @@ -481,6 +481,8 @@ interface MessagesTimelineProps { |
| 481 | 481 | turnDiffSummaries: ReadonlyArray<TurnDiffSummary>; |
| 482 | 482 | routeThreadKey: string; |
| 483 | 483 | displayThreadKey?: string; |
| 484 | /** The rows are a held snapshot of the previous thread while the next one loads. */ | |
| 485 | paintOnly?: boolean; | |
| 484 | 486 | onOpenTurnDiff: (runId: RunId, filePath?: string) => void; |
| 485 | 487 | onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; |
| 486 | 488 | parentThreadLink?: { |
| ... | ... | @@ -595,6 +597,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 595 | 597 | turnDiffSummaries, |
| 596 | 598 | routeThreadKey, |
| 597 | 599 | displayThreadKey, |
| 600 | paintOnly = false, | |
| 598 | 601 | onOpenTurnDiff, |
| 599 | 602 | onOpenThread, |
| 600 | 603 | parentThreadLink = null, |
| ... | ... | @@ -655,7 +658,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 655 | 658 | const previousLatestRunRef = useRef(latestRun); |
| 656 | 659 | // The list stays mounted across thread switches. Its first end pins on the |
| 657 | 660 | // new thread must snap, not glide, even if that thread is mid-turn. |
| 658 | const [settlingListIdentity, setSettlingListIdentity] = useState<string | null>(null); | |
| 661 | const [settlingListIdentity, setSettlingListIdentity] = useState<string | null>(listIdentityKey); | |
| 659 | 662 | let paintedExpandedRunIds = expandedRunIds; |
| 660 | 663 | let paintedExpandedWorkGroupIds = expandedWorkGroupIds; |
| 661 | 664 | let paintedExpandedAttemptIds = expandedAttemptIds; |
| ... | ... | @@ -1214,24 +1217,28 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1214 | 1217 | if (restoringThreadPosition || state?.data !== rows) return; |
| 1215 | 1218 | const isAtEnd = resolveTimelineIsAtEnd(state); |
| 1216 | 1219 | const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined; |
| 1217 | if (position && state && isAtEnd !== undefined) { | |
| 1220 | if (position && state && isAtEnd !== undefined && !paintOnly) { | |
| 1218 | 1221 | const index = state.indexByKey(position.rowId); |
| 1219 | 1222 | const row = index === undefined ? undefined : state.elementAtIndex(index); |
| 1220 | 1223 | const element = listRef.current?.getScrollableNode(); |
| 1221 | 1224 | if (row && element) { |
| 1222 | rememberTimelinePosition(listIdentityKey, { | |
| 1223 | ...position, | |
| 1224 | // DOM geometry includes the header and the virtualizer's layout adjustment. | |
| 1225 | offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, | |
| 1226 | scrollOffset: element.scrollTop, | |
| 1227 | atEnd: isAtEnd, | |
| 1228 | disclosures: { | |
| 1229 | runs: paintedExpandedRunIds, | |
| 1230 | workGroups: paintedExpandedWorkGroupIds, | |
| 1231 | attempts: paintedExpandedAttemptIds, | |
| 1232 | workGroupState: workGroupViewState, | |
| 1225 | rememberTimelinePosition( | |
| 1226 | listIdentityKey, | |
| 1227 | { | |
| 1228 | ...position, | |
| 1229 | // DOM geometry includes the header and the virtualizer's layout adjustment. | |
| 1230 | offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, | |
| 1231 | scrollOffset: element.scrollTop, | |
| 1232 | atEnd: isAtEnd, | |
| 1233 | disclosures: { | |
| 1234 | runs: paintedExpandedRunIds, | |
| 1235 | workGroups: paintedExpandedWorkGroupIds, | |
| 1236 | attempts: paintedExpandedAttemptIds, | |
| 1237 | workGroupState: workGroupViewState, | |
| 1238 | }, | |
| 1233 | 1239 | }, |
| 1234 | }); | |
| 1240 | liveFollowEnabled && !anchoredEndSpace, | |
| 1241 | ); | |
| 1235 | 1242 | } |
| 1236 | 1243 | } |
| 1237 | 1244 | if (isAtEnd !== undefined && !citationPositioning) { |
| ... | ... | @@ -1283,6 +1290,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1283 | 1290 | workGroupViewState, |
| 1284 | 1291 | rows, |
| 1285 | 1292 | listIdentityKey, |
| 1293 | liveFollowEnabled, | |
| 1294 | anchoredEndSpace, | |
| 1295 | paintOnly, | |
| 1286 | 1296 | restoringThreadPosition, |
| 1287 | 1297 | listRef, |
| 1288 | 1298 | minimapItems, |
| ... | ... | @@ -1296,6 +1306,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1296 | 1306 | return () => cancelAnimationFrame(frame); |
| 1297 | 1307 | }, [handleScroll, rows.length]); |
| 1298 | 1308 | |
| 1309 | const savedLiveFollowEnabledRef = useRef(liveFollowEnabled); | |
| 1310 | useLayoutEffect(() => { | |
| 1311 | const wasFollowing = savedLiveFollowEnabledRef.current; | |
| 1312 | savedLiveFollowEnabledRef.current = liveFollowEnabled; | |
| 1313 | if (wasFollowing && !liveFollowEnabled) handleScroll(); | |
| 1314 | }, [handleScroll, liveFollowEnabled]); | |
| 1315 | ||
| 1299 | 1316 | useEffect(() => { |
| 1300 | 1317 | if (!timelineViewportElement) { |
| 1301 | 1318 | return; |
apps/web/src/components/chat/timelineScrollAnchoring.test.tsx+4-4| ... | ... | @@ -271,14 +271,14 @@ describe("remembered timeline positions", () => { |
| 271 | 271 | it("keeps reading positions and end-follow independent across threads and environments", () => { |
| 272 | 272 | const reading = { rowId: "message-4", offsetWithinRow: 32, scrollOffset: 932, atEnd: false }; |
| 273 | 273 | const following = { rowId: "message-9", offsetWithinRow: 10, scrollOffset: 2010, atEnd: true }; |
| 274 | rememberTimelinePosition("scroll-test-a:thread-1", reading); | |
| 275 | rememberTimelinePosition("scroll-test-a:thread-2", following); | |
| 276 | rememberTimelinePosition("scroll-test-b:thread-1", following); | |
| 274 | rememberTimelinePosition("scroll-test-a:thread-1", reading, false); | |
| 275 | rememberTimelinePosition("scroll-test-a:thread-2", following, true); | |
| 276 | rememberTimelinePosition("scroll-test-b:thread-1", following, true); | |
| 277 | 277 | expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(reading); |
| 278 | 278 | expect(readTimelinePosition("scroll-test-a:thread-2")).toEqual(following); |
| 279 | 279 | expect(readTimelinePosition("scroll-test-b:thread-1")).toEqual(following); |
| 280 | 280 | expect(readTimelinePosition("scroll-test-a:unvisited")).toBeUndefined(); |
| 281 | rememberTimelinePosition("scroll-test-a:thread-1", following); | |
| 281 | rememberTimelinePosition("scroll-test-a:thread-1", following, true); | |
| 282 | 282 | expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(following); |
| 283 | 283 | }); |
| 284 | 284 | }); |
apps/web/src/components/chat/timelineScrollAnchoring.ts+9-2| ... | ... | @@ -165,9 +165,16 @@ export function readTimelinePosition(threadKey: string) { |
| 165 | 165 | return rememberedTimelinePositions.get(threadKey); |
| 166 | 166 | } |
| 167 | 167 | |
| 168 | export function rememberTimelinePosition(threadKey: string, position: RememberedTimelinePosition) { | |
| 168 | export function rememberTimelinePosition( | |
| 169 | threadKey: string, | |
| 170 | position: RememberedTimelinePosition, | |
| 171 | liveFollowEnabled: boolean, | |
| 172 | ) { | |
| 169 | 173 | rememberedTimelinePositions.delete(threadKey); |
| 170 | rememberedTimelinePositions.set(threadKey, position); | |
| 174 | rememberedTimelinePositions.set(threadKey, { | |
| 175 | ...position, | |
| 176 | atEnd: liveFollowEnabled || position.atEnd, | |
| 177 | }); | |
| 171 | 178 | if (rememberedTimelinePositions.size > 100) { |
| 172 | 179 | const oldest = rememberedTimelinePositions.keys().next().value; |
| 173 | 180 | if (oldest !== undefined) rememberedTimelinePositions.delete(oldest); |