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,