Reviewer eval admin: configurable runs + stacked form layout (#1540)

Drive dashboard-triggered reviewer eval runs with per-run model, effort,
score mode, severity threshold, cap, limit, and concurrency overrides, plus
per-example start/finish/error logging in the eval target.

Rework the admin eval form from the label-left/control-right SettingsRow
(which crushed the description column when packing 3-4 wide inputs) into
stacked field groups with captioned inputs in a responsive grid.

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Johannes du Plessis 2026-06-16 10:12:35 -07:00 • committed by GitHub
parent 554d754585
commit e3025dee77
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1036 additions and 123 deletions

View file

@ -17,7 +17,7 @@ import sys
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal
from typing import Any, Literal, TypedDict
from langgraph_sdk import get_client
@ -27,7 +27,7 @@ EVALS_NAMESPACE: list[str] = ["evals"]
REVIEWER_EVAL_KEY = "reviewer"
DEFAULT_EVAL_PROJECT = "open-swe-evals"
_MODULE = "evals.reviewer.run_eval"
_LOG_TAIL_CHARS = 4000
_LOG_TAIL_CHARS = 12000
_EXPERIMENT_URL_RE = re.compile(r"https://\S*smith\.langchain\.com/\S+")
# The owning worker refreshes the heartbeat this often while the subprocess
# runs; a record is only reconciled as failed once its heartbeat is older than
@ -36,6 +36,37 @@ _HEARTBEAT_INTERVAL_SECONDS = 10
_HEARTBEAT_STALE_SECONDS = 60
EvalStatus = Literal["idle", "running", "completed", "failed"]
ScoreMode = Literal["all_findings", "surfaced_findings"]
Severity = Literal["low", "medium", "high", "critical"]
class ReviewerEvalConfig(TypedDict):
dataset_name: str
experiment_prefix: str
max_concurrency: int
langsmith_project: str
langgraph_url: str
assistant_id: str
model_id: str
reasoning_effort: str
score_mode: ScoreMode
severity_threshold: Severity
cap: int
DEFAULT_REVIEWER_EVAL_CONFIG: ReviewerEvalConfig = {
"dataset_name": "openswe-reviewer-v1",
"experiment_prefix": "openswe-review-confidence",
"max_concurrency": 5,
"langsmith_project": DEFAULT_EVAL_PROJECT,
"langgraph_url": "",
"assistant_id": "reviewer",
"model_id": "google_genai:gemini-3.5-flash",
"reasoning_effort": "medium",
"score_mode": "all_findings",
"severity_threshold": "medium",
"cap": 4,
}
# Identifies this process so heartbeat ownership can be reasoned about across
# workers that share the persisted record.
@ -70,12 +101,47 @@ def _eval_project() -> str:
return os.environ.get("EVAL_LANGSMITH_PROJECT") or DEFAULT_EVAL_PROJECT
def _resolve_eval_config(config: ReviewerEvalConfig | None = None) -> ReviewerEvalConfig:
resolved: ReviewerEvalConfig = {
**DEFAULT_REVIEWER_EVAL_CONFIG,
"langsmith_project": _eval_project(),
"langgraph_url": _resolve_langgraph_url() or "",
}
if config is not None:
resolved.update(config)
return resolved
def _config_cli_args(config: ReviewerEvalConfig) -> list[str]:
values: list[tuple[str, object]] = [
("--dataset-name", config["dataset_name"]),
("--experiment-prefix", config["experiment_prefix"]),
("--max-concurrency", config["max_concurrency"]),
("--langsmith-project", config["langsmith_project"]),
("--assistant-id", config["assistant_id"]),
("--model-id", config["model_id"]),
("--reasoning-effort", config["reasoning_effort"]),
("--score-mode", config["score_mode"]),
("--severity-threshold", config["severity_threshold"]),
("--cap", config["cap"]),
]
if config["langgraph_url"]:
values.append(("--langgraph-url", config["langgraph_url"]))
args: list[str] = []
for flag, value in values:
args.extend([flag, str(value)])
return args
def _idle_record() -> dict[str, Any]:
config = _resolve_eval_config()
return {
"name": REVIEWER_EVAL_KEY,
"status": "idle",
"langsmith_project": _eval_project(),
"run_name": config["experiment_prefix"],
"langsmith_project": config["langsmith_project"],
"limit": None,
"config_snapshot": config,
"started_at": None,
"finished_at": None,
"created_by": None,
@ -163,6 +229,7 @@ async def get_reviewer_eval_status() -> dict[str, Any]:
async def start_reviewer_eval(
*,
limit: int | None,
config: ReviewerEvalConfig | None = None,
created_by: str,
) -> dict[str, Any]:
"""Launch the reviewer eval subprocess and persist a ``running`` record.
@ -176,10 +243,12 @@ async def start_reviewer_eval(
if existing and existing.get("status") == "running" and _is_heartbeat_fresh(existing):
raise RuntimeError("a reviewer eval is already running")
project = _eval_project()
config_snapshot = _resolve_eval_config(config)
project = config_snapshot["langsmith_project"]
cmd = [sys.executable, "-m", _MODULE]
if limit is not None and limit > 0:
cmd += ["--limit", str(limit)]
cmd += _config_cli_args(config_snapshot)
env = {
**os.environ,
@ -205,6 +274,10 @@ async def start_reviewer_eval(
{
**_idle_record(),
"status": "failed",
"run_name": config_snapshot["experiment_prefix"],
"langsmith_project": project,
"limit": limit,
"config_snapshot": config_snapshot,
"finished_at": _now_iso(),
"created_by": created_by,
"error": f"Failed to launch eval: {exc}",
@ -216,8 +289,10 @@ async def start_reviewer_eval(
{
**_idle_record(),
"status": "running",
"run_name": config_snapshot["experiment_prefix"],
"langsmith_project": project,
"limit": limit,
"config_snapshot": config_snapshot,
"started_at": _now_iso(),
"finished_at": None,
"created_by": created_by,
@ -226,7 +301,14 @@ async def start_reviewer_eval(
"heartbeat": _now_iso(),
}
)
asyncio.create_task(_monitor(proc, created_by=created_by, limit=limit, project=project))
asyncio.create_task(
_monitor(
proc,
created_by=created_by,
limit=limit,
config_snapshot=config_snapshot,
)
)
return record
@ -295,7 +377,7 @@ async def _monitor(
*,
created_by: str,
limit: int | None,
project: str,
config_snapshot: ReviewerEvalConfig,
) -> None:
heartbeat = asyncio.create_task(_heartbeat_loop(proc))
tail = ""
@ -322,8 +404,10 @@ async def _monitor(
{
**record,
"status": status,
"langsmith_project": project,
"run_name": config_snapshot["experiment_prefix"],
"langsmith_project": config_snapshot["langsmith_project"],
"limit": limit,
"config_snapshot": config_snapshot,
"created_by": created_by,
"finished_at": _now_iso(),
"pid": proc.pid,

View file

@ -5,12 +5,12 @@ from __future__ import annotations
import hmac
import logging
import os
from typing import Any
from typing import Any, Literal
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, Response, StreamingResponse
from pydantic import BaseModel
from pydantic import BaseModel, field_validator, model_validator
from .admin import is_admin
from .agent_instructions import (
@ -33,6 +33,8 @@ from .enabled_repos import (
set_review_repo_enabled,
)
from .eval_jobs import (
DEFAULT_REVIEWER_EVAL_CONFIG,
ReviewerEvalConfig,
cancel_reviewer_eval,
get_reviewer_eval_status,
start_reviewer_eval,
@ -54,7 +56,7 @@ from .oauth import (
require_session,
sanitize_redirect_to,
)
from .options import SUPPORTED_MODELS
from .options import SUPPORTED_MODEL_IDS, SUPPORTED_MODELS, model_supports_effort
from .profiles import (
ProfileUpdate,
get_profile,
@ -567,8 +569,96 @@ async def admin_delete_user_mapping(
return {"deleted": deleted}
ScoreMode = Literal["all_findings", "surfaced_findings"]
Severity = Literal["low", "medium", "high", "critical"]
class ReviewerEvalStartBody(BaseModel):
limit: int | None = None
dataset_name: str = DEFAULT_REVIEWER_EVAL_CONFIG["dataset_name"]
experiment_prefix: str = DEFAULT_REVIEWER_EVAL_CONFIG["experiment_prefix"]
max_concurrency: int = DEFAULT_REVIEWER_EVAL_CONFIG["max_concurrency"]
langsmith_project: str = DEFAULT_REVIEWER_EVAL_CONFIG["langsmith_project"]
langgraph_url: str = DEFAULT_REVIEWER_EVAL_CONFIG["langgraph_url"]
assistant_id: str = DEFAULT_REVIEWER_EVAL_CONFIG["assistant_id"]
model_id: str = DEFAULT_REVIEWER_EVAL_CONFIG["model_id"]
reasoning_effort: str = DEFAULT_REVIEWER_EVAL_CONFIG["reasoning_effort"]
score_mode: ScoreMode = DEFAULT_REVIEWER_EVAL_CONFIG["score_mode"]
severity_threshold: Severity = DEFAULT_REVIEWER_EVAL_CONFIG["severity_threshold"]
cap: int = DEFAULT_REVIEWER_EVAL_CONFIG["cap"]
@field_validator(
"dataset_name",
"experiment_prefix",
"langsmith_project",
"assistant_id",
"model_id",
"reasoning_effort",
mode="before",
)
@classmethod
def _normalize_required_string(cls, value: object) -> str:
if not isinstance(value, str):
raise ValueError("must be a string")
text = value.strip()
if not text:
raise ValueError("must not be blank")
return text
@field_validator("langgraph_url", mode="before")
@classmethod
def _normalize_optional_string(cls, value: object) -> str:
if value is None:
return ""
if not isinstance(value, str):
raise ValueError("must be a string")
return value.strip()
@field_validator("limit")
@classmethod
def _validate_limit(cls, value: int | None) -> int | None:
if value is not None and value <= 0:
raise ValueError("limit must be positive")
return value
@field_validator("max_concurrency")
@classmethod
def _validate_max_concurrency(cls, value: int) -> int:
if value <= 0:
raise ValueError("max_concurrency must be positive")
return value
@field_validator("cap")
@classmethod
def _validate_cap(cls, value: int) -> int:
if value < 0:
raise ValueError("cap must be non-negative")
return value
@model_validator(mode="after")
def _validate_model_effort(self) -> ReviewerEvalStartBody:
if self.model_id not in SUPPORTED_MODEL_IDS:
raise ValueError(f"unsupported reviewer eval model: {self.model_id}")
if not model_supports_effort(self.model_id, self.reasoning_effort):
raise ValueError(
f"effort {self.reasoning_effort!r} not supported by model {self.model_id!r}"
)
return self
def eval_config(self) -> ReviewerEvalConfig:
return {
"dataset_name": self.dataset_name,
"experiment_prefix": self.experiment_prefix,
"max_concurrency": self.max_concurrency,
"langsmith_project": self.langsmith_project,
"langgraph_url": self.langgraph_url,
"assistant_id": self.assistant_id,
"model_id": self.model_id,
"reasoning_effort": self.reasoning_effort,
"score_mode": self.score_mode,
"severity_threshold": self.severity_threshold,
"cap": self.cap,
}
@router.get("/admin/evals/reviewer")
@ -587,7 +677,11 @@ async def admin_start_reviewer_eval(
if status.get("status") == "running":
raise HTTPException(409, "a reviewer eval is already running")
try:
return await start_reviewer_eval(limit=body.limit, created_by=session["sub"])
return await start_reviewer_eval(
limit=body.limit,
config=body.eval_config(),
created_by=session["sub"],
)
except RuntimeError as exc:
raise HTTPException(409, str(exc)) from exc

View file

@ -10,7 +10,7 @@ import argparse
import logging
import os
import tomllib
from collections.abc import Iterable
from collections.abc import Iterable, Mapping
from pathlib import Path
from typing import Any, Literal, TypedDict
@ -28,6 +28,22 @@ CONFIG_PATH = Path(__file__).with_name("config.toml")
DEFAULT_LANGSMITH_PROJECT = "open-swe-evals"
ScoreMode = Literal["all_findings", "surfaced_findings"]
Severity = Literal["low", "medium", "high", "critical"]
_VALID_SCORE_MODES: set[str] = {"all_findings", "surfaced_findings"}
_VALID_SEVERITIES: set[str] = {"low", "medium", "high", "critical"}
_ENV_MAPPING: dict[str, str] = {
"dataset_name": "REVIEWER_EVAL_DATASET_NAME",
"experiment_prefix": "REVIEWER_EVAL_EXPERIMENT_PREFIX",
"max_concurrency": "REVIEWER_EVAL_MAX_CONCURRENCY",
"langgraph_url": "LANGGRAPH_URL",
"langsmith_project": "LANGSMITH_PROJECT",
"assistant_id": "REVIEWER_ASSISTANT_ID",
"model_id": "REVIEWER_EVAL_MODEL_ID",
"reasoning_effort": "REVIEWER_EVAL_REASONING_EFFORT",
"score_mode": "REVIEWER_EVAL_SCORE_MODE",
"severity_threshold": "REVIEWER_EVAL_SEVERITY_THRESHOLD",
"cap": "REVIEWER_EVAL_CAP",
}
class ReviewerEvalConfig(TypedDict, total=False):
@ -44,6 +60,28 @@ class ReviewerEvalConfig(TypedDict, total=False):
cap: int
DEFAULT_CONFIG: ReviewerEvalConfig = {
"dataset_name": "openswe-reviewer-v1",
"experiment_prefix": "openswe-reviewer-baseline",
"max_concurrency": 5,
"langgraph_url": "",
"langsmith_project": DEFAULT_LANGSMITH_PROJECT,
"assistant_id": "reviewer",
"model_id": "google_genai:gemini-3.5-flash",
"reasoning_effort": "medium",
"score_mode": "all_findings",
"severity_threshold": "medium",
"cap": 4,
}
def _parse_int(value: str) -> int | None:
try:
return int(value)
except ValueError:
return None
def _load_config() -> ReviewerEvalConfig:
if not CONFIG_PATH.exists():
return {}
@ -87,11 +125,11 @@ def _coerce_config(raw: dict[str, Any]) -> ReviewerEvalConfig:
config["max_concurrency"] = max_concurrency
score_mode = raw.get("score_mode")
if score_mode in {"all_findings", "surfaced_findings"}:
if score_mode in _VALID_SCORE_MODES:
config["score_mode"] = score_mode
severity_threshold = raw.get("severity_threshold")
if severity_threshold in {"low", "medium", "high", "critical"}:
if severity_threshold in _VALID_SEVERITIES:
config["severity_threshold"] = severity_threshold
cap = raw.get("cap")
@ -100,17 +138,45 @@ def _coerce_config(raw: dict[str, Any]) -> ReviewerEvalConfig:
return config
def _apply_config_to_env(config: ReviewerEvalConfig) -> None:
env_mapping = {
"langgraph_url": "LANGGRAPH_URL",
"assistant_id": "REVIEWER_ASSISTANT_ID",
"model_id": "REVIEWER_EVAL_MODEL_ID",
"reasoning_effort": "REVIEWER_EVAL_REASONING_EFFORT",
"score_mode": "REVIEWER_EVAL_SCORE_MODE",
"severity_threshold": "REVIEWER_EVAL_SEVERITY_THRESHOLD",
"cap": "REVIEWER_EVAL_CAP",
def _load_env_config(env: Mapping[str, str] = os.environ) -> ReviewerEvalConfig:
raw: dict[str, Any] = {}
for config_key, env_key in _ENV_MAPPING.items():
value = env.get(env_key)
if value is None or value == "":
continue
if config_key in {"max_concurrency", "cap"}:
parsed = _parse_int(value)
if parsed is not None:
raw[config_key] = parsed
else:
raw[config_key] = value
return _coerce_config(raw)
def _config_from_args(args: argparse.Namespace) -> ReviewerEvalConfig:
raw: dict[str, Any] = {}
for key in _ENV_MAPPING:
value = getattr(args, key, None)
if value is not None:
raw[key] = value
return _coerce_config(raw)
def _resolve_config(cli_config: ReviewerEvalConfig | None = None) -> ReviewerEvalConfig:
resolved: ReviewerEvalConfig = {
**DEFAULT_CONFIG,
**_load_config(),
**_load_env_config(),
}
for config_key, env_key in env_mapping.items():
if cli_config is not None:
resolved.update(cli_config)
return resolved
def _apply_config_to_env(config: ReviewerEvalConfig) -> None:
for config_key, env_key in _ENV_MAPPING.items():
if config_key == "langsmith_project":
continue
value = config.get(config_key)
if value is not None:
os.environ[env_key] = str(value)
@ -120,10 +186,10 @@ def _apply_config_to_env(config: ReviewerEvalConfig) -> None:
def _apply_langsmith_project(project: str | None) -> None:
"""Route eval traces to a dedicated LangSmith project.
A project already set in the environment (e.g. by the admin-triggered job)
wins so callers can override the config default.
``project`` is expected to be the resolved value after CLI/env/config
precedence has already been applied.
"""
resolved = os.environ.get("LANGSMITH_PROJECT") or project or DEFAULT_LANGSMITH_PROJECT
resolved = project or os.environ.get("LANGSMITH_PROJECT") or DEFAULT_LANGSMITH_PROJECT
os.environ["LANGSMITH_PROJECT"] = resolved
os.environ["LANGCHAIN_PROJECT"] = resolved
os.environ.setdefault("LANGSMITH_TRACING", "true")
@ -144,22 +210,58 @@ async def _cleanup_threads(thread_ids: Iterable[str]) -> None:
async def main() -> None:
logging.basicConfig(
level=os.environ.get("REVIEWER_EVAL_LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
load_dotenv()
config = _load_config()
_apply_config_to_env(config)
ap = argparse.ArgumentParser()
ap.add_argument("--limit", type=int, default=None, help="Run only the first N examples.")
ap.add_argument("--dataset-name", dest="dataset_name")
ap.add_argument("--experiment-prefix", dest="experiment_prefix")
ap.add_argument("--max-concurrency", dest="max_concurrency", type=int)
ap.add_argument("--langgraph-url", dest="langgraph_url")
ap.add_argument("--langsmith-project", dest="langsmith_project")
ap.add_argument("--assistant-id", dest="assistant_id")
ap.add_argument("--model-id", dest="model_id")
ap.add_argument("--reasoning-effort", dest="reasoning_effort")
ap.add_argument("--score-mode", dest="score_mode", choices=sorted(_VALID_SCORE_MODES))
ap.add_argument(
"--severity-threshold",
dest="severity_threshold",
choices=sorted(_VALID_SEVERITIES),
)
ap.add_argument("--cap", type=int)
ap.add_argument(
"--no-cleanup",
action="store_true",
help="Skip deleting LangGraph threads after the experiment finishes.",
)
args = ap.parse_args()
config = _resolve_config(_config_from_args(args))
_apply_config_to_env(config)
dataset_name = config.get("dataset_name", "openswe-reviewer-v1")
experiment_prefix = config.get("experiment_prefix", "openswe-reviewer-baseline")
max_concurrency = config.get("max_concurrency", 5)
dataset_name = config["dataset_name"]
experiment_prefix = config["experiment_prefix"]
max_concurrency = config["max_concurrency"]
logger.info(
"Starting reviewer eval: dataset=%s experiment_prefix=%s max_concurrency=%s "
"model=%s effort=%s score_mode=%s severity_threshold=%s cap=%s project=%s "
"assistant_id=%s langgraph_url=%s limit=%s",
dataset_name,
experiment_prefix,
max_concurrency,
config["model_id"],
config["reasoning_effort"],
config["score_mode"],
config["severity_threshold"],
config["cap"],
config["langsmith_project"],
config["assistant_id"],
config["langgraph_url"] or "(default)",
args.limit,
)
data: str | list[Example]
if args.limit:

View file

@ -9,6 +9,7 @@ verbatim form martian published.
from __future__ import annotations
import logging
import os
import threading
from typing import Any, Literal, cast
@ -17,6 +18,8 @@ from langgraph_sdk import get_client
from agent.reviewer_findings import Finding, Severity, filter_findings_for_publish
logger = logging.getLogger(__name__)
DEFAULT_REVIEWER_ASSISTANT_ID = "reviewer"
DEFAULT_LANGGRAPH_URL = "http://localhost:2024"
ScoreMode = Literal["all_findings", "surfaced_findings"]
@ -110,19 +113,41 @@ def _build_configurable(inputs: dict[str, Any]) -> dict[str, Any]:
async def review_pr(inputs: dict[str, Any]) -> dict[str, Any]:
"""LangSmith target: run the reviewer agent on one PR."""
repo = inputs.get("repo", "")
pr_number = inputs.get("pr_number")
pr_url = inputs.get("pr_url", "")
logger.info(
"Starting reviewer eval example: repo=%s pr=%s url=%s",
repo,
pr_number,
pr_url,
)
client = get_client(url=get_langgraph_url())
thread = await client.threads.create()
thread_id: str = thread["thread_id"]
_record_thread_id(thread_id)
result = await client.runs.wait(
thread_id,
assistant_id=get_reviewer_assistant_id(),
input={"messages": [{"role": "user", "content": _build_user_message(inputs)}]},
config={"configurable": _build_configurable(inputs)},
)
if get_score_mode() == "surfaced_findings":
return {"comments": await _extract_surfaced_comments(client, thread_id)}
return {"comments": _extract_comments(result)}
try:
result = await client.runs.wait(
thread_id,
assistant_id=get_reviewer_assistant_id(),
input={"messages": [{"role": "user", "content": _build_user_message(inputs)}]},
config={"configurable": _build_configurable(inputs)},
)
if get_score_mode() == "surfaced_findings":
comments = await _extract_surfaced_comments(client, thread_id)
else:
comments = _extract_comments(result)
logger.info(
"Finished reviewer eval example: repo=%s pr=%s comments=%d thread_id=%s",
repo,
pr_number,
len(comments),
thread_id,
)
return {"comments": comments}
except Exception:
logger.exception("Reviewer eval example failed: repo=%s pr=%s", repo, pr_number)
raise
def _extract_comments(result: Any) -> list[dict[str, Any]]:

View file

@ -73,6 +73,8 @@ async def test_start_reviewer_eval_launches_subprocess() -> None:
assert record["status"] == "running"
assert record["limit"] == 3
assert record["run_name"] == "openswe-review-confidence"
assert record["config_snapshot"]["model_id"] == "google_genai:gemini-3.5-flash"
assert record["pid"] == 4321
assert record["heartbeat"] is not None
assert record["worker_id"] == eval_jobs._WORKER_ID
@ -80,10 +82,60 @@ async def test_start_reviewer_eval_launches_subprocess() -> None:
args, kwargs = create.call_args
assert "--limit" in args and "3" in args
assert "--experiment-prefix" in args and "openswe-review-confidence" in args
assert "--model-id" in args and "google_genai:gemini-3.5-flash" in args
assert "--reasoning-effort" in args and "medium" in args
assert kwargs["env"]["LANGSMITH_PROJECT"] == eval_jobs.DEFAULT_EVAL_PROJECT
assert kwargs["env"]["LANGGRAPH_URL"] == "https://lg.test"
@pytest.mark.asyncio
async def test_start_reviewer_eval_uses_config_snapshot() -> None:
proc = MagicMock()
proc.pid = 4321
proc.returncode = None
create = AsyncMock(return_value=proc)
config: eval_jobs.ReviewerEvalConfig = {
**eval_jobs.DEFAULT_REVIEWER_EVAL_CONFIG,
"experiment_prefix": "custom-run",
"langsmith_project": "custom-project",
"model_id": "anthropic:claude-opus-4-8",
"reasoning_effort": "high",
"score_mode": "surfaced_findings",
"severity_threshold": "high",
"cap": 2,
}
def _consume(coro):
coro.close()
return MagicMock()
with (
patch.object(eval_jobs.asyncio, "create_subprocess_exec", new=create),
patch.object(eval_jobs.asyncio, "create_task", new=_consume),
patch.object(eval_jobs, "_get_record", new=AsyncMock(return_value=None)),
patch.object(eval_jobs, "_put_record", new=AsyncMock(side_effect=lambda r: r)),
):
record = await eval_jobs.start_reviewer_eval(
limit=None,
config=config,
created_by="octo",
)
assert record["run_name"] == "custom-run"
assert record["langsmith_project"] == "custom-project"
assert record["config_snapshot"] == config
args, kwargs = create.call_args
assert "--experiment-prefix" in args and "custom-run" in args
assert "--langsmith-project" in args and "custom-project" in args
assert "--model-id" in args and "anthropic:claude-opus-4-8" in args
assert "--score-mode" in args and "surfaced_findings" in args
assert "--severity-threshold" in args and "high" in args
assert "--cap" in args and "2" in args
assert kwargs["env"]["LANGSMITH_PROJECT"] == "custom-project"
@pytest.mark.asyncio
async def test_start_reviewer_eval_rejects_when_running() -> None:
running = MagicMock()

View file

@ -3,11 +3,14 @@ from __future__ import annotations
import os
from unittest.mock import patch
from evals.reviewer import run_eval
from evals.reviewer.run_eval import (
DEFAULT_LANGSMITH_PROJECT,
_apply_config_to_env,
_apply_langsmith_project,
_coerce_config,
_load_env_config,
_resolve_config,
)
@ -46,7 +49,11 @@ def test_reviewer_eval_config_sets_target_env() -> None:
with patch.dict(os.environ, {}, clear=True):
_apply_config_to_env(
{
"dataset_name": "dataset",
"experiment_prefix": "experiment",
"max_concurrency": 2,
"langgraph_url": "https://example.test",
"langsmith_project": "project",
"assistant_id": "reviewer",
"model_id": "anthropic:claude-opus-4-8",
"reasoning_effort": "high",
@ -56,7 +63,12 @@ def test_reviewer_eval_config_sets_target_env() -> None:
}
)
assert os.environ["REVIEWER_EVAL_DATASET_NAME"] == "dataset"
assert os.environ["REVIEWER_EVAL_EXPERIMENT_PREFIX"] == "experiment"
assert os.environ["REVIEWER_EVAL_MAX_CONCURRENCY"] == "2"
assert os.environ["LANGGRAPH_URL"] == "https://example.test"
assert os.environ["LANGSMITH_PROJECT"] == "project"
assert os.environ["LANGCHAIN_PROJECT"] == "project"
assert os.environ["REVIEWER_ASSISTANT_ID"] == "reviewer"
assert os.environ["REVIEWER_EVAL_MODEL_ID"] == "anthropic:claude-opus-4-8"
assert os.environ["REVIEWER_EVAL_REASONING_EFFORT"] == "high"
@ -84,8 +96,76 @@ def test_apply_langsmith_project_falls_back_to_default() -> None:
assert os.environ["LANGSMITH_PROJECT"] == DEFAULT_LANGSMITH_PROJECT
def test_apply_langsmith_project_env_overrides_config() -> None:
def test_apply_langsmith_project_uses_resolved_config_over_env() -> None:
with patch.dict(os.environ, {"LANGSMITH_PROJECT": "from-env"}, clear=True):
_apply_langsmith_project("from-config")
assert os.environ["LANGSMITH_PROJECT"] == "from-env"
assert os.environ["LANGCHAIN_PROJECT"] == "from-env"
assert os.environ["LANGSMITH_PROJECT"] == "from-config"
assert os.environ["LANGCHAIN_PROJECT"] == "from-config"
def test_load_env_config_reads_all_supported_keys() -> None:
env = {
"REVIEWER_EVAL_DATASET_NAME": "dataset-env",
"REVIEWER_EVAL_EXPERIMENT_PREFIX": "experiment-env",
"REVIEWER_EVAL_MAX_CONCURRENCY": "3",
"LANGGRAPH_URL": "https://lg.env",
"LANGSMITH_PROJECT": "project-env",
"REVIEWER_ASSISTANT_ID": "reviewer-env",
"REVIEWER_EVAL_MODEL_ID": "openai:gpt-5.5",
"REVIEWER_EVAL_REASONING_EFFORT": "xhigh",
"REVIEWER_EVAL_SCORE_MODE": "surfaced_findings",
"REVIEWER_EVAL_SEVERITY_THRESHOLD": "critical",
"REVIEWER_EVAL_CAP": "1",
}
assert _load_env_config(env) == {
"dataset_name": "dataset-env",
"experiment_prefix": "experiment-env",
"max_concurrency": 3,
"langgraph_url": "https://lg.env",
"langsmith_project": "project-env",
"assistant_id": "reviewer-env",
"model_id": "openai:gpt-5.5",
"reasoning_effort": "xhigh",
"score_mode": "surfaced_findings",
"severity_threshold": "critical",
"cap": 1,
}
def test_resolve_config_prefers_cli_then_env_then_toml() -> None:
with (
patch.object(
run_eval,
"_load_config",
return_value={
"dataset_name": "dataset-config",
"experiment_prefix": "experiment-config",
"model_id": "anthropic:claude-opus-4-8",
"reasoning_effort": "high",
"langsmith_project": "project-config",
},
),
patch.dict(
os.environ,
{
"REVIEWER_EVAL_MODEL_ID": "google_genai:gemini-3.5-flash",
"REVIEWER_EVAL_REASONING_EFFORT": "medium",
"LANGSMITH_PROJECT": "project-env",
},
clear=True,
),
):
config = _resolve_config(
{
"experiment_prefix": "experiment-cli",
"model_id": "openai:gpt-5.5",
"reasoning_effort": "xhigh",
}
)
assert config["dataset_name"] == "dataset-config"
assert config["experiment_prefix"] == "experiment-cli"
assert config["model_id"] == "openai:gpt-5.5"
assert config["reasoning_effort"] == "xhigh"
assert config["langsmith_project"] == "project-env"

View file

@ -410,11 +410,34 @@ export function reviewChatApiBase(
return path;
}
export type ReviewerEvalScoreMode = "all_findings" | "surfaced_findings";
export type ReviewerEvalSeverity = "low" | "medium" | "high" | "critical";
export interface ReviewerEvalConfig {
dataset_name: string;
experiment_prefix: string;
max_concurrency: number;
langsmith_project: string;
langgraph_url: string;
assistant_id: string;
model_id: string;
reasoning_effort: string;
score_mode: ReviewerEvalScoreMode;
severity_threshold: ReviewerEvalSeverity;
cap: number;
}
export interface ReviewerEvalStartRequest extends ReviewerEvalConfig {
limit: number | null;
}
export interface ReviewerEvalStatus {
name: string;
status: "idle" | "running" | "completed" | "failed";
run_name?: string;
langsmith_project: string;
limit: number | null;
config_snapshot?: ReviewerEvalConfig;
started_at: string | null;
finished_at: string | null;
created_by: string | null;
@ -554,10 +577,10 @@ export const api = {
),
getReviewerEval: () =>
request<ReviewerEvalStatus>("/admin/evals/reviewer"),
startReviewerEval: (limit: number | null) =>
startReviewerEval: (body: ReviewerEvalStartRequest) =>
request<ReviewerEvalStatus>("/admin/evals/reviewer", {
method: "POST",
body: JSON.stringify({ limit }),
body: JSON.stringify(body),
}),
cancelReviewerEval: () =>
request<ReviewerEvalStatus>("/admin/evals/reviewer", { method: "DELETE" }),

View file

@ -1,14 +1,30 @@
import { Navigate, createFileRoute } from "@tanstack/react-router"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useEffect, useRef, useState } from "react"
import type { ReactNode } from "react"
import type { ReviewerEvalStatus } from "@/lib/api"
import type {
ModelOption,
ReviewerEvalConfig,
ReviewerEvalScoreMode,
ReviewerEvalSeverity,
ReviewerEvalStartRequest,
ReviewerEvalStatus,
} from "@/lib/api"
import { AppShell, SettingsSection } from "@/components/AppShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { api } from "@/lib/api"
import { useSession } from "@/lib/session"
import { cn } from "@/lib/utils"
export const Route = createFileRoute("/admin_/evals")({ component: ReviewerEvalPage })
@ -38,10 +54,129 @@ function ReviewerEvalPage() {
)
}
const DEFAULT_REVIEWER_EVAL_CONFIG: ReviewerEvalConfig = {
dataset_name: "openswe-reviewer-v1",
experiment_prefix: "openswe-review-confidence",
max_concurrency: 5,
langsmith_project: "open-swe-evals",
langgraph_url: "",
assistant_id: "reviewer",
model_id: "google_genai:gemini-3.5-flash",
reasoning_effort: "medium",
score_mode: "all_findings",
severity_threshold: "medium",
cap: 4,
}
interface ReviewerEvalFormState {
dataset_name: string
experiment_prefix: string
max_concurrency: string
langsmith_project: string
langgraph_url: string
assistant_id: string
model_id: string
reasoning_effort: string
score_mode: ReviewerEvalScoreMode
severity_threshold: ReviewerEvalSeverity
cap: string
limit: string
}
function formFromConfig(
config: ReviewerEvalConfig,
limit: number | null = null
): ReviewerEvalFormState {
return {
dataset_name: config.dataset_name,
experiment_prefix: config.experiment_prefix,
max_concurrency: String(config.max_concurrency),
langsmith_project: config.langsmith_project,
langgraph_url: config.langgraph_url,
assistant_id: config.assistant_id,
model_id: config.model_id,
reasoning_effort: config.reasoning_effort,
score_mode: config.score_mode,
severity_threshold: config.severity_threshold,
cap: String(config.cap),
limit: limit ? String(limit) : "",
}
}
function parsePositiveInt(label: string, value: string): number {
const n = Number(value.trim())
if (!Number.isInteger(n) || n <= 0) {
throw new Error(`${label} must be a positive whole number`)
}
return n
}
function parseOptionalPositiveInt(label: string, value: string): number | null {
return value.trim() ? parsePositiveInt(label, value) : null
}
function parseNonNegativeInt(label: string, value: string): number {
const n = Number(value.trim())
if (!Number.isInteger(n) || n < 0) {
throw new Error(`${label} must be a non-negative whole number`)
}
return n
}
function requireText(label: string, value: string): string {
const text = value.trim()
if (!text) throw new Error(`${label} is required`)
return text
}
function FieldGroup({
label,
description,
children,
}: {
label: string
description?: string
children: ReactNode
}) {
return (
<div className="px-4 py-4">
<div className="mb-3 flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">{label}</span>
{description && (
<span className="text-xs text-muted-foreground">{description}</span>
)}
</div>
{children}
</div>
)
}
function Field({
label,
className,
children,
}: {
label: string
className?: string
children: ReactNode
}) {
return (
<div className={cn("flex flex-col gap-1", className)}>
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{label}
</span>
{children}
</div>
)
}
function ReviewerEvalRunner() {
const qc = useQueryClient()
const [limit, setLimit] = useState("")
const [draft, setDraft] = useState<ReviewerEvalFormState>(() =>
formFromConfig(DEFAULT_REVIEWER_EVAL_CONFIG)
)
const [error, setError] = useState<string | null>(null)
const initialized = useRef(false)
const status = useQuery({
queryKey: ["reviewerEval"],
@ -49,9 +184,61 @@ function ReviewerEvalRunner() {
refetchInterval: (query) =>
query.state.data?.status === "running" ? 5000 : false,
})
const options = useQuery({ queryKey: ["options"], queryFn: api.options })
const data = status.data
const running = data?.status === "running"
const currentModel: ModelOption | undefined =
options.data?.models.find((m) => m.id === draft.model_id) ??
options.data?.models[0]
useEffect(() => {
if (initialized.current || !data?.config_snapshot) return
initialized.current = true
setDraft(formFromConfig(data.config_snapshot, data.limit))
}, [data?.config_snapshot, data?.limit])
useEffect(() => {
if (!currentModel) return
if (currentModel.id !== draft.model_id) {
setDraft((current) => ({
...current,
model_id: currentModel.id,
reasoning_effort: currentModel.default_effort,
}))
return
}
if (!currentModel.efforts.includes(draft.reasoning_effort)) {
setDraft((current) => ({
...current,
reasoning_effort: currentModel.default_effort,
}))
}
}, [currentModel, draft.model_id, draft.reasoning_effort])
const setField = <TKey extends keyof ReviewerEvalFormState>(
key: TKey,
value: ReviewerEvalFormState[TKey]
) => {
setDraft((current) => ({ ...current, [key]: value }))
}
const buildRequest = (): ReviewerEvalStartRequest => {
return {
dataset_name: requireText("Dataset", draft.dataset_name),
experiment_prefix: requireText("Run name", draft.experiment_prefix),
max_concurrency: parsePositiveInt("Max concurrency", draft.max_concurrency),
langsmith_project: requireText("LangSmith project", draft.langsmith_project),
langgraph_url: draft.langgraph_url.trim(),
assistant_id: requireText("Assistant ID", draft.assistant_id),
model_id: requireText("Model", draft.model_id),
reasoning_effort: requireText("Effort", draft.reasoning_effort),
score_mode: draft.score_mode,
severity_threshold: draft.severity_threshold,
cap: parseNonNegativeInt("Cap", draft.cap),
limit: parseOptionalPositiveInt("Limit", draft.limit),
}
}
const onSuccess = (next: ReviewerEvalStatus) => {
qc.setQueryData(["reviewerEval"], next)
@ -61,11 +248,7 @@ function ReviewerEvalRunner() {
const start = useMutation({
mutationFn: () => {
const n = limit.trim() ? Number(limit.trim()) : null
if (n !== null && (!Number.isInteger(n) || n <= 0)) {
throw new Error("Limit must be a positive whole number")
}
return api.startReviewerEval(n)
return api.startReviewerEval(buildRequest())
},
onSuccess,
onError,
@ -77,82 +260,335 @@ function ReviewerEvalRunner() {
})
return (
<SettingsSection
title="Run"
description="Traces are sent to the open-swe-evals project. Leave the limit blank for the full dataset, or enter N to run only the first N PRs (smoke test)."
>
<div className="flex flex-col gap-3 p-4">
<div className="flex items-center gap-2">
<Input
className="w-48"
type="number"
min={1}
placeholder="Limit (optional)"
value={limit}
disabled={running}
onChange={(e) => setLimit(e.target.value)}
/>
<Button
size="sm"
onClick={() => start.mutate()}
disabled={running || start.isPending}
<>
<SettingsSection
title="Run configuration"
description="Configure one reviewer eval run. These values override config.toml for this dashboard-triggered run without editing the file."
>
<div className="divide-y divide-border">
<FieldGroup
label="Run details"
description="Run name maps to the LangSmith experiment prefix. Leave limit blank for the full dataset."
>
{start.isPending ? "Starting…" : "Run eval"}
</Button>
{running && (
<Button
size="sm"
variant="outline"
onClick={() => cancel.mutate()}
disabled={cancel.isPending}
>
Cancel
</Button>
)}
</div>
{data && (
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
<span>
Status: <span className="font-medium">{data.status}</span>
{data.langsmith_project ? ` · ${data.langsmith_project}` : ""}
{data.limit ? ` · limit ${data.limit}` : ""}
</span>
{data.started_at && (
<span>Started: {new Date(data.started_at).toLocaleString()}</span>
)}
{data.finished_at && (
<span>
Finished: {new Date(data.finished_at).toLocaleString()}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Run name">
<Input
className="w-full"
placeholder="openswe-review-confidence"
value={draft.experiment_prefix}
disabled={running}
onChange={(e) => setField("experiment_prefix", e.target.value)}
/>
</Field>
<Field label="Dataset">
<Input
className="w-full"
placeholder="openswe-reviewer-v1"
value={draft.dataset_name}
disabled={running}
onChange={(e) => setField("dataset_name", e.target.value)}
/>
</Field>
<Field label="Limit">
<Input
className="w-full"
type="number"
min={1}
placeholder="Full dataset"
value={draft.limit}
disabled={running}
onChange={(e) => setField("limit", e.target.value)}
/>
</Field>
<Field label="Concurrency">
<Input
className="w-full"
type="number"
min={1}
value={draft.max_concurrency}
disabled={running}
onChange={(e) => setField("max_concurrency", e.target.value)}
/>
</Field>
</div>
</FieldGroup>
<FieldGroup
label="Reviewer model"
description="Model and reasoning effort passed to the reviewer graph for each PR."
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Model">
<Select
value={draft.model_id}
onValueChange={(value) => {
const model = options.data?.models.find((m) => m.id === value)
if (!model) return
setDraft((current) => ({
...current,
model_id: model.id,
reasoning_effort: model.efforts.includes(current.reasoning_effort)
? current.reasoning_effort
: model.default_effort,
}))
}}
disabled={running || options.isLoading}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.data?.models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field label="Effort">
<Select
value={draft.reasoning_effort}
onValueChange={(value) => {
if (value) setField("reasoning_effort", value)
}}
disabled={running || !currentModel}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="effort" />
</SelectTrigger>
<SelectContent>
{currentModel?.efforts.map((effort) => (
<SelectItem key={effort} value={effort}>
{effort}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
</FieldGroup>
<FieldGroup
label="Scoring"
description="Choose whether to score all findings or only findings that would be surfaced in production."
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Field label="Score mode">
<Select
value={draft.score_mode}
onValueChange={(value) => {
if (value === "all_findings" || value === "surfaced_findings") {
setField("score_mode", value)
}
}}
disabled={running}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all_findings">All findings</SelectItem>
<SelectItem value="surfaced_findings">
Surfaced findings
</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Severity threshold">
<Select
value={draft.severity_threshold}
onValueChange={(value) => {
if (
value === "low" ||
value === "medium" ||
value === "high" ||
value === "critical"
) {
setField("severity_threshold", value)
}
}}
disabled={running || draft.score_mode !== "surfaced_findings"}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">low</SelectItem>
<SelectItem value="medium">medium</SelectItem>
<SelectItem value="high">high</SelectItem>
<SelectItem value="critical">critical</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Cap">
<Input
className="w-full"
type="number"
min={0}
value={draft.cap}
disabled={running || draft.score_mode !== "surfaced_findings"}
onChange={(e) => setField("cap", e.target.value)}
/>
</Field>
</div>
</FieldGroup>
<FieldGroup
label="Advanced"
description="Override the LangSmith project, LangGraph URL, or reviewer assistant id for this run."
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<Field label="LangSmith project">
<Input
className="w-full"
placeholder="open-swe-evals"
value={draft.langsmith_project}
disabled={running}
onChange={(e) => setField("langsmith_project", e.target.value)}
/>
</Field>
<Field label="LangGraph URL">
<Input
className="w-full"
placeholder="Optional"
value={draft.langgraph_url}
disabled={running}
onChange={(e) => setField("langgraph_url", e.target.value)}
/>
</Field>
<Field label="Assistant ID">
<Input
className="w-full"
placeholder="reviewer"
value={draft.assistant_id}
disabled={running}
onChange={(e) => setField("assistant_id", e.target.value)}
/>
</Field>
</div>
</FieldGroup>
<div className="flex flex-col gap-3 px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">Start</span>
<span className="text-xs text-muted-foreground">
Only one reviewer eval can run at a time.
</span>
)}
{data.experiment_url && (
<a
href={data.experiment_url}
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => start.mutate()}
disabled={running || start.isPending || options.isLoading}
>
View experiment in LangSmith
</a>
)}
{data.error && <span className="text-destructive">{data.error}</span>}
{start.isPending ? "Starting…" : "Run eval"}
</Button>
{running && (
<Button
size="sm"
variant="outline"
onClick={() => cancel.mutate()}
disabled={cancel.isPending}
>
Cancel
</Button>
)}
</div>
</div>
)}
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
{error && <p className="text-xs text-destructive">{error}</p>}
<SettingsSection
title="Current run"
description="Status and resolved configuration for the latest reviewer eval run."
>
<ReviewerEvalStatusView data={data ?? null} />
</SettingsSection>
</>
)
}
function ReviewerEvalStatusView({ data }: { data: ReviewerEvalStatus | null }) {
if (!data) {
return (
<div className="p-4 text-xs text-muted-foreground">
Loading reviewer eval status…
</div>
</SettingsSection>
)
}
const config = data.config_snapshot
return (
<div className="grid gap-2 p-4 text-xs text-muted-foreground sm:grid-cols-2">
<StatusLine label="Status" value={data.status} strong />
<StatusLine label="Run name" value={data.run_name ?? config?.experiment_prefix} />
<StatusLine label="Dataset" value={config?.dataset_name} />
<StatusLine label="Limit" value={data.limit ? String(data.limit) : "full dataset"} />
<StatusLine label="Model" value={config?.model_id} />
<StatusLine label="Effort" value={config?.reasoning_effort} />
<StatusLine label="Score mode" value={config?.score_mode} />
<StatusLine label="Threshold" value={config?.severity_threshold} />
<StatusLine label="Cap" value={config ? String(config.cap) : null} />
<StatusLine label="LangSmith project" value={data.langsmith_project} />
<StatusLine label="PID" value={data.pid ? String(data.pid) : null} />
<StatusLine label="Exit code" value={data.exit_code !== null ? String(data.exit_code) : null} />
{data.started_at && (
<StatusLine
label="Started"
value={new Date(data.started_at).toLocaleString()}
/>
)}
{data.finished_at && (
<StatusLine
label="Finished"
value={new Date(data.finished_at).toLocaleString()}
/>
)}
{data.experiment_url && (
<a
href={data.experiment_url}
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
View experiment in LangSmith
</a>
)}
{data.error && <span className="text-destructive">{data.error}</span>}
</div>
)
}
function StatusLine({
label,
value,
strong = false,
}: {
label: string
value: string | null | undefined
strong?: boolean
}) {
return (
<span>
{label}:{" "}
<span className={strong ? "font-medium text-foreground" : ""}>
{value || "—"}
</span>
</span>
)
}
function ReviewerEvalLogs() {
const status = useQuery({ queryKey: ["reviewerEval"], queryFn: api.getReviewerEval })
const status = useQuery({
queryKey: ["reviewerEval"],
queryFn: api.getReviewerEval,
refetchInterval: (query) =>
query.state.data?.status === "running" ? 5000 : false,
})
const logTail = status.data?.log_tail ?? null
const running = status.data?.status === "running"
const scrollRef = useRef<HTMLPreElement>(null)
const [follow, setFollow] = useState(true)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (follow && scrollRef.current) {
@ -160,19 +596,36 @@ function ReviewerEvalLogs() {
}
}, [logTail, follow])
const copyLogs = async () => {
if (!logTail) return
await navigator.clipboard.writeText(logTail)
setCopied(true)
window.setTimeout(() => setCopied(false), 1500)
}
return (
<SettingsSection
title="Output"
description="Last 4000 characters of the eval process output. Updates roughly every 10 seconds while running."
description="Live tail of the eval process output. Each PR logs start and finish lines while the run is active."
action={
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
checked={follow}
onChange={(e) => setFollow(e.target.checked)}
/>
Follow
</label>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void copyLogs()}
disabled={!logTail}
>
{copied ? "Copied" : "Copy logs"}
</Button>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<input
type="checkbox"
checked={follow}
onChange={(e) => setFollow(e.target.checked)}
/>
Follow
</label>
</div>
}
>
<div className="p-4">