From 2666adb3a6ebda34f6c9bd004df8037e70ef6b54 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 13 Apr 2026 13:43:57 -0700 Subject: [PATCH] Fix timeline autoscroll for first non-message rows - Remove the message-only empty-state gate from ChatView - Auto-snap MessagesTimeline to the bottom when non-message rows first appear - Add browser coverage for empty-to-populated and activity-row rendering --- apps/web/src/components/ChatView.tsx | 1 - .../chat/MessagesTimeline.browser.tsx | 160 ++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 22 ++- 3 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/chat/MessagesTimeline.browser.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0bb0e408bca..1017a7c52df 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3186,7 +3186,6 @@ export default function ChatView(props: ChatViewProps) { {/* Messages — LegendList handles virtualization and scrolling internally */} 0} isWorking={isWorking} activeTurnInProgress={isWorking || !latestTurnSettled} activeTurnId={activeLatestTurn?.turnId ?? null} diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx new file mode 100644 index 00000000000..0eb5c8a1fc0 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -0,0 +1,160 @@ +import "../../index.css"; + +import { EnvironmentId } from "@t3tools/contracts"; +import { createRef } from "react"; +import type { LegendListRef } from "@legendapp/list/react"; +import { page } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +const scrollToEndSpy = vi.fn(); +const getStateSpy = vi.fn(() => ({ isAtEnd: true })); + +vi.mock("@legendapp/list/react", async () => { + const React = await import("react"); + + const LegendList = React.forwardRef(function MockLegendList( + props: { + data: Array<{ id: string }>; + keyExtractor: (item: { id: string }) => string; + renderItem: (args: { item: { id: string } }) => React.ReactNode; + ListHeaderComponent?: React.ReactNode; + ListFooterComponent?: React.ReactNode; + }, + ref: React.ForwardedRef, + ) { + React.useImperativeHandle( + ref, + () => + ({ + scrollToEnd: scrollToEndSpy, + getState: getStateSpy, + }) as unknown as LegendListRef, + ); + + return ( +
+ {props.ListHeaderComponent} + {props.data.map((item) => ( +
{props.renderItem({ item })}
+ ))} + {props.ListFooterComponent} +
+ ); + }); + + return { LegendList }; +}); + +import { MessagesTimeline } from "./MessagesTimeline"; + +function buildProps() { + return { + isWorking: false, + activeTurnInProgress: false, + activeTurnId: null, + activeTurnStartedAt: null, + listRef: createRef(), + completionDividerBeforeEntryId: null, + completionSummary: null, + turnDiffSummaryByAssistantMessageId: new Map(), + routeThreadKey: "environment-local:thread-1", + onOpenTurnDiff: vi.fn(), + revertTurnCountByUserMessageId: new Map(), + onRevertUserMessage: vi.fn(), + isRevertingCheckpoint: false, + onImageExpand: vi.fn(), + activeThreadEnvironmentId: EnvironmentId.make("environment-local"), + markdownCwd: undefined, + resolvedTheme: "dark" as const, + timestampFormat: "24-hour" as const, + workspaceRoot: undefined, + onIsAtEndChange: vi.fn(), + }; +} + +describe("MessagesTimeline", () => { + afterEach(() => { + scrollToEndSpy.mockReset(); + getStateSpy.mockClear(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("renders activity rows instead of the empty placeholder when a thread has non-message timeline data", async () => { + const screen = await render( + , + ); + + try { + await expect + .element(page.getByText("Send a message to start the conversation.")) + .not.toBeInTheDocument(); + await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible(); + } finally { + await screen.unmount(); + } + }); + + it("snaps to the bottom when timeline rows appear after an initially empty render", async () => { + const requestAnimationFrameSpy = vi + .spyOn(window, "requestAnimationFrame") + .mockImplementation((callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined); + + const props = buildProps(); + const screen = await render(); + + try { + await expect + .element(page.getByText("Send a message to start the conversation.")) + .toBeVisible(); + + await screen.rerender( + , + ); + + await expect.element(page.getByText("Thinking - Inspecting repository state")).toBeVisible(); + expect(props.onIsAtEndChange).toHaveBeenCalledWith(true); + expect(scrollToEndSpy).toHaveBeenCalledWith({ animated: false }); + expect(requestAnimationFrameSpy).toHaveBeenCalled(); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5714e3be08b..183dcde4f81 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -92,7 +92,6 @@ const TimelineRowCtx = createContext(null!); // --------------------------------------------------------------------------- interface MessagesTimelineProps { - hasMessages: boolean; isWorking: boolean; activeTurnInProgress: boolean; activeTurnId?: TurnId | null; @@ -121,7 +120,6 @@ interface MessagesTimelineProps { // --------------------------------------------------------------------------- export const MessagesTimeline = memo(function MessagesTimeline({ - hasMessages, isWorking, activeTurnInProgress, activeTurnId, @@ -172,6 +170,24 @@ export const MessagesTimeline = memo(function MessagesTimeline({ } }, [listRef, onIsAtEndChange]); + const previousRowCountRef = useRef(rows.length); + useEffect(() => { + const previousRowCount = previousRowCountRef.current; + previousRowCountRef.current = rows.length; + + if (previousRowCount > 0 || rows.length === 0) { + return; + } + + onIsAtEndChange(true); + const frameId = window.requestAnimationFrame(() => { + void listRef.current?.scrollToEnd?.({ animated: false }); + }); + return () => { + window.cancelAnimationFrame(frameId); + }; + }, [listRef, onIsAtEndChange, rows.length]); + // Memoised context value — only changes on state transitions, NOT on // every streaming chunk. Callbacks from ChatView are useCallback-stable. const sharedState = useMemo( @@ -220,7 +236,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [], ); - if (!hasMessages && !isWorking) { + if (rows.length === 0 && !isWorking) { return (