feat: chat with your PR on the review page (#1534)

* feat: chat with your PR on the review page

Add a sandbox-less `chat` graph that answers questions about a single PR
from its diff, the published review findings, and read-only GitHub access.

- agent/chat.py: deepagents graph, no sandbox (default StateBackend, file
  mutation + execute tools excluded). PR context is seeded as virtual files
  under /pr/; a repo-scoped App token is resolved in-graph.
- tools: read_repo_file, search_repo_code, list_review_findings.
- dashboard/review_chat_api.py + routes: per-user chat thread, LangGraph
  stream/commands/state/history proxy pinned to the chat assistant, seeds
  diff/findings/overview on first run. Gated by repo access.
- UI: Chat tab wired to a chat-scoped StreamProvider (replaces Coming Soon).

* feat: admin setting for review-chat default model

Add a 'Open SWE Review Chat' default to team settings (default_chat_model /
default_chat_reasoning_effort). get_team_default_model("chat") inherits the
Agent default when unset; the chat graph resolves through it. Admin RolePicker
gains an 'Agent default' inherit option that clears the override.

* feat: multi-conversation review chat (tabs, new chat, history)

Replace the single per-PR chat thread with multiple per-user conversations:
- threads minted client-side; first message persists with a title derived
  from the prompt.
- list + delete endpoints; chat panel gets a tab strip (history), new-chat
  (+), close (x), refresh, an intro greeting, and suggested prompts.
- get_review_chat now returns availability only (ids are client-minted).

* ui fixes

* ui: review-chat history dropdown, full-width AI replies, resizable side panel

* fix(review-chat): enforce per-user thread ownership on proxy endpoints; reseed PR context on head change

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-15 17:17:30 -07:00 • committed by GitHub
parent 698613e6db
commit 911c835c2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2689 additions and 127 deletions

159
agent/chat.py Normal file
View file

@ -0,0 +1,159 @@
"""Chat graph.
A read-only "chat with this PR" agent for the review UI. Unlike the main agent
and reviewer, it has **no sandbox**: it answers questions about a single pull
request using the diff, the published review findings, and read-only access to
the repository over the GitHub API.
PR context (diff, findings, overview) is seeded as virtual files under ``/pr/``
into the ``files`` state channel by the dashboard chat proxy
(``agent/dashboard/review_chat_api.py``); the built-in ``read_file``/``grep``
tools operate over those. Repo coordinates and the reviewer thread id arrive in
``configurable``; a repo-scoped GitHub App token is resolved here so the
GitHub-backed tools never receive a user credential.
"""
# ruff: noqa: E402
from __future__ import annotations
import logging
import warnings
from langgraph.graph.state import RunnableConfig
from langgraph.pregel import Pregel
warnings.filterwarnings("ignore", module="langchain_core._api.deprecation")
warnings.filterwarnings("ignore", message=".*Pydantic V1.*", category=UserWarning)
from deepagents import create_deep_agent
from langchain.agents.middleware import ModelCallLimitMiddleware
from .dashboard.options import SUPPORTED_MODEL_IDS, model_supports_effort
from .dashboard.team_settings import get_team_default_model
from .middleware import (
ExcludeToolsMiddleware,
SanitizeThinkingBlocksMiddleware,
SanitizeToolInputsMiddleware,
ToolErrorMiddleware,
)
from .server import (
DEFAULT_LLM_MAX_TOKENS,
DEFAULT_RECURSION_LIMIT,
graph_loaded_for_execution,
)
from .tools import (
fetch_url,
list_review_findings,
read_repo_file,
search_repo_code,
web_search,
)
from .utils.github_app import get_github_app_installation_token
from .utils.model import DEFAULT_LLM_REASONING, make_model, provider_model_kwargs
from .utils.tracing import AGENT_TRACING_PROJECT, traced_graph_factory
logger = logging.getLogger(__name__)
CHAT_MODEL_CALL_LIMIT = 100
# Read-only: the chat agent never mutates files or runs shell commands. These are
# injected by deepagents' FilesystemMiddleware and stripped before the model sees
# them (there is no sandbox, so ``execute`` would error anyway).
_EXCLUDED_TOOLS = frozenset({"execute", "write_file", "edit_file"})
CHAT_PROMPT = """You are a code-review chat assistant. You help the author and reviewers \
understand one GitHub pull request: `{repo_owner}/{repo_name}` #{pr_number}.
You have NO sandbox and cannot run code, execute tests, commit, or open PRs. You \
reason from the PR's diff, the published review findings, and read-only access to \
the repository.
Context already loaded as virtual files (use `read_file`, `ls`, `grep`):
- `/pr/overview.md` — title, description, author, branches, head commit, change stats.
- `/pr/diff.patch` — the unified diff under review.
- `/pr/findings.md` — the reviewer's published findings, rendered for reading.
Tools:
- `read_repo_file(path, ref)` — read any repo file/dir at a commit (defaults to the \
PR head). Use it to inspect callers, definitions, and neighboring code beyond the diff.
- `search_repo_code(query)` — find a symbol or phrase across the repository.
- `list_review_findings(status_filter)` — the live findings (open/resolved/dismissed) \
with severity, confidence, and resolution notes.
- `web_search`, `fetch_url` — for external docs or standards.
Guidance:
- Be concrete and cite specific files and line numbers from the diff.
- Ground claims about the review in the actual findings; don't invent issues.
- When you propose a change, describe it precisely — you cannot apply it yourself.
- Keep answers focused and skimmable. Match the depth of the question.
"""
async def _resolve_chat_model(configurable: dict) -> tuple[str, str]:
model_id = configurable.get("chat_model_id")
effort = configurable.get("chat_effort")
if (
isinstance(model_id, str)
and model_id in SUPPORTED_MODEL_IDS
and isinstance(effort, str)
and model_supports_effort(model_id, effort)
):
return model_id, effort
# Team review-chat default, which itself inherits the Agent default if unset.
return await get_team_default_model("chat")
async def get_chat_agent(config: RunnableConfig) -> Pregel:
"""Get a read-only PR chat agent. No sandbox; PR context comes via config."""
thread_id = config["configurable"].get("thread_id")
config["recursion_limit"] = DEFAULT_RECURSION_LIMIT
if thread_id is None or not graph_loaded_for_execution(config):
return create_deep_agent(system_prompt="", tools=[]).with_config(config)
configurable = config["configurable"]
repo_owner = str(configurable.get("chat_repo_owner") or "")
repo_name = str(configurable.get("chat_repo_name") or "")
pr_number = configurable.get("chat_pr_number")
# Resolve a repo-scoped, read-only App token in-graph so a user credential is
# never passed through the run config. Tools read it from configurable.
token = await get_github_app_installation_token(repositories=[repo_name] if repo_name else None)
if isinstance(token, str) and token:
configurable["chat_github_token"] = token
model_id, effort = await _resolve_chat_model(configurable)
model_kwargs = provider_model_kwargs(
model_id,
effort,
max_tokens=DEFAULT_LLM_MAX_TOKENS,
openai_reasoning_default=DEFAULT_LLM_REASONING,
)
system_prompt = CHAT_PROMPT.format(
repo_owner=repo_owner or "<owner>",
repo_name=repo_name or "<repo>",
pr_number=pr_number if isinstance(pr_number, int) else "?",
)
return create_deep_agent(
model=make_model(model_id, **model_kwargs),
system_prompt=system_prompt,
tools=[
read_repo_file,
search_repo_code,
list_review_findings,
web_search,
fetch_url,
],
middleware=[
SanitizeToolInputsMiddleware(),
ModelCallLimitMiddleware(run_limit=CHAT_MODEL_CALL_LIMIT, exit_behavior="end"),
ToolErrorMiddleware(),
ExcludeToolsMiddleware(excluded=_EXCLUDED_TOOLS),
SanitizeThinkingBlocksMiddleware(),
],
).with_config(config)
traced_chat_agent = traced_graph_factory(get_chat_agent, AGENT_TRACING_PROJECT)

View file

@ -0,0 +1,507 @@
"""Backend for the review page's "chat with this PR" feature.
A dedicated, sandbox-less ``chat`` graph (``agent/chat.py``) answers questions
about one PR. This module mints a per-user chat thread, seeds the PR diff,
review findings, and an overview as virtual files on the first run, and proxies
the LangGraph stream/commands/state/history protocol the frontend SDK speaks —
the chat counterpart of ``thread_api``'s agent proxy, pinned to assistant
``chat`` and scoped to the review's PR.
"""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Any
import httpx
from deepagents.backends.utils import create_file_data
from fastapi import HTTPException
from ..reviewer_diff import fetch_pr_diff
from ..reviewer_findings import REVIEWER_THREAD_KIND
from ..utils.github_app import get_github_app_installation_token
from ..utils.thread_ops import langgraph_client, langgraph_url
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
from .review_api import classify_finding, get_review, reviewer_thread_id
from .thread_api import (
_DASHBOARD_STREAM_MODES,
_langgraph_proxy_headers,
_require_json_content_type,
_stream_thread_events,
)
logger = logging.getLogger(__name__)
_CHAT_ASSISTANT_ID = "chat"
_CHAT_SOURCE = "review_chat"
_PROXY_REQUEST_TIMEOUT = httpx.Timeout(30.0, connect=5.0)
_MAX_DIFF_CHARS = 400_000
def _now_ms() -> int:
return int(datetime.now(UTC).timestamp() * 1000)
_TITLE_MAX_CHARS = 60
async def _reviewer_thread_exists(owner: str, repo: str, pr_number: int) -> bool:
try:
thread = await langgraph_client().threads.get(reviewer_thread_id(owner, repo, pr_number))
except Exception: # noqa: BLE001
return False
metadata = thread.get("metadata") if isinstance(thread, dict) else None
return isinstance(metadata, dict) and metadata.get("kind") == REVIEWER_THREAD_KIND
async def get_review_chat(owner: str, repo: str, pr_number: int, login: str) -> dict[str, Any]:
"""Chat availability for this PR. Threads are minted client-side per chat."""
return {
"available": await _reviewer_thread_exists(owner, repo, pr_number),
"assistant_id": _CHAT_ASSISTANT_ID,
}
def _chat_thread_search_metadata(
owner: str, repo: str, pr_number: int, login: str
) -> dict[str, Any]:
return {
"kind": _CHAT_SOURCE,
"github_login": login,
"repo_owner": owner,
"repo_name": repo,
"pr_number": pr_number,
}
async def list_review_chat_threads(
owner: str, repo: str, pr_number: int, login: str, *, limit: int = 50
) -> list[dict[str, Any]]:
"""This user's chat conversations for the PR, newest first."""
client = langgraph_client()
try:
threads = await client.threads.search(
metadata=_chat_thread_search_metadata(owner, repo, pr_number, login),
limit=limit,
sort_by="updated_at",
sort_order="desc",
)
except Exception: # noqa: BLE001
logger.debug(
"chat thread search failed for %s/%s#%s", owner, repo, pr_number, exc_info=True
)
return []
out: list[dict[str, Any]] = []
for thread in threads or []:
if not isinstance(thread, dict):
continue
metadata = thread.get("metadata") if isinstance(thread.get("metadata"), dict) else {}
thread_id = thread.get("thread_id") or thread.get("id")
if not isinstance(thread_id, str):
continue
out.append(
{
"thread_id": thread_id,
"title": metadata.get("title") or "New chat",
"updated_at": thread.get("updated_at")
if isinstance(thread.get("updated_at"), str)
else None,
}
)
return out
async def delete_review_chat_thread(
owner: str, repo: str, pr_number: int, login: str, thread_id: str
) -> None:
"""Delete one of the user's chat threads (scoped + ownership-checked)."""
metadata = await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
if metadata is None:
return # already gone; treat as success
await langgraph_client().threads.delete(thread_id)
def _first_user_text(params: dict[str, Any]) -> str:
run_input = params.get("input")
messages = run_input.get("messages") if isinstance(run_input, dict) else None
if not isinstance(messages, list):
return ""
for message in messages:
if not isinstance(message, dict) or message.get("type") != "human":
continue
content = message.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
text = block.get("text")
if isinstance(text, str) and text.strip():
return text.strip()
return ""
def _derive_title(params: dict[str, Any]) -> str:
text = _first_user_text(params)
if not text:
return "New chat"
flattened = " ".join(text.split())
return flattened[:_TITLE_MAX_CHARS] if flattened else "New chat"
def _render_overview(review: dict[str, Any]) -> str:
pr = review.get("pr") if isinstance(review.get("pr"), dict) else {}
lines = [
f"# {review.get('title') or 'Pull request'} (#{review.get('number')})",
"",
f"- Repository: {review.get('full_name', '')}",
f"- Author: {review.get('author', '')}",
f"- Head: {review.get('head_ref', '')} @ {review.get('head_sha', '')[:12]}",
f"- Base: {review.get('base_ref', '')}",
f"- State: {pr.get('state', '')}",
f"- Changes: +{pr.get('additions', 0)} -{pr.get('deletions', 0)} "
f"across {pr.get('changed_files', 0)} file(s), {pr.get('commits', 0)} commit(s)",
"",
"## Description",
"",
str(pr.get("body") or "_No description provided._"),
]
return "\n".join(lines)
def _render_findings(findings: list[dict[str, Any]]) -> str:
if not findings:
return "# Review findings\n\n_No findings were published for this PR._"
out = ["# Review findings", ""]
for finding in findings:
group = classify_finding(finding)
location = finding.get("file") or ""
start = finding.get("start_line")
if location and start:
location = f"{location}:{start}"
out.append(
f"## [{group}] {finding.get('title') or 'Untitled'} "
f"({finding.get('severity', 'low')}/{finding.get('confidence', 'medium')}, "
f"{finding.get('status', 'open')})"
)
if location:
out.append(f"`{location}`")
out.append("")
out.append(str(finding.get("description") or ""))
suggestion = finding.get("suggestion")
if suggestion:
out.append("")
out.append(f"Suggested change:\n```\n{suggestion}\n```")
note = finding.get("resolution_note")
if note:
out.append("")
out.append(f"Resolution: {note}")
out.append("")
return "\n".join(out)
def _review_head_sha(review: dict[str, Any]) -> str:
pr = review.get("pr") if isinstance(review.get("pr"), dict) else {}
return str(pr.get("head_sha") or review.get("head_sha") or "")
async def _build_pr_context(
owner: str,
repo: str,
pr_number: int,
token: str,
*,
review: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], str]:
"""Fetch diff + findings + overview as seedable files; return ``(files, head_sha)``.
Accepts an already-fetched ``review`` to avoid re-fetching it when the caller
has just read it to decide whether a reseed is needed.
"""
if review is None:
review = await get_review(owner, repo, pr_number)
findings = review.get("findings") if isinstance(review.get("findings"), list) else []
head_sha = _review_head_sha(review)
diff = await fetch_pr_diff(owner=owner, repo=repo, pr_number=pr_number, token=token) or ""
if len(diff) > _MAX_DIFF_CHARS:
diff = diff[:_MAX_DIFF_CHARS] + "\n\n[diff truncated]\n"
files = {
"/pr/overview.md": create_file_data(_render_overview(review)),
"/pr/diff.patch": create_file_data(diff or "[diff unavailable]"),
"/pr/findings.md": create_file_data(_render_findings(findings)),
}
return files, head_sha
async def _get_chat_thread_metadata(thread_id: str) -> dict[str, Any] | None:
client = langgraph_client()
try:
thread = await client.threads.get(thread_id)
except Exception: # noqa: BLE001
return None
return thread.get("metadata") if isinstance(thread, dict) else None
async def assert_chat_thread_access(
thread_id: str, owner: str, repo: str, pr_number: int, login: str
) -> dict[str, Any] | None:
"""Authorize a client-supplied chat thread id before proxying to LangGraph.
Chat threads are private per viewer and their ids come from the client, so
every proxy route must confirm the caller owns the thread it names. Returns
the thread metadata when it exists and belongs to ``login`` for this PR, or
``None`` when the thread doesn't exist yet (it's created lazily on the first
run, so there is nothing to leak). Raises 404 when a thread exists but is
owned by someone else or scoped to a different repo/PR — this also rejects
reviewer (or any non-chat) threads, whose ``kind`` is not ``_CHAT_SOURCE``.
"""
metadata = await _get_chat_thread_metadata(thread_id)
if metadata is None:
return None
owns = (
metadata.get("kind") == _CHAT_SOURCE
and metadata.get("github_login") == login
and metadata.get("repo_owner") == owner
and metadata.get("repo_name") == repo
and metadata.get("pr_number") == pr_number
)
if not owns:
raise HTTPException(404, "chat not found")
return metadata
async def _create_chat_thread(
thread_id: str, owner: str, repo: str, pr_number: int, login: str, *, title: str
) -> None:
now_ms = _now_ms()
metadata = {
"kind": _CHAT_SOURCE,
"source": _CHAT_SOURCE,
"github_login": login,
"repo_owner": owner,
"repo_name": repo,
"pr_number": pr_number,
"title": title,
"created_at_ms": now_ms,
"updated_at_ms": now_ms,
}
await langgraph_client().threads.create(
thread_id=thread_id, metadata=metadata, if_exists="do_nothing"
)
def _normalize_chat_model(configurable: dict[str, Any]) -> tuple[str | None, str | None]:
model_id = configurable.get("chat_model_id")
effort = configurable.get("chat_effort")
if (
isinstance(model_id, str)
and model_id in SUPPORTED_MODEL_IDS
and isinstance(effort, str)
and model_supports_effort(model_id, effort)
):
return model_id, effort
return None, None
async def _enrich_chat_command(
command: dict[str, Any],
*,
owner: str,
repo: str,
pr_number: int,
login: str,
thread_id: str,
) -> dict[str, Any]:
if command.get("method") != "run.start":
return command
params = command.get("params")
if not isinstance(params, dict):
params = {}
command["params"] = params
metadata = await _get_chat_thread_metadata(thread_id)
created = metadata is None
if created:
await _create_chat_thread(
thread_id, owner, repo, pr_number, login, title=_derive_title(params)
)
metadata = {}
client_config = params.get("config")
if not isinstance(client_config, dict):
client_config = {}
client_configurable = client_config.get("configurable")
if not isinstance(client_configurable, dict):
client_configurable = {}
configurable: dict[str, Any] = {
"thread_id": thread_id,
"source": _CHAT_SOURCE,
"github_login": login,
"chat_repo_owner": owner,
"chat_repo_name": repo,
"chat_pr_number": pr_number,
"reviewer_thread_id": reviewer_thread_id(owner, repo, pr_number),
}
model_id, effort = _normalize_chat_model(client_configurable)
if model_id and effort:
configurable["chat_model_id"] = model_id
configurable["chat_effort"] = effort
# Seed PR context on the thread's first run, and reseed whenever the PR head
# has moved since the last seed — otherwise the chat keeps answering from a
# stale diff/findings while the review page already shows the current head.
stored_head = metadata.get("chat_head_sha") if isinstance(metadata, dict) else None
stored_head = stored_head if isinstance(stored_head, str) else ""
review: dict[str, Any] | None = None
needs_seed = created
if not created:
try:
review = await get_review(owner, repo, pr_number)
except HTTPException:
review = None # transient/missing review: keep the existing context
if review is not None:
current_head = _review_head_sha(review)
needs_seed = bool(current_head) and current_head != stored_head
if needs_seed:
token = await get_github_app_installation_token(repositories=[repo])
if not token:
raise HTTPException(503, "GitHub App token unavailable")
try:
pr_files, head_sha = await _build_pr_context(
owner, repo, pr_number, token, review=review
)
except HTTPException:
raise
except Exception as exc: # noqa: BLE001
logger.warning("Failed to seed PR chat context for %s/%s#%s", owner, repo, pr_number)
raise HTTPException(502, "could not load PR context") from exc
if head_sha:
configurable["chat_head_sha"] = head_sha
await langgraph_client().threads.update(
thread_id=thread_id, metadata={"chat_head_sha": head_sha}
)
elif stored_head:
configurable["chat_head_sha"] = stored_head
run_input = params.get("input")
if not isinstance(run_input, dict):
run_input = {}
existing_files = run_input.get("files")
run_input["files"] = {
**(existing_files if isinstance(existing_files, dict) else {}),
**pr_files,
}
params["input"] = run_input
elif stored_head:
configurable["chat_head_sha"] = stored_head
params["assistant_id"] = _CHAT_ASSISTANT_ID
params.setdefault("stream_mode", list(_DASHBOARD_STREAM_MODES))
params.setdefault("stream_resumable", True)
params["config"] = {**client_config, "configurable": configurable}
command["params"] = params
return command
async def proxy_review_chat_commands(
owner: str,
repo: str,
pr_number: int,
login: str,
thread_id: str,
body: bytes,
*,
content_type: str = "application/json",
) -> tuple[int, bytes, str | None]:
# Reject threads the caller doesn't own; a missing thread is created lazily
# below on the first `run.start` (with the caller as owner).
await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
_require_json_content_type(content_type)
try:
parsed = json.loads(body)
except json.JSONDecodeError as exc:
raise HTTPException(400, "command body must be a JSON object") from exc
if not isinstance(parsed, dict):
raise HTTPException(400, "command body must be a JSON object")
enriched = await _enrich_chat_command(
parsed, owner=owner, repo=repo, pr_number=pr_number, login=login, thread_id=thread_id
)
url = f"{langgraph_url().rstrip('/')}/threads/{thread_id}/commands"
headers = _langgraph_proxy_headers(content_type=content_type)
async with httpx.AsyncClient(timeout=_PROXY_REQUEST_TIMEOUT) as client:
response = await client.post(url, content=json.dumps(enriched).encode(), headers=headers)
return response.status_code, response.content, response.headers.get("content-type")
async def proxy_review_chat_stream_events(
owner: str,
repo: str,
pr_number: int,
login: str,
thread_id: str,
body: bytes,
*,
content_type: str = "application/json",
) -> AsyncIterator[bytes]:
await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
_require_json_content_type(content_type)
return _stream_thread_events(thread_id, body, content_type)
async def _proxy_passthrough(
method: str, thread_id: str, suffix: str, body: bytes | None, content_type: str
) -> tuple[int, bytes, str | None]:
url = f"{langgraph_url().rstrip('/')}/threads/{thread_id}/{suffix}"
headers = _langgraph_proxy_headers(content_type=content_type)
async with httpx.AsyncClient(timeout=_PROXY_REQUEST_TIMEOUT) as client:
if method == "GET":
response = await client.get(url, headers=headers)
else:
response = await client.post(url, content=body or b"{}", headers=headers)
return response.status_code, response.content, response.headers.get("content-type")
async def proxy_review_chat_state(
owner: str, repo: str, pr_number: int, login: str, thread_id: str
) -> tuple[int, bytes, str | None]:
await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
status_code, content, media_type = await _proxy_passthrough(
"GET", thread_id, "state", None, "application/json"
)
# The chat thread is created lazily on the first run, so an initial getState
# hits a missing thread. Return an empty idle state so the SDK hydrates a
# fresh thread instead of surfacing the 404 as a hard error.
if status_code == 404:
empty = json.dumps({"values": {}, "next": []}).encode()
return 200, empty, "application/json"
return status_code, content, media_type
async def proxy_review_chat_history(
owner: str,
repo: str,
pr_number: int,
login: str,
thread_id: str,
body: bytes,
*,
content_type: str = "application/json",
) -> tuple[int, bytes, str | None]:
await assert_chat_thread_access(thread_id, owner, repo, pr_number, login)
_require_json_content_type(content_type)
status_code, content, media_type = await _proxy_passthrough(
"POST", thread_id, "history", body, content_type
)
# The thread is created lazily on the first run; before then, hydration
# history reads hit a missing thread. Return an empty list so the SDK
# treats it as a fresh thread instead of erroring.
if status_code == 404:
return 200, b"[]", "application/json"
return status_code, content, media_type

