open-swe/ui/src/components/agents/AgentGitPanel.tsx
Johannes du Plessis 0108d764d9
fix(ui): resolve eslint errors in dashboard components (#1538)
Clears ~83 pre-existing eslint errors in the UI: auto-fixable rules
(array-type, import/order, sort-imports, type-only imports, redundant
assertions/conditions) plus manual fixes for unnecessary conditions and
banned @ts-nocheck directives. The 8 ported components excluded from
tsconfig are now also ignored by eslint so type-aware linting no longer
fails to parse them.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
2026-06-15 17:29:38 -07:00

588 lines
19 KiB
TypeScript

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<Message>
}
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>): 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<HTMLDivElement>) => {
e.preventDefault()
startRef.current = { x: e.clientX, width }
setDragging(true)
e.currentTarget.setPointerCapture(e.pointerId)
}
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!startRef.current) return
onResize(startRef.current.width - (e.clientX - startRef.current.x))
}
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
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 (
<div
role="separator"
aria-orientation="vertical"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={cn(
"absolute top-0 left-0 z-20 h-full w-1 cursor-col-resize touch-none select-none",
"after:absolute after:inset-y-0 after:left-0 after:w-px after:bg-transparent after:transition-colors",
"hover:after:bg-[var(--ui-border)]",
dragging && "after:bg-[var(--ui-border)]"
)}
/>
)
}
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<HTMLDivElement>(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<string | null>(null)
const sectionRefs = useRef<Record<string, HTMLDivElement | null>>({})
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<Array<PanelFile>>(() => {
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 (
<button
type="button"
onClick={() => setCollapsed(false)}
aria-label="Expand git panel"
title="Expand git panel"
className="fixed top-3 right-3 z-30 flex size-7 items-center justify-center rounded-md border border-border bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground"
>
<SidebarSimpleIcon className="size-4" />
</button>
)
}
return (
<aside
ref={panelRef}
className={cn(
"relative flex shrink-0 flex-col bg-[var(--ui-bg)]",
fullScreen ? "fixed inset-0 !w-full" : "h-full"
)}
style={fullScreen ? { zIndex: Z.MODAL } : { width }}
>
<div className="flex h-11 shrink-0 items-center gap-1 px-3">
{(
[
["git", "Git"],
["desktop", "Desktop"],
["terminal", "Terminal"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
onClick={() => setTopTab(id)}
className={cn(
"rounded-md px-2.5 py-1 text-xs transition-colors",
topTab === 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>
))}
<button
type="button"
onClick={() => {
setFullScreen(false)
setCollapsed(true)
}}
aria-label="Collapse git panel"
title="Collapse git panel"
className="ml-auto rounded-md p-1.5 text-[var(--ui-text-dim)] transition-colors hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
<SidebarSimpleIcon className="size-4" />
</button>
<button
type="button"
onClick={() => setFullScreen((v) => !v)}
aria-label={fullScreen ? "Exit full screen" : "Enter full screen"}
className="rounded-md p-1.5 text-[var(--ui-text-dim)] transition-colors hover:bg-[var(--ui-panel-2)] hover:text-[var(--ui-text)]"
>
{fullScreen ? (
<ArrowsInIcon className="size-4" />
) : (
<ArrowsOutIcon className="size-4" />
)}
</button>
</div>
<div
className={cn(
"flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-[var(--ui-border)] bg-[var(--ui-surface)] shadow-sm",
fullScreen ? "mx-3 mb-3" : "mr-4 mb-4 ml-1"
)}
>
{topTab !== "git" ? (
<div className="flex flex-1 items-center justify-center p-6 text-xs text-[var(--ui-text-dim)]">
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 className="flex items-center gap-1 border-b border-[var(--ui-border)] px-3 py-2">
{(
[
["diff", "Diff"],
["review", "Review"],
["commits", "Commits"],
] as const
).map(([id, label]) => (
<button
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 className="flex min-h-0 flex-1">
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "diff" && files.length > 0 ? (
<div className="space-y-2 p-2">
{files.map((file) => (
<FileDiffSection
key={file.filePath}
file={file}
sectionRef={(node) => {
sectionRefs.current[file.filePath] = node
}}
/>
))}
</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 && 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>
{!fullScreen && <PanelResizeHandle width={width} onResize={setWidth} />}
</aside>
)
}
function FileDiffSection({
file,
sectionRef,
}: {
file: PanelFile
sectionRef: (node: HTMLDivElement | null) => void
}) {
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 FileTreeExplorer({
files,
selectedTreePath,
onSelect,
}: {
files: Array<PanelFile>
selectedTreePath: string | null
onSelect: (path: string) => void
}) {
const paths = useMemo(() => files.map((file) => file.treePath), [files])
const gitStatus = useMemo<Array<GitStatusEntry>>(
() => 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 (
<div className="flex h-full flex-col">
<FileTree model={model} style={{ height: "100%", ...treeThemeStyle() }} />
</div>
)
}