authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-11 16:25:57-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 20:25:57-03:00
log83fbbc18f8718b0254e696f02b8e381386fd6c7b
treea3d6ad71a2b17ff55b0544c0a9ddf18c0e4e15f7
parent2c8e85e6626d62d06a3dd13583f3b8c1b05c1895
signature Signed by PGP key B5690EEEBB952194

fix(web): timeline keeps live-following after returning from Settings (#17348)


5 files changed, 198 insertions(+), 20 deletions(-)

apps/web/src/components/ChatView.tsx+1
......@@ -11548,6 +11548,7 @@ export default function ChatView(props: ChatViewProps) {
1154811548 }
1154911549 routeThreadKey={displayedTimelineKey}
1155011550 displayThreadKey={displayedTimelineKey}
11551 paintOnly={paintOnlyDisplayedTimeline}
1155111552 onOpenTurnDiff={paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiff}
1155211553 onOpenThread={onOpenRelatedThread}
1155311554 parentThreadLink={paintOnlyDisplayedTimeline ? null : parentThreadLink}
apps/web/src/components/chat/MessagesTimeline.test.tsx+153
......@@ -13,6 +13,7 @@ import {
1313 act,
1414 createRef,
1515 useLayoutEffect,
16 useState,
1617 type ReactNode,
1718 type Ref,
1819 type ReactElement,
......@@ -87,6 +88,11 @@ beforeEach(() => {
8788 activityTestState.expandedRuns = false;
8889});
8990
91const legendListTestState = vi.hoisted(() => ({
92 data: undefined as unknown,
93 onScroll: undefined as (() => void) | undefined,
94}));
95
9096vi.mock("@legendapp/list/react", async () => {
9197 const legendListTestId = "legend-list";
9298
......@@ -122,8 +128,11 @@ vi.mock("@legendapp/list/react", async () => {
122128 };
123129 className?: string;
124130 contentInsetEndAdjustment?: number;
131 onScroll?: () => void;
125132 ref?: Ref<LegendListRef>;
126133 }) => {
134 legendListTestState.data = props.data;
135 legendListTestState.onScroll = props.onScroll;
127136 if (props.anchoredEndSpace) {
128137 props.anchoredEndSpace.onSizeChanged?.(240);
129138 props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex });
......@@ -741,6 +750,150 @@ describe("MessagesTimeline", () => {
741750 },
742751 );
743752
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
744897 it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => {
745898 const { MessagesTimeline } = await import("./MessagesTimeline");
746899 const markup = renderToStaticMarkup(
apps/web/src/components/chat/MessagesTimeline.tsx+31-14
......@@ -481,6 +481,8 @@ interface MessagesTimelineProps {
481481 turnDiffSummaries: ReadonlyArray<TurnDiffSummary>;
482482 routeThreadKey: string;
483483 displayThreadKey?: string;
484 /** The rows are a held snapshot of the previous thread while the next one loads. */
485 paintOnly?: boolean;
484486 onOpenTurnDiff: (runId: RunId, filePath?: string) => void;
485487 onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void;
486488 parentThreadLink?: {
......@@ -595,6 +597,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
595597 turnDiffSummaries,
596598 routeThreadKey,
597599 displayThreadKey,
600 paintOnly = false,
598601 onOpenTurnDiff,
599602 onOpenThread,
600603 parentThreadLink = null,
......@@ -655,7 +658,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
655658 const previousLatestRunRef = useRef(latestRun);
656659 // The list stays mounted across thread switches. Its first end pins on the
657660 // 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);
659662 let paintedExpandedRunIds = expandedRunIds;
660663 let paintedExpandedWorkGroupIds = expandedWorkGroupIds;
661664 let paintedExpandedAttemptIds = expandedAttemptIds;
......@@ -1214,24 +1217,28 @@ const ConversationTimeline = memo(function ConversationTimeline({
12141217 if (restoringThreadPosition || state?.data !== rows) return;
12151218 const isAtEnd = resolveTimelineIsAtEnd(state);
12161219 const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined;
1217 if (position && state && isAtEnd !== undefined) {
1220 if (position && state && isAtEnd !== undefined && !paintOnly) {
12181221 const index = state.indexByKey(position.rowId);
12191222 const row = index === undefined ? undefined : state.elementAtIndex(index);
12201223 const element = listRef.current?.getScrollableNode();
12211224 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 },
12331239 },
1234 });
1240 liveFollowEnabled && !anchoredEndSpace,
1241 );
12351242 }
12361243 }
12371244 if (isAtEnd !== undefined && !citationPositioning) {
......@@ -1283,6 +1290,9 @@ const ConversationTimeline = memo(function ConversationTimeline({
12831290 workGroupViewState,
12841291 rows,
12851292 listIdentityKey,
1293 liveFollowEnabled,
1294 anchoredEndSpace,
1295 paintOnly,
12861296 restoringThreadPosition,
12871297 listRef,
12881298 minimapItems,
......@@ -1296,6 +1306,13 @@ const ConversationTimeline = memo(function ConversationTimeline({
12961306 return () => cancelAnimationFrame(frame);
12971307 }, [handleScroll, rows.length]);
12981308
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
12991316 useEffect(() => {
13001317 if (!timelineViewportElement) {
13011318 return;
apps/web/src/components/chat/timelineScrollAnchoring.test.tsx+4-4
......@@ -271,14 +271,14 @@ describe("remembered timeline positions", () => {
271271 it("keeps reading positions and end-follow independent across threads and environments", () => {
272272 const reading = { rowId: "message-4", offsetWithinRow: 32, scrollOffset: 932, atEnd: false };
273273 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);
277277 expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(reading);
278278 expect(readTimelinePosition("scroll-test-a:thread-2")).toEqual(following);
279279 expect(readTimelinePosition("scroll-test-b:thread-1")).toEqual(following);
280280 expect(readTimelinePosition("scroll-test-a:unvisited")).toBeUndefined();
281 rememberTimelinePosition("scroll-test-a:thread-1", following);
281 rememberTimelinePosition("scroll-test-a:thread-1", following, true);
282282 expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(following);
283283 });
284284});
apps/web/src/components/chat/timelineScrollAnchoring.ts+9-2
......@@ -165,9 +165,16 @@ export function readTimelinePosition(threadKey: string) {
165165 return rememberedTimelinePositions.get(threadKey);
166166}
167167
168export function rememberTimelinePosition(threadKey: string, position: RememberedTimelinePosition) {
168export function rememberTimelinePosition(
169 threadKey: string,
170 position: RememberedTimelinePosition,
171 liveFollowEnabled: boolean,
172) {
169173 rememberedTimelinePositions.delete(threadKey);
170 rememberedTimelinePositions.set(threadKey, position);
174 rememberedTimelinePositions.set(threadKey, {
175 ...position,
176 atEnd: liveFollowEnabled || position.atEnd,
177 });
171178 if (rememberedTimelinePositions.size > 100) {
172179 const oldest = rememberedTimelinePositions.keys().next().value;
173180 if (oldest !== undefined) rememberedTimelinePositions.delete(oldest);