View file

@ -69,6 +69,15 @@ from .review_api import (
list_reviews,
trigger_re_review,
)
from .review_chat_api import (
delete_review_chat_thread,
get_review_chat,
list_review_chat_threads,
proxy_review_chat_commands,
proxy_review_chat_history,
proxy_review_chat_state,
proxy_review_chat_stream_events,
)
from .review_style_jobs import (
cancel_review_style_analysis,
start_bootstrap_analysis,
@ -830,6 +839,136 @@ async def api_re_review(
return await trigger_re_review(owner, repo, pr_number, session["sub"])
# --- PR chat (sandbox-less ``chat`` graph) -----------------------------------
# The frontend points a LangGraph StreamProvider at the base
# ``/reviews/{owner}/{repo}/{pr_number}/chat``; the SDK then issues the
# ``/threads/{id}/{commands,stream/events,state,history}`` calls proxied below.
@router.get("/reviews/{owner}/{repo}/{pr_number}/chat")
async def api_get_review_chat(
owner: str,
repo: str,
pr_number: int,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
return await get_review_chat(owner, repo, pr_number, session["sub"])
@router.get("/reviews/{owner}/{repo}/{pr_number}/chat/threads")
async def api_list_review_chat_threads(
owner: str,
repo: str,
pr_number: int,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
threads = await list_review_chat_threads(owner, repo, pr_number, session["sub"])
return {"threads": threads}
@router.delete("/reviews/{owner}/{repo}/{pr_number}/chat/threads/{thread_id}")
async def api_delete_review_chat_thread(
owner: str,
repo: str,
pr_number: int,
thread_id: str,
session: dict[str, Any] = _SESSION_DEP,
) -> Response:
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
await delete_review_chat_thread(owner, repo, pr_number, session["sub"], thread_id)
return Response(status_code=204)
@router.post("/reviews/{owner}/{repo}/{pr_number}/chat/threads/{thread_id}/commands")
async def api_review_chat_commands(
owner: str,
repo: str,
pr_number: int,
thread_id: str,
request: Request,
session: dict[str, Any] = _SESSION_DEP,
) -> Response:
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
body = await request.body()
status_code, content, media_type = await proxy_review_chat_commands(
owner,
repo,
pr_number,
session["sub"],
thread_id,
body,
content_type=request.headers.get("content-type", "application/json"),
)
return Response(content=content, status_code=status_code, media_type=media_type)
@router.post("/reviews/{owner}/{repo}/{pr_number}/chat/threads/{thread_id}/stream/events")
async def api_review_chat_stream_events(
owner: str,
repo: str,
pr_number: int,
thread_id: str,
request: Request,
session: dict[str, Any] = _SESSION_DEP,
) -> StreamingResponse:
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
body = await request.body()
stream = await proxy_review_chat_stream_events(
owner,
repo,
pr_number,
session["sub"],
thread_id,
body,
content_type=request.headers.get("content-type", "application/json"),
)
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
@router.get("/reviews/{owner}/{repo}/{pr_number}/chat/threads/{thread_id}/state")
async def api_review_chat_state(
owner: str,
repo: str,
pr_number: int,
thread_id: str,
session: dict[str, Any] = _SESSION_DEP,
) -> Response:
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
status_code, content, media_type = await proxy_review_chat_state(
owner, repo, pr_number, session["sub"], thread_id
)
return Response(content=content, status_code=status_code, media_type=media_type)
@router.post("/reviews/{owner}/{repo}/{pr_number}/chat/threads/{thread_id}/history")
async def api_review_chat_history(
owner: str,
repo: str,
pr_number: int,
thread_id: str,
request: Request,
session: dict[str, Any] = _SESSION_DEP,
) -> Response:
await require_repo_access_for_user(session["sub"], f"{owner}/{repo}")
body = await request.body()
status_code, content, media_type = await proxy_review_chat_history(
owner,
repo,
pr_number,
session["sub"],
thread_id,
body,
content_type=request.headers.get("content-type", "application/json"),
)
return Response(content=content, status_code=status_code, media_type=media_type)
@router.post("/review-styles")
async def api_create_review_style(
body: ReviewStyleCreate,

View file

@ -52,6 +52,8 @@ class TeamSettingsUpdate(BaseModel):
default_reviewer_reasoning_effort: str | None = None
default_reviewer_subagent_model: str | None = None
default_reviewer_subagent_reasoning_effort: str | None = None
default_chat_model: str | None = None
default_chat_reasoning_effort: str | None = None
@field_validator("org_guidelines", mode="before")
@classmethod
@ -87,6 +89,9 @@ class TeamSettingsUpdate(BaseModel):
self.default_reviewer_subagent_reasoning_effort,
"reviewer subagent",
)
_validate_model_effort_pair(
self.default_chat_model, self.default_chat_reasoning_effort, "review chat"
)
return self
@ -139,6 +144,9 @@ def _default_settings() -> dict[str, Any]:
"default_reviewer_reasoning_effort": fallback_effort,
"default_reviewer_subagent_model": fallback_model,
"default_reviewer_subagent_reasoning_effort": fallback_effort,
# No hardcoded chat default: unset means "inherit the Agent default".
"default_chat_model": None,
"default_chat_reasoning_effort": None,
"updated_at": None,
}
@ -184,6 +192,8 @@ async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
"default_reviewer_reasoning_effort": update.default_reviewer_reasoning_effort,
"default_reviewer_subagent_model": update.default_reviewer_subagent_model,
"default_reviewer_subagent_reasoning_effort": update.default_reviewer_subagent_reasoning_effort,
"default_chat_model": update.default_chat_model,
"default_chat_reasoning_effort": update.default_chat_reasoning_effort,
"updated_at": datetime.now(UTC).isoformat(),
}
await _client().store.put_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY, value)
@ -196,7 +206,7 @@ async def get_team_default_repo() -> dict[str, str] | None:
async def get_team_default_model(
role: Literal["agent", "reviewer"],
role: Literal["agent", "reviewer", "chat"],
) -> tuple[str, str]:
"""Return the team-wide default ``(model_id, reasoning_effort)`` for ``role``.
@ -205,9 +215,25 @@ async def get_team_default_model(
(so a stale Anthropic/OpenAI selection stays on its provider rather than
jumping cross-provider); otherwise the hardcoded global default from
:func:`agent.dashboard.options.default_model_pair`.
``"chat"`` (the review-page PR chat) has no hardcoded default: when its
admin setting is unset/invalid it inherits the team **agent** default.
"""
settings = await get_team_settings()
if role == "agent":
if role == "chat":
model = settings.get("default_chat_model")
effort = settings.get("default_chat_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)
# Inherit the Agent default when no chat-specific model is configured.
model = settings.get("default_agent_model")
effort = settings.get("default_agent_reasoning_effort")
elif role == "agent":
model = settings.get("default_agent_model")
effort = settings.get("default_agent_reasoning_effort")
else:

View file

@ -9,11 +9,14 @@ from .linear_get_issue_comments import linear_get_issue_comments
from .linear_list_teams import linear_list_teams
from .linear_update_issue import linear_update_issue
from .list_findings import list_findings
from .list_review_findings import list_review_findings
from .open_pull_request import open_pull_request
from .publish_review import publish_review
from .read_repo_file import read_repo_file
from .reply_to_finding_thread import reply_to_finding_thread
from .request_pr_review import request_pr_review
from .resolve_finding_thread import resolve_finding_thread
from .search_repo_code import search_repo_code
from .slack_read_thread_messages import slack_read_thread_messages
from .slack_thread_reply import slack_thread_reply
from .update_finding import update_finding
@ -31,11 +34,14 @@ __all__ = [
"linear_list_teams",
"linear_update_issue",
"list_findings",
"list_review_findings",
"open_pull_request",
"publish_review",
"read_repo_file",
"request_pr_review",
"reply_to_finding_thread",
"resolve_finding_thread",
"search_repo_code",
"slack_read_thread_messages",
"slack_thread_reply",
"update_finding",

View file

@ -0,0 +1,71 @@
"""Tool: ``list_review_findings``. Read the published review's findings.
The PR chat agent runs on its own thread; the findings live on the canonical
reviewer thread for the PR. The reviewer thread id is seeded into the run config
by the dashboard chat proxy.
"""
from __future__ import annotations
import asyncio
from typing import Any
from langgraph.config import get_config
from ..reviewer_findings import list_findings as list_findings_async
_COMPACT_FIELDS = (
"id",
"severity",
"confidence",
"category",
"title",
"description",
"suggestion",
"file",
"start_line",
"end_line",
"side",
"status",
"resolution_note",
)
def _compact(finding: dict[str, Any]) -> dict[str, Any]:
return {key: finding.get(key) for key in _COMPACT_FIELDS if finding.get(key) is not None}
def list_review_findings(status_filter: str | None = None) -> dict[str, Any]:
"""List the findings the reviewer published for this PR.
Use this to ground answers about the review — what was flagged, the
severity/confidence, and any resolution notes. Prefer quoting these over
re-deriving issues from the diff.
Args:
status_filter: One of ``open``, ``resolved``, ``dismissed``. ``None``
(default) returns findings of every status.
Returns:
``{findings, count}``; ``{findings: [], count: 0, error}`` on failure.
"""
if status_filter is not None and status_filter not in {"open", "resolved", "dismissed"}:
return {"findings": [], "count": 0, "error": f"Invalid status_filter: {status_filter}"}
config = get_config()
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
reviewer_thread_id = (
configurable.get("reviewer_thread_id") if isinstance(configurable, dict) else None
)
if not isinstance(reviewer_thread_id, str) or not reviewer_thread_id:
return {"findings": [], "count": 0, "error": "reviewer thread unavailable"}
try:
findings = asyncio.run(list_findings_async(reviewer_thread_id))
except Exception as exc: # noqa: BLE001
return {"findings": [], "count": 0, "error": f"could not load findings: {exc!s}"}
if status_filter is not None:
findings = [f for f in findings if f.get("status") == status_filter]
compact = [_compact(f) for f in findings]
return {"findings": compact, "count": len(compact)}

View file

@ -0,0 +1,104 @@
"""Tool: ``read_repo_file``. Read repo files/dirs over the GitHub API (no sandbox).
The PR chat agent has no sandbox, so it reads source at a specific ref through
the GitHub contents API. Repo coordinates and a read-only token come from the
run config (seeded by the dashboard chat proxy).
"""
from __future__ import annotations
import base64
from typing import Any
import requests
from langgraph.config import get_config
from ..utils.github_checks import github_headers
_GITHUB_API = "https://api.github.com"
_MAX_FILE_BYTES = 256 * 1024
def _chat_repo_context() -> tuple[str, str, str | None, str | None]:
config = get_config()
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
if not isinstance(configurable, dict):
configurable = {}
owner = configurable.get("chat_repo_owner")
repo = configurable.get("chat_repo_name")
token = configurable.get("chat_github_token")
head_sha = configurable.get("chat_head_sha")
return (
owner if isinstance(owner, str) else "",
repo if isinstance(repo, str) else "",
token if isinstance(token, str) and token else None,
head_sha if isinstance(head_sha, str) and head_sha else None,
)
def read_repo_file(path: str, ref: str | None = None) -> dict[str, Any]:
"""Read a file (or list a directory) from the PR's repository at a git ref.
Use this to inspect code beyond the diff — callers, definitions, neighboring
modules, config — at the exact commit under review. The diff itself is
already available as the virtual file ``/pr/diff.patch``.
Args:
path: Repo-relative path, e.g. ``src/app/main.py`` or ``src/app`` for a
directory listing. Leading slashes are ignored.
ref: Git ref (branch, tag, or SHA). Defaults to the PR head commit.
Returns:
For a file: ``{success, path, ref, content, truncated}``.
For a directory: ``{success, path, ref, entries}`` where each entry is
``{name, type, path}``.
On failure: ``{success: False, error}``.
"""
owner, repo, token, head_sha = _chat_repo_context()
if not owner or not repo:
return {"success": False, "error": "repository context unavailable"}
clean_path = path.strip().lstrip("/")
resolved_ref = (ref or head_sha or "").strip()
params = {"ref": resolved_ref} if resolved_ref else None
url = f"{_GITHUB_API}/repos/{owner}/{repo}/contents/{clean_path}"
headers = github_headers(token or "")
try:
response = requests.get(url, headers=headers, params=params, timeout=30)
except requests.exceptions.RequestException as exc:
return {"success": False, "error": f"GitHub request failed: {exc!s}"}
if response.status_code == 404:
return {"success": False, "error": f"not found: {clean_path} @ {resolved_ref or 'default'}"}
if response.status_code >= 400:
return {"success": False, "error": f"GitHub returned {response.status_code}"}
payload = response.json()
if isinstance(payload, list):
entries = [
{
"name": item.get("name"),
"type": item.get("type"),
"path": item.get("path"),
}
for item in payload
if isinstance(item, dict)
]
return {"success": True, "path": clean_path, "ref": resolved_ref, "entries": entries}
if not isinstance(payload, dict) or payload.get("type") != "file":
return {"success": False, "error": f"unsupported content type for {clean_path}"}
encoded = payload.get("content")
if not isinstance(encoded, str):
return {"success": False, "error": "file content unavailable (too large for contents API)"}
raw = base64.b64decode(encoded)
truncated = len(raw) > _MAX_FILE_BYTES
text = raw[:_MAX_FILE_BYTES].decode("utf-8", errors="replace")
return {
"success": True,
"path": clean_path,
"ref": resolved_ref,
"content": text,
"truncated": truncated,
}

View file

@ -0,0 +1,83 @@
"""Tool: ``search_repo_code``. Search the PR's repository via the GitHub code-search API."""
from __future__ import annotations
from typing import Any
import requests
from langgraph.config import get_config
from ..utils.github_checks import github_headers
_GITHUB_API = "https://api.github.com"
def _chat_repo_context() -> tuple[str, str, str | None]:
config = get_config()
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
if not isinstance(configurable, dict):
configurable = {}
owner = configurable.get("chat_repo_owner")
repo = configurable.get("chat_repo_name")
token = configurable.get("chat_github_token")
return (
owner if isinstance(owner, str) else "",
repo if isinstance(repo, str) else "",
token if isinstance(token, str) and token else None,
)
def search_repo_code(query: str, max_results: int = 20) -> dict[str, Any]:
"""Search code in the PR's repository for a keyword, symbol, or phrase.
Backed by GitHub code search, which indexes the repository's default branch
(not arbitrary refs). Use it to locate where a symbol is defined or used,
then ``read_repo_file`` for the surrounding context. For matches within the
changed lines, search the virtual file ``/pr/diff.patch`` instead.
Args:
query: Search terms. Repo scoping is added automatically.
max_results: Max matches to return (capped at 50).
Returns:
``{success, total_count, results}`` where each result is
``{path, fragments}``; ``{success: False, error}`` on failure.
"""
owner, repo, token = _chat_repo_context()
if not owner or not repo:
return {"success": False, "error": "repository context unavailable"}
capped = max(1, min(max_results, 50))
headers = github_headers(token or "")
headers["Accept"] = "application/vnd.github.text-match+json"
params = {"q": f"{query} repo:{owner}/{repo}", "per_page": capped}
try:
response = requests.get(
f"{_GITHUB_API}/search/code", headers=headers, params=params, timeout=30
)
except requests.exceptions.RequestException as exc:
return {"success": False, "error": f"GitHub request failed: {exc!s}"}
if response.status_code == 422:
return {"success": False, "error": "query rejected by GitHub code search"}
if response.status_code >= 400:
return {"success": False, "error": f"GitHub returned {response.status_code}"}
payload = response.json()
items = payload.get("items") if isinstance(payload, dict) else None
results: list[dict[str, Any]] = []
for item in items if isinstance(items, list) else []:
if not isinstance(item, dict):
continue
fragments = [
match.get("fragment")
for match in item.get("text_matches", [])
if isinstance(match, dict) and isinstance(match.get("fragment"), str)
]
results.append({"path": item.get("path", ""), "fragments": fragments})
total = payload.get("total_count") if isinstance(payload, dict) else None
return {
"success": True,
"total_count": total if isinstance(total, int) else len(results),
"results": results,
}

View file

@ -6,6 +6,7 @@
"agent": "agent.server:traced_agent",
"reviewer": "agent.reviewer:traced_reviewer_agent",
"analyzer": "agent.analyzer:traced_analyzer",
"chat": "agent.chat:traced_chat_agent",
"scheduler": "agent.scheduler:get_scheduler",
"ci_monitor": "agent.ci_monitor:get_ci_monitor"
},

532
tests/test_review_chat.py Normal file
View file

@ -0,0 +1,532 @@
from __future__ import annotations
import asyncio
import importlib
from types import SimpleNamespace
from typing import Any
import pytest
from agent.dashboard import review_chat_api
# `agent.tools.__init__` rebinds these names to the tool *functions*, shadowing
# the submodules. Import the real modules so we can monkeypatch their globals.
list_review_findings = importlib.import_module("agent.tools.list_review_findings")
read_repo_file = importlib.import_module("agent.tools.read_repo_file")
search_repo_code = importlib.import_module("agent.tools.search_repo_code")
# --- chat thread list / delete / title ---------------------------------------
def test_derive_title_from_first_user_message() -> None:
params = {
"input": {"messages": [{"type": "human", "content": " Why did we drop\nstructs? "}]}
}
assert review_chat_api._derive_title(params) == "Why did we drop structs?"
def test_derive_title_defaults_when_no_message() -> None:
assert review_chat_api._derive_title({"input": {"messages": []}}) == "New chat"
def test_derive_title_truncates() -> None:
params = {"input": {"messages": [{"type": "human", "content": "x" * 200}]}}
assert len(review_chat_api._derive_title(params)) == review_chat_api._TITLE_MAX_CHARS
@pytest.mark.asyncio
async def test_list_review_chat_threads_scopes_and_maps(monkeypatch) -> None:
captured: dict[str, Any] = {}
async def search(**kwargs: Any) -> list[dict[str, Any]]:
captured["metadata"] = kwargs.get("metadata")
return [
{
"thread_id": "c1",
"updated_at": "2026-06-15T00:00:00Z",
"metadata": {"title": "Why structs?"},
},
{"thread_id": "c2", "metadata": {}}, # untitled -> default label
]
client = SimpleNamespace(threads=SimpleNamespace(search=search))
monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client)
threads = await review_chat_api.list_review_chat_threads("acme", "repo", 7, "octocat")
assert captured["metadata"] == {
"kind": "review_chat",
"github_login": "octocat",
"repo_owner": "acme",
"repo_name": "repo",
"pr_number": 7,
}
assert threads[0] == {
"thread_id": "c1",
"title": "Why structs?",
"updated_at": "2026-06-15T00:00:00Z",
}
assert threads[1]["title"] == "New chat"
@pytest.mark.asyncio
async def test_delete_review_chat_thread_checks_ownership(monkeypatch) -> None:
deleted: list[str] = []
async def get(thread_id: str) -> dict[str, Any]:
return {
"thread_id": thread_id,
"metadata": {
"kind": "review_chat",
"github_login": "octocat",
"repo_owner": "acme",
"repo_name": "repo",
"pr_number": 7,
},
}
async def delete(thread_id: str) -> None:
deleted.append(thread_id)
client = SimpleNamespace(threads=SimpleNamespace(get=get, delete=delete))
monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client)
await review_chat_api.delete_review_chat_thread("acme", "repo", 7, "octocat", "c1")
assert deleted == ["c1"]
@pytest.mark.asyncio
async def test_delete_review_chat_thread_rejects_other_user(monkeypatch) -> None:
async def get(thread_id: str) -> dict[str, Any]:
return {
"thread_id": thread_id,
"metadata": {"kind": "review_chat", "github_login": "hubot"},
}
async def delete(thread_id: str) -> None:
raise AssertionError("should not delete another user's chat")
client = SimpleNamespace(threads=SimpleNamespace(get=get, delete=delete))
monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client)
with pytest.raises(Exception): # noqa: B017,PT011 - HTTPException(404)
await review_chat_api.delete_review_chat_thread("acme", "repo", 7, "octocat", "c1")
def _patch_thread_metadata(monkeypatch, metadata: dict[str, Any] | None) -> None:
async def get(thread_id: str) -> dict[str, Any]:
if metadata is None:
raise RuntimeError("not found")
return {"thread_id": thread_id, "metadata": metadata}
client = SimpleNamespace(threads=SimpleNamespace(get=get))
monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client)
@pytest.mark.asyncio
async def test_assert_chat_thread_access_allows_owner(monkeypatch) -> None:
_patch_thread_metadata(
monkeypatch,
{
"kind": "review_chat",
"github_login": "octocat",
"repo_owner": "acme",
"repo_name": "repo",
"pr_number": 7,
},
)
meta = await review_chat_api.assert_chat_thread_access("ct-1", "acme", "repo", 7, "octocat")
assert meta is not None
@pytest.mark.asyncio
async def test_assert_chat_thread_access_missing_thread_returns_none(monkeypatch) -> None:
_patch_thread_metadata(monkeypatch, None)
assert (
await review_chat_api.assert_chat_thread_access("ct-1", "acme", "repo", 7, "octocat")
is None
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"metadata",
[
{ # another user's chat thread
"kind": "review_chat",
"github_login": "hubot",
"repo_owner": "acme",
"repo_name": "repo",
"pr_number": 7,
},
{ # a reviewer (non-chat) thread with the same deterministic id space
"kind": "reviewer",
"github_login": "octocat",
"repo_owner": "acme",
"repo_name": "repo",
"pr_number": 7,
},
{ # right user, wrong PR scope
"kind": "review_chat",
"github_login": "octocat",
"repo_owner": "acme",
"repo_name": "repo",
"pr_number": 8,
},
],
)
async def test_assert_chat_thread_access_rejects_unauthorized(monkeypatch, metadata) -> None:
_patch_thread_metadata(monkeypatch, metadata)
with pytest.raises(Exception): # noqa: B017,PT011 - HTTPException(404)
await review_chat_api.assert_chat_thread_access("ct-1", "acme", "repo", 7, "octocat")
# --- tools -------------------------------------------------------------------
def test_list_review_findings_compacts_and_filters(monkeypatch) -> None:
monkeypatch.setattr(
list_review_findings,
"get_config",
lambda: {"configurable": {"reviewer_thread_id": "rt-1"}},
)
async def fake_list(thread_id: str) -> list[dict[str, Any]]:
assert thread_id == "rt-1"
return [
{
"id": "f1",
"title": "Open one",
"status": "open",
"severity": "high",
"github_review_comment_id": 999,
},
{"id": "f2", "title": "Closed one", "status": "resolved", "severity": "low"},
]
monkeypatch.setattr(list_review_findings, "list_findings_async", fake_list)
result = list_review_findings.list_review_findings(status_filter="open")
assert result["count"] == 1
finding = result["findings"][0]
assert finding["id"] == "f1"
# compact view drops GitHub plumbing fields
assert "github_review_comment_id" not in finding
def test_list_review_findings_requires_reviewer_thread(monkeypatch) -> None:
monkeypatch.setattr(list_review_findings, "get_config", lambda: {"configurable": {}})
result = list_review_findings.list_review_findings()
assert result["count"] == 0
assert "reviewer thread" in result["error"]
def test_read_repo_file_decodes_file(monkeypatch) -> None:
import base64
monkeypatch.setattr(
read_repo_file,
"get_config",
lambda: {
"configurable": {
"chat_repo_owner": "acme",
"chat_repo_name": "repo",
"chat_github_token": "tok",
"chat_head_sha": "deadbeef",
}
},
)
captured: dict[str, Any] = {}
def fake_get(url, headers=None, params=None, timeout=None):
captured["url"] = url
captured["params"] = params
return SimpleNamespace(
status_code=200,
json=lambda: {"type": "file", "content": base64.b64encode(b"hello\nworld").decode()},
)
monkeypatch.setattr(read_repo_file.requests, "get", fake_get)
result = read_repo_file.read_repo_file("src/app.py")
assert result["success"] is True
assert result["content"] == "hello\nworld"
assert result["ref"] == "deadbeef" # defaults to head sha
assert captured["params"] == {"ref": "deadbeef"}
def test_read_repo_file_lists_directory(monkeypatch) -> None:
monkeypatch.setattr(
read_repo_file,
"get_config",
lambda: {
"configurable": {
"chat_repo_owner": "acme",
"chat_repo_name": "repo",
"chat_github_token": "tok",
}
},
)
def fake_get(url, headers=None, params=None, timeout=None):
return SimpleNamespace(
status_code=200,
json=lambda: [
{"name": "a.py", "type": "file", "path": "src/a.py"},
{"name": "sub", "type": "dir", "path": "src/sub"},
],
)
monkeypatch.setattr(read_repo_file.requests, "get", fake_get)
result = read_repo_file.read_repo_file("src")
assert result["success"] is True
assert {e["name"] for e in result["entries"]} == {"a.py", "sub"}
def test_read_repo_file_missing_context(monkeypatch) -> None:
monkeypatch.setattr(read_repo_file, "get_config", lambda: {"configurable": {}})
result = read_repo_file.read_repo_file("src/app.py")
assert result["success"] is False
def test_search_repo_code_scopes_to_repo(monkeypatch) -> None:
monkeypatch.setattr(
search_repo_code,
"get_config",
lambda: {
"configurable": {
"chat_repo_owner": "acme",
"chat_repo_name": "repo",
"chat_github_token": "tok",
}
},
)
captured: dict[str, Any] = {}
def fake_get(url, headers=None, params=None, timeout=None):
captured["params"] = params
return SimpleNamespace(
status_code=200,
json=lambda: {
"total_count": 1,
"items": [{"path": "src/a.py", "text_matches": [{"fragment": "def foo()"}]}],
},
)
monkeypatch.setattr(search_repo_code.requests, "get", fake_get)
result = search_repo_code.search_repo_code("foo")
assert result["success"] is True
assert "repo:acme/repo" in captured["params"]["q"]
assert result["results"][0]["path"] == "src/a.py"
# --- proxy enrichment --------------------------------------------------------
def _fake_review() -> dict[str, Any]:
return {
"title": "Fix things",
"number": 7,
"full_name": "acme/repo",
"author": "octocat",
"head_ref": "feature",
"base_ref": "main",
"head_sha": "abc123def456",
"findings": [
{
"id": "f1",
"title": "Bug",
"severity": "high",
"confidence": "high",
"status": "open",
"file": "src/a.py",
"start_line": 5,
"description": "boom",
"group": "bug",
},
],
"pr": {
"state": "open",
"body": "desc",
"additions": 1,
"deletions": 2,
"changed_files": 1,
"commits": 1,
"head_sha": "abc123def456",
},
}
def _client_for_enrich(existing_metadata: dict[str, Any] | None) -> tuple[Any, dict[str, Any]]:
captured: dict[str, Any] = {"created": False, "updated": []}
async def get(thread_id: str) -> dict[str, Any]:
if existing_metadata is None:
raise RuntimeError("not found")
return {"thread_id": thread_id, "metadata": existing_metadata}
async def create(**kwargs: Any) -> None:
captured["created"] = True
async def update(**kwargs: Any) -> None:
captured["updated"].append(kwargs.get("metadata"))
client = SimpleNamespace(threads=SimpleNamespace(get=get, create=create, update=update))
return client, captured
def _patch_enrich_deps(monkeypatch, *, metadata: dict[str, Any] | None) -> dict[str, Any]:
client, captured = _client_for_enrich(metadata)
monkeypatch.setattr(review_chat_api, "langgraph_client", lambda: client)
async def fake_get_review(owner, repo, pr_number):
return _fake_review()
async def fake_diff(*, owner, repo, pr_number, token):
return "diff --git a/x b/x\n+added\n"
async def fake_token(repositories=None):
return "app-token"
monkeypatch.setattr(review_chat_api, "get_review", fake_get_review)
monkeypatch.setattr(review_chat_api, "fetch_pr_diff", fake_diff)
monkeypatch.setattr(review_chat_api, "get_github_app_installation_token", fake_token)
return captured
@pytest.mark.asyncio
async def test_enrich_chat_command_seeds_context_on_create(monkeypatch) -> None:
_patch_enrich_deps(monkeypatch, metadata=None)
command = {"method": "run.start", "params": {"input": {"messages": []}}}
enriched = await review_chat_api._enrich_chat_command(
command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1"
)
params = enriched["params"]
assert params["assistant_id"] == "chat"
configurable = params["config"]["configurable"]
assert configurable["chat_repo_owner"] == "acme"
assert configurable["chat_repo_name"] == "repo"
assert configurable["chat_pr_number"] == 7
assert configurable["chat_head_sha"] == "abc123def456"
assert configurable["reviewer_thread_id"] == review_chat_api.reviewer_thread_id(
"acme", "repo", 7
)
files = params["input"]["files"]
assert set(files) == {"/pr/overview.md", "/pr/diff.patch", "/pr/findings.md"}
@pytest.mark.asyncio
async def test_enrich_chat_command_reuses_context_when_head_unchanged(monkeypatch) -> None:
# Stored head matches the current review head -> no reseed.
captured = _patch_enrich_deps(
monkeypatch, metadata={"kind": "review_chat", "chat_head_sha": "abc123def456"}
)
command = {"method": "run.start", "params": {"input": {"messages": []}}}
enriched = await review_chat_api._enrich_chat_command(
command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1"
)
params = enriched["params"]
# No re-seeding of files when the head hasn't moved.
assert "files" not in params["input"]
assert params["config"]["configurable"]["chat_head_sha"] == "abc123def456"
assert captured["created"] is False
assert captured["updated"] == []
@pytest.mark.asyncio
async def test_enrich_chat_command_reseeds_on_head_change(monkeypatch) -> None:
# Stored head is stale relative to the current review head -> reseed.
captured = _patch_enrich_deps(
monkeypatch, metadata={"kind": "review_chat", "chat_head_sha": "old-stale-sha"}
)
command = {"method": "run.start", "params": {"input": {"messages": []}}}
enriched = await review_chat_api._enrich_chat_command(
command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1"
)
params = enriched["params"]
files = params["input"]["files"]
assert set(files) == {"/pr/overview.md", "/pr/diff.patch", "/pr/findings.md"}
assert params["config"]["configurable"]["chat_head_sha"] == "abc123def456"
assert {"chat_head_sha": "abc123def456"} in captured["updated"]
@pytest.mark.asyncio
async def test_enrich_chat_command_ignores_non_run_start(monkeypatch) -> None:
_patch_enrich_deps(monkeypatch, metadata=None)
command = {"method": "something.else", "params": {}}
enriched = await review_chat_api._enrich_chat_command(
command, owner="acme", repo="repo", pr_number=7, login="octocat", thread_id="ct-1"
)
assert enriched == command
assert "assistant_id" not in enriched["params"]
@pytest.mark.asyncio
async def test_proxy_state_normalizes_missing_thread(monkeypatch) -> None:
async def fake_passthrough(method, thread_id, suffix, body, content_type):
return 404, b"not found", "text/plain"
async def no_thread(thread_id: str) -> None:
return None
monkeypatch.setattr(review_chat_api, "_proxy_passthrough", fake_passthrough)
monkeypatch.setattr(review_chat_api, "_get_chat_thread_metadata", no_thread)
status, content, media_type = await review_chat_api.proxy_review_chat_state(
"acme", "repo", 7, "octocat", "ct-1"
)
assert status == 200
assert b'"next": []' in content
@pytest.mark.asyncio
async def test_proxy_history_normalizes_missing_thread(monkeypatch) -> None:
async def fake_passthrough(method, thread_id, suffix, body, content_type):
return 404, b"not found", "text/plain"
async def no_thread(thread_id: str) -> None:
return None
monkeypatch.setattr(review_chat_api, "_proxy_passthrough", fake_passthrough)
monkeypatch.setattr(review_chat_api, "_get_chat_thread_metadata", no_thread)
status, content, _ = await review_chat_api.proxy_review_chat_history(
"acme", "repo", 7, "octocat", "ct-1", b"{}"
)
assert status == 200
assert content == b"[]"
@pytest.mark.asyncio
async def test_proxy_state_rejects_foreign_thread(monkeypatch) -> None:
async def other_owner(thread_id: str) -> dict[str, Any]:
return {
"kind": "review_chat",
"github_login": "hubot",
"repo_owner": "acme",
"repo_name": "repo",
"pr_number": 7,
}
async def fake_passthrough(*args, **kwargs):
raise AssertionError("must not proxy a thread the caller doesn't own")
monkeypatch.setattr(review_chat_api, "_get_chat_thread_metadata", other_owner)
monkeypatch.setattr(review_chat_api, "_proxy_passthrough", fake_passthrough)
with pytest.raises(Exception): # noqa: B017,PT011 - HTTPException(404)
await review_chat_api.proxy_review_chat_state("acme", "repo", 7, "octocat", "ct-1")
# --- graph factory guard -----------------------------------------------------
def test_get_chat_agent_returns_trivial_when_not_for_execution() -> None:
from agent.chat import get_chat_agent
graph = asyncio.run(get_chat_agent({"configurable": {"thread_id": None}}))
assert graph is not None

