mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
feat: refactor UI into sidebar layout (#1318)
* feat(dashboard): refactor UI into Cursor-style sidebar layout
Replace the top-bar AppHeader with a left sidebar that holds the four
primary sections (My Settings, Cloud Agents, Open SWE Review,
Integrations) and the user account menu at the bottom. Admin remains a
hidden route reachable from the sidebar only when is_admin.
UI:
- AppShell + AppSidebar with bottom-anchored avatar/sign-out menu.
- My Settings: profile (first/last name) + PR destination preference.
- Cloud Agents: model/effort, default repo, base branch, branch prefix,
PR toggles (auto-fix CI, create PRs, allow artifacts), Slack
notifications.
- Open SWE Review: trigger mode, draft reviews, PR summaries, autofix
mode + severity threshold, plus the existing per-repo review-style
prompts (was /review-styles).
- Integrations: GitHub install status + Slack/Linear status rows.
- New Switch and Menu primitives.
Backend:
- Extend ProfileUpdate with first_name, last_name, base_branch,
branch_prefix, auto_fix_ci, create_prs, allow_artifacts,
slack_notifications, preferred_pr_destination.
- Add team_settings module + /team-settings GET (any session) / PUT
(admin only) for the reviewer configuration.
* fix(dashboard): replace base-ui Menu with plain dropdown, stabilise Selects
- base-ui's Menu hit an "Invalid hook call / Cannot read properties of
null (reading 'useRef')" crash inside fastComponent under Vite's dep
optimisation. The sidebar account menu is the only consumer, so swap
it for a useState-driven dropdown with click-outside + Esc handling
and drop the unused Menu wrapper.
- Initialise the Open SWE Review settings form with the same defaults
the server returns instead of `null`, so the Selects don't switch
from uncontrolled (`undefined`) to controlled on first data load.
* fix(dashboard): address review feedback on admin overwrite + form reset
- admin PUT /admin/profiles/{login} only sent model/effort/repo from the
admin form. ProfileUpdate's Pydantic defaults then wrote auto_fix_ci /
create_prs / slack_notifications etc back onto the target user's
profile, clobbering whatever they had configured. Switch to
model_dump(exclude_unset=True) and overlay the incoming fields on the
user's existing stored profile so only sent fields change.
- my-settings.tsx and cloud-agents.tsx initialised local state from
profile.data on every change. Because useSaveProfile updates the
cached profile on success, toggling any control would overwrite the
user's typed-but-unsaved input in another field. Guard the
initialisation effect with a ref so it runs once on first load.
---------
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
5ddce73b06
commit
1ea9c0d880
20 changed files with 1599 additions and 523 deletions
|
|
@ -33,6 +33,15 @@ class ProfileUpdate(BaseModel):
|
||||||
default_model: str
|
default_model: str
|
||||||
reasoning_effort: str
|
reasoning_effort: str
|
||||||
default_repo: str | None = None
|
default_repo: str | None = None
|
||||||
|
first_name: str | None = None
|
||||||
|
last_name: str | None = None
|
||||||
|
base_branch: str | None = None
|
||||||
|
branch_prefix: str | None = None
|
||||||
|
auto_fix_ci: bool = True
|
||||||
|
create_prs: bool = True
|
||||||
|
allow_artifacts: bool = False
|
||||||
|
slack_notifications: bool = True
|
||||||
|
preferred_pr_destination: str | None = None
|
||||||
|
|
||||||
@field_validator("default_model")
|
@field_validator("default_model")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -84,6 +93,15 @@ async def upsert_profile(login: str, email: str, update: ProfileUpdate) -> dict[
|
||||||
"default_model": update.default_model,
|
"default_model": update.default_model,
|
||||||
"reasoning_effort": update.reasoning_effort,
|
"reasoning_effort": update.reasoning_effort,
|
||||||
"default_repo": update.default_repo,
|
"default_repo": update.default_repo,
|
||||||
|
"first_name": update.first_name,
|
||||||
|
"last_name": update.last_name,
|
||||||
|
"base_branch": update.base_branch,
|
||||||
|
"branch_prefix": update.branch_prefix,
|
||||||
|
"auto_fix_ci": update.auto_fix_ci,
|
||||||
|
"create_prs": update.create_prs,
|
||||||
|
"allow_artifacts": update.allow_artifacts,
|
||||||
|
"slack_notifications": update.slack_notifications,
|
||||||
|
"preferred_pr_destination": update.preferred_pr_destination,
|
||||||
"updated_at": datetime.now(UTC).isoformat(),
|
"updated_at": datetime.now(UTC).isoformat(),
|
||||||
}
|
}
|
||||||
await _client().store.put_item(PROFILES_NAMESPACE, login, value)
|
await _client().store.put_item(PROFILES_NAMESPACE, login, value)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ from .review_styles import (
|
||||||
normalize_repo_full_name,
|
normalize_repo_full_name,
|
||||||
set_custom_prompt,
|
set_custom_prompt,
|
||||||
)
|
)
|
||||||
|
from .team_settings import (
|
||||||
|
TeamSettingsUpdate,
|
||||||
|
get_team_settings,
|
||||||
|
upsert_team_settings,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -226,14 +231,32 @@ async def admin_put_profile(
|
||||||
update.validate_pairing()
|
update.validate_pairing()
|
||||||
existing = await get_profile(login) or {}
|
existing = await get_profile(login) or {}
|
||||||
email = update.email or existing.get("email") or ""
|
email = update.email or existing.get("email") or ""
|
||||||
|
# Overlay only fields that were explicitly sent so the admin form (which
|
||||||
|
# only sends model/effort/repo) can't reset other fields the target user
|
||||||
|
# configured via My Settings / Cloud Agents to ProfileUpdate's defaults.
|
||||||
|
incoming = update.model_dump(exclude={"email"}, exclude_unset=True)
|
||||||
|
merged = {**existing, **incoming}
|
||||||
base = ProfileUpdate(
|
base = ProfileUpdate(
|
||||||
default_model=update.default_model,
|
**{k: v for k, v in merged.items() if k in ProfileUpdate.model_fields},
|
||||||
reasoning_effort=update.reasoning_effort,
|
|
||||||
default_repo=update.default_repo,
|
|
||||||
)
|
)
|
||||||
return await upsert_profile(login, email, base)
|
return await upsert_profile(login, email, base)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/team-settings")
|
||||||
|
async def api_get_team_settings(
|
||||||
|
session: dict[str, Any] = _SESSION_DEP,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await get_team_settings()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/team-settings")
|
||||||
|
async def api_put_team_settings(
|
||||||
|
update: TeamSettingsUpdate,
|
||||||
|
_admin: dict[str, Any] = _ADMIN_DEP,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return await upsert_team_settings(update)
|
||||||
|
|
||||||
|
|
||||||
def _next_link_url(link_header: str | None) -> str | None:
|
def _next_link_url(link_header: str | None) -> str | None:
|
||||||
if not link_header:
|
if not link_header:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
73
agent/dashboard/team_settings.py
Normal file
73
agent/dashboard/team_settings.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""Team-wide Open SWE Review (Bugbot) settings stored in LangGraph Store.
|
||||||
|
|
||||||
|
A single record keyed ``"default"`` keeps all instance-wide reviewer
|
||||||
|
configuration in one place. Per-repo style prompts live in
|
||||||
|
:mod:`agent.dashboard.review_styles`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from langgraph_sdk import get_client
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TEAM_SETTINGS_NAMESPACE: list[str] = ["team_settings"]
|
||||||
|
TEAM_SETTINGS_KEY = "default"
|
||||||
|
|
||||||
|
TriggerMode = Literal["every_push", "ready_for_review", "manual"]
|
||||||
|
AutofixMode = Literal["off", "low", "medium", "high"]
|
||||||
|
|
||||||
|
|
||||||
|
class TeamSettingsUpdate(BaseModel):
|
||||||
|
trigger_mode: TriggerMode = "every_push"
|
||||||
|
review_draft_prs: bool = False
|
||||||
|
pr_summaries: bool = True
|
||||||
|
autofix_mode: AutofixMode = "off"
|
||||||
|
autofix_severity_threshold: AutofixMode = "medium"
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
return get_client()
|
||||||
|
|
||||||
|
|
||||||
|
def _default_settings() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"trigger_mode": "every_push",
|
||||||
|
"review_draft_prs": False,
|
||||||
|
"pr_summaries": True,
|
||||||
|
"autofix_mode": "off",
|
||||||
|
"autofix_severity_threshold": "medium",
|
||||||
|
"updated_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_team_settings() -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
item = await _client().store.get_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("team settings lookup failed: %s", e)
|
||||||
|
return _default_settings()
|
||||||
|
if item is None:
|
||||||
|
return _default_settings()
|
||||||
|
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return _default_settings()
|
||||||
|
return {**_default_settings(), **value}
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_team_settings(update: TeamSettingsUpdate) -> dict[str, Any]:
|
||||||
|
value: dict[str, Any] = {
|
||||||
|
"trigger_mode": update.trigger_mode,
|
||||||
|
"review_draft_prs": update.review_draft_prs,
|
||||||
|
"pr_summaries": update.pr_summaries,
|
||||||
|
"autofix_mode": update.autofix_mode,
|
||||||
|
"autofix_severity_threshold": update.autofix_severity_threshold,
|
||||||
|
"updated_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
await _client().store.put_item(TEAM_SETTINGS_NAMESPACE, TEAM_SETTINGS_KEY, value)
|
||||||
|
return value
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { Link, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
import type {SessionUser} from "@/lib/api";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
|
|
||||||
export function AppHeader({ user }: { user: SessionUser }) {
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const onLogout = async () => {
|
|
||||||
await api.logout();
|
|
||||||
qc.setQueryData(["session"], null);
|
|
||||||
navigate({ to: "/login" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="border-b">
|
|
||||||
<div className="container mx-auto flex items-center gap-4 px-4 py-3">
|
|
||||||
<Link to="/profile" className="font-medium">
|
|
||||||
open-swe
|
|
||||||
</Link>
|
|
||||||
<nav className="flex items-center gap-3 text-sm">
|
|
||||||
<Link
|
|
||||||
to="/profile"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
activeProps={{ className: "text-foreground font-medium" }}
|
|
||||||
>
|
|
||||||
Profile
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/review-styles"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
activeProps={{ className: "text-foreground font-medium" }}
|
|
||||||
>
|
|
||||||
Review styles
|
|
||||||
</Link>
|
|
||||||
{user.is_admin && (
|
|
||||||
<Link
|
|
||||||
to="/admin"
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
activeProps={{ className: "text-foreground font-medium" }}
|
|
||||||
>
|
|
||||||
Admin
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
<div className="ml-auto flex items-center gap-3">
|
|
||||||
{user.is_admin && <Badge variant="secondary">admin</Badge>}
|
|
||||||
<span className="text-muted-foreground text-sm">{user.login}</span>
|
|
||||||
<Avatar className="size-7">
|
|
||||||
{user.avatar_url && <AvatarImage src={user.avatar_url} alt={user.login} />}
|
|
||||||
<AvatarFallback>{user.login.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<Separator orientation="vertical" className="h-6" />
|
|
||||||
<Button variant="ghost" size="sm" onClick={onLogout}>
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
77
ui/src/components/AppShell.tsx
Normal file
77
ui/src/components/AppShell.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import type { SessionUser } from "@/lib/api";
|
||||||
|
import { AppSidebar } from "@/components/AppSidebar";
|
||||||
|
|
||||||
|
interface AppShellProps {
|
||||||
|
user: SessionUser;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({ user, title, description, children }: AppShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh bg-background text-foreground">
|
||||||
|
<AppSidebar user={user} />
|
||||||
|
<main className="flex-1 overflow-y-auto">
|
||||||
|
<div className="mx-auto max-w-3xl px-8 py-10">
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="font-heading text-lg font-medium">{title}</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<div className="space-y-8">{children}</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsSectionProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsSection({ title, description, action, children }: SettingsSectionProps) {
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground/80">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-card">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsRowProps {
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
control: ReactNode;
|
||||||
|
htmlFor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsRow({ label, description, control, htmlFor }: SettingsRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-6 border-b border-border px-4 py-3 last:border-b-0">
|
||||||
|
<label className="flex flex-col gap-0.5" htmlFor={htmlFor}>
|
||||||
|
<span className="text-xs font-medium text-foreground">{label}</span>
|
||||||
|
{description && (
|
||||||
|
<span className="text-xs text-muted-foreground">{description}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<div className="shrink-0">{control}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
ui/src/components/AppSidebar.tsx
Normal file
137
ui/src/components/AppSidebar.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
CloudIcon,
|
||||||
|
GearSixIcon,
|
||||||
|
PlugsIcon,
|
||||||
|
ShieldCheckIcon,
|
||||||
|
SignOutIcon,
|
||||||
|
SlidersHorizontalIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type {ComponentType} from "react";
|
||||||
|
|
||||||
|
import type { SessionUser } from "@/lib/api";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type IconType = ComponentType<{ className?: string; weight?: "regular" | "fill" | "duotone" }>;
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
icon: IconType;
|
||||||
|
adminOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAV: Array<NavItem> = [
|
||||||
|
{ to: "/my-settings", label: "My Settings", icon: SlidersHorizontalIcon },
|
||||||
|
{ to: "/cloud-agents", label: "Cloud Agents", icon: CloudIcon },
|
||||||
|
{ to: "/review", label: "Open SWE Review", icon: ShieldCheckIcon },
|
||||||
|
{ to: "/integrations", label: "Integrations", icon: PlugsIcon },
|
||||||
|
{ to: "/admin", label: "Admin", icon: GearSixIcon, adminOnly: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppSidebar({ user }: { user: SessionUser }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menuOpen) return;
|
||||||
|
const onClickOutside = (e: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||||
|
setMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onClickOutside);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onClickOutside);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [menuOpen]);
|
||||||
|
|
||||||
|
const onLogout = async () => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
await api.logout();
|
||||||
|
qc.setQueryData(["session"], null);
|
||||||
|
navigate({ to: "/login" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const initials = (user.login || "?").slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-svh w-60 shrink-0 flex-col border-r border-border bg-sidebar text-sidebar-foreground">
|
||||||
|
<div className="px-4 pt-5 pb-4">
|
||||||
|
<Link to="/my-settings" className="font-heading text-sm font-medium tracking-tight">
|
||||||
|
open-swe
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex flex-1 flex-col gap-0.5 px-2">
|
||||||
|
{NAV.filter((n) => !n.adminOnly || user.is_admin).map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-xs/relaxed text-muted-foreground transition-colors",
|
||||||
|
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
)}
|
||||||
|
activeProps={{
|
||||||
|
className: "bg-sidebar-accent text-sidebar-accent-foreground font-medium",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div ref={menuRef} className="relative border-t border-border p-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left outline-none hover:bg-sidebar-accent"
|
||||||
|
>
|
||||||
|
<Avatar className="size-7">
|
||||||
|
{user.avatar_url && <AvatarImage src={user.avatar_url} alt={user.login} />}
|
||||||
|
<AvatarFallback>{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-xs font-medium">{user.login}</span>
|
||||||
|
{user.email && (
|
||||||
|
<span className="truncate text-[10px] text-muted-foreground">{user.email}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{menuOpen && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute bottom-full left-2 right-2 mb-2 overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => void onLogout()}
|
||||||
|
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs/relaxed hover:bg-muted"
|
||||||
|
>
|
||||||
|
<SignOutIcon className="size-3.5" />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
ui/src/components/ReviewStylesPanel.tsx
Normal file
267
ui/src/components/ReviewStylesPanel.tsx
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type {ReviewStyle} from "@/lib/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxList,
|
||||||
|
} from "@/components/ui/combobox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { ApiError, api } from "@/lib/api";
|
||||||
|
import { normalizeRepoFullName } from "@/lib/repo";
|
||||||
|
|
||||||
|
function statusVariant(status: ReviewStyle["status"]) {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return "default" as const;
|
||||||
|
case "running":
|
||||||
|
return "secondary" as const;
|
||||||
|
case "failed":
|
||||||
|
return "destructive" as const;
|
||||||
|
default:
|
||||||
|
return "outline" as const;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewStylesPanel() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [addRepo, setAddRepo] = useState("");
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const [draftPrompt, setDraftPrompt] = useState("");
|
||||||
|
|
||||||
|
const styles = useQuery({
|
||||||
|
queryKey: ["reviewStyles"],
|
||||||
|
queryFn: api.listReviewStyles,
|
||||||
|
refetchInterval: (q) => {
|
||||||
|
const hasRunning = (q.state.data ?? []).some((r) => r.status === "running");
|
||||||
|
return hasRunning ? 4000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const repos = useQuery({
|
||||||
|
queryKey: ["repos"],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await api.repos();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 401)
|
||||||
|
return { installations: [], repositories: [] };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const detail = useQuery({
|
||||||
|
queryKey: ["reviewStyle", selected],
|
||||||
|
queryFn: () => api.getReviewStyle(selected!),
|
||||||
|
enabled: !!selected,
|
||||||
|
refetchInterval: (q) => (q.state.data?.status === "running" ? 4000 : false),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (detail.data?.custom_prompt != null) {
|
||||||
|
setDraftPrompt(detail.data.custom_prompt);
|
||||||
|
} else if (detail.data) {
|
||||||
|
setDraftPrompt("");
|
||||||
|
}
|
||||||
|
}, [detail.data?.custom_prompt, detail.data?.full_name]);
|
||||||
|
|
||||||
|
const createStyle = useMutation({
|
||||||
|
mutationFn: (full_name: string) => api.createReviewStyle(full_name),
|
||||||
|
onSuccess: (record) => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["reviewStyles"] });
|
||||||
|
setSelected(record.full_name);
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const analyze = useMutation({
|
||||||
|
mutationFn: (full_name: string) => api.analyzeReviewStyle(full_name),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["reviewStyles"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["reviewStyle", selected] });
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const savePrompt = useMutation({
|
||||||
|
mutationFn: ({ full_name, custom_prompt }: { full_name: string; custom_prompt: string }) =>
|
||||||
|
api.saveReviewStylePrompt(full_name, custom_prompt),
|
||||||
|
onSuccess: () => {
|
||||||
|
void qc.invalidateQueries({ queryKey: ["reviewStyles"] });
|
||||||
|
void qc.invalidateQueries({ queryKey: ["reviewStyle", selected] });
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (styles.isLoading) {
|
||||||
|
return <Skeleton className="h-40" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configured = new Set((styles.data ?? []).map((s) => s.full_name));
|
||||||
|
const suggestedRepos = (repos.data?.repositories ?? []).filter(
|
||||||
|
(r) => !configured.has(r.full_name),
|
||||||
|
);
|
||||||
|
const normalizedAddRepo = normalizeRepoFullName(addRepo);
|
||||||
|
const canAdd = normalizedAddRepo !== null && !configured.has(normalizedAddRepo);
|
||||||
|
const active = detail.data ?? styles.data?.find((s) => s.full_name === selected) ?? null;
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!normalizedAddRepo || !canAdd) return;
|
||||||
|
void createStyle.mutateAsync(normalizedAddRepo).then(() => setAddRepo(""));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 p-4 md:grid-cols-[260px_1fr]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="add-repo">Add repository</Label>
|
||||||
|
<Input
|
||||||
|
id="add-repo"
|
||||||
|
placeholder="owner/repo"
|
||||||
|
value={addRepo}
|
||||||
|
onChange={(e) => setAddRepo(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAdd();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{suggestedRepos.length > 0 && (
|
||||||
|
<Combobox
|
||||||
|
items={suggestedRepos.map((r) => r.full_name)}
|
||||||
|
value={addRepo}
|
||||||
|
onValueChange={(v) => setAddRepo(typeof v === "string" ? v : "")}
|
||||||
|
>
|
||||||
|
<ComboboxInput
|
||||||
|
placeholder="Search installed repos…"
|
||||||
|
showClear
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ComboboxContent className="min-w-[var(--anchor-width)]">
|
||||||
|
<ComboboxList className="max-h-48">
|
||||||
|
<ComboboxEmpty>No matches</ComboboxEmpty>
|
||||||
|
{suggestedRepos.map((r) => (
|
||||||
|
<ComboboxItem key={r.full_name} value={r.full_name}>
|
||||||
|
<span className="truncate">{r.full_name}</span>
|
||||||
|
{r.private && (
|
||||||
|
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||||
|
private
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</ComboboxItem>
|
||||||
|
))}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxContent>
|
||||||
|
</Combobox>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!canAdd || createStyle.isPending}
|
||||||
|
onClick={handleAdd}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{(styles.data ?? []).map((s) => (
|
||||||
|
<li key={s.full_name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-xs hover:bg-muted ${
|
||||||
|
selected === s.full_name ? "bg-muted font-medium" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelected(s.full_name)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{s.full_name}</span>
|
||||||
|
<Badge variant={statusVariant(s.status)} className="ml-2 shrink-0">
|
||||||
|
{s.status}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{(styles.data ?? []).length === 0 && (
|
||||||
|
<li className="px-2 py-1 text-xs text-muted-foreground">No repositories yet.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{!selected || !active ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Select a repository on the left to view or edit its review style prompt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<Badge variant={statusVariant(active.status)}>{active.status}</Badge>
|
||||||
|
{active.top_reviewers.length > 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Reviewers: {active.top_reviewers.join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{active.prs_sampled > 0 && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{active.prs_sampled} PRs · {active.reviews_sampled} reviews sampled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{active.analysis_summary && (
|
||||||
|
<p className="text-xs text-muted-foreground">{active.analysis_summary}</p>
|
||||||
|
)}
|
||||||
|
{active.error && <p className="text-xs text-destructive">{active.error}</p>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={active.status === "running" || analyze.isPending}
|
||||||
|
onClick={() => void analyze.mutateAsync(active.full_name)}
|
||||||
|
>
|
||||||
|
{active.status === "running" ? "Analyzing…" : "Run analysis"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!draftPrompt.trim() || savePrompt.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
void savePrompt.mutateAsync({
|
||||||
|
full_name: active.full_name,
|
||||||
|
custom_prompt: draftPrompt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Save prompt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[320px] font-mono text-xs"
|
||||||
|
value={draftPrompt}
|
||||||
|
onChange={(e) => setDraftPrompt(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
active.status === "running"
|
||||||
|
? "Analysis in progress…"
|
||||||
|
: "Run analysis or write a custom prompt for this repository."
|
||||||
|
}
|
||||||
|
disabled={active.status === "running"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
ui/src/components/ui/switch.tsx
Normal file
31
ui/src/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Switch({ className, ...props }: SwitchPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors outline-none",
|
||||||
|
"focus-visible:ring-2 focus-visible:ring-ring/30",
|
||||||
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"bg-input data-[checked]:bg-primary",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block size-4 rounded-full bg-background ring-0 shadow-sm transition-transform",
|
||||||
|
"translate-x-0.5 data-[checked]:translate-x-[18px]",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
|
|
@ -61,6 +61,15 @@ export interface Profile {
|
||||||
default_model?: string;
|
default_model?: string;
|
||||||
reasoning_effort?: string;
|
reasoning_effort?: string;
|
||||||
default_repo?: string | null;
|
default_repo?: string | null;
|
||||||
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
|
base_branch?: string | null;
|
||||||
|
branch_prefix?: string | null;
|
||||||
|
auto_fix_ci?: boolean;
|
||||||
|
create_prs?: boolean;
|
||||||
|
allow_artifacts?: boolean;
|
||||||
|
slack_notifications?: boolean;
|
||||||
|
preferred_pr_destination?: string | null;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +77,27 @@ export interface ProfileUpdate {
|
||||||
default_model: string;
|
default_model: string;
|
||||||
reasoning_effort: string;
|
reasoning_effort: string;
|
||||||
default_repo?: string | null;
|
default_repo?: string | null;
|
||||||
|
first_name?: string | null;
|
||||||
|
last_name?: string | null;
|
||||||
|
base_branch?: string | null;
|
||||||
|
branch_prefix?: string | null;
|
||||||
|
auto_fix_ci?: boolean;
|
||||||
|
create_prs?: boolean;
|
||||||
|
allow_artifacts?: boolean;
|
||||||
|
slack_notifications?: boolean;
|
||||||
|
preferred_pr_destination?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TriggerMode = "every_push" | "ready_for_review" | "manual";
|
||||||
|
export type AutofixMode = "off" | "low" | "medium" | "high";
|
||||||
|
|
||||||
|
export interface TeamSettings {
|
||||||
|
trigger_mode: TriggerMode;
|
||||||
|
review_draft_prs: boolean;
|
||||||
|
pr_summaries: boolean;
|
||||||
|
autofix_mode: AutofixMode;
|
||||||
|
autofix_severity_threshold: AutofixMode;
|
||||||
|
updated_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Repository {
|
export interface Repository {
|
||||||
|
|
@ -130,6 +160,9 @@ export const api = {
|
||||||
request<ReviewStyle>(`/review-styles/${encodeURIComponent(full_name)}/analyze`, {
|
request<ReviewStyle>(`/review-styles/${encodeURIComponent(full_name)}/analyze`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
}),
|
}),
|
||||||
|
getTeamSettings: () => request<TeamSettings>("/team-settings"),
|
||||||
|
saveTeamSettings: (body: TeamSettings) =>
|
||||||
|
request<TeamSettings>("/team-settings", { method: "PUT", body: JSON.stringify(body) }),
|
||||||
adminListProfiles: () => request<Array<Profile>>("/admin/profiles"),
|
adminListProfiles: () => request<Array<Profile>>("/admin/profiles"),
|
||||||
adminSaveProfile: (login: string, body: ProfileUpdate & { email?: string }) =>
|
adminSaveProfile: (login: string, body: ProfileUpdate & { email?: string }) =>
|
||||||
request<Profile>(`/admin/profiles/${encodeURIComponent(login)}`, {
|
request<Profile>(`/admin/profiles/${encodeURIComponent(login)}`, {
|
||||||
|
|
|
||||||
78
ui/src/lib/profile.ts
Normal file
78
ui/src/lib/profile.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { ApiError, api } from "./api";
|
||||||
|
import { useSession } from "./session";
|
||||||
|
import type {Profile, ProfileUpdate} from "./api";
|
||||||
|
|
||||||
|
export function useProfile() {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["profile"],
|
||||||
|
queryFn: api.profile,
|
||||||
|
enabled: !!session.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOptions() {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["options"],
|
||||||
|
queryFn: api.options,
|
||||||
|
enabled: !!session.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRepos() {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["repos"],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await api.repos();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 401)
|
||||||
|
return { installations: [], repositories: [] };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!session.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveProfile() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (body: ProfileUpdate) => api.saveProfile(body),
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
qc.setQueryData(["profile"], saved);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a ProfileUpdate body from the current cached profile plus a patch,
|
||||||
|
* so individual pages can mutate one field without losing values managed by
|
||||||
|
* other pages.
|
||||||
|
*/
|
||||||
|
export function buildProfileUpdate(
|
||||||
|
current: Profile | undefined,
|
||||||
|
patch: Partial<ProfileUpdate>,
|
||||||
|
fallbackModel: string,
|
||||||
|
fallbackEffort: string,
|
||||||
|
): ProfileUpdate {
|
||||||
|
return {
|
||||||
|
default_model: current?.default_model ?? fallbackModel,
|
||||||
|
reasoning_effort: current?.reasoning_effort ?? fallbackEffort,
|
||||||
|
default_repo: current?.default_repo ?? null,
|
||||||
|
first_name: current?.first_name ?? null,
|
||||||
|
last_name: current?.last_name ?? null,
|
||||||
|
base_branch: current?.base_branch ?? null,
|
||||||
|
branch_prefix: current?.branch_prefix ?? null,
|
||||||
|
auto_fix_ci: current?.auto_fix_ci ?? true,
|
||||||
|
create_prs: current?.create_prs ?? true,
|
||||||
|
allow_artifacts: current?.allow_artifacts ?? false,
|
||||||
|
slack_notifications: current?.slack_notifications ?? true,
|
||||||
|
preferred_pr_destination: current?.preferred_pr_destination ?? null,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -9,20 +9,22 @@
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as ReviewStylesRouteImport } from './routes/review-styles'
|
import { Route as ReviewRouteImport } from './routes/review'
|
||||||
import { Route as ProfileRouteImport } from './routes/profile'
|
import { Route as MySettingsRouteImport } from './routes/my-settings'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as IntegrationsRouteImport } from './routes/integrations'
|
||||||
|
import { Route as CloudAgentsRouteImport } from './routes/cloud-agents'
|
||||||
import { Route as AdminRouteImport } from './routes/admin'
|
import { Route as AdminRouteImport } from './routes/admin'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
const ReviewStylesRoute = ReviewStylesRouteImport.update({
|
const ReviewRoute = ReviewRouteImport.update({
|
||||||
id: '/review-styles',
|
id: '/review',
|
||||||
path: '/review-styles',
|
path: '/review',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const ProfileRoute = ProfileRouteImport.update({
|
const MySettingsRoute = MySettingsRouteImport.update({
|
||||||
id: '/profile',
|
id: '/my-settings',
|
||||||
path: '/profile',
|
path: '/my-settings',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
|
@ -30,6 +32,16 @@ const LoginRoute = LoginRouteImport.update({
|
||||||
path: '/login',
|
path: '/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const IntegrationsRoute = IntegrationsRouteImport.update({
|
||||||
|
id: '/integrations',
|
||||||
|
path: '/integrations',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const CloudAgentsRoute = CloudAgentsRouteImport.update({
|
||||||
|
id: '/cloud-agents',
|
||||||
|
path: '/cloud-agents',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AdminRoute = AdminRouteImport.update({
|
const AdminRoute = AdminRouteImport.update({
|
||||||
id: '/admin',
|
id: '/admin',
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
|
|
@ -44,55 +56,85 @@ const IndexRoute = IndexRouteImport.update({
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/admin': typeof AdminRoute
|
'/admin': typeof AdminRoute
|
||||||
|
'/cloud-agents': typeof CloudAgentsRoute
|
||||||
|
'/integrations': typeof IntegrationsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/profile': typeof ProfileRoute
|
'/my-settings': typeof MySettingsRoute
|
||||||
'/review-styles': typeof ReviewStylesRoute
|
'/review': typeof ReviewRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/admin': typeof AdminRoute
|
'/admin': typeof AdminRoute
|
||||||
|
'/cloud-agents': typeof CloudAgentsRoute
|
||||||
|
'/integrations': typeof IntegrationsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/profile': typeof ProfileRoute
|
'/my-settings': typeof MySettingsRoute
|
||||||
'/review-styles': typeof ReviewStylesRoute
|
'/review': typeof ReviewRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/admin': typeof AdminRoute
|
'/admin': typeof AdminRoute
|
||||||
|
'/cloud-agents': typeof CloudAgentsRoute
|
||||||
|
'/integrations': typeof IntegrationsRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/profile': typeof ProfileRoute
|
'/my-settings': typeof MySettingsRoute
|
||||||
'/review-styles': typeof ReviewStylesRoute
|
'/review': typeof ReviewRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/admin' | '/login' | '/profile' | '/review-styles'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/admin'
|
||||||
|
| '/cloud-agents'
|
||||||
|
| '/integrations'
|
||||||
|
| '/login'
|
||||||
|
| '/my-settings'
|
||||||
|
| '/review'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/admin' | '/login' | '/profile' | '/review-styles'
|
to:
|
||||||
id: '__root__' | '/' | '/admin' | '/login' | '/profile' | '/review-styles'
|
| '/'
|
||||||
|
| '/admin'
|
||||||
|
| '/cloud-agents'
|
||||||
|
| '/integrations'
|
||||||
|
| '/login'
|
||||||
|
| '/my-settings'
|
||||||
|
| '/review'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/admin'
|
||||||
|
| '/cloud-agents'
|
||||||
|
| '/integrations'
|
||||||
|
| '/login'
|
||||||
|
| '/my-settings'
|
||||||
|
| '/review'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
AdminRoute: typeof AdminRoute
|
AdminRoute: typeof AdminRoute
|
||||||
|
CloudAgentsRoute: typeof CloudAgentsRoute
|
||||||
|
IntegrationsRoute: typeof IntegrationsRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
ProfileRoute: typeof ProfileRoute
|
MySettingsRoute: typeof MySettingsRoute
|
||||||
ReviewStylesRoute: typeof ReviewStylesRoute
|
ReviewRoute: typeof ReviewRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
interface FileRoutesByPath {
|
interface FileRoutesByPath {
|
||||||
'/review-styles': {
|
'/review': {
|
||||||
id: '/review-styles'
|
id: '/review'
|
||||||
path: '/review-styles'
|
path: '/review'
|
||||||
fullPath: '/review-styles'
|
fullPath: '/review'
|
||||||
preLoaderRoute: typeof ReviewStylesRouteImport
|
preLoaderRoute: typeof ReviewRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/profile': {
|
'/my-settings': {
|
||||||
id: '/profile'
|
id: '/my-settings'
|
||||||
path: '/profile'
|
path: '/my-settings'
|
||||||
fullPath: '/profile'
|
fullPath: '/my-settings'
|
||||||
preLoaderRoute: typeof ProfileRouteImport
|
preLoaderRoute: typeof MySettingsRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
'/login': {
|
'/login': {
|
||||||
|
|
@ -102,6 +144,20 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/integrations': {
|
||||||
|
id: '/integrations'
|
||||||
|
path: '/integrations'
|
||||||
|
fullPath: '/integrations'
|
||||||
|
preLoaderRoute: typeof IntegrationsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/cloud-agents': {
|
||||||
|
id: '/cloud-agents'
|
||||||
|
path: '/cloud-agents'
|
||||||
|
fullPath: '/cloud-agents'
|
||||||
|
preLoaderRoute: typeof CloudAgentsRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/admin': {
|
'/admin': {
|
||||||
id: '/admin'
|
id: '/admin'
|
||||||
path: '/admin'
|
path: '/admin'
|
||||||
|
|
@ -122,9 +178,11 @@ declare module '@tanstack/react-router' {
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
AdminRoute: AdminRoute,
|
AdminRoute: AdminRoute,
|
||||||
|
CloudAgentsRoute: CloudAgentsRoute,
|
||||||
|
IntegrationsRoute: IntegrationsRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
ProfileRoute: ProfileRoute,
|
MySettingsRoute: MySettingsRoute,
|
||||||
ReviewStylesRoute: ReviewStylesRoute,
|
ReviewRoute: ReviewRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@ import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type {Profile, ProfileUpdate} from "@/lib/api";
|
import type { Profile, ProfileUpdate } from "@/lib/api";
|
||||||
import { AppHeader } from "@/components/AppHeader";
|
import { AppShell, SettingsSection } from "@/components/AppShell";
|
||||||
import { ProfileForm } from "@/components/ProfileForm";
|
import { ProfileForm } from "@/components/ProfileForm";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
export const Route = createFileRoute("/admin")({ component: AdminPage });
|
export const Route = createFileRoute("/admin")({ component: AdminPage });
|
||||||
|
|
@ -43,27 +42,26 @@ function AdminPage() {
|
||||||
|
|
||||||
if (session.isLoading) {
|
if (session.isLoading) {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto p-6">
|
<main className="p-6">
|
||||||
<Skeleton className="h-64 w-full" />
|
<Skeleton className="h-64 w-full" />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!session.data) return <Navigate to="/login" />;
|
if (!session.data) return <Navigate to="/login" />;
|
||||||
if (!session.data.is_admin) return <Navigate to="/profile" />;
|
if (!session.data.is_admin) return <Navigate to="/my-settings" />;
|
||||||
|
|
||||||
const activeProfile: Profile | null =
|
const activeProfile: Profile | null =
|
||||||
(selected && profiles.data?.find((p) => p.login === selected)) || null;
|
(selected && profiles.data?.find((p) => p.login === selected)) || null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-svh">
|
<AppShell
|
||||||
<AppHeader user={session.data} />
|
user={session.data}
|
||||||
<main className="container mx-auto grid grid-cols-1 gap-6 p-6 md:grid-cols-[280px_1fr]">
|
title="Admin"
|
||||||
<Card>
|
description="Edit any user's profile defaults."
|
||||||
<CardHeader>
|
>
|
||||||
<CardTitle>All users</CardTitle>
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-[260px_1fr]">
|
||||||
<CardDescription>{profiles.data?.length ?? 0} profiles</CardDescription>
|
<SettingsSection title={`Users · ${profiles.data?.length ?? 0}`}>
|
||||||
</CardHeader>
|
<div className="flex flex-col gap-0.5 p-2">
|
||||||
<CardContent className="flex flex-col gap-1">
|
|
||||||
{profiles.isLoading ? (
|
{profiles.isLoading ? (
|
||||||
<Skeleton className="h-32" />
|
<Skeleton className="h-32" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -78,17 +76,18 @@ function AdminPage() {
|
||||||
</Button>
|
</Button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</SettingsSection>
|
||||||
|
|
||||||
<Card>
|
<SettingsSection
|
||||||
<CardHeader>
|
title={activeProfile?.login ?? "Select a user"}
|
||||||
<CardTitle>{activeProfile?.login ?? "Select a user"}</CardTitle>
|
description={activeProfile?.email ?? undefined}
|
||||||
<CardDescription>{activeProfile?.email ?? ""}</CardDescription>
|
>
|
||||||
</CardHeader>
|
<div className="p-4">
|
||||||
<CardContent>
|
|
||||||
{!activeProfile ? (
|
{!activeProfile ? (
|
||||||
<p className="text-muted-foreground text-sm">Pick a user on the left to edit.</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Pick a user on the left to edit.
|
||||||
|
</p>
|
||||||
) : options.isLoading ? (
|
) : options.isLoading ? (
|
||||||
<Skeleton className="h-48" />
|
<Skeleton className="h-48" />
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -103,9 +102,9 @@ function AdminPage() {
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</SettingsSection>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</AppShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
266
ui/src/routes/cloud-agents.tsx
Normal file
266
ui/src/routes/cloud-agents.tsx
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import type { ModelOption } from "@/lib/api";
|
||||||
|
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Combobox,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxList,
|
||||||
|
} from "@/components/ui/combobox";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { buildProfileUpdate, useOptions, useProfile, useRepos, useSaveProfile } from "@/lib/profile";
|
||||||
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/cloud-agents")({ component: CloudAgentsPage });
|
||||||
|
|
||||||
|
function CloudAgentsPage() {
|
||||||
|
const session = useSession();
|
||||||
|
const profile = useProfile();
|
||||||
|
const options = useOptions();
|
||||||
|
const repos = useRepos();
|
||||||
|
const save = useSaveProfile();
|
||||||
|
|
||||||
|
const [modelId, setModelId] = useState("");
|
||||||
|
const [effort, setEffort] = useState("");
|
||||||
|
const [defaultRepo, setDefaultRepo] = useState("");
|
||||||
|
const [baseBranch, setBaseBranch] = useState("");
|
||||||
|
const [branchPrefix, setBranchPrefix] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
const firstModel: ModelOption | undefined = options.data?.models[0];
|
||||||
|
const currentModel: ModelOption | undefined =
|
||||||
|
options.data?.models.find((m) => m.id === modelId) ?? firstModel;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile.data || initialized.current) return;
|
||||||
|
// For users with no saved profile, wait until the options API has loaded
|
||||||
|
// so the model/effort selects can initialise to the first available option.
|
||||||
|
const hasModel = !!profile.data.default_model || !!firstModel;
|
||||||
|
if (!hasModel) return;
|
||||||
|
initialized.current = true;
|
||||||
|
setModelId(profile.data.default_model ?? firstModel?.id ?? "");
|
||||||
|
setEffort(profile.data.reasoning_effort ?? firstModel?.default_effort ?? "");
|
||||||
|
setDefaultRepo(profile.data.default_repo ?? "");
|
||||||
|
setBaseBranch(profile.data.base_branch ?? "");
|
||||||
|
setBranchPrefix(profile.data.branch_prefix ?? "");
|
||||||
|
}, [profile.data, firstModel?.id, firstModel?.default_effort, firstModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentModel && !currentModel.efforts.includes(effort)) {
|
||||||
|
setEffort(currentModel.default_effort);
|
||||||
|
}
|
||||||
|
}, [currentModel, effort]);
|
||||||
|
|
||||||
|
if (session.isLoading) {
|
||||||
|
return (
|
||||||
|
<main className="p-6">
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!session.data) return <Navigate to="/login" />;
|
||||||
|
|
||||||
|
const fallbackModel = firstModel?.id ?? "";
|
||||||
|
const fallbackEffort = firstModel?.default_effort ?? "";
|
||||||
|
|
||||||
|
const persist = (patch: Parameters<typeof buildProfileUpdate>[1]) => {
|
||||||
|
setError(null);
|
||||||
|
save
|
||||||
|
.mutateAsync(
|
||||||
|
buildProfileUpdate(profile.data, patch, fallbackModel, fallbackEffort),
|
||||||
|
)
|
||||||
|
.catch((e: Error) => setError(e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistDefaults = () => {
|
||||||
|
persist({
|
||||||
|
default_model: modelId,
|
||||||
|
reasoning_effort: effort,
|
||||||
|
default_repo: defaultRepo || null,
|
||||||
|
base_branch: baseBranch || null,
|
||||||
|
branch_prefix: branchPrefix || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
user={session.data}
|
||||||
|
title="Cloud Agents"
|
||||||
|
description="Configure how Cloud Agents pick a model, repository, and PR defaults."
|
||||||
|
>
|
||||||
|
<SettingsSection title="Defaults">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<SettingsRow
|
||||||
|
label="Default Model"
|
||||||
|
description="Used when no model is specified"
|
||||||
|
control={
|
||||||
|
<Select value={modelId} onValueChange={(v) => v && setModelId(v)}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="Pick a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{options.data?.models.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
{m.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Reasoning Effort"
|
||||||
|
description="How hard the model thinks before answering"
|
||||||
|
control={
|
||||||
|
<Select value={effort} onValueChange={(v) => v && setEffort(v)}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{currentModel?.efforts.map((e) => (
|
||||||
|
<SelectItem key={e} value={e}>
|
||||||
|
{e}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Default Repository"
|
||||||
|
description="Used when no repository is specified"
|
||||||
|
control={
|
||||||
|
<div className="w-64">
|
||||||
|
{repos.data && repos.data.repositories.length > 0 ? (
|
||||||
|
<Combobox
|
||||||
|
items={repos.data.repositories.map((r) => r.full_name)}
|
||||||
|
value={defaultRepo}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
setDefaultRepo(typeof v === "string" ? v : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ComboboxInput placeholder="Pick a repository…" showClear className="w-full" />
|
||||||
|
<ComboboxContent className="min-w-[var(--anchor-width)]">
|
||||||
|
<ComboboxList className="max-h-64">
|
||||||
|
<ComboboxEmpty>No matches</ComboboxEmpty>
|
||||||
|
{repos.data.repositories.map((r) => (
|
||||||
|
<ComboboxItem key={r.full_name} value={r.full_name}>
|
||||||
|
<span className="truncate">{r.full_name}</span>
|
||||||
|
</ComboboxItem>
|
||||||
|
))}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxContent>
|
||||||
|
</Combobox>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
placeholder="owner/repo"
|
||||||
|
value={defaultRepo}
|
||||||
|
onChange={(e) => setDefaultRepo(e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Base Branch"
|
||||||
|
description="When empty, Cloud Agent will use a repository's default branch (recommended)"
|
||||||
|
htmlFor="base-branch"
|
||||||
|
control={
|
||||||
|
<Input
|
||||||
|
id="base-branch"
|
||||||
|
className="w-56"
|
||||||
|
placeholder="Branch name…"
|
||||||
|
value={baseBranch}
|
||||||
|
onChange={(e) => setBaseBranch(e.target.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Branch Prefix"
|
||||||
|
description="Prefix for branch names created by Cloud Agent"
|
||||||
|
htmlFor="branch-prefix"
|
||||||
|
control={
|
||||||
|
<Input
|
||||||
|
id="branch-prefix"
|
||||||
|
className="w-56"
|
||||||
|
placeholder="open-swe/"
|
||||||
|
value={branchPrefix}
|
||||||
|
onChange={(e) => setBranchPrefix(e.target.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end px-4 py-3">
|
||||||
|
<Button size="sm" onClick={persistDefaults} disabled={save.isPending}>
|
||||||
|
{save.isPending ? "Saving…" : "Save defaults"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection title="Pull Requests">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<SettingsRow
|
||||||
|
label="Automatically fix CI failures"
|
||||||
|
description="Agent will attempt to fix failing CI checks on PRs it opens."
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={profile.data?.auto_fix_ci ?? true}
|
||||||
|
onCheckedChange={(v) => persist({ auto_fix_ci: v })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Create PRs"
|
||||||
|
description="Automatically create a pull request when a Cloud Agent completes."
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={profile.data?.create_prs ?? true}
|
||||||
|
onCheckedChange={(v) => persist({ create_prs: v })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Allow posting artifacts to GitHub"
|
||||||
|
description="Allow cloud agents to embed images directly in PR descriptions using hard-to-guess public URLs."
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={profile.data?.allow_artifacts ?? false}
|
||||||
|
onCheckedChange={(v) => persist({ allow_artifacts: v })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection title="Notifications">
|
||||||
|
<SettingsRow
|
||||||
|
label="Slack Notifications"
|
||||||
|
description="Get notified in Slack when a Cloud Agent completes a task."
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={profile.data?.slack_notifications ?? true}
|
||||||
|
onCheckedChange={(v) => persist({ slack_notifications: v })}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,5 +3,5 @@ import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||||
export const Route = createFileRoute("/")({ component: Index });
|
export const Route = createFileRoute("/")({ component: Index });
|
||||||
|
|
||||||
function Index() {
|
function Index() {
|
||||||
return <Navigate to="/profile" />;
|
return <Navigate to="/my-settings" />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
123
ui/src/routes/integrations.tsx
Normal file
123
ui/src/routes/integrations.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { GithubLogoIcon, KanbanIcon, SlackLogoIcon } from "@phosphor-icons/react";
|
||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
import type { ReposPayload } from "@/lib/api";
|
||||||
|
import { AppShell, SettingsSection } from "@/components/AppShell";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { ApiError, api } from "@/lib/api";
|
||||||
|
import { useSession } from "@/lib/session";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/integrations")({ component: IntegrationsPage });
|
||||||
|
|
||||||
|
type IconType = ComponentType<{ className?: string; weight?: "regular" | "fill" | "duotone" }>;
|
||||||
|
|
||||||
|
interface IntegrationRowProps {
|
||||||
|
icon: IconType;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
connected: boolean;
|
||||||
|
badge?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegrationRow({ icon: Icon, name, description, connected, badge }: IntegrationRowProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium">
|
||||||
|
{name}
|
||||||
|
{badge && (
|
||||||
|
<span className="rounded-sm bg-muted px-1.5 py-0.5 text-[10px] font-normal text-muted-foreground">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||||
|
connected
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{connected ? "Connected" : "Not connected"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntegrationsPage() {
|
||||||
|
const session = useSession();
|
||||||
|
const repos = useQuery<ReposPayload>({
|
||||||
|
queryKey: ["repos"],
|
||||||
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
|
return await api.repos();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 401)
|
||||||
|
return { installations: [], repositories: [] };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: !!session.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.isLoading) {
|
||||||
|
return (
|
||||||
|
<main className="p-6">
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!session.data) return <Navigate to="/login" />;
|
||||||
|
|
||||||
|
const installs = repos.data?.installations ?? [];
|
||||||
|
const githubDescription = installs.length
|
||||||
|
? `Connected to ${installs.length} ${installs.length === 1 ? "installation" : "installations"}: ${installs
|
||||||
|
.map((i) => i.account ?? "?")
|
||||||
|
.join(", ")}`
|
||||||
|
: "Install the open-swe GitHub App to enable PR and issue triggers.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
user={session.data}
|
||||||
|
title="Integrations"
|
||||||
|
description="Connect external tools that can trigger or receive updates from open-swe."
|
||||||
|
>
|
||||||
|
<SettingsSection title="Source Control">
|
||||||
|
<IntegrationRow
|
||||||
|
icon={GithubLogoIcon}
|
||||||
|
name="GitHub"
|
||||||
|
description={githubDescription}
|
||||||
|
connected={installs.length > 0}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection title="Communication">
|
||||||
|
<IntegrationRow
|
||||||
|
icon={SlackLogoIcon}
|
||||||
|
name="Slack"
|
||||||
|
description="Trigger Open SWE from Slack and receive run notifications."
|
||||||
|
badge="Team-managed"
|
||||||
|
connected
|
||||||
|
/>
|
||||||
|
<IntegrationRow
|
||||||
|
icon={KanbanIcon}
|
||||||
|
name="Linear"
|
||||||
|
description="Delegate Linear issues to Open SWE via mentions or commands."
|
||||||
|
badge="Team-managed"
|
||||||
|
connected
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,7 @@ function Login() {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.data) {
|
if (session.data) {
|
||||||
return <Navigate to="/profile" />;
|
return <Navigate to="/my-settings" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
142
ui/src/routes/my-settings.tsx
Normal file
142
ui/src/routes/my-settings.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { buildProfileUpdate, useOptions, useProfile, useSaveProfile } from "@/lib/profile";
|
||||||
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/my-settings")({ component: MySettingsPage });
|
||||||
|
|
||||||
|
function MySettingsPage() {
|
||||||
|
const session = useSession();
|
||||||
|
const profile = useProfile();
|
||||||
|
const options = useOptions();
|
||||||
|
const save = useSaveProfile();
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState("");
|
||||||
|
const [lastName, setLastName] = useState("");
|
||||||
|
const [prDestination, setPrDestination] = useState<string>("team_default");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile.data || initialized.current) return;
|
||||||
|
initialized.current = true;
|
||||||
|
setFirstName(profile.data.first_name ?? "");
|
||||||
|
setLastName(profile.data.last_name ?? "");
|
||||||
|
setPrDestination(profile.data.preferred_pr_destination ?? "team_default");
|
||||||
|
}, [profile.data]);
|
||||||
|
|
||||||
|
if (session.isLoading) {
|
||||||
|
return (
|
||||||
|
<main className="p-6">
|
||||||
|
<Skeleton className="h-40 w-full" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!session.data) return <Navigate to="/login" />;
|
||||||
|
|
||||||
|
const firstModel = options.data?.models[0];
|
||||||
|
const handleSaveProfile = () => {
|
||||||
|
setError(null);
|
||||||
|
const body = buildProfileUpdate(
|
||||||
|
profile.data,
|
||||||
|
{ first_name: firstName || null, last_name: lastName || null },
|
||||||
|
firstModel?.id ?? "",
|
||||||
|
firstModel?.default_effort ?? "",
|
||||||
|
);
|
||||||
|
save
|
||||||
|
.mutateAsync(body)
|
||||||
|
.catch((e: Error) => setError(e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePrDestination = (value: string | null) => {
|
||||||
|
if (!value) return;
|
||||||
|
setPrDestination(value);
|
||||||
|
const body = buildProfileUpdate(
|
||||||
|
profile.data,
|
||||||
|
{ preferred_pr_destination: value === "team_default" ? null : value },
|
||||||
|
firstModel?.id ?? "",
|
||||||
|
firstModel?.default_effort ?? "",
|
||||||
|
);
|
||||||
|
save.mutateAsync(body).catch((e: Error) => setError(e.message));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell user={session.data} title="My Settings">
|
||||||
|
<SettingsSection title="Profile">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<SettingsRow
|
||||||
|
label="Email"
|
||||||
|
control={
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{session.data.email ?? "—"}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="First Name"
|
||||||
|
htmlFor="first-name"
|
||||||
|
control={
|
||||||
|
<Input
|
||||||
|
id="first-name"
|
||||||
|
className="w-56"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Last Name"
|
||||||
|
htmlFor="last-name"
|
||||||
|
control={
|
||||||
|
<Input
|
||||||
|
id="last-name"
|
||||||
|
className="w-56"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end px-4 py-3">
|
||||||
|
<Button size="sm" onClick={handleSaveProfile} disabled={save.isPending}>
|
||||||
|
{save.isPending ? "Saving…" : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<SettingsSection title="PR Preferences">
|
||||||
|
<SettingsRow
|
||||||
|
label="Preferred PR destination"
|
||||||
|
description="Choose where PR links open across web, the desktop app and IDE."
|
||||||
|
control={
|
||||||
|
<Select value={prDestination} onValueChange={handleSavePrDestination}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="team_default">Team default</SelectItem>
|
||||||
|
<SelectItem value="web">Web</SelectItem>
|
||||||
|
<SelectItem value="desktop">Desktop App</SelectItem>
|
||||||
|
<SelectItem value="ide">IDE</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import type { ProfileUpdate } from "@/lib/api";
|
|
||||||
import { AppHeader } from "@/components/AppHeader";
|
|
||||||
import { ProfileForm } from "@/components/ProfileForm";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { ApiError, api } from "@/lib/api";
|
|
||||||
import { useSession } from "@/lib/session";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/profile")({ component: ProfilePage });
|
|
||||||
|
|
||||||
function ProfilePage() {
|
|
||||||
const session = useSession();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const options = useQuery({
|
|
||||||
queryKey: ["options"],
|
|
||||||
queryFn: api.options,
|
|
||||||
enabled: !!session.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
const profile = useQuery({
|
|
||||||
queryKey: ["profile"],
|
|
||||||
queryFn: api.profile,
|
|
||||||
enabled: !!session.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
const repos = useQuery({
|
|
||||||
queryKey: ["repos"],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
return await api.repos();
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ApiError && e.status === 401) return { installations: [], repositories: [] };
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled: !!session.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
const save = useMutation({
|
|
||||||
mutationFn: (body: ProfileUpdate) => api.saveProfile(body),
|
|
||||||
onSuccess: (saved) => {
|
|
||||||
qc.setQueryData(["profile"], saved);
|
|
||||||
setError(null);
|
|
||||||
},
|
|
||||||
onError: (e: Error) => setError(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (session.isLoading) {
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto p-6">
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.data) return <Navigate to="/login" />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-svh">
|
|
||||||
<AppHeader user={session.data} />
|
|
||||||
<main className="container mx-auto p-6">
|
|
||||||
<Card className="mx-auto max-w-2xl">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Your open-swe profile</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
These defaults are applied when you invoke open-swe from Slack, Linear, or GitHub.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{options.isLoading || profile.isLoading ? (
|
|
||||||
<Skeleton className="h-48 w-full" />
|
|
||||||
) : (
|
|
||||||
<ProfileForm
|
|
||||||
models={options.data?.models ?? []}
|
|
||||||
repos={repos.data?.repositories ?? []}
|
|
||||||
initial={profile.data ?? ({})}
|
|
||||||
onSubmit={(body) => save.mutateAsync(body)}
|
|
||||||
saving={save.isPending}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,299 +0,0 @@
|
||||||
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { AppHeader } from "@/components/AppHeader";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Combobox,
|
|
||||||
ComboboxContent,
|
|
||||||
ComboboxEmpty,
|
|
||||||
ComboboxInput,
|
|
||||||
ComboboxItem,
|
|
||||||
ComboboxList,
|
|
||||||
} from "@/components/ui/combobox";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { ApiError, api, type ReviewStyle } from "@/lib/api";
|
|
||||||
import { normalizeRepoFullName } from "@/lib/repo";
|
|
||||||
import { useSession } from "@/lib/session";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/review-styles")({ component: ReviewStylesPage });
|
|
||||||
|
|
||||||
function statusVariant(status: ReviewStyle["status"]) {
|
|
||||||
switch (status) {
|
|
||||||
case "completed":
|
|
||||||
return "default" as const;
|
|
||||||
case "running":
|
|
||||||
return "secondary" as const;
|
|
||||||
case "failed":
|
|
||||||
return "destructive" as const;
|
|
||||||
default:
|
|
||||||
return "outline" as const;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function ReviewStylesPage() {
|
|
||||||
const session = useSession();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [addRepo, setAddRepo] = useState("");
|
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
|
||||||
const [draftPrompt, setDraftPrompt] = useState("");
|
|
||||||
|
|
||||||
const styles = useQuery({
|
|
||||||
queryKey: ["reviewStyles"],
|
|
||||||
queryFn: api.listReviewStyles,
|
|
||||||
enabled: !!session.data,
|
|
||||||
refetchInterval: (q) => {
|
|
||||||
const hasRunning = (q.state.data ?? []).some((r) => r.status === "running");
|
|
||||||
return hasRunning ? 4000 : false;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const repos = useQuery({
|
|
||||||
queryKey: ["repos"],
|
|
||||||
queryFn: async () => {
|
|
||||||
try {
|
|
||||||
return await api.repos();
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ApiError && e.status === 401) return { installations: [], repositories: [] };
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled: !!session.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
const detail = useQuery({
|
|
||||||
queryKey: ["reviewStyle", selected],
|
|
||||||
queryFn: () => api.getReviewStyle(selected!),
|
|
||||||
enabled: !!selected,
|
|
||||||
refetchInterval: (q) => (q.state.data?.status === "running" ? 4000 : false),
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (detail.data?.custom_prompt != null) {
|
|
||||||
setDraftPrompt(detail.data.custom_prompt);
|
|
||||||
} else if (detail.data && !detail.data.custom_prompt) {
|
|
||||||
setDraftPrompt("");
|
|
||||||
}
|
|
||||||
}, [detail.data?.custom_prompt, detail.data?.full_name]);
|
|
||||||
|
|
||||||
const createStyle = useMutation({
|
|
||||||
mutationFn: (full_name: string) => api.createReviewStyle(full_name),
|
|
||||||
onSuccess: (record) => {
|
|
||||||
void qc.invalidateQueries({ queryKey: ["reviewStyles"] });
|
|
||||||
setSelected(record.full_name);
|
|
||||||
setError(null);
|
|
||||||
},
|
|
||||||
onError: (e: Error) => setError(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const analyze = useMutation({
|
|
||||||
mutationFn: (full_name: string) => api.analyzeReviewStyle(full_name),
|
|
||||||
onSuccess: () => {
|
|
||||||
void qc.invalidateQueries({ queryKey: ["reviewStyles"] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ["reviewStyle", selected] });
|
|
||||||
setError(null);
|
|
||||||
},
|
|
||||||
onError: (e: Error) => setError(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
const savePrompt = useMutation({
|
|
||||||
mutationFn: ({ full_name, custom_prompt }: { full_name: string; custom_prompt: string }) =>
|
|
||||||
api.saveReviewStylePrompt(full_name, custom_prompt),
|
|
||||||
onSuccess: () => {
|
|
||||||
void qc.invalidateQueries({ queryKey: ["reviewStyles"] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ["reviewStyle", selected] });
|
|
||||||
setError(null);
|
|
||||||
},
|
|
||||||
onError: (e: Error) => setError(e.message),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (session.isLoading) {
|
|
||||||
return (
|
|
||||||
<main className="container mx-auto p-6">
|
|
||||||
<Skeleton className="h-64 w-full" />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!session.data) return <Navigate to="/login" />;
|
|
||||||
|
|
||||||
const configured = new Set((styles.data ?? []).map((s) => s.full_name));
|
|
||||||
const suggestedRepos = (repos.data?.repositories ?? []).filter((r) => !configured.has(r.full_name));
|
|
||||||
const normalizedAddRepo = normalizeRepoFullName(addRepo);
|
|
||||||
const canAdd = normalizedAddRepo !== null && !configured.has(normalizedAddRepo);
|
|
||||||
const active = detail.data ?? styles.data?.find((s) => s.full_name === selected) ?? null;
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
if (!normalizedAddRepo || !canAdd) return;
|
|
||||||
void createStyle.mutateAsync(normalizedAddRepo).then(() => setAddRepo(""));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-svh">
|
|
||||||
<AppHeader user={session.data} />
|
|
||||||
<main className="container mx-auto grid grid-cols-1 gap-6 p-6 md:grid-cols-[300px_1fr]">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Repositories</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
An agent browses recent merged PR review feedback on GitHub, then writes a
|
|
||||||
per-repo style guide for the reviewer.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="add-repo">Add repository</Label>
|
|
||||||
<Input
|
|
||||||
id="add-repo"
|
|
||||||
className="w-full"
|
|
||||||
placeholder="owner/repo or https://github.com/owner/repo"
|
|
||||||
value={addRepo}
|
|
||||||
onChange={(e) => setAddRepo(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleAdd();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
Any public repository is supported. Private repos require your GitHub account to
|
|
||||||
have read access.
|
|
||||||
</p>
|
|
||||||
{suggestedRepos.length > 0 && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-muted-foreground text-xs">Your installations</p>
|
|
||||||
<Combobox
|
|
||||||
items={suggestedRepos.map((r) => r.full_name)}
|
|
||||||
value={addRepo}
|
|
||||||
onValueChange={(v) => setAddRepo(typeof v === "string" ? v : "")}
|
|
||||||
>
|
|
||||||
<ComboboxInput placeholder="Search installed repos…" showClear className="w-full" />
|
|
||||||
<ComboboxContent className="min-w-[var(--anchor-width)]">
|
|
||||||
<ComboboxList className="max-h-48">
|
|
||||||
<ComboboxEmpty>No matches</ComboboxEmpty>
|
|
||||||
{suggestedRepos.map((r) => (
|
|
||||||
<ComboboxItem key={r.full_name} value={r.full_name}>
|
|
||||||
<span className="truncate">{r.full_name}</span>
|
|
||||||
{r.private && (
|
|
||||||
<span className="text-muted-foreground ml-auto text-[10px]">
|
|
||||||
private
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</ComboboxItem>
|
|
||||||
))}
|
|
||||||
</ComboboxList>
|
|
||||||
</ComboboxContent>
|
|
||||||
</Combobox>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{addRepo.trim() && !normalizedAddRepo && (
|
|
||||||
<p className="text-destructive text-xs">Enter a repository as owner/repo</p>
|
|
||||||
)}
|
|
||||||
{normalizedAddRepo && configured.has(normalizedAddRepo) && (
|
|
||||||
<p className="text-muted-foreground text-xs">Already added</p>
|
|
||||||
)}
|
|
||||||
<Button className="w-full" disabled={!canAdd || createStyle.isPending} onClick={handleAdd}>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-1">
|
|
||||||
{(styles.data ?? []).map((s) => (
|
|
||||||
<li key={s.full_name}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-sm hover:bg-muted ${
|
|
||||||
selected === s.full_name ? "bg-muted font-medium" : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => setSelected(s.full_name)}
|
|
||||||
>
|
|
||||||
<span className="truncate">{s.full_name}</span>
|
|
||||||
<Badge variant={statusVariant(s.status)} className="ml-2 shrink-0">
|
|
||||||
{s.status}
|
|
||||||
</Badge>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Review style prompt</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Injected into the reviewer agent for PRs on this repo. Edit after analysis or write
|
|
||||||
your own.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{!selected || !active ? (
|
|
||||||
<p className="text-muted-foreground text-sm">Select a repository to view or edit.</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
||||||
<Badge variant={statusVariant(active.status)}>{active.status}</Badge>
|
|
||||||
{active.top_reviewers.length > 0 && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
Reviewers: {active.top_reviewers.join(", ")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{active.prs_sampled > 0 && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{active.prs_sampled} PRs · {active.reviews_sampled} reviews sampled
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{active.analysis_summary && (
|
|
||||||
<p className="text-muted-foreground text-sm">{active.analysis_summary}</p>
|
|
||||||
)}
|
|
||||||
{active.error && (
|
|
||||||
<p className="text-destructive text-sm">{active.error}</p>
|
|
||||||
)}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={active.status === "running" || analyze.isPending}
|
|
||||||
onClick={() => void analyze.mutateAsync(active.full_name)}
|
|
||||||
>
|
|
||||||
{active.status === "running" ? "Analyzing…" : "Run analysis"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled={!draftPrompt.trim() || savePrompt.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
void savePrompt.mutateAsync({
|
|
||||||
full_name: active.full_name,
|
|
||||||
custom_prompt: draftPrompt,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Save prompt
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Textarea
|
|
||||||
className="min-h-[360px] font-mono text-sm"
|
|
||||||
value={draftPrompt}
|
|
||||||
onChange={(e) => setDraftPrompt(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
active.status === "running"
|
|
||||||
? "Analysis in progress…"
|
|
||||||
: "Run analysis or write a custom prompt for this repository."
|
|
||||||
}
|
|
||||||
disabled={active.status === "running"}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{error && <p className="text-destructive text-sm">{error}</p>}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
210
ui/src/routes/review.tsx
Normal file
210
ui/src/routes/review.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { AutofixMode, TeamSettings, TriggerMode } from "@/lib/api";
|
||||||
|
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell";
|
||||||
|
import { ReviewStylesPanel } from "@/components/ReviewStylesPanel";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/review")({ component: ReviewPage });
|
||||||
|
|
||||||
|
const TRIGGER_MODES: Array<{ value: TriggerMode; label: string; description: string }> = [
|
||||||
|
{ value: "every_push", label: "Every Push", description: "Review every push to a PR" },
|
||||||
|
{
|
||||||
|
value: "ready_for_review",
|
||||||
|
label: "Ready For Review",
|
||||||
|
description: "Only when the PR is marked ready for review",
|
||||||
|
},
|
||||||
|
{ value: "manual", label: "Manual", description: "Review only when explicitly invoked" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AUTOFIX_MODES: Array<{ value: AutofixMode; label: string }> = [
|
||||||
|
{ value: "off", label: "Off" },
|
||||||
|
{ value: "low", label: "Low" },
|
||||||
|
{ value: "medium", label: "Medium" },
|
||||||
|
{ value: "high", label: "High" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: TeamSettings = {
|
||||||
|
trigger_mode: "every_push",
|
||||||
|
review_draft_prs: false,
|
||||||
|
pr_summaries: true,
|
||||||
|
autofix_mode: "off",
|
||||||
|
autofix_severity_threshold: "medium",
|
||||||
|
};
|
||||||
|
|
||||||
|
function ReviewPage() {
|
||||||
|
const session = useSession();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const settings = useQuery({
|
||||||
|
queryKey: ["teamSettings"],
|
||||||
|
queryFn: api.getTeamSettings,
|
||||||
|
enabled: !!session.data,
|
||||||
|
});
|
||||||
|
const [local, setLocal] = useState<TeamSettings>(DEFAULT_SETTINGS);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.data) setLocal(settings.data);
|
||||||
|
}, [settings.data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
qc.setQueryData(["teamSettings"], saved);
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: (e: Error) => setError(e.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (session.isLoading) {
|
||||||
|
return (
|
||||||
|
<main className="p-6">
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!session.data) return <Navigate to="/login" />;
|
||||||
|
|
||||||
|
const current: TeamSettings = local;
|
||||||
|
const canEdit = session.data.is_admin;
|
||||||
|
|
||||||
|
const persist = (patch: Partial<TeamSettings>) => {
|
||||||
|
const next: TeamSettings = { ...current, ...patch };
|
||||||
|
setLocal(next);
|
||||||
|
if (canEdit) save.mutate(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerDescription =
|
||||||
|
TRIGGER_MODES.find((m) => m.value === current.trigger_mode)?.description ??
|
||||||
|
"Open SWE Review will automatically review every push to a PR";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
user={session.data}
|
||||||
|
title="Open SWE Review"
|
||||||
|
description="Automatically review pull requests for bugs and issues. Runs are billed based on underlying agent usage."
|
||||||
|
>
|
||||||
|
<SettingsSection title="Configuration">
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
<SettingsRow
|
||||||
|
label="Trigger Mode"
|
||||||
|
description={triggerDescription}
|
||||||
|
control={
|
||||||
|
<Select
|
||||||
|
value={current.trigger_mode}
|
||||||
|
onValueChange={(v) => persist({ trigger_mode: v as TriggerMode })}
|
||||||
|
disabled={!canEdit}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{TRIGGER_MODES.map((m) => (
|
||||||
|
<SelectItem key={m.value} value={m.value}>
|
||||||
|
{m.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Review Draft PRs"
|
||||||
|
description="Allow Open SWE Review to automatically review draft pull requests"
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={current.review_draft_prs}
|
||||||
|
onCheckedChange={(v) => persist({ review_draft_prs: v })}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="PR Summaries"
|
||||||
|
description="Generate descriptions on pull requests"
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={current.pr_summaries}
|
||||||
|
onCheckedChange={(v) => persist({ pr_summaries: v })}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Autofix Mode"
|
||||||
|
description="When enabled, the reviewer will propose fixes. Billed at plan rates."
|
||||||
|
control={
|
||||||
|
<Select
|
||||||
|
value={current.autofix_mode}
|
||||||
|
onValueChange={(v) => persist({ autofix_mode: v as AutofixMode })}
|
||||||
|
disabled={!canEdit}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AUTOFIX_MODES.map((m) => (
|
||||||
|
<SelectItem key={m.value} value={m.value}>
|
||||||
|
{m.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsRow
|
||||||
|
label="Autofix Severity Threshold"
|
||||||
|
description="Findings at this severity or higher are auto-fixed"
|
||||||
|
control={
|
||||||
|
<Select
|
||||||
|
value={current.autofix_severity_threshold}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
persist({ autofix_severity_threshold: v as AutofixMode })
|
||||||
|
}
|
||||||
|
disabled={!canEdit}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AUTOFIX_MODES.map((m) => (
|
||||||
|
<SelectItem key={m.value} value={m.value}>
|
||||||
|
{m.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{!canEdit && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
These settings are read-only. Ask a workspace admin to change them.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsSection
|
||||||
|
title="Review Style Prompts"
|
||||||
|
description="An agent browses recent merged PR review feedback on GitHub, then writes a per-repo style guide for the reviewer."
|
||||||
|
>
|
||||||
|
<ReviewStylesPanel />
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue