diff --git a/agent/dashboard/profiles.py b/agent/dashboard/profiles.py
index 011183c2..0b4bfedf 100644
--- a/agent/dashboard/profiles.py
+++ b/agent/dashboard/profiles.py
@@ -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)
diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py
index 1aab24bc..b52884a4 100644
--- a/agent/dashboard/routes.py
+++ b/agent/dashboard/routes.py
@@ -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
diff --git a/agent/dashboard/team_settings.py b/agent/dashboard/team_settings.py
new file mode 100644
index 00000000..39da1c99
--- /dev/null
+++ b/agent/dashboard/team_settings.py
@@ -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
diff --git a/ui/src/components/AppHeader.tsx b/ui/src/components/AppHeader.tsx
deleted file mode 100644
index a3980c72..00000000
--- a/ui/src/components/AppHeader.tsx
+++ /dev/null
@@ -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 (
-
-
-
- open-swe
-
-
-
- Profile
-
-
- Review styles
-
- {user.is_admin && (
-
- Admin
-
- )}
-
-
- {user.is_admin &&
admin }
-
{user.login}
-
- {user.avatar_url && }
- {user.login.slice(0, 2).toUpperCase()}
-
-
-
- Sign out
-
-
-
-
- );
-}
diff --git a/ui/src/components/AppShell.tsx b/ui/src/components/AppShell.tsx
new file mode 100644
index 00000000..48996062
--- /dev/null
+++ b/ui/src/components/AppShell.tsx
@@ -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 (
+
+
+
+
+
+ {title}
+ {description && (
+ {description}
+ )}
+
+
{children}
+
+
+
+ );
+}
+
+interface SettingsSectionProps {
+ title: string;
+ description?: string;
+ action?: ReactNode;
+ children: ReactNode;
+}
+
+export function SettingsSection({ title, description, action, children }: SettingsSectionProps) {
+ return (
+
+
+
+
+ {title}
+
+ {description && (
+
{description}
+ )}
+
+ {action}
+
+ {children}
+
+ );
+}
+
+interface SettingsRowProps {
+ label: string;
+ description?: string;
+ control: ReactNode;
+ htmlFor?: string;
+}
+
+export function SettingsRow({ label, description, control, htmlFor }: SettingsRowProps) {
+ return (
+
+
+ {label}
+ {description && (
+ {description}
+ )}
+
+
{control}
+
+ );
+}
diff --git a/ui/src/components/AppSidebar.tsx b/ui/src/components/AppSidebar.tsx
new file mode 100644
index 00000000..2acdc8e1
--- /dev/null
+++ b/ui/src/components/AppSidebar.tsx
@@ -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 = [
+ { 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(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 (
+
+ );
+}
diff --git a/ui/src/components/ReviewStylesPanel.tsx b/ui/src/components/ReviewStylesPanel.tsx
new file mode 100644
index 00000000..a97a62a7
--- /dev/null
+++ b/ui/src/components/ReviewStylesPanel.tsx
@@ -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(null);
+ const [addRepo, setAddRepo] = useState("");
+ const [selected, setSelected] = useState(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 ;
+ }
+
+ 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 (
+
+
+
+ Add repository
+ setAddRepo(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ e.preventDefault();
+ handleAdd();
+ }
+ }}
+ />
+ {suggestedRepos.length > 0 && (
+ r.full_name)}
+ value={addRepo}
+ onValueChange={(v) => setAddRepo(typeof v === "string" ? v : "")}
+ >
+
+
+
+ No matches
+ {suggestedRepos.map((r) => (
+
+ {r.full_name}
+ {r.private && (
+
+ private
+
+ )}
+
+ ))}
+
+
+
+ )}
+
+ Add
+
+
+
+ {(styles.data ?? []).map((s) => (
+
+ setSelected(s.full_name)}
+ >
+ {s.full_name}
+
+ {s.status}
+
+
+
+ ))}
+ {(styles.data ?? []).length === 0 && (
+ No repositories yet.
+ )}
+
+
+
+
+ {!selected || !active ? (
+
+ Select a repository on the left to view or edit its review style prompt.
+
+ ) : (
+ <>
+
+ {active.status}
+ {active.top_reviewers.length > 0 && (
+
+ Reviewers: {active.top_reviewers.join(", ")}
+
+ )}
+ {active.prs_sampled > 0 && (
+
+ {active.prs_sampled} PRs · {active.reviews_sampled} reviews sampled
+
+ )}
+
+ {active.analysis_summary && (
+
{active.analysis_summary}
+ )}
+ {active.error &&
{active.error}
}
+
+ void analyze.mutateAsync(active.full_name)}
+ >
+ {active.status === "running" ? "Analyzing…" : "Run analysis"}
+
+
+ void savePrompt.mutateAsync({
+ full_name: active.full_name,
+ custom_prompt: draftPrompt,
+ })
+ }
+ >
+ Save prompt
+
+
+
+
+ );
+}
diff --git a/ui/src/components/ui/switch.tsx b/ui/src/components/ui/switch.tsx
new file mode 100644
index 00000000..0de31955
--- /dev/null
+++ b/ui/src/components/ui/switch.tsx
@@ -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 (
+
+
+
+ );
+}
+
+export { Switch };
diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts
index 6a7f0cda..7e9862e5 100644
--- a/ui/src/lib/api.ts
+++ b/ui/src/lib/api.ts
@@ -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(`/review-styles/${encodeURIComponent(full_name)}/analyze`, {
method: "POST",
}),
+ getTeamSettings: () => request("/team-settings"),
+ saveTeamSettings: (body: TeamSettings) =>
+ request("/team-settings", { method: "PUT", body: JSON.stringify(body) }),
adminListProfiles: () => request>("/admin/profiles"),
adminSaveProfile: (login: string, body: ProfileUpdate & { email?: string }) =>
request(`/admin/profiles/${encodeURIComponent(login)}`, {
diff --git a/ui/src/lib/profile.ts b/ui/src/lib/profile.ts
new file mode 100644
index 00000000..f9b39568
--- /dev/null
+++ b/ui/src/lib/profile.ts
@@ -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,
+ 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,
+ };
+}
diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts
index a5e5c831..01599e3e 100644
--- a/ui/src/routeTree.gen.ts
+++ b/ui/src/routeTree.gen.ts
@@ -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)
diff --git a/ui/src/routes/admin.tsx b/ui/src/routes/admin.tsx
index d8def5ac..3bda806e 100644
--- a/ui/src/routes/admin.tsx
+++ b/ui/src/routes/admin.tsx
@@ -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 (
-
+
);
}
if (!session.data) return ;
- if (!session.data.is_admin) return ;
+ if (!session.data.is_admin) return ;
const activeProfile: Profile | null =
(selected && profiles.data?.find((p) => p.login === selected)) || null;
return (
-
-
-
-
-
- All users
- {profiles.data?.length ?? 0} profiles
-
-
+
+
+
+
{profiles.isLoading ? (
) : (
@@ -78,17 +76,18 @@ function AdminPage() {
))
)}
-
-
+
+
-
-
- {activeProfile?.login ?? "Select a user"}
- {activeProfile?.email ?? ""}
-
-
+
+
{!activeProfile ? (
-
Pick a user on the left to edit.
+
+ Pick a user on the left to edit.
+
) : options.isLoading ? (
) : (
@@ -103,9 +102,9 @@ function AdminPage() {
error={error}
/>
)}
-
-
-
-
+
+
+
+
);
}
diff --git a/ui/src/routes/cloud-agents.tsx b/ui/src/routes/cloud-agents.tsx
new file mode 100644
index 00000000..2b9dfaa0
--- /dev/null
+++ b/ui/src/routes/cloud-agents.tsx
@@ -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(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 (
+
+
+
+ );
+ }
+ if (!session.data) return ;
+
+ const fallbackModel = firstModel?.id ?? "";
+ const fallbackEffort = firstModel?.default_effort ?? "";
+
+ const persist = (patch: Parameters[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 (
+
+
+
+ v && setModelId(v)}>
+
+
+
+
+ {options.data?.models.map((m) => (
+
+ {m.label}
+
+ ))}
+
+
+ }
+ />
+ v && setEffort(v)}>
+
+
+
+
+ {currentModel?.efforts.map((e) => (
+
+ {e}
+
+ ))}
+
+
+ }
+ />
+
+ {repos.data && repos.data.repositories.length > 0 ? (
+ r.full_name)}
+ value={defaultRepo}
+ onValueChange={(v) =>
+ setDefaultRepo(typeof v === "string" ? v : "")
+ }
+ >
+
+
+
+ No matches
+ {repos.data.repositories.map((r) => (
+
+ {r.full_name}
+
+ ))}
+
+
+
+ ) : (
+ setDefaultRepo(e.target.value)}
+ />
+ )}
+
+ }
+ />
+ setBaseBranch(e.target.value)}
+ />
+ }
+ />
+ setBranchPrefix(e.target.value)}
+ />
+ }
+ />
+
+
+ {save.isPending ? "Saving…" : "Save defaults"}
+
+
+
+
+
+
+
+ persist({ auto_fix_ci: v })}
+ />
+ }
+ />
+ persist({ create_prs: v })}
+ />
+ }
+ />
+ persist({ allow_artifacts: v })}
+ />
+ }
+ />
+
+
+
+
+ persist({ slack_notifications: v })}
+ />
+ }
+ />
+
+
+ {error && {error}
}
+
+ );
+}
diff --git a/ui/src/routes/index.tsx b/ui/src/routes/index.tsx
index 42b3d2ad..f299e5e4 100644
--- a/ui/src/routes/index.tsx
+++ b/ui/src/routes/index.tsx
@@ -3,5 +3,5 @@ import { Navigate, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ component: Index });
function Index() {
- return ;
+ return ;
}
diff --git a/ui/src/routes/integrations.tsx b/ui/src/routes/integrations.tsx
new file mode 100644
index 00000000..b8fe410a
--- /dev/null
+++ b/ui/src/routes/integrations.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {name}
+ {badge && (
+
+ {badge}
+
+ )}
+
+
{description}
+
+
+
+ {connected ? "Connected" : "Not connected"}
+
+
+ );
+}
+
+function IntegrationsPage() {
+ const session = useSession();
+ 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,
+ });
+
+ if (session.isLoading) {
+ return (
+
+
+
+ );
+ }
+ if (!session.data) return ;
+
+ 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 (
+
+
+ 0}
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/src/routes/login.tsx b/ui/src/routes/login.tsx
index 2622f9cd..0cefa50a 100644
--- a/ui/src/routes/login.tsx
+++ b/ui/src/routes/login.tsx
@@ -21,7 +21,7 @@ function Login() {
}
if (session.data) {
- return ;
+ return ;
}
return (
diff --git a/ui/src/routes/my-settings.tsx b/ui/src/routes/my-settings.tsx
new file mode 100644
index 00000000..389427d7
--- /dev/null
+++ b/ui/src/routes/my-settings.tsx
@@ -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("team_default");
+ const [error, setError] = useState(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 (
+
+
+
+ );
+ }
+ if (!session.data) return ;
+
+ 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 (
+
+
+
+
+ {session.data.email ?? "—"}
+
+ }
+ />
+ setFirstName(e.target.value)}
+ />
+ }
+ />
+ setLastName(e.target.value)}
+ />
+ }
+ />
+
+
+ {save.isPending ? "Saving…" : "Save"}
+
+
+
+
+
+
+
+
+
+
+
+ Team default
+ Web
+ Desktop App
+ IDE
+
+
+ }
+ />
+
+
+ {error && {error}
}
+
+ );
+}
diff --git a/ui/src/routes/profile.tsx b/ui/src/routes/profile.tsx
deleted file mode 100644
index c226e3b9..00000000
--- a/ui/src/routes/profile.tsx
+++ /dev/null
@@ -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(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 (
-
-
-
- );
- }
-
- if (!session.data) return ;
-
- return (
-
-
-
-
-
- Your open-swe profile
-
- These defaults are applied when you invoke open-swe from Slack, Linear, or GitHub.
-
-
-
- {options.isLoading || profile.isLoading ? (
-
- ) : (
- save.mutateAsync(body)}
- saving={save.isPending}
- error={error}
- />
- )}
-
-
-
-
- );
-}
diff --git a/ui/src/routes/review-styles.tsx b/ui/src/routes/review-styles.tsx
deleted file mode 100644
index 19597cac..00000000
--- a/ui/src/routes/review-styles.tsx
+++ /dev/null
@@ -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(null);
- const [addRepo, setAddRepo] = useState("");
- const [selected, setSelected] = useState(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 (
-
-
-
- );
- }
-
- if (!session.data) return ;
-
- 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 (
-
-
-
-
-
- Repositories
-
- An agent browses recent merged PR review feedback on GitHub, then writes a
- per-repo style guide for the reviewer.
-
-
-
-
-
Add repository
-
setAddRepo(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- handleAdd();
- }
- }}
- />
-
- Any public repository is supported. Private repos require your GitHub account to
- have read access.
-
- {suggestedRepos.length > 0 && (
-
-
Your installations
-
r.full_name)}
- value={addRepo}
- onValueChange={(v) => setAddRepo(typeof v === "string" ? v : "")}
- >
-
-
-
- No matches
- {suggestedRepos.map((r) => (
-
- {r.full_name}
- {r.private && (
-
- private
-
- )}
-
- ))}
-
-
-
-
- )}
- {addRepo.trim() && !normalizedAddRepo && (
-
Enter a repository as owner/repo
- )}
- {normalizedAddRepo && configured.has(normalizedAddRepo) && (
-
Already added
- )}
-
- Add
-
-
-
- {(styles.data ?? []).map((s) => (
-
- setSelected(s.full_name)}
- >
- {s.full_name}
-
- {s.status}
-
-
-
- ))}
-
-
-
-
-
-
- Review style prompt
-
- Injected into the reviewer agent for PRs on this repo. Edit after analysis or write
- your own.
-
-
-
- {!selected || !active ? (
- Select a repository to view or edit.
- ) : (
- <>
-
- {active.status}
- {active.top_reviewers.length > 0 && (
-
- Reviewers: {active.top_reviewers.join(", ")}
-
- )}
- {active.prs_sampled > 0 && (
-
- {active.prs_sampled} PRs · {active.reviews_sampled} reviews sampled
-
- )}
-
- {active.analysis_summary && (
- {active.analysis_summary}
- )}
- {active.error && (
- {active.error}
- )}
-
- void analyze.mutateAsync(active.full_name)}
- >
- {active.status === "running" ? "Analyzing…" : "Run analysis"}
-
-
- void savePrompt.mutateAsync({
- full_name: active.full_name,
- custom_prompt: draftPrompt,
- })
- }
- >
- Save prompt
-
-
-
-
-
-
- );
-}
diff --git a/ui/src/routes/review.tsx b/ui/src/routes/review.tsx
new file mode 100644
index 00000000..9fe2a2ed
--- /dev/null
+++ b/ui/src/routes/review.tsx
@@ -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(DEFAULT_SETTINGS);
+ const [error, setError] = useState(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 (
+
+
+
+ );
+ }
+ if (!session.data) return ;
+
+ const current: TeamSettings = local;
+ const canEdit = session.data.is_admin;
+
+ const persist = (patch: Partial) => {
+ 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 (
+
+
+
+ persist({ trigger_mode: v as TriggerMode })}
+ disabled={!canEdit}
+ >
+
+
+
+
+ {TRIGGER_MODES.map((m) => (
+
+ {m.label}
+
+ ))}
+
+
+ }
+ />
+ persist({ review_draft_prs: v })}
+ disabled={!canEdit}
+ />
+ }
+ />
+ persist({ pr_summaries: v })}
+ disabled={!canEdit}
+ />
+ }
+ />
+ persist({ autofix_mode: v as AutofixMode })}
+ disabled={!canEdit}
+ >
+
+
+
+
+ {AUTOFIX_MODES.map((m) => (
+
+ {m.label}
+
+ ))}
+
+
+ }
+ />
+
+ persist({ autofix_severity_threshold: v as AutofixMode })
+ }
+ disabled={!canEdit}
+ >
+
+
+
+
+ {AUTOFIX_MODES.map((m) => (
+
+ {m.label}
+
+ ))}
+
+
+ }
+ />
+
+
+
+ {!canEdit && (
+
+ These settings are read-only. Ask a workspace admin to change them.
+
+ )}
+
+
+
+
+
+ {error && {error}
}
+
+ );
+}