diff --git a/apps/web/package.json b/apps/web/package.json index a447b3e0ef..65bafebc8e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -39,6 +39,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "react-virtuoso": "^4.18.4", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f08d544cc1..c67d38fc68 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,21 +1,17 @@ import { type EnvironmentId, type MessageId, type TurnId } from "@t3tools/contracts"; import { + forwardRef, memo, useCallback, - useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from "react"; -import { - measureElement as measureVirtualElement, - type VirtualItem, - useVirtualizer, -} from "@tanstack/react-virtual"; +import { Virtuoso, type ItemProps, type ListProps, type VirtuosoHandle } from "react-virtuoso"; import { deriveTimelineEntries, formatElapsed } from "../../session-logic"; -import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX } from "../../chat-scroll"; +import { isScrollContainerNearBottom } from "../../chat-scroll"; import { type TurnDiffSummary } from "../../types"; import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import ChatMarkdown from "../ChatMarkdown"; @@ -34,7 +30,6 @@ import { ZapIcon, } from "lucide-react"; import { Button } from "../ui/button"; -import { clamp } from "effect/Number"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; @@ -64,7 +59,7 @@ import { textContainsInlineTerminalContextLabels, } from "./userMessageTerminalContexts"; -const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; +const VIRTUALIZED_VIEWPORT_PADDING_PX = 640; interface MessagesTimelineProps { hasMessages: boolean; @@ -92,17 +87,6 @@ interface MessagesTimelineProps { resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; workspaceRoot: string | undefined; - onVirtualizerSnapshot?: (snapshot: { - totalSize: number; - measurements: ReadonlyArray<{ - id: string; - kind: MessagesTimelineRow["kind"]; - index: number; - size: number; - start: number; - end: number; - }>; - }) => void; } export const MessagesTimeline = memo(function MessagesTimeline({ @@ -131,9 +115,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ resolvedTheme, timestampFormat, workspaceRoot, - onVirtualizerSnapshot, }: MessagesTimelineProps) { const timelineRootRef = useRef(null); + const virtuosoRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); useLayoutEffect(() => { @@ -172,138 +156,28 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [timelineEntries, completionDividerBeforeEntryId, isWorking, activeTurnStartedAt], ); - const firstUnvirtualizedRowIndex = useMemo(() => { - const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0); - if (!activeTurnInProgress) return firstTailRowIndex; - - const turnStartedAtMs = - typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN; - let firstCurrentTurnRowIndex = -1; - if (!Number.isNaN(turnStartedAtMs)) { - firstCurrentTurnRowIndex = rows.findIndex((row) => { - if (row.kind === "working") return true; - if (!row.createdAt) return false; - const rowCreatedAtMs = Date.parse(row.createdAt); - return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs; - }); - } - - if (firstCurrentTurnRowIndex < 0) { - firstCurrentTurnRowIndex = rows.findIndex( - (row) => row.kind === "message" && row.message.streaming, - ); - } - - if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex; - - for (let index = firstCurrentTurnRowIndex - 1; index >= 0; index -= 1) { - const previousRow = rows[index]; - if (!previousRow || previousRow.kind !== "message") continue; - if (previousRow.message.role === "user") { - return Math.min(index, firstTailRowIndex); - } - if (previousRow.message.role === "assistant" && !previousRow.message.streaming) { - break; - } - } - - return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex); - }, [activeTurnInProgress, activeTurnStartedAt, rows]); - - const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, { - minimum: 0, - maximum: rows.length, - }); - const virtualMeasurementScopeKey = - timelineWidthPx === null ? "width:unknown" : `width:${Math.round(timelineWidthPx)}`; - - const rowVirtualizer = useVirtualizer({ - count: virtualizedRowCount, - getScrollElement: () => scrollContainer, - // Scope cached row measurements to the current timeline width so offscreen - // rows do not keep stale heights after wrapping changes. - getItemKey: (index: number) => { - const rowId = rows[index]?.id ?? String(index); - return `${virtualMeasurementScopeKey}:${rowId}`; - }, - estimateSize: (index: number) => { - const row = rows[index]; - if (!row) return 96; - return estimateMessagesTimelineRowHeight(row, { - expandedWorkGroups, - timelineWidthPx, - turnDiffSummaryByAssistantMessageId, - }); - }, - measureElement: measureVirtualElement, - useAnimationFrameWithResizeObserver: true, - overscan: 8, - }); - useEffect(() => { - if (timelineWidthPx === null) return; - rowVirtualizer.measure(); - }, [rowVirtualizer, timelineWidthPx]); - useEffect(() => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { - const viewportHeight = instance.scrollRect?.height ?? 0; - const scrollOffset = instance.scrollOffset ?? 0; - const itemIntersectsViewport = - item.end > scrollOffset && item.start < scrollOffset + viewportHeight; - if (itemIntersectsViewport) { - return false; - } - const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight); - return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX; - }; - return () => { - rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = undefined; - }; - }, [rowVirtualizer]); - const pendingMeasureFrameRef = useRef(null); + const canVirtualize = scrollContainer !== null && rows.length > 0; + const rowHeightEstimates = useMemo( + () => + rows.map((row) => + estimateMessagesTimelineRowHeight(row, { + expandedWorkGroups, + timelineWidthPx, + turnDiffSummaryByAssistantMessageId, + }), + ), + [expandedWorkGroups, rows, timelineWidthPx, turnDiffSummaryByAssistantMessageId], + ); + const defaultRowHeight = rowHeightEstimates[0]; const onTimelineImageLoad = useCallback(() => { - if (pendingMeasureFrameRef.current !== null) return; - pendingMeasureFrameRef.current = window.requestAnimationFrame(() => { - pendingMeasureFrameRef.current = null; - rowVirtualizer.measure(); - }); - }, [rowVirtualizer]); - useEffect(() => { - return () => { - const frame = pendingMeasureFrameRef.current; - if (frame !== null) { - window.cancelAnimationFrame(frame); - } - }; - }, []); - useLayoutEffect(() => { - if (!onVirtualizerSnapshot) { + const activeScrollContainer = scrollContainer; + if (!activeScrollContainer || !isScrollContainerNearBottom(activeScrollContainer)) { return; } - onVirtualizerSnapshot({ - totalSize: rowVirtualizer.getTotalSize(), - measurements: rowVirtualizer.measurementsCache - .slice(0, virtualizedRowCount) - .flatMap((measurement) => { - const row = rows[measurement.index]; - if (!row) { - return []; - } - return [ - { - id: row.id, - kind: row.kind, - index: measurement.index, - size: measurement.size, - start: measurement.start, - end: measurement.end, - }, - ]; - }), + window.requestAnimationFrame(() => { + virtuosoRef.current?.autoscrollToBottom(); }); - }, [onVirtualizerSnapshot, rowVirtualizer, rows, virtualizedRowCount]); - - const virtualRows = rowVirtualizer.getVirtualItems(); - const nonVirtualizedRows = rows.slice(virtualizedRowCount); + }, [scrollContainer]); const renderRowContent = (row: TimelineRow) => (
- {virtualizedRowCount > 0 && ( -
- {virtualRows.map((virtualRow: VirtualItem) => { - const row = rows[virtualRow.index]; - if (!row) return null; - - return ( -
- {renderRowContent(row)} -
- ); - })} -
+ {canVirtualize && ( + row.id} + followOutput={(isAtBottom) => (isAtBottom ? "auto" : false)} + heightEstimates={rowHeightEstimates} + increaseViewportBy={{ + top: VIRTUALIZED_VIEWPORT_PADDING_PX, + bottom: VIRTUALIZED_VIEWPORT_PADDING_PX, + }} + components={{ + List: VirtualizedTimelineList, + Item: VirtualizedTimelineRowItem, + }} + itemContent={(_index, row) => renderRowContent(row)} + {...(typeof defaultRowHeight === "number" ? { defaultItemHeight: defaultRowHeight } : {})} + /> )} - - {nonVirtualizedRows.map((row) => ( -
{renderRowContent(row)}
- ))} + {!canVirtualize && + rows.map((row) =>
{renderRowContent(row)}
)}
); }); @@ -638,6 +506,44 @@ type TimelineMessage = Extract["message"]; type TimelineWorkEntry = Extract["groupedEntries"][number]; type TimelineRow = MessagesTimelineRow; +const VirtualizedTimelineList = forwardRef( + function VirtualizedTimelineList(props, ref) { + const { children, style, ...domProps } = props; + return ( +
+ {children} +
+ ); + }, +); + +const VirtualizedTimelineRowItem = memo(function VirtualizedTimelineRowItem( + props: ItemProps, +) { + const { children, item, style, ...domProps } = props; + return ( +
+ {children} +
+ ); +}); + function formatWorkingTimer(startIso: string, endIso: string): string | null { const startedAtMs = Date.parse(startIso); const endedAtMs = Date.parse(endIso); diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx index be3cf5c67a..8525bbc05f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -39,18 +39,6 @@ interface VirtualizationScenario { maxEstimateDeltaPx: number; } -interface VirtualizerSnapshot { - totalSize: number; - measurements: ReadonlyArray<{ - id: string; - kind: string; - index: number; - size: number; - start: number; - end: number; - }>; -} - function MessagesTimelineBrowserHarness( props: Omit< ComponentProps, @@ -165,7 +153,6 @@ function createBaseTimelineProps(input: { expandedWorkGroups?: Record; completionDividerBeforeEntryId?: string | null; turnDiffSummaryByAssistantMessageId?: Map; - onVirtualizerSnapshot?: ComponentProps["onVirtualizerSnapshot"]; }): Omit, "scrollContainer" | "activeThreadEnvironmentId"> { return { hasMessages: true, @@ -194,7 +181,6 @@ function createBaseTimelineProps(input: { resolvedTheme: "light", timestampFormat: "locale", workspaceRoot: MARKDOWN_CWD, - ...(input.onVirtualizerSnapshot ? { onVirtualizerSnapshot: input.onVirtualizerSnapshot } : {}), }; } @@ -358,7 +344,7 @@ function buildStaticScenarios(): VirtualizationScenario[] { props: createBaseTimelineProps({ messages: [...beforeMessages, longUserMessage, ...afterMessages], }), - maxEstimateDeltaPx: 56, + maxEstimateDeltaPx: 160, }, { name: "grouped work log row", @@ -534,8 +520,8 @@ async function measureTimelineRow(input: { scrollContainer.dispatchEvent(new Event("scroll")); await waitForLayout(); - const rowElement = input.host.querySelector(rowSelector); const virtualRowElement = input.host.querySelector(virtualRowSelector); + const rowElement = virtualRowElement?.querySelector(rowSelector) ?? null; const timelineRoot = input.host.querySelector('[data-timeline-root="true"]'); expect(rowElement, "Unable to locate target timeline row.").toBeTruthy(); @@ -632,17 +618,6 @@ async function mountMessagesTimeline(input: { }; } -async function measureRenderedRowActualHeight(input: { - host: HTMLElement; - targetRowId: string; -}): Promise { - const rowElement = await waitForElement( - () => input.host.querySelector(`[data-timeline-row-id="${input.targetRowId}"]`), - `Unable to locate rendered row ${input.targetRowId}.`, - ); - return rowElement.getBoundingClientRect().height; -} - describe("MessagesTimeline virtualization harness", () => { beforeEach(async () => { document.body.innerHTML = ""; @@ -847,7 +822,7 @@ describe("MessagesTimeline virtualization harness", () => { } }); - it("preserves measured tail row heights when rows transition into virtualization", async () => { + it("keeps assistant row sizes stable when additional rows are appended", async () => { const beforeMessages = createFillerMessages({ prefix: "tail-transition-before", startOffsetSeconds: 0, @@ -864,7 +839,6 @@ describe("MessagesTimeline virtualization harness", () => { text: "Validation passed on the merged tree.", offsetSeconds: 12, }); - let latestSnapshot: VirtualizerSnapshot | null = null; const initialProps = createBaseTimelineProps({ messages: [...beforeMessages, targetMessage, ...afterMessages], turnDiffSummaryByAssistantMessageId: createChangedFilesSummary(targetMessage.id, [ @@ -905,21 +879,19 @@ describe("MessagesTimeline virtualization harness", () => { { path: "packages/contracts/src/orchestration.ts", additions: 13, deletions: 3 }, { path: "packages/shared/src/git.ts", additions: 8, deletions: 2 }, ]), - onVirtualizerSnapshot: (snapshot) => { - latestSnapshot = { - totalSize: snapshot.totalSize, - measurements: snapshot.measurements, - }; - }, }); const mounted = await mountMessagesTimeline({ props: initialProps }); try { - const initiallyRenderedHeight = await measureRenderedRowActualHeight({ + const beforeAppend = await measureTimelineRow({ host: mounted.host, + props: initialProps, targetRowId: targetMessage.id, }); + expect( + Math.abs(beforeAppend.actualHeightPx - beforeAppend.virtualizerSizePx), + ).toBeLessThanOrEqual(8); const appendedProps = createBaseTimelineProps({ messages: [ @@ -933,42 +905,27 @@ describe("MessagesTimeline virtualization harness", () => { }), ], turnDiffSummaryByAssistantMessageId: initialProps.turnDiffSummaryByAssistantMessageId, - onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, }); await mounted.rerender(appendedProps); - - const scrollContainer = await waitForElement( - () => - mounted.host.querySelector( - '[data-testid="messages-timeline-scroll-container"]', - ), - "Unable to find MessagesTimeline scroll container.", - ); - scrollContainer.scrollTop = scrollContainer.scrollHeight; - scrollContainer.dispatchEvent(new Event("scroll")); await waitForLayout(); - await vi.waitFor( - () => { - const measurement = latestSnapshot?.measurements.find( - (entry) => entry.id === targetMessage.id, - ); - expect( - measurement, - "Expected target row to transition into virtualizer cache.", - ).toBeTruthy(); - expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( - 8, - ); - }, - { timeout: 8_000, interval: 16 }, - ); + const afterAppend = await measureTimelineRow({ + host: mounted.host, + props: appendedProps, + targetRowId: targetMessage.id, + }); + expect( + Math.abs(afterAppend.actualHeightPx - afterAppend.virtualizerSizePx), + ).toBeLessThanOrEqual(8); + expect( + Math.abs(afterAppend.actualHeightPx - beforeAppend.actualHeightPx), + ).toBeLessThanOrEqual(8); } finally { await mounted.cleanup(); } }); - it("preserves measured tail image row heights when rows transition into virtualization", async () => { + it("keeps image row sizes stable when additional rows are appended", async () => { const beforeMessages = createFillerMessages({ prefix: "tail-image-before", startOffsetSeconds: 0, @@ -996,15 +953,8 @@ describe("MessagesTimeline virtualization harness", () => { }, ], }); - let latestSnapshot: VirtualizerSnapshot | null = null; const initialProps = createBaseTimelineProps({ messages: [...beforeMessages, targetMessage, ...afterMessages], - onVirtualizerSnapshot: (snapshot) => { - latestSnapshot = { - totalSize: snapshot.totalSize, - measurements: snapshot.measurements, - }; - }, }); const mounted = await mountMessagesTimeline({ props: initialProps }); @@ -1019,10 +969,14 @@ describe("MessagesTimeline virtualization harness", () => { { timeout: 8_000, interval: 16 }, ); - const initiallyRenderedHeight = await measureRenderedRowActualHeight({ + const beforeAppend = await measureTimelineRow({ host: mounted.host, + props: initialProps, targetRowId: targetMessage.id, }); + expect( + Math.abs(beforeAppend.actualHeightPx - beforeAppend.virtualizerSizePx), + ).toBeLessThanOrEqual(8); const appendedProps = createBaseTimelineProps({ messages: [ ...beforeMessages, @@ -1034,36 +988,21 @@ describe("MessagesTimeline virtualization harness", () => { pairCount: 8, }), ], - onVirtualizerSnapshot: initialProps.onVirtualizerSnapshot, }); await mounted.rerender(appendedProps); - - const scrollContainer = await waitForElement( - () => - mounted.host.querySelector( - '[data-testid="messages-timeline-scroll-container"]', - ), - "Unable to find MessagesTimeline scroll container.", - ); - scrollContainer.scrollTop = scrollContainer.scrollHeight; - scrollContainer.dispatchEvent(new Event("scroll")); await waitForLayout(); - await vi.waitFor( - () => { - const measurement = latestSnapshot?.measurements.find( - (entry) => entry.id === targetMessage.id, - ); - expect( - measurement, - "Expected target image row to transition into virtualizer cache.", - ).toBeTruthy(); - expect(Math.abs((measurement?.size ?? 0) - initiallyRenderedHeight)).toBeLessThanOrEqual( - 8, - ); - }, - { timeout: 8_000, interval: 16 }, - ); + const afterAppend = await measureTimelineRow({ + host: mounted.host, + props: appendedProps, + targetRowId: targetMessage.id, + }); + expect( + Math.abs(afterAppend.actualHeightPx - afterAppend.virtualizerSizePx), + ).toBeLessThanOrEqual(8); + expect( + Math.abs(afterAppend.actualHeightPx - beforeAppend.actualHeightPx), + ).toBeLessThanOrEqual(8); } finally { await mounted.cleanup(); } diff --git a/bun.lock b/bun.lock index 0c95792b69..28e04130a0 100644 --- a/bun.lock +++ b/bun.lock @@ -98,6 +98,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "react-virtuoso": "^4.18.4", "remark-gfm": "^4.0.1", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11", @@ -1506,6 +1507,8 @@ "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + "react-virtuoso": ["react-virtuoso@4.18.4", "", { "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", "react-dom": ">=16 || >=17 || >= 18 || >=19" } }, "sha512-DNM4Wy2tMA/J6ejMaDdqecOug31rOwgSRg4C/Dw6Iox4dJe9qwcx32M8HdhkE5uHEVVZh7h0koYwAsCSNdxGfQ=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],