mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-10-06 01:52:12 +00:00
feat: AI-sorted PR review view with diff grouping (#1544)
* feat: AI-sorted PR review view with diff grouping Group a PR's changed files into a logical top-to-bottom walkthrough via a best-effort structured-output LLM pass, kicked off concurrently with the reviewer run (~0 added latency) and persisted on reviewer thread metadata. Results render in the review UI behind an AI sorted / file tree toggle that persists across PRs; the view falls back to the file tree when groups are absent or stale. Adds a grouping-model team default (inherits the Reviewer subagent model when unset) and the admin RolePicker for it. * feat(reviews): richer AI-sorted explanations + sidebar polish Sidebar group rows get Devin-style spacing (dividers, padding), a per-group file list (click a file to jump to its diff), inline-code chips in titles, and an accent Read explanation link. Group explanations are now rich markdown: the grouping prompt feeds per-hunk line ranges and asks for inline code, a short code block, and [path:line](#loc=...) references. The Markdown renderer turns those #loc= links into in-page buttons that scroll the diff to the hunk and highlight the range, reusing the existing selectedLines path. * fix(reviews): drop stale diff groups from the AI-sorted view When groups were generated for a previous head, a persisted "ai" view in localStorage still rendered the outdated walkthrough. groupedView now returns null on diff_groups_stale, so the file-tree fallback is used and the view toggle hides until fresh groups arrive. --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
272ffc78a6
commit
801f93b4de
15 changed files with 1684 additions and 390 deletions
|
|
@ -116,6 +116,47 @@ def classify_finding(finding: dict[str, Any]) -> Literal["bug", "investigate", "
|
||||||
return "informational"
|
return "informational"
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_diff_groups(
|
||||||
|
metadata: dict[str, Any], head_sha: str
|
||||||
|
) -> tuple[list[dict[str, Any]], bool]:
|
||||||
|
"""Serialize the persisted ``diff_groups`` for the AI sorted view.
|
||||||
|
|
||||||
|
Returns ``(groups, stale)`` where each group carries a 1-based ``index``
|
||||||
|
and validated fields, and ``stale`` is True when the groups were generated
|
||||||
|
for a different head SHA than the one currently being rendered.
|
||||||
|
"""
|
||||||
|
raw = metadata.get("diff_groups")
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return [], False
|
||||||
|
raw_groups = raw.get("groups")
|
||||||
|
if not isinstance(raw_groups, list):
|
||||||
|
return [], False
|
||||||
|
groups: list[dict[str, Any]] = []
|
||||||
|
for group in raw_groups:
|
||||||
|
if not isinstance(group, dict):
|
||||||
|
continue
|
||||||
|
title = group.get("title")
|
||||||
|
if not isinstance(title, str) or not title.strip():
|
||||||
|
continue
|
||||||
|
files = [f for f in (group.get("files") or []) if isinstance(f, str) and f]
|
||||||
|
if not files:
|
||||||
|
continue
|
||||||
|
summary = group.get("summary")
|
||||||
|
groups.append(
|
||||||
|
{
|
||||||
|
"index": len(groups) + 1,
|
||||||
|
"title": title.strip(),
|
||||||
|
"summary": summary.strip() if isinstance(summary, str) else "",
|
||||||
|
"files": files,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
groups_head = raw.get("head_sha")
|
||||||
|
stale = bool(
|
||||||
|
head_sha and isinstance(groups_head, str) and groups_head and groups_head != head_sha
|
||||||
|
)
|
||||||
|
return groups, stale
|
||||||
|
|
||||||
|
|
||||||
def _finding_counts(findings: list[dict[str, Any]]) -> dict[str, int]:
|
def _finding_counts(findings: list[dict[str, Any]]) -> dict[str, int]:
|
||||||
counts = {"open": 0, "resolved": 0, "dismissed": 0, "bugs": 0, "flags": 0}
|
counts = {"open": 0, "resolved": 0, "dismissed": 0, "bugs": 0, "flags": 0}
|
||||||
for finding in findings:
|
for finding in findings:
|
||||||
|
|
@ -336,7 +377,16 @@ async def get_review(owner: str, repo: str, pr_number: int) -> dict[str, Any]:
|
||||||
for finding in findings:
|
for finding in findings:
|
||||||
finding["group"] = classify_finding(finding)
|
finding["group"] = classify_finding(finding)
|
||||||
|
|
||||||
return {**summary, "pr": details, "checks": checks, "findings": findings}
|
diff_groups, diff_groups_stale = _serialize_diff_groups(metadata, head_sha)
|
||||||
|
|
||||||
|
return {
|
||||||
|
**summary,
|
||||||
|
"pr": details,
|
||||||
|
"checks": checks,
|
||||||
|
"findings": findings,
|
||||||
|
"diff_groups": diff_groups,
|
||||||
|
"diff_groups_stale": diff_groups_stale,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ class TeamSettingsUpdate(BaseModel):
|
||||||
default_reviewer_reasoning_effort: str | None = None
|
default_reviewer_reasoning_effort: str | None = None
|
||||||
default_reviewer_subagent_model: str | None = None
|
default_reviewer_subagent_model: str | None = None
|
||||||
default_reviewer_subagent_reasoning_effort: str | None = None
|
default_reviewer_subagent_reasoning_effort: str | None = None
|
||||||
|
default_grouping_model: str | None = None
|
||||||
|
default_grouping_reasoning_effort: str | None = None
|
||||||
default_chat_model: str | None = None
|
default_chat_model: str | None = None
|
||||||
default_chat_reasoning_effort: str | None = None
|
default_chat_reasoning_effort: str | None = None
|
||||||
|
|
||||||
|
|
@ -89,6 +91,11 @@ class TeamSettingsUpdate(BaseModel):
|
||||||
self.default_reviewer_subagent_reasoning_effort,
|
self.default_reviewer_subagent_reasoning_effort,
|
||||||
"reviewer subagent",
|
"reviewer subagent",
|
||||||
)
|
)
|
||||||
|
_validate_model_effort_pair(
|
||||||
|
self.default_grouping_model,
|
||||||
|
self.default_grouping_reasoning_effort,
|
||||||
|
"review diff grouping",
|
||||||
|
)
|
||||||
_validate_model_effort_pair(
|
_validate_model_effort_pair(
|
||||||
self.default_chat_model, self.default_chat_reasoning_effort, "review chat"
|
self.default_chat_model, self.default_chat_reasoning_effort, "review chat"
|
||||||
)
|
)
|
||||||
|
|
@ -144,6 +151,10 @@ def _default_settings() -> dict[str, Any]:
|
||||||
"default_reviewer_reasoning_effort": fallback_effort,
|
"default_reviewer_reasoning_effort": fallback_effort,
|
||||||
"default_reviewer_subagent_model": fallback_model,
|
"default_reviewer_subagent_model": fallback_model,
|
||||||
"default_reviewer_subagent_reasoning_effort": fallback_effort,
|
"default_reviewer_subagent_reasoning_effort": fallback_effort,
|
||||||
|
# No hardcoded grouping default: unset means "inherit the Reviewer
|
||||||
|
# subagent default".
|
||||||
|
"default_grouping_model": None,
|
||||||
|
"default_grouping_reasoning_effort": None,
|
||||||
# No hardcoded chat default: unset means "inherit the Agent default".
|
# No hardcoded chat default: unset means "inherit the Agent default".
|
||||||
"default_chat_model": None,
|
"default_chat_model": None,
|
||||||
"default_chat_reasoning_effort": None,
|
"default_chat_reasoning_effort": None,
|
||||||
|
|
@ -192,6 +203,8 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
|
||||||
"default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort,
|
"default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort,
|
||||||
"default_reviewer_subagent_model": update.default_reviewer_subagent_model,
|
"default_reviewer_subagent_model": update.default_reviewer_subagent_model,
|
||||||
"default_reviewer_subagent_reasoning_effort": update.default_reviewer_subagent_reasoning_effort,
|
"default_reviewer_subagent_reasoning_effort": update.default_reviewer_subagent_reasoning_effort,
|
||||||
|
"default_grouping_model": update.default_grouping_model,
|
||||||
|
"default_grouping_reasoning_effort": update.default_grouping_reasoning_effort,
|
||||||
"default_chat_model": update.default_chat_model,
|
"default_chat_model": update.default_chat_model,
|
||||||
"default_chat_reasoning_effort": update.default_chat_reasoning_effort,
|
"default_chat_reasoning_effort": update.default_chat_reasoning_effort,
|
||||||
"updated_at": datetime.now(UTC).isoformat(),
|
"updated_at": datetime.now(UTC).isoformat(),
|
||||||
|
|
@ -268,6 +281,31 @@ async def get_team_default_model_pair(
|
||||||
return main, subagent
|
return main, subagent
|
||||||
|
|
||||||
|
|
||||||
|
async def get_team_default_grouping_model() -> tuple[str, str]:
|
||||||
|
"""Return the team-wide default ``(model_id, reasoning_effort)`` for the
|
||||||
|
review diff-grouping pass.
|
||||||
|
|
||||||
|
When no grouping-specific model is configured (or it's no longer
|
||||||
|
supported), inherit the team **reviewer subagent** default — the grouping
|
||||||
|
pass is a cheap, fast companion to the reviewer, so it should track that
|
||||||
|
cheaper tier rather than the primary reviewer model.
|
||||||
|
"""
|
||||||
|
settings = await get_team_settings()
|
||||||
|
model = settings.get("default_grouping_model")
|
||||||
|
effort = settings.get("default_grouping_reasoning_effort")
|
||||||
|
if (
|
||||||
|
isinstance(model, str)
|
||||||
|
and isinstance(effort, str)
|
||||||
|
and model in SUPPORTED_MODEL_IDS
|
||||||
|
and model_supports_effort(model, effort)
|
||||||
|
):
|
||||||
|
return _resolve_default_pair(model, effort)
|
||||||
|
return _resolve_default_pair(
|
||||||
|
settings.get("default_reviewer_subagent_model"),
|
||||||
|
settings.get("default_reviewer_subagent_reasoning_effort"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_autofix_settings() -> dict[str, Any]:
|
async def get_autofix_settings() -> dict[str, Any]:
|
||||||
"""Return the team-wide auto-fix config: mode, severity threshold, trigger mode."""
|
"""Return the team-wide auto-fix config: mode, severity threshold, trigger mode."""
|
||||||
settings = await get_team_settings()
|
settings = await get_team_settings()
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,13 @@ warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarnin
|
||||||
|
|
||||||
from deepagents import create_deep_agent
|
from deepagents import create_deep_agent
|
||||||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||||||
|
from langchain_core.language_models.chat_models import BaseChatModel
|
||||||
|
|
||||||
from .dashboard.team_settings import get_org_review_guidelines, get_team_default_model_pair
|
from .dashboard.team_settings import (
|
||||||
|
get_org_review_guidelines,
|
||||||
|
get_team_default_grouping_model,
|
||||||
|
get_team_default_model_pair,
|
||||||
|
)
|
||||||
from .middleware import (
|
from .middleware import (
|
||||||
SanitizeThinkingBlocksMiddleware,
|
SanitizeThinkingBlocksMiddleware,
|
||||||
SanitizeToolInputsMiddleware,
|
SanitizeToolInputsMiddleware,
|
||||||
|
|
@ -46,6 +51,7 @@ from .reviewer_diff import compute_diff_line_set, fetch_pr_diff, fetch_pr_metada
|
||||||
from .reviewer_findings import (
|
from .reviewer_findings import (
|
||||||
list_findings as list_findings_async,
|
list_findings as list_findings_async,
|
||||||
)
|
)
|
||||||
|
from .reviewer_groups import maybe_generate_and_store_diff_groups
|
||||||
from .reviewer_publish import fetch_pr_review_threads
|
from .reviewer_publish import fetch_pr_review_threads
|
||||||
from .reviewer_reconcile import reconcile_findings_with_review_threads
|
from .reviewer_reconcile import reconcile_findings_with_review_threads
|
||||||
from .server import (
|
from .server import (
|
||||||
|
|
@ -727,6 +733,43 @@ def _format_existing_findings(findings: list[dict]) -> str:
|
||||||
return "\n".join(lines) if lines else "_(no open findings)_"
|
return "\n".join(lines) if lines else "_(no open findings)_"
|
||||||
|
|
||||||
|
|
||||||
|
# Strong references to fire-and-forget background tasks (e.g. the AI-sorted
|
||||||
|
# diff grouping pass) so the event loop doesn't garbage-collect them mid-flight.
|
||||||
|
_BACKGROUND_TASKS: set[asyncio.Task[None]] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _on_background_task_done(task: asyncio.Task[None]) -> None:
|
||||||
|
_BACKGROUND_TASKS.discard(task)
|
||||||
|
if task.cancelled():
|
||||||
|
return
|
||||||
|
exc = task.exception()
|
||||||
|
if exc is not None:
|
||||||
|
logger.warning("Background reviewer task failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_grouping_model(configurable: dict[str, object]) -> BaseChatModel:
|
||||||
|
"""Resolve the model for the diff-grouping pass.
|
||||||
|
|
||||||
|
Per-run override (``grouping_model_id``/``grouping_reasoning_effort``) wins;
|
||||||
|
otherwise the team default, which itself inherits the reviewer subagent
|
||||||
|
model when no grouping-specific model is configured.
|
||||||
|
"""
|
||||||
|
configured_model_id = configurable.get("grouping_model_id")
|
||||||
|
configured_effort = configurable.get("grouping_reasoning_effort")
|
||||||
|
if isinstance(configured_model_id, str) and configured_model_id:
|
||||||
|
model_id = configured_model_id
|
||||||
|
effort = configured_effort if isinstance(configured_effort, str) else None
|
||||||
|
else:
|
||||||
|
model_id, effort = await get_team_default_grouping_model()
|
||||||
|
model_kwargs = provider_model_kwargs(
|
||||||
|
model_id,
|
||||||
|
effort,
|
||||||
|
max_tokens=DEFAULT_LLM_MAX_TOKENS,
|
||||||
|
openai_reasoning_default=DEFAULT_LLM_REASONING,
|
||||||
|
)
|
||||||
|
return make_model(model_id, **model_kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
||||||
"""Get or create a reviewer agent with a sandbox + prepped repo."""
|
"""Get or create a reviewer agent with a sandbox + prepped repo."""
|
||||||
thread_id = config["configurable"].get("thread_id", None)
|
thread_id = config["configurable"].get("thread_id", None)
|
||||||
|
|
@ -1025,6 +1068,25 @@ async def get_reviewer_agent(config: RunnableConfig) -> Pregel:
|
||||||
|
|
||||||
reviewer_model = make_model(model_id, **model_kwargs)
|
reviewer_model = make_model(model_id, **model_kwargs)
|
||||||
reviewer_subagent_model = make_model(subagent_model_id, **subagent_model_kwargs)
|
reviewer_subagent_model = make_model(subagent_model_id, **subagent_model_kwargs)
|
||||||
|
|
||||||
|
# Kick off the AI-sorted diff grouping pass at run start, concurrently with
|
||||||
|
# the review, so it adds ~0 latency. First-review and re-review only — a
|
||||||
|
# finding_reply run doesn't change the diff. Best-effort: the task swallows
|
||||||
|
# its own errors and the UI falls back to the folder view when groups are
|
||||||
|
# absent.
|
||||||
|
if reviewer_event != "finding_reply" and pr_diff_text and thread_id:
|
||||||
|
grouping_model = await _resolve_grouping_model(config["configurable"])
|
||||||
|
grouping_task = asyncio.create_task(
|
||||||
|
maybe_generate_and_store_diff_groups(
|
||||||
|
thread_id=thread_id,
|
||||||
|
head_sha=head_sha,
|
||||||
|
diff_text=pr_diff_text,
|
||||||
|
model=grouping_model,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_BACKGROUND_TASKS.add(grouping_task)
|
||||||
|
grouping_task.add_done_callback(_on_background_task_done)
|
||||||
|
|
||||||
return create_deep_agent(
|
return create_deep_agent(
|
||||||
model=reviewer_model,
|
model=reviewer_model,
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
|
|
|
||||||
241
agent/reviewer_groups.py
Normal file
241
agent/reviewer_groups.py
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
"""Diff grouping for the reviewer "AI sorted" view.
|
||||||
|
|
||||||
|
A lightweight, single structured-output LLM pass that partitions a PR's
|
||||||
|
changed files into ordered, logically-connected groups, each with a short
|
||||||
|
headline and explanation. The pass is kicked off concurrently with the
|
||||||
|
reviewer run (so it adds ~0 latency) and its result is stored on the reviewer
|
||||||
|
thread metadata under ``diff_groups`` for the review UI to render as a
|
||||||
|
top-to-bottom walkthrough of the PR.
|
||||||
|
|
||||||
|
Everything here is best-effort: any failure logs and degrades to "no groups",
|
||||||
|
and the UI falls back to the folder/flat view. The grouping never blocks or
|
||||||
|
breaks the review itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TypedDict
|
||||||
|
|
||||||
|
from langchain_core.language_models.chat_models import BaseChatModel
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .reviewer_diff import parse_unified_diff
|
||||||
|
from .reviewer_findings import get_thread_metadata, set_reviewer_thread_metadata
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Bound the prompt so a huge PR can't blow up the cost/latency of the grouping
|
||||||
|
# pass. The full changed-file list is always included (so every file can be
|
||||||
|
# assigned); only the per-file hunk previews are budgeted.
|
||||||
|
MAX_PROMPT_CHARS = 48_000
|
||||||
|
MAX_FILE_HUNK_CHARS = 4_000
|
||||||
|
MAX_GROUPS = 8
|
||||||
|
|
||||||
|
|
||||||
|
class DiffGroup(TypedDict):
|
||||||
|
"""One logical group of changed files for the AI sorted view."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
files: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class _DiffGroupModel(BaseModel):
|
||||||
|
title: str = Field(
|
||||||
|
description=(
|
||||||
|
"Short headline naming the logical change, roughly 4-10 words, no "
|
||||||
|
"trailing punctuation. Wrap code identifiers in `backticks`."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
summary: str = Field(
|
||||||
|
description=(
|
||||||
|
"GitHub-flavored markdown walkthrough of this group: a few short "
|
||||||
|
"sentences or bullets, code identifiers in `backticks`, optionally one "
|
||||||
|
"short fenced code block of the key snippet, and concrete locations "
|
||||||
|
"linked as [path:start-end](#loc=path:start-end)."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
files: list[str] = Field(
|
||||||
|
description="Changed file paths in this group, copied verbatim, in reading order."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _DiffGroupingResult(BaseModel):
|
||||||
|
groups: list[_DiffGroupModel] = Field(
|
||||||
|
description="Ordered groups forming a top-to-bottom walkthrough of the PR."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_PROMPT_TEMPLATE = """You are organizing a GitHub pull request's changed files into a clear, \
|
||||||
|
top-to-bottom walkthrough for a human reviewer.
|
||||||
|
|
||||||
|
Group the changed files by logical intent — put files that implement one \
|
||||||
|
coherent change together (e.g. "the new feature", "the supporting refactor", \
|
||||||
|
"the config wiring", "the tests"). Order the groups so a reviewer can read \
|
||||||
|
them top to bottom and build a mental model of the PR.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Assign every changed file to exactly one group.
|
||||||
|
- Use at most {max_groups} groups. Prefer fewer, larger groups over many tiny ones.
|
||||||
|
- title: a short headline naming the change, roughly 4-10 words, no trailing \
|
||||||
|
punctuation. Wrap code identifiers (symbols, flags, file names) in `backticks`.
|
||||||
|
- summary: GitHub-flavored markdown explaining what the group changes and why, \
|
||||||
|
written as a short walkthrough a reviewer can skim:
|
||||||
|
- Keep it focused — a few short sentences or bullets. Do not restate the diff line by line.
|
||||||
|
- Wrap every code identifier, symbol, type, flag, and path in `backticks`.
|
||||||
|
- When one change is central, include at most ONE short fenced code block \
|
||||||
|
(```lang, <= ~8 lines) of the key snippet — not the whole hunk.
|
||||||
|
- Reference concrete locations as markdown links of the EXACT form \
|
||||||
|
[path:start-end](#loc=path:start-end), where path is the verbatim changed-file \
|
||||||
|
path and start/end are line numbers from the "lines X-Y" annotations below \
|
||||||
|
(use [path:line](#loc=path:line) when start == end). Prefer these links over \
|
||||||
|
describing locations in prose — they let the reader jump straight to the hunk.
|
||||||
|
- files: the exact file paths (copied verbatim from the list below) in this \
|
||||||
|
group, in the order a reviewer should read them.
|
||||||
|
|
||||||
|
Changed files:
|
||||||
|
{file_list}
|
||||||
|
|
||||||
|
Diffs (each hunk is annotated with its line range in the new file):
|
||||||
|
{diffs}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def diff_signature(diff_text: str) -> str:
|
||||||
|
"""Stable content hash of the diff, used to skip regeneration when unchanged."""
|
||||||
|
return hashlib.sha256(diff_text.encode("utf-8", "ignore")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_files(diff_text: str) -> list[str]:
|
||||||
|
return [fd.file for fd in parse_unified_diff(diff_text)]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_prompt(diff_text: str, files: list[str]) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
budget = MAX_PROMPT_CHARS
|
||||||
|
for file_diff in parse_unified_diff(diff_text):
|
||||||
|
segments: list[str] = []
|
||||||
|
for hunk in file_diff.hunks:
|
||||||
|
body = hunk.body
|
||||||
|
if len(body) > MAX_FILE_HUNK_CHARS:
|
||||||
|
body = body[:MAX_FILE_HUNK_CHARS] + "\n... (truncated)"
|
||||||
|
# Surface the new-file line range so the model can cite accurate
|
||||||
|
# locations in its summary links.
|
||||||
|
segments.append(f"lines {hunk.new_start}-{hunk.new_end}:\n```diff\n{body}\n```")
|
||||||
|
block = f"### {file_diff.file}\n" + "\n".join(segments) + "\n"
|
||||||
|
if budget - len(block) < 0:
|
||||||
|
parts.append(f"### {file_diff.file}\n(diff omitted — prompt budget reached)\n")
|
||||||
|
continue
|
||||||
|
budget -= len(block)
|
||||||
|
parts.append(block)
|
||||||
|
file_list = "\n".join(f"- {path}" for path in files)
|
||||||
|
return _PROMPT_TEMPLATE.format(
|
||||||
|
max_groups=MAX_GROUPS,
|
||||||
|
file_list=file_list,
|
||||||
|
diffs="\n".join(parts),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_groups(result: _DiffGroupingResult, files: list[str]) -> list[DiffGroup]:
|
||||||
|
"""Validate the model output into a clean partition.
|
||||||
|
|
||||||
|
Each file is assigned at most once (first group wins), unknown paths are
|
||||||
|
dropped, empty/untitled groups are dropped, and the group count is capped.
|
||||||
|
Files the model failed to assign are intentionally left out here — the UI
|
||||||
|
collects them into a trailing "Other changes" group, which also keeps the
|
||||||
|
view robust against stale group data.
|
||||||
|
"""
|
||||||
|
valid = set(files)
|
||||||
|
seen: set[str] = set()
|
||||||
|
groups: list[DiffGroup] = []
|
||||||
|
for group in result.groups:
|
||||||
|
title = (group.title or "").strip()
|
||||||
|
summary = (group.summary or "").strip()
|
||||||
|
picked: list[str] = []
|
||||||
|
for path in group.files:
|
||||||
|
if path in valid and path not in seen:
|
||||||
|
seen.add(path)
|
||||||
|
picked.append(path)
|
||||||
|
if not title or not picked:
|
||||||
|
continue
|
||||||
|
groups.append({"title": title, "summary": summary, "files": picked})
|
||||||
|
if len(groups) >= MAX_GROUPS:
|
||||||
|
break
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_diff_groups(
|
||||||
|
*,
|
||||||
|
diff_text: str,
|
||||||
|
model: BaseChatModel,
|
||||||
|
) -> list[DiffGroup] | None:
|
||||||
|
"""Run the single structured-output grouping pass over a unified diff.
|
||||||
|
|
||||||
|
Returns the normalized groups, ``[]`` when there are no changed files, or
|
||||||
|
``None`` when the LLM call fails (caller treats ``None`` as "leave existing
|
||||||
|
groups untouched").
|
||||||
|
"""
|
||||||
|
files = _changed_files(diff_text)
|
||||||
|
if not files:
|
||||||
|
return []
|
||||||
|
prompt = _build_prompt(diff_text, files)
|
||||||
|
try:
|
||||||
|
structured = model.with_structured_output(_DiffGroupingResult)
|
||||||
|
result = await structured.ainvoke(prompt)
|
||||||
|
except Exception: # noqa: BLE001 — grouping is best-effort, never break the review
|
||||||
|
logger.exception("Diff grouping LLM call failed")
|
||||||
|
return None
|
||||||
|
if not isinstance(result, _DiffGroupingResult):
|
||||||
|
logger.warning("Diff grouping returned unexpected type: %s", type(result))
|
||||||
|
return None
|
||||||
|
return _normalize_groups(result, files)
|
||||||
|
|
||||||
|
|
||||||
|
async def maybe_generate_and_store_diff_groups(
|
||||||
|
*,
|
||||||
|
thread_id: str,
|
||||||
|
head_sha: str,
|
||||||
|
diff_text: str,
|
||||||
|
model: BaseChatModel,
|
||||||
|
) -> None:
|
||||||
|
"""Generate diff groups and persist them on the reviewer thread metadata.
|
||||||
|
|
||||||
|
Skips regeneration when the persisted signature already matches the current
|
||||||
|
diff (the cheap no-op path for re-reviews with no file changes). All errors
|
||||||
|
are logged and swallowed so this can run as a fire-and-forget background
|
||||||
|
task without ever affecting the review.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not thread_id or not diff_text:
|
||||||
|
return
|
||||||
|
signature = diff_signature(diff_text)
|
||||||
|
metadata = await get_thread_metadata(thread_id)
|
||||||
|
existing = metadata.get("diff_groups") if isinstance(metadata, dict) else None
|
||||||
|
if (
|
||||||
|
isinstance(existing, dict)
|
||||||
|
and existing.get("signature") == signature
|
||||||
|
and isinstance(existing.get("groups"), list)
|
||||||
|
and existing.get("groups")
|
||||||
|
):
|
||||||
|
return
|
||||||
|
groups = await generate_diff_groups(diff_text=diff_text, model=model)
|
||||||
|
if groups is None:
|
||||||
|
return
|
||||||
|
payload = {
|
||||||
|
"head_sha": head_sha,
|
||||||
|
"signature": signature,
|
||||||
|
"generated_at": datetime.now(UTC).isoformat(),
|
||||||
|
"groups": groups,
|
||||||
|
}
|
||||||
|
await set_reviewer_thread_metadata(thread_id, extra={"diff_groups": payload})
|
||||||
|
logger.info(
|
||||||
|
"Stored %d diff group(s) for reviewer thread %s (head %s)",
|
||||||
|
len(groups),
|
||||||
|
thread_id,
|
||||||
|
head_sha[:7] if head_sha else "?",
|
||||||
|
)
|
||||||
|
except Exception: # noqa: BLE001 — best-effort; never break the review
|
||||||
|
logger.exception("Failed to generate/store diff groups for thread %s", thread_id)
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from agent.dashboard.review_api import (
|
from agent.dashboard.review_api import (
|
||||||
_finding_counts,
|
_finding_counts,
|
||||||
|
_serialize_diff_groups,
|
||||||
_serialize_finding,
|
_serialize_finding,
|
||||||
_thread_review_summary,
|
_thread_review_summary,
|
||||||
classify_finding,
|
classify_finding,
|
||||||
|
|
@ -71,3 +72,40 @@ def test_thread_review_summary_requires_pr_meta():
|
||||||
|
|
||||||
def test_reviewer_thread_id_matches_webapp():
|
def test_reviewer_thread_id_matches_webapp():
|
||||||
assert reviewer_thread_id("acme", "repo", 7) == generate_reviewer_thread_id("acme", "repo", 7)
|
assert reviewer_thread_id("acme", "repo", 7) == generate_reviewer_thread_id("acme", "repo", 7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_diff_groups_assigns_index_and_drops_invalid():
|
||||||
|
metadata = {
|
||||||
|
"diff_groups": {
|
||||||
|
"head_sha": "abc",
|
||||||
|
"groups": [
|
||||||
|
{"title": "Feature", "summary": "Adds it", "files": ["a.py", "b.py"]},
|
||||||
|
{"title": " ", "summary": "x", "files": ["c.py"]},
|
||||||
|
{"title": "Empty", "summary": "", "files": []},
|
||||||
|
{"title": "Tests", "summary": "Covers it", "files": ["t.py", 5]},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups, stale = _serialize_diff_groups(metadata, "abc")
|
||||||
|
assert stale is False
|
||||||
|
assert groups == [
|
||||||
|
{"index": 1, "title": "Feature", "summary": "Adds it", "files": ["a.py", "b.py"]},
|
||||||
|
{"index": 2, "title": "Tests", "summary": "Covers it", "files": ["t.py"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_diff_groups_marks_stale_on_head_mismatch():
|
||||||
|
metadata = {
|
||||||
|
"diff_groups": {
|
||||||
|
"head_sha": "old",
|
||||||
|
"groups": [{"title": "T", "summary": "", "files": ["a.py"]}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups, stale = _serialize_diff_groups(metadata, "new")
|
||||||
|
assert stale is True
|
||||||
|
assert groups[0]["index"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_diff_groups_handles_missing():
|
||||||
|
assert _serialize_diff_groups({}, "abc") == ([], False)
|
||||||
|
assert _serialize_diff_groups({"diff_groups": {"groups": "nope"}}, "abc") == ([], False)
|
||||||
|
|
|
||||||
172
tests/test_reviewer_groups.py
Normal file
172
tests/test_reviewer_groups.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agent.reviewer_groups import (
|
||||||
|
_build_prompt,
|
||||||
|
_DiffGroupingResult,
|
||||||
|
_DiffGroupModel,
|
||||||
|
diff_signature,
|
||||||
|
generate_diff_groups,
|
||||||
|
maybe_generate_and_store_diff_groups,
|
||||||
|
)
|
||||||
|
|
||||||
|
_DIFF = """diff --git a/foo.py b/foo.py
|
||||||
|
--- a/foo.py
|
||||||
|
+++ b/foo.py
|
||||||
|
@@ -1,2 +1,3 @@
|
||||||
|
a
|
||||||
|
+b
|
||||||
|
c
|
||||||
|
diff --git a/bar.py b/bar.py
|
||||||
|
--- a/bar.py
|
||||||
|
+++ b/bar.py
|
||||||
|
@@ -1 +1,2 @@
|
||||||
|
x
|
||||||
|
+y
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeStructured:
|
||||||
|
def __init__(self, result: Any, *, raises: bool = False) -> None:
|
||||||
|
self._result = result
|
||||||
|
self._raises = raises
|
||||||
|
|
||||||
|
async def ainvoke(self, _prompt: str) -> Any:
|
||||||
|
if self._raises:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
return self._result
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeModel:
|
||||||
|
def __init__(self, result: Any, *, raises: bool = False) -> None:
|
||||||
|
self._structured = _FakeStructured(result, raises=raises)
|
||||||
|
|
||||||
|
def with_structured_output(self, _schema: Any) -> _FakeStructured:
|
||||||
|
return self._structured
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_diff_groups_partitions() -> None:
|
||||||
|
result = _DiffGroupingResult(
|
||||||
|
groups=[
|
||||||
|
_DiffGroupModel(title="Foo change", summary="Edits foo", files=["foo.py"]),
|
||||||
|
_DiffGroupModel(title="Bar change", summary="Edits bar", files=["bar.py"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
groups = await generate_diff_groups(diff_text=_DIFF, model=_FakeModel(result))
|
||||||
|
assert groups == [
|
||||||
|
{"title": "Foo change", "summary": "Edits foo", "files": ["foo.py"]},
|
||||||
|
{"title": "Bar change", "summary": "Edits bar", "files": ["bar.py"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_diff_groups_dedupes_and_drops_unknown() -> None:
|
||||||
|
result = _DiffGroupingResult(
|
||||||
|
groups=[
|
||||||
|
_DiffGroupModel(
|
||||||
|
title="First",
|
||||||
|
summary="",
|
||||||
|
files=["foo.py", "foo.py", "does-not-exist.py"],
|
||||||
|
),
|
||||||
|
_DiffGroupModel(title="Second", summary="", files=["foo.py", "bar.py"]),
|
||||||
|
_DiffGroupModel(title="", summary="ignored", files=["bar.py"]),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
groups = await generate_diff_groups(diff_text=_DIFF, model=_FakeModel(result))
|
||||||
|
# foo.py only in the first group, bar.py only in the second; the untitled
|
||||||
|
# group and the unknown path are dropped.
|
||||||
|
assert groups == [
|
||||||
|
{"title": "First", "summary": "", "files": ["foo.py"]},
|
||||||
|
{"title": "Second", "summary": "", "files": ["bar.py"]},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_diff_groups_empty_diff_returns_empty() -> None:
|
||||||
|
assert await generate_diff_groups(diff_text="", model=_FakeModel(None)) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_diff_groups_llm_failure_returns_none() -> None:
|
||||||
|
groups = await generate_diff_groups(diff_text=_DIFF, model=_FakeModel(None, raises=True))
|
||||||
|
assert groups is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_prompt_annotates_hunk_line_ranges() -> None:
|
||||||
|
prompt = _build_prompt(_DIFF, ["foo.py", "bar.py"])
|
||||||
|
assert "### foo.py" in prompt
|
||||||
|
assert "### bar.py" in prompt
|
||||||
|
# New-file line ranges from the @@ headers are surfaced so the model can
|
||||||
|
# cite accurate locations: foo.py @@ -1,2 +1,3 @@ and bar.py @@ -1 +1,2 @@.
|
||||||
|
assert "lines 1-3:" in prompt
|
||||||
|
assert "lines 1-2:" in prompt
|
||||||
|
# The verbatim file list is included so every file can be assigned.
|
||||||
|
assert "- foo.py" in prompt
|
||||||
|
assert "- bar.py" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_diff_signature_is_stable_and_content_sensitive() -> None:
|
||||||
|
assert diff_signature(_DIFF) == diff_signature(_DIFF)
|
||||||
|
assert diff_signature(_DIFF) != diff_signature(_DIFF + "\n+extra")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_maybe_generate_skips_when_signature_unchanged() -> None:
|
||||||
|
existing = {
|
||||||
|
"diff_groups": {
|
||||||
|
"signature": diff_signature(_DIFF),
|
||||||
|
"groups": [{"title": "t", "summary": "", "files": ["foo.py"]}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.reviewer_groups.get_thread_metadata",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=existing,
|
||||||
|
),
|
||||||
|
patch("agent.reviewer_groups.generate_diff_groups", new_callable=AsyncMock) as gen,
|
||||||
|
patch(
|
||||||
|
"agent.reviewer_groups.set_reviewer_thread_metadata",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as set_meta,
|
||||||
|
):
|
||||||
|
await maybe_generate_and_store_diff_groups(
|
||||||
|
thread_id="t1", head_sha="h", diff_text=_DIFF, model=MagicMock()
|
||||||
|
)
|
||||||
|
gen.assert_not_awaited()
|
||||||
|
set_meta.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_maybe_generate_stores_when_changed() -> None:
|
||||||
|
groups = [{"title": "t", "summary": "s", "files": ["foo.py"]}]
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"agent.reviewer_groups.get_thread_metadata",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value={},
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"agent.reviewer_groups.generate_diff_groups",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=groups,
|
||||||
|
) as gen,
|
||||||
|
patch(
|
||||||
|
"agent.reviewer_groups.set_reviewer_thread_metadata",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as set_meta,
|
||||||
|
):
|
||||||
|
await maybe_generate_and_store_diff_groups(
|
||||||
|
thread_id="t1", head_sha="head123", diff_text=_DIFF, model=MagicMock()
|
||||||
|
)
|
||||||
|
gen.assert_awaited_once()
|
||||||
|
set_meta.assert_awaited_once()
|
||||||
|
payload = set_meta.call_args.kwargs["extra"]["diff_groups"]
|
||||||
|
assert payload["head_sha"] == "head123"
|
||||||
|
assert payload["signature"] == diff_signature(_DIFF)
|
||||||
|
assert payload["groups"] == groups
|
||||||
83
tests/test_team_settings_grouping.py
Normal file
83
tests/test_team_settings_grouping.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from agent.dashboard.team_settings import (
|
||||||
|
TeamSettingsUpdate,
|
||||||
|
get_team_default_grouping_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
_REVIEWER_SUBAGENT_PAIR = ("openai:gpt-5.5", "low")
|
||||||
|
_GROUPING_PAIR = ("google_genai:gemini-3.5-flash", "low")
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(**overrides: object) -> dict[str, object]:
|
||||||
|
base: dict[str, object] = {
|
||||||
|
"default_reviewer_subagent_model": _REVIEWER_SUBAGENT_PAIR[0],
|
||||||
|
"default_reviewer_subagent_reasoning_effort": _REVIEWER_SUBAGENT_PAIR[1],
|
||||||
|
"default_grouping_model": None,
|
||||||
|
"default_grouping_reasoning_effort": None,
|
||||||
|
}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grouping_inherits_reviewer_subagent_when_unset() -> None:
|
||||||
|
with patch(
|
||||||
|
"agent.dashboard.team_settings.get_team_settings",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=_settings(),
|
||||||
|
):
|
||||||
|
assert await get_team_default_grouping_model() == _REVIEWER_SUBAGENT_PAIR
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grouping_uses_configured_model_when_set() -> None:
|
||||||
|
with patch(
|
||||||
|
"agent.dashboard.team_settings.get_team_settings",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=_settings(
|
||||||
|
default_grouping_model=_GROUPING_PAIR[0],
|
||||||
|
default_grouping_reasoning_effort=_GROUPING_PAIR[1],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
assert await get_team_default_grouping_model() == _GROUPING_PAIR
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_grouping_inherits_when_configured_model_invalid() -> None:
|
||||||
|
with patch(
|
||||||
|
"agent.dashboard.team_settings.get_team_settings",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=_settings(
|
||||||
|
default_grouping_model="bogus:model",
|
||||||
|
default_grouping_reasoning_effort="high",
|
||||||
|
),
|
||||||
|
):
|
||||||
|
assert await get_team_default_grouping_model() == _REVIEWER_SUBAGENT_PAIR
|
||||||
|
|
||||||
|
|
||||||
|
def test_team_settings_update_accepts_grouping_pair() -> None:
|
||||||
|
update = TeamSettingsUpdate(
|
||||||
|
default_grouping_model=_GROUPING_PAIR[0],
|
||||||
|
default_grouping_reasoning_effort=_GROUPING_PAIR[1],
|
||||||
|
)
|
||||||
|
assert update.default_grouping_model == _GROUPING_PAIR[0]
|
||||||
|
assert update.default_grouping_reasoning_effort == _GROUPING_PAIR[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_team_settings_update_rejects_grouping_effort_without_model() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
TeamSettingsUpdate(default_grouping_reasoning_effort="high")
|
||||||
|
|
||||||
|
|
||||||
|
def test_team_settings_update_rejects_unsupported_grouping_effort() -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
TeamSettingsUpdate(
|
||||||
|
default_grouping_model=_GROUPING_PAIR[0],
|
||||||
|
default_grouping_reasoning_effort="max",
|
||||||
|
)
|
||||||
|
|
@ -27,7 +27,7 @@ import type { SessionUser } from "@/lib/api"
|
||||||
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
import type { AgentSource, AgentThread } from "@/lib/agents/types"
|
||||||
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
|
import { SidebarUserMenu } from "@/components/SidebarUserMenu"
|
||||||
import {
|
import {
|
||||||
ReviewFileTree,
|
ReviewSidebarPanel,
|
||||||
ReviewSidebarProvider,
|
ReviewSidebarProvider,
|
||||||
useReviewSidebarData,
|
useReviewSidebarData,
|
||||||
} from "@/components/agents/ReviewSidebar"
|
} from "@/components/agents/ReviewSidebar"
|
||||||
|
|
@ -159,7 +159,7 @@ export function AgentsSidebar({ user, activeThreadId }: AgentsSidebarProps) {
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{reviewSidebar ? (
|
{reviewSidebar ? (
|
||||||
<ReviewFileTree data={reviewSidebar} />
|
<ReviewSidebarPanel data={reviewSidebar} />
|
||||||
) : (
|
) : (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
||||||
<ThreadGroup
|
<ThreadGroup
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ import {
|
||||||
useFileTree,
|
useFileTree,
|
||||||
useFileTreeSelection,
|
useFileTreeSelection,
|
||||||
} from "@pierre/trees/react"
|
} from "@pierre/trees/react"
|
||||||
|
import {
|
||||||
|
CaretRightIcon,
|
||||||
|
ListBulletsIcon,
|
||||||
|
TreeViewIcon,
|
||||||
|
} from "@phosphor-icons/react"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
FileTreeDirectoryHandle,
|
FileTreeDirectoryHandle,
|
||||||
|
|
@ -11,8 +17,10 @@ import type {
|
||||||
GitStatusEntry,
|
GitStatusEntry,
|
||||||
} from "@pierre/trees"
|
} from "@pierre/trees"
|
||||||
import type { ReviewDiffFile } from "@/lib/api"
|
import type { ReviewDiffFile } from "@/lib/api"
|
||||||
|
import { Markdown } from "@/components/agents/ported"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { TREE_UNSAFE_CSS, treeThemeStyle } from "@/components/agents/AgentGitPanel"
|
import { TREE_UNSAFE_CSS, treeThemeStyle } from "@/components/agents/AgentGitPanel"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function reviewFileGitStatus(status: ReviewDiffFile["status"]): GitStatus {
|
function reviewFileGitStatus(status: ReviewDiffFile["status"]): GitStatus {
|
||||||
if (status === "removed") return "deleted"
|
if (status === "removed") return "deleted"
|
||||||
|
|
@ -21,12 +29,29 @@ function reviewFileGitStatus(status: ReviewDiffFile["status"]): GitStatus {
|
||||||
return "modified"
|
return "modified"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReviewSidebarView = "ai" | "files"
|
||||||
|
|
||||||
|
export interface ReviewSidebarGroup {
|
||||||
|
index: number
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
fileCount: number
|
||||||
|
files: Array<string>
|
||||||
|
}
|
||||||
|
|
||||||
export interface ReviewSidebarData {
|
export interface ReviewSidebarData {
|
||||||
title: string
|
title: string
|
||||||
files: Array<ReviewDiffFile> | null
|
files: Array<ReviewDiffFile> | null
|
||||||
selected: string | null
|
selected: string | null
|
||||||
viewed: Set<string>
|
viewed: Set<string>
|
||||||
onSelect: (path: string) => void
|
onSelect: (path: string) => void
|
||||||
|
groups: Array<ReviewSidebarGroup> | null
|
||||||
|
view: ReviewSidebarView
|
||||||
|
onViewChange: (view: ReviewSidebarView) => void
|
||||||
|
onSelectGroup: (index: number) => void
|
||||||
|
onLocationClick?: (file: string, startLine: number, endLine: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReviewSidebarContext = createContext<{
|
const ReviewSidebarContext = createContext<{
|
||||||
|
|
@ -61,13 +86,28 @@ export function useRegisterReviewSidebar(data: ReviewSidebarData) {
|
||||||
}, [setData, data])
|
}, [setData, data])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewFileTree({ data }: { data: ReviewSidebarData }) {
|
export function ReviewSidebarPanel({ data }: { data: ReviewSidebarData }) {
|
||||||
|
const hasGroups = data.groups !== null && data.groups.length > 0
|
||||||
|
const showAi = data.view === "ai" && hasGroups
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col pb-2">
|
<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">
|
<div className="flex items-center justify-between gap-2 px-4 py-1">
|
||||||
{data.title}
|
<span className="text-[10px] font-semibold tracking-wide text-[var(--ui-text-dim)] uppercase">
|
||||||
|
{data.title}
|
||||||
|
</span>
|
||||||
|
{hasGroups && (
|
||||||
|
<ReviewViewToggle view={data.view} onChange={data.onViewChange} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!data.files ? (
|
{showAi ? (
|
||||||
|
<ReviewGroupList
|
||||||
|
groups={data.groups ?? []}
|
||||||
|
onSelectGroup={data.onSelectGroup}
|
||||||
|
onSelectFile={data.onSelect}
|
||||||
|
onLocationClick={data.onLocationClick}
|
||||||
|
/>
|
||||||
|
) : !data.files ? (
|
||||||
<div className="px-4 pt-1">
|
<div className="px-4 pt-1">
|
||||||
<Skeleton className="h-40 w-full" />
|
<Skeleton className="h-40 w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -82,6 +122,209 @@ export function ReviewFileTree({ data }: { data: ReviewSidebarData }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReviewViewToggle({
|
||||||
|
view,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
view: ReviewSidebarView
|
||||||
|
onChange: (view: ReviewSidebarView) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5 rounded-md border border-[var(--ui-border)] p-0.5">
|
||||||
|
<ReviewViewToggleButton
|
||||||
|
active={view === "ai"}
|
||||||
|
label="AI sorted"
|
||||||
|
onClick={() => onChange("ai")}
|
||||||
|
>
|
||||||
|
<ListBulletsIcon className="size-3.5" />
|
||||||
|
</ReviewViewToggleButton>
|
||||||
|
<ReviewViewToggleButton
|
||||||
|
active={view === "files"}
|
||||||
|
label="File tree"
|
||||||
|
onClick={() => onChange("files")}
|
||||||
|
>
|
||||||
|
<TreeViewIcon className="size-3.5" />
|
||||||
|
</ReviewViewToggleButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewViewToggleButton({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={label}
|
||||||
|
className={cn(
|
||||||
|
"flex size-5 items-center justify-center rounded text-[var(--ui-text-dim)] transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-[var(--ui-sidebar-hover)] text-[var(--ui-text)]"
|
||||||
|
: "hover:text-[var(--ui-text)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewGroupList({
|
||||||
|
groups,
|
||||||
|
onSelectGroup,
|
||||||
|
onSelectFile,
|
||||||
|
onLocationClick,
|
||||||
|
}: {
|
||||||
|
groups: Array<ReviewSidebarGroup>
|
||||||
|
onSelectGroup: (index: number) => void
|
||||||
|
onSelectFile: (path: string) => void
|
||||||
|
onLocationClick?: (file: string, startLine: number, endLine: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 divide-y divide-[var(--ui-border-subtle)] overflow-y-auto">
|
||||||
|
{groups.map((group) => (
|
||||||
|
<ReviewGroupRow
|
||||||
|
key={group.index}
|
||||||
|
group={group}
|
||||||
|
onSelect={() => onSelectGroup(group.index)}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onLocationClick={onLocationClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitPath(path: string): { dir: string; base: string } {
|
||||||
|
const idx = path.lastIndexOf("/")
|
||||||
|
if (idx === -1) return { dir: "", base: path }
|
||||||
|
return { dir: path.slice(0, idx), base: path.slice(idx + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a title with `backtick`-delimited spans as inline code chips, matching
|
||||||
|
// the Markdown component's inline-code styling, without pulling in the full
|
||||||
|
// block renderer for a single line.
|
||||||
|
function renderInlineCode(text: string): Array<ReactNode> {
|
||||||
|
return text.split(/(`[^`]+`)/g).map((part, i) => {
|
||||||
|
if (part.length >= 2 && part.startsWith("`") && part.endsWith("`")) {
|
||||||
|
return (
|
||||||
|
<code
|
||||||
|
key={i}
|
||||||
|
className="rounded bg-[var(--ui-panel-2)] px-1 py-0.5 font-mono text-[0.9em] text-[var(--ui-accent)]"
|
||||||
|
>
|
||||||
|
{part.slice(1, -1)}
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewGroupRow({
|
||||||
|
group,
|
||||||
|
onSelect,
|
||||||
|
onSelectFile,
|
||||||
|
onLocationClick,
|
||||||
|
}: {
|
||||||
|
group: ReviewSidebarGroup
|
||||||
|
onSelect: () => void
|
||||||
|
onSelectFile: (path: string) => void
|
||||||
|
onLocationClick?: (file: string, startLine: number, endLine: number) => void
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
return (
|
||||||
|
<div className="px-3 py-3 transition-colors hover:bg-[var(--ui-sidebar-hover)]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
className="flex w-full items-start gap-2 text-left"
|
||||||
|
>
|
||||||
|
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded bg-[var(--ui-panel-2)] text-[11px] font-medium text-[var(--ui-text-dim)]">
|
||||||
|
{group.index}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block text-xs leading-5 font-medium text-[var(--ui-text)]">
|
||||||
|
{renderInlineCode(group.title)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-[var(--ui-text-dim)]">
|
||||||
|
<span>
|
||||||
|
{group.fileCount} file{group.fileCount === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
{group.additions > 0 && (
|
||||||
|
<span className="text-emerald-500">+{group.additions}</span>
|
||||||
|
)}
|
||||||
|
{group.deletions > 0 && (
|
||||||
|
<span className="text-red-500">-{group.deletions}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{group.files.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-0.5 pl-7">
|
||||||
|
{group.files.map((path) => {
|
||||||
|
const { dir, base } = splitPath(path)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={path}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectFile(path)}
|
||||||
|
title={path}
|
||||||
|
className="flex w-full items-baseline gap-1.5 text-left text-[11px] hover:text-[var(--ui-accent)]"
|
||||||
|
>
|
||||||
|
<span className="shrink-0 font-medium text-[var(--ui-text-muted)]">
|
||||||
|
{base}
|
||||||
|
</span>
|
||||||
|
{dir && (
|
||||||
|
<span className="min-w-0 truncate text-[var(--ui-text-dim)]">
|
||||||
|
{dir}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{group.summary && (
|
||||||
|
<div className="mt-2 pl-7">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] font-medium text-[var(--ui-accent)]"
|
||||||
|
>
|
||||||
|
<CaretRightIcon
|
||||||
|
className={cn(
|
||||||
|
"size-3 transition-transform",
|
||||||
|
expanded && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Read explanation
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<Markdown
|
||||||
|
content={group.summary}
|
||||||
|
onLocationClick={onLocationClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function ReviewFileTreeExplorer({
|
function ReviewFileTreeExplorer({
|
||||||
files,
|
files,
|
||||||
selected,
|
selected,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { memo } from "react";
|
import { memo, useMemo } from "react";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
|
import { parseLocationHref } from "./markdownLocation";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import "streamdown/styles.css";
|
import "streamdown/styles.css";
|
||||||
|
|
||||||
|
|
@ -7,6 +8,12 @@ interface MarkdownProps {
|
||||||
content: string;
|
content: string;
|
||||||
/** When true, keep Streamdown in streaming mode for the duration of the run. */
|
/** When true, keep Streamdown in streaming mode for the duration of the run. */
|
||||||
isLive?: boolean;
|
isLive?: boolean;
|
||||||
|
/**
|
||||||
|
* When set, `#loc=path:start-end` links render as in-page buttons that invoke
|
||||||
|
* this callback instead of navigating. Used by the review view to jump the
|
||||||
|
* diff to a referenced hunk.
|
||||||
|
*/
|
||||||
|
onLocationClick?: (file: string, startLine: number, endLine: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -44,16 +51,6 @@ const STREAMDOWN_COMPONENTS = {
|
||||||
{children}
|
{children}
|
||||||
</p>
|
</p>
|
||||||
),
|
),
|
||||||
a: ({ href, children }: { href?: string; children?: ReactNode }) => (
|
|
||||||
<a
|
|
||||||
className="text-[color:var(--ui-accent)] underline decoration-[color:var(--ui-accent)]/50 break-words [overflow-wrap:anywhere]"
|
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
ul: ({ children }: { children?: ReactNode }) => (
|
ul: ({ children }: { children?: ReactNode }) => (
|
||||||
<div className="my-1.5 ml-4 min-w-0">{children}</div>
|
<div className="my-1.5 ml-4 min-w-0">{children}</div>
|
||||||
),
|
),
|
||||||
|
|
@ -86,7 +83,42 @@ const STREAMDOWN_COMPONENTS = {
|
||||||
|
|
||||||
const SHIKI_THEME: ["github-light", "github-dark"] = ["github-light", "github-dark"];
|
const SHIKI_THEME: ["github-light", "github-dark"] = ["github-light", "github-dark"];
|
||||||
|
|
||||||
export const Markdown = memo(function Markdown({ content, isLive = false }: MarkdownProps) {
|
export const Markdown = memo(function Markdown({
|
||||||
|
content,
|
||||||
|
isLive = false,
|
||||||
|
onLocationClick,
|
||||||
|
}: MarkdownProps) {
|
||||||
|
const components = useMemo(
|
||||||
|
() => ({
|
||||||
|
...STREAMDOWN_COMPONENTS,
|
||||||
|
a: ({ href, children }: { href?: string; children?: ReactNode }) => {
|
||||||
|
const loc = onLocationClick ? parseLocationHref(href) : null;
|
||||||
|
if (loc && onLocationClick) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLocationClick(loc.file, loc.startLine, loc.endLine)}
|
||||||
|
className="font-mono text-[0.85em] text-[color:var(--ui-accent)] underline decoration-[color:var(--ui-accent)]/50 break-words [overflow-wrap:anywhere]"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="text-[color:var(--ui-accent)] underline decoration-[color:var(--ui-accent)]/50 break-words [overflow-wrap:anywhere]"
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[onLocationClick]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-w-0 max-w-full text-[13px] leading-6 break-words [overflow-wrap:anywhere] [&_.streamdown]:text-[color:var(--ui-text)]">
|
<div className="min-w-0 max-w-full text-[13px] leading-6 break-words [overflow-wrap:anywhere] [&_.streamdown]:text-[color:var(--ui-text)]">
|
||||||
<Streamdown
|
<Streamdown
|
||||||
|
|
@ -96,7 +128,7 @@ export const Markdown = memo(function Markdown({ content, isLive = false }: Mark
|
||||||
animated={isLive ? STREAMDOWN_ANIMATED : false}
|
animated={isLive ? STREAMDOWN_ANIMATED : false}
|
||||||
shikiTheme={SHIKI_THEME}
|
shikiTheme={SHIKI_THEME}
|
||||||
className="streamdown-agent min-w-0 max-w-full"
|
className="streamdown-agent min-w-0 max-w-full"
|
||||||
components={STREAMDOWN_COMPONENTS}
|
components={components}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Streamdown>
|
</Streamdown>
|
||||||
|
|
|
||||||
53
ui/src/components/agents/ported/markdownLocation.test.ts
Normal file
53
ui/src/components/agents/ported/markdownLocation.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { parseLocationHref } from "./markdownLocation";
|
||||||
|
|
||||||
|
describe("parseLocationHref", () => {
|
||||||
|
it("parses a path with a start-end range", () => {
|
||||||
|
expect(parseLocationHref("#loc=src/foo.rs:231-232")).toEqual({
|
||||||
|
file: "src/foo.rs",
|
||||||
|
startLine: 231,
|
||||||
|
endLine: 232,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses a single-line ref (no end)", () => {
|
||||||
|
expect(parseLocationHref("#loc=mod.rs:42")).toEqual({
|
||||||
|
file: "mod.rs",
|
||||||
|
startLine: 42,
|
||||||
|
endLine: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolerates an origin prefix the URL transform may prepend", () => {
|
||||||
|
expect(parseLocationHref("http://localhost/#loc=a/b.ts:5-9")).toEqual({
|
||||||
|
file: "a/b.ts",
|
||||||
|
startLine: 5,
|
||||||
|
endLine: 9,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes percent-encoded hrefs", () => {
|
||||||
|
expect(parseLocationHref("#loc=a%20b/c.ts:1-2")).toEqual({
|
||||||
|
file: "a b/c.ts",
|
||||||
|
startLine: 1,
|
||||||
|
endLine: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps an end below the start to a single line", () => {
|
||||||
|
expect(parseLocationHref("#loc=x.ts:10-3")).toEqual({
|
||||||
|
file: "x.ts",
|
||||||
|
startLine: 10,
|
||||||
|
endLine: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for non-location and malformed hrefs", () => {
|
||||||
|
expect(parseLocationHref(undefined)).toBeNull();
|
||||||
|
expect(parseLocationHref("https://example.com")).toBeNull();
|
||||||
|
expect(parseLocationHref("#loc=noline")).toBeNull();
|
||||||
|
expect(parseLocationHref("#loc=:5")).toBeNull();
|
||||||
|
expect(parseLocationHref("#loc=x.ts:abc")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
35
ui/src/components/agents/ported/markdownLocation.ts
Normal file
35
ui/src/components/agents/ported/markdownLocation.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
export interface MarkdownLocation {
|
||||||
|
file: string;
|
||||||
|
startLine: number;
|
||||||
|
endLine: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a `#loc=<path>:<start>[-<end>]` sentinel href emitted by the reviewer
|
||||||
|
* diff-grouping pass into a file + line range. Tolerant of an origin prefix the
|
||||||
|
* markdown URL transform may prepend, and of percent-encoding. Returns null for
|
||||||
|
* any href that isn't a location link.
|
||||||
|
*/
|
||||||
|
export function parseLocationHref(href?: string): MarkdownLocation | null {
|
||||||
|
if (!href) return null;
|
||||||
|
const marker = href.indexOf("#loc=");
|
||||||
|
if (marker === -1) return null;
|
||||||
|
let raw = href.slice(marker + "#loc=".length);
|
||||||
|
try {
|
||||||
|
raw = decodeURIComponent(raw);
|
||||||
|
} catch {
|
||||||
|
// Keep raw as-is if it isn't valid percent-encoding.
|
||||||
|
}
|
||||||
|
const colon = raw.lastIndexOf(":");
|
||||||
|
if (colon <= 0) return null;
|
||||||
|
const file = raw.slice(0, colon);
|
||||||
|
const [startStr, endStr] = raw.slice(colon + 1).split("-");
|
||||||
|
const start = Number.parseInt(startStr ?? "", 10);
|
||||||
|
if (!Number.isFinite(start) || start <= 0) return null;
|
||||||
|
const end = endStr !== undefined ? Number.parseInt(endStr, 10) : start;
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
startLine: start,
|
||||||
|
endLine: Number.isFinite(end) && end >= start ? end : start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -5,23 +5,29 @@
|
||||||
* cookie set by the OAuth callback rides along on cross-origin calls.
|
* cookie set by the OAuth callback rides along on cross-origin calls.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(/\/$/, "");
|
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(
|
||||||
|
/\/$/,
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
if (!API_BASE && typeof window !== "undefined") {
|
if (!API_BASE && typeof window !== "undefined") {
|
||||||
console.warn("VITE_DASHBOARD_API_BASE_URL is not set");
|
console.warn("VITE_DASHBOARD_API_BASE_URL is not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(public readonly status: number, message: string) {
|
constructor(
|
||||||
super(message);
|
public readonly status: number,
|
||||||
this.name = "ApiError";
|
message: string
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = "ApiError"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isGithubReauthError(error: unknown): boolean {
|
export function isGithubReauthError(error: unknown): boolean {
|
||||||
if (!(error instanceof ApiError)) return false;
|
if (!(error instanceof ApiError)) return false
|
||||||
if (error.status === 401) return true;
|
if (error.status === 401) return true
|
||||||
return /github token|re-login required/i.test(error.message);
|
return /github token|re-login required/i.test(error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
|
@ -32,364 +38,379 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(init.headers ?? {}),
|
...(init.headers ?? {}),
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let message = res.statusText;
|
let message = res.statusText
|
||||||
try {
|
try {
|
||||||
const body = await res.json();
|
const body = await res.json()
|
||||||
if (body?.detail) message = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail);
|
if (body?.detail)
|
||||||
|
message =
|
||||||
|
typeof body.detail === "string"
|
||||||
|
? body.detail
|
||||||
|
: JSON.stringify(body.detail)
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
throw new ApiError(res.status, message);
|
throw new ApiError(res.status, message)
|
||||||
}
|
}
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T
|
||||||
return (await res.json()) as T;
|
return (await res.json()) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionUser {
|
export interface SessionUser {
|
||||||
login: string;
|
login: string
|
||||||
email: string | null;
|
email: string | null
|
||||||
avatar_url: string | null;
|
avatar_url: string | null
|
||||||
is_admin: boolean;
|
is_admin: boolean
|
||||||
slack_oauth_enabled?: boolean;
|
slack_oauth_enabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelOption {
|
export interface ModelOption {
|
||||||
id: string;
|
id: string
|
||||||
label: string;
|
label: string
|
||||||
efforts: Array<string>;
|
efforts: Array<string>
|
||||||
default_effort: string;
|
default_effort: string
|
||||||
supports_images: boolean;
|
supports_images: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptionsPayload {
|
export interface OptionsPayload {
|
||||||
models: Array<ModelOption>;
|
models: Array<ModelOption>
|
||||||
default_agent_model: string;
|
default_agent_model: string
|
||||||
default_agent_reasoning_effort: string;
|
default_agent_reasoning_effort: string
|
||||||
default_agent_subagent_model: string;
|
default_agent_subagent_model: string
|
||||||
default_agent_subagent_reasoning_effort: string;
|
default_agent_subagent_reasoning_effort: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
login?: string;
|
login?: string
|
||||||
email?: string;
|
email?: string
|
||||||
default_model?: string;
|
default_model?: string
|
||||||
reasoning_effort?: string;
|
reasoning_effort?: string
|
||||||
default_subagent_model?: string | null;
|
default_subagent_model?: string | null
|
||||||
subagent_reasoning_effort?: string | null;
|
subagent_reasoning_effort?: string | null
|
||||||
default_repo?: string | null;
|
default_repo?: string | null
|
||||||
base_branch?: string | null;
|
base_branch?: string | null
|
||||||
branch_prefix?: string | null;
|
branch_prefix?: string | null
|
||||||
auto_fix_ci?: boolean;
|
auto_fix_ci?: boolean
|
||||||
create_prs?: boolean;
|
create_prs?: boolean
|
||||||
review_draft_prs?: boolean | null;
|
review_draft_prs?: boolean | null
|
||||||
updated_at?: string;
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileUpdate {
|
export interface ProfileUpdate {
|
||||||
default_model: string;
|
default_model: string
|
||||||
reasoning_effort: string;
|
reasoning_effort: string
|
||||||
default_subagent_model?: string | null;
|
default_subagent_model?: string | null
|
||||||
subagent_reasoning_effort?: string | null;
|
subagent_reasoning_effort?: string | null
|
||||||
default_repo?: string | null;
|
default_repo?: string | null
|
||||||
base_branch?: string | null;
|
base_branch?: string | null
|
||||||
branch_prefix?: string | null;
|
branch_prefix?: string | null
|
||||||
auto_fix_ci?: boolean;
|
auto_fix_ci?: boolean
|
||||||
create_prs?: boolean;
|
create_prs?: boolean
|
||||||
review_draft_prs?: boolean | null;
|
review_draft_prs?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TriggerMode = "every_push" | "once_per_pr" | "manual";
|
export type TriggerMode = "every_push" | "once_per_pr" | "manual"
|
||||||
export type AutofixMode = "off" | "low" | "medium" | "high";
|
export type AutofixMode = "off" | "low" | "medium" | "high"
|
||||||
|
|
||||||
export interface TeamSettings {
|
export interface TeamSettings {
|
||||||
trigger_mode: TriggerMode;
|
trigger_mode: TriggerMode
|
||||||
review_draft_prs: boolean;
|
review_draft_prs: boolean
|
||||||
pr_summaries: boolean;
|
pr_summaries: boolean
|
||||||
review_trace_links: boolean;
|
review_trace_links: boolean
|
||||||
autofix_mode: AutofixMode;
|
autofix_mode: AutofixMode
|
||||||
autofix_severity_threshold: AutofixMode;
|
autofix_severity_threshold: AutofixMode
|
||||||
org_guidelines?: string | null;
|
org_guidelines?: string | null
|
||||||
default_agent_model?: string | null;
|
default_agent_model?: string | null
|
||||||
default_agent_reasoning_effort?: string | null;
|
default_agent_reasoning_effort?: string | null
|
||||||
default_agent_subagent_model?: string | null;
|
default_agent_subagent_model?: string | null
|
||||||
default_agent_subagent_reasoning_effort?: string | null;
|
default_agent_subagent_reasoning_effort?: string | null
|
||||||
default_repo?: string | null;
|
default_repo?: string | null
|
||||||
default_reviewer_model?: string | null;
|
default_reviewer_model?: string | null
|
||||||
default_reviewer_reasoning_effort?: string | null;
|
default_reviewer_reasoning_effort?: string | null
|
||||||
default_reviewer_subagent_model?: string | null;
|
default_reviewer_subagent_model?: string | null
|
||||||
default_reviewer_subagent_reasoning_effort?: string | null;
|
default_reviewer_subagent_reasoning_effort?: string | null
|
||||||
default_chat_model?: string | null;
|
default_grouping_model?: string | null
|
||||||
default_chat_reasoning_effort?: string | null;
|
default_grouping_reasoning_effort?: string | null
|
||||||
updated_at?: string | null;
|
default_chat_model?: string | null
|
||||||
|
default_chat_reasoning_effort?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderCredentialStatus {
|
export interface ProviderCredentialStatus {
|
||||||
connected: boolean;
|
connected: boolean
|
||||||
site?: string;
|
site?: string
|
||||||
endpoint?: string;
|
endpoint?: string
|
||||||
api_key_last4?: string;
|
api_key_last4?: string
|
||||||
updated_at?: string | null;
|
updated_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamCredentialsStatus {
|
export interface TeamCredentialsStatus {
|
||||||
datadog: ProviderCredentialStatus;
|
datadog: ProviderCredentialStatus
|
||||||
langsmith: ProviderCredentialStatus;
|
langsmith: ProviderCredentialStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatadogConnectBody {
|
export interface DatadogConnectBody {
|
||||||
site: string;
|
site: string
|
||||||
api_key: string;
|
api_key: string
|
||||||
app_key: string;
|
app_key: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LangSmithConnectBody {
|
export interface LangSmithConnectBody {
|
||||||
api_key: string;
|
api_key: string
|
||||||
endpoint?: string | null;
|
endpoint?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserMapping {
|
export interface UserMapping {
|
||||||
github_login: string;
|
github_login: string
|
||||||
work_email: string;
|
work_email: string
|
||||||
slack_user_id?: string | null;
|
slack_user_id?: string | null
|
||||||
source?: string;
|
source?: string
|
||||||
status?: string;
|
status?: string
|
||||||
created_at?: string;
|
created_at?: string
|
||||||
updated_at?: string;
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserMappingsPage {
|
export interface UserMappingsPage {
|
||||||
items: Array<UserMapping>;
|
items: Array<UserMapping>
|
||||||
total: number;
|
total: number
|
||||||
page: number;
|
page: number
|
||||||
page_size: number;
|
page_size: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UsageLeaderboardPeriod = "7d" | "30d" | "all";
|
export type UsageLeaderboardPeriod = "7d" | "30d" | "all"
|
||||||
|
|
||||||
export interface UsageLeaderboardRow {
|
export interface UsageLeaderboardRow {
|
||||||
rank: number;
|
rank: number
|
||||||
user: {
|
user: {
|
||||||
name: string;
|
name: string
|
||||||
github_login: string | null;
|
github_login: string | null
|
||||||
email: string | null;
|
email: string | null
|
||||||
};
|
}
|
||||||
favorite_model: string;
|
favorite_model: string
|
||||||
agent_runs: number;
|
agent_runs: number
|
||||||
prs_opened: number;
|
prs_opened: number
|
||||||
merged_prs: number;
|
merged_prs: number
|
||||||
agent_loc: number;
|
agent_loc: number
|
||||||
additions: number;
|
additions: number
|
||||||
deletions: number;
|
deletions: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewerStatsCounterRow {
|
export interface ReviewerStatsCounterRow {
|
||||||
name: string;
|
name: string
|
||||||
count: number;
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewerStatsPayload {
|
export interface ReviewerStatsPayload {
|
||||||
period: UsageLeaderboardPeriod;
|
period: UsageLeaderboardPeriod
|
||||||
reviewed_prs: number;
|
reviewed_prs: number
|
||||||
prs_with_findings: number;
|
prs_with_findings: number
|
||||||
findings_recorded: number;
|
findings_recorded: number
|
||||||
surfaced_findings: number;
|
surfaced_findings: number
|
||||||
addressed_findings: number;
|
addressed_findings: number
|
||||||
resolved_after_update: number;
|
resolved_after_update: number
|
||||||
dismissed_findings: number;
|
dismissed_findings: number
|
||||||
unresolved_surfaced_findings: number;
|
unresolved_surfaced_findings: number
|
||||||
resolution_rate: number;
|
resolution_rate: number
|
||||||
human_replies: number;
|
human_replies: number
|
||||||
severity_counts: Record<string, number>;
|
severity_counts: Record<string, number>
|
||||||
top_categories: Array<ReviewerStatsCounterRow>;
|
top_categories: Array<ReviewerStatsCounterRow>
|
||||||
generated_at_ms: number | null;
|
generated_at_ms: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsageLeaderboardPayload {
|
export interface UsageLeaderboardPayload {
|
||||||
period: UsageLeaderboardPeriod;
|
period: UsageLeaderboardPeriod
|
||||||
rows: Array<UsageLeaderboardRow>;
|
rows: Array<UsageLeaderboardRow>
|
||||||
total_members: number;
|
total_members: number
|
||||||
current_user_rank: number | null;
|
current_user_rank: number | null
|
||||||
generated_at_ms: number | null;
|
generated_at_ms: number | null
|
||||||
reviewer_stats: ReviewerStatsPayload;
|
reviewer_stats: ReviewerStatsPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Repository {
|
export interface Repository {
|
||||||
full_name: string;
|
full_name: string
|
||||||
private: boolean;
|
private: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Installation {
|
export interface Installation {
|
||||||
id: number;
|
id: number
|
||||||
account: string | null;
|
account: string | null
|
||||||
account_type: string | null;
|
account_type: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReposPayload {
|
export interface ReposPayload {
|
||||||
installations: Array<Installation>;
|
installations: Array<Installation>
|
||||||
repositories: Array<Repository>;
|
repositories: Array<Repository>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReviewStyleStatus = "idle" | "running" | "completed" | "failed";
|
export type ReviewStyleStatus = "idle" | "running" | "completed" | "failed"
|
||||||
|
|
||||||
export interface ReviewStyle {
|
export interface ReviewStyle {
|
||||||
full_name: string;
|
full_name: string
|
||||||
owner?: string;
|
owner?: string
|
||||||
name?: string;
|
name?: string
|
||||||
status: ReviewStyleStatus;
|
status: ReviewStyleStatus
|
||||||
custom_prompt: string | null;
|
custom_prompt: string | null
|
||||||
analysis_summary: string | null;
|
analysis_summary: string | null
|
||||||
top_reviewers: Array<string>;
|
top_reviewers: Array<string>
|
||||||
prs_sampled: number;
|
prs_sampled: number
|
||||||
reviews_sampled: number;
|
reviews_sampled: number
|
||||||
analysis_thread_id: string | null;
|
analysis_thread_id: string | null
|
||||||
analysis_run_id: string | null;
|
analysis_run_id: string | null
|
||||||
error: string | null;
|
error: string | null
|
||||||
created_by?: string;
|
created_by?: string
|
||||||
created_at?: string;
|
created_at?: string
|
||||||
updated_at?: string;
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentInstructions {
|
export interface AgentInstructions {
|
||||||
full_name: string;
|
full_name: string
|
||||||
owner?: string;
|
owner?: string
|
||||||
name?: string;
|
name?: string
|
||||||
instructions: string;
|
instructions: string
|
||||||
created_by?: string;
|
created_by?: string
|
||||||
created_at?: string;
|
created_at?: string
|
||||||
updated_at?: string;
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FindingSeverity = "low" | "medium" | "high" | "critical";
|
export type FindingSeverity = "low" | "medium" | "high" | "critical"
|
||||||
export type FindingConfidence = "low" | "medium" | "high";
|
export type FindingConfidence = "low" | "medium" | "high"
|
||||||
export type FindingStatus = "open" | "resolved" | "dismissed";
|
export type FindingStatus = "open" | "resolved" | "dismissed"
|
||||||
export type FindingGroup = "bug" | "investigate" | "informational";
|
export type FindingGroup = "bug" | "investigate" | "informational"
|
||||||
|
|
||||||
export interface FindingInteraction {
|
export interface FindingInteraction {
|
||||||
kind: "human_reply" | "bot_reply";
|
kind: "human_reply" | "bot_reply"
|
||||||
author?: string;
|
author?: string
|
||||||
body?: string;
|
body?: string
|
||||||
created_at?: string;
|
created_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewFinding {
|
export interface ReviewFinding {
|
||||||
id: string;
|
id: string
|
||||||
severity: FindingSeverity;
|
severity: FindingSeverity
|
||||||
confidence: FindingConfidence;
|
confidence: FindingConfidence
|
||||||
category: string;
|
category: string
|
||||||
title: string;
|
title: string
|
||||||
description: string;
|
description: string
|
||||||
suggestion: string | null;
|
suggestion: string | null
|
||||||
file: string;
|
file: string
|
||||||
start_line: number | null;
|
start_line: number | null
|
||||||
end_line: number | null;
|
end_line: number | null
|
||||||
side: "LEFT" | "RIGHT";
|
side: "LEFT" | "RIGHT"
|
||||||
in_diff: boolean;
|
in_diff: boolean
|
||||||
status: FindingStatus;
|
status: FindingStatus
|
||||||
outdated: boolean;
|
outdated: boolean
|
||||||
resolution_note: string | null;
|
resolution_note: string | null
|
||||||
diff_hunk: string | null;
|
diff_hunk: string | null
|
||||||
github_thread_resolved: boolean;
|
github_thread_resolved: boolean
|
||||||
github_review_comment_id: number | null;
|
github_review_comment_id: number | null
|
||||||
interactions: Array<FindingInteraction>;
|
interactions: Array<FindingInteraction>
|
||||||
group: FindingGroup;
|
group: FindingGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewCounts {
|
export interface ReviewCounts {
|
||||||
open: number;
|
open: number
|
||||||
resolved: number;
|
resolved: number
|
||||||
dismissed: number;
|
dismissed: number
|
||||||
bugs: number;
|
bugs: number
|
||||||
flags: number;
|
flags: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewSummary {
|
export interface ReviewSummary {
|
||||||
thread_id: string;
|
thread_id: string
|
||||||
owner: string;
|
owner: string
|
||||||
repo: string;
|
repo: string
|
||||||
number: number;
|
number: number
|
||||||
title: string;
|
title: string
|
||||||
url: string;
|
url: string
|
||||||
head_ref: string;
|
head_ref: string
|
||||||
base_ref: string;
|
base_ref: string
|
||||||
author: string;
|
author: string
|
||||||
head_sha: string;
|
head_sha: string
|
||||||
watch: boolean;
|
watch: boolean
|
||||||
status: "running" | "error" | "idle";
|
status: "running" | "error" | "idle"
|
||||||
counts: ReviewCounts;
|
counts: ReviewCounts
|
||||||
updated_at: string | null;
|
updated_at: string | null
|
||||||
full_name?: string;
|
full_name?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewListPayload {
|
export interface ReviewListPayload {
|
||||||
reviews: Array<ReviewSummary>;
|
reviews: Array<ReviewSummary>
|
||||||
page: number;
|
page: number
|
||||||
has_more: boolean;
|
has_more: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewUserRef {
|
export interface ReviewUserRef {
|
||||||
login: string;
|
login: string
|
||||||
avatar_url?: string | null;
|
avatar_url?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewCheckRun {
|
export interface ReviewCheckRun {
|
||||||
name: string;
|
name: string
|
||||||
status: string;
|
status: string
|
||||||
conclusion: string | null;
|
conclusion: string | null
|
||||||
url: string | null;
|
url: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewPrDetails {
|
export interface ReviewPrDetails {
|
||||||
state: string;
|
state: string
|
||||||
title: string;
|
title: string
|
||||||
body: string;
|
body: string
|
||||||
additions: number;
|
additions: number
|
||||||
deletions: number;
|
deletions: number
|
||||||
changed_files: number;
|
changed_files: number
|
||||||
commits: number;
|
commits: number
|
||||||
head_sha: string;
|
head_sha: string
|
||||||
head_ref: string;
|
head_ref: string
|
||||||
base_ref: string;
|
base_ref: string
|
||||||
author: ReviewUserRef | null;
|
author: ReviewUserRef | null
|
||||||
assignees: Array<ReviewUserRef>;
|
assignees: Array<ReviewUserRef>
|
||||||
requested_reviewers: Array<ReviewUserRef>;
|
requested_reviewers: Array<ReviewUserRef>
|
||||||
labels: Array<{ name: string; color: string | null }>;
|
labels: Array<{ name: string; color: string | null }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewDiffGroup {
|
||||||
|
index: number
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
files: Array<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewDetail extends ReviewSummary {
|
export interface ReviewDetail extends ReviewSummary {
|
||||||
pr: ReviewPrDetails;
|
pr: ReviewPrDetails
|
||||||
checks: Array<ReviewCheckRun>;
|
checks: Array<ReviewCheckRun>
|
||||||
findings: Array<ReviewFinding>;
|
findings: Array<ReviewFinding>
|
||||||
|
diff_groups: Array<ReviewDiffGroup>
|
||||||
|
diff_groups_stale: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewDiffFile {
|
export interface ReviewDiffFile {
|
||||||
path: string;
|
path: string
|
||||||
previousPath: string | null;
|
previousPath: string | null
|
||||||
status: "added" | "removed" | "modified" | "renamed";
|
status: "added" | "removed" | "modified" | "renamed"
|
||||||
additions: number;
|
additions: number
|
||||||
deletions: number;
|
deletions: number
|
||||||
originalContent: string;
|
originalContent: string
|
||||||
modifiedContent: string;
|
modifiedContent: string
|
||||||
unrenderable?: boolean;
|
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;
|
truncated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewChatMeta {
|
export interface ReviewChatMeta {
|
||||||
available: boolean;
|
available: boolean
|
||||||
assistant_id: string;
|
assistant_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewChatThread {
|
export interface ReviewChatThread {
|
||||||
thread_id: string;
|
thread_id: string
|
||||||
title: string;
|
title: string
|
||||||
updated_at?: string | null;
|
updated_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -400,53 +421,53 @@ export interface ReviewChatThread {
|
||||||
export function reviewChatApiBase(
|
export function reviewChatApiBase(
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
number: number,
|
number: number
|
||||||
): string {
|
): string {
|
||||||
const path = `${API_BASE}/dashboard/api/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`;
|
const path = `${API_BASE}/dashboard/api/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`
|
||||||
if (/^https?:\/\//.test(path)) return path;
|
if (/^https?:\/\//.test(path)) return path
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
return `${window.location.origin}${path.startsWith("/") ? "" : "/"}${path}`;
|
return `${window.location.origin}${path.startsWith("/") ? "" : "/"}${path}`
|
||||||
}
|
}
|
||||||
return path;
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReviewerEvalScoreMode = "all_findings" | "surfaced_findings";
|
export type ReviewerEvalScoreMode = "all_findings" | "surfaced_findings"
|
||||||
export type ReviewerEvalSeverity = "low" | "medium" | "high" | "critical";
|
export type ReviewerEvalSeverity = "low" | "medium" | "high" | "critical"
|
||||||
|
|
||||||
export interface ReviewerEvalConfig {
|
export interface ReviewerEvalConfig {
|
||||||
dataset_name: string;
|
dataset_name: string
|
||||||
experiment_prefix: string;
|
experiment_prefix: string
|
||||||
max_concurrency: number;
|
max_concurrency: number
|
||||||
langsmith_project: string;
|
langsmith_project: string
|
||||||
langgraph_url: string;
|
langgraph_url: string
|
||||||
assistant_id: string;
|
assistant_id: string
|
||||||
model_id: string;
|
model_id: string
|
||||||
reasoning_effort: string;
|
reasoning_effort: string
|
||||||
score_mode: ReviewerEvalScoreMode;
|
score_mode: ReviewerEvalScoreMode
|
||||||
severity_threshold: ReviewerEvalSeverity;
|
severity_threshold: ReviewerEvalSeverity
|
||||||
cap: number;
|
cap: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewerEvalStartRequest extends ReviewerEvalConfig {
|
export interface ReviewerEvalStartRequest extends ReviewerEvalConfig {
|
||||||
limit: number | null;
|
limit: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewerEvalStatus {
|
export interface ReviewerEvalStatus {
|
||||||
name: string;
|
name: string
|
||||||
status: "idle" | "running" | "completed" | "failed";
|
status: "idle" | "running" | "completed" | "failed"
|
||||||
run_name?: string;
|
run_name?: string
|
||||||
langsmith_project: string;
|
langsmith_project: string
|
||||||
limit: number | null;
|
limit: number | null
|
||||||
config_snapshot?: ReviewerEvalConfig;
|
config_snapshot?: ReviewerEvalConfig
|
||||||
started_at: string | null;
|
started_at: string | null
|
||||||
finished_at: string | null;
|
finished_at: string | null
|
||||||
created_by: string | null;
|
created_by: string | null
|
||||||
pid: number | null;
|
pid: number | null
|
||||||
exit_code: number | null;
|
exit_code: number | null
|
||||||
experiment_url: string | null;
|
experiment_url: string | null
|
||||||
error: string | null;
|
error: string | null
|
||||||
log_tail: string | null;
|
log_tail: string | null
|
||||||
updated_at: string;
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
|
@ -470,37 +491,52 @@ export const api = {
|
||||||
body: JSON.stringify({ custom_prompt }),
|
body: JSON.stringify({ custom_prompt }),
|
||||||
}),
|
}),
|
||||||
analyzeReviewStyle: (full_name: string) =>
|
analyzeReviewStyle: (full_name: string) =>
|
||||||
request<ReviewStyle>(`/review-styles/${encodeURIComponent(full_name)}/analyze`, {
|
request<ReviewStyle>(
|
||||||
method: "POST",
|
`/review-styles/${encodeURIComponent(full_name)}/analyze`,
|
||||||
}),
|
{
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
),
|
||||||
cancelReviewStyle: (full_name: string) =>
|
cancelReviewStyle: (full_name: string) =>
|
||||||
request<ReviewStyle>(`/review-styles/${encodeURIComponent(full_name)}/cancel`, {
|
request<ReviewStyle>(
|
||||||
method: "POST",
|
`/review-styles/${encodeURIComponent(full_name)}/cancel`,
|
||||||
}),
|
{
|
||||||
|
method: "POST",
|
||||||
|
}
|
||||||
|
),
|
||||||
deleteReviewStyle: (full_name: string) =>
|
deleteReviewStyle: (full_name: string) =>
|
||||||
request<void>(`/review-styles/${encodeURIComponent(full_name)}`, {
|
request<void>(`/review-styles/${encodeURIComponent(full_name)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}),
|
}),
|
||||||
listAgentInstructions: () => request<Array<AgentInstructions>>("/agent-instructions"),
|
listAgentInstructions: () =>
|
||||||
|
request<Array<AgentInstructions>>("/agent-instructions"),
|
||||||
createAgentInstructions: (full_name: string) =>
|
createAgentInstructions: (full_name: string) =>
|
||||||
request<AgentInstructions>("/agent-instructions", {
|
request<AgentInstructions>("/agent-instructions", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ full_name }),
|
body: JSON.stringify({ full_name }),
|
||||||
}),
|
}),
|
||||||
getAgentInstructions: (full_name: string) =>
|
getAgentInstructions: (full_name: string) =>
|
||||||
request<AgentInstructions>(`/agent-instructions/${encodeURIComponent(full_name)}`),
|
request<AgentInstructions>(
|
||||||
|
`/agent-instructions/${encodeURIComponent(full_name)}`
|
||||||
|
),
|
||||||
saveAgentInstructions: (full_name: string, instructions: string) =>
|
saveAgentInstructions: (full_name: string, instructions: string) =>
|
||||||
request<AgentInstructions>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
|
request<AgentInstructions>(
|
||||||
method: "PUT",
|
`/agent-instructions/${encodeURIComponent(full_name)}`,
|
||||||
body: JSON.stringify({ instructions }),
|
{
|
||||||
}),
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ instructions }),
|
||||||
|
}
|
||||||
|
),
|
||||||
deleteAgentInstructions: (full_name: string) =>
|
deleteAgentInstructions: (full_name: string) =>
|
||||||
request<void>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
|
request<void>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
}),
|
}),
|
||||||
getTeamSettings: () => request<TeamSettings>("/team-settings"),
|
getTeamSettings: () => request<TeamSettings>("/team-settings"),
|
||||||
saveTeamSettings: (body: TeamSettings) =>
|
saveTeamSettings: (body: TeamSettings) =>
|
||||||
request<TeamSettings>("/team-settings", { method: "PUT", body: JSON.stringify(body) }),
|
request<TeamSettings>("/team-settings", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
getTeamCredentials: () => request<TeamCredentialsStatus>("/team-credentials"),
|
getTeamCredentials: () => request<TeamCredentialsStatus>("/team-credentials"),
|
||||||
connectDatadog: (body: DatadogConnectBody) =>
|
connectDatadog: (body: DatadogConnectBody) =>
|
||||||
request<TeamCredentialsStatus>("/team-credentials/datadog", {
|
request<TeamCredentialsStatus>("/team-credentials/datadog", {
|
||||||
|
|
@ -508,14 +544,18 @@ export const api = {
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
disconnectDatadog: () =>
|
disconnectDatadog: () =>
|
||||||
request<TeamCredentialsStatus>("/team-credentials/datadog", { method: "DELETE" }),
|
request<TeamCredentialsStatus>("/team-credentials/datadog", {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
connectLangSmith: (body: LangSmithConnectBody) =>
|
connectLangSmith: (body: LangSmithConnectBody) =>
|
||||||
request<TeamCredentialsStatus>("/team-credentials/langsmith", {
|
request<TeamCredentialsStatus>("/team-credentials/langsmith", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
disconnectLangSmith: () =>
|
disconnectLangSmith: () =>
|
||||||
request<TeamCredentialsStatus>("/team-credentials/langsmith", { method: "DELETE" }),
|
request<TeamCredentialsStatus>("/team-credentials/langsmith", {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
listEnabledReviewRepos: () =>
|
listEnabledReviewRepos: () =>
|
||||||
request<{ repos: Array<string> }>("/enabled-review-repos"),
|
request<{ repos: Array<string> }>("/enabled-review-repos"),
|
||||||
setEnabledReviewRepo: (full_name: string, enabled: boolean) =>
|
setEnabledReviewRepo: (full_name: string, enabled: boolean) =>
|
||||||
|
|
@ -525,58 +565,57 @@ export const api = {
|
||||||
}),
|
}),
|
||||||
usageLeaderboard: (period: UsageLeaderboardPeriod = "30d", limit = 10) =>
|
usageLeaderboard: (period: UsageLeaderboardPeriod = "30d", limit = 10) =>
|
||||||
request<UsageLeaderboardPayload>(
|
request<UsageLeaderboardPayload>(
|
||||||
`/agent-usage-leaderboard?period=${encodeURIComponent(period)}&limit=${limit}`,
|
`/agent-usage-leaderboard?period=${encodeURIComponent(period)}&limit=${limit}`
|
||||||
),
|
),
|
||||||
myMapping: () => request<Partial<UserMapping>>("/my-mapping"),
|
myMapping: () => request<Partial<UserMapping>>("/my-mapping"),
|
||||||
adminListUserMappings: (page = 1, pageSize = 20) =>
|
adminListUserMappings: (page = 1, pageSize = 20) =>
|
||||||
request<UserMappingsPage>(
|
request<UserMappingsPage>(
|
||||||
`/admin/user-mappings?page=${page}&page_size=${pageSize}`,
|
`/admin/user-mappings?page=${page}&page_size=${pageSize}`
|
||||||
),
|
),
|
||||||
adminDeleteUserMapping: (github_login: string) =>
|
adminDeleteUserMapping: (github_login: string) =>
|
||||||
request<{ deleted: boolean }>(
|
request<{ deleted: boolean }>(
|
||||||
`/admin/user-mappings/${encodeURIComponent(github_login)}`,
|
`/admin/user-mappings/${encodeURIComponent(github_login)}`,
|
||||||
{ method: "DELETE" },
|
{ method: "DELETE" }
|
||||||
),
|
),
|
||||||
listReviews: (page: number, mine: boolean) =>
|
listReviews: (page: number, mine: boolean) =>
|
||||||
request<ReviewListPayload>(`/reviews?page=${page}&mine=${mine}`),
|
request<ReviewListPayload>(`/reviews?page=${page}&mine=${mine}`),
|
||||||
getReview: (owner: string, repo: string, number: number) =>
|
getReview: (owner: string, repo: string, number: number) =>
|
||||||
request<ReviewDetail>(
|
request<ReviewDetail>(
|
||||||
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}`,
|
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}`
|
||||||
),
|
),
|
||||||
getReviewDiff: (owner: string, repo: string, number: number) =>
|
getReviewDiff: (owner: string, repo: string, number: number) =>
|
||||||
request<ReviewDiffPayload>(
|
request<ReviewDiffPayload>(
|
||||||
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/diff`,
|
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/diff`
|
||||||
),
|
),
|
||||||
getReviewChat: (owner: string, repo: string, number: number) =>
|
getReviewChat: (owner: string, repo: string, number: number) =>
|
||||||
request<ReviewChatMeta>(
|
request<ReviewChatMeta>(
|
||||||
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`,
|
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`
|
||||||
),
|
),
|
||||||
listReviewChatThreads: (owner: string, repo: string, number: number) =>
|
listReviewChatThreads: (owner: string, repo: string, number: number) =>
|
||||||
request<{ threads: Array<ReviewChatThread> }>(
|
request<{ threads: Array<ReviewChatThread> }>(
|
||||||
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads`,
|
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads`
|
||||||
),
|
),
|
||||||
deleteReviewChatThread: (
|
deleteReviewChatThread: (
|
||||||
owner: string,
|
owner: string,
|
||||||
repo: string,
|
repo: string,
|
||||||
number: number,
|
number: number,
|
||||||
threadId: string,
|
threadId: string
|
||||||
) =>
|
) =>
|
||||||
request<void>(
|
request<void>(
|
||||||
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads/${encodeURIComponent(threadId)}`,
|
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads/${encodeURIComponent(threadId)}`,
|
||||||
{ method: "DELETE" },
|
{ method: "DELETE" }
|
||||||
),
|
),
|
||||||
reReview: (owner: string, repo: string, number: number) =>
|
reReview: (owner: string, repo: string, number: number) =>
|
||||||
request<{
|
request<{
|
||||||
success: boolean;
|
success: boolean
|
||||||
queued: boolean;
|
queued: boolean
|
||||||
thread_id: string;
|
thread_id: string
|
||||||
pr_url: string;
|
pr_url: string
|
||||||
}>(
|
}>(
|
||||||
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/re-review`,
|
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/re-review`,
|
||||||
{ method: "POST" },
|
{ method: "POST" }
|
||||||
),
|
),
|
||||||
getReviewerEval: () =>
|
getReviewerEval: () => request<ReviewerEvalStatus>("/admin/evals/reviewer"),
|
||||||
request<ReviewerEvalStatus>("/admin/evals/reviewer"),
|
|
||||||
startReviewerEval: (body: ReviewerEvalStartRequest) =>
|
startReviewerEval: (body: ReviewerEvalStartRequest) =>
|
||||||
request<ReviewerEvalStatus>("/admin/evals/reviewer", {
|
request<ReviewerEvalStatus>("/admin/evals/reviewer", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -585,14 +624,15 @@ export const api = {
|
||||||
cancelReviewerEval: () =>
|
cancelReviewerEval: () =>
|
||||||
request<ReviewerEvalStatus>("/admin/evals/reviewer", { method: "DELETE" }),
|
request<ReviewerEvalStatus>("/admin/evals/reviewer", { method: "DELETE" }),
|
||||||
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
logout: () => request<void>("/auth/logout", { method: "POST" }),
|
||||||
};
|
}
|
||||||
|
|
||||||
export function loginUrl(redirectTo?: string): string {
|
export function loginUrl(redirectTo?: string): string {
|
||||||
const target = redirectTo ?? (typeof window !== "undefined" ? window.location.origin : "");
|
const target =
|
||||||
const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : "";
|
redirectTo ?? (typeof window !== "undefined" ? window.location.origin : "")
|
||||||
return `${API_BASE}/dashboard/api/auth/login${qs}`;
|
const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : ""
|
||||||
|
return `${API_BASE}/dashboard/api/auth/login${qs}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function slackConnectUrl(): string {
|
export function slackConnectUrl(): string {
|
||||||
return `${API_BASE}/dashboard/api/slack/login`;
|
return `${API_BASE}/dashboard/api/slack/login`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,12 @@ function AdminPage() {
|
||||||
className="flex items-center justify-between gap-6 px-4 py-3 hover:bg-muted/40"
|
className="flex items-center justify-between gap-6 px-4 py-3 hover:bg-muted/40"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="flex flex-col gap-0.5">
|
||||||
<span className="text-xs font-medium text-foreground">Reviewer eval</span>
|
<span className="text-xs font-medium text-foreground">
|
||||||
|
Reviewer eval
|
||||||
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Run the offline reviewer benchmark and watch its output stream live.
|
Run the offline reviewer benchmark and watch its output stream
|
||||||
|
live.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CaretRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
<CaretRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
|
@ -559,6 +562,31 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
|
||||||
}
|
}
|
||||||
disabled={!settings.data || save.isPending}
|
disabled={!settings.data || save.isPending}
|
||||||
/>
|
/>
|
||||||
|
<RolePicker
|
||||||
|
label="Open SWE Review Diff Grouping"
|
||||||
|
description="Model used for the review's 'AI sorted' view that groups changed files into a logical walkthrough. Inherits the Reviewer subagent default when unset."
|
||||||
|
models={models}
|
||||||
|
model={settings.data?.default_grouping_model ?? null}
|
||||||
|
effort={settings.data?.default_grouping_reasoning_effort ?? null}
|
||||||
|
inheritLabel="Reviewer subagent default"
|
||||||
|
onInherit={() =>
|
||||||
|
settings.data &&
|
||||||
|
save.mutate({
|
||||||
|
...settings.data,
|
||||||
|
default_grouping_model: null,
|
||||||
|
default_grouping_reasoning_effort: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onChange={(model, effort) =>
|
||||||
|
settings.data &&
|
||||||
|
save.mutate({
|
||||||
|
...settings.data,
|
||||||
|
default_grouping_model: model,
|
||||||
|
default_grouping_reasoning_effort: effort,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!settings.data || save.isPending}
|
||||||
|
/>
|
||||||
<RolePicker
|
<RolePicker
|
||||||
label="Open SWE Review Chat"
|
label="Open SWE Review Chat"
|
||||||
description="Model used by the 'chat with this PR' assistant on the review page. Inherits the Agent default when unset."
|
description="Model used by the 'chat with this PR' assistant on the review page. Inherits the Agent default when unset."
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ import type {
|
||||||
ReviewFinding,
|
ReviewFinding,
|
||||||
ReviewUserRef,
|
ReviewUserRef,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
|
import type {
|
||||||
|
ReviewSidebarGroup,
|
||||||
|
ReviewSidebarView,
|
||||||
|
} from "@/components/agents/ReviewSidebar"
|
||||||
import { Markdown } from "@/components/agents/ported"
|
import { Markdown } from "@/components/agents/ported"
|
||||||
import { ReviewChat } from "@/components/agents/ReviewChat"
|
import { ReviewChat } from "@/components/agents/ReviewChat"
|
||||||
import { useRegisterReviewSidebar } from "@/components/agents/ReviewSidebar"
|
import { useRegisterReviewSidebar } from "@/components/agents/ReviewSidebar"
|
||||||
|
|
@ -52,6 +56,17 @@ export const Route = createFileRoute("/agents/reviews/$owner/$repo/$number")({
|
||||||
|
|
||||||
type SideTab = "info" | "chat"
|
type SideTab = "info" | "chat"
|
||||||
|
|
||||||
|
const REVIEW_VIEW_STORAGE_KEY = "open-swe.review.view"
|
||||||
|
|
||||||
|
interface ResolvedGroup {
|
||||||
|
index: number
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
files: Array<ReviewDiffFile>
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
}
|
||||||
|
|
||||||
const GROUP_STYLES = {
|
const GROUP_STYLES = {
|
||||||
bug: { label: "Bug", className: "text-destructive", Icon: BugBeetleIcon },
|
bug: { label: "Bug", className: "text-destructive", Icon: BugBeetleIcon },
|
||||||
investigate: {
|
investigate: {
|
||||||
|
|
@ -203,6 +218,12 @@ function ReviewBody({
|
||||||
const [focused, setFocused] = useState<ReviewFinding | null>(null)
|
const [focused, setFocused] = useState<ReviewFinding | null>(null)
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const groupRefs = useRef<Record<number, HTMLDivElement | null>>({})
|
||||||
|
const [selectedRange, setSelectedRange] = useState<{
|
||||||
|
file: string
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void warmDiffHighlighter()
|
void warmDiffHighlighter()
|
||||||
|
|
@ -282,6 +303,85 @@ function ReviewBody({
|
||||||
.reduce((acc, file) => acc + file.additions + file.deletions, 0)
|
.reduce((acc, file) => acc + file.additions + file.deletions, 0)
|
||||||
}, [diffFiles, viewed])
|
}, [diffFiles, viewed])
|
||||||
|
|
||||||
|
// Resolve the AI-sorted groups against the actual diff: drop stale groups
|
||||||
|
// (generated for a previous head) so the file-tree fallback is used, drop
|
||||||
|
// paths no longer in the diff and empty groups, and collect any unassigned
|
||||||
|
// files into a trailing "Other changes" group so nothing ever disappears.
|
||||||
|
const groupedView = useMemo<Array<ResolvedGroup> | null>(() => {
|
||||||
|
if (
|
||||||
|
!diffFiles ||
|
||||||
|
detail.diff_groups_stale ||
|
||||||
|
detail.diff_groups.length === 0
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
const byPath = new Map(diffFiles.map((file) => [file.path, file]))
|
||||||
|
const assigned = new Set<string>()
|
||||||
|
const resolved: Array<Omit<ResolvedGroup, "index">> = []
|
||||||
|
for (const group of detail.diff_groups) {
|
||||||
|
const files: Array<ReviewDiffFile> = []
|
||||||
|
for (const path of group.files) {
|
||||||
|
const file = byPath.get(path)
|
||||||
|
if (file && !assigned.has(path)) {
|
||||||
|
assigned.add(path)
|
||||||
|
files.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (files.length === 0) continue
|
||||||
|
resolved.push({
|
||||||
|
title: group.title,
|
||||||
|
summary: group.summary,
|
||||||
|
files,
|
||||||
|
additions: files.reduce((acc, file) => acc + file.additions, 0),
|
||||||
|
deletions: files.reduce((acc, file) => acc + file.deletions, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const leftover = diffFiles.filter((file) => !assigned.has(file.path))
|
||||||
|
if (leftover.length > 0) {
|
||||||
|
resolved.push({
|
||||||
|
title: "Other changes",
|
||||||
|
summary: "",
|
||||||
|
files: leftover,
|
||||||
|
additions: leftover.reduce((acc, file) => acc + file.additions, 0),
|
||||||
|
deletions: leftover.reduce((acc, file) => acc + file.deletions, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (resolved.length === 0) return null
|
||||||
|
return resolved.map((group, i) => ({ ...group, index: i + 1 }))
|
||||||
|
}, [diffFiles, detail.diff_groups, detail.diff_groups_stale])
|
||||||
|
|
||||||
|
const sidebarGroups = useMemo<Array<ReviewSidebarGroup> | null>(() => {
|
||||||
|
if (!groupedView) return null
|
||||||
|
return groupedView.map((group) => ({
|
||||||
|
index: group.index,
|
||||||
|
title: group.title,
|
||||||
|
summary: group.summary,
|
||||||
|
additions: group.additions,
|
||||||
|
deletions: group.deletions,
|
||||||
|
fileCount: group.files.length,
|
||||||
|
files: group.files.map((file) => file.path),
|
||||||
|
}))
|
||||||
|
}, [groupedView])
|
||||||
|
|
||||||
|
// The view follows fresh-group availability until the user explicitly picks
|
||||||
|
// one, after which the choice persists across PRs.
|
||||||
|
const hasFreshGroups =
|
||||||
|
detail.diff_groups.length > 0 && !detail.diff_groups_stale
|
||||||
|
const [explicitView, setExplicitView] = useState<ReviewSidebarView | null>(
|
||||||
|
() => {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
const stored = window.localStorage.getItem(REVIEW_VIEW_STORAGE_KEY)
|
||||||
|
return stored === "ai" || stored === "files" ? stored : null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const view: ReviewSidebarView =
|
||||||
|
explicitView ?? (hasFreshGroups ? "ai" : "files")
|
||||||
|
const setView = useCallback((next: ReviewSidebarView) => {
|
||||||
|
setExplicitView(next)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.localStorage.setItem(REVIEW_VIEW_STORAGE_KEY, next)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const scrollToFile = useCallback((path: string) => {
|
const scrollToFile = useCallback((path: string) => {
|
||||||
setSelectedFile(path)
|
setSelectedFile(path)
|
||||||
setExpandedFiles((prev) => ({ ...prev, [path]: true }))
|
setExpandedFiles((prev) => ({ ...prev, [path]: true }))
|
||||||
|
|
@ -293,6 +393,30 @@ function ReviewBody({
|
||||||
})
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const scrollToGroup = useCallback((index: number) => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
groupRefs.current[index]?.scrollIntoView({
|
||||||
|
block: "start",
|
||||||
|
behavior: "smooth",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const scrollToLineRange = useCallback(
|
||||||
|
(file: string, start: number, end: number) => {
|
||||||
|
setSelectedFile(file)
|
||||||
|
setSelectedRange({ file, start, end })
|
||||||
|
setExpandedFiles((prev) => ({ ...prev, [file]: true }))
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
fileRefs.current[file]?.scrollIntoView({
|
||||||
|
block: "start",
|
||||||
|
behavior: "smooth",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
const openFinding = useCallback(
|
const openFinding = useCallback(
|
||||||
(finding: ReviewFinding) => {
|
(finding: ReviewFinding) => {
|
||||||
markRead(finding.id)
|
markRead(finding.id)
|
||||||
|
|
@ -314,6 +438,39 @@ function ReviewBody({
|
||||||
|
|
||||||
const closeFinding = useCallback(() => setFocused(null), [])
|
const closeFinding = useCallback(() => setFocused(null), [])
|
||||||
|
|
||||||
|
const renderFileCard = (file: ReviewDiffFile) => (
|
||||||
|
<FileDiffCard
|
||||||
|
key={file.path}
|
||||||
|
file={file}
|
||||||
|
findings={findingsByFile.get(file.path) ?? []}
|
||||||
|
focused={focused}
|
||||||
|
selectedRange={selectedRange}
|
||||||
|
viewed={viewed.has(file.path)}
|
||||||
|
onToggleViewed={() => {
|
||||||
|
const becomingViewed = !viewed.has(file.path)
|
||||||
|
if (becomingViewed && focused?.file === file.path) setFocused(null)
|
||||||
|
toggleViewed(file.path)
|
||||||
|
setExpandedFiles((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[file.path]: !becomingViewed,
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
expanded={expandedFiles[file.path] ?? !viewed.has(file.path)}
|
||||||
|
onToggleExpanded={() => {
|
||||||
|
const next = !(expandedFiles[file.path] ?? !viewed.has(file.path))
|
||||||
|
if (!next && focused?.file === file.path) setFocused(null)
|
||||||
|
setExpandedFiles((prev) => ({ ...prev, [file.path]: next }))
|
||||||
|
}}
|
||||||
|
onFindingClick={openFinding}
|
||||||
|
sectionRef={(node) => {
|
||||||
|
fileRefs.current[file.path] = node
|
||||||
|
}}
|
||||||
|
anchorRef={(id, node) => {
|
||||||
|
anchorRefs.current[id] = node
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
const sidebarData = useMemo(
|
const sidebarData = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
title: `PR #${detail.number}`,
|
title: `PR #${detail.number}`,
|
||||||
|
|
@ -321,8 +478,24 @@ function ReviewBody({
|
||||||
selected: selectedFile,
|
selected: selectedFile,
|
||||||
viewed,
|
viewed,
|
||||||
onSelect: scrollToFile,
|
onSelect: scrollToFile,
|
||||||
|
groups: sidebarGroups,
|
||||||
|
view,
|
||||||
|
onViewChange: setView,
|
||||||
|
onSelectGroup: scrollToGroup,
|
||||||
|
onLocationClick: scrollToLineRange,
|
||||||
}),
|
}),
|
||||||
[detail.number, diffFiles, selectedFile, viewed, scrollToFile]
|
[
|
||||||
|
detail.number,
|
||||||
|
diffFiles,
|
||||||
|
selectedFile,
|
||||||
|
viewed,
|
||||||
|
scrollToFile,
|
||||||
|
sidebarGroups,
|
||||||
|
view,
|
||||||
|
setView,
|
||||||
|
scrollToGroup,
|
||||||
|
scrollToLineRange,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
useRegisterReviewSidebar(sidebarData)
|
useRegisterReviewSidebar(sidebarData)
|
||||||
|
|
||||||
|
|
@ -380,48 +553,23 @@ function ReviewBody({
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
No diff available.
|
No diff available.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : view === "ai" && groupedView ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-6">
|
||||||
{diffFiles.map((file) => (
|
{groupedView.map((group) => (
|
||||||
<FileDiffCard
|
<div
|
||||||
key={file.path}
|
key={group.index}
|
||||||
file={file}
|
ref={(node) => {
|
||||||
findings={findingsByFile.get(file.path) ?? []}
|
groupRefs.current[group.index] = node
|
||||||
focused={focused}
|
|
||||||
viewed={viewed.has(file.path)}
|
|
||||||
onToggleViewed={() => {
|
|
||||||
const becomingViewed = !viewed.has(file.path)
|
|
||||||
if (becomingViewed && focused?.file === file.path)
|
|
||||||
setFocused(null)
|
|
||||||
toggleViewed(file.path)
|
|
||||||
setExpandedFiles((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[file.path]: !becomingViewed,
|
|
||||||
}))
|
|
||||||
}}
|
}}
|
||||||
expanded={
|
className="scroll-mt-4 space-y-3"
|
||||||
expandedFiles[file.path] ?? !viewed.has(file.path)
|
>
|
||||||
}
|
<GroupHeader group={group} />
|
||||||
onToggleExpanded={() => {
|
{group.files.map(renderFileCard)}
|
||||||
const next = !(
|
</div>
|
||||||
expandedFiles[file.path] ?? !viewed.has(file.path)
|
|
||||||
)
|
|
||||||
if (!next && focused?.file === file.path) setFocused(null)
|
|
||||||
setExpandedFiles((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[file.path]: next,
|
|
||||||
}))
|
|
||||||
}}
|
|
||||||
onFindingClick={openFinding}
|
|
||||||
sectionRef={(node) => {
|
|
||||||
fileRefs.current[file.path] = node
|
|
||||||
}}
|
|
||||||
anchorRef={(id, node) => {
|
|
||||||
anchorRefs.current[id] = node
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">{diffFiles.map(renderFileCard)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -515,10 +663,30 @@ function PrHeader({ detail }: { detail: ReviewDetail }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GroupHeader({ group }: { group: ResolvedGroup }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex size-5 shrink-0 items-center justify-center rounded bg-[var(--ui-panel-2)] text-[11px] font-medium text-muted-foreground">
|
||||||
|
{group.index}
|
||||||
|
</span>
|
||||||
|
<h3 className="min-w-0 truncate text-sm font-medium">{group.title}</h3>
|
||||||
|
<span className="flex shrink-0 items-center gap-1.5 font-mono text-[11px]">
|
||||||
|
{group.additions > 0 && (
|
||||||
|
<span className="text-emerald-500">+{group.additions}</span>
|
||||||
|
)}
|
||||||
|
{group.deletions > 0 && (
|
||||||
|
<span className="text-red-500">-{group.deletions}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function FileDiffCard({
|
function FileDiffCard({
|
||||||
file,
|
file,
|
||||||
findings,
|
findings,
|
||||||
focused,
|
focused,
|
||||||
|
selectedRange,
|
||||||
viewed,
|
viewed,
|
||||||
onToggleViewed,
|
onToggleViewed,
|
||||||
expanded,
|
expanded,
|
||||||
|
|
@ -530,6 +698,7 @@ function FileDiffCard({
|
||||||
file: ReviewDiffFile
|
file: ReviewDiffFile
|
||||||
findings: Array<ReviewFinding>
|
findings: Array<ReviewFinding>
|
||||||
focused: ReviewFinding | null
|
focused: ReviewFinding | null
|
||||||
|
selectedRange: { file: string; start: number; end: number } | null
|
||||||
viewed: boolean
|
viewed: boolean
|
||||||
onToggleViewed: () => void
|
onToggleViewed: () => void
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
|
|
@ -552,10 +721,20 @@ function FileDiffCard({
|
||||||
[findings]
|
[findings]
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedLines =
|
const selectedLines = useMemo<SelectedLineRange | null>(() => {
|
||||||
focused?.file === file.path && isAnchored(focused)
|
if (focused?.file === file.path && isAnchored(focused)) {
|
||||||
? findingSelectedRange(focused)
|
return findingSelectedRange(focused)
|
||||||
: null
|
}
|
||||||
|
if (selectedRange?.file === file.path) {
|
||||||
|
return {
|
||||||
|
start: selectedRange.start,
|
||||||
|
end: selectedRange.end,
|
||||||
|
side: "additions",
|
||||||
|
endSide: "additions",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [focused, selectedRange, file.path])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue