feat: render Reviews page diffs with pierre MultiFileDiff (#1517)

* feat: render Reviews page diffs with pierre MultiFileDiff

The Reviews detail page used a hand-rolled hunk renderer with no syntax
highlighting. Switch it to the same pierre MultiFileDiff + theming the agent
chat git panel uses.

- review-diff API now returns full original/modified file contents instead of
  hunks, via a shared build_pr_diff_files helper extracted from thread_api
- findings render as right-anchored markers (pierre line annotations); focus
  highlight uses selectedLines; floating finding card still anchors to the marker

* feat: auto-collapse a review diff card when marked as viewed

* fix: URL-encode file path in Contents API fetch

Filenames containing reserved URL characters (#, ?) were truncated, so those
files rendered as empty/unrenderable. quote(path, safe='/') preserves the path
separators while escaping the rest.

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-12 11:03:51 -07:00 • committed by GitHub
parent 7dac89a3b4
commit 09d5d00e59
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 250 additions and 427 deletions

124
agent/dashboard/pr_diff.py Normal file
View file

@ -0,0 +1,124 @@
"""Shared builder for full-content PR diffs.
Fetches a PR's changed files and their full original/modified contents so the
UI can render syntax-highlighted diffs with pierre's ``MultiFileDiff``. Used by
both the thread PR diff endpoint (user token) and the review diff endpoint
(App installation token).
"""
from __future__ import annotations
import asyncio
from typing import Any
from urllib.parse import quote
import httpx
from fastapi import HTTPException
_GITHUB_API = "https://api.github.com"
PR_DIFF_MAX_FILES = 50
PR_DIFF_MAX_FILE_BYTES = 200_000
PR_DIFF_FETCH_CONCURRENCY = 5
async def _fetch_file_at_ref(
client: httpx.AsyncClient,
semaphore: asyncio.Semaphore,
full_name: str,
path: str,
ref: str,
) -> str | None:
async with semaphore:
response = await client.get(
f"{_GITHUB_API}/repos/{full_name}/contents/{quote(path, safe='/')}",
params={"ref": ref},
headers={"Accept": "application/vnd.github.raw+json"},
)
if response.status_code == 404:
return ""
if response.status_code != 200:
return None
if len(response.content) > PR_DIFF_MAX_FILE_BYTES:
return None
try:
return response.content.decode("utf-8")
except UnicodeDecodeError:
return None
async def build_pr_diff_files(
client: httpx.AsyncClient,
full_name: str,
pr_number: int,
) -> dict[str, Any]:
"""Return ``{base_sha, head_sha, truncated, files}`` for a PR.
Each file carries full ``originalContent``/``modifiedContent`` (or ``None``
for binary/oversized blobs, flagged via ``unrenderable``). ``client`` must
already be configured with auth headers.
"""
pull_response = await client.get(f"{_GITHUB_API}/repos/{full_name}/pulls/{pr_number}")
if pull_response.status_code == 404:
raise HTTPException(404, "pull request not found")
if pull_response.status_code != 200:
raise HTTPException(502, f"github API error ({pull_response.status_code})")
pull = pull_response.json()
base_sha = pull.get("base", {}).get("sha")
head_sha = pull.get("head", {}).get("sha")
if not isinstance(base_sha, str) or not isinstance(head_sha, str):
raise HTTPException(502, "github API returned an unexpected pull request payload")
files_response = await client.get(
f"{_GITHUB_API}/repos/{full_name}/pulls/{pr_number}/files",
params={"per_page": 100},
)
if files_response.status_code != 200:
raise HTTPException(502, f"github API error ({files_response.status_code})")
raw_files = files_response.json()
if not isinstance(raw_files, list):
raise HTTPException(502, "github API returned an unexpected files payload")
truncated = len(raw_files) > PR_DIFF_MAX_FILES
raw_files = raw_files[:PR_DIFF_MAX_FILES]
semaphore = asyncio.Semaphore(PR_DIFF_FETCH_CONCURRENCY)
async def build_entry(raw: dict[str, Any]) -> dict[str, Any] | None:
path = raw.get("filename")
if not isinstance(path, str):
return None
status = raw.get("status") if isinstance(raw.get("status"), str) else "modified"
previous = raw.get("previous_filename")
original_path = previous if isinstance(previous, str) else path
original: str | None = ""
modified: str | None = ""
if status != "added":
original = await _fetch_file_at_ref(
client, semaphore, full_name, original_path, base_sha
)
if status != "removed":
modified = await _fetch_file_at_ref(client, semaphore, full_name, path, head_sha)
return {
"path": path,
"previousPath": previous if isinstance(previous, str) else None,
"status": status,
"additions": raw.get("additions") if isinstance(raw.get("additions"), int) else 0,
"deletions": raw.get("deletions") if isinstance(raw.get("deletions"), int) else 0,
"originalContent": original,
"modifiedContent": modified,
# Binary or oversized blobs come back as None — the client renders a
# placeholder instead of file contents.
"unrenderable": original is None or modified is None,
}
entries = await asyncio.gather(*(build_entry(raw) for raw in raw_files))
return {
"base_sha": base_sha,
"head_sha": head_sha,
"truncated": truncated,
"files": [entry for entry in entries if entry is not None],
}

View file

@ -9,30 +9,23 @@ with the App installation token.
from __future__ import annotations from __future__ import annotations
import logging import logging
import re
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any, Literal from typing import Any, Literal
import httpx import httpx
from fastapi import HTTPException from fastapi import HTTPException
from ..reviewer_diff import parse_unified_diff
from ..reviewer_findings import REVIEWER_THREAD_KIND from ..reviewer_findings import REVIEWER_THREAD_KIND
from ..utils.github_app import get_github_app_installation_token from ..utils.github_app import get_github_app_installation_token
from ..utils.github_checks import github_headers from ..utils.github_checks import github_headers
from ..utils.thread_ops import langgraph_client from ..utils.thread_ops import langgraph_client
from .pr_diff import build_pr_diff_files
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_GITHUB_API = "https://api.github.com" _GITHUB_API = "https://api.github.com"
_GITHUB_TIMEOUT = httpx.Timeout(15.0, connect=5.0) _GITHUB_TIMEOUT = httpx.Timeout(15.0, connect=5.0)
DiffLineKind = Literal["context", "add", "del"]
_DIFF_NEW_FILE_RE = re.compile(r"^new file mode", re.MULTILINE)
_DIFF_DELETED_FILE_RE = re.compile(r"^deleted file mode", re.MULTILINE)
_DIFF_RENAME_RE = re.compile(r"^rename from ", re.MULTILINE)
async def _require_app_token() -> str: async def _require_app_token() -> str:
token = await get_github_app_installation_token() token = await get_github_app_installation_token()
@ -342,105 +335,21 @@ async def get_review(owner: str, repo: str, pr_number: int) -> dict[str, Any]:
return {**summary, "pr": details, "checks": checks, "findings": findings} return {**summary, "pr": details, "checks": checks, "findings": findings}
def parse_diff_files(diff_text: str) -> list[dict[str, Any]]:
"""Parse a unified diff into renderable per-file hunk/line records.
Iterates the raw ``diff --git`` sections so metadata-only changes
(pure renames, mode changes) still appear, with empty hunks.
"""
raw_sections = _split_file_sections(diff_text)
parsed_by_file = {file_diff.file: file_diff for file_diff in parse_unified_diff(diff_text)}
files: list[dict[str, Any]] = []
for path, section in raw_sections.items():
status = "modified"
if _DIFF_NEW_FILE_RE.search(section):
status = "added"
elif _DIFF_DELETED_FILE_RE.search(section):
status = "deleted"
elif _DIFF_RENAME_RE.search(section):
status = "renamed"
file_diff = parsed_by_file.get(path)
hunks = []
additions = 0
deletions = 0
for hunk in file_diff.hunks if file_diff else ():
lines: list[dict[str, Any]] = []
old_line = hunk.old_start
new_line = hunk.new_start
body_lines = hunk.body.splitlines()
for raw in body_lines[1:]:
if raw.startswith("+"):
lines.append({"kind": "add", "new_line": new_line, "text": raw[1:]})
new_line += 1
additions += 1
elif raw.startswith("-"):
lines.append({"kind": "del", "old_line": old_line, "text": raw[1:]})
old_line += 1
deletions += 1
elif raw.startswith("\\"):
continue
else:
lines.append(
{
"kind": "context",
"old_line": old_line,
"new_line": new_line,
"text": raw[1:] if raw.startswith(" ") else raw,
}
)
old_line += 1
new_line += 1
hunks.append(
{
"header": body_lines[0] if body_lines else "",
"old_start": hunk.old_start,
"new_start": hunk.new_start,
"lines": lines,
}
)
files.append(
{
"path": path,
"status": status,
"additions": additions,
"deletions": deletions,
"hunks": hunks,
}
)
return files
def _split_file_sections(diff_text: str) -> dict[str, str]:
sections: dict[str, str] = {}
current_file: str | None = None
current_lines: list[str] = []
header_re = re.compile(r"^diff --git a/(?P<a>.+?) b/(?P<b>.+?)$")
for line in diff_text.splitlines():
match = header_re.match(line)
if match:
if current_file is not None:
sections[current_file] = "\n".join(current_lines)
current_file = match.group("b")
current_lines = [line]
elif current_file is not None:
current_lines.append(line)
if current_file is not None:
sections[current_file] = "\n".join(current_lines)
return sections
async def get_review_diff(owner: str, repo: str, pr_number: int) -> dict[str, Any]: async def get_review_diff(owner: str, repo: str, pr_number: int) -> dict[str, Any]:
"""Return the PR's changed files with full original/modified contents.
Uses the App installation token so the diff is available regardless of who
is viewing the review. The client renders these with pierre's MultiFileDiff.
"""
token = await _require_app_token() token = await _require_app_token()
diff_text = await _github_get( async with httpx.AsyncClient(headers=github_headers(token), timeout=_GITHUB_TIMEOUT) as client:
f"/repos/{owner}/{repo}/pulls/{pr_number}", diff = await build_pr_diff_files(client, f"{owner}/{repo}", pr_number)
token, files = diff["files"]
accept="application/vnd.github.diff",
)
files = parse_diff_files(diff_text if isinstance(diff_text, str) else "")
return { return {
"files": files, "files": files,
"total_additions": sum(f["additions"] for f in files), "total_additions": sum(f["additions"] for f in files),
"total_deletions": sum(f["deletions"] for f in files), "total_deletions": sum(f["deletions"] for f in files),
"truncated": diff["truncated"],
} }

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import base64 import base64
import binascii import binascii
import json import json
@ -26,6 +25,7 @@ from ..utils.thread_ops import (
) )
from .agent_overrides import normalize_profile_overrides from .agent_overrides import normalize_profile_overrides
from .options import SUPPORTED_MODEL_IDS, model_supports_effort, model_supports_images from .options import SUPPORTED_MODEL_IDS, model_supports_effort, model_supports_images
from .pr_diff import build_pr_diff_files
from .profiles import get_profile, get_valid_access_token from .profiles import get_profile, get_valid_access_token
from .team_settings import get_team_default_model from .team_settings import get_team_default_model
from .user_mappings import email_for_login from .user_mappings import email_for_login
@ -901,12 +901,6 @@ async def get_dashboard_thread_state(
return result return result
_PR_DIFF_MAX_FILES = 50
_PR_DIFF_MAX_FILE_BYTES = 200_000
_PR_DIFF_FETCH_CONCURRENCY = 5
_GITHUB_API = "https://api.github.com"
# No app-installation-token fallback: PR file contents must be fetched with # No app-installation-token fallback: PR file contents must be fetched with
# the user's own credential so GitHub enforces their current repo access. # the user's own credential so GitHub enforces their current repo access.
async def _github_token_for_login(login: str) -> str: async def _github_token_for_login(login: str) -> str:
@ -916,31 +910,6 @@ async def _github_token_for_login(login: str) -> str:
return token return token
async def _fetch_file_at_ref(
client: httpx.AsyncClient,
semaphore: asyncio.Semaphore,
full_name: str,
path: str,
ref: str,
) -> str | None:
async with semaphore:
response = await client.get(
f"{_GITHUB_API}/repos/{full_name}/contents/{path}",
params={"ref": ref},
headers={"Accept": "application/vnd.github.raw+json"},
)
if response.status_code == 404:
return ""
if response.status_code != 200:
return None
if len(response.content) > _PR_DIFF_MAX_FILE_BYTES:
return None
try:
return response.content.decode("utf-8")
except UnicodeDecodeError:
return None
async def get_dashboard_thread_pr_diff( async def get_dashboard_thread_pr_diff(
thread_id: str, login: str, *, email: str | None = None thread_id: str, login: str, *, email: str | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -957,70 +926,14 @@ async def get_dashboard_thread_pr_diff(
"X-GitHub-Api-Version": "2022-11-28", "X-GitHub-Api-Version": "2022-11-28",
} }
async with httpx.AsyncClient(headers=headers, timeout=_PROXY_REQUEST_TIMEOUT) as client: async with httpx.AsyncClient(headers=headers, timeout=_PROXY_REQUEST_TIMEOUT) as client:
pull_response = await client.get(f"{_GITHUB_API}/repos/{full_name}/pulls/{pr_number}") diff = await build_pr_diff_files(client, full_name, pr_number)
if pull_response.status_code == 404:
raise HTTPException(404, "pull request not found")
if pull_response.status_code != 200:
raise HTTPException(502, f"github API error ({pull_response.status_code})")
pull = pull_response.json()
base_sha = pull.get("base", {}).get("sha")
head_sha = pull.get("head", {}).get("sha")
if not isinstance(base_sha, str) or not isinstance(head_sha, str):
raise HTTPException(502, "github API returned an unexpected pull request payload")
files_response = await client.get(
f"{_GITHUB_API}/repos/{full_name}/pulls/{pr_number}/files",
params={"per_page": 100},
)
if files_response.status_code != 200:
raise HTTPException(502, f"github API error ({files_response.status_code})")
raw_files = files_response.json()
if not isinstance(raw_files, list):
raise HTTPException(502, "github API returned an unexpected files payload")
truncated = len(raw_files) > _PR_DIFF_MAX_FILES
raw_files = raw_files[:_PR_DIFF_MAX_FILES]
semaphore = asyncio.Semaphore(_PR_DIFF_FETCH_CONCURRENCY)
async def build_entry(raw: dict[str, Any]) -> dict[str, Any] | None:
path = raw.get("filename")
if not isinstance(path, str):
return None
status = raw.get("status") if isinstance(raw.get("status"), str) else "modified"
previous = raw.get("previous_filename")
original_path = previous if isinstance(previous, str) else path
original: str | None = ""
modified: str | None = ""
if status != "added":
original = await _fetch_file_at_ref(
client, semaphore, full_name, original_path, base_sha
)
if status != "removed":
modified = await _fetch_file_at_ref(client, semaphore, full_name, path, head_sha)
return {
"path": path,
"previousPath": previous if isinstance(previous, str) else None,
"status": status,
"additions": raw.get("additions") if isinstance(raw.get("additions"), int) else 0,
"deletions": raw.get("deletions") if isinstance(raw.get("deletions"), int) else 0,
"originalContent": original,
"modifiedContent": modified,
# Binary or oversized blobs come back as None — the client
# renders a placeholder instead of file contents.
"unrenderable": original is None or modified is None,
}
entries = await asyncio.gather(*(build_entry(raw) for raw in raw_files))
return { return {
"prNumber": pr_number, "prNumber": pr_number,
"baseSha": base_sha, "baseSha": diff["base_sha"],
"headSha": head_sha, "headSha": diff["head_sha"],
"truncated": truncated, "truncated": diff["truncated"],
"files": [entry for entry in entries if entry is not None], "files": diff["files"],
} }

View file

@ -3,62 +3,10 @@ from agent.dashboard.review_api import (
_serialize_finding, _serialize_finding,
_thread_review_summary, _thread_review_summary,
classify_finding, classify_finding,
parse_diff_files,
reviewer_thread_id, reviewer_thread_id,
) )
from agent.webapp import generate_reviewer_thread_id from agent.webapp import generate_reviewer_thread_id
DIFF = """\
diff --git a/src/app.py b/src/app.py
index 111..222 100644
--- a/src/app.py
+++ b/src/app.py
@@ -1,4 +1,5 @@
import os
-x = 1
+x = 2
+y = 3
print(x)
diff --git a/new.txt b/new.txt
new file mode 100644
index 000..333
--- /dev/null
+++ b/new.txt
@@ -0,0 +1,2 @@
+hello
+world
diff --git a/gone.txt b/gone.txt
deleted file mode 100644
index 444..000
--- a/gone.txt
+++ /dev/null
@@ -1,1 +0,0 @@
-bye
"""
def test_parse_diff_files_statuses_and_counts():
files = parse_diff_files(DIFF)
by_path = {f["path"]: f for f in files}
assert by_path["src/app.py"]["status"] == "modified"
assert by_path["src/app.py"]["additions"] == 2
assert by_path["src/app.py"]["deletions"] == 1
assert by_path["new.txt"]["status"] == "added"
assert by_path["new.txt"]["additions"] == 2
assert by_path["gone.txt"]["status"] == "deleted"
assert by_path["gone.txt"]["deletions"] == 1
def test_parse_diff_files_line_numbers():
files = parse_diff_files(DIFF)
app = next(f for f in files if f["path"] == "src/app.py")
lines = app["hunks"][0]["lines"]
assert lines[0] == {"kind": "context", "old_line": 1, "new_line": 1, "text": "import os"}
assert lines[1] == {"kind": "del", "old_line": 2, "text": "x = 1"}
assert lines[2] == {"kind": "add", "new_line": 2, "text": "x = 2"}
assert lines[3] == {"kind": "add", "new_line": 3, "text": "y = 3"}
assert lines[4] == {"kind": "context", "old_line": 3, "new_line": 4, "text": "print(x)"}
def test_classify_finding(): def test_classify_finding():
assert classify_finding({"severity": "critical", "confidence": "high"}) == "bug" assert classify_finding({"severity": "critical", "confidence": "high"}) == "bug"

View file

@ -5,11 +5,18 @@ import {
useFileTreeSelection, useFileTreeSelection,
} from "@pierre/trees/react" } from "@pierre/trees/react"
import type { GitStatusEntry } from "@pierre/trees" import type { GitStatus, GitStatusEntry } from "@pierre/trees"
import type { ReviewDiffFile } from "@/lib/api" import type { ReviewDiffFile } from "@/lib/api"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { treeThemeStyle } from "@/components/agents/AgentGitPanel" import { treeThemeStyle } from "@/components/agents/AgentGitPanel"
function reviewFileGitStatus(status: ReviewDiffFile["status"]): GitStatus {
if (status === "removed") return "deleted"
if (status === "added") return "added"
if (status === "renamed") return "renamed"
return "modified"
}
export interface ReviewSidebarData { export interface ReviewSidebarData {
title: string title: string
files: Array<ReviewDiffFile> | null files: Array<ReviewDiffFile> | null
@ -82,7 +89,11 @@ function ReviewFileTreeExplorer({
}) { }) {
const paths = useMemo(() => files.map((file) => file.path), [files]) const paths = useMemo(() => files.map((file) => file.path), [files])
const gitStatus = useMemo<Array<GitStatusEntry>>( const gitStatus = useMemo<Array<GitStatusEntry>>(
() => files.map((file) => ({ path: file.path, status: file.status })), () =>
files.map((file) => ({
path: file.path,
status: reviewFileGitStatus(file.status),
})),
[files] [files]
) )

View file

@ -361,32 +361,22 @@ export interface ReviewDetail extends ReviewSummary {
findings: Array<ReviewFinding>; findings: Array<ReviewFinding>;
} }
export interface ReviewDiffLine {
kind: "context" | "add" | "del";
old_line?: number;
new_line?: number;
text: string;
}
export interface ReviewDiffHunk {
header: string;
old_start: number;
new_start: number;
lines: Array<ReviewDiffLine>;
}
export interface ReviewDiffFile { export interface ReviewDiffFile {
path: string; path: string;
status: "added" | "deleted" | "modified" | "renamed"; previousPath: string | null;
status: "added" | "removed" | "modified" | "renamed";
additions: number; additions: number;
deletions: number; deletions: number;
hunks: Array<ReviewDiffHunk>; originalContent: string;
modifiedContent: string;
unrenderable?: boolean;
} }
export interface ReviewDiffPayload { export interface ReviewDiffPayload {
files: Array<ReviewDiffFile>; files: Array<ReviewDiffFile>;
total_additions: number; total_additions: number;
total_deletions: number; total_deletions: number;
truncated: boolean;
} }
export const api = { export const api = {

View file

@ -24,17 +24,22 @@ import {
XIcon, XIcon,
} from "@phosphor-icons/react" } from "@phosphor-icons/react"
import { IoLogoGithub } from "react-icons/io5" import { IoLogoGithub } from "react-icons/io5"
import { MultiFileDiff } from "@pierre/diffs/react"
import type { DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs"
import type { import type {
ReviewCheckRun, ReviewCheckRun,
ReviewDetail, ReviewDetail,
ReviewDiffFile, ReviewDiffFile,
ReviewDiffLine,
ReviewFinding, ReviewFinding,
ReviewUserRef, ReviewUserRef,
} from "@/lib/api" } from "@/lib/api"
import { Markdown } from "@/components/agents/ported" import { Markdown } from "@/components/agents/ported"
import { useRegisterReviewSidebar } from "@/components/agents/ReviewSidebar" import { useRegisterReviewSidebar } from "@/components/agents/ReviewSidebar"
import {
useDiffOptions,
warmDiffHighlighter,
} from "@/components/agents/utils/diffUtils"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { useSession } from "@/lib/session" import { useSession } from "@/lib/session"
@ -72,46 +77,19 @@ function isAnchored(finding: ReviewFinding): boolean {
return Boolean(finding.file) && finding.in_diff && finding.end_line !== null return Boolean(finding.file) && finding.in_diff && finding.end_line !== null
} }
type HighlightEdge = { top: boolean; bottom: boolean } | null function findingSide(finding: ReviewFinding): "deletions" | "additions" {
return finding.side === "LEFT" ? "deletions" : "additions"
}
function highlightRange( function findingSelectedRange(finding: ReviewFinding): SelectedLineRange | null {
lines: Array<ReviewDiffLine>, if (finding.end_line === null) return null
finding: ReviewFinding const side = findingSide(finding)
): { start: number; end: number } | null { return {
let start = -1 start: finding.start_line ?? finding.end_line,
let end = -1 end: finding.end_line,
for (let index = 0; index < lines.length; index++) { side,
const line = lines[index] endSide: side,
if (line && lineMatchesFinding(line, finding)) {
if (start === -1) start = index
end = index
}
} }
return start === -1 ? null : { start, end }
}
function lineMatchesFinding(
line: ReviewDiffLine,
finding: ReviewFinding
): boolean {
if (finding.end_line === null) return false
const start = finding.start_line ?? finding.end_line
const lineNumber = finding.side === "LEFT" ? line.old_line : line.new_line
if (lineNumber === undefined) return false
if (finding.side === "LEFT" && line.kind !== "del") return false
if (finding.side === "RIGHT" && line.kind === "del") return false
return lineNumber >= start && lineNumber <= finding.end_line
}
function isFindingAnchorRow(
line: ReviewDiffLine,
finding: ReviewFinding
): boolean {
if (finding.end_line === null) return false
const lineNumber = finding.side === "LEFT" ? line.old_line : line.new_line
if (finding.side === "LEFT" && line.kind !== "del") return false
if (finding.side === "RIGHT" && line.kind === "del") return false
return lineNumber === finding.end_line
} }
function findingClipboardText(finding: ReviewFinding): string { function findingClipboardText(finding: ReviewFinding): string {
@ -215,14 +193,18 @@ function ReviewBody({
const [sideTab, setSideTab] = useState<SideTab>("info") const [sideTab, setSideTab] = useState<SideTab>("info")
const [selectedFile, setSelectedFile] = useState<string | null>(null) const [selectedFile, setSelectedFile] = useState<string | null>(null)
const fileRefs = useRef<Record<string, HTMLDivElement | null>>({}) const fileRefs = useRef<Record<string, HTMLDivElement | null>>({})
const anchorRefs = useRef<Record<string, HTMLDivElement | null>>({}) const anchorRefs = useRef<Record<string, HTMLElement | null>>({})
const [expandedFiles, setExpandedFiles] = useState<Record<string, boolean>>( const [expandedFiles, setExpandedFiles] = useState<Record<string, boolean>>(
{} {}
) )
const [focused, setFocused] = useState<ReviewFinding | null>(null) const [focused, setFocused] = useState<ReviewFinding | null>(null)
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>(null) const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null) const scrollRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
void warmDiffHighlighter()
}, [])
const viewedStorageKey = `open-swe.review.viewed.${detail.owner}/${detail.repo}/${detail.number}.${detail.head_sha}` const viewedStorageKey = `open-swe.review.viewed.${detail.owner}/${detail.repo}/${detail.number}.${detail.head_sha}`
const [viewed, setViewed] = useState<Set<string>>(() => { const [viewed, setViewed] = useState<Set<string>>(() => {
if (typeof window === "undefined") return new Set() if (typeof window === "undefined") return new Set()
@ -405,12 +387,14 @@ function ReviewBody({
focused={focused} focused={focused}
viewed={viewed.has(file.path)} viewed={viewed.has(file.path)}
onToggleViewed={() => { onToggleViewed={() => {
const collapses = const becomingViewed = !viewed.has(file.path)
!viewed.has(file.path) && if (becomingViewed && focused?.file === file.path)
expandedFiles[file.path] === undefined
if (collapses && focused?.file === file.path)
setFocused(null) setFocused(null)
toggleViewed(file.path) toggleViewed(file.path)
setExpandedFiles((prev) => ({
...prev,
[file.path]: !becomingViewed,
}))
}} }}
expanded={ expanded={
expandedFiles[file.path] ?? !viewed.has(file.path) expandedFiles[file.path] ?? !viewed.has(file.path)
@ -549,17 +533,33 @@ function FileDiffCard({
onToggleExpanded: () => void onToggleExpanded: () => void
onFindingClick: (finding: ReviewFinding) => void onFindingClick: (finding: ReviewFinding) => void
sectionRef: (node: HTMLDivElement | null) => void sectionRef: (node: HTMLDivElement | null) => void
anchorRef: (id: string, node: HTMLDivElement | null) => void anchorRef: (id: string, node: HTMLElement | null) => void
}) { }) {
const fileFocused = const diffOptions = useDiffOptions()
focused?.file === file.path && isAnchored(focused) ? focused : null
const lineAnnotations = useMemo<Array<DiffLineAnnotation<ReviewFinding>>>(
() =>
findings
.filter((finding) => finding.end_line !== null)
.map((finding) => ({
side: findingSide(finding),
lineNumber: finding.end_line as number,
metadata: finding,
})),
[findings]
)
const selectedLines =
focused?.file === file.path && isAnchored(focused)
? findingSelectedRange(focused)
: null
return ( return (
<div <div
ref={sectionRef} ref={sectionRef}
className="scroll-mt-4 overflow-hidden rounded-lg border border-border" className="scroll-mt-4 overflow-hidden rounded-lg border border-[var(--ui-border)]"
> >
<div className="flex items-center gap-2 bg-muted/40 px-3 py-2 text-xs"> <div className="flex items-center gap-2 bg-[var(--ui-panel-2)] px-3 py-2 text-xs">
<button <button
type="button" type="button"
onClick={onToggleExpanded} onClick={onToggleExpanded}
@ -599,131 +599,59 @@ function FileDiffCard({
</button> </button>
</label> </label>
</div> </div>
{expanded && ( {expanded &&
<div className="overflow-x-auto bg-card font-mono text-[11px] leading-5"> (file.unrenderable ? (
{file.hunks.map((hunk, hunkIndex) => { <div className="bg-[var(--ui-panel)] p-4 text-center text-xs text-[var(--ui-text-dim)]">
const range = Binary or large file — diff not shown.
fileFocused !== null </div>
? highlightRange(hunk.lines, fileFocused) ) : (
: null <div className="overflow-x-auto bg-[var(--ui-panel)] font-mono text-[11px] leading-5">
const anchorRows = new Map<number, Array<ReviewFinding>>() <MultiFileDiff<ReviewFinding>
for (const finding of findings) { oldFile={{ name: file.path, contents: file.originalContent }}
const index = hunk.lines.findIndex((hunkLine) => newFile={{ name: file.path, contents: file.modifiedContent }}
lineMatchesFinding(hunkLine, finding) options={diffOptions}
) lineAnnotations={lineAnnotations}
if (index !== -1) { selectedLines={selectedLines}
anchorRows.set(index, [ renderAnnotation={(annotation) => (
...(anchorRows.get(index) ?? []), <FindingRailMarker
finding, finding={annotation.metadata}
]) onFindingClick={onFindingClick}
} anchorRef={anchorRef}
} />
return ( )}
<div key={hunkIndex}> />
<div className="bg-muted/60 px-3 py-1 text-muted-foreground"> </div>
{hunk.header} ))}
</div>
{hunk.lines.map((line, lineIndex) => {
const lineFindings = findings.filter((finding) =>
isFindingAnchorRow(line, finding)
)
const anchorFindings = anchorRows.get(lineIndex) ?? []
let highlight: HighlightEdge = null
if (
range &&
lineIndex >= range.start &&
lineIndex <= range.end
) {
highlight = {
top: lineIndex === range.start,
bottom: lineIndex === range.end,
}
}
return (
<DiffLineRow
key={lineIndex}
line={line}
findings={lineFindings}
anchorFindings={anchorFindings}
highlight={highlight}
onFindingClick={onFindingClick}
anchorRef={anchorRef}
/>
)
})}
</div>
)
})}
</div>
)}
</div> </div>
) )
} }
function DiffLineRow({ function FindingRailMarker({
line, finding,
findings,
anchorFindings,
highlight,
onFindingClick, onFindingClick,
anchorRef, anchorRef,
}: { }: {
line: ReviewDiffLine finding: ReviewFinding
findings: Array<ReviewFinding>
anchorFindings: Array<ReviewFinding>
highlight: HighlightEdge
onFindingClick: (finding: ReviewFinding) => void onFindingClick: (finding: ReviewFinding) => void
anchorRef: (id: string, node: HTMLDivElement | null) => void anchorRef: (id: string, node: HTMLElement | null) => void
}) { }) {
const first = findings[0] const style = GROUP_STYLES[finding.group]
const Icon = style.Icon
return ( return (
<div <div className="flex justify-end px-2 py-0.5">
ref={ <button
anchorFindings.length > 0 ref={(node) => anchorRef(finding.id, node)}
? (node) => { type="button"
for (const finding of anchorFindings) anchorRef(finding.id, node) onClick={() => onFindingClick(finding)}
} aria-label={`Open finding: ${finding.title}`}
: undefined
}
className={cn(
"flex",
line.kind === "add" && "bg-emerald-500/10",
line.kind === "del" && "bg-red-500/10",
highlight && "border-x border-sky-400/50 bg-sky-400/10",
highlight?.top && "rounded-t-sm border-t",
highlight?.bottom && "rounded-b-sm border-b"
)}
>
<span className="w-10 shrink-0 px-1 text-right text-muted-foreground/60 select-none">
{line.old_line ?? ""}
</span>
<span className="w-10 shrink-0 px-1 text-right text-muted-foreground/60 select-none">
{line.new_line ?? ""}
</span>
<span
className={cn( className={cn(
"w-4 shrink-0 text-center select-none", "inline-flex items-center gap-1 rounded border border-[var(--ui-border)] bg-[var(--ui-surface)] px-1.5 py-0.5 text-[10px]",
line.kind === "add" && "text-emerald-500", style.className
line.kind === "del" && "text-red-500"
)} )}
> >
{line.kind === "add" ? "+" : line.kind === "del" ? "-" : ""} <span className="font-sans">{finding.title}</span>
</span> <Icon className="size-3 shrink-0" />
<span className="pr-3 whitespace-pre">{line.text}</span> </button>
{first && (
<button
type="button"
onClick={() => onFindingClick(first)}
aria-label={`Open finding: ${first.title}`}
className="mr-2 ml-auto shrink-0 self-center"
>
{(() => {
const style = GROUP_STYLES[first.group]
const Icon = style.Icon
return <Icon className={cn("size-3.5", style.className)} />
})()}
</button>
)}
</div> </div>
) )
} }
@ -746,7 +674,7 @@ function AnchoredFindingCard({
}: { }: {
detail: ReviewDetail detail: ReviewDetail
finding: ReviewFinding finding: ReviewFinding
anchorEl: HTMLDivElement anchorEl: HTMLElement
scrollRef: React.RefObject<HTMLDivElement | null> scrollRef: React.RefObject<HTMLDivElement | null>
onClose: () => void onClose: () => void
}) { }) {