import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import { MultiFileDiff, Virtualizer, WorkerPoolContextProvider, } from "@pierre/diffs/react" import { FileTree, useFileTree, useFileTreeSelection, } from "@pierre/trees/react" import { ArrowsInIcon, ArrowsOutIcon, CaretDownIcon, SidebarSimpleIcon, } from "@phosphor-icons/react" import type { FileContents } from "@pierre/diffs/react" import type { GitStatus, GitStatusEntry } from "@pierre/trees" import type { AgentThread, Message } from "@/lib/agents/types" import type { ThreadPrDiffFile } from "@/lib/agents/api" import type { ChangedFileSummaryItem } from "@/components/agents/messages" import { agentsApi } from "@/lib/agents/api" import { useAgentThreadPrDiff } from "@/lib/agents/queries" import { ReviewTab } from "@/components/agents/ReviewTab" import { PrHeader } from "@/components/agents/PrHeader" import { buttonVariants } from "@/components/ui/button" import { DIFF_VIRTUALIZER_CONFIG, DIFF_VIRTUAL_METRICS, DIFF_WORKER_HIGHLIGHTER_OPTIONS, DIFF_WORKER_POOL_OPTIONS, fileContentsCacheKey, useDiffOptions, } from "@/components/agents/utils/diffUtils" import { summarizeChangedFiles } from "@/components/agents/ported" import { Z } from "@/components/agents/z-index" import { useIsMobile } from "@/lib/useIsMobile" import { cn } from "@/lib/utils" interface AgentGitPanelProps { thread: AgentThread messages: Array collapsed: boolean onCollapsedChange: (next: boolean) => void } interface PanelFile { filePath: string treePath: string additions: number deletions: number originalContent: string modifiedContent: string status: GitStatus unrenderable?: boolean } function prFileStatus(file: ThreadPrDiffFile): GitStatus { if (file.status === "added") return "added" if (file.status === "removed") return "deleted" return "modified" } function deriveStatus(file: ChangedFileSummaryItem): GitStatus { if (file.originalContent.length === 0 && file.modifiedContent.length > 0) { return "added" } if (file.modifiedContent.length === 0 && file.originalContent.length > 0) { return "deleted" } return "modified" } function commonDirPrefix(paths: Array): string { const first = paths[0] if (paths.length === 0 || first === undefined) return "" const base = first.split("/").slice(0, -1) let depth = base.length for (const path of paths) { const segments = path.split("/").slice(0, -1) let i = 0 while (i < depth && i < segments.length && segments[i] === base[i]) i++ depth = i } return depth === 0 ? "" : `${base.slice(0, depth).join("/")}/` } const PANEL_STORAGE_WIDTH = "open-swe.gitpanel.width" const PANEL_STORAGE_COLLAPSED = "open-swe.gitpanel.collapsed" const COLLAPSED_STATE_TRUE = "1" const COLLAPSED_STATE_FALSE = "0" const PANEL_DEFAULT_WIDTH = 420 const PANEL_MIN_WIDTH = 320 // Keep at least this much room for the chat so the panel can grow to nearly the // full window (e.g. ~50/50 on ultrawide screens) without squishing the chat. // Exported so the chat column can enforce the same floor via min-width. export const PANEL_MIN_CHAT_WIDTH = 360 function getPanelMaxWidth(availableWidth?: number): number { if (typeof window === "undefined") return PANEL_DEFAULT_WIDTH const available = availableWidth ?? window.innerWidth return Math.max(PANEL_MIN_WIDTH, available - PANEL_MIN_CHAT_WIDTH) } function clampPanelWidth(width: number, availableWidth?: number): number { return Math.min( getPanelMaxWidth(availableWidth), Math.max(PANEL_MIN_WIDTH, width) ) } function readStoredPanelWidth(): number { if (typeof window === "undefined") return PANEL_DEFAULT_WIDTH const raw = window.localStorage.getItem(PANEL_STORAGE_WIDTH) const parsed = raw ? Number(raw) : NaN if (!Number.isFinite(parsed)) return PANEL_DEFAULT_WIDTH return clampPanelWidth(parsed) } export 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 ) } export function writeStoredPanelCollapsed(collapsed: boolean): void { if (typeof window === "undefined") return window.localStorage.setItem( PANEL_STORAGE_COLLAPSED, collapsed ? COLLAPSED_STATE_TRUE : COLLAPSED_STATE_FALSE ) } function PanelResizeHandle({ width, onResize, onResizeEnd, }: { width: number 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: latestWidthRef.current } setDragging(true) e.currentTarget.setPointerCapture(e.pointerId) } const onPointerMove = (e: React.PointerEvent) => { if (!startRef.current) return 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(() => { return () => { 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]) return (
) } // Neutral filename foreground from the pierre Shiki themes (pierre-light / // pierre-dark sidebar foreground). The tree tints filename text by git status, // so feeding this keeps names neutral grey/white instead of accent-blue. const TREE_FILE_FG = "light-dark(#525252, #a3a3a3)" // Selected rows must read as high-contrast (white in dark, near-black in light) // while the rest stay neutral. The built-in git-status content color outranks // the selection color by specificity, so override it from the `unsafe` layer. export const TREE_UNSAFE_CSS = ` [data-item-selected="true"] [data-item-section="content"] { color: var(--trees-selected-fg); } /* On click a row is focus-ringed a frame before it's marked selected, which * flashes the accent outline. Pointer focus doesn't match :focus-visible, so * drop the ring there; keyboard navigation keeps it. */ [data-item-focused="true"]:not(:focus-visible)::before { outline-color: transparent; } ` export function treeThemeStyle(): React.CSSProperties { return { "--trees-theme-sidebar-bg": "var(--ui-surface)", "--trees-theme-sidebar-fg": "var(--ui-text)", "--trees-theme-sidebar-border": "var(--ui-border)", "--trees-theme-sidebar-header-fg": "var(--ui-text-dim)", "--trees-theme-list-hover-bg": "color-mix(in oklab, var(--ui-accent) 10%, transparent)", "--trees-theme-list-active-selection-bg": "color-mix(in oklab, var(--ui-accent) 22%, transparent)", "--trees-theme-list-active-selection-fg": "var(--ui-text)", "--trees-selected-focused-border-color-override": "transparent", "--trees-theme-input-bg": "var(--ui-panel)", "--trees-theme-input-fg": "var(--ui-text)", "--trees-theme-input-border": "var(--ui-border)", "--trees-theme-focus-ring": "var(--ui-accent)", "--trees-theme-scrollbar-thumb": "var(--ui-border)", "--trees-theme-git-added-fg": TREE_FILE_FG, "--trees-theme-git-modified-fg": TREE_FILE_FG, "--trees-theme-git-deleted-fg": TREE_FILE_FG, "--trees-theme-git-renamed-fg": TREE_FILE_FG, "--trees-theme-git-untracked-fg": TREE_FILE_FG, "--trees-theme-git-ignored-fg": "var(--ui-text-dim)", } as React.CSSProperties } export function AgentGitPanel({ thread, messages, collapsed, onCollapsedChange, }: AgentGitPanelProps) { const [topTab, setTopTab] = useState<"git" | "desktop" | "terminal">("git") const [tab, setTab] = useState<"diff" | "review" | "commits">("diff") const [width, setWidthState] = useState(() => readStoredPanelWidth()) const [fullScreen, setFullScreen] = useState(false) const isMobile = useIsMobile() // On mobile the panel is never an inline resizable column — it's a full-screen // overlay that the user navigates to (and back from), like the sidebar. const overlay = fullScreen || isMobile const panelRef = useRef(null) // Collapsed state is owned by the parent (so the plan banner can reserve space // for the floating expand button); persistence to localStorage lives there too. const setCollapsed = onCollapsedChange 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 = () => commitWidth(width) reclamp() window.addEventListener("resize", reclamp) return () => window.removeEventListener("resize", reclamp) }, [commitWidth, width]) const [selectedTreePath, setSelectedTreePath] = useState(null) const sectionRefs = useRef>({}) const pr = thread.pr // The open/closed state is persisted to localStorage, so it carries across // threads and reloads. Still uncollapse when a PR lands mid-session. const [prSeen, setPrSeen] = useState<{ threadId: string; hadPr: boolean }>( () => ({ threadId: thread.id, hadPr: Boolean(pr) }) ) if (prSeen.threadId !== thread.id) { setPrSeen({ threadId: thread.id, hadPr: Boolean(pr) }) } else if (pr && !prSeen.hadPr) { setPrSeen({ threadId: thread.id, hadPr: true }) setCollapsed(false) } const prDiff = useAgentThreadPrDiff(thread.id, Boolean(pr)) const [recoveringPatch, setRecoveringPatch] = useState(false) const [recoveryError, setRecoveryError] = useState(null) const canDownloadRecovery = thread.status !== "running" && thread.isOwner !== false const downloadRecoveryPatch = useCallback(async () => { setRecoveringPatch(true) setRecoveryError(null) try { const { blob, filename } = await agentsApi.downloadThreadRecoveryPatch( thread.id ) const url = window.URL.createObjectURL(blob) const link = document.createElement("a") link.href = url link.download = filename document.body.appendChild(link) link.click() link.remove() window.URL.revokeObjectURL(url) } catch (error) { setRecoveryError( error instanceof Error ? error.message : "Failed to download patch" ) } finally { setRecoveringPatch(false) } }, [thread.id]) const chunks = useMemo( () => messages.flatMap((message) => message.chunks), [messages] ) const files = useMemo>(() => { if (prDiff.data) { return prDiff.data.files.map((file) => ({ filePath: file.path, treePath: file.path, additions: file.additions, deletions: file.deletions, originalContent: file.originalContent ?? "", modifiedContent: file.modifiedContent ?? "", status: prFileStatus(file), unrenderable: file.unrenderable, })) } const summary = summarizeChangedFiles(chunks) const prefix = commonDirPrefix(summary.map((file) => file.filePath)) return summary.map((file) => ({ filePath: file.filePath, treePath: prefix && file.filePath.startsWith(prefix) ? file.filePath.slice(prefix.length) : file.filePath, additions: file.additions, deletions: file.deletions, originalContent: file.originalContent, modifiedContent: file.modifiedContent, status: deriveStatus(file), })) }, [chunks, prDiff.data]) const totals = useMemo( () => files.reduce( (acc, file) => ({ additions: acc.additions + file.additions, deletions: acc.deletions + file.deletions, }), { additions: 0, deletions: 0 } ), [files] ) useEffect(() => { if (!selectedTreePath) return const target = files.find((file) => file.treePath === selectedTreePath) if (!target) return sectionRefs.current[target.filePath]?.scrollIntoView({ block: "start", behavior: "smooth", }) }, [selectedTreePath, files]) if (collapsed) { return ( ) } return ( ) } 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, onSelect, }: { files: Array selectedTreePath: string | null onSelect: (path: string) => void }) { const paths = useMemo(() => files.map((file) => file.treePath), [files]) const gitStatus = useMemo>( () => files.map((file) => ({ path: file.treePath, status: file.status })), [files] ) const { model } = useFileTree({ paths, gitStatus, initialExpansion: "open", flattenEmptyDirectories: true, search: true, icons: "complete", unsafeCSS: TREE_UNSAFE_CSS, }) useEffect(() => { model.resetPaths(paths) }, [model, paths]) useEffect(() => { model.setGitStatus(gitStatus) }, [model, gitStatus]) const selection = useFileTreeSelection(model) useEffect(() => { const path = selection[0] if (path) onSelect(path) }, [selection, onSelect]) useEffect(() => { if (selectedTreePath) { model.scrollToPath(selectedTreePath, { focus: false }) } }, [model, selectedTreePath]) return (
) }