View file

@ -9,8 +9,12 @@ from agent.dashboard.team_settings import (
ORG_GUIDELINES_MAX_CHARS,
TeamSettingsUpdate,
get_org_review_guidelines,
get_team_default_model,
)
_AGENT_PAIR = ("anthropic:claude-opus-4-8", "high")
_CHAT_PAIR = ("google_genai:gemini-3.5-flash", "low")
def test_org_guidelines_blank_normalizes_to_none() -> None:
assert TeamSettingsUpdate(org_guidelines=" ").org_guidelines is None
@ -45,3 +49,69 @@ async def test_get_org_review_guidelines_returns_none_when_unset() -> None:
return_value={"org_guidelines": None},
):
assert await get_org_review_guidelines() is None
def _settings(**overrides: object) -> dict[str, object]:
base = {
"default_agent_model": _AGENT_PAIR[0],
"default_agent_reasoning_effort": _AGENT_PAIR[1],
"default_chat_model": None,
"default_chat_reasoning_effort": None,
}
base.update(overrides)
return base
@pytest.mark.asyncio
async def test_chat_default_inherits_agent_when_unset() -> None:
with patch(
"agent.dashboard.team_settings.get_team_settings",
new_callable=AsyncMock,
return_value=_settings(),
):
assert await get_team_default_model("chat") == _AGENT_PAIR
@pytest.mark.asyncio
async def test_chat_default_uses_chat_model_when_set() -> None:
with patch(
"agent.dashboard.team_settings.get_team_settings",
new_callable=AsyncMock,
return_value=_settings(
default_chat_model=_CHAT_PAIR[0],
default_chat_reasoning_effort=_CHAT_PAIR[1],
),
):
assert await get_team_default_model("chat") == _CHAT_PAIR
@pytest.mark.asyncio
async def test_chat_default_inherits_agent_when_chat_model_invalid() -> None:
with patch(
"agent.dashboard.team_settings.get_team_settings",
new_callable=AsyncMock,
return_value=_settings(
default_chat_model="bogus:model",
default_chat_reasoning_effort="high",
),
):
assert await get_team_default_model("chat") == _AGENT_PAIR
def test_team_settings_update_accepts_chat_pair() -> None:
update = TeamSettingsUpdate(
default_chat_model=_CHAT_PAIR[0],
default_chat_reasoning_effort=_CHAT_PAIR[1],
)
assert update.default_chat_model == _CHAT_PAIR[0]
assert update.default_chat_reasoning_effort == _CHAT_PAIR[1]
def test_team_settings_update_rejects_chat_effort_without_model() -> None:
with pytest.raises(ValidationError):
TeamSettingsUpdate(default_chat_reasoning_effort="high")
def test_team_settings_update_rejects_unsupported_chat_effort() -> None:
with pytest.raises(ValidationError):
TeamSettingsUpdate(default_chat_model=_CHAT_PAIR[0], default_chat_reasoning_effort="max")

