fix: optimize agent git diff rendering (#1564)

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-17 13:19:53 -07:00 • committed by GitHub
parent 5e0fb95f2e
commit e4d737e18c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 349 additions and 182 deletions

View file

@ -1,5 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { MultiFileDiff } from "@pierre/diffs/react" import {
MultiFileDiff,
Virtualizer,
WorkerPoolContextProvider,
} from "@pierre/diffs/react"
import { import {
FileTree, FileTree,
useFileTree, useFileTree,
@ -13,6 +17,12 @@ import {
GitPullRequestIcon, GitPullRequestIcon,
SidebarSimpleIcon, SidebarSimpleIcon,
} from "@phosphor-icons/react" } from "@phosphor-icons/react"
import type {
FileContents,
VirtualFileMetrics,
WorkerInitializationRenderOptions,
WorkerPoolOptions,
} from "@pierre/diffs/react"
import type { GitStatus, GitStatusEntry } from "@pierre/trees" import type { GitStatus, GitStatusEntry } from "@pierre/trees"
import type { AgentThread, Message } from "@/lib/agents/types" 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. // Exported so the chat column can enforce the same floor via min-width.
export const PANEL_MIN_CHAT_WIDTH = 360 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<VirtualFileMetrics>
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 { function getPanelMaxWidth(availableWidth?: number): number {
if (typeof window === "undefined") return PANEL_DEFAULT_WIDTH if (typeof window === "undefined") return PANEL_DEFAULT_WIDTH
const available = availableWidth ?? window.innerWidth const available = availableWidth ?? window.innerWidth
@ -90,7 +132,10 @@ function getPanelMaxWidth(availableWidth?: number): number {
} }
function clampPanelWidth(width: number, 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 { function readStoredPanelWidth(): number {
@ -105,46 +150,84 @@ function readStoredPanelCollapsed(): boolean {
if (typeof window === "undefined") return true if (typeof window === "undefined") return true
// Default to collapsed until the user opens it once. // Default to collapsed until the user opens it once.
return ( return (
window.localStorage.getItem(PANEL_STORAGE_COLLAPSED) !== COLLAPSED_STATE_FALSE window.localStorage.getItem(PANEL_STORAGE_COLLAPSED) !==
COLLAPSED_STATE_FALSE
) )
} }
function PanelResizeHandle({ function PanelResizeHandle({
width, width,
onResize, onResize,
onResizeEnd,
}: { }: {
width: number width: number
onResize: (next: number) => void onResize: (next: number) => number
onResizeEnd: (next: number) => void
}) { }) {
const startRef = useRef<{ x: number; width: number } | null>(null) const startRef = useRef<{ x: number; width: number } | null>(null)
const pendingWidthRef = useRef<number | null>(null)
const latestWidthRef = useRef(width)
const frameRef = useRef<number | null>(null)
const [dragging, setDragging] = useState(false) 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<HTMLDivElement>) => { const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault() e.preventDefault()
startRef.current = { x: e.clientX, width } startRef.current = { x: e.clientX, width: latestWidthRef.current }
setDragging(true) setDragging(true)
e.currentTarget.setPointerCapture(e.pointerId) e.currentTarget.setPointerCapture(e.pointerId)
} }
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => { const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!startRef.current) return 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<HTMLDivElement>) => { const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
if (frameRef.current != null) {
window.cancelAnimationFrame(frameRef.current)
flushResize()
}
startRef.current = null startRef.current = null
setDragging(false) setDragging(false)
onResizeEnd(latestWidthRef.current)
if (e.currentTarget.hasPointerCapture(e.pointerId)) { if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId) e.currentTarget.releasePointerCapture(e.pointerId)
} }
} }
useEffect(() => { useEffect(() => {
if (!dragging) return
const prev = document.body.style.cursor
document.body.style.cursor = "col-resize"
return () => { 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]) }, [dragging])
@ -237,22 +320,38 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
} }
} }
const setWidth = useCallback((next: number) => { const applyWidth = useCallback(
(next: number) => {
const available = panelRef.current?.parentElement?.clientWidth const available = panelRef.current?.parentElement?.clientWidth
const clamped = clampPanelWidth(next, available) const clamped = clampPanelWidth(next, available)
setWidthState(clamped) 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)) window.localStorage.setItem(PANEL_STORAGE_WIDTH, String(clamped))
}, []) }
},
[applyWidth]
)
// Re-clamp against the real container width on mount and whenever the window // 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. // resizes, so the panel can never squeeze the chat below its minimum width.
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return if (typeof window === "undefined") return
const reclamp = () => setWidth(width) const reclamp = () => commitWidth(width)
reclamp() reclamp()
window.addEventListener("resize", reclamp) window.addEventListener("resize", reclamp)
return () => window.removeEventListener("resize", reclamp) return () => window.removeEventListener("resize", reclamp)
}, [setWidth, width]) }, [commitWidth, width])
const [selectedTreePath, setSelectedTreePath] = useState<string | null>(null) const [selectedTreePath, setSelectedTreePath] = useState<string | null>(null)
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({}) const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
const pr = thread.pr const pr = thread.pr
@ -434,7 +533,10 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
href={pr.url} href={pr.url}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className={buttonVariants({ variant: "outline", size: "sm" })} className={buttonVariants({
variant: "outline",
size: "sm",
})}
> >
<ArrowSquareOutIcon className="size-3" /> <ArrowSquareOutIcon className="size-3" />
View PR View PR
@ -474,15 +576,24 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
<span className="text-[var(--ui-success)]"> <span className="text-[var(--ui-success)]">
+{totals.additions} +{totals.additions}
</span> </span>
<span className="text-[var(--ui-danger)]">-{totals.deletions}</span> <span className="text-[var(--ui-danger)]">
-{totals.deletions}
</span>
</span> </span>
)} )}
</div> </div>
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "diff" && files.length > 0 ? ( {tab === "diff" && files.length > 0 ? (
<div className="space-y-2 p-2"> <WorkerPoolContextProvider
poolOptions={DIFF_WORKER_POOL_OPTIONS}
highlighterOptions={DIFF_WORKER_HIGHLIGHTER_OPTIONS}
>
<Virtualizer
className="min-h-0 flex-1 overflow-y-auto"
contentClassName="space-y-2 p-2"
config={DIFF_VIRTUALIZER_CONFIG}
>
{files.map((file) => ( {files.map((file) => (
<FileDiffSection <FileDiffSection
key={file.filePath} key={file.filePath}
@ -492,9 +603,10 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
}} }}
/> />
))} ))}
</div> </Virtualizer>
</WorkerPoolContextProvider>
) : ( ) : (
<div className="p-6 text-center text-xs text-[var(--ui-text-dim)]"> <div className="min-h-0 flex-1 overflow-y-auto p-6 text-center text-xs text-[var(--ui-text-dim)]">
{tab !== "diff" {tab !== "diff"
? "Coming Soon" ? "Coming Soon"
: prDiff.isLoading : prDiff.isLoading
@ -502,7 +614,6 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
: "No diff available."} : "No diff available."}
</div> </div>
)} )}
</div>
{fullScreen && !isMobile && files.length > 0 && ( {fullScreen && !isMobile && files.length > 0 && (
<div className="w-72 shrink-0 border-l border-[var(--ui-border)] bg-[var(--ui-surface)]"> <div className="w-72 shrink-0 border-l border-[var(--ui-border)] bg-[var(--ui-surface)]">
@ -517,20 +628,68 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
</> </>
)} )}
</div> </div>
{!overlay && <PanelResizeHandle width={width} onResize={setWidth} />} {!overlay && (
<PanelResizeHandle
width={width}
onResize={applyWidth}
onResizeEnd={commitWidth}
/>
)}
</aside> </aside>
) )
} }
function FileDiffSection({ 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, file,
sectionRef, sectionRef,
}: { }: {
file: PanelFile file: PanelFile
sectionRef: (node: HTMLDivElement | null) => void sectionRef: (node: HTMLDivElement | null) => void
}) { }) {
const [open, setOpen] = useState(true) const [open, setOpen] = useState(true)
const diffOptions = useDiffOptions() const diffOptions = useDiffOptions()
const oldFile = useMemo<FileContents>(
() => ({
name: file.treePath,
contents: file.originalContent,
cacheKey: fileContentsCacheKey(
file.filePath,
"old",
file.originalContent
),
}),
[file.filePath, file.originalContent, file.treePath]
)
const newFile = useMemo<FileContents>(
() => ({
name: file.treePath,
contents: file.modifiedContent,
cacheKey: fileContentsCacheKey(
file.filePath,
"new",
file.modifiedContent
),
}),
[file.filePath, file.modifiedContent, file.treePath]
)
return ( return (
<div <div
@ -559,17 +718,20 @@ function FileDiffSection({
Binary or large file — diff not shown. Binary or large file — diff not shown.
</div> </div>
) : ( ) : (
<div className="max-h-[420px] overflow-auto bg-[var(--ui-panel)] p-2 font-mono text-[11px] leading-5"> <div className="overflow-hidden bg-[var(--ui-panel)] p-2">
<MultiFileDiff <MultiFileDiff
oldFile={{ name: file.treePath, contents: file.originalContent }} oldFile={oldFile}
newFile={{ name: file.treePath, contents: file.modifiedContent }} newFile={newFile}
options={diffOptions} options={diffOptions}
metrics={DIFF_VIRTUAL_METRICS}
/> />
</div> </div>
))} ))}
</div> </div>
) )
} },
(prev, next) => prev.file === next.file
)
function FileTreeExplorer({ function FileTreeExplorer({
files, files,

View file

@ -1,6 +1,6 @@
import { useMemo } from "react"; import { useMemo } from "react"
import { preloadHighlighter } from "@pierre/diffs"; import { preloadHighlighter } from "@pierre/diffs"
import { useResolvedTheme } from "@/lib/theme"; import { useResolvedTheme } from "@/lib/theme"
export const DIFF_UNSAFE_CSS = ` export const DIFF_UNSAFE_CSS = `
[data-diffs-header], [data-diffs-header],
@ -55,7 +55,7 @@ export const DIFF_UNSAFE_CSS = `
background-color: var(--ui-accent-bubble) !important; background-color: var(--ui-accent-bubble) !important;
color: var(--ui-text-dim) !important; color: var(--ui-text-dim) !important;
} }
`; `
export const diffOptions = { export const diffOptions = {
theme: { light: "pierre-light", dark: "pierre-dark" } as const, theme: { light: "pierre-light", dark: "pierre-dark" } as const,
@ -65,17 +65,21 @@ export const diffOptions = {
disableFileHeader: true, disableFileHeader: true,
unsafeCSS: DIFF_UNSAFE_CSS, unsafeCSS: DIFF_UNSAFE_CSS,
collapsedContextThreshold: 4, collapsedContextThreshold: 4,
}; lineDiffType: "word-alt" as const,
maxLineDiffLength: 800,
tokenizeMaxLineLength: 1200,
tokenizeMaxLength: 120_000,
}
export function useDiffOptions() { export function useDiffOptions() {
const resolvedTheme = useResolvedTheme(); const resolvedTheme = useResolvedTheme()
return useMemo( return useMemo(
() => ({ ...diffOptions, themeType: resolvedTheme }), () => ({ ...diffOptions, themeType: resolvedTheme }),
[resolvedTheme] [resolvedTheme]
); )
} }
let highlighterWarmup: Promise<void> | null = null; let highlighterWarmup: Promise<void> | null = null
/** /**
* Pierre's <MultiFileDiff> renders an empty <diffs-container> on its first mount * Pierre's <MultiFileDiff> renders an empty <diffs-container> on its first mount
@ -88,15 +92,15 @@ let highlighterWarmup: Promise<void> | null = null;
* Idempotent and client-only (preloadHighlighter creates a Shiki instance). * Idempotent and client-only (preloadHighlighter creates a Shiki instance).
*/ */
export function warmDiffHighlighter(): Promise<void> { export function warmDiffHighlighter(): Promise<void> {
if (typeof window === "undefined") return Promise.resolve(); if (typeof window === "undefined") return Promise.resolve()
if (highlighterWarmup == null) { if (highlighterWarmup == null) {
highlighterWarmup = preloadHighlighter({ highlighterWarmup = preloadHighlighter({
themes: [diffOptions.theme.light, diffOptions.theme.dark], themes: [diffOptions.theme.light, diffOptions.theme.dark],
langs: ["text"], langs: ["text"],
}).catch((error) => { }).catch((error) => {
highlighterWarmup = null; highlighterWarmup = null
throw error; throw error
}); })
} }
return highlighterWarmup; return highlighterWarmup
} }

View file

@ -9,6 +9,7 @@ import { VitePWA } from "vite-plugin-pwa"
const config = defineConfig({ const config = defineConfig({
optimizeDeps: { include: ["workbox-window"] }, optimizeDeps: { include: ["workbox-window"] },
worker: { format: "es" },
plugins: [ plugins: [
devtools(), devtools(),
nitro(), nitro(),