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:
Johannes du Plessis 2026-05-20 14:13:23 -07:00 • committed by GitHub
parent 5ddce73b06
commit 1ea9c0d880
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1599 additions and 523 deletions

View file

@ -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)

View file

@ -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

View 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

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };

View file

@ -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
View 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,
};
}

View file

@ -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)

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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" />;
}

View 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>
);
}

View file

@ -21,7 +21,7 @@ function Login() {
}
if (session.data) {
return <Navigate to="/profile" />;
return <Navigate to="/my-settings" />;
}
return (

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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
View 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>
);
}