@@ -546,17 +536,15 @@ export function AgentsShell({
}) {
const layout = useSidebarLayout()
return (
-
-
-
-
-
+
+
+
)
}
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.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}
+
+ )}
+
+
+ )
+}
+
+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() {
) : (
-