View file

@ -0,0 +1,641 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { StreamProvider, useStreamContext } from "@langchain/react"
import { overrideFetchImplementation } from "@langchain/langgraph-sdk"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
ArrowClockwiseIcon,
ArrowUpIcon,
CheckIcon,
PlusIcon,
SparkleIcon,
TrashIcon,
XIcon,
} from "@phosphor-icons/react"
import { Menu } from "@base-ui/react/menu"
import type { BaseMessage } from "@langchain/core/messages"
import type { ReviewChatThread } from "@/lib/api"
import { Markdown } from "@/components/agents/ported"
import { IconButton } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Skeleton } from "@/components/ui/skeleton"
import { api, reviewChatApiBase } from "@/lib/api"
import { cn } from "@/lib/utils"
const dashboardFetch: typeof fetch = (input, init) =>
fetch(input, { ...init, credentials: "include" })
// The SDK's internal Client issues some reads (getState, history) outside the
// transport's fetch; without this they drop the session cookie cross-origin.
overrideFetchImplementation(dashboardFetch)
const SUGGESTED_PROMPTS = [
"Summarize the changes in this PR",
"Walk me through the review findings",
"What are the riskiest parts of this change?",
]
// Kept in sync with the backend's `_derive_title` so the optimistic tab label
// matches the title the server persists for the thread.
const DEFAULT_TITLE = "New chat"
const TITLE_MAX_CHARS = 60
function deriveTitle(text: string): string {
const flattened = text.trim().split(/\s+/).join(" ")
return flattened ? flattened.slice(0, TITLE_MAX_CHARS) : DEFAULT_TITLE
}
function messageType(message: BaseMessage): string {
const candidate = message as unknown as {
getType?: () => string
type?: string
role?: string
}
return candidate.getType?.() ?? candidate.type ?? candidate.role ?? "ai"
}
function messageText(content: BaseMessage["content"]): string {
if (typeof content === "string") return content
if (!Array.isArray(content)) return ""
return content
.map((block) => {
if (typeof block === "string") return block
if (typeof block === "object" && "text" in block) {
const text = (block as { text?: unknown }).text
return typeof text === "string" ? text : ""
}
return ""
})
.filter(Boolean)
.join("\n")
}
// --- Conversation store ------------------------------------------------------
//
// The client owns the conversation list. Each conversation is a client-minted
// thread id; the thread is created server-side lazily on its first message.
// The server thread list is only used to recover conversations on a fresh
// browser and to reconcile titles — it is never the sole source for the tabs,
// so an empty/lagging server response can no longer make open chats vanish.
interface Conversation {
id: string
title: string
createdAt: number
}
interface ChatState {
conversations: Array<Conversation>
activeId: string
}
function storageKey(owner: string, repo: string, number: number): string {
return `osw:review-chat:${owner}/${repo}/${number}`
}
function newDraft(): Conversation {
return { id: crypto.randomUUID(), title: DEFAULT_TITLE, createdAt: Date.now() }
}
function isConversation(value: unknown): value is Conversation {
if (!value || typeof value !== "object") return false
const candidate = value as Record<string, unknown>
return (
typeof candidate.id === "string" &&
typeof candidate.title === "string" &&
typeof candidate.createdAt === "number"
)
}
function loadState(key: string): ChatState {
if (typeof window !== "undefined") {
try {
const raw = window.localStorage.getItem(key)
if (raw) {
const parsed: unknown = JSON.parse(raw)
const source = (parsed ?? {}) as Record<string, unknown>
const conversations = Array.isArray(source.conversations)
? source.conversations.filter(isConversation)
: []
const first = conversations[0]
if (first) {
const activeId =
typeof source.activeId === "string" &&
conversations.some((c) => c.id === source.activeId)
? source.activeId
: first.id
return { conversations, activeId }
}
}
} catch {
/* fall through to a fresh draft */
}
}
const draft = newDraft()
return { conversations: [draft], activeId: draft.id }
}
function saveState(key: string, state: ChatState): void {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(key, JSON.stringify(state))
} catch {
/* ignore quota / availability errors */
}
}
function useConversations(key: string) {
const [state, setState] = useState<ChatState>(() => loadState(key))
const update = useCallback(
(fn: (prev: ChatState) => ChatState) => {
setState((prev) => {
const next = fn(prev)
if (next === prev) return prev
saveState(key, next)
return next
})
},
[key],
)
const select = useCallback(
(conversation: Conversation) => {
update((prev) => ({
conversations: prev.conversations.some((c) => c.id === conversation.id)
? prev.conversations
: [...prev.conversations, conversation],
activeId: conversation.id,
}))
},
[update],
)
const newChat = useCallback(() => {
update((prev) => {
const active = prev.conversations.find((c) => c.id === prev.activeId)
// Reuse a pristine, never-sent draft instead of stacking empty tabs.
if (active && active.title === DEFAULT_TITLE) return prev
const draft = newDraft()
return { conversations: [...prev.conversations, draft], activeId: draft.id }
})
}, [update])
// Names a conversation from its first message; later messages don't rename it.
const nameConversation = useCallback(
(id: string, title: string) => {
update((prev) => {
const current = prev.conversations.find((c) => c.id === id)
if (!current || current.title !== DEFAULT_TITLE) return prev
return {
...prev,
conversations: prev.conversations.map((c) =>
c.id === id ? { ...c, title } : c,
),
}
})
},
[update],
)
const close = useCallback(
(id: string) => {
update((prev) => {
const index = prev.conversations.findIndex((c) => c.id === id)
if (index === -1) return prev
const remaining = prev.conversations.filter((c) => c.id !== id)
const fallback = remaining[Math.max(0, index - 1)]
if (!fallback) {
const draft = newDraft()
return { conversations: [draft], activeId: draft.id }
}
return {
conversations: remaining,
activeId: prev.activeId === id ? fallback.id : prev.activeId,
}
})
},
[update],
)
return { ...state, select, newChat, nameConversation, close }
}
// --- View --------------------------------------------------------------------
function EmptyState({ onPick }: { onPick: (prompt: string) => void }) {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<p className="text-sm text-foreground">
I've reviewed this PR. Ask me about the diff, the findings, or the
surrounding code — I have read-only access to the repository.
</p>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-muted-foreground">
Suggested prompts
</span>
{SUGGESTED_PROMPTS.map((prompt) => (
<button
key={prompt}
type="button"
onClick={() => onPick(prompt)}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-foreground hover:bg-muted/60"
>
<SparkleIcon className="size-4 shrink-0 text-muted-foreground" />
{prompt}
</button>
))}
</div>
</div>
)
}
function LoadingState() {
return (
<div className="flex flex-1 flex-col gap-4 p-4">
<div className="flex justify-end">
<Skeleton className="h-12 w-2/5 rounded-lg" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/5" />
</div>
</div>
)
}
function ChatBody({
onUserSend,
expectsHistory,
}: {
onUserSend: (text: string) => void
expectsHistory: boolean
}) {
const stream = useStreamContext()
const [value, setValue] = useState("")
const endRef = useRef<HTMLDivElement>(null)
const messages = stream.messages
const busy = stream.isLoading
// True during the one-time getState hydration when switching to / loading an
// existing thread, before its messages have arrived.
const hydrating = stream.isThreadLoading
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
const send = useCallback(
(text: string) => {
const trimmed = text.trim()
if (!trimmed || busy) return
onUserSend(trimmed)
void stream.submit({ messages: [{ type: "human", content: trimmed }] })
},
[busy, stream, onUserSend],
)
const visible = messages.filter((message) => {
const type = messageType(message)
if (type !== "human" && type !== "ai") return false
return messageText(message.content).trim().length > 0
})
const submitComposer = () => {
send(value)
setValue("")
}
// Show the loading placeholder (not the empty/intro state) while an existing
// conversation hydrates, so a chat with messages never flashes its greeting.
const showEmpty = visible.length === 0 && !busy
const showLoading = showEmpty && hydrating && expectsHistory
return (
<div className="flex flex-1 flex-col overflow-hidden">
{showLoading ? (
<LoadingState />
) : showEmpty ? (
<EmptyState onPick={send} />
) : (
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
{visible.map((message, index) => {
const isUser = messageType(message) === "human"
return (
<div
key={message.id ?? index}
className={cn("flex", isUser ? "justify-end" : "justify-start")}
>
<div
className={cn(
"text-sm text-foreground",
isUser
? "max-w-[85%] rounded-lg bg-muted px-3 py-2"
: "w-full",
)}
>
{isUser ? (
<span className="whitespace-pre-wrap">
{messageText(message.content)}
</span>
) : (
<Markdown content={messageText(message.content)} />
)}
</div>
</div>
)
})}
{busy && (
<div className="flex justify-start">
<div className="px-3 py-2 text-sm text-muted-foreground">
Thinking…
</div>
</div>
)}
<div ref={endRef} />
</div>
)}
<div className="p-3">
<div className="flex items-end gap-2 rounded-2xl border border-border bg-background py-1.5 pl-3.5 pr-1.5 transition-colors focus-within:border-ring/60">
<Textarea
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
submitComposer()
}
}}
placeholder="Ask anything about this PR…"
rows={1}
className="max-h-40 min-h-7 flex-1 resize-none rounded-none border-0 bg-transparent px-0 py-1 shadow-none focus-visible:border-transparent focus-visible:ring-0"
/>
<IconButton
type="button"
onClick={submitComposer}
disabled={!value.trim() || busy}
aria-label="Send message"
className="rounded-full"
>
<ArrowUpIcon className="size-4" />
</IconButton>
</div>
</div>
</div>
)
}
function ChatPanel({
owner,
repo,
number,
assistantId,
}: {
owner: string
repo: string
number: number
assistantId: string
}) {
const qc = useQueryClient()
const threadsKey = useMemo(
() => ["review-chat-threads", owner, repo, number] as const,
[owner, repo, number],
)
const { conversations, activeId, select, newChat, nameConversation, close } =
useConversations(storageKey(owner, repo, number))
const threadsQuery = useQuery({
queryKey: threadsKey,
queryFn: () => api.listReviewChatThreads(owner, repo, number),
})
const serverThreads = useMemo(
() => threadsQuery.data?.threads ?? [],
[threadsQuery.data],
)
const invalidateThreads = useCallback(() => {
void qc.invalidateQueries({ queryKey: threadsKey })
}, [qc, threadsKey])
const deleteThread = useMutation({
mutationFn: (id: string) =>
api.deleteReviewChatThread(owner, repo, number, id),
onMutate: (id: string) => {
qc.setQueryData<{ threads: Array<ReviewChatThread> }>(threadsKey, (old) =>
old ? { threads: old.threads.filter((t) => t.thread_id !== id) } : old,
)
},
onSettled: invalidateThreads,
})
// Open tabs are the client's conversations (titles reconciled from the
// server). The history dropdown is the full set — open tabs plus every other
// conversation the server knows about (e.g. recovered on a fresh browser) —
// newest first, so past chats are reachable without cluttering the tab strip.
const { openTabs, historyItems } = useMemo(() => {
const byId = new Map(conversations.map((c) => [c.id, { ...c }]))
for (const thread of serverThreads) {
const existing = byId.get(thread.thread_id)
if (existing) {
if (thread.title && thread.title !== DEFAULT_TITLE) {
existing.title = thread.title
}
} else {
byId.set(thread.thread_id, {
id: thread.thread_id,
title: thread.title || DEFAULT_TITLE,
createdAt: thread.updated_at ? Date.parse(thread.updated_at) || 0 : 0,
})
}
}
return {
openTabs: conversations.map((c) => byId.get(c.id) ?? c),
historyItems: [...byId.values()].sort((a, b) => b.createdAt - a.createdAt),
}
}, [conversations, serverThreads])
const handleClose = useCallback(
(id: string) => {
if (serverThreads.some((t) => t.thread_id === id)) {
deleteThread.mutate(id)
}
close(id)
},
[serverThreads, deleteThread, close],
)
const handleUserSend = useCallback(
(text: string) => {
nameConversation(activeId, deriveTitle(text))
},
[nameConversation, activeId],
)
// Whether the active conversation should already have messages: it exists
// server-side, or it's been named by a sent message. Drives the hydration
// placeholder so an existing chat never flashes the intro state on load.
const expectsHistory = useMemo(() => {
if (serverThreads.some((t) => t.thread_id === activeId)) return true
const active = conversations.find((c) => c.id === activeId)
return active !== undefined && active.title !== DEFAULT_TITLE
}, [serverThreads, conversations, activeId])
return (
<div className="flex h-full flex-1 flex-col overflow-hidden">
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
<div className="flex flex-1 items-center gap-1 overflow-x-auto">
{openTabs.map((tab) => (
<div
key={tab.id}
className={cn(
"flex shrink-0 items-center gap-1 rounded-md py-1 pl-2 pr-1 text-xs",
tab.id === activeId
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50",
)}
>
<button
type="button"
onClick={() => select(tab)}
className="max-w-[140px] truncate"
>
{tab.title}
</button>
<IconButton
type="button"
variant="ghost"
size="icon-xs"
aria-label="Close chat"
onClick={() => handleClose(tab.id)}
>
<XIcon />
</IconButton>
</div>
))}
</div>
<IconButton
type="button"
variant="ghost"
size="icon-sm"
aria-label="New chat"
onClick={newChat}
>
<PlusIcon />
</IconButton>
<Menu.Root
onOpenChange={(open) => {
if (open) void threadsQuery.refetch()
}}
>
<Menu.Trigger
render={
<IconButton
type="button"
variant="ghost"
size="icon-sm"
aria-label="Chat history"
>
<ArrowClockwiseIcon />
</IconButton>
}
/>
<Menu.Portal>
<Menu.Positioner align="end" sideOffset={6} className="z-50">
<Menu.Popup className="origin-(--transform-origin) max-h-80 w-64 overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
{historyItems.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
No conversations yet
</div>
) : (
historyItems.map((item) => (
<div
key={item.id}
className="group/hist relative flex items-stretch"
>
<Menu.Item
onClick={() => select(item)}
className="flex flex-1 cursor-default items-center gap-2 rounded-md py-1.5 pl-2 pr-8 text-xs outline-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground"
>
{item.id === activeId ? (
<CheckIcon className="size-3.5 shrink-0" />
) : (
<span className="size-3.5 shrink-0" />
)}
<span className="flex-1 truncate text-left">
{item.title}
</span>
</Menu.Item>
<IconButton
type="button"
variant="ghost"
size="icon-xs"
aria-label="Delete chat"
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/hist:opacity-100"
onClick={() => handleClose(item.id)}
>
<TrashIcon />
</IconButton>
</div>
))
)}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</div>
<StreamProvider
key={activeId}
apiUrl={reviewChatApiBase(owner, repo, number)}
assistantId={assistantId}
fetch={dashboardFetch}
threadId={activeId}
onCompleted={invalidateThreads}
>
<ChatBody onUserSend={handleUserSend} expectsHistory={expectsHistory} />
</StreamProvider>
</div>
)
}
export function ReviewChat({
owner,
repo,
number,
}: {
owner: string
repo: string
number: number
}) {
const meta = useQuery({
queryKey: ["review-chat", owner, repo, number],
queryFn: () => api.getReviewChat(owner, repo, number),
})
if (meta.isPending) {
return (
<div className="flex flex-1 flex-col gap-3 p-4">
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
)
}
if (meta.isError || !meta.data.available) {
return (
<div className="flex flex-1 items-center justify-center p-6 text-center text-xs text-muted-foreground">
Chat becomes available once the review has finished running.
</div>
)
}
return (
<ChatPanel
key={`${owner}/${repo}/${number}`}
owner={owner}
repo={repo}
number={number}
assistantId={meta.data.assistant_id}
/>
)
}

