From 83fbbc18f8718b0254e696f02b8e381386fd6c7b Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:25:57 -0700 Subject: [PATCH] fix(web): timeline keeps live-following after returning from Settings (#17348) --- apps/web/src/components/ChatView.tsx | 1 + .../components/chat/MessagesTimeline.test.tsx | 153 ++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 45 ++++-- .../chat/timelineScrollAnchoring.test.tsx | 8 +- .../chat/timelineScrollAnchoring.ts | 11 +- 5 files changed, 198 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4b6d03636192fb6ce80b365c42fae58e1e53b4c5..b760810a38767c13eb935052c7047fbf1e7db9f4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -11548,6 +11548,7 @@ export default function ChatView(props: ChatViewProps) { } routeThreadKey={displayedTimelineKey} displayThreadKey={displayedTimelineKey} + paintOnly={paintOnlyDisplayedTimeline} onOpenTurnDiff={paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiff} onOpenThread={onOpenRelatedThread} parentThreadLink={paintOnlyDisplayedTimeline ? null : parentThreadLink} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 1ceb55060608e2c1ec4bd4d8e5e908063da9bf77..01857ac0004f18de53e93c512593b458cf2aeb50 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -13,6 +13,7 @@ import { act, createRef, useLayoutEffect, + useState, type ReactNode, type Ref, type ReactElement, @@ -87,6 +88,11 @@ beforeEach(() => { activityTestState.expandedRuns = false; }); +const legendListTestState = vi.hoisted(() => ({ + data: undefined as unknown, + onScroll: undefined as (() => void) | undefined, +})); + vi.mock("@legendapp/list/react", async () => { const legendListTestId = "legend-list"; @@ -122,8 +128,11 @@ vi.mock("@legendapp/list/react", async () => { }; className?: string; contentInsetEndAdjustment?: number; + onScroll?: () => void; ref?: Ref; }) => { + legendListTestState.data = props.data; + legendListTestState.onScroll = props.onScroll; if (props.anchoredEndSpace) { props.anchoredEndSpace.onSizeChanged?.(240); props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); @@ -741,6 +750,150 @@ describe("MessagesTimeline", () => { }, ); + function stubFollowTimeline(routeThreadKey: string) { + const frames = new Map(); + let nextFrame = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(++nextFrame, callback); + return nextFrame; + }); + vi.stubGlobal("cancelAnimationFrame", (frame: number) => frames.delete(frame)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const props = { ...buildProps(), routeThreadKey }; + const timeline = { + props, + scrollTop: 1400, + flushFrame: () => + act(() => { + const callbacks = [...frames.values()]; + frames.clear(); + callbacks.forEach((callback) => callback(0)); + }), + }; + const viewport = { + get scrollTop() { + return timeline.scrollTop; + }, + getBoundingClientRect: () => ({ top: 0 }), + }; + props.listRef.current = { + getScrollableNode: () => viewport, + getState: () => ({ + data: legendListTestState.data, + scroll: timeline.scrollTop, + scrollLength: 600, + contentLength: 2000, + indexByKey: () => 0, + positionAtIndex: () => 0, + sizeAtIndex: () => 2000, + elementAtIndex: () => ({ getBoundingClientRect: () => ({ top: -timeline.scrollTop }) }), + }), + } as unknown as LegendListRef; + return timeline; + } + + it("remembers a manual reading position when the chat closes before the next frame", async () => { + const { readTimelinePosition } = await import("./timelineScrollAnchoring"); + const timeline = stubFollowTimeline("environment-local:manual-departure"); + const { props } = timeline; + let navigateManually = () => {}; + function ChatProbe() { + const [liveFollowEnabled, setLiveFollowEnabled] = useState(true); + navigateManually = () => setLiveFollowEnabled(false); + return ( + + ); + } + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create(); + }); + await timeline.flushFrame(); + expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true); + + // The scroll event lands before the follow opt-out renders. + timeline.scrollTop = 1300; + await act(() => { + navigateManually(); + legendListTestState.onScroll?.(); + }); + await act(() => renderer?.unmount()); + renderer = undefined; + + expect(readTimelinePosition(props.routeThreadKey)).toMatchObject({ + atEnd: false, + scrollOffset: 1300, + }); + } finally { + await act(() => renderer?.unmount()); + } + }); + + it("keeps a following thread's intent while its timeline is held for the next thread", async () => { + const { readTimelinePosition } = await import("./timelineScrollAnchoring"); + const timeline = stubFollowTimeline("environment-local:held-departure"); + const { props } = timeline; + // Smooth follow is still catching up with the stream. + timeline.scrollTop = 1300; + const render = (held: boolean) => ( + + ); + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create(render(false)); + }); + await timeline.flushFrame(); + expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true); + + await act(() => renderer?.update(render(true))); + await timeline.flushFrame(); + + expect(readTimelinePosition(props.routeThreadKey)?.atEnd).toBe(true); + } finally { + await act(() => renderer?.unmount()); + } + }); + + it("remembers a first send held near the top as a reading position", async () => { + const { readTimelinePosition } = await import("./timelineScrollAnchoring"); + const timeline = stubFollowTimeline("environment-local:anchored-first-send"); + const { props } = timeline; + timeline.scrollTop = 1300; + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + , + ); + }); + await timeline.flushFrame(); + + expect(readTimelinePosition(props.routeThreadKey)).toMatchObject({ + atEnd: false, + scrollOffset: 1300, + }); + } finally { + await act(() => renderer?.unmount()); + } + }); + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b69bb4bc24002ea20451dbc5085e7aa9310aeb63..2a45017fd057061433968117bc4f11fcc2b0b12d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -481,6 +481,8 @@ interface MessagesTimelineProps { turnDiffSummaries: ReadonlyArray; routeThreadKey: string; displayThreadKey?: string; + /** The rows are a held snapshot of the previous thread while the next one loads. */ + paintOnly?: boolean; onOpenTurnDiff: (runId: RunId, filePath?: string) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; parentThreadLink?: { @@ -595,6 +597,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ turnDiffSummaries, routeThreadKey, displayThreadKey, + paintOnly = false, onOpenTurnDiff, onOpenThread, parentThreadLink = null, @@ -655,7 +658,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ const previousLatestRunRef = useRef(latestRun); // The list stays mounted across thread switches. Its first end pins on the // new thread must snap, not glide, even if that thread is mid-turn. - const [settlingListIdentity, setSettlingListIdentity] = useState(null); + const [settlingListIdentity, setSettlingListIdentity] = useState(listIdentityKey); let paintedExpandedRunIds = expandedRunIds; let paintedExpandedWorkGroupIds = expandedWorkGroupIds; let paintedExpandedAttemptIds = expandedAttemptIds; @@ -1214,24 +1217,28 @@ const ConversationTimeline = memo(function ConversationTimeline({ if (restoringThreadPosition || state?.data !== rows) return; const isAtEnd = resolveTimelineIsAtEnd(state); const position = state?.data?.length ? resolveWorkGroupScrollAnchor(state) : undefined; - if (position && state && isAtEnd !== undefined) { + if (position && state && isAtEnd !== undefined && !paintOnly) { const index = state.indexByKey(position.rowId); const row = index === undefined ? undefined : state.elementAtIndex(index); const element = listRef.current?.getScrollableNode(); if (row && element) { - rememberTimelinePosition(listIdentityKey, { - ...position, - // DOM geometry includes the header and the virtualizer's layout adjustment. - offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, - scrollOffset: element.scrollTop, - atEnd: isAtEnd, - disclosures: { - runs: paintedExpandedRunIds, - workGroups: paintedExpandedWorkGroupIds, - attempts: paintedExpandedAttemptIds, - workGroupState: workGroupViewState, + rememberTimelinePosition( + listIdentityKey, + { + ...position, + // DOM geometry includes the header and the virtualizer's layout adjustment. + offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, + scrollOffset: element.scrollTop, + atEnd: isAtEnd, + disclosures: { + runs: paintedExpandedRunIds, + workGroups: paintedExpandedWorkGroupIds, + attempts: paintedExpandedAttemptIds, + workGroupState: workGroupViewState, + }, }, - }); + liveFollowEnabled && !anchoredEndSpace, + ); } } if (isAtEnd !== undefined && !citationPositioning) { @@ -1283,6 +1290,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ workGroupViewState, rows, listIdentityKey, + liveFollowEnabled, + anchoredEndSpace, + paintOnly, restoringThreadPosition, listRef, minimapItems, @@ -1296,6 +1306,13 @@ const ConversationTimeline = memo(function ConversationTimeline({ return () => cancelAnimationFrame(frame); }, [handleScroll, rows.length]); + const savedLiveFollowEnabledRef = useRef(liveFollowEnabled); + useLayoutEffect(() => { + const wasFollowing = savedLiveFollowEnabledRef.current; + savedLiveFollowEnabledRef.current = liveFollowEnabled; + if (wasFollowing && !liveFollowEnabled) handleScroll(); + }, [handleScroll, liveFollowEnabled]); + useEffect(() => { if (!timelineViewportElement) { return; diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index d0c49711ee260892725e077a7979d0764a6e0e6d..6300a80338933c07c10587d5b1e1bb0ddcadfa2b 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -271,14 +271,14 @@ describe("remembered timeline positions", () => { it("keeps reading positions and end-follow independent across threads and environments", () => { const reading = { rowId: "message-4", offsetWithinRow: 32, scrollOffset: 932, atEnd: false }; const following = { rowId: "message-9", offsetWithinRow: 10, scrollOffset: 2010, atEnd: true }; - rememberTimelinePosition("scroll-test-a:thread-1", reading); - rememberTimelinePosition("scroll-test-a:thread-2", following); - rememberTimelinePosition("scroll-test-b:thread-1", following); + rememberTimelinePosition("scroll-test-a:thread-1", reading, false); + rememberTimelinePosition("scroll-test-a:thread-2", following, true); + rememberTimelinePosition("scroll-test-b:thread-1", following, true); expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(reading); expect(readTimelinePosition("scroll-test-a:thread-2")).toEqual(following); expect(readTimelinePosition("scroll-test-b:thread-1")).toEqual(following); expect(readTimelinePosition("scroll-test-a:unvisited")).toBeUndefined(); - rememberTimelinePosition("scroll-test-a:thread-1", following); + rememberTimelinePosition("scroll-test-a:thread-1", following, true); expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(following); }); }); diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index c83bb8b83ca44d40438ffa47621837d7b8a3919e..d38e63d543a5d32454a70b1d339d1dcad3dac269 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -165,9 +165,16 @@ export function readTimelinePosition(threadKey: string) { return rememberedTimelinePositions.get(threadKey); } -export function rememberTimelinePosition(threadKey: string, position: RememberedTimelinePosition) { +export function rememberTimelinePosition( + threadKey: string, + position: RememberedTimelinePosition, + liveFollowEnabled: boolean, +) { rememberedTimelinePositions.delete(threadKey); - rememberedTimelinePositions.set(threadKey, position); + rememberedTimelinePositions.set(threadKey, { + ...position, + atEnd: liveFollowEnabled || position.atEnd, + }); if (rememberedTimelinePositions.size > 100) { const oldest = rememberedTimelinePositions.keys().next().value; if (oldest !== undefined) rememberedTimelinePositions.delete(oldest); -- 2.54.0