From cfe6e632c3b095dc4b1ba8244c7ba217c7161ccd Mon Sep 17 00:00:00 2001 From: maco Date: Mon, 28 Sep 2026 06:43:33 +0000 Subject: [PATCH] fix(web): restore the scroll position when returning to a thread Layout scrolls were saved as a reading position while live follow was still on, so a long thread left at the bottom reopened mid-list with Scroll to end. A latched follow is now remembered at the end, and a gesture that releases the latch stores the real offset. Fixes #14051 Co-authored-by: maco --- apps/web/src/components/ChatView.tsx | 6 + .../components/chat/MessagesTimeline.test.tsx | 106 +++++++++++++++++- .../src/components/chat/MessagesTimeline.tsx | 14 ++- 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 61f1770c4bef..36a3d84f88bc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5322,6 +5322,11 @@ export default function ChatView(props: ChatViewProps) { settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; }, []); + /** Whether live follow is still held. A gesture clears this before the next render. */ + const isTimelineLiveFollowLatched = useCallback( + () => liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current, + [], + ); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, ); @@ -9887,6 +9892,7 @@ export default function ChatView(props: ChatViewProps) { onAnchorReady={onTimelineAnchorReady} contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} + isLiveFollowLatched={isTimelineLiveFollowLatched} onIsAtEndChange={onIsAtEndChange} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEnd} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index a87dc2c2a444..9a33fcbad205 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -10,8 +10,13 @@ import { act, createRef, useLayoutEffect, type ReactNode, type Ref } from "react import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import type { LegendListRef, MaintainScrollAtEndOptions } from "@legendapp/list/react"; +import { + LegendList, + type LegendListRef, + type MaintainScrollAtEndOptions, +} from "@legendapp/list/react"; import { shouldUseRestingComposerLayout } from "../composerFooterLayout"; +import { readTimelinePosition } from "./timelineScrollAnchoring"; import { useComposerFocusState } from "./useComposerFocusState"; vi.mock("@legendapp/list/react", async () => { @@ -195,6 +200,7 @@ beforeEach(stubDomGlobals); const ACTIVE_THREAD_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const MESSAGE_CREATED_AT = "2026-03-17T19:12:28.000Z"; +/** Default MessagesTimeline props for render tests. Live follow starts latched. */ function buildProps() { return { isWorking: false, @@ -218,6 +224,7 @@ function buildProps() { onAnchorReady: () => {}, contentInsetEndAdjustment: 0, liveFollowEnabled: true, + isLiveFollowLatched: () => true, onIsAtEndChange: () => {}, onManualNavigation: () => {}, }; @@ -1016,6 +1023,103 @@ describe("MessagesTimeline", () => { } }); + it("remembers a latched follow at the end when layout leaves a gap", () => { + vi.stubGlobal("requestAnimationFrame", () => 0); + vi.stubGlobal("cancelAnimationFrame", () => {}); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const threadKey = "env-1:thread-follow-gap"; + const workEntry = { + id: "entry-follow-work", + kind: "work" as const, + createdAt: MESSAGE_CREATED_AT, + entry: { + id: "work-follow", + createdAt: MESSAGE_CREATED_AT, + toolCallId: "call-follow", + label: "Run lint", + tone: "tool" as const, + itemType: "command_execution" as const, + command: "pnpm lint", + toolLifecycleStatus: "completed" as const, + }, + }; + let renderer: ReactTestRenderer | undefined; + let latched = true; + const props = { + ...buildProps(), + isLiveFollowLatched: () => latched, + routeThreadKey: threadKey, + }; + // Streamed or measured content sits 500px past the viewport. The follow + // scroll has not caught up, so geometry alone is not at the end. + props.listRef.current = { + getState: () => ({ + data: renderer?.root.findAllByType(LegendList)[0]?.props.data, + isAtEnd: false, + contentLength: 2000, + scroll: 700, + scrollLength: 800, + positionAtIndex: () => 0, + indexByKey: () => 0, + elementAtIndex: () => ({ getBoundingClientRect: () => ({ top: -700 }) }), + }), + getScrollableNode: () => ({ + scrollTop: 700, + getBoundingClientRect: () => ({ top: 0 }), + }), + } as unknown as LegendListRef; + try { + act(() => { + renderer = create( + , + ); + }); + act(() => { + renderer?.root.findByType(LegendList).props.onScroll(); + }); + expect(readTimelinePosition(threadKey)?.atEnd).toBe(true); + + // A gesture releases the latch before the next render turns follow off. + latched = false; + act(() => { + renderer?.root.findByType(LegendList).props.onScroll(); + }); + expect(readTimelinePosition(threadKey)).toMatchObject({ atEnd: false, scrollOffset: 700 }); + + latched = true; + act(() => { + renderer?.update( + , + ); + }); + act(() => { + renderer?.root.findByType(LegendList).props.onScroll(); + }); + expect(readTimelinePosition(threadKey)).toMatchObject({ atEnd: false, scrollOffset: 700 }); + + // A first send anchored near the top is not following the end either. + act(() => { + renderer?.update( + , + ); + }); + act(() => { + renderer?.root.findByType(LegendList).props.onScroll(); + }); + expect(readTimelinePosition(threadKey)).toMatchObject({ atEnd: false, scrollOffset: 700 }); + } finally { + act(() => { + renderer?.unmount(); + }); + vi.unstubAllGlobals(); + } + }); + it("keeps reserved end space when tool work starts while reading history", () => { const turnId = TurnId.make("turn-with-active-tool"); const firstEntry = buildUserTimelineEntry("Run the command."); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bf7faa735fed..000f51c5d869 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -449,6 +449,8 @@ interface MessagesTimelineProps { * scroll-mode refs whenever the user drifts near the bottom. */ liveFollowEnabled: boolean; + /** Live-follow latch. A gesture releases it before the next render. */ + isLiveFollowLatched: () => boolean; onIsAtEndChange: (isAtEnd: boolean) => void; /** * Whether the real rows extend past the viewport above the composer. @@ -513,6 +515,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onAnchorReady, contentInsetEndAdjustment, liveFollowEnabled, + isLiveFollowLatched, onIsAtEndChange, onContentOverflowChange, onToolOutputCollapsedAtEnd, @@ -1018,6 +1021,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onContentOverflowChange?.(measureContentOverflow()); }, [cancelContentOverflowFrame, measureContentOverflow, onContentOverflowChange, rows.length]); + /** + * Saves the visible row. Layout scrolls keep a latched follow at the end + * so a measurement gap is not restored as a reading position. + */ const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); if (restoringThreadPosition || state?.data !== rows) return; @@ -1033,7 +1040,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ // DOM geometry includes the header and the virtualizer's layout adjustment. offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, scrollOffset: element.scrollTop, - atEnd: isAtEnd, + // A latched follow treats a layout gap as the end. Anchored end + // space holds the first send near the top, so that gap is kept. + atEnd: isAtEnd || (liveFollowEnabled && isLiveFollowLatched() && !anchoredEndSpace), disclosures: { turns: paintedExpandedTurnIds, workGroups: paintedExpandedWorkGroupIds, @@ -1091,6 +1100,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workGroupViewState, rows, listIdentityKey, + liveFollowEnabled, + isLiveFollowLatched, + anchoredEndSpace, restoringThreadPosition, listRef, minimapItems,