| 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,6 +11548,7 @@ export default function ChatView(props: ChatViewProps) { |
| 11548 | } | 11548 | } |
| 11549 | routeThreadKey={displayedTimelineKey} | 11549 | routeThreadKey={displayedTimelineKey} |
| 11550 | displayThreadKey={displayedTimelineKey} | 11550 | displayThreadKey={displayedTimelineKey} |
| 11551 | paintOnly={paintOnlyDisplayedTimeline} | ||
| 11551 | onOpenTurnDiff={paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiff} | 11552 | onOpenTurnDiff={paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiff} |
| 11552 | onOpenThread={onOpenRelatedThread} | 11553 | onOpenThread={onOpenRelatedThread} |
| 11553 | parentThreadLink={paintOnlyDisplayedTimeline ? null : parentThreadLink} | 11554 | parentThreadLink={paintOnlyDisplayedTimeline ? null : parentThreadLink} |
apps/web/src/components/chat/MessagesTimeline.test.tsx+153| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | act, | 13 | act, |
| 14 | createRef, | 14 | createRef, |
| 15 | useLayoutEffect, | 15 | useLayoutEffect, |
| 16 | useState, | ||
| 16 | type ReactNode, | 17 | type ReactNode, |
| 17 | type Ref, | 18 | type Ref, |
| 18 | type ReactElement, | 19 | type ReactElement, |
| ... | @@ -87,6 +88,11 @@ beforeEach(() => { | ... | @@ -87,6 +88,11 @@ beforeEach(() => { |
| 87 | activityTestState.expandedRuns = false; | 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 | vi.mock("@legendapp/list/react", async () => { | 96 | vi.mock("@legendapp/list/react", async () => { |
| 91 | const legendListTestId = "legend-list"; | 97 | const legendListTestId = "legend-list"; |
| 92 | 98 | ||
| ... | @@ -122,8 +128,11 @@ vi.mock("@legendapp/list/react", async () => { | ... | @@ -122,8 +128,11 @@ vi.mock("@legendapp/list/react", async () => { |
| 122 | }; | 128 | }; |
| 123 | className?: string; | 129 | className?: string; |
| 124 | contentInsetEndAdjustment?: number; | 130 | contentInsetEndAdjustment?: number; |
| 131 | onScroll?: () => void; | ||
| 125 | ref?: Ref<LegendListRef>; | 132 | ref?: Ref<LegendListRef>; |
| 126 | }) => { | 133 | }) => { |
| 134 | legendListTestState.data = props.data; | ||
| 135 | legendListTestState.onScroll = props.onScroll; | ||
| 127 | if (props.anchoredEndSpace) { | 136 | if (props.anchoredEndSpace) { |
| 128 | props.anchoredEndSpace.onSizeChanged?.(240); | 137 | props.anchoredEndSpace.onSizeChanged?.(240); |
| 129 | props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); | 138 | props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); |
| ... | @@ -741,6 +750,150 @@ describe("MessagesTimeline", () => { | ... | @@ -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 | it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { | 897 | it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { |
| 745 | const { MessagesTimeline } = await import("./MessagesTimeline"); | 898 | const { MessagesTimeline } = await import("./MessagesTimeline"); |
| 746 | const markup = renderToStaticMarkup( | 899 | const markup = renderToStaticMarkup( |
apps/web/src/components/chat/MessagesTimeline.tsx+31-14| ... | @@ -481,6 +481,8 @@ interface MessagesTimelineProps { | ... | @@ -481,6 +481,8 @@ interface MessagesTimelineProps { |
| 481 | turnDiffSummaries: ReadonlyArray<TurnDiffSummary>; | 481 | turnDiffSummaries: ReadonlyArray<TurnDiffSummary>; |
| 482 | routeThreadKey: string; | 482 | routeThreadKey: string; |
| 483 | displayThreadKey?: string; | 483 | displayThreadKey?: string; |
| 484 | /** The rows are a held snapshot of the previous thread while the next one loads. */ | ||
| 485 | paintOnly?: boolean; | ||
| 484 | onOpenTurnDiff: (runId: RunId, filePath?: string) => void; | 486 | onOpenTurnDiff: (runId: RunId, filePath?: string) => void; |
| 485 | onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; | 487 | onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; |
| 486 | parentThreadLink?: { | 488 | parentThreadLink?: { |
| ... | @@ -595,6 +597,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ | ... | @@ -595,6 +597,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 595 | turnDiffSummaries, | 597 | turnDiffSummaries, |
| 596 | routeThreadKey, | 598 | routeThreadKey, |
| 597 | displayThreadKey, | 599 | displayThreadKey, |
| 600 | paintOnly = false, | ||
| 598 | onOpenTurnDiff, | 601 | onOpenTurnDiff, |
| 599 | onOpenThread, | 602 | onOpenThread, |
| 600 | parentThreadLink = null, | 603 | parentThreadLink = null, |
| ... | @@ -655,7 +658,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ | ... | @@ -655,7 +658,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 655 | const previousLatestRunRef = useRef(latestRun); | 658 | const previousLatestRunRef = useRef(latestRun); |
| 656 | // The list stays mounted across thread switches. Its first end pins on the | 659 | // The list stays mounted across thread switches. Its first end pins on the |
| 657 | // new thread must snap, not glide, even if that thread is mid-turn. | 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 | let paintedExpandedRunIds = expandedRunIds; | 662 | let paintedExpandedRunIds = expandedRunIds; |
| 660 | let paintedExpandedWorkGroupIds = expandedWorkGroupIds; | 663 | let paintedExpandedWorkGroupIds = expandedWorkGroupIds; |
| 661 | let paintedExpandedAttemptIds = expandedAttemptIds; | 664 | let paintedExpandedAttemptIds = expandedAttemptIds; |
| ... | @@ -1214,24 +1217,28 @@ const ConversationTimeline = memo(function ConversationTimeline({ | ... | @@ -1214,24 +1217,28 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1214 | if (restoringThreadPosition || state?.data !== rows) return; | 1217 | if (restoringThreadPosition || state?.data !== rows) return; |
| 1215 | const isAtEnd = resolveTimelineIsAtEnd(state); | 1218 | const isAtEnd = resolveTimelineIsAtEnd(state); |
| 1216 | const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined; | 1219 | const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined; |
| 1217 | if (position && state && isAtEnd !== undefined) { | 1220 | if (position && state && isAtEnd !== undefined && !paintOnly) { |
| 1218 | const index = state.indexByKey(position.rowId); | 1221 | const index = state.indexByKey(position.rowId); |
| 1219 | const row = index === undefined ? undefined : state.elementAtIndex(index); | 1222 | const row = index === undefined ? undefined : state.elementAtIndex(index); |
| 1220 | const element = listRef.current?.getScrollableNode(); | 1223 | const element = listRef.current?.getScrollableNode(); |
| 1221 | if (row && element) { | 1224 | if (row && element) { |
| 1222 | rememberTimelinePosition(listIdentityKey, { | 1225 | rememberTimelinePosition( |
| 1223 | ...position, | 1226 | listIdentityKey, |
| 1224 | // DOM geometry includes the header and the virtualizer's layout adjustment. | 1227 | { |
| 1225 | offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, | 1228 | ...position, |
| 1226 | scrollOffset: element.scrollTop, | 1229 | // DOM geometry includes the header and the virtualizer's layout adjustment. |
| 1227 | atEnd: isAtEnd, | 1230 | offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, |
| 1228 | disclosures: { | 1231 | scrollOffset: element.scrollTop, |
| 1229 | runs: paintedExpandedRunIds, | 1232 | atEnd: isAtEnd, |
| 1230 | workGroups: paintedExpandedWorkGroupIds, | 1233 | disclosures: { |
| 1231 | attempts: paintedExpandedAttemptIds, | 1234 | runs: paintedExpandedRunIds, |
| 1232 | workGroupState: workGroupViewState, | 1235 | workGroups: paintedExpandedWorkGroupIds, |
| 1236 | attempts: paintedExpandedAttemptIds, | ||
| 1237 | workGroupState: workGroupViewState, | ||
| 1238 | }, | ||
| 1233 | }, | 1239 | }, |
| 1234 | }); | 1240 | liveFollowEnabled && !anchoredEndSpace, |
| 1241 | ); | ||
| 1235 | } | 1242 | } |
| 1236 | } | 1243 | } |
| 1237 | if (isAtEnd !== undefined && !citationPositioning) { | 1244 | if (isAtEnd !== undefined && !citationPositioning) { |
| ... | @@ -1283,6 +1290,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ | ... | @@ -1283,6 +1290,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1283 | workGroupViewState, | 1290 | workGroupViewState, |
| 1284 | rows, | 1291 | rows, |
| 1285 | listIdentityKey, | 1292 | listIdentityKey, |
| 1293 | liveFollowEnabled, | ||
| 1294 | anchoredEndSpace, | ||
| 1295 | paintOnly, | ||
| 1286 | restoringThreadPosition, | 1296 | restoringThreadPosition, |
| 1287 | listRef, | 1297 | listRef, |
| 1288 | minimapItems, | 1298 | minimapItems, |
| ... | @@ -1296,6 +1306,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ | ... | @@ -1296,6 +1306,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ |
| 1296 | return () => cancelAnimationFrame(frame); | 1306 | return () => cancelAnimationFrame(frame); |
| 1297 | }, [handleScroll, rows.length]); | 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 | useEffect(() => { | 1316 | useEffect(() => { |
| 1300 | if (!timelineViewportElement) { | 1317 | if (!timelineViewportElement) { |
| 1301 | return; | 1318 | return; |
apps/web/src/components/chat/timelineScrollAnchoring.test.tsx+4-4| ... | @@ -271,14 +271,14 @@ describe("remembered timeline positions", () => { | ... | @@ -271,14 +271,14 @@ describe("remembered timeline positions", () => { |
| 271 | it("keeps reading positions and end-follow independent across threads and environments", () => { | 271 | it("keeps reading positions and end-follow independent across threads and environments", () => { |
| 272 | const reading = { rowId: "message-4", offsetWithinRow: 32, scrollOffset: 932, atEnd: false }; | 272 | const reading = { rowId: "message-4", offsetWithinRow: 32, scrollOffset: 932, atEnd: false }; |
| 273 | const following = { rowId: "message-9", offsetWithinRow: 10, scrollOffset: 2010, atEnd: true }; | 273 | const following = { rowId: "message-9", offsetWithinRow: 10, scrollOffset: 2010, atEnd: true }; |
| 274 | rememberTimelinePosition("scroll-test-a:thread-1", reading); | 274 | rememberTimelinePosition("scroll-test-a:thread-1", reading, false); |
| 275 | rememberTimelinePosition("scroll-test-a:thread-2", following); | 275 | rememberTimelinePosition("scroll-test-a:thread-2", following, true); |
| 276 | rememberTimelinePosition("scroll-test-b:thread-1", following); | 276 | rememberTimelinePosition("scroll-test-b:thread-1", following, true); |
| 277 | expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(reading); | 277 | expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(reading); |
| 278 | expect(readTimelinePosition("scroll-test-a:thread-2")).toEqual(following); | 278 | expect(readTimelinePosition("scroll-test-a:thread-2")).toEqual(following); |
| 279 | expect(readTimelinePosition("scroll-test-b:thread-1")).toEqual(following); | 279 | expect(readTimelinePosition("scroll-test-b:thread-1")).toEqual(following); |
| 280 | expect(readTimelinePosition("scroll-test-a:unvisited")).toBeUndefined(); | 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 | expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(following); | 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,9 +165,16 @@ export function readTimelinePosition(threadKey: string) { |
| 165 | return rememberedTimelinePositions.get(threadKey); | 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 | rememberedTimelinePositions.delete(threadKey); | 173 | rememberedTimelinePositions.delete(threadKey); |
| 170 | rememberedTimelinePositions.set(threadKey, position); | 174 | rememberedTimelinePositions.set(threadKey, { |
| 175 | ...position, | ||
| 176 | atEnd: liveFollowEnabled || position.atEnd, | ||
| 177 | }); | ||
| 171 | if (rememberedTimelinePositions.size > 100) { | 178 | if (rememberedTimelinePositions.size > 100) { |
| 172 | const oldest = rememberedTimelinePositions.keys().next().value; | 179 | const oldest = rememberedTimelinePositions.keys().next().value; |
| 173 | if (oldest !== undefined) rememberedTimelinePositions.delete(oldest); | 180 | if (oldest !== undefined) rememberedTimelinePositions.delete(oldest); |