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) {...@@ -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});
8990
91const legendListTestState = vi.hoisted(() => ({
92 data: undefined as unknown,
93 onScroll: undefined as (() => void) | undefined,
94}));
95
90vi.mock("@legendapp/list/react", async () => {96vi.mock("@legendapp/list/react", async () => {
91 const legendListTestId = "legend-list";97 const legendListTestId = "legend-list";
9298
...@@ -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 );
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
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 the659 // 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]);
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
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}
167167
168export function rememberTimelinePosition(threadKey: string, position: RememberedTimelinePosition) {168export 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);