From e4d737e18c0c64e2036410dc5d4f627192f3369f Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Wed, 17 Jun 2026 13:19:53 -0700 Subject: [PATCH] fix: optimize agent git diff rendering (#1564) Co-authored-by: open-swe[bot] --- ui/src/components/agents/AgentGitPanel.tsx | 500 +++++++++++++------- ui/src/components/agents/utils/diffUtils.ts | 30 +- ui/vite.config.ts | 1 + 3 files changed, 349 insertions(+), 182 deletions(-) diff --git a/ui/src/components/agents/AgentGitPanel.tsx b/ui/src/components/agents/AgentGitPanel.tsx index fa24286e..2d53c290 100644 --- a/ui/src/components/agents/AgentGitPanel.tsx +++ b/ui/src/components/agents/AgentGitPanel.tsx @@ -1,5 +1,9 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react" -import { MultiFileDiff } from "@pierre/diffs/react" +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" +import { + MultiFileDiff, + Virtualizer, + WorkerPoolContextProvider, +} from "@pierre/diffs/react" import { FileTree, useFileTree, @@ -13,6 +17,12 @@ import { GitPullRequestIcon, SidebarSimpleIcon, } from "@phosphor-icons/react" +import type { + FileContents, + VirtualFileMetrics, + WorkerInitializationRenderOptions, + WorkerPoolOptions, +} from "@pierre/diffs/react" import type { GitStatus, GitStatusEntry } from "@pierre/trees" import type { AgentThread, Message } from "@/lib/agents/types" @@ -83,6 +93,38 @@ const PANEL_MIN_WIDTH = 320 // Exported so the chat column can enforce the same floor via min-width. export const PANEL_MIN_CHAT_WIDTH = 360 +const DIFF_VIRTUALIZER_CONFIG = { + overscrollSize: 1200, + intersectionObserverMargin: 4800, +} + +const DIFF_VIRTUAL_METRICS = { + hunkLineCount: 80, + lineHeight: 18, + diffHeaderHeight: 0, + spacing: 8, +} satisfies Partial + +const DIFF_WORKER_POOL_OPTIONS = { + workerFactory: () => + new Worker( + new URL("@pierre/diffs/worker/worker-portable.js", import.meta.url), + { + type: "module", + } + ), + poolSize: 2, + totalASTLRUCacheSize: 120, +} satisfies WorkerPoolOptions + +const DIFF_WORKER_HIGHLIGHTER_OPTIONS = { + theme: { light: "pierre-light", dark: "pierre-dark" }, + lineDiffType: "word-alt", + maxLineDiffLength: 800, + tokenizeMaxLineLength: 1200, + langs: ["text"], +} satisfies WorkerInitializationRenderOptions + function getPanelMaxWidth(availableWidth?: number): number { if (typeof window === "undefined") return PANEL_DEFAULT_WIDTH const available = availableWidth ?? window.innerWidth @@ -90,7 +132,10 @@ function getPanelMaxWidth(availableWidth?: number): number { } function clampPanelWidth(width: number, availableWidth?: number): number { - return Math.min(getPanelMaxWidth(availableWidth), Math.max(PANEL_MIN_WIDTH, width)) + return Math.min( + getPanelMaxWidth(availableWidth), + Math.max(PANEL_MIN_WIDTH, width) + ) } function readStoredPanelWidth(): number { @@ -105,46 +150,84 @@ function readStoredPanelCollapsed(): boolean { if (typeof window === "undefined") return true // Default to collapsed until the user opens it once. return ( - window.localStorage.getItem(PANEL_STORAGE_COLLAPSED) !== COLLAPSED_STATE_FALSE + window.localStorage.getItem(PANEL_STORAGE_COLLAPSED) !== + COLLAPSED_STATE_FALSE ) } function PanelResizeHandle({ width, onResize, + onResizeEnd, }: { width: number - onResize: (next: number) => void + onResize: (next: number) => number + onResizeEnd: (next: number) => void }) { const startRef = useRef<{ x: number; width: number } | null>(null) + const pendingWidthRef = useRef(null) + const latestWidthRef = useRef(width) + const frameRef = useRef(null) const [dragging, setDragging] = useState(false) + useEffect(() => { + latestWidthRef.current = width + }, [width]) + + const flushResize = useCallback(() => { + frameRef.current = null + const next = pendingWidthRef.current + pendingWidthRef.current = null + if (next == null) return + latestWidthRef.current = onResize(next) + }, [onResize]) + const onPointerDown = (e: React.PointerEvent) => { e.preventDefault() - startRef.current = { x: e.clientX, width } + startRef.current = { x: e.clientX, width: latestWidthRef.current } setDragging(true) e.currentTarget.setPointerCapture(e.pointerId) } const onPointerMove = (e: React.PointerEvent) => { if (!startRef.current) return - onResize(startRef.current.width - (e.clientX - startRef.current.x)) + pendingWidthRef.current = + startRef.current.width - (e.clientX - startRef.current.x) + if (frameRef.current == null) { + frameRef.current = window.requestAnimationFrame(flushResize) + } } const onPointerUp = (e: React.PointerEvent) => { + if (frameRef.current != null) { + window.cancelAnimationFrame(frameRef.current) + flushResize() + } startRef.current = null setDragging(false) + onResizeEnd(latestWidthRef.current) if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId) } } useEffect(() => { - if (!dragging) return - const prev = document.body.style.cursor - document.body.style.cursor = "col-resize" return () => { - document.body.style.cursor = prev + if (frameRef.current != null) { + window.cancelAnimationFrame(frameRef.current) + } + } + }, []) + + useEffect(() => { + if (!dragging) return + const prevCursor = document.body.style.cursor + const prevUserSelect = document.body.style.userSelect + document.body.style.cursor = "col-resize" + document.body.style.userSelect = "none" + return () => { + document.body.style.cursor = prevCursor + document.body.style.userSelect = prevUserSelect } }, [dragging]) @@ -237,22 +320,38 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) { } } - const setWidth = useCallback((next: number) => { - const available = panelRef.current?.parentElement?.clientWidth - const clamped = clampPanelWidth(next, available) - setWidthState(clamped) - window.localStorage.setItem(PANEL_STORAGE_WIDTH, String(clamped)) - }, []) + const applyWidth = useCallback( + (next: number) => { + const available = panelRef.current?.parentElement?.clientWidth + const clamped = clampPanelWidth(next, available) + if (!overlay && panelRef.current) { + panelRef.current.style.width = `${clamped}px` + } + return clamped + }, + [overlay] + ) + + const commitWidth = useCallback( + (next: number) => { + const clamped = applyWidth(next) + setWidthState((current) => (current === clamped ? current : clamped)) + if (typeof window !== "undefined") { + window.localStorage.setItem(PANEL_STORAGE_WIDTH, String(clamped)) + } + }, + [applyWidth] + ) // Re-clamp against the real container width on mount and whenever the window // resizes, so the panel can never squeeze the chat below its minimum width. useEffect(() => { if (typeof window === "undefined") return - const reclamp = () => setWidth(width) + const reclamp = () => commitWidth(width) reclamp() window.addEventListener("resize", reclamp) return () => window.removeEventListener("resize", reclamp) - }, [setWidth, width]) + }, [commitWidth, width]) const [selectedTreePath, setSelectedTreePath] = useState(null) const sectionRefs = useRef>({}) const pr = thread.pr @@ -406,171 +505,234 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) { overlay ? "mx-3 mb-3" : "mr-4 mb-4 ml-1" )} > - {topTab !== "git" ? ( -
- Coming Soon -
- ) : ( - <> - {pr && ( -
-
-
-
- {pr.title} #{pr.number} -
-
- - - {pr.state} - - - {pr.headRef} → {pr.baseRef} - -
-
- {pr.url && ( - - - View PR - - )} + {topTab !== "git" ? ( +
+ Coming Soon
-
- )} - -
- {( - [ - ["diff", "Diff"], - ["review", "Review"], - ["commits", "Commits"], - ] as const - ).map(([id, label]) => ( - - ))} - {files.length > 0 && ( - - - {files.length} file{files.length === 1 ? "" : "s"} - - - +{totals.additions} - - -{totals.deletions} - - )} -
-
-
- {tab === "diff" && files.length > 0 ? ( -
- {files.map((file) => ( - { - sectionRefs.current[file.filePath] = node - }} - /> +
+ {( + [ + ["diff", "Diff"], + ["review", "Review"], + ["commits", "Commits"], + ] as const + ).map(([id, label]) => ( + ))} + {files.length > 0 && ( + + + {files.length} file{files.length === 1 ? "" : "s"} + + + +{totals.additions} + + + -{totals.deletions} + + + )}
- ) : ( -
- {tab !== "diff" - ? "Coming Soon" - : prDiff.isLoading - ? "Loading PR diff…" - : "No diff available."} -
- )} -
- {fullScreen && !isMobile && files.length > 0 && ( -
- -
+
+ {tab === "diff" && files.length > 0 ? ( + + + {files.map((file) => ( + { + sectionRefs.current[file.filePath] = node + }} + /> + ))} + + + ) : ( +
+ {tab !== "diff" + ? "Coming Soon" + : prDiff.isLoading + ? "Loading PR diff…" + : "No diff available."} +
+ )} + + {fullScreen && !isMobile && files.length > 0 && ( +
+ +
+ )} +
+ )}
- + {!overlay && ( + )} -
- {!overlay && } ) } -function FileDiffSection({ - file, - sectionRef, -}: { - file: PanelFile - sectionRef: (node: HTMLDivElement | null) => void -}) { - const [open, setOpen] = useState(true) - const diffOptions = useDiffOptions() - - return ( -
- - {open && - (file.unrenderable ? ( -
- Binary or large file — diff not shown. -
- ) : ( -
- -
- ))} -
- ) +function hashFileContents(contents: string): string { + let hash = 0x811c9dc5 + for (let i = 0; i < contents.length; i++) { + hash ^= contents.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return (hash >>> 0).toString(36) } +function fileContentsCacheKey( + path: string, + side: "old" | "new", + contents: string +): string { + return `${path}:${side}:${contents.length}:${hashFileContents(contents)}` +} + +const FileDiffSection = memo( + function FileDiffSection({ + file, + sectionRef, + }: { + file: PanelFile + sectionRef: (node: HTMLDivElement | null) => void + }) { + const [open, setOpen] = useState(true) + const diffOptions = useDiffOptions() + const oldFile = useMemo( + () => ({ + name: file.treePath, + contents: file.originalContent, + cacheKey: fileContentsCacheKey( + file.filePath, + "old", + file.originalContent + ), + }), + [file.filePath, file.originalContent, file.treePath] + ) + const newFile = useMemo( + () => ({ + name: file.treePath, + contents: file.modifiedContent, + cacheKey: fileContentsCacheKey( + file.filePath, + "new", + file.modifiedContent + ), + }), + [file.filePath, file.modifiedContent, file.treePath] + ) + + return ( +
+ + {open && + (file.unrenderable ? ( +
+ Binary or large file — diff not shown. +
+ ) : ( +
+ +
+ ))} +
+ ) + }, + (prev, next) => prev.file === next.file +) + function FileTreeExplorer({ files, selectedTreePath, diff --git a/ui/src/components/agents/utils/diffUtils.ts b/ui/src/components/agents/utils/diffUtils.ts index 7342798d..b538eb14 100644 --- a/ui/src/components/agents/utils/diffUtils.ts +++ b/ui/src/components/agents/utils/diffUtils.ts @@ -1,6 +1,6 @@ -import { useMemo } from "react"; -import { preloadHighlighter } from "@pierre/diffs"; -import { useResolvedTheme } from "@/lib/theme"; +import { useMemo } from "react" +import { preloadHighlighter } from "@pierre/diffs" +import { useResolvedTheme } from "@/lib/theme" export const DIFF_UNSAFE_CSS = ` [data-diffs-header], @@ -55,7 +55,7 @@ export const DIFF_UNSAFE_CSS = ` background-color: var(--ui-accent-bubble) !important; color: var(--ui-text-dim) !important; } -`; +` export const diffOptions = { theme: { light: "pierre-light", dark: "pierre-dark" } as const, @@ -65,17 +65,21 @@ export const diffOptions = { disableFileHeader: true, unsafeCSS: DIFF_UNSAFE_CSS, collapsedContextThreshold: 4, -}; + lineDiffType: "word-alt" as const, + maxLineDiffLength: 800, + tokenizeMaxLineLength: 1200, + tokenizeMaxLength: 120_000, +} export function useDiffOptions() { - const resolvedTheme = useResolvedTheme(); + const resolvedTheme = useResolvedTheme() return useMemo( () => ({ ...diffOptions, themeType: resolvedTheme }), [resolvedTheme] - ); + ) } -let highlighterWarmup: Promise | null = null; +let highlighterWarmup: Promise | null = null /** * Pierre's renders an empty on its first mount @@ -88,15 +92,15 @@ let highlighterWarmup: Promise | null = null; * Idempotent and client-only (preloadHighlighter creates a Shiki instance). */ export function warmDiffHighlighter(): Promise { - if (typeof window === "undefined") return Promise.resolve(); + if (typeof window === "undefined") return Promise.resolve() if (highlighterWarmup == null) { highlighterWarmup = preloadHighlighter({ themes: [diffOptions.theme.light, diffOptions.theme.dark], langs: ["text"], }).catch((error) => { - highlighterWarmup = null; - throw error; - }); + highlighterWarmup = null + throw error + }) } - return highlighterWarmup; + return highlighterWarmup } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index b14270ed..f50d5cb4 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -9,6 +9,7 @@ import { VitePWA } from "vite-plugin-pwa" const config = defineConfig({ optimizeDeps: { include: ["workbox-window"] }, + worker: { format: "es" }, plugins: [ devtools(), nitro(),