Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down Expand Up @@ -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}
Expand Down
106 changes: 105 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand All @@ -218,6 +224,7 @@ function buildProps() {
onAnchorReady: () => {},
contentInsetEndAdjustment: 0,
liveFollowEnabled: true,
isLiveFollowLatched: () => true,
onIsAtEndChange: () => {},
onManualNavigation: () => {},
};
Expand Down Expand Up @@ -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(
<MessagesTimeline {...props} liveFollowEnabled timelineEntries={[workEntry]} />,
);
});
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(
<MessagesTimeline {...props} liveFollowEnabled={false} timelineEntries={[workEntry]} />,
);
});
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(
<MessagesTimeline
{...props}
liveFollowEnabled
anchorMessageId={MessageId.make("message-1")}
timelineEntries={[buildUserTimelineEntry("First send"), workEntry]}
/>,
);
});
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.");
Expand Down
14 changes: 13 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -513,6 +515,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onAnchorReady,
contentInsetEndAdjustment,
liveFollowEnabled,
isLiveFollowLatched,
onIsAtEndChange,
onContentOverflowChange,
onToolOutputCollapsedAtEnd,
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -1091,6 +1100,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
workGroupViewState,
rows,
listIdentityKey,
liveFollowEnabled,
isLiveFollowLatched,
anchoredEndSpace,
restoringThreadPosition,
listRef,
minimapItems,
Expand Down
Loading