import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { MultiFileDiff } from "@pierre/diffs/react" import { FileTree, useFileTree, useFileTreeSelection, } from "@pierre/trees/react" import { ArrowSquareOutIcon, ArrowsInIcon, ArrowsOutIcon, CaretDownIcon, GitPullRequestIcon, SidebarSimpleIcon, } from "@phosphor-icons/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 { useAgentThreadPrDiff } from "@/lib/agents/queries" import { buttonVariants } from "@/components/ui/button" import { useDiffOptions } from "@/components/agents/utils/diffUtils" import { summarizeChangedFiles } from "@/components/agents/ported" import { Z } from "@/components/agents/z-index" import { cn } from "@/lib/utils" interface AgentGitPanelProps { thread: AgentThread messages: Array } 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) } 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 ) } function PanelResizeHandle({ width, onResize, }: { width: number onResize: (next: number) => void }) { const startRef = useRef<{ x: number; width: number } | null>(null) const [dragging, setDragging] = useState(false) const onPointerDown = (e: React.PointerEvent) => { e.preventDefault() startRef.current = { x: e.clientX, width } 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)) } const onPointerUp = (e: React.PointerEvent) => { startRef.current = null setDragging(false) 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 } }, [dragging]) return (
) } 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": "var(--ui-panel-2)", "--trees-theme-list-active-selection-bg": "var(--ui-accent-bubble)", "--trees-theme-list-active-selection-fg": "var(--ui-text)", "--trees-theme-input-bg": "var(--ui-panel)", "--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": "var(--ui-success)", "--trees-theme-git-deleted-fg": "var(--ui-danger)", "--trees-theme-git-modified-fg": "var(--ui-accent)", } as React.CSSProperties } export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) { const [topTab, setTopTab] = useState<"git" | "desktop" | "terminal">("git") const [tab, setTab] = useState<"diff" | "review" | "commits">("diff") const [collapsed, setCollapsedState] = useState(() => readStoredPanelCollapsed() ) const [width, setWidthState] = useState(() => readStoredPanelWidth()) const [fullScreen, setFullScreen] = useState(false) const panelRef = useRef(null) const setCollapsed = (next: boolean) => { setCollapsedState(next) if (typeof window !== "undefined") { window.localStorage.setItem( PANEL_STORAGE_COLLAPSED, next ? COLLAPSED_STATE_TRUE : COLLAPSED_STATE_FALSE ) } } 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)) }, []) // 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) reclamp() window.addEventListener("resize", reclamp) return () => window.removeEventListener("resize", reclamp) }, [setWidth, 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 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 ( ) } 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 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: "standard", }) 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 (
) }