From 7004cbbb38aeb40b5768a2abaed5b88c0d3ef606 Mon Sep 17 00:00:00 2001 From: flamboh Date: Wed, 7 Oct 2026 22:33:57 +0000 Subject: [PATCH 1/4] perf(web): reuse timeline measurements on thread return --- .../src/components/chat/MessagesTimeline.tsx | 50 +++++++++++++++---- .../chat/timelineScrollAnchoring.ts | 4 ++ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 8d2b445a22ce..a6e8d9eb5b9f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -630,11 +630,25 @@ const ConversationTimeline = memo(function ConversationTimeline({ historyControls, loadEarlier = null, }: MessagesTimelineProps) { + const canvas = useChatCanvas(); + const [timelineViewportElement, setTimelineViewportElement] = useState( + null, + ); const listIdentityKey = displayThreadKey ?? routeThreadKey; const rememberedPosition = useMemo( () => readTimelinePosition(listIdentityKey), [listIdentityKey], ); + const rememberedItemSize = rememberedPosition?.itemSize; + const timelineReady = + !rememberedItemSize || + timelineViewportElement !== null || + !canvas || + canvas.container.width > 0; + const estimatedItemSize = + rememberedItemSize && rememberedItemSize.viewportWidth === canvas?.container.width + ? rememberedItemSize.average + : 90; const [expandedRunIds, setExpandedRunIds] = useState>( () => rememberedPosition?.disclosures?.runs ?? new Set(), ); @@ -932,7 +946,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ [restoreRowIndex, restoringThreadPosition], ); useLayoutEffect(() => { - if (!restoringThreadPosition || rows.length === 0) return; + if (!timelineReady || !restoringThreadPosition || rows.length === 0) return; const list = listRef.current; if (!list) return; if (citationRequest !== null) { @@ -1044,11 +1058,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ rememberedPosition, restoringThreadPosition, rows, + timelineReady, ]); - const [timelineViewportElement, setTimelineViewportElement] = useState( - null, - ); // Re-measure the minimap gutter when the chat column changes width without a viewport resize. const chatWidth = useClientSettings((settings) => settings.chatWidth); const { @@ -1172,8 +1184,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ // it is dropped first. useLayoutEffect(() => { cancelContentOverflowFrame(); - onContentOverflowChange?.(measureContentOverflow()); - }, [cancelContentOverflowFrame, measureContentOverflow, onContentOverflowChange, rows.length]); + if (timelineReady) onContentOverflowChange?.(measureContentOverflow()); + }, [ + cancelContentOverflowFrame, + measureContentOverflow, + onContentOverflowChange, + rows.length, + timelineReady, + ]); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); @@ -1185,12 +1203,25 @@ const ConversationTimeline = memo(function ConversationTimeline({ const row = index === undefined ? undefined : state.elementAtIndex(index); const element = listRef.current?.getScrollableNode(); if (row && element) { + const viewportRect = element.getBoundingClientRect(); + const itemSizes = Object.values(state.getAverageItemSizes()); + const measuredItemCount = itemSizes.reduce((sum, item) => sum + item.count, 0); + const measuredItemSize = + itemSizes.reduce((sum, item) => sum + item.average * item.count, 0) / measuredItemCount; rememberTimelinePosition(listIdentityKey, { ...position, // DOM geometry includes the header and the virtualizer's layout adjustment. - offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, + offsetWithinRow: viewportRect.top - row.getBoundingClientRect().top, scrollOffset: element.scrollTop, atEnd: isAtEnd, + ...(measuredItemCount > 0 + ? { + itemSize: { + viewportWidth: Math.round(viewportRect.width), + average: measuredItemSize, + }, + } + : {}), disclosures: { runs: paintedExpandedRunIds, workGroups: paintedExpandedWorkGroupIds, @@ -1436,7 +1467,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); }, [historyControls, onOpenThread, parentThreadLink, topFadeEnabled]); - const canvas = useChatCanvas(); const registerTimeline = canvas?.registerTimeline; const setTimelineList = useCallback( (list: LegendListRef | null) => { @@ -1561,6 +1591,8 @@ const ConversationTimeline = memo(function ConversationTimeline({ ); } + if (!timelineReady) return null; + return ( @@ -1584,7 +1616,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} - estimatedItemSize={90} + estimatedItemSize={estimatedItemSize} initialScrollAtEnd={ !findActive && citationRequest === null && rememberedPosition?.atEnd !== false } diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index c83bb8b83ca4..9fa55b0d6692 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -147,6 +147,10 @@ export interface RememberedTimelinePosition { readonly offsetWithinRow: number; readonly scrollOffset: number; readonly atEnd: boolean; + readonly itemSize?: { + readonly viewportWidth: number; + readonly average: number; + }; readonly disclosures?: { readonly runs: ReadonlySet; readonly workGroups: ReadonlySet; From 30a409e183d0235aa3eab8881cb55e5561dd3dbf Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 9 Oct 2026 06:45:38 +0000 Subject: [PATCH 2/4] fix(web): scope remembered row size to the current thread's rows --- .../src/components/chat/MessagesTimeline.tsx | 8 +++----- .../chat/timelineScrollAnchoring.test.tsx | 20 +++++++++++++++++++ .../chat/timelineScrollAnchoring.ts | 17 ++++++++++++++++ 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a6e8d9eb5b9f..28aa03c80b71 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -179,6 +179,7 @@ import { ChangedFilesCard } from "./ChangedFilesTree"; import { useFileContextMenuHandler } from "../../fileContextMenu"; import { useProject, useThreadShell } from "../../state/entities"; import { + averageMeasuredRowSize, CHAT_TIMELINE_ANCHOR_OFFSET, readTimelinePosition, rememberTimelinePosition, @@ -1204,17 +1205,14 @@ const ConversationTimeline = memo(function ConversationTimeline({ const element = listRef.current?.getScrollableNode(); if (row && element) { const viewportRect = element.getBoundingClientRect(); - const itemSizes = Object.values(state.getAverageItemSizes()); - const measuredItemCount = itemSizes.reduce((sum, item) => sum + item.count, 0); - const measuredItemSize = - itemSizes.reduce((sum, item) => sum + item.average * item.count, 0) / measuredItemCount; + const measuredItemSize = averageMeasuredRowSize(rows, state.sizes); rememberTimelinePosition(listIdentityKey, { ...position, // DOM geometry includes the header and the virtualizer's layout adjustment. offsetWithinRow: viewportRect.top - row.getBoundingClientRect().top, scrollOffset: element.scrollTop, atEnd: isAtEnd, - ...(measuredItemCount > 0 + ...(measuredItemSize !== undefined ? { itemSize: { viewportWidth: Math.round(viewportRect.width), diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index d0c49711ee26..ed1e5166a6ce 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -1,6 +1,7 @@ import { MessageId, RunId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + averageMeasuredRowSize, observeTimelineRun, getAnchoredTurnMetrics, getRowBottom, @@ -282,3 +283,22 @@ describe("remembered timeline positions", () => { expect(readTimelinePosition("scroll-test-a:thread-1")).toEqual(following); }); }); + +describe("averageMeasuredRowSize", () => { + it("ignores measurements the retained list kept from previously visited threads", () => { + const compactRows = Array.from({ length: 255 }, (_, index) => ({ id: `fold-${index}` })); + const longRows = Array.from({ length: 160 }, (_, index) => ({ id: `assistant-${index}` })); + const measuredSizes = new Map([ + ...compactRows.map((row) => [row.id, 30] as const), + ...longRows.slice(0, 17).map((row) => [row.id, 300] as const), + ]); + expect(averageMeasuredRowSize(longRows, measuredSizes)).toBe(300); + expect(averageMeasuredRowSize(compactRows, measuredSizes)).toBe(30); + }); + + it("reports nothing until a current row has been measured", () => { + expect(averageMeasuredRowSize([{ id: "unmeasured" }], new Map([["other", 40]]))).toBe( + undefined, + ); + }); +}); diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 9fa55b0d6692..fc064f46ae0b 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -162,6 +162,23 @@ export interface RememberedTimelinePosition { }; } +// LegendList keeps measurements from every thread the mounted list has shown, +// so only the given rows count toward this thread's estimate. +export function averageMeasuredRowSize( + rows: ReadonlyArray<{ readonly id: string }>, + measuredSizes: ReadonlyMap, +) { + let total = 0; + let count = 0; + for (const row of rows) { + const size = measuredSizes.get(row.id); + if (size === undefined) continue; + total += size; + count++; + } + return count > 0 ? total / count : undefined; +} + // Scoped thread keys keep separate environments independent. Bound the session cache. const rememberedTimelinePositions = new Map(); From 32c4342b8d4a38dc743db3c2b49fecbf30fbc686 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 9 Oct 2026 06:58:59 +0000 Subject: [PATCH 3/4] chore(web): drop explanatory comment --- apps/web/src/components/chat/timelineScrollAnchoring.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index fc064f46ae0b..0510a3c4d409 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -162,8 +162,6 @@ export interface RememberedTimelinePosition { }; } -// LegendList keeps measurements from every thread the mounted list has shown, -// so only the given rows count toward this thread's estimate. export function averageMeasuredRowSize( rows: ReadonlyArray<{ readonly id: string }>, measuredSizes: ReadonlyMap, From d9b7356d15c015b1002e2e4b44de6f9901c568fd Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 10 Oct 2026 00:21:18 +0000 Subject: [PATCH 4/4] perf(web): remember the median row size when leaving a thread --- .../components/chat/MessagesTimeline.test.tsx | 69 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 35 ++++++---- .../chat/timelineScrollAnchoring.test.tsx | 24 +++++-- .../chat/timelineScrollAnchoring.ts | 25 ++++--- 4 files changed, 122 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index f7aeb679a87b..68fcc4221dea 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -669,6 +669,75 @@ describe("MessagesTimeline", () => { }, ); + it("remembers the median row size of the thread it leaves", async () => { + const { ChatCanvasContext } = await import("./ChatCanvasContext"); + const { readTimelinePosition, rememberTimelinePosition } = + await import("./timelineScrollAnchoring"); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + const props = { ...buildProps(), routeThreadKey: "environment-local:measured-departure" }; + const entries = ["short", "code-block", "medium"].map((id) => { + const entry = buildUserTimelineEntry(id); + return { ...entry, id, message: { ...entry.message, id: MessageId.make(id) } }; + }); + const sizes = new Map([ + ["short", 60], + ["code-block", 3000], + ["medium", 80], + ["row-from-another-thread", 20], + ]); + props.listRef.current = { + getScrollableNode: () => null, + getState: () => ({ sizes }), + } as unknown as LegendListRef; + rememberTimelinePosition(props.routeThreadKey, { + rowId: "medium", + offsetWithinRow: 0, + scrollOffset: 0, + atEnd: true, + }); + const canvas = { + container: { width: 801, height: 900 }, + registerTimeline: () => {}, + } as unknown as NonNullable>; + const element = { + addEventListener: () => {}, + removeEventListener: () => {}, + getBoundingClientRect: () => ({ top: 0, width: 800.5, height: 900 }), + querySelector: () => null, + ownerDocument: { addEventListener: () => {}, removeEventListener: () => {} }, + }; + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + + + , + { + createNodeMock: (node) => + (node.props as { onScrollCapture?: unknown }).onScrollCapture ? element : null, + }, + ); + }); + await act(() => renderer?.unmount()); + renderer = undefined; + + expect(readTimelinePosition(props.routeThreadKey)?.itemSize).toEqual({ + viewportWidth: 801, + median: 80, + }); + } finally { + await act(() => renderer?.unmount()); + } + }); + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 28aa03c80b71..38f33c25fdf4 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -179,9 +179,10 @@ import { ChangedFilesCard } from "./ChangedFilesTree"; import { useFileContextMenuHandler } from "../../fileContextMenu"; import { useProject, useThreadShell } from "../../state/entities"; import { - averageMeasuredRowSize, CHAT_TIMELINE_ANCHOR_OFFSET, + medianMeasuredRowSize, readTimelinePosition, + rememberTimelineItemSize, rememberTimelinePosition, timelineContentOverflowsViewport, } from "./timelineScrollAnchoring"; @@ -648,7 +649,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ canvas.container.width > 0; const estimatedItemSize = rememberedItemSize && rememberedItemSize.viewportWidth === canvas?.container.width - ? rememberedItemSize.average + ? rememberedItemSize.median : 90; const [expandedRunIds, setExpandedRunIds] = useState>( () => rememberedPosition?.disclosures?.runs ?? new Set(), @@ -1204,22 +1205,12 @@ const ConversationTimeline = memo(function ConversationTimeline({ const row = index === undefined ? undefined : state.elementAtIndex(index); const element = listRef.current?.getScrollableNode(); if (row && element) { - const viewportRect = element.getBoundingClientRect(); - const measuredItemSize = averageMeasuredRowSize(rows, state.sizes); rememberTimelinePosition(listIdentityKey, { ...position, // DOM geometry includes the header and the virtualizer's layout adjustment. - offsetWithinRow: viewportRect.top - row.getBoundingClientRect().top, + offsetWithinRow: element.getBoundingClientRect().top - row.getBoundingClientRect().top, scrollOffset: element.scrollTop, atEnd: isAtEnd, - ...(measuredItemSize !== undefined - ? { - itemSize: { - viewportWidth: Math.round(viewportRect.width), - average: measuredItemSize, - }, - } - : {}), disclosures: { runs: paintedExpandedRunIds, workGroups: paintedExpandedWorkGroupIds, @@ -1291,6 +1282,24 @@ const ConversationTimeline = memo(function ConversationTimeline({ return () => cancelAnimationFrame(frame); }, [handleScroll, rows.length]); + const canvasWidth = canvas?.container.width; + const departingTimelineRef = useRef({ rows, canvasWidth }); + useLayoutEffect(() => { + departingTimelineRef.current = { rows, canvasWidth }; + }); + useLayoutEffect(() => { + const list = listRef.current; + if (!timelineViewportElement || !list) return; + return () => { + const { rows: departingRows, canvasWidth: viewportWidth } = departingTimelineRef.current; + const sizes = list.getState?.()?.sizes; + const median = sizes && medianMeasuredRowSize(departingRows, sizes); + if (viewportWidth && median !== undefined) { + rememberTimelineItemSize(listIdentityKey, { viewportWidth, median }); + } + }; + }, [listIdentityKey, listRef, timelineViewportElement]); + useEffect(() => { if (!timelineViewportElement) { return; diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx index ed1e5166a6ce..5ff2b495c88f 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx @@ -1,7 +1,7 @@ import { MessageId, RunId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { - averageMeasuredRowSize, + medianMeasuredRowSize, observeTimelineRun, getAnchoredTurnMetrics, getRowBottom, @@ -284,7 +284,7 @@ describe("remembered timeline positions", () => { }); }); -describe("averageMeasuredRowSize", () => { +describe("medianMeasuredRowSize", () => { it("ignores measurements the retained list kept from previously visited threads", () => { const compactRows = Array.from({ length: 255 }, (_, index) => ({ id: `fold-${index}` })); const longRows = Array.from({ length: 160 }, (_, index) => ({ id: `assistant-${index}` })); @@ -292,13 +292,23 @@ describe("averageMeasuredRowSize", () => { ...compactRows.map((row) => [row.id, 30] as const), ...longRows.slice(0, 17).map((row) => [row.id, 300] as const), ]); - expect(averageMeasuredRowSize(longRows, measuredSizes)).toBe(300); - expect(averageMeasuredRowSize(compactRows, measuredSizes)).toBe(30); + expect(medianMeasuredRowSize(longRows, measuredSizes)).toBe(300); + expect(medianMeasuredRowSize(compactRows, measuredSizes)).toBe(30); + }); + + it("is not inflated by a few very tall rows", () => { + const rows = ["a", "b", "c", "d", "e"].map((id) => ({ id })); + const measuredSizes = new Map([ + ["a", 60], + ["b", 3000], + ["c", 64], + ["d", 2400], + ["e", 58], + ]); + expect(medianMeasuredRowSize(rows, measuredSizes)).toBe(64); }); it("reports nothing until a current row has been measured", () => { - expect(averageMeasuredRowSize([{ id: "unmeasured" }], new Map([["other", 40]]))).toBe( - undefined, - ); + expect(medianMeasuredRowSize([{ id: "unmeasured" }], new Map([["other", 40]]))).toBe(undefined); }); }); diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts index 0510a3c4d409..b2bbb48ac549 100644 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ b/apps/web/src/components/chat/timelineScrollAnchoring.ts @@ -149,7 +149,7 @@ export interface RememberedTimelinePosition { readonly atEnd: boolean; readonly itemSize?: { readonly viewportWidth: number; - readonly average: number; + readonly median: number; }; readonly disclosures?: { readonly runs: ReadonlySet; @@ -162,19 +162,14 @@ export interface RememberedTimelinePosition { }; } -export function averageMeasuredRowSize( +export function medianMeasuredRowSize( rows: ReadonlyArray<{ readonly id: string }>, measuredSizes: ReadonlyMap, ) { - let total = 0; - let count = 0; - for (const row of rows) { - const size = measuredSizes.get(row.id); - if (size === undefined) continue; - total += size; - count++; - } - return count > 0 ? total / count : undefined; + const sizes = rows + .flatMap((row) => measuredSizes.get(row.id) ?? []) + .toSorted((left, right) => left - right); + return sizes[Math.floor(sizes.length / 2)]; } // Scoped thread keys keep separate environments independent. Bound the session cache. @@ -192,3 +187,11 @@ export function rememberTimelinePosition(threadKey: string, position: Remembered if (oldest !== undefined) rememberedTimelinePositions.delete(oldest); } } + +export function rememberTimelineItemSize( + threadKey: string, + itemSize: NonNullable, +) { + const position = rememberedTimelinePositions.get(threadKey); + if (position) rememberedTimelinePositions.set(threadKey, { ...position, itemSize }); +}