diff --git a/ui/src/components/agents/AgentGitPanel.tsx b/ui/src/components/agents/AgentGitPanel.tsx index ccbe69a1..1090defb 100644 --- a/ui/src/components/agents/AgentGitPanel.tsx +++ b/ui/src/components/agents/AgentGitPanel.tsx @@ -24,6 +24,7 @@ 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 { ReviewTab } from "@/components/agents/ReviewTab" import { buttonVariants } from "@/components/ui/button" import { DIFF_VIRTUALIZER_CONFIG, @@ -554,7 +555,9 @@ export function AgentGitPanel({ thread, messages }: AgentGitPanelProps) {
- {tab === "diff" && files.length > 0 ? ( + {tab === "review" ? ( + + ) : tab === "diff" && files.length > 0 ? ( )} - {fullScreen && !isMobile && files.length > 0 && ( -
- -
- )} + {tab === "diff" && + fullScreen && + !isMobile && + files.length > 0 && ( +
+ +
+ )}
)} diff --git a/ui/src/components/agents/AgentsSidebar.tsx b/ui/src/components/agents/AgentsSidebar.tsx index fecc7407..43bda382 100644 --- a/ui/src/components/agents/AgentsSidebar.tsx +++ b/ui/src/components/agents/AgentsSidebar.tsx @@ -27,11 +27,6 @@ import type { SessionUser } from "@/lib/api" import type { AgentSource, AgentThread } from "@/lib/agents/types" import type { SidebarLayout } from "@/components/sidebar-layout" import { SidebarUserMenu } from "@/components/SidebarUserMenu" -import { - ReviewSidebarPanel, - ReviewSidebarProvider, - useReviewSidebarData, -} from "@/components/agents/ReviewSidebar" import { Button } from "@/components/ui/button" import { SidebarCollapseButton, @@ -114,7 +109,6 @@ export function AgentsSidebar({ useSeedAgentThreadDetails(visibleThreads, activeThreadId) useRunCompletionNotifier(visibleThreads, activeThreadId) const groups = groupThreads(activeThreads) - const reviewSidebar = useReviewSidebarData() return ( - {reviewSidebar ? ( - - ) : ( -
- - - - - -
- )} +
+ + + + + +
@@ -546,17 +536,15 @@ export function AgentsShell({ }) { const layout = useSidebarLayout() return ( - - -
- -
{children}
-
-
-
+ +
+ +
{children}
+
+
) } diff --git a/ui/src/components/agents/ReviewMainBody.tsx b/ui/src/components/agents/ReviewMainBody.tsx new file mode 100644 index 00000000..2e34c675 --- /dev/null +++ b/ui/src/components/agents/ReviewMainBody.tsx @@ -0,0 +1,1944 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { + createContext, + memo, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react" +import { + ArrowClockwiseIcon, + ArrowSquareOutIcon, + BugBeetleIcon, + CaretDownIcon, + CheckCircleIcon, + CheckIcon, + CircleIcon, + CopyIcon, + FlagIcon, + GitPullRequestIcon, + InfoIcon, + RowsIcon, + SquareSplitHorizontalIcon, + XCircleIcon, +} from "@phosphor-icons/react" +import { IoLogoGithub } from "react-icons/io5" +import { + MultiFileDiff, + Virtualizer, + WorkerPoolContextProvider, +} from "@pierre/diffs/react" +import type { FileContents } from "@pierre/diffs/react" +import type { + FileDiff as CoreFileDiff, + DiffLineAnnotation, + SelectedLineRange, + SelectionSide, +} from "@pierre/diffs" + +import type { + ReviewCheckRun, + ReviewDetail, + ReviewDiffFile, + ReviewFinding, + ReviewUserRef, +} from "@/lib/api" +import type { + ReviewSidebarGroup, + ReviewSidebarView, +} from "@/components/agents/ReviewSidebar" +import type { ChatAttachment } from "@/components/agents/ReviewChat" +import type { DiffStyle } from "@/components/agents/utils/diffUtils" +import { Markdown } from "@/components/agents/ported" +import { + ReviewChat, + ReviewChatComposerProvider, + useReviewChatComposer, +} from "@/components/agents/ReviewChat" +import { ReviewSidebarPanel } from "@/components/agents/ReviewSidebar" +import { + DIFF_VIRTUALIZER_CONFIG, + DIFF_VIRTUAL_METRICS, + DIFF_WORKER_HIGHLIGHTER_OPTIONS, + DIFF_WORKER_POOL_OPTIONS, + fileContentsCacheKey, + useDiffOptions, + warmDiffHighlighter, +} from "@/components/agents/utils/diffUtils" +import { Skeleton } from "@/components/ui/skeleton" +import { api, reviewImageProxyUrl } from "@/lib/api" +import { cn } from "@/lib/utils" + +type SideTab = "info" | "chat" + +const REVIEW_VIEW_STORAGE_KEY = "open-swe.review.view" +const REVIEW_DIFF_STYLE_STORAGE_KEY = "open-swe.review.diffStyle" +const FINDING_SCROLL_MAX_FRAMES = 120 + +function readStoredDiffStyle(): DiffStyle { + if (typeof window === "undefined") return "unified" + return window.localStorage.getItem(REVIEW_DIFF_STYLE_STORAGE_KEY) === "split" + ? "split" + : "unified" +} + +// One attachment for a single-side line range. Deletions resolve against the +// original file, additions against the modified file. +function makeSideAttachment( + file: ReviewDiffFile, + side: "deletions" | "additions", + fromLine: number, + toLine: number +): ChatAttachment { + const source = + side === "deletions" ? file.originalContent : file.modifiedContent + const lines = source.split("\n") + const start = Math.max(1, Math.min(fromLine, toLine)) + const end = Math.max(fromLine, toLine) + const snippet = lines.slice(start - 1, end).join("\n") + const sideLabel = side === "deletions" ? "L" : "R" + const lineLabel = + start === end ? `${sideLabel}${start}` : `${sideLabel}${start}-${end}` + const language = file.path.includes(".") + ? (file.path.split(".").pop() ?? "") + : "" + return { + id: crypto.randomUUID(), + path: file.path, + lineLabel, + language, + snippet, + } +} + +// Build chat attachments from the selected range. A range can span from a +// deletion to an addition (side !== endSide) when dragging across a replaced +// block; slicing one file by start..end would paste the wrong lines, so each +// side is collected separately. +function buildSelectionAttachments( + file: ReviewDiffFile, + range: SelectedLineRange +): Array { + const startSide = range.side ?? "additions" + const endSide = range.endSide ?? startSide + if (startSide === endSide) { + return [makeSideAttachment(file, startSide, range.start, range.end)] + } + const deletionLine = startSide === "deletions" ? range.start : range.end + const additionLine = startSide === "additions" ? range.start : range.end + return [ + makeSideAttachment(file, "deletions", deletionLine, deletionLine), + makeSideAttachment(file, "additions", additionLine, additionLine), + ] +} + +// Scroll a file card / group flush to the top of the diff scroller. Under +// virtualization, scrollIntoView computes its target against estimated row +// heights; scrolling past unmeasured files reconciles their real heights +// mid-animation and the Virtualizer re-pins its scroll anchor, which leaves the +// target off the top. Once the smooth scroll settles, re-assert alignment (now +// against measured heights) until the target sits at the top or the budget runs +// out. Respects the element's scroll-margin-top. +function scrollCardToTop(el: HTMLElement, scroller: HTMLElement | null): void { + el.scrollIntoView({ block: "start", behavior: "smooth" }) + if (!scroller) return + let frames = 0 + let lastTop = Number.NaN + let stableFrames = 0 + let corrections = 0 + const align = () => { + if (frames++ > 240) return + const top = scroller.scrollTop + if (top === lastTop) stableFrames++ + else { + stableFrames = 0 + lastTop = top + } + // Wait for the smooth scroll + height reconciliation to settle. + if (stableFrames < 3) { + requestAnimationFrame(align) + return + } + const marginTop = parseFloat(getComputedStyle(el).scrollMarginTop) || 0 + const delta = + el.getBoundingClientRect().top - + scroller.getBoundingClientRect().top - + marginTop + if (Math.abs(delta) > 1 && corrections++ < 5) { + el.scrollIntoView({ block: "start", behavior: "smooth" }) + stableFrames = 0 + lastTop = Number.NaN + requestAnimationFrame(align) + } + } + requestAnimationFrame(align) +} + +interface PositionedDiffInstance { + getLinePosition: ( + lineNumber: number, + side?: SelectionSide + ) => { top: number; height: number } | undefined +} + +interface RegisteredDiffInstance { + host: HTMLElement + instance: CoreFileDiff +} + +function hasLinePosition( + instance: CoreFileDiff +): instance is CoreFileDiff & PositionedDiffInstance { + return ( + typeof (instance as { getLinePosition?: unknown }).getLinePosition === + "function" + ) +} + +function clampScrollTop(scroller: HTMLElement, top: number): number { + return Math.max( + 0, + Math.min(top, scroller.scrollHeight - scroller.clientHeight) + ) +} + +function scrollElementToCenter(el: HTMLElement, scroller: HTMLElement): number { + const elementRect = el.getBoundingClientRect() + const scrollerRect = scroller.getBoundingClientRect() + const delta = + elementRect.top - + scrollerRect.top - + (scroller.clientHeight - elementRect.height) / 2 + const targetTop = clampScrollTop(scroller, scroller.scrollTop + delta) + scroller.scrollTo({ top: targetTop, behavior: "auto" }) + return Math.abs(delta) +} + +function scrollFindingLineToCenter({ + target, + finding, + scroller, +}: { + target: RegisteredDiffInstance + finding: ReviewFinding + scroller: HTMLElement +}): boolean { + if (finding.end_line === null || !hasLinePosition(target.instance)) + return false + const line = target.instance.getLinePosition( + finding.end_line, + findingSide(finding) + ) + if (!line) return false + const hostTop = + target.host.getBoundingClientRect().top - + scroller.getBoundingClientRect().top + + scroller.scrollTop + const targetTop = clampScrollTop( + scroller, + hostTop + line.top - (scroller.clientHeight - line.height) / 2 + ) + scroller.scrollTo({ top: targetTop, behavior: "auto" }) + return true +} + +interface ResolvedGroup { + index: number + title: string + summary: string + files: Array + additions: number + deletions: number +} + +const GROUP_STYLES = { + bug: { label: "Bug", className: "text-destructive", Icon: BugBeetleIcon }, + investigate: { + label: "Investigate", + className: "text-amber-500", + Icon: FlagIcon, + }, + informational: { + label: "Informational", + className: "text-muted-foreground", + Icon: InfoIcon, + }, +} as const + +function findingAnchorLabel(finding: ReviewFinding): string { + if (finding.start_line === null || finding.end_line === null) + return finding.file + if (finding.start_line === finding.end_line) + return `${finding.file}:${finding.end_line}` + return `${finding.file}:${finding.start_line}-${finding.end_line}` +} + +function isAnchored(finding: ReviewFinding): boolean { + return Boolean(finding.file) && finding.in_diff && finding.end_line !== null +} + +function findingSide(finding: ReviewFinding): "deletions" | "additions" { + return finding.side === "LEFT" ? "deletions" : "additions" +} + +function findingSelectedRange( + finding: ReviewFinding +): SelectedLineRange | null { + if (finding.end_line === null) return null + const side = findingSide(finding) + return { + start: finding.start_line ?? finding.end_line, + end: finding.end_line, + side, + endSide: side, + } +} + +function findingClipboardText(finding: ReviewFinding): string { + const style = GROUP_STYLES[finding.group] + const lines = [ + `**${style.label}: ${finding.title}**`, + `${findingAnchorLabel(finding)}`, + "", + finding.description, + ] + if (finding.suggestion) + lines.push("", "```suggestion", finding.suggestion, "```") + return lines.join("\n") +} + +// Inline findings live inside Pierre's diff via React portals, so their +// expand/collapse state is lifted here and shared through context — surviving +// the annotation's mount/unmount as rows window in and out under +// virtualization, and letting the side panel drive the same expansion. +interface ExpandedFindingContextValue { + expandedId: string | null + reviewUrl: string + toggle: (finding: ReviewFinding) => void + registerAnnotation: (id: string, node: HTMLElement | null) => void +} + +const ExpandedFindingContext = + createContext(null) + +function useExpandedFinding(): ExpandedFindingContextValue { + const ctx = useContext(ExpandedFindingContext) + if (!ctx) + throw new Error("useExpandedFinding must be used within its provider") + return ctx +} + +const NO_FINDINGS: Array = [] + +interface UserSelection { + file: string + range: SelectedLineRange +} + +export type ReviewMainBodyVariant = "full" | "embedded" + +export interface ReviewMainBodyProps { + detail: ReviewDetail + diffFiles: Array | null + // "full" renders the side panel + chat alongside the diffs; "embedded" renders + // just the main body with an expand affordance (used inside the git panel). + variant?: ReviewMainBodyVariant + onExpand?: () => void +} + +export function ReviewMainBody({ + detail, + diffFiles, + variant = "full", + onExpand, +}: ReviewMainBodyProps) { + // The composer provider lives here so it remounts in lockstep with the + // head_sha-keyed body (and the activeId-keyed chat thread). The embedded + // variant has no chat, so it skips the provider. + if (variant === "embedded") { + return ( + + ) + } + return ( + + + + ) +} + +function ReviewBodyInner({ + detail, + diffFiles, + variant, + onExpand, +}: { + detail: ReviewDetail + diffFiles: Array | null + variant: ReviewMainBodyVariant + onExpand?: () => void +}) { + const embedded = variant === "embedded" + const composer = useReviewChatComposer() + const transformPrImage = useCallback( + (src: string) => + reviewImageProxyUrl(detail.owner, detail.repo, detail.number, src), + [detail.owner, detail.repo, detail.number] + ) + const [sideTab, setSideTab] = useState("info") + const [selectedFile, setSelectedFile] = useState(null) + const fileRefs = useRef>({}) + const diffInstanceRefs = useRef< + Record + >({}) + const annotationRefs = useRef>({}) + const [expandedFiles, setExpandedFiles] = useState>( + {} + ) + const [expandedId, setExpandedId] = useState(null) + const [userSelection, setUserSelection] = useState(null) + const diffScrollElRef = useRef(null) + const findingScrollRequestRef = useRef(0) + const groupRefs = useRef>({}) + const [diffStyle, setDiffStyleState] = useState(() => + readStoredDiffStyle() + ) + const setDiffStyle = useCallback((next: DiffStyle) => { + setDiffStyleState(next) + if (typeof window !== "undefined") { + window.localStorage.setItem(REVIEW_DIFF_STYLE_STORAGE_KEY, next) + } + }, []) + + useEffect(() => { + void warmDiffHighlighter() + }, []) + + // Latest-value refs so the callbacks below can stay referentially stable + // (so memo(FileDiffCard) actually skips unrelated re-renders) while still + // reading current state. + const expandedFinding = useMemo( + () => detail.findings.find((f) => f.id === expandedId) ?? null, + [detail.findings, expandedId] + ) + const expandedFindingRef = useRef(expandedFinding) + expandedFindingRef.current = expandedFinding + + const viewedStorageKey = `open-swe.review.viewed.${detail.owner}/${detail.repo}/${detail.number}.${detail.head_sha}` + const [viewed, setViewed] = useState>(() => { + if (typeof window === "undefined") return new Set() + try { + const raw = window.localStorage.getItem(viewedStorageKey) + return new Set(raw ? (JSON.parse(raw) as Array) : []) + } catch { + return new Set() + } + }) + const viewedRef = useRef(viewed) + viewedRef.current = viewed + const expandedRef = useRef(expandedFiles) + expandedRef.current = expandedFiles + + const toggleViewed = useCallback( + (path: string) => { + const becomingViewed = !viewedRef.current.has(path) + setViewed((prev) => { + const next = new Set(prev) + if (becomingViewed) next.add(path) + else next.delete(path) + window.localStorage.setItem( + viewedStorageKey, + JSON.stringify(Array.from(next)) + ) + return next + }) + if (becomingViewed && expandedFindingRef.current?.file === path) + setExpandedId(null) + setExpandedFiles((prev) => ({ ...prev, [path]: !becomingViewed })) + }, + [viewedStorageKey] + ) + + const readStorageKey = `open-swe.review.read.${detail.thread_id}` + const [read, setRead] = useState>(() => { + if (typeof window === "undefined") return new Set() + try { + const raw = window.localStorage.getItem(readStorageKey) + return new Set(raw ? (JSON.parse(raw) as Array) : []) + } catch { + return new Set() + } + }) + const persistRead = useCallback( + (next: Set) => { + window.localStorage.setItem( + readStorageKey, + JSON.stringify(Array.from(next)) + ) + }, + [readStorageKey] + ) + const markRead = useCallback( + (id: string) => { + setRead((prev) => { + const next = new Set(prev).add(id) + persistRead(next) + return next + }) + }, + [persistRead] + ) + const markAllRead = useCallback(() => { + const next = new Set(detail.findings.map((f) => f.id)) + setRead(next) + persistRead(next) + }, [detail.findings, persistRead]) + + const findingsByFile = useMemo(() => { + const byFile = new Map>() + for (const finding of detail.findings) { + if (!isAnchored(finding)) continue + const list = byFile.get(finding.file) ?? [] + list.push(finding) + byFile.set(finding.file, list) + } + return byFile + }, [detail.findings]) + + const linesLeft = useMemo(() => { + if (!diffFiles) return null + return diffFiles + .filter((file) => !viewed.has(file.path)) + .reduce((acc, file) => acc + file.additions + file.deletions, 0) + }, [diffFiles, viewed]) + + // Resolve the AI-sorted groups against the actual diff: drop stale groups + // (generated for a previous head) so the file-tree fallback is used, drop + // paths no longer in the diff and empty groups, and collect any unassigned + // files into a trailing "Other changes" group so nothing ever disappears. + const groupedView = useMemo | null>(() => { + if ( + !diffFiles || + detail.diff_groups_stale || + detail.diff_groups.length === 0 + ) + return null + const byPath = new Map(diffFiles.map((file) => [file.path, file])) + const assigned = new Set() + const resolved: Array> = [] + for (const group of detail.diff_groups) { + const files: Array = [] + for (const path of group.files) { + const file = byPath.get(path) + if (file && !assigned.has(path)) { + assigned.add(path) + files.push(file) + } + } + if (files.length === 0) continue + resolved.push({ + title: group.title, + summary: group.summary, + files, + additions: files.reduce((acc, file) => acc + file.additions, 0), + deletions: files.reduce((acc, file) => acc + file.deletions, 0), + }) + } + const leftover = diffFiles.filter((file) => !assigned.has(file.path)) + if (leftover.length > 0) { + resolved.push({ + title: "Other changes", + summary: "", + files: leftover, + additions: leftover.reduce((acc, file) => acc + file.additions, 0), + deletions: leftover.reduce((acc, file) => acc + file.deletions, 0), + }) + } + if (resolved.length === 0) return null + return resolved.map((group, i) => ({ ...group, index: i + 1 })) + }, [diffFiles, detail.diff_groups, detail.diff_groups_stale]) + + const sidebarGroups = useMemo | null>(() => { + if (!groupedView) return null + return groupedView.map((group) => ({ + index: group.index, + title: group.title, + summary: group.summary, + additions: group.additions, + deletions: group.deletions, + fileCount: group.files.length, + files: group.files.map((file) => file.path), + })) + }, [groupedView]) + + // The view follows fresh-group availability until the user explicitly picks + // one, after which the choice persists across PRs. + const hasFreshGroups = + detail.diff_groups.length > 0 && !detail.diff_groups_stale + const [explicitView, setExplicitView] = useState( + () => { + if (typeof window === "undefined") return null + const stored = window.localStorage.getItem(REVIEW_VIEW_STORAGE_KEY) + return stored === "ai" || stored === "files" ? stored : null + } + ) + const view: ReviewSidebarView = + explicitView ?? (hasFreshGroups ? "ai" : "files") + const setView = useCallback((next: ReviewSidebarView) => { + setExplicitView(next) + if (typeof window !== "undefined") { + window.localStorage.setItem(REVIEW_VIEW_STORAGE_KEY, next) + } + }, []) + + const scrollToFile = useCallback((path: string) => { + setSelectedFile(path) + setExpandedFiles((prev) => ({ ...prev, [path]: true })) + requestAnimationFrame(() => { + const el = fileRefs.current[path] + if (el) scrollCardToTop(el, diffScrollElRef.current) + }) + }, []) + + const scrollToGroup = useCallback((index: number) => { + requestAnimationFrame(() => { + const el = groupRefs.current[index] + if (el) scrollCardToTop(el, diffScrollElRef.current) + }) + }, []) + + const filesByPath = useMemo( + () => new Map((diffFiles ?? []).map((file) => [file.path, file])), + [diffFiles] + ) + const filesByPathRef = useRef(filesByPath) + filesByPathRef.current = filesByPath + + // The Virtualizer doesn't forward a ref; grab its scroll element (the + // grandparent of this hidden probe, which lives in its content div) so + // scroll-to-file/group can align against it. + const scrollerProbe = useCallback((node: HTMLDivElement | null) => { + const scroller = node?.parentElement?.parentElement + diffScrollElRef.current = + scroller instanceof HTMLDivElement ? scroller : null + }, []) + + const registerSection = useCallback( + (path: string, node: HTMLDivElement | null) => { + fileRefs.current[path] = node + }, + [] + ) + const registerAnnotation = useCallback( + (id: string, node: HTMLElement | null) => { + annotationRefs.current[id] = node + }, + [] + ) + const registerDiffInstance = useCallback( + (path: string, target: RegisteredDiffInstance | null) => { + if (target) diffInstanceRefs.current[path] = target + else delete diffInstanceRefs.current[path] + }, + [] + ) + + const toggleExpanded = useCallback((path: string) => { + const current = expandedRef.current[path] ?? !viewedRef.current.has(path) + const next = !current + if (!next && expandedFindingRef.current?.file === path) setExpandedId(null) + setExpandedFiles((prev) => ({ ...prev, [path]: next })) + }, []) + + const selectLines = useCallback( + (path: string, range: SelectedLineRange | null) => { + if (range) { + setUserSelection({ file: path, range }) + if (expandedFindingRef.current) setExpandedId(null) + } else { + setUserSelection((prev) => (prev?.file === path ? null : prev)) + } + }, + [] + ) + + const addToChat = useCallback( + (path: string, range: SelectedLineRange) => { + const file = filesByPathRef.current.get(path) + if (!file) return + for (const attachment of buildSelectionAttachments(file, range)) { + composer?.addAttachment(attachment) + } + setSideTab("chat") + setUserSelection(null) + }, + [composer] + ) + + // ⌘L / Ctrl+L adds the current line selection to the chat (Cursor-style). + const userSelectionRef = useRef(userSelection) + userSelectionRef.current = userSelection + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "l") { + const sel = userSelectionRef.current + if (sel) { + event.preventDefault() + addToChat(sel.file, sel.range) + } + } + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [addToChat]) + + // Clicking away from the highlighted rows clears the selection. A pointer-down + // that begins a fresh selection clears here first, then the new drag repaints. + // Reads the ref so the listener is registered once (no churn during a drag). + useEffect(() => { + const onPointerDown = (event: PointerEvent) => { + if (!userSelectionRef.current) return + const target = event.target + if (target instanceof Element && target.closest("[data-add-to-chat]")) + return + setUserSelection(null) + } + window.addEventListener("pointerdown", onPointerDown) + return () => window.removeEventListener("pointerdown", onPointerDown) + }, []) + + // Toggle a finding from its in-diff header — it's already on-screen, so no + // scrolling is needed. + const toggleInline = useCallback( + (finding: ReviewFinding) => { + markRead(finding.id) + setExpandedId((prev) => (prev === finding.id ? null : finding.id)) + }, + [markRead] + ) + + // Open a finding from the side panel. Anchored findings expand inline in the + // diff: open the file, scroll it into view, then poll a few frames for the + // annotation node (its diff rows window in/out under virtualization) and + // scroll that into view. Non-anchored findings expand inline in the panel. + const openFromPanel = useCallback( + (finding: ReviewFinding) => { + markRead(finding.id) + const willExpand = expandedFindingRef.current?.id !== finding.id + const requestId = ++findingScrollRequestRef.current + setUserSelection(null) + setExpandedId(willExpand ? finding.id : null) + if (!willExpand || !isAnchored(finding)) return + setSelectedFile(finding.file) + setExpandedFiles((prev) => ({ ...prev, [finding.file]: true })) + let frames = 0 + let lineScrollDone = false + const snap = () => { + if (requestId !== findingScrollRequestRef.current) return + const scroller = diffScrollElRef.current + if (!scroller) return + + const annotation = annotationRefs.current[finding.id] + if (annotation?.isConnected && annotation.getClientRects().length > 0) { + const delta = scrollElementToCenter(annotation, scroller) + if (delta <= 1 || frames >= FINDING_SCROLL_MAX_FRAMES) return + frames += 1 + requestAnimationFrame(snap) + return + } + + const diffTarget = diffInstanceRefs.current[finding.file] + if (diffTarget) { + lineScrollDone = scrollFindingLineToCenter({ + target: diffTarget, + finding, + scroller, + }) + } else if (!lineScrollDone) { + const fileNode = fileRefs.current[finding.file] + if (fileNode) scrollElementToCenter(fileNode, scroller) + } + + if (frames++ < FINDING_SCROLL_MAX_FRAMES) requestAnimationFrame(snap) + } + requestAnimationFrame(snap) + }, + [markRead] + ) + + const renderFileCard = (file: ReviewDiffFile) => { + const selectedLines = + expandedFinding?.file === file.path && isAnchored(expandedFinding) + ? findingSelectedRange(expandedFinding) + : userSelection?.file === file.path + ? userSelection.range + : null + return ( + + ) + } + + const sidebarData = useMemo( + () => ({ + title: `PR #${detail.number}`, + files: diffFiles, + selected: selectedFile, + viewed, + onSelect: scrollToFile, + groups: sidebarGroups, + view, + onViewChange: setView, + onSelectGroup: scrollToGroup, + }), + [ + detail.number, + diffFiles, + selectedFile, + viewed, + scrollToFile, + sidebarGroups, + view, + setView, + scrollToGroup, + ] + ) + + useEffect(() => { + if (!expandedId) return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setExpandedId(null) + } + window.addEventListener("keydown", onKeyDown) + return () => window.removeEventListener("keydown", onKeyDown) + }, [expandedId]) + + const expandedFindingCtx = useMemo( + () => ({ + expandedId, + reviewUrl: detail.url, + toggle: toggleInline, + registerAnnotation, + }), + [expandedId, detail.url, toggleInline, registerAnnotation] + ) + + return ( + +
+
+ {!embedded && ( +
+ +
+ )} +
+ {embedded && ( +
+ +
+ )} + + +
+ +
+ {detail.pr.body ? ( + + ) : ( +

+ This PR has no description. +

+ )} +
+ +
+
+

Changes

+
+ {linesLeft !== null && ( + + {linesLeft === 0 + ? "All lines reviewed" + : `${linesLeft} lines left`} + + )} + {diffFiles && diffFiles.length > 0 && ( + + )} +
+
+ {!diffFiles ? ( + + ) : diffFiles.length === 0 ? ( +

+ No diff available. +

+ ) : view === "ai" && groupedView ? ( +
+ {groupedView.map((group) => ( +
{ + groupRefs.current[group.index] = node + }} + className="scroll-mt-4 space-y-3" + > + + {group.files.map(renderFileCard)} +
+ ))} +
+ ) : ( +
+ {diffFiles.map(renderFileCard)} +
+ )} +
+ + +
+
+ + {!embedded && ( + + )} +
+
+ ) +} + +function DiffStyleToggle({ + value, + onChange, +}: { + value: DiffStyle + onChange: (value: DiffStyle) => void +}) { + return ( +
+ onChange("unified")} + > + + + onChange("split")} + > + + +
+ ) +} + +function DiffStyleButton({ + active, + label, + onClick, + children, +}: { + active: boolean + label: string + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + +function PrHeader({ detail }: { detail: ReviewDetail }) { + const { pr } = detail + const stateStyles: Record = { + open: "border-emerald-600/40 text-emerald-500", + draft: "border-border text-muted-foreground", + merged: "border-purple-600/40 text-purple-500", + closed: "border-red-600/40 text-red-500", + } + return ( +
+ + + {pr.state} + +

+ + {pr.title} + +

+
+ {pr.author && ( + {pr.author.login} + )} + + {pr.base_ref} + + ← + + {pr.head_ref} + + + {pr.changed_files} file{pr.changed_files === 1 ? "" : "s"} + + +{pr.additions} + -{pr.deletions} +
+
+ ) +} + +function GroupHeader({ group }: { group: ResolvedGroup }) { + return ( +
+ + {group.index} + +

{group.title}

+ + {group.additions > 0 && ( + +{group.additions} + )} + {group.deletions > 0 && ( + -{group.deletions} + )} + +
+ ) +} + +const FileDiffCard = memo(function FileDiffCard({ + file, + findings, + selectedLines, + viewed, + onToggleViewed, + expanded, + onToggleExpanded, + onSelectLines, + onAddToChat, + registerSection, + registerDiffInstance, + diffStyle, +}: { + file: ReviewDiffFile + findings: Array + selectedLines: SelectedLineRange | null + viewed: boolean + onToggleViewed: (path: string) => void + expanded: boolean + onToggleExpanded: (path: string) => void + onSelectLines: (path: string, range: SelectedLineRange | null) => void + onAddToChat?: (path: string, range: SelectedLineRange) => void + registerSection: (path: string, node: HTMLDivElement | null) => void + registerDiffInstance: ( + path: string, + target: RegisteredDiffInstance | null + ) => void + diffStyle: DiffStyle +}) { + // No chat means no line-selection → "Add to Chat" affordance (embedded view). + const selectable = Boolean(onAddToChat) + const diffOptions = useDiffOptions(diffStyle) + const diffWrapperRef = useRef(null) + const lastPointerRef = useRef<{ x: number; y: number } | null>(null) + const [popup, setPopup] = useState<{ + range: SelectedLineRange + x: number + y: number + } | null>(null) + + const lineAnnotations = useMemo>>( + () => + findings + .filter((finding) => finding.end_line !== null) + .map((finding) => ({ + side: findingSide(finding), + lineNumber: finding.end_line as number, + metadata: finding, + })), + [findings] + ) + + // Native line selection plus the visible gutter "+" handle you can click and + // drag to select a range. onLineSelectionChange fires on every drag move; we + // push it into the controlled selection so the rows highlight live as you + // drag (in controlled mode Pierre only paints when the prop updates). The + // "Add to Chat" popup is shown on onLineSelectionEnd (release only); ⌘L + // (handled in ReviewBody) adds without it. onGutterUtilityClick must be + // non-null for Pierre to turn the "+" into a drag selector. + const cardOptions = useMemo( + () => ({ + ...diffOptions, + enableLineSelection: selectable, + enableGutterUtility: selectable, + onGutterUtilityClick: selectable ? () => undefined : undefined, + onLineSelectionChange: (range: SelectedLineRange | null) => + onSelectLines(file.path, range), + onLineSelectionEnd: (range: SelectedLineRange | null) => { + onSelectLines(file.path, range) + if (!range) { + setPopup(null) + return + } + // Anchor to the gutter "+" handle Pierre places on the selection's + // bottom line (inside the diff's shadow DOM) — a stable position, + // unlike the pointer-release point. Fall back to the pointer. + const host = diffWrapperRef.current?.querySelector("diffs-container") + const handle = host?.shadowRoot?.querySelector( + "[data-gutter-utility-slot]" + ) + const rect = + handle instanceof HTMLElement ? handle.getBoundingClientRect() : null + const pointer = lastPointerRef.current + if (rect) setPopup({ range, x: rect.left, y: rect.top }) + else if (pointer) setPopup({ range, x: pointer.x, y: pointer.y }) + else setPopup(null) + }, + onPostRender: ( + node: HTMLElement, + instance: CoreFileDiff + ) => registerDiffInstance(file.path, { host: node, instance }), + }), + [diffOptions, selectable, file.path, onSelectLines, registerDiffInstance] + ) + + const addPopupToChat = useCallback(() => { + if (popup) onAddToChat?.(file.path, popup.range) + setPopup(null) + }, [popup, onAddToChat, file.path]) + + // Drop the popup once the selection clears (e.g. added via ⌘L, or a finding + // took focus) so it can't add the same range twice. + useEffect(() => { + if (!selectedLines) setPopup(null) + }, [selectedLines]) + + const oldFile = useMemo( + () => ({ + name: file.path, + contents: file.originalContent, + cacheKey: fileContentsCacheKey(file.path, "old", file.originalContent), + }), + [file.path, file.originalContent] + ) + const newFile = useMemo( + () => ({ + name: file.path, + contents: file.modifiedContent, + cacheKey: fileContentsCacheKey(file.path, "new", file.modifiedContent), + }), + [file.path, file.modifiedContent] + ) + + const sectionRef = useCallback( + (node: HTMLDivElement | null) => registerSection(file.path, node), + [registerSection, file.path] + ) + useEffect( + () => () => registerDiffInstance(file.path, null), + [file.path, registerDiffInstance] + ) + const renderAnnotation = useCallback( + (annotation: DiffLineAnnotation) => ( + + ), + [] + ) + + return ( +
+
+ + + +{file.additions} + -{file.deletions} + + {findings.length > 0 && ( + + + {findings.length} + + )} + +
+ {expanded && + (file.unrenderable ? ( +
+ Binary or large file — diff not shown. +
+ ) : ( +
{ + lastPointerRef.current = { x: event.clientX, y: event.clientY } + }} + className="overflow-x-auto bg-[var(--ui-panel)] font-mono text-[11px] leading-5" + > + + oldFile={oldFile} + newFile={newFile} + options={cardOptions} + metrics={DIFF_VIRTUAL_METRICS} + lineAnnotations={lineAnnotations} + selectedLines={selectedLines} + renderAnnotation={renderAnnotation} + /> + {popup && ( + setPopup(null)} + /> + )} +
+ ))} +
+ ) +}) + +function AddToChatPopup({ + x, + y, + onAdd, + onDismiss, +}: { + x: number + y: number + onAdd: () => void + onDismiss: () => void +}) { + // Positioned fixed at the pointer-release point so it escapes the diff's + // overflow clipping. Dismiss on Escape, scroll, or any outside pointer-down. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onDismiss() + } + const onPointerDown = (event: PointerEvent) => { + const target = event.target + if (target instanceof Element && target.closest("[data-add-to-chat]")) + return + onDismiss() + } + window.addEventListener("keydown", onKeyDown) + window.addEventListener("pointerdown", onPointerDown) + // Capture so it also catches scrolls from the diff scroll container. + window.addEventListener("scroll", onDismiss, true) + return () => { + window.removeEventListener("keydown", onKeyDown) + window.removeEventListener("pointerdown", onPointerDown) + window.removeEventListener("scroll", onDismiss, true) + } + }, [onDismiss]) + + return ( +
+ +
+ ) +} + +// The finding rendered inline in the diff (via Pierre's annotation portal). A +// collapsed header sits at the line; clicking it expands the full details in +// place. Expand state is shared through context so it survives the annotation +// remounting as rows window in/out, and so the side panel can drive it. +function InlineFinding({ finding }: { finding: ReviewFinding }) { + const { expandedId, reviewUrl, toggle, registerAnnotation } = + useExpandedFinding() + const expanded = expandedId === finding.id + const style = GROUP_STYLES[finding.group] + const Icon = style.Icon + return ( +
registerAnnotation(finding.id, node)} + className="px-2 py-1 font-sans" + > +
+ + {expanded && } +
+
+ ) +} + +// The expandable body + actions of a finding, shared by the inline diff +// annotation and the side-panel row (non-anchored findings). +function FindingDetails({ + finding, + reviewUrl, +}: { + finding: ReviewFinding + reviewUrl: string +}) { + const [copied, setCopied] = useState(false) + const githubUrl = + finding.github_review_comment_id !== null + ? `${reviewUrl}#discussion_r${finding.github_review_comment_id}` + : null + + const copy = () => { + void navigator.clipboard + .writeText(findingClipboardText(finding)) + .then(() => { + setCopied(true) + window.setTimeout(() => setCopied(false), 1500) + }) + } + + return ( +
+
+ +
+ {finding.resolution_note && ( +

+ Resolution: {finding.resolution_note} +

+ )} +
+ + {githubUrl && ( + + + View on GitHub + + )} +
+
+ ) +} + +function Badgeish({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +const REVIEW_PANEL_STORAGE_WIDTH = "open-swe.review-panel.width" +const REVIEW_PANEL_DEFAULT_WIDTH = 420 +const REVIEW_PANEL_MIN_WIDTH = 360 +// Keep at least this much room for the PR content column so the panel can grow +// wide without squeezing the diff/description below a usable width. +const REVIEW_PANEL_MIN_MAIN_WIDTH = 480 + +function reviewPanelMaxWidth(availableWidth?: number): number { + if (typeof window === "undefined") return REVIEW_PANEL_DEFAULT_WIDTH + const available = availableWidth ?? window.innerWidth + return Math.max( + REVIEW_PANEL_MIN_WIDTH, + available - REVIEW_PANEL_MIN_MAIN_WIDTH + ) +} + +function clampReviewPanelWidth(width: number, availableWidth?: number): number { + return Math.min( + reviewPanelMaxWidth(availableWidth), + Math.max(REVIEW_PANEL_MIN_WIDTH, width) + ) +} + +function readStoredReviewPanelWidth(): number { + if (typeof window === "undefined") return REVIEW_PANEL_DEFAULT_WIDTH + const raw = window.localStorage.getItem(REVIEW_PANEL_STORAGE_WIDTH) + const parsed = raw ? Number(raw) : NaN + if (!Number.isFinite(parsed)) return REVIEW_PANEL_DEFAULT_WIDTH + return clampReviewPanelWidth(parsed) +} + +function ReviewPanelResizeHandle({ + 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 ( +
+ ) +} + +function SidePanel({ + detail, + tab, + onTabChange, + read, + expandedId, + onMarkAllRead, + onFindingClick, +}: { + detail: ReviewDetail + tab: SideTab + onTabChange: (tab: SideTab) => void + read: Set + expandedId: string | null + onMarkAllRead: () => void + onFindingClick: (finding: ReviewFinding) => void +}) { + const qc = useQueryClient() + const reReview = useMutation({ + mutationFn: () => api.reReview(detail.owner, detail.repo, detail.number), + onSuccess: () => { + void qc.invalidateQueries({ + queryKey: ["review", detail.owner, detail.repo, detail.number], + }) + }, + }) + + const panelRef = useRef(null) + const [width, setWidthState] = useState(() => readStoredReviewPanelWidth()) + const setWidth = useCallback((next: number) => { + const available = panelRef.current?.parentElement?.clientWidth + const clamped = clampReviewPanelWidth(next, available) + setWidthState(clamped) + if (typeof window !== "undefined") { + window.localStorage.setItem(REVIEW_PANEL_STORAGE_WIDTH, String(clamped)) + } + }, []) + + // Re-clamp against the real container width on mount and on window resize so + // the panel can never squeeze the PR content below its minimum. + useEffect(() => { + if (typeof window === "undefined") return + const reclamp = () => setWidth(width) + reclamp() + window.addEventListener("resize", reclamp) + return () => window.removeEventListener("resize", reclamp) + }, [setWidth, width]) + + const bugs = detail.findings.filter((f) => f.group === "bug") + const flags = detail.findings.filter((f) => f.group !== "bug") + const openBugs = bugs.filter((f) => f.status === "open") + const openFlags = flags.filter((f) => f.status === "open") + + return ( +
+ + +
+ ) +} + +function FindingSection({ + icon: HeaderIcon, + label, + emptyLabel, + findings, + read, + expandedId, + reviewUrl, + onFindingClick, + action, +}: { + icon: (typeof GROUP_STYLES)["bug"]["Icon"] + label: string + emptyLabel: string + findings: Array + read: Set + expandedId: string | null + reviewUrl: string + onFindingClick: (finding: ReviewFinding) => void + action?: React.ReactNode +}) { + const [collapsed, setCollapsed] = useState(false) + return ( +
+
+ + {action} +
+ {!collapsed && + (findings.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
+ {findings.map((finding) => { + const style = GROUP_STYLES[finding.group] + const Icon = style.Icon + const isRead = read.has(finding.id) + const muted = finding.status !== "open" || isRead + const anchored = isAnchored(finding) + const expanded = expandedId === finding.id && !anchored + return ( +
+ + {expanded && ( + + )} +
+ ) + })} +
+ ))} +
+ ) +} + +function ChecksSection({ checks }: { checks: Array }) { + return ( +
+

Checks

+ {checks.length === 0 ? ( +

No checks reported.

+ ) : ( +
+ {checks.map((check, index) => + check.url ? ( + + + {check.name} + + ) : ( + + + {check.name} + + ) + )} +
+ )} +
+ ) +} + +function CheckStatusIcon({ check }: { check: ReviewCheckRun }) { + if (check.status !== "completed") { + return ( + + ) + } + if (check.conclusion === "success" || check.conclusion === "neutral") { + return + } + if (check.conclusion === "skipped") { + return + } + return +} + +function PeopleSection({ + title, + people, +}: { + title: string + people: Array +}) { + return ( +
+

{title}

+ {people.length === 0 ? ( +

None

+ ) : ( +
+ {people.map((person) => ( +
+ {person.avatar_url ? ( + + ) : ( + + )} + {person.login} +
+ ))} +
+ )} +
+ ) +} diff --git a/ui/src/components/agents/ReviewSidebar.tsx b/ui/src/components/agents/ReviewSidebar.tsx index a8d886ab..d9b97c22 100644 --- a/ui/src/components/agents/ReviewSidebar.tsx +++ b/ui/src/components/agents/ReviewSidebar.tsx @@ -1,12 +1,4 @@ -import { - createContext, - memo, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react" +import { memo, useCallback, useEffect, useMemo, useState } from "react" import { FileTree, useFileTree, @@ -27,7 +19,10 @@ import type { import type { ReviewDiffFile } from "@/lib/api" import { Markdown } from "@/components/agents/ported" import { Skeleton } from "@/components/ui/skeleton" -import { TREE_UNSAFE_CSS, treeThemeStyle } from "@/components/agents/AgentGitPanel" +import { + TREE_UNSAFE_CSS, + treeThemeStyle, +} from "@/components/agents/AgentGitPanel" import { cn } from "@/lib/utils" function reviewFileGitStatus(status: ReviewDiffFile["status"]): GitStatus { @@ -61,38 +56,6 @@ export interface ReviewSidebarData { onSelectGroup: (index: number) => void } -const ReviewSidebarContext = createContext<{ - data: ReviewSidebarData | null - setData: (data: ReviewSidebarData | null) => void -} | null>(null) - -export function ReviewSidebarProvider({ - children, -}: { - children: React.ReactNode -}) { - const [data, setData] = useState(null) - const value = useMemo(() => ({ data, setData }), [data]) - return ( - - {children} - - ) -} - -export function useReviewSidebarData(): ReviewSidebarData | null { - return useContext(ReviewSidebarContext)?.data ?? null -} - -export function useRegisterReviewSidebar(data: ReviewSidebarData) { - const setData = useContext(ReviewSidebarContext)?.setData - useEffect(() => { - if (!setData) return - setData(data) - return () => setData(null) - }, [setData, data]) -} - export function ReviewSidebarPanel({ data }: { data: ReviewSidebarData }) { const hasGroups = data.groups !== null && data.groups.length > 0 const showAi = data.view === "ai" && hasGroups diff --git a/ui/src/components/agents/ReviewTab.tsx b/ui/src/components/agents/ReviewTab.tsx new file mode 100644 index 00000000..bae0679c --- /dev/null +++ b/ui/src/components/agents/ReviewTab.tsx @@ -0,0 +1,72 @@ +import { useNavigate } from "@tanstack/react-router" +import { useQuery } from "@tanstack/react-query" + +import type { AgentThread } from "@/lib/agents/types" +import { ReviewMainBody } from "@/components/agents/ReviewMainBody" +import { api } from "@/lib/api" + +// The git panel's "Review" sub-tab: the PR's Open SWE review rendered inline (no +// side panel / chat), with an expand affordance that opens the full review page. +export function ReviewTab({ thread }: { thread: AgentThread }) { + const navigate = useNavigate() + const pr = thread.pr + const [owner, repo] = thread.repoFullName.split("/") + const number = pr?.number ?? null + const enabled = Boolean(owner && repo && number !== null) + + const detail = useQuery({ + queryKey: ["review", owner, repo, number], + queryFn: () => + api.getReview(owner as string, repo as string, number as number), + enabled, + refetchInterval: (query) => + query.state.data?.status === "running" ? 5000 : false, + }) + const diff = useQuery({ + queryKey: ["reviewDiff", owner, repo, number], + queryFn: () => + api.getReviewDiff(owner as string, repo as string, number as number), + enabled, + }) + + if (!enabled) { + return ( +
+ Open a pull request to see its review here. +
+ ) + } + if (detail.isLoading) { + return ( +
+ Loading review… +
+ ) + } + if (detail.error || !detail.data) { + return ( +
+ No review for this pull request yet. +
+ ) + } + + return ( + + navigate({ + to: "/agents/reviews/$owner/$repo/$number", + params: { + owner: owner as string, + repo: repo as string, + number: String(number), + }, + }) + } + /> + ) +} diff --git a/ui/src/routes/agents/reviews/$owner.$repo.$number.tsx b/ui/src/routes/agents/reviews/$owner.$repo.$number.tsx index fa6bab79..567f218b 100644 --- a/ui/src/routes/agents/reviews/$owner.$repo.$number.tsx +++ b/ui/src/routes/agents/reviews/$owner.$repo.$number.tsx @@ -1,77 +1,12 @@ import { Link, Navigate, createFileRoute } from "@tanstack/react-router" -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { - createContext, - memo, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from "react" -import { - ArrowClockwiseIcon, - ArrowLeftIcon, - BugBeetleIcon, - CaretDownIcon, - CheckCircleIcon, - CheckIcon, - CircleIcon, - CopyIcon, - FlagIcon, - GitPullRequestIcon, - InfoIcon, - RowsIcon, - SquareSplitHorizontalIcon, - XCircleIcon, -} from "@phosphor-icons/react" -import { IoLogoGithub } from "react-icons/io5" -import { - MultiFileDiff, - Virtualizer, - WorkerPoolContextProvider, -} from "@pierre/diffs/react" -import type { FileContents } from "@pierre/diffs/react" -import type { - FileDiff as CoreFileDiff, - DiffLineAnnotation, - SelectedLineRange, - SelectionSide, -} from "@pierre/diffs" +import { useQuery, useQueryClient } from "@tanstack/react-query" +import { useEffect, useRef } from "react" +import { ArrowLeftIcon, GitPullRequestIcon } from "@phosphor-icons/react" -import type { - ReviewCheckRun, - ReviewDetail, - ReviewDiffFile, - ReviewFinding, - ReviewUserRef, -} from "@/lib/api" -import type { - ReviewSidebarGroup, - ReviewSidebarView, -} from "@/components/agents/ReviewSidebar" -import type { ChatAttachment } from "@/components/agents/ReviewChat" -import type { DiffStyle } from "@/components/agents/utils/diffUtils" -import { Markdown } from "@/components/agents/ported" -import { - ReviewChat, - ReviewChatComposerProvider, - useReviewChatComposer, -} from "@/components/agents/ReviewChat" -import { useRegisterReviewSidebar } from "@/components/agents/ReviewSidebar" +import { ReviewMainBody } from "@/components/agents/ReviewMainBody" import { useSidebarCollapsed } from "@/components/sidebar-layout" -import { - DIFF_VIRTUALIZER_CONFIG, - DIFF_VIRTUAL_METRICS, - DIFF_WORKER_HIGHLIGHTER_OPTIONS, - DIFF_WORKER_POOL_OPTIONS, - fileContentsCacheKey, - useDiffOptions, - warmDiffHighlighter, -} from "@/components/agents/utils/diffUtils" import { Skeleton } from "@/components/ui/skeleton" -import { api, reviewImageProxyUrl } from "@/lib/api" +import { api } from "@/lib/api" import { useSession } from "@/lib/session" import { cn } from "@/lib/utils" @@ -79,268 +14,6 @@ export const Route = createFileRoute("/agents/reviews/$owner/$repo/$number")({ component: ReviewDetailPage, }) -type SideTab = "info" | "chat" - -const REVIEW_VIEW_STORAGE_KEY = "open-swe.review.view" -const REVIEW_DIFF_STYLE_STORAGE_KEY = "open-swe.review.diffStyle" -const FINDING_SCROLL_MAX_FRAMES = 120 - -function readStoredDiffStyle(): DiffStyle { - if (typeof window === "undefined") return "unified" - return window.localStorage.getItem(REVIEW_DIFF_STYLE_STORAGE_KEY) === "split" - ? "split" - : "unified" -} - -// One attachment for a single-side line range. Deletions resolve against the -// original file, additions against the modified file. -function makeSideAttachment( - file: ReviewDiffFile, - side: "deletions" | "additions", - fromLine: number, - toLine: number -): ChatAttachment { - const source = - side === "deletions" ? file.originalContent : file.modifiedContent - const lines = source.split("\n") - const start = Math.max(1, Math.min(fromLine, toLine)) - const end = Math.max(fromLine, toLine) - const snippet = lines.slice(start - 1, end).join("\n") - const sideLabel = side === "deletions" ? "L" : "R" - const lineLabel = - start === end ? `${sideLabel}${start}` : `${sideLabel}${start}-${end}` - const language = file.path.includes(".") - ? (file.path.split(".").pop() ?? "") - : "" - return { - id: crypto.randomUUID(), - path: file.path, - lineLabel, - language, - snippet, - } -} - -// Build chat attachments from the selected range. A range can span from a -// deletion to an addition (side !== endSide) when dragging across a replaced -// block; slicing one file by start..end would paste the wrong lines, so each -// side is collected separately. -function buildSelectionAttachments( - file: ReviewDiffFile, - range: SelectedLineRange -): Array { - const startSide = range.side ?? "additions" - const endSide = range.endSide ?? startSide - if (startSide === endSide) { - return [makeSideAttachment(file, startSide, range.start, range.end)] - } - const deletionLine = startSide === "deletions" ? range.start : range.end - const additionLine = startSide === "additions" ? range.start : range.end - return [ - makeSideAttachment(file, "deletions", deletionLine, deletionLine), - makeSideAttachment(file, "additions", additionLine, additionLine), - ] -} - -// Scroll a file card / group flush to the top of the diff scroller. Under -// virtualization, scrollIntoView computes its target against estimated row -// heights; scrolling past unmeasured files reconciles their real heights -// mid-animation and the Virtualizer re-pins its scroll anchor, which leaves the -// target off the top. Once the smooth scroll settles, re-assert alignment (now -// against measured heights) until the target sits at the top or the budget runs -// out. Respects the element's scroll-margin-top. -function scrollCardToTop(el: HTMLElement, scroller: HTMLElement | null): void { - el.scrollIntoView({ block: "start", behavior: "smooth" }) - if (!scroller) return - let frames = 0 - let lastTop = Number.NaN - let stableFrames = 0 - let corrections = 0 - const align = () => { - if (frames++ > 240) return - const top = scroller.scrollTop - if (top === lastTop) stableFrames++ - else { - stableFrames = 0 - lastTop = top - } - // Wait for the smooth scroll + height reconciliation to settle. - if (stableFrames < 3) { - requestAnimationFrame(align) - return - } - const marginTop = parseFloat(getComputedStyle(el).scrollMarginTop) || 0 - const delta = - el.getBoundingClientRect().top - - scroller.getBoundingClientRect().top - - marginTop - if (Math.abs(delta) > 1 && corrections++ < 5) { - el.scrollIntoView({ block: "start", behavior: "smooth" }) - stableFrames = 0 - lastTop = Number.NaN - requestAnimationFrame(align) - } - } - requestAnimationFrame(align) -} - -interface PositionedDiffInstance { - getLinePosition: ( - lineNumber: number, - side?: SelectionSide - ) => { top: number; height: number } | undefined -} - -interface RegisteredDiffInstance { - host: HTMLElement - instance: CoreFileDiff -} - -function hasLinePosition( - instance: CoreFileDiff -): instance is CoreFileDiff & PositionedDiffInstance { - return ( - typeof (instance as { getLinePosition?: unknown }).getLinePosition === - "function" - ) -} - -function clampScrollTop(scroller: HTMLElement, top: number): number { - return Math.max( - 0, - Math.min(top, scroller.scrollHeight - scroller.clientHeight) - ) -} - -function scrollElementToCenter( - el: HTMLElement, - scroller: HTMLElement -): number { - const elementRect = el.getBoundingClientRect() - const scrollerRect = scroller.getBoundingClientRect() - const delta = - elementRect.top - - scrollerRect.top - - (scroller.clientHeight - elementRect.height) / 2 - const targetTop = clampScrollTop(scroller, scroller.scrollTop + delta) - scroller.scrollTo({ top: targetTop, behavior: "auto" }) - return Math.abs(delta) -} - -function scrollFindingLineToCenter({ - target, - finding, - scroller, -}: { - target: RegisteredDiffInstance - finding: ReviewFinding - scroller: HTMLElement -}): boolean { - if (finding.end_line === null || !hasLinePosition(target.instance)) - return false - const line = target.instance.getLinePosition( - finding.end_line, - findingSide(finding) - ) - if (!line) return false - const hostTop = - target.host.getBoundingClientRect().top - - scroller.getBoundingClientRect().top + - scroller.scrollTop - const targetTop = clampScrollTop( - scroller, - hostTop + line.top - (scroller.clientHeight - line.height) / 2 - ) - scroller.scrollTo({ top: targetTop, behavior: "auto" }) - return true -} - -interface ResolvedGroup { - index: number - title: string - summary: string - files: Array - additions: number - deletions: number -} - -const GROUP_STYLES = { - bug: { label: "Bug", className: "text-destructive", Icon: BugBeetleIcon }, - investigate: { - label: "Investigate", - className: "text-amber-500", - Icon: FlagIcon, - }, - informational: { - label: "Informational", - className: "text-muted-foreground", - Icon: InfoIcon, - }, -} as const - -function findingAnchorLabel(finding: ReviewFinding): string { - if (finding.start_line === null || finding.end_line === null) - return finding.file - if (finding.start_line === finding.end_line) - return `${finding.file}:${finding.end_line}` - return `${finding.file}:${finding.start_line}-${finding.end_line}` -} - -function isAnchored(finding: ReviewFinding): boolean { - return Boolean(finding.file) && finding.in_diff && finding.end_line !== null -} - -function findingSide(finding: ReviewFinding): "deletions" | "additions" { - return finding.side === "LEFT" ? "deletions" : "additions" -} - -function findingSelectedRange( - finding: ReviewFinding -): SelectedLineRange | null { - if (finding.end_line === null) return null - const side = findingSide(finding) - return { - start: finding.start_line ?? finding.end_line, - end: finding.end_line, - side, - endSide: side, - } -} - -function findingClipboardText(finding: ReviewFinding): string { - const style = GROUP_STYLES[finding.group] - const lines = [ - `**${style.label}: ${finding.title}**`, - `${findingAnchorLabel(finding)}`, - "", - finding.description, - ] - if (finding.suggestion) - lines.push("", "```suggestion", finding.suggestion, "```") - return lines.join("\n") -} - -// Inline findings live inside Pierre's diff via React portals, so their -// expand/collapse state is lifted here and shared through context — surviving -// the annotation's mount/unmount as rows window in and out under -// virtualization, and letting the side panel drive the same expansion. -interface ExpandedFindingContextValue { - expandedId: string | null - reviewUrl: string - toggle: (finding: ReviewFinding) => void - registerAnnotation: (id: string, node: HTMLElement | null) => void -} - -const ExpandedFindingContext = - createContext(null) - -function useExpandedFinding(): ExpandedFindingContextValue { - const ctx = useContext(ExpandedFindingContext) - if (!ctx) - throw new Error("useExpandedFinding must be used within its provider") - return ctx -} - function ReviewDetailPage() { const { owner, repo, number } = Route.useParams() const prNumber = Number(number) @@ -419,7 +92,7 @@ function ReviewDetailPage() {
) : ( - ) } - -const NO_FINDINGS: Array = [] - -interface UserSelection { - file: string - range: SelectedLineRange -} - -function ReviewBody({ - detail, - diffFiles, -}: { - detail: ReviewDetail - diffFiles: Array | null -}) { - // The composer provider lives inside ReviewBody so it remounts in lockstep - // with the head_sha-keyed body (and the activeId-keyed chat thread). - return ( - - - - ) -} - -function ReviewBodyInner({ - detail, - diffFiles, -}: { - detail: ReviewDetail - diffFiles: Array | null -}) { - const composer = useReviewChatComposer() - const transformPrImage = useCallback( - (src: string) => - reviewImageProxyUrl(detail.owner, detail.repo, detail.number, src), - [detail.owner, detail.repo, detail.number] - ) - const [sideTab, setSideTab] = useState("info") - const [selectedFile, setSelectedFile] = useState(null) - const fileRefs = useRef>({}) - const diffInstanceRefs = useRef< - Record - >({}) - const annotationRefs = useRef>({}) - const [expandedFiles, setExpandedFiles] = useState>( - {} - ) - const [expandedId, setExpandedId] = useState(null) - const [userSelection, setUserSelection] = useState(null) - const diffScrollElRef = useRef(null) - const findingScrollRequestRef = useRef(0) - const groupRefs = useRef>({}) - const [diffStyle, setDiffStyleState] = useState(() => - readStoredDiffStyle() - ) - const setDiffStyle = useCallback((next: DiffStyle) => { - setDiffStyleState(next) - if (typeof window !== "undefined") { - window.localStorage.setItem(REVIEW_DIFF_STYLE_STORAGE_KEY, next) - } - }, []) - - useEffect(() => { - void warmDiffHighlighter() - }, []) - - // Latest-value refs so the callbacks below can stay referentially stable - // (so memo(FileDiffCard) actually skips unrelated re-renders) while still - // reading current state. - const expandedFinding = useMemo( - () => detail.findings.find((f) => f.id === expandedId) ?? null, - [detail.findings, expandedId] - ) - const expandedFindingRef = useRef(expandedFinding) - expandedFindingRef.current = expandedFinding - - const viewedStorageKey = `open-swe.review.viewed.${detail.owner}/${detail.repo}/${detail.number}.${detail.head_sha}` - const [viewed, setViewed] = useState>(() => { - if (typeof window === "undefined") return new Set() - try { - const raw = window.localStorage.getItem(viewedStorageKey) - return new Set(raw ? (JSON.parse(raw) as Array) : []) - } catch { - return new Set() - } - }) - const viewedRef = useRef(viewed) - viewedRef.current = viewed - const expandedRef = useRef(expandedFiles) - expandedRef.current = expandedFiles - - const toggleViewed = useCallback( - (path: string) => { - const becomingViewed = !viewedRef.current.has(path) - setViewed((prev) => { - const next = new Set(prev) - if (becomingViewed) next.add(path) - else next.delete(path) - window.localStorage.setItem( - viewedStorageKey, - JSON.stringify(Array.from(next)) - ) - return next - }) - if (becomingViewed && expandedFindingRef.current?.file === path) - setExpandedId(null) - setExpandedFiles((prev) => ({ ...prev, [path]: !becomingViewed })) - }, - [viewedStorageKey] - ) - - const readStorageKey = `open-swe.review.read.${detail.thread_id}` - const [read, setRead] = useState>(() => { - if (typeof window === "undefined") return new Set() - try { - const raw = window.localStorage.getItem(readStorageKey) - return new Set(raw ? (JSON.parse(raw) as Array) : []) - } catch { - return new Set() - } - }) - const persistRead = useCallback( - (next: Set) => { - window.localStorage.setItem( - readStorageKey, - JSON.stringify(Array.from(next)) - ) - }, - [readStorageKey] - ) - const markRead = useCallback( - (id: string) => { - setRead((prev) => { - const next = new Set(prev).add(id) - persistRead(next) - return next - }) - }, - [persistRead] - ) - const markAllRead = useCallback(() => { - const next = new Set(detail.findings.map((f) => f.id)) - setRead(next) - persistRead(next) - }, [detail.findings, persistRead]) - - const findingsByFile = useMemo(() => { - const byFile = new Map>() - for (const finding of detail.findings) { - if (!isAnchored(finding)) continue - const list = byFile.get(finding.file) ?? [] - list.push(finding) - byFile.set(finding.file, list) - } - return byFile - }, [detail.findings]) - - const linesLeft = useMemo(() => { - if (!diffFiles) return null - return diffFiles - .filter((file) => !viewed.has(file.path)) - .reduce((acc, file) => acc + file.additions + file.deletions, 0) - }, [diffFiles, viewed]) - - // Resolve the AI-sorted groups against the actual diff: drop stale groups - // (generated for a previous head) so the file-tree fallback is used, drop - // paths no longer in the diff and empty groups, and collect any unassigned - // files into a trailing "Other changes" group so nothing ever disappears. - const groupedView = useMemo | null>(() => { - if ( - !diffFiles || - detail.diff_groups_stale || - detail.diff_groups.length === 0 - ) - return null - const byPath = new Map(diffFiles.map((file) => [file.path, file])) - const assigned = new Set() - const resolved: Array> = [] - for (const group of detail.diff_groups) { - const files: Array = [] - for (const path of group.files) { - const file = byPath.get(path) - if (file && !assigned.has(path)) { - assigned.add(path) - files.push(file) - } - } - if (files.length === 0) continue - resolved.push({ - title: group.title, - summary: group.summary, - files, - additions: files.reduce((acc, file) => acc + file.additions, 0), - deletions: files.reduce((acc, file) => acc + file.deletions, 0), - }) - } - const leftover = diffFiles.filter((file) => !assigned.has(file.path)) - if (leftover.length > 0) { - resolved.push({ - title: "Other changes", - summary: "", - files: leftover, - additions: leftover.reduce((acc, file) => acc + file.additions, 0), - deletions: leftover.reduce((acc, file) => acc + file.deletions, 0), - }) - } - if (resolved.length === 0) return null - return resolved.map((group, i) => ({ ...group, index: i + 1 })) - }, [diffFiles, detail.diff_groups, detail.diff_groups_stale]) - - const sidebarGroups = useMemo | null>(() => { - if (!groupedView) return null - return groupedView.map((group) => ({ - index: group.index, - title: group.title, - summary: group.summary, - additions: group.additions, - deletions: group.deletions, - fileCount: group.files.length, - files: group.files.map((file) => file.path), - })) - }, [groupedView]) - - // The view follows fresh-group availability until the user explicitly picks - // one, after which the choice persists across PRs. - const hasFreshGroups = - detail.diff_groups.length > 0 && !detail.diff_groups_stale - const [explicitView, setExplicitView] = useState( - () => { - if (typeof window === "undefined") return null - const stored = window.localStorage.getItem(REVIEW_VIEW_STORAGE_KEY) - return stored === "ai" || stored === "files" ? stored : null - } - ) - const view: ReviewSidebarView = - explicitView ?? (hasFreshGroups ? "ai" : "files") - const setView = useCallback((next: ReviewSidebarView) => { - setExplicitView(next) - if (typeof window !== "undefined") { - window.localStorage.setItem(REVIEW_VIEW_STORAGE_KEY, next) - } - }, []) - - const scrollToFile = useCallback((path: string) => { - setSelectedFile(path) - setExpandedFiles((prev) => ({ ...prev, [path]: true })) - requestAnimationFrame(() => { - const el = fileRefs.current[path] - if (el) scrollCardToTop(el, diffScrollElRef.current) - }) - }, []) - - const scrollToGroup = useCallback((index: number) => { - requestAnimationFrame(() => { - const el = groupRefs.current[index] - if (el) scrollCardToTop(el, diffScrollElRef.current) - }) - }, []) - - const filesByPath = useMemo( - () => new Map((diffFiles ?? []).map((file) => [file.path, file])), - [diffFiles] - ) - const filesByPathRef = useRef(filesByPath) - filesByPathRef.current = filesByPath - - // The Virtualizer doesn't forward a ref; grab its scroll element (the - // grandparent of this hidden probe, which lives in its content div) so - // scroll-to-file/group can align against it. - const scrollerProbe = useCallback((node: HTMLDivElement | null) => { - const scroller = node?.parentElement?.parentElement - diffScrollElRef.current = - scroller instanceof HTMLDivElement ? scroller : null - }, []) - - const registerSection = useCallback( - (path: string, node: HTMLDivElement | null) => { - fileRefs.current[path] = node - }, - [] - ) - const registerAnnotation = useCallback( - (id: string, node: HTMLElement | null) => { - annotationRefs.current[id] = node - }, - [] - ) - const registerDiffInstance = useCallback( - (path: string, target: RegisteredDiffInstance | null) => { - if (target) diffInstanceRefs.current[path] = target - else delete diffInstanceRefs.current[path] - }, - [] - ) - - const toggleExpanded = useCallback((path: string) => { - const current = expandedRef.current[path] ?? !viewedRef.current.has(path) - const next = !current - if (!next && expandedFindingRef.current?.file === path) setExpandedId(null) - setExpandedFiles((prev) => ({ ...prev, [path]: next })) - }, []) - - const selectLines = useCallback( - (path: string, range: SelectedLineRange | null) => { - if (range) { - setUserSelection({ file: path, range }) - if (expandedFindingRef.current) setExpandedId(null) - } else { - setUserSelection((prev) => (prev?.file === path ? null : prev)) - } - }, - [] - ) - - const addToChat = useCallback( - (path: string, range: SelectedLineRange) => { - const file = filesByPathRef.current.get(path) - if (!file) return - for (const attachment of buildSelectionAttachments(file, range)) { - composer?.addAttachment(attachment) - } - setSideTab("chat") - setUserSelection(null) - }, - [composer] - ) - - // ⌘L / Ctrl+L adds the current line selection to the chat (Cursor-style). - const userSelectionRef = useRef(userSelection) - userSelectionRef.current = userSelection - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "l") { - const sel = userSelectionRef.current - if (sel) { - event.preventDefault() - addToChat(sel.file, sel.range) - } - } - } - window.addEventListener("keydown", onKeyDown) - return () => window.removeEventListener("keydown", onKeyDown) - }, [addToChat]) - - // Clicking away from the highlighted rows clears the selection. A pointer-down - // that begins a fresh selection clears here first, then the new drag repaints. - // Reads the ref so the listener is registered once (no churn during a drag). - useEffect(() => { - const onPointerDown = (event: PointerEvent) => { - if (!userSelectionRef.current) return - const target = event.target - if (target instanceof Element && target.closest("[data-add-to-chat]")) - return - setUserSelection(null) - } - window.addEventListener("pointerdown", onPointerDown) - return () => window.removeEventListener("pointerdown", onPointerDown) - }, []) - - // Toggle a finding from its in-diff header — it's already on-screen, so no - // scrolling is needed. - const toggleInline = useCallback( - (finding: ReviewFinding) => { - markRead(finding.id) - setExpandedId((prev) => (prev === finding.id ? null : finding.id)) - }, - [markRead] - ) - - // Open a finding from the side panel. Anchored findings expand inline in the - // diff: open the file, scroll it into view, then poll a few frames for the - // annotation node (its diff rows window in/out under virtualization) and - // scroll that into view. Non-anchored findings expand inline in the panel. - const openFromPanel = useCallback( - (finding: ReviewFinding) => { - markRead(finding.id) - const willExpand = expandedFindingRef.current?.id !== finding.id - const requestId = ++findingScrollRequestRef.current - setUserSelection(null) - setExpandedId(willExpand ? finding.id : null) - if (!willExpand || !isAnchored(finding)) return - setSelectedFile(finding.file) - setExpandedFiles((prev) => ({ ...prev, [finding.file]: true })) - let frames = 0 - let lineScrollDone = false - const snap = () => { - if (requestId !== findingScrollRequestRef.current) return - const scroller = diffScrollElRef.current - if (!scroller) return - - const annotation = annotationRefs.current[finding.id] - if ( - annotation?.isConnected && - annotation.getClientRects().length > 0 - ) { - const delta = scrollElementToCenter(annotation, scroller) - if (delta <= 1 || frames >= FINDING_SCROLL_MAX_FRAMES) return - frames += 1 - requestAnimationFrame(snap) - return - } - - const diffTarget = diffInstanceRefs.current[finding.file] - if (diffTarget) { - lineScrollDone = scrollFindingLineToCenter({ - target: diffTarget, - finding, - scroller, - }) - } else if (!lineScrollDone) { - const fileNode = fileRefs.current[finding.file] - if (fileNode) scrollElementToCenter(fileNode, scroller) - } - - if (frames++ < FINDING_SCROLL_MAX_FRAMES) requestAnimationFrame(snap) - } - requestAnimationFrame(snap) - }, - [markRead] - ) - - const renderFileCard = (file: ReviewDiffFile) => { - const selectedLines = - expandedFinding?.file === file.path && isAnchored(expandedFinding) - ? findingSelectedRange(expandedFinding) - : userSelection?.file === file.path - ? userSelection.range - : null - return ( - - ) - } - - const sidebarData = useMemo( - () => ({ - title: `PR #${detail.number}`, - files: diffFiles, - selected: selectedFile, - viewed, - onSelect: scrollToFile, - groups: sidebarGroups, - view, - onViewChange: setView, - onSelectGroup: scrollToGroup, - }), - [ - detail.number, - diffFiles, - selectedFile, - viewed, - scrollToFile, - sidebarGroups, - view, - setView, - scrollToGroup, - ] - ) - useRegisterReviewSidebar(sidebarData) - - useEffect(() => { - if (!expandedId) return - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") setExpandedId(null) - } - window.addEventListener("keydown", onKeyDown) - return () => window.removeEventListener("keydown", onKeyDown) - }, [expandedId]) - - const expandedFindingCtx = useMemo( - () => ({ - expandedId, - reviewUrl: detail.url, - toggle: toggleInline, - registerAnnotation, - }), - [expandedId, detail.url, toggleInline, registerAnnotation] - ) - - return ( - -
-
- - -
- -
- {detail.pr.body ? ( - - ) : ( -

- This PR has no description. -

- )} -
- -
-
-

Changes

-
- {linesLeft !== null && ( - - {linesLeft === 0 - ? "All lines reviewed" - : `${linesLeft} lines left`} - - )} - {diffFiles && diffFiles.length > 0 && ( - - )} -
-
- {!diffFiles ? ( - - ) : diffFiles.length === 0 ? ( -

- No diff available. -

- ) : view === "ai" && groupedView ? ( -
- {groupedView.map((group) => ( -
{ - groupRefs.current[group.index] = node - }} - className="scroll-mt-4 space-y-3" - > - - {group.files.map(renderFileCard)} -
- ))} -
- ) : ( -
- {diffFiles.map(renderFileCard)} -
- )} -
- - -
- - -
-
- ) -} - -function DiffStyleToggle({ - value, - onChange, -}: { - value: DiffStyle - onChange: (value: DiffStyle) => void -}) { - return ( -
- onChange("unified")} - > - - - onChange("split")} - > - - -
- ) -} - -function DiffStyleButton({ - active, - label, - onClick, - children, -}: { - active: boolean - label: string - onClick: () => void - children: React.ReactNode -}) { - return ( - - ) -} - -function PrHeader({ detail }: { detail: ReviewDetail }) { - const { pr } = detail - const stateStyles: Record = { - open: "border-emerald-600/40 text-emerald-500", - draft: "border-border text-muted-foreground", - merged: "border-purple-600/40 text-purple-500", - closed: "border-red-600/40 text-red-500", - } - return ( -
- - - {pr.state} - -

- - {pr.title} - -

-
- {pr.author && ( - {pr.author.login} - )} - - {pr.base_ref} - - ← - - {pr.head_ref} - - - {pr.changed_files} file{pr.changed_files === 1 ? "" : "s"} - - +{pr.additions} - -{pr.deletions} -
-
- ) -} - -function GroupHeader({ group }: { group: ResolvedGroup }) { - return ( -
- - {group.index} - -

{group.title}

- - {group.additions > 0 && ( - +{group.additions} - )} - {group.deletions > 0 && ( - -{group.deletions} - )} - -
- ) -} - -const FileDiffCard = memo(function FileDiffCard({ - file, - findings, - selectedLines, - viewed, - onToggleViewed, - expanded, - onToggleExpanded, - onSelectLines, - onAddToChat, - registerSection, - registerDiffInstance, - diffStyle, -}: { - file: ReviewDiffFile - findings: Array - selectedLines: SelectedLineRange | null - viewed: boolean - onToggleViewed: (path: string) => void - expanded: boolean - onToggleExpanded: (path: string) => void - onSelectLines: (path: string, range: SelectedLineRange | null) => void - onAddToChat: (path: string, range: SelectedLineRange) => void - registerSection: (path: string, node: HTMLDivElement | null) => void - registerDiffInstance: ( - path: string, - target: RegisteredDiffInstance | null - ) => void - diffStyle: DiffStyle -}) { - const diffOptions = useDiffOptions(diffStyle) - const diffWrapperRef = useRef(null) - const lastPointerRef = useRef<{ x: number; y: number } | null>(null) - const [popup, setPopup] = useState<{ - range: SelectedLineRange - x: number - y: number - } | null>(null) - - const lineAnnotations = useMemo>>( - () => - findings - .filter((finding) => finding.end_line !== null) - .map((finding) => ({ - side: findingSide(finding), - lineNumber: finding.end_line as number, - metadata: finding, - })), - [findings] - ) - - // Native line selection plus the visible gutter "+" handle you can click and - // drag to select a range. onLineSelectionChange fires on every drag move; we - // push it into the controlled selection so the rows highlight live as you - // drag (in controlled mode Pierre only paints when the prop updates). The - // "Add to Chat" popup is shown on onLineSelectionEnd (release only); ⌘L - // (handled in ReviewBody) adds without it. onGutterUtilityClick must be - // non-null for Pierre to turn the "+" into a drag selector. - const cardOptions = useMemo( - () => ({ - ...diffOptions, - enableLineSelection: true, - enableGutterUtility: true, - onGutterUtilityClick: () => undefined, - onLineSelectionChange: (range: SelectedLineRange | null) => - onSelectLines(file.path, range), - onLineSelectionEnd: (range: SelectedLineRange | null) => { - onSelectLines(file.path, range) - if (!range) { - setPopup(null) - return - } - // Anchor to the gutter "+" handle Pierre places on the selection's - // bottom line (inside the diff's shadow DOM) — a stable position, - // unlike the pointer-release point. Fall back to the pointer. - const host = diffWrapperRef.current?.querySelector("diffs-container") - const handle = host?.shadowRoot?.querySelector( - "[data-gutter-utility-slot]" - ) - const rect = - handle instanceof HTMLElement ? handle.getBoundingClientRect() : null - const pointer = lastPointerRef.current - if (rect) setPopup({ range, x: rect.left, y: rect.top }) - else if (pointer) setPopup({ range, x: pointer.x, y: pointer.y }) - else setPopup(null) - }, - onPostRender: ( - node: HTMLElement, - instance: CoreFileDiff - ) => registerDiffInstance(file.path, { host: node, instance }), - }), - [diffOptions, file.path, onSelectLines, registerDiffInstance] - ) - - const addPopupToChat = useCallback(() => { - if (popup) onAddToChat(file.path, popup.range) - setPopup(null) - }, [popup, onAddToChat, file.path]) - - // Drop the popup once the selection clears (e.g. added via ⌘L, or a finding - // took focus) so it can't add the same range twice. - useEffect(() => { - if (!selectedLines) setPopup(null) - }, [selectedLines]) - - const oldFile = useMemo( - () => ({ - name: file.path, - contents: file.originalContent, - cacheKey: fileContentsCacheKey(file.path, "old", file.originalContent), - }), - [file.path, file.originalContent] - ) - const newFile = useMemo( - () => ({ - name: file.path, - contents: file.modifiedContent, - cacheKey: fileContentsCacheKey(file.path, "new", file.modifiedContent), - }), - [file.path, file.modifiedContent] - ) - - const sectionRef = useCallback( - (node: HTMLDivElement | null) => registerSection(file.path, node), - [registerSection, file.path] - ) - useEffect( - () => () => registerDiffInstance(file.path, null), - [file.path, registerDiffInstance] - ) - const renderAnnotation = useCallback( - (annotation: DiffLineAnnotation) => ( - - ), - [] - ) - - return ( -
-
- - - +{file.additions} - -{file.deletions} - - {findings.length > 0 && ( - - - {findings.length} - - )} - -
- {expanded && - (file.unrenderable ? ( -
- Binary or large file — diff not shown. -
- ) : ( -
{ - lastPointerRef.current = { x: event.clientX, y: event.clientY } - }} - className="overflow-x-auto bg-[var(--ui-panel)] font-mono text-[11px] leading-5" - > - - oldFile={oldFile} - newFile={newFile} - options={cardOptions} - metrics={DIFF_VIRTUAL_METRICS} - lineAnnotations={lineAnnotations} - selectedLines={selectedLines} - renderAnnotation={renderAnnotation} - /> - {popup && ( - setPopup(null)} - /> - )} -
- ))} -
- ) -}) - -function AddToChatPopup({ - x, - y, - onAdd, - onDismiss, -}: { - x: number - y: number - onAdd: () => void - onDismiss: () => void -}) { - // Positioned fixed at the pointer-release point so it escapes the diff's - // overflow clipping. Dismiss on Escape, scroll, or any outside pointer-down. - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") onDismiss() - } - const onPointerDown = (event: PointerEvent) => { - const target = event.target - if (target instanceof Element && target.closest("[data-add-to-chat]")) - return - onDismiss() - } - window.addEventListener("keydown", onKeyDown) - window.addEventListener("pointerdown", onPointerDown) - // Capture so it also catches scrolls from the diff scroll container. - window.addEventListener("scroll", onDismiss, true) - return () => { - window.removeEventListener("keydown", onKeyDown) - window.removeEventListener("pointerdown", onPointerDown) - window.removeEventListener("scroll", onDismiss, true) - } - }, [onDismiss]) - - return ( -
- -
- ) -} - -// The finding rendered inline in the diff (via Pierre's annotation portal). A -// collapsed header sits at the line; clicking it expands the full details in -// place. Expand state is shared through context so it survives the annotation -// remounting as rows window in/out, and so the side panel can drive it. -function InlineFinding({ finding }: { finding: ReviewFinding }) { - const { expandedId, reviewUrl, toggle, registerAnnotation } = - useExpandedFinding() - const expanded = expandedId === finding.id - const style = GROUP_STYLES[finding.group] - const Icon = style.Icon - return ( -
registerAnnotation(finding.id, node)} - className="px-2 py-1 font-sans" - > -
- - {expanded && } -
-
- ) -} - -// The expandable body + actions of a finding, shared by the inline diff -// annotation and the side-panel row (non-anchored findings). -function FindingDetails({ - finding, - reviewUrl, -}: { - finding: ReviewFinding - reviewUrl: string -}) { - const [copied, setCopied] = useState(false) - const githubUrl = - finding.github_review_comment_id !== null - ? `${reviewUrl}#discussion_r${finding.github_review_comment_id}` - : null - - const copy = () => { - void navigator.clipboard - .writeText(findingClipboardText(finding)) - .then(() => { - setCopied(true) - window.setTimeout(() => setCopied(false), 1500) - }) - } - - return ( -
-
- -
- {finding.resolution_note && ( -

- Resolution: {finding.resolution_note} -

- )} -
- - {githubUrl && ( - - - View on GitHub - - )} -
-
- ) -} - -function Badgeish({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ) -} - -const REVIEW_PANEL_STORAGE_WIDTH = "open-swe.review-panel.width" -const REVIEW_PANEL_DEFAULT_WIDTH = 420 -const REVIEW_PANEL_MIN_WIDTH = 360 -// Keep at least this much room for the PR content column so the panel can grow -// wide without squeezing the diff/description below a usable width. -const REVIEW_PANEL_MIN_MAIN_WIDTH = 480 - -function reviewPanelMaxWidth(availableWidth?: number): number { - if (typeof window === "undefined") return REVIEW_PANEL_DEFAULT_WIDTH - const available = availableWidth ?? window.innerWidth - return Math.max( - REVIEW_PANEL_MIN_WIDTH, - available - REVIEW_PANEL_MIN_MAIN_WIDTH - ) -} - -function clampReviewPanelWidth(width: number, availableWidth?: number): number { - return Math.min( - reviewPanelMaxWidth(availableWidth), - Math.max(REVIEW_PANEL_MIN_WIDTH, width) - ) -} - -function readStoredReviewPanelWidth(): number { - if (typeof window === "undefined") return REVIEW_PANEL_DEFAULT_WIDTH - const raw = window.localStorage.getItem(REVIEW_PANEL_STORAGE_WIDTH) - const parsed = raw ? Number(raw) : NaN - if (!Number.isFinite(parsed)) return REVIEW_PANEL_DEFAULT_WIDTH - return clampReviewPanelWidth(parsed) -} - -function ReviewPanelResizeHandle({ - 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 ( -
- ) -} - -function SidePanel({ - detail, - tab, - onTabChange, - read, - expandedId, - onMarkAllRead, - onFindingClick, -}: { - detail: ReviewDetail - tab: SideTab - onTabChange: (tab: SideTab) => void - read: Set - expandedId: string | null - onMarkAllRead: () => void - onFindingClick: (finding: ReviewFinding) => void -}) { - const qc = useQueryClient() - const reReview = useMutation({ - mutationFn: () => api.reReview(detail.owner, detail.repo, detail.number), - onSuccess: () => { - void qc.invalidateQueries({ - queryKey: ["review", detail.owner, detail.repo, detail.number], - }) - }, - }) - - const panelRef = useRef(null) - const [width, setWidthState] = useState(() => readStoredReviewPanelWidth()) - const setWidth = useCallback((next: number) => { - const available = panelRef.current?.parentElement?.clientWidth - const clamped = clampReviewPanelWidth(next, available) - setWidthState(clamped) - if (typeof window !== "undefined") { - window.localStorage.setItem(REVIEW_PANEL_STORAGE_WIDTH, String(clamped)) - } - }, []) - - // Re-clamp against the real container width on mount and on window resize so - // the panel can never squeeze the PR content below its minimum. - useEffect(() => { - if (typeof window === "undefined") return - const reclamp = () => setWidth(width) - reclamp() - window.addEventListener("resize", reclamp) - return () => window.removeEventListener("resize", reclamp) - }, [setWidth, width]) - - const bugs = detail.findings.filter((f) => f.group === "bug") - const flags = detail.findings.filter((f) => f.group !== "bug") - const openBugs = bugs.filter((f) => f.status === "open") - const openFlags = flags.filter((f) => f.status === "open") - - return ( -
- - -
- ) -} - -function FindingSection({ - icon: HeaderIcon, - label, - emptyLabel, - findings, - read, - expandedId, - reviewUrl, - onFindingClick, - action, -}: { - icon: (typeof GROUP_STYLES)["bug"]["Icon"] - label: string - emptyLabel: string - findings: Array - read: Set - expandedId: string | null - reviewUrl: string - onFindingClick: (finding: ReviewFinding) => void - action?: React.ReactNode -}) { - const [collapsed, setCollapsed] = useState(false) - return ( -
-
- - {action} -
- {!collapsed && - (findings.length === 0 ? ( -

{emptyLabel}

- ) : ( -
- {findings.map((finding) => { - const style = GROUP_STYLES[finding.group] - const Icon = style.Icon - const isRead = read.has(finding.id) - const muted = finding.status !== "open" || isRead - const anchored = isAnchored(finding) - const expanded = expandedId === finding.id && !anchored - return ( -
- - {expanded && ( - - )} -
- ) - })} -
- ))} -
- ) -} - -function ChecksSection({ checks }: { checks: Array }) { - return ( -
-

Checks

- {checks.length === 0 ? ( -

No checks reported.

- ) : ( -
- {checks.map((check, index) => - check.url ? ( - - - {check.name} - - ) : ( - - - {check.name} - - ) - )} -
- )} -
- ) -} - -function CheckStatusIcon({ check }: { check: ReviewCheckRun }) { - if (check.status !== "completed") { - return ( - - ) - } - if (check.conclusion === "success" || check.conclusion === "neutral") { - return - } - if (check.conclusion === "skipped") { - return - } - return -} - -function PeopleSection({ - title, - people, -}: { - title: string - people: Array -}) { - return ( -
-

{title}

- {people.length === 0 ? ( -

None

- ) : ( -
- {people.map((person) => ( -
- {person.avatar_url ? ( - - ) : ( - - )} - {person.login} -
- ))} -
- )} -
- ) -}