From 63e4c2baaccd553144f9f516f667b8c42e738d86 Mon Sep 17 00:00:00 2001 From: Caroline di Vittorio <43390382+carolinedivittorio@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:27:51 -0700 Subject: [PATCH] feat: relocate review chunks and embed review in git panel (#1590) * feat: relocate review chunks and embed review in git panel Move the AI-sorted review chunk navigator out of the global left sidebar and into the review page's own main panel (sidebar keeps the thread list), and surface the review main body inside the agent thread's Git > Review sub-tab with an expand link to the full review page. The review main body + side panel + helpers are extracted into a shared ReviewMainBody component with "full" and "embedded" variants. Co-authored-by: open-swe[bot] * chore: move ReviewTab into its own file Co-authored-by: open-swe[bot] --------- Co-authored-by: open-swe[bot] --- ui/src/components/agents/AgentGitPanel.tsx | 26 +- ui/src/components/agents/AgentsSidebar.tsx | 100 +- ui/src/components/agents/ReviewMainBody.tsx | 1944 +++++++++++++++++ ui/src/components/agents/ReviewSidebar.tsx | 47 +- ui/src/components/agents/ReviewTab.tsx | 72 + .../agents/reviews/$owner.$repo.$number.tsx | 1906 +--------------- 6 files changed, 2087 insertions(+), 2008 deletions(-) create mode 100644 ui/src/components/agents/ReviewMainBody.tsx create mode 100644 ui/src/components/agents/ReviewTab.tsx 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} -
- ))} -
- )} -
- ) -}