feat: PR review page (#1495)

* feat: PR review page

* feat: manual review trigger card on admin page

* feat: finding focus mode with floating card and dimmed backdrop

* fix: dim only info panel, subtle block highlight for focused finding

* refactor: move PR reviews into agents page with file-tree sidebar

* fix: address review feedback

- paginate list_reviews until enough accessible records collected
- include rename/metadata-only files in diff API with empty hunks
- remount ReviewBody on head_sha change so viewed-files state resets

* fix: review tree background + finding card anchoring

* fix: remove no-op PR size chip and dead check links

* fix: parallel diff fetch, complete reReview type, shared github_headers

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-11 16:11:10 -07:00 • committed by GitHub
parent 194e9cd60e
commit bfcb714c5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 2506 additions and 156 deletions

View file

@ -0,0 +1,448 @@
"""Read API for the PR review UI.
Reviewer threads (``metadata.kind == "reviewer"``) hold the durable review
state for a PR: identity (``pr``), findings, watch flag, and head SHA. These
endpoints surface that state plus live PR details/diff fetched from GitHub
with the App installation token.
"""
from __future__ import annotations
import logging
import re
from collections.abc import Awaitable, Callable
from typing import Any, Literal
import httpx
from fastapi import HTTPException
from ..reviewer_diff import parse_unified_diff
from ..reviewer_findings import REVIEWER_THREAD_KIND
from ..utils.github_app import get_github_app_installation_token
from ..utils.github_checks import github_headers
from ..utils.thread_ops import langgraph_client
logger = logging.getLogger(__name__)
_GITHUB_API = "https://api.github.com"
_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:
token = await get_github_app_installation_token()
if not token:
raise HTTPException(503, "GitHub App token unavailable")
return token
async def _github_get(
path: str, token: str, *, accept: str | None = None, params: dict[str, Any] | None = None
) -> Any:
headers = github_headers(token)
if accept:
headers["Accept"] = accept
async with httpx.AsyncClient(timeout=_GITHUB_TIMEOUT) as client:
response = await client.get(f"{_GITHUB_API}{path}", headers=headers, params=params)
if response.status_code == 404:
raise HTTPException(404, "not found on GitHub")
if response.status_code >= 400:
logger.warning("GitHub GET %s failed: %s", path, response.status_code)
raise HTTPException(502, f"GitHub request failed ({response.status_code})")
if accept and "json" not in accept:
return response.text
return response.json()
def reviewer_thread_id(owner: str, repo: str, pr_number: int) -> str:
import uuid
stable_key = f"{owner}/{repo}/pr/{pr_number}/reviewer"
return str(uuid.uuid5(uuid.NAMESPACE_URL, stable_key))
def _findings_list(metadata: dict[str, Any]) -> list[dict[str, Any]]:
findings = metadata.get("findings")
if not isinstance(findings, list):
return []
return [f for f in findings if isinstance(f, dict) and isinstance(f.get("id"), str)]
def _serialize_finding(finding: dict[str, Any], head_sha: str | None) -> dict[str, Any]:
last_confirmed = finding.get("last_confirmed_sha")
outdated = bool(
head_sha
and isinstance(last_confirmed, str)
and last_confirmed
and last_confirmed != head_sha
)
interactions = finding.get("interactions")
return {
"id": finding.get("id"),
"severity": finding.get("severity", "low"),
"confidence": finding.get("confidence", "medium"),
"category": finding.get("category", ""),
"title": finding.get("title") or "",
"description": finding.get("description", ""),
"suggestion": finding.get("suggestion"),
"file": finding.get("file", ""),
"start_line": finding.get("start_line"),
"end_line": finding.get("end_line"),
"side": finding.get("side", "RIGHT"),
"in_diff": bool(finding.get("in_diff", True)),
"status": finding.get("status", "open"),
"outdated": outdated,
"resolution_note": finding.get("resolution_note"),
"diff_hunk": finding.get("diff_hunk"),
"github_thread_resolved": bool(finding.get("github_thread_resolved")),
"github_review_comment_id": (
finding["github_review_comment_id"]
if isinstance(finding.get("github_review_comment_id"), int)
else None
),
"interactions": interactions if isinstance(interactions, list) else [],
}
_BUG_SEVERITIES = frozenset({"high", "critical"})
def classify_finding(finding: dict[str, Any]) -> Literal["bug", "investigate", "informational"]:
"""Map our severity/confidence model onto the UI's Bugs/Flags split."""
severity = finding.get("severity", "low")
confidence = finding.get("confidence", "medium")
if severity in _BUG_SEVERITIES and confidence == "high":
return "bug"
if severity != "low":
return "investigate"
return "informational"
def _finding_counts(findings: list[dict[str, Any]]) -> dict[str, int]:
counts = {"open": 0, "resolved": 0, "dismissed": 0, "bugs": 0, "flags": 0}
for finding in findings:
status = finding.get("status", "open")
if status in counts:
counts[status] += 1
if status == "open":
if classify_finding(finding) == "bug":
counts["bugs"] += 1
else:
counts["flags"] += 1
return counts
def _run_status(thread: dict[str, Any], metadata: dict[str, Any]) -> str:
if thread.get("status") == "busy":
return "running"
latest = metadata.get("latest_run_status")
if latest in {"pending", "running"}:
return "running"
if latest in {"error", "failed", "timeout", "interrupted"}:
return "error"
return "idle"
def _thread_review_summary(thread: dict[str, Any]) -> dict[str, Any] | None:
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
pr = metadata.get("pr")
if not isinstance(pr, dict):
return None
owner = pr.get("owner")
name = pr.get("name")
number = pr.get("number")
if not (isinstance(owner, str) and isinstance(name, str) and isinstance(number, int)):
return None
findings = _findings_list(metadata)
updated_at = thread.get("updated_at")
return {
"thread_id": thread.get("thread_id"),
"owner": owner,
"repo": name,
"full_name": f"{owner}/{name}",
"number": number,
"title": pr.get("title") or f"PR #{number}",
"url": pr.get("url") or f"https://github.com/{owner}/{name}/pull/{number}",
"head_ref": pr.get("head_ref") or "",
"base_ref": pr.get("base_ref") or "",
"head_sha": metadata.get("head_sha") or "",
"watch": bool(metadata.get("watch")),
"status": _run_status(thread, metadata),
"counts": _finding_counts(findings),
"updated_at": updated_at if isinstance(updated_at, str) else None,
}
async def list_reviews(
limit: int = 100,
*,
is_accessible: Callable[[dict[str, Any]], Awaitable[bool]] | None = None,
page_size: int = 100,
max_scan: int = 1000,
) -> list[dict[str, Any]]:
"""List review summaries, newest first.
When ``is_accessible`` is given, keeps paging through reviewer threads
until ``limit`` accessible summaries are collected (or ``max_scan``
threads have been examined), so inaccessible records don't crowd
accessible ones out of a single fixed-size page.
"""
client = langgraph_client()
summaries: list[dict[str, Any]] = []
offset = 0
while len(summaries) < limit and offset < max_scan:
threads = await client.threads.search(
metadata={"kind": REVIEWER_THREAD_KIND},
limit=page_size,
offset=offset,
sort_by="updated_at",
sort_order="desc",
)
if not threads:
break
for thread in threads:
if not isinstance(thread, dict):
continue
summary = _thread_review_summary(thread)
if not summary:
continue
if is_accessible is not None and not await is_accessible(summary):
continue
summaries.append(summary)
if len(summaries) >= limit:
break
if len(threads) < page_size:
break
offset += page_size
return summaries
def _user_ref(value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
login = value.get("login")
if not isinstance(login, str):
return None
return {"login": login, "avatar_url": value.get("avatar_url")}
def _serialize_pr_details(payload: dict[str, Any]) -> dict[str, Any]:
labels = payload.get("labels")
state = payload.get("state")
if payload.get("merged"):
state = "merged"
elif payload.get("draft"):
state = "draft"
return {
"state": state if isinstance(state, str) else "open",
"title": payload.get("title") or "",
"body": payload.get("body") or "",
"additions": payload.get("additions") or 0,
"deletions": payload.get("deletions") or 0,
"changed_files": payload.get("changed_files") or 0,
"commits": payload.get("commits") or 0,
"head_sha": (payload.get("head") or {}).get("sha") or "",
"head_ref": (payload.get("head") or {}).get("ref") or "",
"base_ref": (payload.get("base") or {}).get("ref") or "",
"author": _user_ref(payload.get("user")),
"assignees": [
user
for user in (_user_ref(value) for value in payload.get("assignees") or [])
if user is not None
],
"requested_reviewers": [
user
for user in (_user_ref(value) for value in payload.get("requested_reviewers") or [])
if user is not None
],
"labels": [
{"name": label.get("name"), "color": label.get("color")}
for label in (labels if isinstance(labels, list) else [])
if isinstance(label, dict) and isinstance(label.get("name"), str)
],
}
async def _fetch_check_runs(owner: str, repo: str, sha: str, token: str) -> list[dict[str, Any]]:
if not sha:
return []
try:
payload = await _github_get(
f"/repos/{owner}/{repo}/commits/{sha}/check-runs",
token,
params={"per_page": 50},
)
except HTTPException:
return []
runs = payload.get("check_runs") if isinstance(payload, dict) else None
out: list[dict[str, Any]] = []
for run in runs if isinstance(runs, list) else []:
if not isinstance(run, dict):
continue
out.append(
{
"name": run.get("name") or "",
"status": run.get("status") or "",
"conclusion": run.get("conclusion"),
"url": run.get("html_url"),
}
)
return out
async def get_review(owner: str, repo: str, pr_number: int) -> dict[str, Any]:
thread_id = reviewer_thread_id(owner, repo, pr_number)
client = langgraph_client()
try:
thread = await client.threads.get(thread_id)
except Exception as exc: # noqa: BLE001
raise HTTPException(404, "review not found") from exc
if not isinstance(thread, dict):
raise HTTPException(404, "review not found")
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
summary = _thread_review_summary(thread)
if not summary:
raise HTTPException(404, "review not found")
token = await _require_app_token()
pr_payload = await _github_get(f"/repos/{owner}/{repo}/pulls/{pr_number}", token)
details = _serialize_pr_details(pr_payload if isinstance(pr_payload, dict) else {})
head_sha = details["head_sha"] or summary["head_sha"]
checks = await _fetch_check_runs(owner, repo, head_sha, token)
findings = [_serialize_finding(finding, head_sha) for finding in _findings_list(metadata)]
findings.sort(
key=lambda f: (
f["status"] != "open",
{"bug": 0, "investigate": 1, "informational": 2}[classify_finding(f)],
f["file"],
f["start_line"] or 0,
)
)
for finding in findings:
finding["group"] = classify_finding(finding)
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]:
token = await _require_app_token()
diff_text = await _github_get(
f"/repos/{owner}/{repo}/pulls/{pr_number}",
token,
accept="application/vnd.github.diff",
)
files = parse_diff_files(diff_text if isinstance(diff_text, str) else "")
return {
"files": files,
"total_additions": sum(f["additions"] for f in files),
"total_deletions": sum(f["deletions"] for f in files),
}
async def trigger_re_review(owner: str, repo: str, pr_number: int, login: str) -> dict[str, Any]:
from ..utils.slack import GitHubPrRef
from ..webapp import trigger_pr_review_from_ref
pr_ref = GitHubPrRef(
owner=owner,
repo=repo,
number=pr_number,
url=f"https://github.com/{owner}/{repo}/pull/{pr_number}",
)
result = await trigger_pr_review_from_ref(pr_ref, source="dashboard", github_login=login)
if not result.get("success"):
raise HTTPException(502, str(result.get("error") or "could not trigger review"))
return result

View file

@ -58,6 +58,12 @@ from .profiles import (
upsert_profile,
)
from .repo_access import require_repo_access_for_user
from .review_api import (
get_review,
get_review_diff,
list_reviews,
trigger_re_review,
)
from .review_style_jobs import (
cancel_review_style_analysis,
start_bootstrap_analysis,
@ -703,6 +709,61 @@ async def api_list_review_styles(
return out
@router.get("/reviews")
async def api_list_reviews(
session: dict[str, Any] = _SESSION_DEP,
) -> list[dict[str, Any]]:
login = session["sub"]
access_cache: dict[str, bool] = {}
async def is_accessible(summary: dict[str, Any]) -> bool:
full_name = summary["full_name"]
if full_name not in access_cache:
try:
await require_repo_access_for_user(login, full_name)
access_cache[full_name] = True
except HTTPException as exc:
if exc.status_code not in {403, 404}:
raise
access_cache[full_name] = False
return access_cache[full_name]
return await list_reviews(is_accessible=is_accessible)
@router.get("/reviews/{owner}/{repo}/{pr_number}")
async def api_get_review(
owner: str,
repo: str,
pr_number: int,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
return await get_review(owner, repo, pr_number)
@router.get("/reviews/{owner}/{repo}/{pr_number}/diff")
async def api_get_review_diff(
owner: str,
repo: str,
pr_number: int,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
return await get_review_diff(owner, repo, pr_number)
@router.post("/reviews/{owner}/{repo}/{pr_number}/re-review")
async def api_re_review(
owner: str,
repo: str,
pr_number: int,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
return await trigger_re_review(owner, repo, pr_number, session["sub"])
@router.post("/review-styles")
async def api_create_review_style(
body: ReviewStyleCreate,

View file

@ -47,7 +47,7 @@ class ReviewStyleSamples:
reviews_scanned: int = 0
def _github_headers(token: str) -> dict[str, str]:
def github_headers(token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
@ -157,7 +157,7 @@ async def collect_review_samples(
) -> ReviewStyleSamples:
"""Sample recent merged PR feedback to identify reviewer style."""
full_name = f"{owner}/{repo}"
headers = _github_headers(token)
headers = github_headers(token)
raw_entries: list[tuple[str, int, ReviewSample]] = []
reviewer_counts: Counter[str] = Counter()

View file

@ -26,7 +26,7 @@ _GITHUB_API_BASE = "https://api.github.com"
CheckConclusion = Literal["success", "neutral", "failure"]
def _github_headers(token: str) -> dict[str, str]:
def github_headers(token: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
@ -67,7 +67,7 @@ async def create_review_check_run(
try:
async with httpx.AsyncClient() as client:
response = await client.post(
url, headers=_github_headers(token), json=payload, timeout=30
url, headers=github_headers(token), json=payload, timeout=30
)
response.raise_for_status()
except httpx.HTTPError:
@ -105,7 +105,7 @@ async def complete_review_check_run(
try:
async with httpx.AsyncClient() as client:
response = await client.patch(
url, headers=_github_headers(token), json=payload, timeout=30
url, headers=github_headers(token), json=payload, timeout=30
)
response.raise_for_status()
except httpx.HTTPError:

125
tests/test_review_api.py Normal file
View file

@ -0,0 +1,125 @@
from agent.dashboard.review_api import (
_finding_counts,
_serialize_finding,
_thread_review_summary,
classify_finding,
parse_diff_files,
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():
assert classify_finding({"severity": "critical", "confidence": "high"}) == "bug"
assert classify_finding({"severity": "high", "confidence": "high"}) == "bug"
assert classify_finding({"severity": "high", "confidence": "medium"}) == "investigate"
assert classify_finding({"severity": "medium", "confidence": "high"}) == "investigate"
assert classify_finding({"severity": "low", "confidence": "high"}) == "informational"
def test_finding_counts_only_open_in_groups():
findings = [
{"id": "f_1", "severity": "high", "confidence": "high", "status": "open"},
{"id": "f_2", "severity": "medium", "confidence": "high", "status": "open"},
{"id": "f_3", "severity": "high", "confidence": "high", "status": "resolved"},
{"id": "f_4", "severity": "low", "confidence": "low", "status": "dismissed"},
]
counts = _finding_counts(findings)
assert counts == {"open": 2, "resolved": 1, "dismissed": 1, "bugs": 1, "flags": 1}
def test_serialize_finding_outdated():
finding = {"id": "f_1", "last_confirmed_sha": "aaa"}
assert _serialize_finding(finding, "bbb")["outdated"] is True
assert _serialize_finding(finding, "aaa")["outdated"] is False
assert _serialize_finding(finding, None)["outdated"] is False
assert _serialize_finding({"id": "f_2"}, "bbb")["outdated"] is False
def test_thread_review_summary():
thread = {
"thread_id": "t1",
"status": "idle",
"updated_at": "2026-06-10T00:00:00Z",
"metadata": {
"kind": "reviewer",
"pr": {
"owner": "acme",
"name": "repo",
"number": 7,
"title": "Fix things",
"head_ref": "fix",
"base_ref": "main",
},
"head_sha": "abc",
"watch": True,
"latest_run_status": "success",
"findings": [{"id": "f_1", "severity": "high", "confidence": "high", "status": "open"}],
},
}
summary = _thread_review_summary(thread)
assert summary is not None
assert summary["owner"] == "acme"
assert summary["number"] == 7
assert summary["status"] == "idle"
assert summary["watch"] is True
assert summary["counts"]["bugs"] == 1
def test_thread_review_summary_requires_pr_meta():
assert _thread_review_summary({"metadata": {"kind": "reviewer"}}) is None
def test_reviewer_thread_id_matches_webapp():
assert reviewer_thread_id("acme", "repo", 7) == generate_reviewer_thread_id("acme", "repo", 7)

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="158" height="28" fill="none" viewBox="0 0 158 28">
<rect width="158" height="28" fill="#0d1117" rx="6"/>
<rect width="157" height="27" x=".5" y=".5" stroke="#f0f6fc" stroke-opacity=".14" rx="5.5"/>
<g transform="translate(8 6) scale(0.125)" fill="#fff"><path d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/><path d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/><path d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/><path d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/></g>
<text x="30" y="18.5" fill="#f0f6fc" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" font-size="12" font-weight="500">Open in Open SWE</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="158" height="28" fill="none" viewBox="0 0 158 28">
<rect width="158" height="28" fill="#fff" rx="6"/>
<rect width="157" height="27" x=".5" y=".5" stroke="#171c24" stroke-opacity=".14" rx="5.5"/>
<g transform="translate(8 6) scale(0.125)" fill="#151b23"><path d="M40.1024 85.0722C47.6207 77.5537 51.8469 67.3453 51.8469 56.7136C51.8469 46.0818 47.617 35.8734 40.1024 28.355L11.7446 0C4.22995 7.5185 0 17.7269 0 28.3586C0 38.9903 4.22995 49.1987 11.7446 56.7172L40.0987 85.0722H40.1024Z"/><path d="M99.4385 87.698C91.9239 80.1832 81.7121 75.9531 71.0844 75.9531C60.4566 75.9531 50.2448 80.1832 42.7266 87.698L71.0844 116.057C78.599 123.571 88.8107 127.802 99.4421 127.802C110.074 127.802 120.282 123.571 127.8 116.057L99.4421 87.698H99.4385Z"/><path d="M11.8146 115.987C19.3329 123.502 29.541 127.732 40.1724 127.732V87.6289H0.0664062C0.0700559 98.2606 4.29635 108.469 11.8146 115.987Z"/><path d="M110.387 45.7684C102.869 38.2535 92.6608 34.0198 82.0258 34.0234C71.3943 34.0234 61.1863 38.2535 53.668 45.772L82.0258 74.1306L110.387 45.7684Z"/></g>
<text x="30" y="18.5" fill="#151b23" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" font-size="12" font-weight="500">Open in Open SWE</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -1,4 +1,4 @@
import { Link } from "@tanstack/react-router";
import { Link } from "@tanstack/react-router"
import {
IoArrowBackOutline,
IoCloudOutline,
@ -6,25 +6,25 @@ import {
IoOptionsOutline,
IoSettingsOutline,
IoStatsChartOutline,
} from "react-icons/io5";
import type { ComponentType, SVGProps } from "react";
} from "react-icons/io5"
import type { ComponentType, SVGProps } from "react"
import type { SessionUser } from "@/lib/api";
import { SidebarUserMenu } from "@/components/SidebarUserMenu";
import type { SessionUser } from "@/lib/api"
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
import {
SidebarCollapseButton,
SidebarFrame,
useSidebarLayout,
} from "@/components/sidebar-layout";
import { cn } from "@/lib/utils";
} from "@/components/sidebar-layout"
import { cn } from "@/lib/utils"
type IconType = ComponentType<SVGProps<SVGSVGElement>>;
type IconType = ComponentType<SVGProps<SVGSVGElement>>
interface NavItem {
to: string;
label: string;
icon: IconType;
adminOnly?: boolean;
to: string
label: string
icon: IconType
adminOnly?: boolean
}
const NAV: Array<NavItem> = [
@ -33,12 +33,15 @@ const NAV: Array<NavItem> = [
{ to: "/review", label: "Open SWE Review", icon: IoGitPullRequestOutline },
{ to: "/usage", label: "Usage", icon: IoStatsChartOutline },
{ to: "/admin", label: "Admin", icon: IoSettingsOutline, adminOnly: true },
];
]
export function AppSidebar({ user }: { user: SessionUser }) {
const layout = useSidebarLayout();
const layout = useSidebarLayout()
return (
<SidebarFrame {...layout} className="border-r border-border bg-sidebar text-sidebar-foreground">
<SidebarFrame
{...layout}
className="border-r border-border bg-sidebar text-sidebar-foreground"
>
<div className="flex items-center justify-between px-4 pt-5 pb-4">
<Link
to="/my-settings"
@ -56,14 +59,14 @@ export function AppSidebar({ user }: { user: SessionUser }) {
onClick={layout.closeOnMobile}
className={cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs/relaxed text-muted-foreground transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
>
<IoArrowBackOutline className="size-4" />
<span>Back to Agents</span>
</Link>
{NAV.filter((n) => !n.adminOnly || user.is_admin).map((item) => {
const Icon = item.icon;
const Icon = item.icon
return (
<Link
key={item.to}
@ -71,16 +74,17 @@ export function AppSidebar({ user }: { user: SessionUser }) {
onClick={layout.closeOnMobile}
className={cn(
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs/relaxed text-muted-foreground transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
)}
activeProps={{
className: "bg-sidebar-accent text-sidebar-accent-foreground font-medium",
className:
"bg-sidebar-accent text-sidebar-accent-foreground font-medium",
}}
>
<Icon className="size-4" />
<span>{item.label}</span>
</Link>
);
)
})}
</nav>
@ -88,5 +92,5 @@ export function AppSidebar({ user }: { user: SessionUser }) {
<SidebarUserMenu user={user} />
</div>
</SidebarFrame>
);
)
}

View file

@ -141,7 +141,7 @@ function PanelResizeHandle({
)
}
function treeThemeStyle(): React.CSSProperties {
export function treeThemeStyle(): React.CSSProperties {
return {
"--trees-theme-sidebar-bg": "var(--ui-surface)",
"--trees-theme-sidebar-fg": "var(--ui-text)",

View file

@ -24,6 +24,11 @@ import type { ComponentType, SVGProps } from "react"
import type { SessionUser } from "@/lib/api"
import type { AgentSource, AgentThread } from "@/lib/agents/types"
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
import {
ReviewFileTree,
ReviewSidebarProvider,
useReviewSidebarData,
} from "@/components/agents/ReviewSidebar"
import { Button } from "@/components/ui/button"
import {
SidebarCollapseButton,
@ -84,6 +89,7 @@ interface AgentsSidebarProps {
const NAV = [
{ to: "/agents/automations", label: "Automations", icon: LightningIcon },
{ to: "/my-settings", label: "Dashboard", icon: ChartLineUpIcon },
{ to: "/agents/reviews", label: "Reviews", icon: GitPullRequestIcon },
] as const
export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
@ -92,6 +98,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
useSeedAgentThreadDetails(threads, activeThreadId)
const groups = groupThreads(threads)
const layout = useSidebarLayout()
const reviewSidebar = useReviewSidebarData()
return (
<SidebarFrame
@ -141,34 +148,38 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
})}
</nav>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
<ThreadGroup
label="Today"
threads={groups.today}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
/>
<ThreadGroup
label="Last 7 days"
threads={groups.last7}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
defaultCollapsed
/>
<ThreadGroup
label="Last 30 days"
threads={groups.last30}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
defaultCollapsed
/>
<ThreadGroup
label="Older"
threads={groups.older}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
/>
</div>
{reviewSidebar ? (
<ReviewFileTree data={reviewSidebar} />
) : (
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
<ThreadGroup
label="Today"
threads={groups.today}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
/>
<ThreadGroup
label="Last 7 days"
threads={groups.last7}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
defaultCollapsed
/>
<ThreadGroup
label="Last 30 days"
threads={groups.last30}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
defaultCollapsed
/>
<ThreadGroup
label="Older"
threads={groups.older}
activeThreadId={activeThreadId}
onNavigate={layout.closeOnMobile}
/>
</div>
)}
<div className="p-2">
<SidebarUserMenu user={user} showSettingsLink />
@ -410,9 +421,11 @@ export function AgentsShell({
children: React.ReactNode
}) {
return (
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
<AgentsSidebar user={user} activeThreadId={activeThreadId} />
<div className="flex min-w-0 flex-1">{children}</div>
</div>
<ReviewSidebarProvider>
<div className="agents-ui flex h-svh overflow-hidden bg-[var(--ui-bg)]">
<AgentsSidebar user={user} activeThreadId={activeThreadId} />
<div className="flex min-w-0 flex-1">{children}</div>
</div>
</ReviewSidebarProvider>
)
}

View file

@ -0,0 +1,131 @@
import { createContext, useContext, useEffect, useMemo, useState } from "react"
import {
FileTree,
useFileTree,
useFileTreeSelection,
} from "@pierre/trees/react"
import type { GitStatusEntry } from "@pierre/trees"
import type { ReviewDiffFile } from "@/lib/api"
import { Skeleton } from "@/components/ui/skeleton"
import { treeThemeStyle } from "@/components/agents/AgentGitPanel"
export interface ReviewSidebarData {
title: string
files: Array<ReviewDiffFile> | null
selected: string | null
viewed: Set<string>
onSelect: (path: string) => 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<ReviewSidebarData | null>(null)
const value = useMemo(() => ({ data, setData }), [data])
return (
<ReviewSidebarContext.Provider value={value}>
{children}
</ReviewSidebarContext.Provider>
)
}
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 ReviewFileTree({ data }: { data: ReviewSidebarData }) {
return (
<div className="flex min-h-0 flex-1 flex-col pb-2">
<div className="px-4 py-1 text-[10px] font-semibold tracking-wide text-[var(--ui-text-dim)] uppercase">
{data.title}
</div>
{!data.files ? (
<div className="px-4 pt-1">
<Skeleton className="h-40 w-full" />
</div>
) : (
<ReviewFileTreeExplorer
files={data.files}
selected={data.selected}
onSelect={data.onSelect}
/>
)}
</div>
)
}
function ReviewFileTreeExplorer({
files,
selected,
onSelect,
}: {
files: Array<ReviewDiffFile>
selected: string | null
onSelect: (path: string) => void
}) {
const paths = useMemo(() => files.map((file) => file.path), [files])
const gitStatus = useMemo<Array<GitStatusEntry>>(
() => files.map((file) => ({ path: file.path, status: file.status })),
[files]
)
const { model } = useFileTree({
paths,
gitStatus,
initialExpansion: "open",
flattenEmptyDirectories: true,
icons: "standard",
})
useEffect(() => {
model.resetPaths(paths)
}, [model, paths])
useEffect(() => {
model.setGitStatus(gitStatus)
}, [model, gitStatus])
const selection = useFileTreeSelection(model)
useEffect(() => {
const path = selection[0]
if (path) onSelect(path)
}, [selection, onSelect])
useEffect(() => {
if (selected) {
model.scrollToPath(selected, { focus: false })
}
}, [model, selected])
return (
<div className="min-h-0 flex-1">
<FileTree
model={model}
style={
{
height: "100%",
...treeThemeStyle(),
"--trees-theme-sidebar-bg": "transparent",
} as React.CSSProperties
}
/>
</div>
)
}

View file

@ -259,6 +259,129 @@ export interface AgentInstructions {
updated_at?: string;
}
export type FindingSeverity = "low" | "medium" | "high" | "critical";
export type FindingConfidence = "low" | "medium" | "high";
export type FindingStatus = "open" | "resolved" | "dismissed";
export type FindingGroup = "bug" | "investigate" | "informational";
export interface FindingInteraction {
kind: "human_reply" | "bot_reply";
author?: string;
body?: string;
created_at?: string;
}
export interface ReviewFinding {
id: string;
severity: FindingSeverity;
confidence: FindingConfidence;
category: string;
title: string;
description: string;
suggestion: string | null;
file: string;
start_line: number | null;
end_line: number | null;
side: "LEFT" | "RIGHT";
in_diff: boolean;
status: FindingStatus;
outdated: boolean;
resolution_note: string | null;
diff_hunk: string | null;
github_thread_resolved: boolean;
github_review_comment_id: number | null;
interactions: Array<FindingInteraction>;
group: FindingGroup;
}
export interface ReviewCounts {
open: number;
resolved: number;
dismissed: number;
bugs: number;
flags: number;
}
export interface ReviewSummary {
thread_id: string;
owner: string;
repo: string;
number: number;
title: string;
url: string;
head_ref: string;
base_ref: string;
head_sha: string;
watch: boolean;
status: "running" | "error" | "idle";
counts: ReviewCounts;
updated_at: string | null;
full_name?: string;
}
export interface ReviewUserRef {
login: string;
avatar_url?: string | null;
}
export interface ReviewCheckRun {
name: string;
status: string;
conclusion: string | null;
url: string | null;
}
export interface ReviewPrDetails {
state: string;
title: string;
body: string;
additions: number;
deletions: number;
changed_files: number;
commits: number;
head_sha: string;
head_ref: string;
base_ref: string;
author: ReviewUserRef | null;
assignees: Array<ReviewUserRef>;
requested_reviewers: Array<ReviewUserRef>;
labels: Array<{ name: string; color: string | null }>;
}
export interface ReviewDetail extends ReviewSummary {
pr: ReviewPrDetails;
checks: Array<ReviewCheckRun>;
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 {
path: string;
status: "added" | "deleted" | "modified" | "renamed";
additions: number;
deletions: number;
hunks: Array<ReviewDiffHunk>;
}
export interface ReviewDiffPayload {
files: Array<ReviewDiffFile>;
total_additions: number;
total_deletions: number;
}
export const api = {
me: () => request<SessionUser>("/me"),
options: () => request<OptionsPayload>("/options"),
@ -347,6 +470,25 @@ export const api = {
`/admin/user-mappings/${encodeURIComponent(github_login)}`,
{ method: "DELETE" },
),
listReviews: () => request<Array<ReviewSummary>>("/reviews"),
getReview: (owner: string, repo: string, number: number) =>
request<ReviewDetail>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}`,
),
getReviewDiff: (owner: string, repo: string, number: number) =>
request<ReviewDiffPayload>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/diff`,
),
reReview: (owner: string, repo: string, number: number) =>
request<{
success: boolean;
queued: boolean;
thread_id: string;
pr_url: string;
}>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/re-review`,
{ method: "POST" },
),
logout: () => request<void>("/auth/logout", { method: "POST" }),
};

View file

@ -22,10 +22,12 @@ import { Route as AgentsIndexRouteImport } from './routes/agents/index'
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
import { Route as AgentsInstructionsRouteImport } from './routes/agents_.instructions'
import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId'
import { Route as AgentsReviewsIndexRouteImport } from './routes/agents/reviews/index'
import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/automations/index'
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
import { Route as AgentsAutomationsNewRouteImport } from './routes/agents/automations/new'
import { Route as AgentsAutomationsScheduleIdRouteImport } from './routes/agents/automations/$scheduleId'
import { Route as AgentsReviewsOwnerRepoNumberRouteImport } from './routes/agents/reviews/$owner.$repo.$number'
const UsageRoute = UsageRouteImport.update({
id: '/usage',
@ -92,6 +94,11 @@ const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
path: '/$threadId',
getParentRoute: () => AgentsRoute,
} as any)
const AgentsReviewsIndexRoute = AgentsReviewsIndexRouteImport.update({
id: '/reviews/',
path: '/reviews/',
getParentRoute: () => AgentsRoute,
} as any)
const AgentsAutomationsIndexRoute = AgentsAutomationsIndexRouteImport.update({
id: '/automations/',
path: '/automations/',
@ -113,6 +120,12 @@ const AgentsAutomationsScheduleIdRoute =
path: '/automations/$scheduleId',
getParentRoute: () => AgentsRoute,
} as any)
const AgentsReviewsOwnerRepoNumberRoute =
AgentsReviewsOwnerRepoNumberRouteImport.update({
id: '/reviews/$owner/$repo/$number',
path: '/reviews/$owner/$repo/$number',
getParentRoute: () => AgentsRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@ -132,6 +145,8 @@ export interface FileRoutesByFullPath {
'/agents/automations/new': typeof AgentsAutomationsNewRoute
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
'/agents/automations/': typeof AgentsAutomationsIndexRoute
'/agents/reviews/': typeof AgentsReviewsIndexRoute
'/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@ -150,6 +165,8 @@ export interface FileRoutesByTo {
'/agents/automations/new': typeof AgentsAutomationsNewRoute
'/review/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
'/agents/automations': typeof AgentsAutomationsIndexRoute
'/agents/reviews': typeof AgentsReviewsIndexRoute
'/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@ -170,6 +187,8 @@ export interface FileRoutesById {
'/agents/automations/new': typeof AgentsAutomationsNewRoute
'/review_/repositories/$owner': typeof ReviewRepositoriesOwnerRoute
'/agents/automations/': typeof AgentsAutomationsIndexRoute
'/agents/reviews/': typeof AgentsReviewsIndexRoute
'/agents/reviews/$owner/$repo/$number': typeof AgentsReviewsOwnerRepoNumberRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -191,6 +210,8 @@ export interface FileRouteTypes {
| '/agents/automations/new'
| '/review/repositories/$owner'
| '/agents/automations/'
| '/agents/reviews/'
| '/agents/reviews/$owner/$repo/$number'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -209,6 +230,8 @@ export interface FileRouteTypes {
| '/agents/automations/new'
| '/review/repositories/$owner'
| '/agents/automations'
| '/agents/reviews'
| '/agents/reviews/$owner/$repo/$number'
id:
| '__root__'
| '/'
@ -228,6 +251,8 @@ export interface FileRouteTypes {
| '/agents/automations/new'
| '/review_/repositories/$owner'
| '/agents/automations/'
| '/agents/reviews/'
| '/agents/reviews/$owner/$repo/$number'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@ -338,6 +363,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentsThreadIdRouteImport
parentRoute: typeof AgentsRoute
}
'/agents/reviews/': {
id: '/agents/reviews/'
path: '/reviews'
fullPath: '/agents/reviews/'
preLoaderRoute: typeof AgentsReviewsIndexRouteImport
parentRoute: typeof AgentsRoute
}
'/agents/automations/': {
id: '/agents/automations/'
path: '/automations'
@ -366,6 +398,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentsAutomationsScheduleIdRouteImport
parentRoute: typeof AgentsRoute
}
'/agents/reviews/$owner/$repo/$number': {
id: '/agents/reviews/$owner/$repo/$number'
path: '/reviews/$owner/$repo/$number'
fullPath: '/agents/reviews/$owner/$repo/$number'
preLoaderRoute: typeof AgentsReviewsOwnerRepoNumberRouteImport
parentRoute: typeof AgentsRoute
}
}
}
@ -375,6 +414,8 @@ interface AgentsRouteChildren {
AgentsAutomationsScheduleIdRoute: typeof AgentsAutomationsScheduleIdRoute
AgentsAutomationsNewRoute: typeof AgentsAutomationsNewRoute
AgentsAutomationsIndexRoute: typeof AgentsAutomationsIndexRoute
AgentsReviewsIndexRoute: typeof AgentsReviewsIndexRoute
AgentsReviewsOwnerRepoNumberRoute: typeof AgentsReviewsOwnerRepoNumberRoute
}
const AgentsRouteChildren: AgentsRouteChildren = {
@ -383,6 +424,8 @@ const AgentsRouteChildren: AgentsRouteChildren = {
AgentsAutomationsScheduleIdRoute: AgentsAutomationsScheduleIdRoute,
AgentsAutomationsNewRoute: AgentsAutomationsNewRoute,
AgentsAutomationsIndexRoute: AgentsAutomationsIndexRoute,
AgentsReviewsIndexRoute: AgentsReviewsIndexRoute,
AgentsReviewsOwnerRepoNumberRoute: AgentsReviewsOwnerRepoNumberRoute,
}
const AgentsRouteWithChildren =

View file

@ -1,6 +1,6 @@
import { Navigate, createFileRoute } from "@tanstack/react-router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { Link, Navigate, createFileRoute } from "@tanstack/react-router"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useEffect, useMemo, useState } from "react"
import type {
DatadogConnectBody,
@ -8,41 +8,41 @@ import type {
ModelOption,
TeamSettings,
UserMapping,
} from "@/lib/api";
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
} from "@/lib/api"
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/lib/api";
import { useSession } from "@/lib/session";
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { api } from "@/lib/api"
import { useSession } from "@/lib/session"
export const Route = createFileRoute("/admin")({ component: AdminPage });
export const Route = createFileRoute("/admin")({ component: AdminPage })
function AdminPage() {
const session = useSession();
const session = useSession()
const options = useQuery({
queryKey: ["options"],
queryFn: api.options,
enabled: !!session.data?.is_admin,
});
})
if (session.isLoading) {
return (
<main className="p-6">
<Skeleton className="h-64 w-full" />
</main>
);
)
}
if (!session.data) return <Navigate to="/login" />;
if (!session.data.is_admin) return <Navigate to="/my-settings" />;
if (!session.data) return <Navigate to="/login" />
if (!session.data.is_admin) return <Navigate to="/my-settings" />
return (
<AppShell
@ -52,41 +52,129 @@ function AdminPage() {
>
<GlobalDefaultsSection models={options.data?.models ?? []} />
<TriggerReviewSection />
<ObservabilityCredentialsSection />
<UserMappingsSection enabled={!!session.data.is_admin} />
</AppShell>
);
)
}
const PAGE_SIZE = 20;
const PR_URL_RE = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/
function TriggerReviewSection() {
const [url, setUrl] = useState("")
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const parsed = useMemo(() => {
const match = PR_URL_RE.exec(url.trim())
if (!match) return null
const [, owner, repo, number] = match
if (!owner || !repo || !number) return null
return { owner, repo, number: Number(number) }
}, [url])
const trigger = useMutation({
mutationFn: () => {
if (!parsed) throw new Error("invalid PR URL")
return api.reReview(parsed.owner, parsed.repo, parsed.number)
},
onSuccess: (result) => {
setError(null)
setMessage(
result.queued
? "Review queued — a run is already in progress on this PR."
: "Review started."
)
},
onError: (e: Error) => {
setMessage(null)
setError(e.message)
},
})
return (
<SettingsSection
title="Trigger a review"
description="Manually start an Open SWE Review run on a pull request. The repository must be enabled for review."
>
<div className="flex flex-col gap-2 p-4">
<div className="flex items-center gap-2">
<Input
className="flex-1"
placeholder="https://github.com/owner/repo/pull/123"
value={url}
onChange={(e) => {
setUrl(e.target.value)
setMessage(null)
setError(null)
}}
/>
<Button
size="sm"
onClick={() => trigger.mutate()}
disabled={!parsed || trigger.isPending}
>
{trigger.isPending ? "Starting…" : "Start review"}
</Button>
</div>
{url.trim() && !parsed && (
<p className="text-xs text-muted-foreground">
Enter a full PR URL like https://github.com/owner/repo/pull/123
</p>
)}
{message && parsed && (
<p className="text-xs text-muted-foreground">
{message}{" "}
<Link
to="/agents/reviews/$owner/$repo/$number"
params={{
owner: parsed.owner,
repo: parsed.repo,
number: String(parsed.number),
}}
className="underline hover:text-foreground"
>
View review
</Link>
</p>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
</SettingsSection>
)
}
const PAGE_SIZE = 20
function UserMappingsSection({ enabled }: { enabled: boolean }) {
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const mappings = useQuery({
queryKey: ["adminUserMappings", page],
queryFn: () => api.adminListUserMappings(page, PAGE_SIZE),
enabled,
});
})
const total = mappings.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
const total = mappings.data?.total ?? 0
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE))
useEffect(() => {
if (!mappings.isFetching && page > pageCount) {
setPage(pageCount);
setPage(pageCount)
}
}, [mappings.isFetching, page, pageCount]);
}, [mappings.isFetching, page, pageCount])
const remove = useMutation({
mutationFn: (gh: string) => api.adminDeleteUserMapping(gh),
onSuccess: () => void mappings.refetch(),
onError: (e: Error) => setError(e.message),
});
})
const items = mappings.data?.items ?? [];
const items = mappings.data?.items ?? []
return (
<SettingsSection
@ -131,7 +219,8 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
{total > PAGE_SIZE && (
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span>
{total} mapping{total === 1 ? "" : "s"} · page {page} of {pageCount}
{total} mapping{total === 1 ? "" : "s"} · page {page} of{" "}
{pageCount}
</span>
<div className="flex items-center gap-2">
<Button
@ -155,60 +244,62 @@ function UserMappingsSection({ enabled }: { enabled: boolean }) {
)}
</div>
</SettingsSection>
);
)
}
function ObservabilityCredentialsSection() {
const qc = useQueryClient();
const qc = useQueryClient()
const creds = useQuery({
queryKey: ["teamCredentials"],
queryFn: api.getTeamCredentials,
});
const [error, setError] = useState<string | null>(null);
})
const [error, setError] = useState<string | null>(null)
const [ddSite, setDdSite] = useState("datadoghq.com");
const [ddApiKey, setDdApiKey] = useState("");
const [ddAppKey, setDdAppKey] = useState("");
const [lsApiKey, setLsApiKey] = useState("");
const [lsEndpoint, setLsEndpoint] = useState("");
const [ddSite, setDdSite] = useState("datadoghq.com")
const [ddApiKey, setDdApiKey] = useState("")
const [ddAppKey, setDdAppKey] = useState("")
const [lsApiKey, setLsApiKey] = useState("")
const [lsEndpoint, setLsEndpoint] = useState("")
const onError = (e: Error) => setError(e.message);
const onSuccess = (saved: Awaited<ReturnType<typeof api.getTeamCredentials>>) => {
qc.setQueryData(["teamCredentials"], saved);
setError(null);
};
const onError = (e: Error) => setError(e.message)
const onSuccess = (
saved: Awaited<ReturnType<typeof api.getTeamCredentials>>
) => {
qc.setQueryData(["teamCredentials"], saved)
setError(null)
}
const connectDd = useMutation({
mutationFn: (body: DatadogConnectBody) => api.connectDatadog(body),
onSuccess: (saved) => {
onSuccess(saved);
setDdApiKey("");
setDdAppKey("");
onSuccess(saved)
setDdApiKey("")
setDdAppKey("")
},
onError,
});
})
const disconnectDd = useMutation({
mutationFn: () => api.disconnectDatadog(),
onSuccess,
onError,
});
})
const connectLs = useMutation({
mutationFn: (body: LangSmithConnectBody) => api.connectLangSmith(body),
onSuccess: (saved) => {
onSuccess(saved);
setLsApiKey("");
onSuccess(saved)
setLsApiKey("")
},
onError,
});
})
const disconnectLs = useMutation({
mutationFn: () => api.disconnectLangSmith(),
onSuccess,
onError,
});
})
const datadog = creds.data?.datadog;
const langsmith = creds.data?.langsmith;
const busy = creds.isLoading;
const datadog = creds.data?.datadog
const langsmith = creds.data?.langsmith
const busy = creds.isLoading
return (
<SettingsSection
@ -333,30 +424,30 @@ function ObservabilityCredentialsSection() {
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
);
)
}
function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
const qc = useQueryClient();
const qc = useQueryClient()
const settings = useQuery({
queryKey: ["teamSettings"],
queryFn: api.getTeamSettings,
});
const [error, setError] = useState<string | null>(null);
const [defaultRepoDraft, setDefaultRepoDraft] = useState("");
})
const [error, setError] = useState<string | null>(null)
const [defaultRepoDraft, setDefaultRepoDraft] = useState("")
useEffect(() => {
setDefaultRepoDraft(settings.data?.default_repo ?? "");
}, [settings.data?.default_repo]);
setDefaultRepoDraft(settings.data?.default_repo ?? "")
}, [settings.data?.default_repo])
const save = useMutation({
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
onSuccess: (saved) => {
qc.setQueryData(["teamSettings"], saved);
setError(null);
qc.setQueryData(["teamSettings"], saved)
setError(null)
},
onError: (e: Error) => setError(e.message),
});
})
return (
<SettingsSection
@ -385,7 +476,9 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
description="Model used by delegated main-agent tasks."
models={models}
model={settings.data?.default_agent_subagent_model ?? null}
effort={settings.data?.default_agent_subagent_reasoning_effort ?? null}
effort={
settings.data?.default_agent_subagent_reasoning_effort ?? null
}
onChange={(model, effort) =>
settings.data &&
save.mutate({
@ -437,7 +530,9 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
description="Model used by delegated reviewer tasks."
models={models}
model={settings.data?.default_reviewer_subagent_model ?? null}
effort={settings.data?.default_reviewer_subagent_reasoning_effort ?? null}
effort={
settings.data?.default_reviewer_subagent_reasoning_effort ?? null
}
onChange={(model, effort) =>
settings.data &&
save.mutate({
@ -451,17 +546,17 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
);
)
}
interface RolePickerProps {
label: string;
description: string;
models: Array<ModelOption>;
model: string | null;
effort: string | null;
onChange: (model: string, effort: string) => void;
disabled: boolean;
label: string
description: string
models: Array<ModelOption>
model: string | null
effort: string | null
onChange: (model: string, effort: string) => void
disabled: boolean
}
function RolePicker({
@ -473,34 +568,34 @@ function RolePicker({
onChange,
disabled,
}: RolePickerProps) {
const [localModel, setLocalModel] = useState<string>(model ?? "");
const [localEffort, setLocalEffort] = useState<string>(effort ?? "");
const [localModel, setLocalModel] = useState<string>(model ?? "")
const [localEffort, setLocalEffort] = useState<string>(effort ?? "")
useEffect(() => {
setLocalModel(model ?? "");
setLocalEffort(effort ?? "");
}, [model, effort]);
setLocalModel(model ?? "")
setLocalEffort(effort ?? "")
}, [model, effort])
const selectedModel = models.find((m) => m.id === localModel);
const availableEfforts = selectedModel?.efforts ?? [];
const selectedModel = models.find((m) => m.id === localModel)
const availableEfforts = selectedModel?.efforts ?? []
const handleModelChange = (value: string | null) => {
if (!value) return;
const nextModel = models.find((m) => m.id === value);
if (!nextModel) return;
if (!value) return
const nextModel = models.find((m) => m.id === value)
if (!nextModel) return
const nextEffort = nextModel.efforts.includes(localEffort)
? localEffort
: nextModel.default_effort;
setLocalModel(value);
setLocalEffort(nextEffort);
onChange(value, nextEffort);
};
: nextModel.default_effort
setLocalModel(value)
setLocalEffort(nextEffort)
onChange(value, nextEffort)
}
const handleEffortChange = (value: string | null) => {
if (!value || !localModel) return;
setLocalEffort(value);
onChange(localModel, value);
};
if (!value || !localModel) return
setLocalEffort(value)
onChange(localModel, value)
}
return (
<SettingsRow
@ -508,7 +603,11 @@ function RolePicker({
description={description}
control={
<div className="flex items-center gap-2">
<Select value={localModel} onValueChange={handleModelChange} disabled={disabled}>
<Select
value={localModel}
onValueChange={handleModelChange}
disabled={disabled}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
@ -539,5 +638,5 @@ function RolePicker({
</div>
}
/>
);
)
}

View file

@ -25,7 +25,10 @@ function AgentsLayout() {
})
const [, section, threadId] = pathname.split("/")
const activeThreadId =
section === "agents" && threadId && threadId !== "automations"
section === "agents" &&
threadId &&
threadId !== "automations" &&
threadId !== "reviews"
? threadId
: undefined

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,125 @@
import { Link, createFileRoute } from "@tanstack/react-router"
import { useQuery } from "@tanstack/react-query"
import {
BugBeetleIcon,
FlagIcon,
GitPullRequestIcon,
} from "@phosphor-icons/react"
import type { ReviewSummary } from "@/lib/api"
import { Skeleton } from "@/components/ui/skeleton"
import { api } from "@/lib/api"
import { useSession } from "@/lib/session"
import { cn } from "@/lib/utils"
export const Route = createFileRoute("/agents/reviews/")({
component: ReviewsPage,
})
function statusBadge(review: ReviewSummary) {
if (review.status === "running") {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-[var(--ui-text-muted)]">
<span className="size-1.5 animate-pulse rounded-full bg-amber-500" />
Reviewing
</span>
)
}
if (review.status === "error") {
return <span className="text-xs text-[var(--ui-danger)]">Failed</span>
}
return null
}
function ReviewsPage() {
const session = useSession()
const reviews = useQuery({
queryKey: ["reviews"],
queryFn: api.listReviews,
enabled: !!session.data,
refetchInterval: (query) =>
query.state.data?.some((r) => r.status === "running") ? 5000 : false,
})
return (
<main className="min-w-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl px-6 py-8">
<h1 className="font-heading text-lg font-medium text-[var(--ui-text)]">
PR Reviews
</h1>
<p className="mt-1 text-xs text-[var(--ui-text-muted)]">
Pull requests reviewed by Open SWE Review. Click into one for the full
analysis.
</p>
<div className="mt-6 overflow-hidden rounded-lg border border-[var(--ui-border)] bg-[var(--ui-panel)]">
{reviews.isLoading && (
<div className="p-4">
<Skeleton className="h-24 w-full" />
</div>
)}
{reviews.error && (
<p className="px-4 py-3 text-xs text-[var(--ui-danger)]">
{reviews.error.message}
</p>
)}
{reviews.data && reviews.data.length === 0 && (
<p className="px-4 py-3 text-xs text-[var(--ui-text-muted)]">
No reviews yet. Enable repositories under Open SWE Review settings
and open a PR.
</p>
)}
<div className="divide-y divide-[var(--ui-border)]">
{(reviews.data ?? []).map((review) => (
<Link
key={review.thread_id}
to="/agents/reviews/$owner/$repo/$number"
params={{
owner: review.owner,
repo: review.repo,
number: String(review.number),
}}
className="flex items-center justify-between gap-4 px-4 py-3 transition-colors hover:bg-[var(--ui-sidebar-hover)]"
>
<div className="flex min-w-0 items-center gap-3">
<GitPullRequestIcon className="size-4 shrink-0 text-[var(--ui-text-muted)]" />
<div className="min-w-0">
<div className="truncate text-xs font-medium text-[var(--ui-text)]">
{review.title}
</div>
<div className="mt-0.5 text-xs text-[var(--ui-text-muted)]">
{review.owner}/{review.repo}#{review.number}
{review.head_ref && (
<span className="ml-2 font-mono text-[11px]">
{review.head_ref}
</span>
)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-3 text-xs">
{statusBadge(review)}
<span
className={cn(
"inline-flex items-center gap-1",
review.counts.bugs > 0
? "text-[var(--ui-danger)]"
: "text-[var(--ui-text-muted)]"
)}
>
<BugBeetleIcon className="size-3.5" />
{review.counts.bugs}
</span>
<span className="inline-flex items-center gap-1 text-[var(--ui-text-muted)]">
<FlagIcon className="size-3.5" />
{review.counts.flags}
</span>
</div>
</Link>
))}
</div>
</div>
</div>
</main>
)
}