From be7838b5a76aa2f0b625ec6f68dd170eb0260a1a Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 10 Jul 2026 15:19:26 -0600 Subject: [PATCH 1/6] Optimize changes diff rendering --- apps/web/src/components/app/agents-view.tsx | 8 +- apps/web/src/components/app/changes-tab.tsx | 329 +++++++++++++++++--- 2 files changed, 292 insertions(+), 45 deletions(-) diff --git a/apps/web/src/components/app/agents-view.tsx b/apps/web/src/components/app/agents-view.tsx index b5f9ba8d..d854b5d1 100644 --- a/apps/web/src/components/app/agents-view.tsx +++ b/apps/web/src/components/app/agents-view.tsx @@ -548,14 +548,18 @@ export function AgentsView({ /> ); - const changesElement = ( + const changesVisible = + (isSplit && + (splitState.left === "changes" || splitState.right === "changes")) || + (!isSplit && changesMatch); + const changesElement = changesVisible ? ( - ); + ) : null; return (
diff --git a/apps/web/src/components/app/changes-tab.tsx b/apps/web/src/components/app/changes-tab.tsx index cd78489f..3b86375d 100644 --- a/apps/web/src/components/app/changes-tab.tsx +++ b/apps/web/src/components/app/changes-tab.tsx @@ -1,4 +1,12 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useAtom, useAtomValue } from "jotai"; import { useSearchParams } from "react-router-dom"; import { @@ -49,6 +57,12 @@ type ChangesTabProps = { onReviewSubmitted?: (reviewId: number) => void; }; +const DIFF_FILE_OVERSCAN_PX = 1200; +const DIFF_FILE_MIN_HEIGHT = 46; +const DIFF_FILE_MAX_ESTIMATED_HEIGHT = 900; +const DIFF_FILE_LINE_HEIGHT = 22; +const DIFF_FILE_GAP_PX = 12; + export const ChangesTab = memo(function ChangesTab({ agentId, active, @@ -164,12 +178,16 @@ export const ChangesTab = memo(function ChangesTab({ ); const [selectedFile, setSelectedFile] = useState(null); + const [forceLoadedFiles, setForceLoadedFiles] = useState>( + () => new Set() + ); const [lineSelection, setLineSelection] = useState( null ); const [commentOpen, setCommentOpen] = useState(false); const [fileTreeOpen, setFileTreeOpen] = useAtom(diffFileTreeOpenAtom); const fileRefs = useRef>(new Map()); + const scrollToVirtualFileRef = useRef<((path: string) => void) | null>(null); const handleLineSelection = useCallback((sel: LineSelection | null) => { setLineSelection(sel); @@ -187,6 +205,8 @@ export const ChangesTab = memo(function ChangesTab({ const el = fileRefs.current.get(path); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); + } else { + scrollToVirtualFileRef.current?.(path); } setViewState((prev) => { const s = new Set(prev.collapsedFiles); @@ -212,7 +232,8 @@ export const ChangesTab = memo(function ChangesTab({ if (navLineTarget && targetFile.diff) { const lineNum = Number(navLineTarget); if (Number.isInteger(lineNum) && lineNum > 0) { - requestAnimationFrame(() => { + let attempts = 0; + const scrollToLine = () => { try { const parsed = parseDiff(targetFile.diff!, { nearbySequences: "zip", @@ -227,12 +248,18 @@ export const ChangesTab = memo(function ChangesTab({ const el = scrollRef.current?.querySelector( `[id="${CSS.escape(changeKey)}"]` ); - el?.scrollIntoView({ block: "center", behavior: "smooth" }); + if (el) { + el.scrollIntoView({ block: "center", behavior: "smooth" }); + return; + } } } catch { // diff parse failed — fall back to file-level scroll } - }); + // The target file may need to enter the virtual window first. + if (attempts++ < 60) requestAnimationFrame(scrollToLine); + }; + requestAnimationFrame(scrollToLine); } } }); @@ -341,6 +368,16 @@ export const ChangesTab = memo(function ChangesTab({ onUpdateDraft={updateDraft} onStartReview={() => setReviewMode(true)} feedbackItems={feedbackItems} + forceLoadedFiles={forceLoadedFiles} + onForceLoad={(path) => { + setForceLoadedFiles((prev) => { + if (prev.has(path)) return prev; + return new Set(prev).add(path); + }); + }} + onRegisterScrollToFile={(fn) => { + scrollToVirtualFileRef.current = fn; + }} />
@@ -602,6 +639,9 @@ type DiffPaneProps = { onUpdateDraft?: (id: string, comment: string) => void; onStartReview?: () => void; feedbackItems?: ReviewFeedbackItem[]; + forceLoadedFiles: Set; + onForceLoad: (path: string) => void; + onRegisterScrollToFile?: (fn: ((path: string) => void) | null) => void; }; function DiffPane({ @@ -625,48 +665,207 @@ function DiffPane({ onUpdateDraft, onStartReview, feedbackItems, + forceLoadedFiles, + onForceLoad, + onRegisterScrollToFile, }: DiffPaneProps): JSX.Element { + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); + const [measuredSizes, setMeasuredSizes] = useState>( + () => new Map() + ); + + const fileIndexByPath = useMemo(() => { + const indexes = new Map(); + files.forEach((file, index) => indexes.set(file.path, index)); + return indexes; + }, [files]); + + const estimatedSizes = useMemo( + () => + files.map((file) => { + const measured = measuredSizes.get(file.path); + if (measured != null) return measured; + return estimateFileDiffHeight(file, collapsedFiles.has(file.path)); + }), + [collapsedFiles, files, measuredSizes] + ); + + const offsets = useMemo(() => { + const next: number[] = []; + let cursor = 0; + for (const size of estimatedSizes) { + next.push(cursor); + cursor += size; + } + return { positions: next, total: cursor }; + }, [estimatedSizes]); + + const visibleRange = useMemo(() => { + const min = Math.max(0, scrollTop - DIFF_FILE_OVERSCAN_PX); + const max = scrollTop + viewportHeight + DIFF_FILE_OVERSCAN_PX; + let start = 0; + while ( + start < files.length && + offsets.positions[start]! + estimatedSizes[start]! < min + ) { + start += 1; + } + let end = start; + while (end < files.length && offsets.positions[end]! < max) { + end += 1; + } + return { + start, + end: Math.min(files.length, Math.max(end, start + 1)), + }; + }, [ + estimatedSizes, + files.length, + offsets.positions, + scrollTop, + viewportHeight, + ]); + + const visibleFiles = files.slice(visibleRange.start, visibleRange.end); + const topSpacer = offsets.positions[visibleRange.start] ?? 0; + const bottomSpacer = + offsets.total - + (offsets.positions[visibleRange.end] ?? + offsets.positions[visibleRange.end - 1]! + + (estimatedSizes[visibleRange.end - 1] ?? 0)); + + const handleScroll = useCallback( + (event: React.UIEvent) => { + setScrollTop(event.currentTarget.scrollTop); + onScroll(); + }, + [onScroll] + ); + + const measureFile = useCallback( + (path: string, height: number) => { + const rounded = Math.ceil(height) + DIFF_FILE_GAP_PX; + const previous = measuredSizes.get(path); + if (previous === rounded) return; + + const index = fileIndexByPath.get(path); + const currentSize = + index == null + ? (previous ?? rounded) + : (estimatedSizes[index] ?? rounded); + setMeasuredSizes((prev) => new Map(prev).set(path, rounded)); + + // Keep the first visible file anchored while a measured height replaces an estimate above it. + if ( + index != null && + index < visibleRange.start && + rounded !== currentSize + ) { + const el = scrollRef.current; + if (el) { + const nextScrollTop = Math.max( + 0, + el.scrollTop + rounded - currentSize + ); + el.scrollTop = nextScrollTop; + setScrollTop(nextScrollTop); + } + } + }, + [ + estimatedSizes, + fileIndexByPath, + measuredSizes, + scrollRef, + visibleRange.start, + ] + ); + + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) return; + setScrollTop(el.scrollTop); + setViewportHeight(el.clientHeight); + + const observer = new ResizeObserver(() => { + setViewportHeight(el.clientHeight); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [scrollRef]); + + useEffect(() => { + onRegisterScrollToFile?.((path: string) => { + const index = fileIndexByPath.get(path); + if (index == null) return; + scrollRef.current?.scrollTo({ + top: offsets.positions[index] ?? 0, + behavior: "smooth", + }); + }); + return () => onRegisterScrollToFile?.(null); + }, [fileIndexByPath, offsets.positions, onRegisterScrollToFile, scrollRef]); + return (
- {files.map((file) => ( - onToggleCollapse(file.path)} - setRef={(el) => { - if (el) { - fileRefs.current?.set(file.path, el); - } else { - fileRefs.current?.delete(file.path); - } - }} - lineSelection={lineSelection} - onLineSelection={onLineSelection} - commentOpen={commentOpen} - onCommentOpen={onCommentOpen} - viewType={viewType} - ignoreWhitespace={ignoreWhitespace} - reviewMode={reviewMode} - draftComments={draftComments?.filter((d) => d.filePath === file.path)} - onAddDraft={onAddDraft} - onRemoveDraft={onRemoveDraft} - onUpdateDraft={onUpdateDraft} - onStartReview={onStartReview} - feedbackItems={feedbackItems?.filter( - (fi) => fi.filePath === file.path - )} - /> - ))} +
+
+ {visibleFiles.map((file) => ( + onToggleCollapse(file.path)} + setRef={(el) => { + if (el) { + fileRefs.current?.set(file.path, el); + } else { + fileRefs.current?.delete(file.path); + } + }} + onMeasure={measureFile} + lineSelection={lineSelection} + onLineSelection={onLineSelection} + commentOpen={commentOpen} + onCommentOpen={onCommentOpen} + viewType={viewType} + ignoreWhitespace={ignoreWhitespace} + reviewMode={reviewMode} + draftComments={draftComments?.filter( + (d) => d.filePath === file.path + )} + onAddDraft={onAddDraft} + onRemoveDraft={onRemoveDraft} + onUpdateDraft={onUpdateDraft} + onStartReview={onStartReview} + feedbackItems={feedbackItems?.filter( + (fi) => fi.filePath === file.path + )} + forceLoaded={forceLoadedFiles.has(file.path)} + onForceLoad={() => onForceLoad(file.path)} + /> + ))} +
+
); } +function estimateFileDiffHeight(file: DiffFile, collapsed: boolean): number { + if (collapsed) return DIFF_FILE_MIN_HEIGHT + DIFF_FILE_GAP_PX; + if (file.truncated || !file.diff) return 88 + DIFF_FILE_GAP_PX; + const estimated = + DIFF_FILE_MIN_HEIGHT + + Math.min(file.added + file.deleted + 12, 38) * DIFF_FILE_LINE_HEIGHT; + return Math.min(DIFF_FILE_MAX_ESTIMATED_HEIGHT, estimated) + DIFF_FILE_GAP_PX; +} + type FileDiffSectionProps = { agentId: string | null; file: DiffFile; @@ -691,8 +890,45 @@ type FileDiffSectionProps = { onUpdateDraft?: (id: string, comment: string) => void; onStartReview?: () => void; feedbackItems?: ReviewFeedbackItem[]; + forceLoaded: boolean; + onForceLoad: () => void; }; +type MeasuredFileDiffSectionProps = FileDiffSectionProps & { + onMeasure: (path: string, height: number) => void; +}; + +function MeasuredFileDiffSection({ + onMeasure, + setRef, + file, + ...props +}: MeasuredFileDiffSectionProps): JSX.Element { + const sectionRef = useRef(null); + + const setMeasuredRef = useCallback( + (el: HTMLDivElement | null) => { + sectionRef.current = el; + setRef(el); + }, + [setRef] + ); + + useLayoutEffect(() => { + const el = sectionRef.current; + if (!el) return; + onMeasure(file.path, el.getBoundingClientRect().height); + const observer = new ResizeObserver((entries) => { + const height = entries[0]?.contentRect.height; + if (height != null) onMeasure(file.path, height); + }); + observer.observe(el); + return () => observer.disconnect(); + }, [file.path, onMeasure]); + + return ; +} + function FileDiffSection({ agentId, file, @@ -712,6 +948,8 @@ function FileDiffSection({ onUpdateDraft, onStartReview, feedbackItems, + forceLoaded, + onForceLoad, }: FileDiffSectionProps): JSX.Element { return (
@@ -770,6 +1008,8 @@ function FileDiffSection({ onUpdateDraft={onUpdateDraft} onStartReview={onStartReview} feedbackItems={feedbackItems} + forceLoaded={forceLoaded} + onForceLoad={onForceLoad} /> )} @@ -799,6 +1039,8 @@ type FileDiffContentProps = { onUpdateDraft?: (id: string, comment: string) => void; onStartReview?: () => void; feedbackItems?: ReviewFeedbackItem[]; + forceLoaded: boolean; + onForceLoad: () => void; }; function FileDiffContent({ @@ -817,22 +1059,23 @@ function FileDiffContent({ onUpdateDraft, onStartReview, feedbackItems, + forceLoaded, + onForceLoad, }: FileDiffContentProps): JSX.Element { - const [forceLoad, setForceLoad] = useState(false); const { data: fileDiffData, isLoading: fileDiffLoading } = useAgentFileDiff( agentId, file.path, - file.truncated && forceLoad, + file.truncated && forceLoaded, ignoreWhitespace ); const diffText = file.truncated - ? forceLoad + ? forceLoaded ? (fileDiffData?.diff ?? null) : null : file.diff; - if (file.truncated && !forceLoad) { + if (file.truncated && !forceLoaded) { return (
@@ -842,7 +1085,7 @@ function FileDiffContent({ @@ -850,7 +1093,7 @@ function FileDiffContent({ ); } - if (file.truncated && forceLoad && fileDiffLoading) { + if (file.truncated && forceLoaded && fileDiffLoading) { return (
From e5ed1c2b0161605346498c8011e3480db1db8ba6 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 10 Jul 2026 15:24:41 -0600 Subject: [PATCH 2/6] Preserve diff scroll anchor --- apps/web/src/components/app/changes-tab.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/app/changes-tab.tsx b/apps/web/src/components/app/changes-tab.tsx index 3b86375d..44796521 100644 --- a/apps/web/src/components/app/changes-tab.tsx +++ b/apps/web/src/components/app/changes-tab.tsx @@ -727,6 +727,17 @@ function DiffPane({ viewportHeight, ]); + const scrollAnchorIndex = useMemo(() => { + let index = 0; + while ( + index < files.length && + offsets.positions[index]! + estimatedSizes[index]! <= scrollTop + ) { + index += 1; + } + return index; + }, [estimatedSizes, files.length, offsets.positions, scrollTop]); + const visibleFiles = files.slice(visibleRange.start, visibleRange.end); const topSpacer = offsets.positions[visibleRange.start] ?? 0; const bottomSpacer = @@ -756,10 +767,10 @@ function DiffPane({ : (estimatedSizes[index] ?? rounded); setMeasuredSizes((prev) => new Map(prev).set(path, rounded)); - // Keep the first visible file anchored while a measured height replaces an estimate above it. + // Keep the first viewport file anchored while a measured height replaces an estimate above it. if ( index != null && - index < visibleRange.start && + index < scrollAnchorIndex && rounded !== currentSize ) { const el = scrollRef.current; @@ -778,7 +789,7 @@ function DiffPane({ fileIndexByPath, measuredSizes, scrollRef, - visibleRange.start, + scrollAnchorIndex, ] ); From d8229b9fc4c2d6c066262a211df6db69379eb14c Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Fri, 10 Jul 2026 15:44:05 -0600 Subject: [PATCH 3/6] Use TanStack Virtual for changes diff --- apps/web/package.json | 1 + apps/web/src/components/app/changes-tab.tsx | 291 +++++--------------- pnpm-lock.yaml | 26 ++ 3 files changed, 97 insertions(+), 221 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 22a2fd3f..ddc202cb 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.91.2", + "@tanstack/react-virtual": "^3.14.5", "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", diff --git a/apps/web/src/components/app/changes-tab.tsx b/apps/web/src/components/app/changes-tab.tsx index 44796521..392ecb8e 100644 --- a/apps/web/src/components/app/changes-tab.tsx +++ b/apps/web/src/components/app/changes-tab.tsx @@ -1,12 +1,4 @@ -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtom, useAtomValue } from "jotai"; import { useSearchParams } from "react-router-dom"; import { @@ -23,6 +15,7 @@ import { } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; import { parseDiff } from "react-diff-view"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { useAgentDiff, @@ -57,7 +50,6 @@ type ChangesTabProps = { onReviewSubmitted?: (reviewId: number) => void; }; -const DIFF_FILE_OVERSCAN_PX = 1200; const DIFF_FILE_MIN_HEIGHT = 46; const DIFF_FILE_MAX_ESTIMATED_HEIGHT = 900; const DIFF_FILE_LINE_HEIGHT = 22; @@ -669,212 +661,104 @@ function DiffPane({ onForceLoad, onRegisterScrollToFile, }: DiffPaneProps): JSX.Element { - const [scrollTop, setScrollTop] = useState(0); - const [viewportHeight, setViewportHeight] = useState(0); - const [measuredSizes, setMeasuredSizes] = useState>( - () => new Map() - ); - const fileIndexByPath = useMemo(() => { const indexes = new Map(); files.forEach((file, index) => indexes.set(file.path, index)); return indexes; }, [files]); - const estimatedSizes = useMemo( - () => - files.map((file) => { - const measured = measuredSizes.get(file.path); - if (measured != null) return measured; - return estimateFileDiffHeight(file, collapsedFiles.has(file.path)); - }), - [collapsedFiles, files, measuredSizes] - ); - - const offsets = useMemo(() => { - const next: number[] = []; - let cursor = 0; - for (const size of estimatedSizes) { - next.push(cursor); - cursor += size; - } - return { positions: next, total: cursor }; - }, [estimatedSizes]); - - const visibleRange = useMemo(() => { - const min = Math.max(0, scrollTop - DIFF_FILE_OVERSCAN_PX); - const max = scrollTop + viewportHeight + DIFF_FILE_OVERSCAN_PX; - let start = 0; - while ( - start < files.length && - offsets.positions[start]! + estimatedSizes[start]! < min - ) { - start += 1; - } - let end = start; - while (end < files.length && offsets.positions[end]! < max) { - end += 1; - } - return { - start, - end: Math.min(files.length, Math.max(end, start + 1)), - }; - }, [ - estimatedSizes, - files.length, - offsets.positions, - scrollTop, - viewportHeight, - ]); - - const scrollAnchorIndex = useMemo(() => { - let index = 0; - while ( - index < files.length && - offsets.positions[index]! + estimatedSizes[index]! <= scrollTop - ) { - index += 1; - } - return index; - }, [estimatedSizes, files.length, offsets.positions, scrollTop]); - - const visibleFiles = files.slice(visibleRange.start, visibleRange.end); - const topSpacer = offsets.positions[visibleRange.start] ?? 0; - const bottomSpacer = - offsets.total - - (offsets.positions[visibleRange.end] ?? - offsets.positions[visibleRange.end - 1]! + - (estimatedSizes[visibleRange.end - 1] ?? 0)); - - const handleScroll = useCallback( - (event: React.UIEvent) => { - setScrollTop(event.currentTarget.scrollTop); - onScroll(); - }, - [onScroll] - ); - - const measureFile = useCallback( - (path: string, height: number) => { - const rounded = Math.ceil(height) + DIFF_FILE_GAP_PX; - const previous = measuredSizes.get(path); - if (previous === rounded) return; - - const index = fileIndexByPath.get(path); - const currentSize = - index == null - ? (previous ?? rounded) - : (estimatedSizes[index] ?? rounded); - setMeasuredSizes((prev) => new Map(prev).set(path, rounded)); - - // Keep the first viewport file anchored while a measured height replaces an estimate above it. - if ( - index != null && - index < scrollAnchorIndex && - rounded !== currentSize - ) { - const el = scrollRef.current; - if (el) { - const nextScrollTop = Math.max( - 0, - el.scrollTop + rounded - currentSize - ); - el.scrollTop = nextScrollTop; - setScrollTop(nextScrollTop); - } - } - }, - [ - estimatedSizes, - fileIndexByPath, - measuredSizes, - scrollRef, - scrollAnchorIndex, - ] - ); - - useLayoutEffect(() => { - const el = scrollRef.current; - if (!el) return; - setScrollTop(el.scrollTop); - setViewportHeight(el.clientHeight); - - const observer = new ResizeObserver(() => { - setViewportHeight(el.clientHeight); - }); - observer.observe(el); - return () => observer.disconnect(); - }, [scrollRef]); + const rowVirtualizer = useVirtualizer({ + count: files.length, + getScrollElement: () => scrollRef.current, + estimateSize: (index) => + estimateFileDiffHeight( + files[index]!, + collapsedFiles.has(files[index]!.path) + ), + getItemKey: (index) => files[index]!.path, + overscan: 4, + gap: DIFF_FILE_GAP_PX, + }); useEffect(() => { onRegisterScrollToFile?.((path: string) => { const index = fileIndexByPath.get(path); if (index == null) return; - scrollRef.current?.scrollTo({ - top: offsets.positions[index] ?? 0, + rowVirtualizer.scrollToIndex(index, { + align: "start", behavior: "smooth", }); }); return () => onRegisterScrollToFile?.(null); - }, [fileIndexByPath, offsets.positions, onRegisterScrollToFile, scrollRef]); + }, [fileIndexByPath, onRegisterScrollToFile, rowVirtualizer]); return (
-
-
- {visibleFiles.map((file) => ( - onToggleCollapse(file.path)} - setRef={(el) => { - if (el) { - fileRefs.current?.set(file.path, el); - } else { - fileRefs.current?.delete(file.path); - } - }} - onMeasure={measureFile} - lineSelection={lineSelection} - onLineSelection={onLineSelection} - commentOpen={commentOpen} - onCommentOpen={onCommentOpen} - viewType={viewType} - ignoreWhitespace={ignoreWhitespace} - reviewMode={reviewMode} - draftComments={draftComments?.filter( - (d) => d.filePath === file.path - )} - onAddDraft={onAddDraft} - onRemoveDraft={onRemoveDraft} - onUpdateDraft={onUpdateDraft} - onStartReview={onStartReview} - feedbackItems={feedbackItems?.filter( - (fi) => fi.filePath === file.path - )} - forceLoaded={forceLoadedFiles.has(file.path)} - onForceLoad={() => onForceLoad(file.path)} - /> - ))} +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const file = files[virtualRow.index]!; + return ( +
+ onToggleCollapse(file.path)} + setRef={(el) => { + if (el) { + fileRefs.current?.set(file.path, el); + } else { + fileRefs.current?.delete(file.path); + } + }} + lineSelection={lineSelection} + onLineSelection={onLineSelection} + commentOpen={commentOpen} + onCommentOpen={onCommentOpen} + viewType={viewType} + ignoreWhitespace={ignoreWhitespace} + reviewMode={reviewMode} + draftComments={draftComments?.filter( + (d) => d.filePath === file.path + )} + onAddDraft={onAddDraft} + onRemoveDraft={onRemoveDraft} + onUpdateDraft={onUpdateDraft} + onStartReview={onStartReview} + feedbackItems={feedbackItems?.filter( + (fi) => fi.filePath === file.path + )} + forceLoaded={forceLoadedFiles.has(file.path)} + onForceLoad={() => onForceLoad(file.path)} + /> +
+ ); + })}
-
); } function estimateFileDiffHeight(file: DiffFile, collapsed: boolean): number { - if (collapsed) return DIFF_FILE_MIN_HEIGHT + DIFF_FILE_GAP_PX; - if (file.truncated || !file.diff) return 88 + DIFF_FILE_GAP_PX; + if (collapsed) return DIFF_FILE_MIN_HEIGHT; + if (file.truncated || !file.diff) return 88; const estimated = DIFF_FILE_MIN_HEIGHT + Math.min(file.added + file.deleted + 12, 38) * DIFF_FILE_LINE_HEIGHT; - return Math.min(DIFF_FILE_MAX_ESTIMATED_HEIGHT, estimated) + DIFF_FILE_GAP_PX; + return Math.min(DIFF_FILE_MAX_ESTIMATED_HEIGHT, estimated); } type FileDiffSectionProps = { @@ -905,41 +789,6 @@ type FileDiffSectionProps = { onForceLoad: () => void; }; -type MeasuredFileDiffSectionProps = FileDiffSectionProps & { - onMeasure: (path: string, height: number) => void; -}; - -function MeasuredFileDiffSection({ - onMeasure, - setRef, - file, - ...props -}: MeasuredFileDiffSectionProps): JSX.Element { - const sectionRef = useRef(null); - - const setMeasuredRef = useCallback( - (el: HTMLDivElement | null) => { - sectionRef.current = el; - setRef(el); - }, - [setRef] - ); - - useLayoutEffect(() => { - const el = sectionRef.current; - if (!el) return; - onMeasure(file.path, el.getBoundingClientRect().height); - const observer = new ResizeObserver((entries) => { - const height = entries[0]?.contentRect.height; - if (height != null) onMeasure(file.path, height); - }); - observer.observe(el); - return () => observer.disconnect(); - }, [file.path, onMeasure]); - - return ; -} - function FileDiffSection({ agentId, file, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fc467e22..343e67f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: "@tanstack/react-query": specifier: ^5.91.2 version: 5.95.2(react@18.3.1) + "@tanstack/react-virtual": + specifier: ^3.14.5 + version: 3.14.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) "@xterm/addon-clipboard": specifier: ^0.2.0 version: 0.2.0 @@ -3285,6 +3288,21 @@ packages: peerDependencies: react: ^18 || ^19 + "@tanstack/react-virtual@3.14.5": + resolution: + { + integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==, + } + 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 + + "@tanstack/virtual-core@3.17.3": + resolution: + { + integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==, + } + "@testing-library/dom@10.4.1": resolution: { @@ -11619,6 +11637,14 @@ snapshots: "@tanstack/query-core": 5.95.2 react: 18.3.1 + "@tanstack/react-virtual@3.14.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": + dependencies: + "@tanstack/virtual-core": 3.17.3 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + "@tanstack/virtual-core@3.17.3": {} + "@testing-library/dom@10.4.1": dependencies: "@babel/code-frame": 7.29.0 From b4cc06d308f9f1bb85063cb8bcb2bb451c20ee52 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sat, 11 Jul 2026 13:14:25 -0600 Subject: [PATCH 4/6] Fix virtualized diff navigation --- apps/web/src/components/app/changes-tab.tsx | 159 ++++++++++++------ .../src/components/app/unified-diff-view.tsx | 1 + 2 files changed, 111 insertions(+), 49 deletions(-) diff --git a/apps/web/src/components/app/changes-tab.tsx b/apps/web/src/components/app/changes-tab.tsx index 392ecb8e..efba452a 100644 --- a/apps/web/src/components/app/changes-tab.tsx +++ b/apps/web/src/components/app/changes-tab.tsx @@ -1,4 +1,12 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useAtom, useAtomValue } from "jotai"; import { useSearchParams } from "react-router-dom"; import { @@ -179,7 +187,12 @@ export const ChangesTab = memo(function ChangesTab({ const [commentOpen, setCommentOpen] = useState(false); const [fileTreeOpen, setFileTreeOpen] = useAtom(diffFileTreeOpenAtom); const fileRefs = useRef>(new Map()); - const scrollToVirtualFileRef = useRef<((path: string) => void) | null>(null); + const scrollToVirtualFileRef = useRef<((path: string) => boolean) | null>( + null + ); + const [pendingVirtualScrollPath, setPendingVirtualScrollPath] = useState< + string | null + >(null); const handleLineSelection = useCallback((sel: LineSelection | null) => { setLineSelection(sel); @@ -195,9 +208,11 @@ export const ChangesTab = memo(function ChangesTab({ (path: string) => { setSelectedFile(path); const el = fileRefs.current.get(path); + const canScroll = Boolean(el || scrollToVirtualFileRef.current); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); } else { + setPendingVirtualScrollPath(path); scrollToVirtualFileRef.current?.(path); } setViewState((prev) => { @@ -206,57 +221,61 @@ export const ChangesTab = memo(function ChangesTab({ s.delete(path); return { ...prev, collapsedFiles: [...s] }; }); + return canScroll; }, [setViewState] ); - const [searchParams, setSearchParams] = useSearchParams(); + const [searchParams] = useSearchParams(); const navFileTarget = searchParams.get("file"); const navLineTarget = searchParams.get("line"); useEffect(() => { if (!navFileTarget || files.length === 0) return; const targetFile = files.find((f) => f.path === navFileTarget); - setSearchParams({}, { replace: true }); - if (targetFile) { - requestAnimationFrame(() => { - scrollToFile(navFileTarget); - if (navLineTarget && targetFile.diff) { - const lineNum = Number(navLineTarget); - if (Number.isInteger(lineNum) && lineNum > 0) { - let attempts = 0; - const scrollToLine = () => { - try { - const parsed = parseDiff(targetFile.diff!, { - nearbySequences: "zip", - }); - const hunks = parsed[0]?.hunks ?? []; - const changeKey = findLastChangeKeyInRange( - hunks, - lineNum, - lineNum - ); - if (changeKey) { - const el = scrollRef.current?.querySelector( - `[id="${CSS.escape(changeKey)}"]` - ); - if (el) { - el.scrollIntoView({ block: "center", behavior: "smooth" }); - return; - } - } - } catch { - // diff parse failed — fall back to file-level scroll - } - // The target file may need to enter the virtual window first. - if (attempts++ < 60) requestAnimationFrame(scrollToLine); - }; - requestAnimationFrame(scrollToLine); + if (!targetFile) { + return; + } + + let fileScrollAttempts = 0; + const scrollToTarget = () => { + if (!scrollToFile(navFileTarget)) { + if (fileScrollAttempts++ < 60) requestAnimationFrame(scrollToTarget); + return; + } + if (!navLineTarget || !targetFile.diff) { + return; + } + + const lineNum = Number(navLineTarget); + if (!Number.isInteger(lineNum) || lineNum <= 0) return; + + let lineScrollAttempts = 0; + const scrollToLine = () => { + try { + const parsed = parseDiff(targetFile.diff!, { + nearbySequences: "zip", + }); + const hunks = parsed[0]?.hunks ?? []; + const changeKey = findLastChangeKeyInRange(hunks, lineNum, lineNum); + if (changeKey) { + const el = scrollRef.current?.querySelector( + `[id="${CSS.escape(changeKey)}"]` + ); + if (el) { + el.scrollIntoView({ block: "center", behavior: "smooth" }); + return; + } } + } catch { + // Diff parse failed — fall back to file-level scroll. } - }); - } - }, [navFileTarget, navLineTarget, files, scrollToFile, setSearchParams]); + if (lineScrollAttempts++ < 60) requestAnimationFrame(scrollToLine); + }; + requestAnimationFrame(scrollToLine); + }; + requestAnimationFrame(scrollToTarget); + }, [navFileTarget, navLineTarget, files, scrollToFile, setViewState]); const scrollRef = useRef(null); const scrollTimerRef = useRef | null>(null); @@ -361,6 +380,10 @@ export const ChangesTab = memo(function ChangesTab({ onStartReview={() => setReviewMode(true)} feedbackItems={feedbackItems} forceLoadedFiles={forceLoadedFiles} + pendingVirtualScrollPath={pendingVirtualScrollPath} + onPendingVirtualScrollHandled={() => + setPendingVirtualScrollPath(null) + } onForceLoad={(path) => { setForceLoadedFiles((prev) => { if (prev.has(path)) return prev; @@ -632,8 +655,10 @@ type DiffPaneProps = { onStartReview?: () => void; feedbackItems?: ReviewFeedbackItem[]; forceLoadedFiles: Set; + pendingVirtualScrollPath: string | null; + onPendingVirtualScrollHandled: () => void; onForceLoad: (path: string) => void; - onRegisterScrollToFile?: (fn: ((path: string) => void) | null) => void; + onRegisterScrollToFile?: (fn: ((path: string) => boolean) | null) => void; }; function DiffPane({ @@ -658,6 +683,8 @@ function DiffPane({ onStartReview, feedbackItems, forceLoadedFiles, + pendingVirtualScrollPath, + onPendingVirtualScrollHandled, onForceLoad, onRegisterScrollToFile, }: DiffPaneProps): JSX.Element { @@ -680,17 +707,51 @@ function DiffPane({ gap: DIFF_FILE_GAP_PX, }); - useEffect(() => { + useLayoutEffect(() => { onRegisterScrollToFile?.((path: string) => { const index = fileIndexByPath.get(path); - if (index == null) return; - rowVirtualizer.scrollToIndex(index, { - align: "start", + const scrollElement = scrollRef.current; + if (index == null || !scrollElement) return false; + const offset = rowVirtualizer.getOffsetForIndex(index, "start")?.[0]; + if (offset == null) return false; + scrollElement.scrollTo({ + top: offset, behavior: "smooth", }); + return true; }); return () => onRegisterScrollToFile?.(null); - }, [fileIndexByPath, onRegisterScrollToFile, rowVirtualizer]); + }, [fileIndexByPath, onRegisterScrollToFile, rowVirtualizer, scrollRef]); + + useLayoutEffect(() => { + if (!pendingVirtualScrollPath || !scrollRef.current) return; + let cancelled = false; + let attempts = 0; + const scrollToTarget = () => { + if (cancelled) return; + const index = fileIndexByPath.get(pendingVirtualScrollPath); + const offset = + index == null + ? undefined + : rowVirtualizer.getOffsetForIndex(index, "start")?.[0]; + if (offset != null && scrollRef.current) { + scrollRef.current.scrollTo({ top: offset, behavior: "auto" }); + onPendingVirtualScrollHandled(); + return; + } + if (attempts++ < 60) requestAnimationFrame(scrollToTarget); + }; + requestAnimationFrame(scrollToTarget); + return () => { + cancelled = true; + }; + }, [ + fileIndexByPath, + onPendingVirtualScrollHandled, + pendingVirtualScrollPath, + rowVirtualizer, + scrollRef, + ]); return (
Date: Sat, 11 Jul 2026 13:35:22 -0600 Subject: [PATCH 5/6] Stabilize virtual diff line navigation --- apps/web/src/components/app/changes-tab.tsx | 28 +-------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/apps/web/src/components/app/changes-tab.tsx b/apps/web/src/components/app/changes-tab.tsx index efba452a..e8002383 100644 --- a/apps/web/src/components/app/changes-tab.tsx +++ b/apps/web/src/components/app/changes-tab.tsx @@ -187,9 +187,6 @@ export const ChangesTab = memo(function ChangesTab({ const [commentOpen, setCommentOpen] = useState(false); const [fileTreeOpen, setFileTreeOpen] = useAtom(diffFileTreeOpenAtom); const fileRefs = useRef>(new Map()); - const scrollToVirtualFileRef = useRef<((path: string) => boolean) | null>( - null - ); const [pendingVirtualScrollPath, setPendingVirtualScrollPath] = useState< string | null >(null); @@ -208,12 +205,10 @@ export const ChangesTab = memo(function ChangesTab({ (path: string) => { setSelectedFile(path); const el = fileRefs.current.get(path); - const canScroll = Boolean(el || scrollToVirtualFileRef.current); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); } else { setPendingVirtualScrollPath(path); - scrollToVirtualFileRef.current?.(path); } setViewState((prev) => { const s = new Set(prev.collapsedFiles); @@ -221,7 +216,7 @@ export const ChangesTab = memo(function ChangesTab({ s.delete(path); return { ...prev, collapsedFiles: [...s] }; }); - return canScroll; + return true; }, [setViewState] ); @@ -390,9 +385,6 @@ export const ChangesTab = memo(function ChangesTab({ return new Set(prev).add(path); }); }} - onRegisterScrollToFile={(fn) => { - scrollToVirtualFileRef.current = fn; - }} />
@@ -658,7 +650,6 @@ type DiffPaneProps = { pendingVirtualScrollPath: string | null; onPendingVirtualScrollHandled: () => void; onForceLoad: (path: string) => void; - onRegisterScrollToFile?: (fn: ((path: string) => boolean) | null) => void; }; function DiffPane({ @@ -686,7 +677,6 @@ function DiffPane({ pendingVirtualScrollPath, onPendingVirtualScrollHandled, onForceLoad, - onRegisterScrollToFile, }: DiffPaneProps): JSX.Element { const fileIndexByPath = useMemo(() => { const indexes = new Map(); @@ -707,22 +697,6 @@ function DiffPane({ gap: DIFF_FILE_GAP_PX, }); - useLayoutEffect(() => { - onRegisterScrollToFile?.((path: string) => { - const index = fileIndexByPath.get(path); - const scrollElement = scrollRef.current; - if (index == null || !scrollElement) return false; - const offset = rowVirtualizer.getOffsetForIndex(index, "start")?.[0]; - if (offset == null) return false; - scrollElement.scrollTo({ - top: offset, - behavior: "smooth", - }); - return true; - }); - return () => onRegisterScrollToFile?.(null); - }, [fileIndexByPath, onRegisterScrollToFile, rowVirtualizer, scrollRef]); - useLayoutEffect(() => { if (!pendingVirtualScrollPath || !scrollRef.current) return; let cancelled = false; From 8c9dc1d196f9f679b954666449cf7f4982c21434 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Sat, 11 Jul 2026 15:32:53 -0600 Subject: [PATCH 6/6] Keep changes view mount guard --- apps/web/package.json | 1 - apps/web/src/components/app/changes-tab.tsx | 280 +++++------------- .../src/components/app/unified-diff-view.tsx | 1 - pnpm-lock.yaml | 26 -- 4 files changed, 71 insertions(+), 237 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index ddc202cb..22a2fd3f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,7 +22,6 @@ "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.91.2", - "@tanstack/react-virtual": "^3.14.5", "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-unicode11": "^0.9.0", diff --git a/apps/web/src/components/app/changes-tab.tsx b/apps/web/src/components/app/changes-tab.tsx index e8002383..cd78489f 100644 --- a/apps/web/src/components/app/changes-tab.tsx +++ b/apps/web/src/components/app/changes-tab.tsx @@ -1,12 +1,4 @@ -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtom, useAtomValue } from "jotai"; import { useSearchParams } from "react-router-dom"; import { @@ -23,7 +15,6 @@ import { } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; import { parseDiff } from "react-diff-view"; -import { useVirtualizer } from "@tanstack/react-virtual"; import { useAgentDiff, @@ -58,11 +49,6 @@ type ChangesTabProps = { onReviewSubmitted?: (reviewId: number) => void; }; -const DIFF_FILE_MIN_HEIGHT = 46; -const DIFF_FILE_MAX_ESTIMATED_HEIGHT = 900; -const DIFF_FILE_LINE_HEIGHT = 22; -const DIFF_FILE_GAP_PX = 12; - export const ChangesTab = memo(function ChangesTab({ agentId, active, @@ -178,18 +164,12 @@ export const ChangesTab = memo(function ChangesTab({ ); const [selectedFile, setSelectedFile] = useState(null); - const [forceLoadedFiles, setForceLoadedFiles] = useState>( - () => new Set() - ); const [lineSelection, setLineSelection] = useState( null ); const [commentOpen, setCommentOpen] = useState(false); const [fileTreeOpen, setFileTreeOpen] = useAtom(diffFileTreeOpenAtom); const fileRefs = useRef>(new Map()); - const [pendingVirtualScrollPath, setPendingVirtualScrollPath] = useState< - string | null - >(null); const handleLineSelection = useCallback((sel: LineSelection | null) => { setLineSelection(sel); @@ -207,8 +187,6 @@ export const ChangesTab = memo(function ChangesTab({ const el = fileRefs.current.get(path); if (el) { el.scrollIntoView({ behavior: "smooth", block: "start" }); - } else { - setPendingVirtualScrollPath(path); } setViewState((prev) => { const s = new Set(prev.collapsedFiles); @@ -216,61 +194,50 @@ export const ChangesTab = memo(function ChangesTab({ s.delete(path); return { ...prev, collapsedFiles: [...s] }; }); - return true; }, [setViewState] ); - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const navFileTarget = searchParams.get("file"); const navLineTarget = searchParams.get("line"); useEffect(() => { if (!navFileTarget || files.length === 0) return; const targetFile = files.find((f) => f.path === navFileTarget); - if (!targetFile) { - return; - } - - let fileScrollAttempts = 0; - const scrollToTarget = () => { - if (!scrollToFile(navFileTarget)) { - if (fileScrollAttempts++ < 60) requestAnimationFrame(scrollToTarget); - return; - } - if (!navLineTarget || !targetFile.diff) { - return; - } - - const lineNum = Number(navLineTarget); - if (!Number.isInteger(lineNum) || lineNum <= 0) return; - - let lineScrollAttempts = 0; - const scrollToLine = () => { - try { - const parsed = parseDiff(targetFile.diff!, { - nearbySequences: "zip", - }); - const hunks = parsed[0]?.hunks ?? []; - const changeKey = findLastChangeKeyInRange(hunks, lineNum, lineNum); - if (changeKey) { - const el = scrollRef.current?.querySelector( - `[id="${CSS.escape(changeKey)}"]` - ); - if (el) { - el.scrollIntoView({ block: "center", behavior: "smooth" }); - return; - } + setSearchParams({}, { replace: true }); + if (targetFile) { + requestAnimationFrame(() => { + scrollToFile(navFileTarget); + if (navLineTarget && targetFile.diff) { + const lineNum = Number(navLineTarget); + if (Number.isInteger(lineNum) && lineNum > 0) { + requestAnimationFrame(() => { + try { + const parsed = parseDiff(targetFile.diff!, { + nearbySequences: "zip", + }); + const hunks = parsed[0]?.hunks ?? []; + const changeKey = findLastChangeKeyInRange( + hunks, + lineNum, + lineNum + ); + if (changeKey) { + const el = scrollRef.current?.querySelector( + `[id="${CSS.escape(changeKey)}"]` + ); + el?.scrollIntoView({ block: "center", behavior: "smooth" }); + } + } catch { + // diff parse failed — fall back to file-level scroll + } + }); } - } catch { - // Diff parse failed — fall back to file-level scroll. } - if (lineScrollAttempts++ < 60) requestAnimationFrame(scrollToLine); - }; - requestAnimationFrame(scrollToLine); - }; - requestAnimationFrame(scrollToTarget); - }, [navFileTarget, navLineTarget, files, scrollToFile, setViewState]); + }); + } + }, [navFileTarget, navLineTarget, files, scrollToFile, setSearchParams]); const scrollRef = useRef(null); const scrollTimerRef = useRef | null>(null); @@ -374,17 +341,6 @@ export const ChangesTab = memo(function ChangesTab({ onUpdateDraft={updateDraft} onStartReview={() => setReviewMode(true)} feedbackItems={feedbackItems} - forceLoadedFiles={forceLoadedFiles} - pendingVirtualScrollPath={pendingVirtualScrollPath} - onPendingVirtualScrollHandled={() => - setPendingVirtualScrollPath(null) - } - onForceLoad={(path) => { - setForceLoadedFiles((prev) => { - if (prev.has(path)) return prev; - return new Set(prev).add(path); - }); - }} />
@@ -646,10 +602,6 @@ type DiffPaneProps = { onUpdateDraft?: (id: string, comment: string) => void; onStartReview?: () => void; feedbackItems?: ReviewFeedbackItem[]; - forceLoadedFiles: Set; - pendingVirtualScrollPath: string | null; - onPendingVirtualScrollHandled: () => void; - onForceLoad: (path: string) => void; }; function DiffPane({ @@ -673,129 +625,48 @@ function DiffPane({ onUpdateDraft, onStartReview, feedbackItems, - forceLoadedFiles, - pendingVirtualScrollPath, - onPendingVirtualScrollHandled, - onForceLoad, }: DiffPaneProps): JSX.Element { - const fileIndexByPath = useMemo(() => { - const indexes = new Map(); - files.forEach((file, index) => indexes.set(file.path, index)); - return indexes; - }, [files]); - - const rowVirtualizer = useVirtualizer({ - count: files.length, - getScrollElement: () => scrollRef.current, - estimateSize: (index) => - estimateFileDiffHeight( - files[index]!, - collapsedFiles.has(files[index]!.path) - ), - getItemKey: (index) => files[index]!.path, - overscan: 4, - gap: DIFF_FILE_GAP_PX, - }); - - useLayoutEffect(() => { - if (!pendingVirtualScrollPath || !scrollRef.current) return; - let cancelled = false; - let attempts = 0; - const scrollToTarget = () => { - if (cancelled) return; - const index = fileIndexByPath.get(pendingVirtualScrollPath); - const offset = - index == null - ? undefined - : rowVirtualizer.getOffsetForIndex(index, "start")?.[0]; - if (offset != null && scrollRef.current) { - scrollRef.current.scrollTo({ top: offset, behavior: "auto" }); - onPendingVirtualScrollHandled(); - return; - } - if (attempts++ < 60) requestAnimationFrame(scrollToTarget); - }; - requestAnimationFrame(scrollToTarget); - return () => { - cancelled = true; - }; - }, [ - fileIndexByPath, - onPendingVirtualScrollHandled, - pendingVirtualScrollPath, - rowVirtualizer, - scrollRef, - ]); - return (
-
- {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const file = files[virtualRow.index]!; - return ( -
- onToggleCollapse(file.path)} - setRef={(el) => { - if (el) { - fileRefs.current?.set(file.path, el); - } else { - fileRefs.current?.delete(file.path); - } - }} - lineSelection={lineSelection} - onLineSelection={onLineSelection} - commentOpen={commentOpen} - onCommentOpen={onCommentOpen} - viewType={viewType} - ignoreWhitespace={ignoreWhitespace} - reviewMode={reviewMode} - draftComments={draftComments?.filter( - (d) => d.filePath === file.path - )} - onAddDraft={onAddDraft} - onRemoveDraft={onRemoveDraft} - onUpdateDraft={onUpdateDraft} - onStartReview={onStartReview} - feedbackItems={feedbackItems?.filter( - (fi) => fi.filePath === file.path - )} - forceLoaded={forceLoadedFiles.has(file.path)} - onForceLoad={() => onForceLoad(file.path)} - /> -
- ); - })} -
+ {files.map((file) => ( + onToggleCollapse(file.path)} + setRef={(el) => { + if (el) { + fileRefs.current?.set(file.path, el); + } else { + fileRefs.current?.delete(file.path); + } + }} + lineSelection={lineSelection} + onLineSelection={onLineSelection} + commentOpen={commentOpen} + onCommentOpen={onCommentOpen} + viewType={viewType} + ignoreWhitespace={ignoreWhitespace} + reviewMode={reviewMode} + draftComments={draftComments?.filter((d) => d.filePath === file.path)} + onAddDraft={onAddDraft} + onRemoveDraft={onRemoveDraft} + onUpdateDraft={onUpdateDraft} + onStartReview={onStartReview} + feedbackItems={feedbackItems?.filter( + (fi) => fi.filePath === file.path + )} + /> + ))}
); } -function estimateFileDiffHeight(file: DiffFile, collapsed: boolean): number { - if (collapsed) return DIFF_FILE_MIN_HEIGHT; - if (file.truncated || !file.diff) return 88; - const estimated = - DIFF_FILE_MIN_HEIGHT + - Math.min(file.added + file.deleted + 12, 38) * DIFF_FILE_LINE_HEIGHT; - return Math.min(DIFF_FILE_MAX_ESTIMATED_HEIGHT, estimated); -} - type FileDiffSectionProps = { agentId: string | null; file: DiffFile; @@ -820,8 +691,6 @@ type FileDiffSectionProps = { onUpdateDraft?: (id: string, comment: string) => void; onStartReview?: () => void; feedbackItems?: ReviewFeedbackItem[]; - forceLoaded: boolean; - onForceLoad: () => void; }; function FileDiffSection({ @@ -843,8 +712,6 @@ function FileDiffSection({ onUpdateDraft, onStartReview, feedbackItems, - forceLoaded, - onForceLoad, }: FileDiffSectionProps): JSX.Element { return (
@@ -903,8 +770,6 @@ function FileDiffSection({ onUpdateDraft={onUpdateDraft} onStartReview={onStartReview} feedbackItems={feedbackItems} - forceLoaded={forceLoaded} - onForceLoad={onForceLoad} /> )} @@ -934,8 +799,6 @@ type FileDiffContentProps = { onUpdateDraft?: (id: string, comment: string) => void; onStartReview?: () => void; feedbackItems?: ReviewFeedbackItem[]; - forceLoaded: boolean; - onForceLoad: () => void; }; function FileDiffContent({ @@ -954,23 +817,22 @@ function FileDiffContent({ onUpdateDraft, onStartReview, feedbackItems, - forceLoaded, - onForceLoad, }: FileDiffContentProps): JSX.Element { + const [forceLoad, setForceLoad] = useState(false); const { data: fileDiffData, isLoading: fileDiffLoading } = useAgentFileDiff( agentId, file.path, - file.truncated && forceLoaded, + file.truncated && forceLoad, ignoreWhitespace ); const diffText = file.truncated - ? forceLoaded + ? forceLoad ? (fileDiffData?.diff ?? null) : null : file.diff; - if (file.truncated && !forceLoaded) { + if (file.truncated && !forceLoad) { return (
@@ -980,7 +842,7 @@ function FileDiffContent({ @@ -988,7 +850,7 @@ function FileDiffContent({ ); } - if (file.truncated && forceLoaded && fileDiffLoading) { + if (file.truncated && forceLoad && fileDiffLoading) { return (
diff --git a/apps/web/src/components/app/unified-diff-view.tsx b/apps/web/src/components/app/unified-diff-view.tsx index 15d41629..1b4df4ad 100644 --- a/apps/web/src/components/app/unified-diff-view.tsx +++ b/apps/web/src/components/app/unified-diff-view.tsx @@ -504,7 +504,6 @@ export const UnifiedDiffView = memo(function UnifiedDiffView({ viewType={viewType} diffType={diffType} hunks={file.hunks} - generateAnchorID={getChangeKey} tokens={tokens} gutterEvents={gutterEvents} selectedChanges={selectedChanges} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 343e67f8..fc467e22 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,9 +122,6 @@ importers: "@tanstack/react-query": specifier: ^5.91.2 version: 5.95.2(react@18.3.1) - "@tanstack/react-virtual": - specifier: ^3.14.5 - version: 3.14.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) "@xterm/addon-clipboard": specifier: ^0.2.0 version: 0.2.0 @@ -3288,21 +3285,6 @@ packages: peerDependencies: react: ^18 || ^19 - "@tanstack/react-virtual@3.14.5": - resolution: - { - integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==, - } - 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 - - "@tanstack/virtual-core@3.17.3": - resolution: - { - integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==, - } - "@testing-library/dom@10.4.1": resolution: { @@ -11637,14 +11619,6 @@ snapshots: "@tanstack/query-core": 5.95.2 react: 18.3.1 - "@tanstack/react-virtual@3.14.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)": - dependencies: - "@tanstack/virtual-core": 3.17.3 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - - "@tanstack/virtual-core@3.17.3": {} - "@testing-library/dom@10.4.1": dependencies: "@babel/code-frame": 7.29.0