mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 05:43: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
|
||||
reasoning_effort: str
|
||||
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")
|
||||
@classmethod
|
||||
|
|
@ -84,6 +93,15 @@ async def upsert_profile(login: str, email: str, update: ProfileUpdate) -> dict[
|
|||
"default_model": update.default_model,
|
||||
"reasoning_effort": update.reasoning_effort,
|
||||
"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(),
|
||||
}
|
||||
await _client().store.put_item(PROFILES_NAMESPACE, login, value)
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ from .review_styles import (
|
|||
normalize_repo_full_name,
|
||||
set_custom_prompt,
|
||||
)
|
||||
from .team_settings import (
|
||||
TeamSettingsUpdate,
|
||||
get_team_settings,
|
||||
upsert_team_settings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -226,14 +231,32 @@ async def admin_put_profile(
|
|||
update.validate_pairing()
|
||||
existing = await get_profile(login) 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(
|
||||
default_model=update.default_model,
|
||||
reasoning_effort=update.reasoning_effort,
|
||||
default_repo=update.default_repo,
|
||||
**{k: v for k, v in merged.items() if k in ProfileUpdate.model_fields},
|
||||
)
|
||||
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:
|
||||
if not link_header:
|
||||
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;
|
||||
reasoning_effort?: string;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -68,6 +77,27 @@ export interface ProfileUpdate {
|
|||
default_model: string;
|
||||
reasoning_effort: string;
|
||||
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 {
|
||||
|
|
@ -130,6 +160,9 @@ export const api = {
|
|||
request<ReviewStyle>(`/review-styles/${encodeURIComponent(full_name)}/analyze`, {
|
||||
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"),
|
||||
adminSaveProfile: (login: string, body: ProfileUpdate & { email?: string }) =>
|
||||
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.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as ReviewStylesRouteImport } from './routes/review-styles'
|
||||
import { Route as ProfileRouteImport } from './routes/profile'
|
||||
import { Route as ReviewRouteImport } from './routes/review'
|
||||
import { Route as MySettingsRouteImport } from './routes/my-settings'
|
||||
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 IndexRouteImport } from './routes/index'
|
||||
|
||||
const ReviewStylesRoute = ReviewStylesRouteImport.update({
|
||||
id: '/review-styles',
|
||||
path: '/review-styles',
|
||||
const ReviewRoute = ReviewRouteImport.update({
|
||||
id: '/review',
|
||||
path: '/review',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProfileRoute = ProfileRouteImport.update({
|
||||
id: '/profile',
|
||||
path: '/profile',
|
||||
const MySettingsRoute = MySettingsRouteImport.update({
|
||||
id: '/my-settings',
|
||||
path: '/my-settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
|
|
@ -30,6 +32,16 @@ const LoginRoute = LoginRouteImport.update({
|
|||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} 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({
|
||||
id: '/admin',
|
||||
path: '/admin',
|
||||
|
|
@ -44,55 +56,85 @@ const IndexRoute = IndexRouteImport.update({
|
|||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/cloud-agents': typeof CloudAgentsRoute
|
||||
'/integrations': typeof IntegrationsRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/profile': typeof ProfileRoute
|
||||
'/review-styles': typeof ReviewStylesRoute
|
||||
'/my-settings': typeof MySettingsRoute
|
||||
'/review': typeof ReviewRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/cloud-agents': typeof CloudAgentsRoute
|
||||
'/integrations': typeof IntegrationsRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/profile': typeof ProfileRoute
|
||||
'/review-styles': typeof ReviewStylesRoute
|
||||
'/my-settings': typeof MySettingsRoute
|
||||
'/review': typeof ReviewRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/admin': typeof AdminRoute
|
||||
'/cloud-agents': typeof CloudAgentsRoute
|
||||
'/integrations': typeof IntegrationsRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/profile': typeof ProfileRoute
|
||||
'/review-styles': typeof ReviewStylesRoute
|
||||
'/my-settings': typeof MySettingsRoute
|
||||
'/review': typeof ReviewRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/admin' | '/login' | '/profile' | '/review-styles'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/cloud-agents'
|
||||
| '/integrations'
|
||||
| '/login'
|
||||
| '/my-settings'
|
||||
| '/review'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/admin' | '/login' | '/profile' | '/review-styles'
|
||||
id: '__root__' | '/' | '/admin' | '/login' | '/profile' | '/review-styles'
|
||||
to:
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/cloud-agents'
|
||||
| '/integrations'
|
||||
| '/login'
|
||||
| '/my-settings'
|
||||
| '/review'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/admin'
|
||||
| '/cloud-agents'
|
||||
| '/integrations'
|
||||
| '/login'
|
||||
| '/my-settings'
|
||||
| '/review'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AdminRoute: typeof AdminRoute
|
||||
CloudAgentsRoute: typeof CloudAgentsRoute
|
||||
IntegrationsRoute: typeof IntegrationsRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
ProfileRoute: typeof ProfileRoute
|
||||
ReviewStylesRoute: typeof ReviewStylesRoute
|
||||
MySettingsRoute: typeof MySettingsRoute
|
||||
ReviewRoute: typeof ReviewRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/review-styles': {
|
||||
id: '/review-styles'
|
||||
path: '/review-styles'
|
||||
fullPath: '/review-styles'
|
||||
preLoaderRoute: typeof ReviewStylesRouteImport
|
||||
'/review': {
|
||||
id: '/review'
|
||||
path: '/review'
|
||||
fullPath: '/review'
|
||||
preLoaderRoute: typeof ReviewRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/profile': {
|
||||
id: '/profile'
|
||||
path: '/profile'
|
||||
fullPath: '/profile'
|
||||
preLoaderRoute: typeof ProfileRouteImport
|
||||
'/my-settings': {
|
||||
id: '/my-settings'
|
||||
path: '/my-settings'
|
||||
fullPath: '/my-settings'
|
||||
preLoaderRoute: typeof MySettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
|
|
@ -102,6 +144,20 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof LoginRouteImport
|
||||
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': {
|
||||
id: '/admin'
|
||||
path: '/admin'
|
||||
|
|
@ -122,9 +178,11 @@ declare module '@tanstack/react-router' {
|
|||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AdminRoute: AdminRoute,
|
||||
CloudAgentsRoute: CloudAgentsRoute,
|
||||
IntegrationsRoute: IntegrationsRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
ProfileRoute: ProfileRoute,
|
||||
ReviewStylesRoute: ReviewStylesRoute,
|
||||
MySettingsRoute: MySettingsRoute,
|
||||
ReviewRoute: ReviewRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@ import { Navigate, createFileRoute } from "@tanstack/react-router";
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import type {Profile, ProfileUpdate} from "@/lib/api";
|
||||
import { AppHeader } from "@/components/AppHeader";
|
||||
import type { Profile, ProfileUpdate } from "@/lib/api";
|
||||
import { AppShell, SettingsSection } from "@/components/AppShell";
|
||||
import { ProfileForm } from "@/components/ProfileForm";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/lib/api";
|
||||
import { api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export const Route = createFileRoute("/admin")({ component: AdminPage });
|
||||
|
|
@ -43,27 +42,26 @@ function AdminPage() {
|
|||
|
||||
if (session.isLoading) {
|
||||
return (
|
||||
<main className="container mx-auto p-6">
|
||||
<main className="p-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
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 =
|
||||
(selected && profiles.data?.find((p) => p.login === selected)) || null;
|
||||
|
||||
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-[280px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>All users</CardTitle>
|
||||
<CardDescription>{profiles.data?.length ?? 0} profiles</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-1">
|
||||
<AppShell
|
||||
user={session.data}
|
||||
title="Admin"
|
||||
description="Edit any user's profile defaults."
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-[260px_1fr]">
|
||||
<SettingsSection title={`Users · ${profiles.data?.length ?? 0}`}>
|
||||
<div className="flex flex-col gap-0.5 p-2">
|
||||
{profiles.isLoading ? (
|
||||
<Skeleton className="h-32" />
|
||||
) : (
|
||||
|
|
@ -78,17 +76,18 @@ function AdminPage() {
|
|||
</Button>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{activeProfile?.login ?? "Select a user"}</CardTitle>
|
||||
<CardDescription>{activeProfile?.email ?? ""}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SettingsSection
|
||||
title={activeProfile?.login ?? "Select a user"}
|
||||
description={activeProfile?.email ?? undefined}
|
||||
>
|
||||
<div className="p-4">
|
||||
{!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 ? (
|
||||
<Skeleton className="h-48" />
|
||||
) : (
|
||||
|
|
@ -103,9 +102,9 @@ function AdminPage() {
|
|||
error={error}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</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 });
|
||||
|
||||
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) {
|
||||
return <Navigate to="/profile" />;
|
||||
return <Navigate to="/my-settings" />;
|
||||
}
|
||||
|
||||
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