From 5cf140b0ee2d7320d101b082217a90afb7f230ce Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 18 Feb 2026 23:41:24 -0800 Subject: [PATCH] Virtualize chat timeline rendering for large threads - Add `@tanstack/react-virtual` to web dependencies - Render chat timeline with virtualized rows for work logs, messages, and working indicator - Replace end-of-list sentinel approach with scroll-container-based virtualization --- apps/web/package.json | 1 + apps/web/src/components/ChatView.tsx | 660 +++++++++++++++------------ bun.lock | 5 + 3 files changed, 384 insertions(+), 282 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index cc5046081e2858b67ccc0f7e1b65a32def757f9b..e3aa497bd76a29854840f65e5c9474cb3fbcce92 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,6 +17,7 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", + "@tanstack/react-virtual": "^3.13.18", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 81b4e1fc057e75ef7da5ba7a8d2072d07edd2375..2bccb9ad345429137f59f5b99f97a5f6c9f25dd0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -16,7 +16,6 @@ import { type ClipboardEvent, type DragEvent, type FormEvent, - Fragment, type KeyboardEvent, memo, type RefObject, @@ -29,6 +28,7 @@ import { } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; +import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; @@ -373,7 +373,6 @@ export default function ChatView({ threadId }: ChatViewProps) { Record >(() => readLastInvokedScriptByProjectFromStorage()); const messagesScrollRef = useRef(null); - const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); const textareaRef = useRef(null); const composerCommandInputRef = useRef(null); @@ -1985,6 +1984,7 @@ export default function ChatView({ threadId }: ChatViewProps) { 0} isWorking={isWorking} + scrollContainerRef={messagesScrollRef} timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} @@ -1998,7 +1998,6 @@ export default function ChatView({ threadId }: ChatViewProps) { onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} - messagesEndRef={messagesEndRef} /> @@ -2450,6 +2449,7 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; + scrollContainerRef: RefObject; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; completionSummary: string | null; @@ -2463,12 +2463,29 @@ interface MessagesTimelineProps { onRevertUserMessage: (messageId: string) => void; isRevertingCheckpoint: boolean; onImageExpand: (image: ExpandedImagePreview) => void; - messagesEndRef: RefObject; } +type TimelineEntry = ReturnType[number]; +type TimelineMessage = Extract["message"]; +type TimelineWorkEntry = Extract["entry"]; +type TimelineRow = + | { + kind: "work"; + id: string; + groupedEntries: TimelineWorkEntry[]; + } + | { + kind: "message"; + id: string; + message: TimelineMessage; + showCompletionDivider: boolean; + } + | { kind: "working"; id: string }; + const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, + scrollContainerRef, timelineEntries, completionDividerBeforeEntryId, completionSummary, @@ -2482,8 +2499,67 @@ const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, isRevertingCheckpoint, onImageExpand, - messagesEndRef, }: MessagesTimelineProps) { + const rows = useMemo(() => { + const nextRows: TimelineRow[] = []; + + for (let index = 0; index < timelineEntries.length; index += 1) { + const timelineEntry = timelineEntries[index]; + if (!timelineEntry) { + continue; + } + + if (timelineEntry.kind === "work") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < timelineEntries.length) { + const nextEntry = timelineEntries[cursor]; + if (!nextEntry || nextEntry.kind !== "work") break; + groupedEntries.push(nextEntry.entry); + cursor += 1; + } + nextRows.push({ + kind: "work", + id: timelineEntry.id, + groupedEntries, + }); + index = cursor - 1; + continue; + } + + nextRows.push({ + kind: "message", + id: timelineEntry.id, + message: timelineEntry.message, + showCompletionDivider: + timelineEntry.message.role === "assistant" && + completionDividerBeforeEntryId === timelineEntry.id, + }); + } + + if (isWorking) { + nextRows.push({ kind: "working", id: "working-indicator-row" }); + } + + return nextRows; + }, [timelineEntries, completionDividerBeforeEntryId, isWorking]); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: (index: number) => { + const row = rows[index]; + if (!row) return 96; + if (row.kind === "work") return 112; + if (row.kind === "working") return 40; + return row.message.role === "assistant" ? 220 : 170; + }, + measureElement: (element: HTMLElement) => element.getBoundingClientRect().height, + overscan: 8, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + if (!hasMessages && !isWorking) { return (
@@ -2495,296 +2571,316 @@ const MessagesTimeline = memo(function MessagesTimeline({ } return ( -
- {timelineEntries.map((timelineEntry, index) => { - if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { - return null; - } - - const showCompletionDivider = - timelineEntry.kind === "message" && - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id; - - if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - - const groupId = timelineEntry.id; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const groupLabel = onlyToolEntries - ? groupedEntries.length === 1 - ? "Tool call" - : `Tool calls (${groupedEntries.length})` - : groupedEntries.length === 1 - ? "Work event" - : `Work log (${groupedEntries.length})`; - - return ( - -
-
-

- {groupLabel} -

- {hasOverflow && ( - - )} -
-
- {visibleEntries.map((workEntry) => ( -
- -

- {workEntry.detail ? ( - <> - {workEntry.label} - - {workEntry.detail} - - - ) : ( - workEntry.label - )} -

-
- ))} -
-
-
- ); - } - - if (timelineEntry.message.role === "user") { - const userImages = timelineEntry.message.attachments ?? []; - const canRevertAgentWork = revertTurnCountByUserMessageId.has(timelineEntry.message.id); - return ( - -
-
- {userImages.length > 0 && ( -
- {userImages.map((image) => ( -
- {image.previewUrl ? ( - {image.name} - onImageExpand({ src: image.previewUrl!, name: image.name }) - } - /> - ) : ( -
- {image.name} -
- )} -
- ))} -
- )} - {timelineEntry.message.text && ( -
-                      {timelineEntry.message.text}
-                    
- )} -
- {canRevertAgentWork && ( - - )} -

- {formatTimestamp(timelineEntry.message.createdAt)} -

-
-
-
-
- ); - } +
+ {virtualRows.map((virtualRow: VirtualItem) => { + const row = rows[virtualRow.index]; + if (!row) return null; return ( - - {showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- - {(() => { - const turnSummary = turnDiffSummaryByAssistantMessageId.get( - timelineEntry.message.id, - ); - if (!turnSummary) return null; - const isCheckpointDiffLoading = - !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0; - const summaryStat = turnSummary.unifiedDiff - ? countDiffStat(turnSummary.unifiedDiff) - : turnSummary.files.reduce( - (acc, file) => { - const next = - typeof file.additions === "number" && typeof file.deletions === "number" - ? { additions: file.additions, deletions: file.deletions } - : file.diff - ? countDiffStat(file.diff) - : null; - if (!next) { - return acc; - } - return { - additions: acc.additions + next.additions, - deletions: acc.deletions + next.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); - const changedFileCountLabel = isCheckpointDiffLoading - ? "..." - : String(turnSummary.files.length); - return ( -
-
-

- Changed files ({changedFileCountLabel}) - {!isCheckpointDiffLoading && - (summaryStat.additions > 0 || summaryStat.deletions > 0) && ( - <> - • - +{summaryStat.additions} - / - -{summaryStat.deletions} - - )} -

- -
- {isCheckpointDiffLoading && ( -

- Loading checkpoint diff... -

- )} - {turnSummary.files.length > 0 ? ( -
- {turnSummary.files.map((file) => ( +
+
+ {row.kind === "work" && + (() => { + const groupId = row.id; + const groupedEntries = row.groupedEntries; + const isExpanded = expandedWorkGroups[groupId] ?? false; + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleEntries = + hasOverflow && !isExpanded + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : groupedEntries; + const hiddenCount = groupedEntries.length - visibleEntries.length; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const groupLabel = onlyToolEntries + ? groupedEntries.length === 1 + ? "Tool call" + : `Tool calls (${groupedEntries.length})` + : groupedEntries.length === 1 + ? "Work event" + : `Work log (${groupedEntries.length})`; + + return ( +
+
+

+ {groupLabel} +

+ {hasOverflow && ( + )} +
+
+ {visibleEntries.map((workEntry) => ( +
+ +

+ {workEntry.detail ? ( <> - {file.path} - ( - +{stat.additions} - / - -{stat.deletions} - ) + {workEntry.label} + + {workEntry.detail} + - ); - })()} - + ) : ( + workEntry.label + )} +

+
))}
- ) : !isCheckpointDiffLoading ? ( -

No changed files.

- ) : null} +
+ ); + })()} + + {row.kind === "message" && + row.message.role === "user" && + (() => { + const userImages = row.message.attachments ?? []; + const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); + return ( +
+
+ {userImages.length > 0 && ( +
+ {userImages.map( + (image: NonNullable[number]) => ( +
+ {image.previewUrl ? ( + {image.name} + onImageExpand({ src: image.previewUrl!, name: image.name }) + } + /> + ) : ( +
+ {image.name} +
+ )} +
+ ), + )} +
+ )} + {row.message.text && ( +
+                            {row.message.text}
+                          
+ )} +
+ {canRevertAgentWork && ( + + )} +

+ {formatTimestamp(row.message.createdAt)} +

+
+
+
+ ); + })()} + + {row.kind === "message" && + row.message.role === "assistant" && + (() => { + const messageText = + row.message.text || (row.message.streaming ? "" : "(empty response)"); + return ( + <> + {row.showCompletionDivider && ( +
+ + + {completionSummary ? `Response • ${completionSummary}` : "Response"} + + +
+ )} +
+ + {(() => { + const turnSummary = turnDiffSummaryByAssistantMessageId.get( + row.message.id, + ); + if (!turnSummary) return null; + const isCheckpointDiffLoading = + !turnSummary.checkpointDiffLoaded && turnSummary.files.length === 0; + const summaryStat = turnSummary.unifiedDiff + ? countDiffStat(turnSummary.unifiedDiff) + : turnSummary.files.reduce( + (acc, file) => { + const next = + typeof file.additions === "number" && + typeof file.deletions === "number" + ? { additions: file.additions, deletions: file.deletions } + : file.diff + ? countDiffStat(file.diff) + : null; + if (!next) { + return acc; + } + return { + additions: acc.additions + next.additions, + deletions: acc.deletions + next.deletions, + }; + }, + { additions: 0, deletions: 0 }, + ); + const changedFileCountLabel = isCheckpointDiffLoading + ? "..." + : String(turnSummary.files.length); + return ( +
+
+

+ Changed files ({changedFileCountLabel}) + {!isCheckpointDiffLoading && + (summaryStat.additions > 0 || summaryStat.deletions > 0) && ( + <> + • + + +{summaryStat.additions} + + / + + -{summaryStat.deletions} + + + )} +

+ +
+ {isCheckpointDiffLoading && ( +

+ Loading checkpoint diff... +

+ )} + {turnSummary.files.length > 0 ? ( +
+ {turnSummary.files.map((file) => ( + + ))} +
+ ) : !isCheckpointDiffLoading ? ( +

+ No changed files. +

+ ) : null} +
+ ); + })()} +

+ {formatMessageMeta( + row.message.createdAt, + row.message.streaming + ? formatElapsed(row.message.createdAt, nowIso) + : formatElapsed( + row.message.createdAt, + assistantCompletionByItemId.get(row.message.id), + ), + )} +

+
+ + ); + })()} + + {row.kind === "working" && ( +
+ +
+ + + + +
- ); - })()} -

- {formatMessageMeta( - timelineEntry.message.createdAt, - timelineEntry.message.streaming - ? formatElapsed(timelineEntry.message.createdAt, nowIso) - : formatElapsed( - timelineEntry.message.createdAt, - assistantCompletionByItemId.get(timelineEntry.message.id), - ), - )} -

+
+ )}
- +
); })} - {isWorking && ( -
- -
- - - - - -
-
- )} -
); }); diff --git a/bun.lock b/bun.lock index 9964d3fb197bea73a0261359d9eb2674629a460b..f23b2715815181c2f0ded365bee86bcbbc3662a4 100644 --- a/bun.lock +++ b/bun.lock @@ -57,6 +57,7 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", "@tanstack/react-router": "^1.160.2", + "@tanstack/react-virtual": "^3.13.18", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", @@ -467,6 +468,8 @@ "@tanstack/react-store": ["@tanstack/react-store@0.8.1", "", { "dependencies": { "@tanstack/store": "0.8.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.18", "", { "dependencies": { "@tanstack/virtual-core": "3.13.18" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A=="], + "@tanstack/router-core": ["@tanstack/router-core@1.160.0", "", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-vbh6OsE0MG+0c+SKh2uk5yEEZlWsxT96Ub2JaTs7ixOvZp3Wu9PTEIe2BA3cShNZhEsDI0Le4NqgY4XIaHLLvA=="], "@tanstack/router-generator": ["@tanstack/router-generator@1.160.1", "", { "dependencies": { "@tanstack/router-core": "1.160.0", "@tanstack/router-utils": "1.158.0", "@tanstack/virtual-file-routes": "1.154.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-De6TicInwy3/9rQ++RZAyFOvB2oi5UV5T0iiIlxe3jgiOLFxMA4EKKVlT+alDxKnq6udTLam9xqhvGOVZ6a2hw=="], @@ -477,6 +480,8 @@ "@tanstack/store": ["@tanstack/store@0.8.1", "", {}, "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw=="], + "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.18", "", {}, "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.154.7", "", {}, "sha512-cHHDnewHozgjpI+MIVp9tcib6lYEQK5MyUr0ChHpHFGBl8Xei55rohFK0I0ve/GKoHeioaK42Smd8OixPp6CTg=="], "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], -- 2.54.0