open-swe/ui/src/components/AppSidebar.tsx
Johannes du Plessis 1ea9c0d880
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>
2026-05-20 14:13:23 -07:00

137 lines
4.7 KiB
TypeScript

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