View file

@ -120,6 +120,8 @@ export interface TeamSettings {
default_reviewer_reasoning_effort?: string | null;
default_reviewer_subagent_model?: string | null;
default_reviewer_subagent_reasoning_effort?: string | null;
default_chat_model?: string | null;
default_chat_reasoning_effort?: string | null;
updated_at?: string | null;
}
@ -379,6 +381,35 @@ export interface ReviewDiffPayload {
truncated: boolean;
}
export interface ReviewChatMeta {
available: boolean;
assistant_id: string;
}
export interface ReviewChatThread {
thread_id: string;
title: string;
updated_at?: string | null;
}
/**
* Absolute base URL for the PR chat's LangGraph StreamProvider. The SDK builds
* request URLs as `new URL(apiUrl + path)`, so this must be absolute — a
* same-origin base is promoted using the current origin.
*/
export function reviewChatApiBase(
owner: string,
repo: string,
number: number,
): string {
const path = `${API_BASE}/dashboard/api/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`;
if (/^https?:\/\//.test(path)) return path;
if (typeof window !== "undefined") {
return `${window.location.origin}${path.startsWith("/") ? "" : "/"}${path}`;
}
return path;
}
export interface ReviewerEvalStatus {
name: string;
status: "idle" | "running" | "completed" | "failed";
@ -493,6 +524,24 @@ export const api = {
request<ReviewDiffPayload>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/diff`,
),
getReviewChat: (owner: string, repo: string, number: number) =>
request<ReviewChatMeta>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat`,
),
listReviewChatThreads: (owner: string, repo: string, number: number) =>
request<{ threads: Array<ReviewChatThread> }>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads`,
),
deleteReviewChatThread: (
owner: string,
repo: string,
number: number,
threadId: string,
) =>
request<void>(
`/reviews/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${number}/chat/threads/${encodeURIComponent(threadId)}`,
{ method: "DELETE" },
),
reReview: (owner: string, repo: string, number: number) =>
request<{
success: boolean;

View file

@ -55,16 +55,20 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<body>
<QueryClientProvider client={queryClient}>
{children ?? <Outlet />}
<TanStackDevtools
config={{ position: "bottom-right" }}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<ReactQueryDevtools initialIsOpen={false} />
{import.meta.env.VITE_DEVTOOLS !== "false" && (
<>
<TanStackDevtools
config={{ position: "bottom-right" }}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<ReactQueryDevtools initialIsOpen={false} />
</>
)}
</QueryClientProvider>
<Scripts />
</body>

View file

@ -559,6 +559,31 @@ function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
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."
models={models}
model={settings.data?.default_chat_model ?? null}
effort={settings.data?.default_chat_reasoning_effort ?? null}
inheritLabel="Agent default"
onInherit={() =>
settings.data &&
save.mutate({
...settings.data,
default_chat_model: null,
default_chat_reasoning_effort: null,
})
}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_chat_model: model,
default_chat_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
@ -573,8 +598,17 @@ interface RolePickerProps {
effort: string | null
onChange: (model: string, effort: string) => void
disabled: boolean
/**
* When set, the model dropdown gains a leading "inherit" option with this
* label. Selecting it calls {@link onInherit} (clearing the override); an
* unset `model` renders as this option.
*/
inheritLabel?: string
onInherit?: () => void
}
const INHERIT_VALUE = "__inherit__"
function RolePicker({
label,
description,
@ -583,20 +617,30 @@ function RolePicker({
effort,
onChange,
disabled,
inheritLabel,
onInherit,
}: RolePickerProps) {
const [localModel, setLocalModel] = useState<string>(model ?? "")
const inheritFallback = inheritLabel ? INHERIT_VALUE : ""
const [localModel, setLocalModel] = useState<string>(model ?? inheritFallback)
const [localEffort, setLocalEffort] = useState<string>(effort ?? "")
useEffect(() => {
setLocalModel(model ?? "")
setLocalModel(model ?? inheritFallback)
setLocalEffort(effort ?? "")
}, [model, effort])
}, [model, effort, inheritFallback])
const isInherit = localModel === INHERIT_VALUE
const selectedModel = models.find((m) => m.id === localModel)
const availableEfforts = selectedModel?.efforts ?? []
const handleModelChange = (value: string | null) => {
if (!value) return
if (value === INHERIT_VALUE) {
setLocalModel(INHERIT_VALUE)
setLocalEffort("")
onInherit?.()
return
}
const nextModel = models.find((m) => m.id === value)
if (!nextModel) return
const nextEffort = nextModel.efforts.includes(localEffort)
@ -608,7 +652,7 @@ function RolePicker({
}
const handleEffortChange = (value: string | null) => {
if (!value || !localModel) return
if (!value || !localModel || isInherit) return
setLocalEffort(value)
onChange(localModel, value)
}
@ -628,6 +672,9 @@ function RolePicker({
<SelectValue />
</SelectTrigger>
<SelectContent>
{inheritLabel && (
<SelectItem value={INHERIT_VALUE}>{inheritLabel}</SelectItem>
)}
{models.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.label}
@ -638,7 +685,7 @@ function RolePicker({
<Select
value={localEffort}
onValueChange={handleEffortChange}
disabled={disabled || !localModel}
disabled={disabled || !localModel || isInherit}
>
<SelectTrigger className="w-28">
<SelectValue placeholder="effort" />

View file

@ -35,6 +35,7 @@ import type {
ReviewUserRef,
} from "@/lib/api"
import { Markdown } from "@/components/agents/ported"
import { ReviewChat } from "@/components/agents/ReviewChat"
import { useRegisterReviewSidebar } from "@/components/agents/ReviewSidebar"
import {
useDiffOptions,
@ -81,7 +82,9 @@ function findingSide(finding: ReviewFinding): "deletions" | "additions" {
return finding.side === "LEFT" ? "deletions" : "additions"
}
function findingSelectedRange(finding: ReviewFinding): SelectedLineRange | null {
function findingSelectedRange(
finding: ReviewFinding
): SelectedLineRange | null {
if (finding.end_line === null) return null
const side = findingSide(finding)
return {
@ -825,6 +828,94 @@ function Badgeish({ children }: { children: React.ReactNode }) {
)
}
const REVIEW_PANEL_STORAGE_WIDTH = "open-swe.review-panel.width"
const REVIEW_PANEL_DEFAULT_WIDTH = 420
const REVIEW_PANEL_MIN_WIDTH = 360
// Keep at least this much room for the PR content column so the panel can grow
// wide without squeezing the diff/description below a usable width.
const REVIEW_PANEL_MIN_MAIN_WIDTH = 480
function reviewPanelMaxWidth(availableWidth?: number): number {
if (typeof window === "undefined") return REVIEW_PANEL_DEFAULT_WIDTH
const available = availableWidth ?? window.innerWidth
return Math.max(
REVIEW_PANEL_MIN_WIDTH,
available - REVIEW_PANEL_MIN_MAIN_WIDTH
)
}
function clampReviewPanelWidth(width: number, availableWidth?: number): number {
return Math.min(
reviewPanelMaxWidth(availableWidth),
Math.max(REVIEW_PANEL_MIN_WIDTH, width)
)
}
function readStoredReviewPanelWidth(): number {
if (typeof window === "undefined") return REVIEW_PANEL_DEFAULT_WIDTH
const raw = window.localStorage.getItem(REVIEW_PANEL_STORAGE_WIDTH)
const parsed = raw ? Number(raw) : NaN
if (!Number.isFinite(parsed)) return REVIEW_PANEL_DEFAULT_WIDTH
return clampReviewPanelWidth(parsed)
}
function ReviewPanelResizeHandle({
width,
onResize,
}: {
width: number
onResize: (next: number) => void
}) {
const startRef = useRef<{ x: number; width: number } | null>(null)
const [dragging, setDragging] = useState(false)
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault()
startRef.current = { x: e.clientX, width }
setDragging(true)
e.currentTarget.setPointerCapture(e.pointerId)
}
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!startRef.current) return
onResize(startRef.current.width - (e.clientX - startRef.current.x))
}
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
startRef.current = null
setDragging(false)
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId)
}
}
useEffect(() => {
if (!dragging) return
const prev = document.body.style.cursor
document.body.style.cursor = "col-resize"
return () => {
document.body.style.cursor = prev
}
}, [dragging])
return (
<div
role="separator"
aria-orientation="vertical"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={cn(
"absolute inset-y-0 left-0 z-20 w-1 cursor-col-resize touch-none select-none",
"after:absolute after:inset-y-0 after:left-0 after:w-px after:bg-transparent after:transition-colors",
"hover:after:bg-border",
dragging && "after:bg-border"
)}
/>
)
}
function SidePanel({
detail,
tab,
@ -852,130 +943,162 @@ function SidePanel({
},
})
const panelRef = useRef<HTMLDivElement>(null)
const [width, setWidthState] = useState(() => readStoredReviewPanelWidth())
const setWidth = useCallback((next: number) => {
const available = panelRef.current?.parentElement?.clientWidth
const clamped = clampReviewPanelWidth(next, available)
setWidthState(clamped)
if (typeof window !== "undefined") {
window.localStorage.setItem(REVIEW_PANEL_STORAGE_WIDTH, String(clamped))
}
}, [])
// Re-clamp against the real container width on mount and on window resize so
// the panel can never squeeze the PR content below its minimum.
useEffect(() => {
if (typeof window === "undefined") return
const reclamp = () => setWidth(width)
reclamp()
window.addEventListener("resize", reclamp)
return () => window.removeEventListener("resize", reclamp)
}, [setWidth, width])
const bugs = detail.findings.filter((f) => f.group === "bug")
const flags = detail.findings.filter((f) => f.group !== "bug")
const openBugs = bugs.filter((f) => f.status === "open")
const openFlags = flags.filter((f) => f.status === "open")
return (
<aside
className={cn(
"sticky top-0 hidden h-full w-[420px] shrink-0 flex-col overflow-y-auto border-l border-border transition-opacity xl:flex",
dimmed && "pointer-events-none opacity-30"
)}
<div
ref={panelRef}
style={{ width }}
className="sticky top-0 hidden h-full shrink-0 xl:flex"
>
<div className="flex items-center gap-1 border-b border-border px-3 py-2">
{(
[
["info", "Info"],
["chat", "Chat"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
onClick={() => onTabChange(id)}
className={cn(
"rounded-md px-2.5 py-1 text-xs transition-colors",
tab === id
? "bg-muted font-medium text-foreground"
: "text-muted-foreground hover:bg-muted/50"
)}
>
{label}
</button>
))}
</div>
{tab === "chat" ? (
<div className="flex flex-1 items-center justify-center p-6 text-xs text-muted-foreground">
Coming Soon
</div>
) : (
<div className="divide-y divide-border">
<section className="px-3 py-3">
<div className="flex items-center justify-between text-xs">
<span className="font-medium">
{detail.status === "running"
? "PR analysis in progress"
: detail.status === "error"
? "PR analysis failed"
: "PR analysis complete"}
</span>
<button
type="button"
onClick={() => reReview.mutate()}
disabled={reReview.isPending || detail.status === "running"}
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
>
<ArrowClockwiseIcon className="size-3" />
Re-review
</button>
</div>
<div className="mt-2 space-y-1 text-[11px] text-muted-foreground">
<div>Reviewing commit {detail.head_sha.slice(0, 7) || "—"}</div>
{detail.watch && <div>Watching for new pushes</div>}
{reReview.error && (
<div className="text-destructive">{reReview.error.message}</div>
<ReviewPanelResizeHandle width={width} onResize={setWidth} />
<aside
className={cn(
"flex h-full w-full flex-col overflow-y-auto border-l border-border transition-opacity",
dimmed && "pointer-events-none opacity-30"
)}
>
<div className="flex items-center gap-1 border-b border-border px-3 py-2">
{(
[
["info", "Info"],
["chat", "Chat"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
onClick={() => onTabChange(id)}
className={cn(
"rounded-md px-2.5 py-1 text-xs transition-colors",
tab === id
? "bg-muted font-medium text-foreground"
: "text-muted-foreground hover:bg-muted/50"
)}
</div>
</section>
>
{label}
</button>
))}
</div>
<FindingSection
icon={BugBeetleIcon}
label={`${openBugs.length} Bug${openBugs.length === 1 ? "" : "s"}`}
emptyLabel="No bugs found."
findings={bugs}
read={read}
onFindingClick={onFindingClick}
{tab === "chat" ? (
<ReviewChat
owner={detail.owner}
repo={detail.repo}
number={detail.number}
/>
<FindingSection
icon={FlagIcon}
label={`${openFlags.length} Flag${openFlags.length === 1 ? "" : "s"}`}
emptyLabel="No issues found."
findings={flags}
read={read}
onFindingClick={onFindingClick}
action={
detail.findings.length > 0 ? (
) : (
<div className="divide-y divide-border">
<section className="px-3 py-3">
<div className="flex items-center justify-between text-xs">
<span className="font-medium">
{detail.status === "running"
? "PR analysis in progress"
: detail.status === "error"
? "PR analysis failed"
: "PR analysis complete"}
</span>
<button
type="button"
onClick={onMarkAllRead}
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => reReview.mutate()}
disabled={reReview.isPending || detail.status === "running"}
className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
>
Mark all as read
<ArrowClockwiseIcon className="size-3" />
Re-review
</button>
) : null
}
/>
<ChecksSection checks={detail.checks} />
<PeopleSection
title="Reviewers"
people={detail.pr.requested_reviewers}
/>
<PeopleSection title="Assignees" people={detail.pr.assignees} />
<section className="px-3 py-3">
<h3 className="mb-2 text-xs font-medium">Labels</h3>
{detail.pr.labels.length === 0 ? (
<p className="text-[11px] text-muted-foreground">None</p>
) : (
<div className="flex flex-wrap gap-1">
{detail.pr.labels.map((label) => (
<span
key={label.name}
className="rounded-full border border-border px-2 py-0.5 text-[11px]"
>
{label.name}
</span>
))}
</div>
)}
</section>
</div>
)}
</aside>
<div className="mt-2 space-y-1 text-[11px] text-muted-foreground">
<div>Reviewing commit {detail.head_sha.slice(0, 7) || "—"}</div>
{detail.watch && <div>Watching for new pushes</div>}
{reReview.error && (
<div className="text-destructive">
{reReview.error.message}
</div>
)}
</div>
</section>
<FindingSection
icon={BugBeetleIcon}
label={`${openBugs.length} Bug${openBugs.length === 1 ? "" : "s"}`}
emptyLabel="No bugs found."
findings={bugs}
read={read}
onFindingClick={onFindingClick}
/>
<FindingSection
icon={FlagIcon}
label={`${openFlags.length} Flag${openFlags.length === 1 ? "" : "s"}`}
emptyLabel="No issues found."
findings={flags}
read={read}
onFindingClick={onFindingClick}
action={
detail.findings.length > 0 ? (
<button
type="button"
onClick={onMarkAllRead}
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground hover:text-foreground"
>
Mark all as read
</button>
) : null
}
/>
<ChecksSection checks={detail.checks} />
<PeopleSection
title="Reviewers"
people={detail.pr.requested_reviewers}
/>
<PeopleSection title="Assignees" people={detail.pr.assignees} />
<section className="px-3 py-3">
<h3 className="mb-2 text-xs font-medium">Labels</h3>
{detail.pr.labels.length === 0 ? (
<p className="text-[11px] text-muted-foreground">None</p>
) : (
<div className="flex flex-wrap gap-1">
{detail.pr.labels.map((label) => (
<span
key={label.name}
className="rounded-full border border-border px-2 py-0.5 text-[11px]"
>
{label.name}
</span>
))}
</div>
)}
</section>
</div>
)}
</aside>
</div>
)
}