From 9370a8c7f4cc7f2d7c55ab93ed4d6fef099718da Mon Sep 17 00:00:00 2001 From: Johannes du Plessis Date: Tue, 23 Jun 2026 16:01:46 -0700 Subject: [PATCH] feat: inline PR comments in the reviews UI (#1600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: inline PR comments in the reviews UI Click the diff gutter "+" on a line to open an inline comment composer (rendered like the finding card via a Pierre annotation); submitting posts a real inline PR review comment as the signed-in user through a new POST /reviews/{owner}/{repo}/{number}/comments. The "+" press-drag → "Add to Chat" selection path is unchanged. * feat: GitHub-parity comment box, PR comments dropdown, collapse nav - Comment composer now mirrors GitHub's box: Write/Preview tabs (markdown rendered via the existing Markdown component) and a markdown toolbar (heading, bold, italic, quote, code, link, bulleted/numbered/task list). - Surface other people's inline PR comments in a Devin-style dropdown in the review header (search + link to the thread on GitHub). New GET /reviews/{owner}/{repo}/{number}/comments lists them and flags the reviewer's own (marker-bearing) comments so they're filtered out. - Collapse the global nav by default on a review detail page, restoring the prior preference on leave. * feat: bigger comment-toolbar icons; open dropdown comments inline - Enlarge the markdown toolbar glyphs (Phosphor) in the comment composer — they were rendering at 10px. - Clicking a comment in the PR comments dropdown now opens it inline in the diff as a read-only finding-style card (InlineComment), scrolling its line into view, instead of navigating to GitHub. Falls back to GitHub when the comment's file/line isn't in the current diff. * fix: drive "Add to Chat" from native text selection The gutter "+" is now comment-only; wiring its click to the composer conflicted with its old double-duty as the drag-to-select handle, which broke selection → "Add to Chat". Switch to Devin's model: disable Pierre's interactive line selection and instead map a native text highlight in the diff to a line range (via the data-line / data-line-type attributes Pierre stamps on each line, read from the diff's open shadow root) to show the "Add to Chat" popup. ⌘L and the existing attachment/popup path are unchanged. * feat: gutter "+" drag selects a range for multi-line comments Re-enable Pierre's gutter line selection so dragging the "+" down the gutter comments across a range (click still comments on a single line); onLineSelectionEnd routes the range to the composer. Native code-text selection still drives "Add to Chat" — Pierre only line-selects from the gutter, and onLineSelectionEnd bails when a native text selection is present, so a code highlight never opens the composer. * fix: keep the range highlighted while its comment composer is open Previously opening the composer cleared the selection, so the lines being commented on lost their highlight. Drive the controlled selection from the open comment draft's range so the rows stay highlighted until the composer is closed. * fix: address PR review — paginate comments, fall back for outdated ones - list_review_comments now pages through all PR review comments (bounded by _MAX_REVIEW_COMMENT_PAGES) instead of returning only the first 100, so older comments still show in the dropdown. - Surface GitHub's outdated flag (position == null) as is_outdated; opening such a comment (or one whose line isn't in the diff) now opens it on GitHub instead of silently rendering nothing, plus a timeout fallback if the annotation never mounts (e.g. collapsed context). --- agent/dashboard/review_api.py | 146 +++ agent/dashboard/routes.py | 55 +- .../components/agents/ReviewCommentsMenu.tsx | 167 ++++ ui/src/components/agents/ReviewMainBody.tsx | 844 ++++++++++++++++-- ui/src/components/sidebar-layout.tsx | 6 + ui/src/lib/api.ts | 47 + .../agents/reviews/$owner.$repo.$number.tsx | 37 +- 7 files changed, 1235 insertions(+), 67 deletions(-) create mode 100644 ui/src/components/agents/ReviewCommentsMenu.tsx diff --git a/agent/dashboard/review_api.py b/agent/dashboard/review_api.py index 3351c884..6389601d 100644 --- a/agent/dashboard/review_api.py +++ b/agent/dashboard/review_api.py @@ -10,6 +10,7 @@ from __future__ import annotations import ipaddress import logging +import re import socket from collections.abc import Awaitable, Callable from typing import Any, Literal @@ -55,6 +56,43 @@ async def _github_get( return response.json() +def _github_error_message(response: httpx.Response) -> str: + """Best-effort extraction of GitHub's error message for surfacing to the UI.""" + fallback = f"GitHub request failed ({response.status_code})" + try: + data = response.json() + except ValueError: + return fallback + if not isinstance(data, dict): + return fallback + message = data.get("message") + message_str = message if isinstance(message, str) else "" + errors = data.get("errors") + detail_parts: list[str] = [] + if isinstance(errors, list): + for err in errors: + if isinstance(err, dict) and isinstance(err.get("message"), str): + detail_parts.append(err["message"]) + detail = "; ".join(detail_parts) + if message_str and detail: + return f"{message_str}: {detail}" + return message_str or detail or fallback + + +async def _github_post(path: str, token: str, *, json: dict[str, Any]) -> Any: + async with httpx.AsyncClient(timeout=_GITHUB_TIMEOUT) as client: + response = await client.post( + f"{_GITHUB_API}{path}", headers=github_headers(token), json=json + ) + if response.status_code >= 400: + message = _github_error_message(response) + logger.warning("GitHub POST %s failed: %s %s", path, response.status_code, message) + # Pass 4xx through verbatim (422 = line not in diff, 403 = perms); collapse + # 5xx to a 502 so a GitHub outage doesn't masquerade as a client error. + raise HTTPException(response.status_code if response.status_code < 500 else 502, message) + return response.json() + + def reviewer_thread_id(owner: str, repo: str, pr_number: int) -> str: import uuid @@ -364,6 +402,114 @@ async def get_pr_head_sha(owner: str, repo: str, pr_number: int) -> str: return sha if isinstance(sha, str) else "" +async def create_review_comment( + owner: str, + repo: str, + pr_number: int, + *, + token: str, + path: str, + line: int, + side: Literal["LEFT", "RIGHT"], + body: str, + start_line: int | None = None, + start_side: Literal["LEFT", "RIGHT"] | None = None, +) -> dict[str, Any]: + """Post a single inline review comment to a PR using the caller's token. + + Unlike the reviewer agent (which batches comments into one review via the App + token), this posts a standalone comment immediately, authored by the signed-in + user. ``commit_id`` is the PR's live head SHA. GitHub errors surface verbatim so + the UI can explain a 422 (line not part of the diff) or 403 (missing permission). + """ + head_sha = await get_pr_head_sha(owner, repo, pr_number) + if not head_sha: + raise HTTPException(502, "could not resolve PR head commit") + payload: dict[str, Any] = { + "body": body, + "commit_id": head_sha, + "path": path, + "line": line, + "side": side, + } + # GitHub forbids multi-line ranges that span sides; only add the range start + # when it is a distinct earlier line on the same side. + if start_line is not None and start_line != line: + payload["start_line"] = start_line + payload["start_side"] = start_side or side + return await _github_post( + f"/repos/{owner}/{repo}/pulls/{pr_number}/comments", token, json=payload + ) + + +_HTML_COMMENT_RE = re.compile(r"", re.DOTALL) +# Inline comments the reviewer posts carry this hidden marker (see reviewer_publish). +_OPEN_SWE_COMMENT_RE = re.compile(r"