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(
const available = panelRef.current?.parentElement?.clientWidth (next: number) => {
const clamped = clampPanelWidth(next, available) const available = panelRef.current?.parentElement?.clientWidth
setWidthState(clamped) const clamped = clampPanelWidth(next, available)
window.localStorage.setItem(PANEL_STORAGE_WIDTH, String(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))
}
},
[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
@ -406,171 +505,234 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
overlay ? "mx-3 mb-3" : "mr-4 mb-4 ml-1" overlay ? "mx-3 mb-3" : "mr-4 mb-4 ml-1"
)} )}
> >
{topTab !== "git" ? ( {topTab !== "git" ? (
<div className="flex flex-1 items-center justify-center p-6 text-xs text-[var(--ui-text-dim)]"> <div className="flex flex-1 items-center justify-center p-6 text-xs text-[var(--ui-text-dim)]">
Coming Soon Coming Soon
</div>
) : (
<>
{pr && (
<div className="border-b border-[var(--ui-border)] px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-[var(--ui-text)]">
{pr.title} #{pr.number}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--ui-text-dim)]">
<span className="inline-flex items-center gap-1 rounded border border-[var(--ui-border)] px-1.5 py-0.5 capitalize">
<GitPullRequestIcon className="size-3" />
{pr.state}
</span>
<span>
{pr.headRef} → {pr.baseRef}
</span>
</div>
</div>
{pr.url && (
<a
href={pr.url}
target="_blank"
rel="noreferrer"
className={buttonVariants({ variant: "outline", size: "sm" })}
>
<ArrowSquareOutIcon className="size-3" />
View PR
</a>
)}
</div> </div>
</div> ) : (
)} <>
{pr && (
<div className="flex items-center gap-1 border-b border-[var(--ui-border)] px-3 py-2"> <div className="border-b border-[var(--ui-border)] px-4 py-3">
{( <div className="flex items-start justify-between gap-3">
[ <div className="min-w-0">
["diff", "Diff"], <div className="truncate text-sm font-medium text-[var(--ui-text)]">
["review", "Review"], {pr.title} #{pr.number}
["commits", "Commits"], </div>
] as const <div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--ui-text-dim)]">
).map(([id, label]) => ( <span className="inline-flex items-center gap-1 rounded border border-[var(--ui-border)] px-1.5 py-0.5 capitalize">
<button <GitPullRequestIcon className="size-3" />
key={id} {pr.state}
type="button" </span>
onClick={() => setTab(id)} <span>
className={cn( {pr.headRef} → {pr.baseRef}
"rounded-md px-2.5 py-1 text-xs transition-colors", </span>
tab === id </div>
? "bg-[var(--ui-accent-bubble)] font-medium text-[var(--ui-text)]" </div>
: "text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)]" {pr.url && (
<a
href={pr.url}
target="_blank"
rel="noreferrer"
className={buttonVariants({
variant: "outline",
size: "sm",
})}
>
<ArrowSquareOutIcon className="size-3" />
View PR
</a>
)}
</div>
</div>
)} )}
>
{label}
</button>
))}
{files.length > 0 && (
<span className="ml-auto flex items-center gap-2 text-[11px] text-[var(--ui-text-dim)]">
<span>
{files.length} file{files.length === 1 ? "" : "s"}
</span>
<span className="text-[var(--ui-success)]">
+{totals.additions}
</span>
<span className="text-[var(--ui-danger)]">-{totals.deletions}</span>
</span>
)}
</div>
<div className="flex min-h-0 flex-1"> <div className="flex items-center gap-1 border-b border-[var(--ui-border)] px-3 py-2">
<div className="min-h-0 flex-1 overflow-y-auto"> {(
{tab === "diff" && files.length > 0 ? ( [
<div className="space-y-2 p-2"> ["diff", "Diff"],
{files.map((file) => ( ["review", "Review"],
<FileDiffSection ["commits", "Commits"],
key={file.filePath} ] as const
file={file} ).map(([id, label]) => (
sectionRef={(node) => { <button
sectionRefs.current[file.filePath] = node key={id}
}} type="button"
/> onClick={() => setTab(id)}
className={cn(
"rounded-md px-2.5 py-1 text-xs transition-colors",
tab === id
? "bg-[var(--ui-accent-bubble)] font-medium text-[var(--ui-text)]"
: "text-[var(--ui-text-dim)] hover:bg-[var(--ui-panel-2)]"
)}
>
{label}
</button>
))} ))}
{files.length > 0 && (
<span className="ml-auto flex items-center gap-2 text-[11px] text-[var(--ui-text-dim)]">
<span>
{files.length} file{files.length === 1 ? "" : "s"}
</span>
<span className="text-[var(--ui-success)]">
+{totals.additions}
</span>
<span className="text-[var(--ui-danger)]">
-{totals.deletions}
</span>
</span>
)}
</div> </div>
) : (
<div className="p-6 text-center text-xs text-[var(--ui-text-dim)]">
{tab !== "diff"
? "Coming Soon"
: prDiff.isLoading
? "Loading PR diff…"
: "No diff available."}
</div>
)}
</div>
{fullScreen && !isMobile && files.length > 0 && ( <div className="flex min-h-0 flex-1">
<div className="w-72 shrink-0 border-l border-[var(--ui-border)] bg-[var(--ui-surface)]"> {tab === "diff" && files.length > 0 ? (
<FileTreeExplorer <WorkerPoolContextProvider
files={files} poolOptions={DIFF_WORKER_POOL_OPTIONS}
selectedTreePath={selectedTreePath} highlighterOptions={DIFF_WORKER_HIGHLIGHTER_OPTIONS}
onSelect={setSelectedTreePath} >
/> <Virtualizer
</div> className="min-h-0 flex-1 overflow-y-auto"
contentClassName="space-y-2 p-2"
config={DIFF_VIRTUALIZER_CONFIG}
>
{files.map((file) => (
<FileDiffSection
key={file.filePath}
file={file}
sectionRef={(node) => {
sectionRefs.current[file.filePath] = node
}}
/>
))}
</Virtualizer>
</WorkerPoolContextProvider>
) : (
<div className="min-h-0 flex-1 overflow-y-auto p-6 text-center text-xs text-[var(--ui-text-dim)]">
{tab !== "diff"
? "Coming Soon"
: prDiff.isLoading
? "Loading PR diff…"
: "No diff available."}
</div>
)}
{fullScreen && !isMobile && files.length > 0 && (
<div className="w-72 shrink-0 border-l border-[var(--ui-border)] bg-[var(--ui-surface)]">
<FileTreeExplorer
files={files}
selectedTreePath={selectedTreePath}
onSelect={setSelectedTreePath}
/>
</div>
)}
</div>
</>
)} )}
</div> </div>
</> {!overlay && (
<PanelResizeHandle
width={width}
onResize={applyWidth}
onResizeEnd={commitWidth}
/>
)} )}
</div>
{!overlay && <PanelResizeHandle width={width} onResize={setWidth} />}
</aside> </aside>
) )
} }
function FileDiffSection({ function hashFileContents(contents: string): string {
file, let hash = 0x811c9dc5
sectionRef, for (let i = 0; i < contents.length; i++) {
}: { hash ^= contents.charCodeAt(i)
file: PanelFile hash = Math.imul(hash, 0x01000193)
sectionRef: (node: HTMLDivElement | null) => void }
}) { return (hash >>> 0).toString(36)
const [open, setOpen] = useState(true)
const diffOptions = useDiffOptions()
return (
<div
ref={sectionRef}
className="mb-2 scroll-mt-2 overflow-hidden rounded-lg border border-[var(--ui-border)]"
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 bg-[var(--ui-panel-2)] px-3 py-2 text-left text-xs"
>
<CaretDownIcon
className={cn("size-3 transition-transform", !open && "-rotate-90")}
/>
<span className="truncate font-medium text-[var(--ui-text)]">
{file.treePath}
</span>
<span className="ml-auto flex shrink-0 items-center gap-2">
<span className="text-[var(--ui-success)]">+{file.additions}</span>
<span className="text-[var(--ui-danger)]">-{file.deletions}</span>
</span>
</button>
{open &&
(file.unrenderable ? (
<div className="bg-[var(--ui-panel)] p-4 text-center text-xs text-[var(--ui-text-dim)]">
Binary or large file — diff not shown.
</div>
) : (
<div className="max-h-[420px] overflow-auto bg-[var(--ui-panel)] p-2 font-mono text-[11px] leading-5">
<MultiFileDiff
oldFile={{ name: file.treePath, contents: file.originalContent }}
newFile={{ name: file.treePath, contents: file.modifiedContent }}
options={diffOptions}
/>
</div>
))}
</div>
)
} }
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<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 (
<div
ref={sectionRef}
className="mb-2 scroll-mt-2 overflow-hidden rounded-lg border border-[var(--ui-border)]"
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 bg-[var(--ui-panel-2)] px-3 py-2 text-left text-xs"
>
<CaretDownIcon
className={cn("size-3 transition-transform", !open && "-rotate-90")}
/>
<span className="truncate font-medium text-[var(--ui-text)]">
{file.treePath}
</span>
<span className="ml-auto flex shrink-0 items-center gap-2">
<span className="text-[var(--ui-success)]">+{file.additions}</span>
<span className="text-[var(--ui-danger)]">-{file.deletions}</span>
</span>
</button>
{open &&
(file.unrenderable ? (
<div className="bg-[var(--ui-panel)] p-4 text-center text-xs text-[var(--ui-text-dim)]">
Binary or large file — diff not shown.
</div>
) : (
<div className="overflow-hidden bg-[var(--ui-panel)] p-2">
<MultiFileDiff
oldFile={oldFile}
newFile={newFile}
options={diffOptions}
metrics={DIFF_VIRTUAL_METRICS}
/>
</div>
))}
</div>
)
},
(prev, next) => prev.file === next.file
)
function FileTreeExplorer({ function FileTreeExplorer({
files, files,
selectedTreePath, selectedTreePath,

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(),