diff --git a/site/src/pages/AgentsPage/DiffViewer.tsx b/site/src/pages/AgentsPage/DiffViewer.tsx index 1ed1b07f59..064c5aad7a 100644 --- a/site/src/pages/AgentsPage/DiffViewer.tsx +++ b/site/src/pages/AgentsPage/DiffViewer.tsx @@ -13,6 +13,7 @@ import { ChevronRightIcon } from "lucide-react"; import { type ComponentProps, type FC, + memo, type ReactNode, useCallback, useEffect, @@ -340,66 +341,68 @@ const FileTreeNodeView: FC<{ * FileDiff that the user has already scrolled past, which avoids * layout shifts and repeated highlighting work. */ -const LazyFileDiff: FC<{ +const LazyFileDiff = memo<{ fileDiff: FileDiffMetadata; options: ComponentProps["options"]; lineAnnotations?: DiffLineAnnotation[]; renderAnnotation?: (annotation: DiffLineAnnotation) => ReactNode; -}> = ({ - fileDiff, - options, - lineAnnotations, - renderAnnotation: renderAnnotationProp, -}) => { - const placeholderRef = useRef(null); - const [visible, setVisible] = useState(false); +}>( + ({ + fileDiff, + options, + lineAnnotations, + renderAnnotation: renderAnnotationProp, + }) => { + const placeholderRef = useRef(null); + const [visible, setVisible] = useState(false); - useEffect(() => { - const el = placeholderRef.current; - if (!el || visible) { - return; + useEffect(() => { + const el = placeholderRef.current; + if (!el || visible) { + return; + } + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setVisible(true); + observer.disconnect(); + } + }, + // Pre-load files that are within one viewport-height of + // the visible area so they are ready before the user + // scrolls to them. + { rootMargin: "100% 0px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [visible]); + + if (!visible) { + return ( +
+ + + + +
+ ); } - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { - setVisible(true); - observer.disconnect(); - } - }, - // Pre-load files that are within one viewport-height of - // the visible area so they are ready before the user - // scrolls to them. - { rootMargin: "100% 0px" }, - ); - observer.observe(el); - return () => observer.disconnect(); - }, [visible]); - if (!visible) { return ( -
- - - - -
+ ); - } - - return ( - - ); -}; + }, +); // ------------------------------------------------------------------- // Main component @@ -505,6 +508,30 @@ export const DiffViewer: FC = ({ ); }, [fileTree, parsedFiles]); + // Pre-compute per-file options so each LazyFileDiff receives a + // stable reference and avoids re-highlighting on parent re-render. + const perFileOptions = useMemo(() => { + if (!hasPerFileCallbacks) return null; + const map = new Map["options"]>(); + for (const file of sortedFiles) { + map.set(file.name, getOptionsForFile(file.name)); + } + return map; + }, [hasPerFileCallbacks, sortedFiles, getOptionsForFile]); + + // Pre-compute per-file line annotations for the same reason. + const perFileAnnotations = useMemo(() => { + if (!getLineAnnotations) return null; + const map = new Map[]>(); + for (const file of sortedFiles) { + const annotations = getLineAnnotations(file.name); + if (annotations.length > 0) { + map.set(file.name, annotations); + } + } + return map; + }, [sortedFiles, getLineAnnotations]); + // --------------------------------------------------------------- // Container width measurement via ResizeObserver so we can decide // whether to show the file tree sidebar without a prop from the @@ -730,12 +757,8 @@ export const DiffViewer: FC = ({ > diff --git a/site/src/pages/AgentsPage/RemoteDiffPanel.tsx b/site/src/pages/AgentsPage/RemoteDiffPanel.tsx index d882bed02c..24864b5588 100644 --- a/site/src/pages/AgentsPage/RemoteDiffPanel.tsx +++ b/site/src/pages/AgentsPage/RemoteDiffPanel.tsx @@ -30,6 +30,18 @@ import type { DiffStyle } from "./DiffViewer"; import { DiffViewer } from "./DiffViewer"; import { parsePullRequestUrl } from "./pullRequest"; +// ------------------------------------------------------------------- +// Module-level counter for cache key uniqueness +// ------------------------------------------------------------------- + +/** + * Monotonic counter shared across all RemoteDiffPanel instances. + * Ensures parsePatchFiles cache keys never collide across mounts, + * since component-local refs reset to 0 on remount while the + * worker pool's LRU cache persists. + */ +let remoteDiffVersion = 0; + // ------------------------------------------------------------------- // Diff content extraction // ------------------------------------------------------------------- @@ -256,28 +268,38 @@ export const RemoteDiffPanel: FC = ({ enabled: Boolean(diffStatus?.url), }); + const diffContent = diffContentsQuery.data?.diff; + const diffVersionRef = useRef(0); + const prevDiffRef = useRef(undefined); + if (diffContent !== prevDiffRef.current) { + prevDiffRef.current = diffContent; + diffVersionRef.current = ++remoteDiffVersion; + } + const parsedFiles = useMemo(() => { - const diff = diffContentsQuery.data?.diff; - if (!diff) { + if (!diffContent) { return []; } try { // The cacheKeyPrefix enables the worker pool's LRU cache // so highlighted ASTs are reused across re-renders instead - // of being re-computed on every render cycle. We include - // dataUpdatedAt so that when the diff content changes - // (e.g. new commits pushed) the old cached highlight AST - // is not reused with mismatched line indices, which would - // cause DiffHunksRenderer.processDiffResult to throw. + // of being re-computed on every render cycle. We include a + // version counter derived from the diff content so that when + // the diff changes (e.g. new commits pushed) the old cached + // highlight AST is not reused with mismatched line indices, + // which would cause DiffHunksRenderer.processDiffResult to + // throw. Unlike dataUpdatedAt, this counter only increments + // when the actual diff string changes, avoiding unnecessary + // recomputation on refetches with identical content. const patches = parsePatchFiles( - diff, - `chat-${chatId}-${diffContentsQuery.dataUpdatedAt}`, + diffContent, + `chat-${chatId}-v${diffVersionRef.current}`, ); return patches.flatMap((p) => p.files); } catch { return []; } - }, [diffContentsQuery.data?.diff, diffContentsQuery.dataUpdatedAt, chatId]); + }, [diffContent, chatId]); // --------------------------------------------------------------- // Line interaction callbacks diff --git a/site/src/pages/AgentsPage/useGitWatcher.ts b/site/src/pages/AgentsPage/useGitWatcher.ts index eb793d14af..eda827c172 100644 --- a/site/src/pages/AgentsPage/useGitWatcher.ts +++ b/site/src/pages/AgentsPage/useGitWatcher.ts @@ -77,15 +77,28 @@ export function useGitWatcher({ if (data.type === "changes" && data.repositories) { setRepositories((prev) => { + let changed = false; const next = new Map(prev); for (const repo of data.repositories!) { if (repo.removed) { - next.delete(repo.repo_root); + if (next.has(repo.repo_root)) { + next.delete(repo.repo_root); + changed = true; + } } else { - next.set(repo.repo_root, repo); + const existing = next.get(repo.repo_root); + if ( + !existing || + existing.branch !== repo.branch || + existing.remote_origin !== repo.remote_origin || + existing.unified_diff !== repo.unified_diff + ) { + next.set(repo.repo_root, repo); + changed = true; + } } } - return next; + return changed ? next : prev; }); } else if (data.type === "error") { console.warn("[useGitWatcher] server error:", data.message);