open-swe/agent/dashboard/profiles.py
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

146 lines
4.9 KiB
Python

"""User profile schema and LangGraph Store CRUD.
Storage is split into two namespaces to avoid the read-modify-write race
between profile-edit writes and OAuth-callback token refreshes:
* ``["profiles"]`` — user-editable settings (model, effort, default_repo).
* ``["oauth_tokens"]`` — encrypted GitHub OAuth access token + email.
Each upsert only touches its own namespace, so the two flows can't clobber
each other's fields even when they interleave.
"""
from __future__ import annotations
import logging
from datetime import UTC, datetime
from typing import Any
import httpx
from langgraph_sdk import get_client
from pydantic import BaseModel, field_validator
from ..encryption import decrypt_token, encrypt_token
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
logger = logging.getLogger(__name__)
PROFILES_NAMESPACE: list[str] = ["profiles"]
OAUTH_TOKENS_NAMESPACE: list[str] = ["oauth_tokens"]
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
def _model_supported(cls, v: str) -> str:
if v not in SUPPORTED_MODEL_IDS:
raise ValueError(f"unsupported model: {v}")
return v
def validate_pairing(self) -> None:
if not model_supports_effort(self.default_model, self.reasoning_effort):
raise ValueError(
f"effort {self.reasoning_effort!r} not supported by {self.default_model!r}"
)
def _client():
return get_client()
async def _get_value(namespace: list[str], key: str) -> dict[str, Any] | None:
try:
item = await _client().store.get_item(namespace, key)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return None
raise
if item is None:
return None
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
return value if isinstance(value, dict) else None
async def get_profile(login: str) -> dict[str, Any] | None:
return await _get_value(PROFILES_NAMESPACE, login)
async def upsert_profile(login: str, email: str, update: ProfileUpdate) -> dict[str, Any]:
"""Write the user's editable settings.
Only touches ``["profiles"]`` — the OAuth token in ``["oauth_tokens"]``
is untouched, so a concurrent re-login can't be clobbered by this write
and vice versa.
"""
existing = await get_profile(login) or {}
value: dict[str, Any] = {
**existing,
"login": login,
"email": email or existing.get("email", ""),
"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)
return value
async def upsert_access_token(login: str, email: str, access_token: str) -> None:
"""Persist (or refresh) the user's encrypted GitHub OAuth token.
Only touches ``["oauth_tokens"]`` — the user-editable profile is left
intact even if a save is in flight in another request.
"""
if not access_token:
return
value: dict[str, Any] = {
"login": login,
"email": email,
"encrypted_gh_token": encrypt_token(access_token),
"updated_at": datetime.now(UTC).isoformat(),
}
await _client().store.put_item(OAUTH_TOKENS_NAMESPACE, login, value)
async def get_access_token(login: str) -> str | None:
record = await _get_value(OAUTH_TOKENS_NAMESPACE, login)
if not record:
return None
encrypted = record.get("encrypted_gh_token")
if not encrypted:
return None
return decrypt_token(encrypted) or None
async def list_profiles() -> list[dict[str, Any]]:
result = await _client().store.search_items(PROFILES_NAMESPACE, limit=1000)
items = result.get("items") if isinstance(result, dict) else getattr(result, "items", [])
out: list[dict[str, Any]] = []
for item in items or []:
value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None)
if isinstance(value, dict):
out.append(value)
return out