feat: open-swe dashboard for per-user profile config (#1302)

* feat: dashboard backend — GitHub OAuth, profile CRUD, admin endpoints

Adds agent/dashboard/ FastAPI router mounted at /dashboard/api covering:
- GitHub App OAuth login → JWT cookie session (cross-domain ready)
- profile CRUD against LangGraph Store with model+effort validation
- admin gate via CONFIGURED_ADMINS
- /repos via /user/installations using the user's encrypted OAuth token

CORS allowlist on webapp.py is opt-in via DASHBOARD_ALLOWED_ORIGINS so the
Vercel-hosted frontend can call the LangSmith deployment with credentials.

* feat: apply dashboard profile model/effort overrides in get_agent

Look up the triggering user's GitHub login from config (direct field or
GITHUB_USER_EMAIL_MAP reverse lookup), read their profile from the Store,
and apply default_model + reasoning_effort to make_model when both are
valid. Effort 'max' is captured on the profile but not yet wired through —
the OpenAI Reasoning Literal doesn't accept it.

* feat: ui/ TanStack Start dashboard for profile config

Scaffolded with the shadcn b7CScJIjA preset (TanStack Start template,
base-ui primitives, Tailwind v4). Three routes:

- /login   — Sign in with GitHub (links to /dashboard/api/auth/login)
- /profile — Edit default model, reasoning effort, default repo
- /admin   — Admin-only: list users and edit other profiles

API client (src/lib/api.ts) uses credentials: include so the osw_session
cookie set by the OAuth callback rides cross-origin. VITE_DASHBOARD_API_BASE_URL
points at the LangSmith deployment.

Effort options re-render when the model changes; 'max' on Opus 4.7 is
captured on the profile but ignored downstream until anthropic reasoning
is wired through make_model.

* feat: searchable Combobox for default repo picker

Replaces the Select with a base-ui Combobox so users can filter by typing,
the popup is wider than the trigger so full owner/repo names are readable,
and the list caps at max-h-80 to stay on screen.

* fix: address review comments + wire default_repo and Anthropic thinking

Security/correctness fixes from PR review:

* Open redirect: validate `redirect_to` in `/auth/login` against
  `DASHBOARD_BASE_URL` + `DASHBOARD_ALLOWED_ORIGINS` before signing it
  into the state JWT. Anything off-allowlist falls back to the dashboard
  base URL. (PR #1302 r3250054386)

* Login CSRF: bind the OAuth `state` to the requesting browser. At
  `/auth/login` we generate a fresh nonce, set it as a short-lived
  HttpOnly SameSite=Lax cookie scoped to `/dashboard/api/auth`, and
  embed `hash_state_nonce(nonce)` in the state JWT. At `/auth/callback`
  we require the cookie nonce to hash-match the state JWT's nonce_hash
  (constant-time compare). (PR #1302 r3250054395)

* RMW race in profile vs token writes: split storage into two
  namespaces — `["profiles"]` for user-editable settings and
  `["oauth_tokens"]` for the encrypted GitHub token. Each upsert now
  only writes its own namespace so an in-flight profile save can no
  longer clobber a fresh token from a concurrent re-login (and vice
  versa). (PR #1302 r3250054393)

* /repos pagination: follow `Link: rel="next"` for both
  `/user/installations` and per-installation `/repositories` with
  per_page=100, capped at 1000 items. (PR #1302 r3250054401)

Feature wires:

* default_repo: applied as a fallback in `get_slack_repo_config` (after
  explicit-repo / thread metadata, before the env defaults) and in the
  Linear webhook (after comment-body extraction, before team mapping).
  Both paths resolve the triggering user's GitHub login via
  GITHUB_USER_EMAIL_MAP and read the profile's default_repo.

* Anthropic "thinking" effort: `make_model` now accepts a `thinking`
  kwarg; `get_agent` maps profile effort {low,medium,high,xhigh,max}
  to budget_tokens {1k,4k,12k,32k,60k} when the chosen model is
  anthropic. OpenAI path still ignores "max" since the Literal doesn't
  accept it.
This commit is contained in:
Johannes du Plessis 2026-05-15 11:23:53 -07:00 • committed by GitHub
parent 74f5df5df8
commit 88856a04fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 4875 additions and 8 deletions

View file

@ -0,0 +1,5 @@
"""Dashboard backend: OAuth, profiles, and admin endpoints for the open-swe UI."""
from .routes import router
__all__ = ["router"]

16
agent/dashboard/admin.py Normal file
View file

@ -0,0 +1,16 @@
"""Admin email gate driven by the CONFIGURED_ADMINS env var."""
from __future__ import annotations
import os
def _admin_emails() -> frozenset[str]:
raw = os.environ.get("CONFIGURED_ADMINS", "")
return frozenset(e.strip().lower() for e in raw.split(",") if e.strip())
def is_admin(email: str | None) -> bool:
if not email:
return False
return email.strip().lower() in _admin_emails()

View file

@ -0,0 +1,86 @@
"""Profile lookup + override helpers consumed by ``agent.server.get_agent``."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from langgraph_sdk import get_client
from ..utils.github_user_email_map import GITHUB_USER_EMAIL_MAP
from .options import SUPPORTED_MODEL_IDS, model_supports_effort
from .profiles import PROFILES_NAMESPACE
logger = logging.getLogger(__name__)
def resolve_login_from_email(email: str | None) -> str | None:
"""Reverse-lookup ``GITHUB_USER_EMAIL_MAP`` for the GitHub login of an email."""
if not isinstance(email, str) or not email.strip():
return None
normalized = email.strip().lower()
for gh_login, mapped in GITHUB_USER_EMAIL_MAP.items():
if mapped.lower() == normalized:
return gh_login
return None
def resolve_github_login(config: dict[str, Any]) -> str | None:
"""Best-effort resolution of the triggering user's GitHub login from config."""
configurable = (config or {}).get("configurable") or {}
login = configurable.get("github_login")
if isinstance(login, str) and login.strip():
return login.strip()
slack_thread = configurable.get("slack_thread") or {}
email = configurable.get("user_email") or slack_thread.get("triggering_user_email")
return resolve_login_from_email(email if isinstance(email, str) else None)
async def get_profile_default_repo(login: str | None) -> dict[str, str] | None:
"""Return ``{"owner", "name"}`` for the user's profile default_repo, if set."""
if not login:
return None
profile = await load_profile(login)
if not profile:
return None
default_repo = profile.get("default_repo")
if not isinstance(default_repo, str):
return None
parts = default_repo.strip().split("/", 1)
if len(parts) != 2:
return None
owner, name = parts[0].strip(), parts[1].strip()
if not owner or not name:
return None
return {"owner": owner, "name": name}
async def load_profile(login: str) -> dict[str, Any] | None:
try:
item = await get_client().store.get_item(PROFILES_NAMESPACE, login)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return None
logger.warning("profile lookup failed for %s: %s", login, e)
return None
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
def normalize_profile_overrides(profile: dict[str, Any]) -> tuple[str | None, str | None]:
"""Return ``(model_id, reasoning_effort)`` if both are valid, else ``(None, None)``."""
model_id = profile.get("default_model")
effort = profile.get("reasoning_effort")
if (
isinstance(model_id, str)
and model_id in SUPPORTED_MODEL_IDS
and isinstance(effort, str)
and model_supports_effort(model_id, effort)
):
return model_id, effort
return None, None

182
agent/dashboard/oauth.py Normal file
View file

@ -0,0 +1,182 @@
"""GitHub App OAuth code-exchange and signed-JWT session cookie."""
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import secrets
import time
from typing import Any
from urllib.parse import urlparse
import httpx
import jwt
from fastapi import HTTPException, Request
logger = logging.getLogger(__name__)
COOKIE_NAME = "osw_session"
STATE_COOKIE_NAME = "osw_oauth_state"
SESSION_TTL_SECONDS = 7 * 24 * 60 * 60
STATE_TTL_SECONDS = 600
JWT_ALG = "HS256"
GITHUB_APP_CLIENT_ID = os.environ.get("GITHUB_APP_CLIENT_ID", "")
GITHUB_APP_CLIENT_SECRET = os.environ.get("GITHUB_APP_CLIENT_SECRET", "")
def _secret() -> str:
s = os.environ.get("DASHBOARD_JWT_SECRET", "")
if not s:
raise HTTPException(500, "DASHBOARD_JWT_SECRET not configured")
return s
def _allowed_redirect_origins() -> set[str]:
"""Origins permitted for the post-login redirect.
Built from DASHBOARD_BASE_URL plus any DASHBOARD_ALLOWED_ORIGINS entries
so the dashboard itself and its preview deploys can all be redirect
targets — but nothing else.
"""
origins: set[str] = set()
base = os.environ.get("DASHBOARD_BASE_URL", "").strip()
if base:
origins.add(_origin_of(base))
for entry in os.environ.get("DASHBOARD_ALLOWED_ORIGINS", "").split(","):
entry = entry.strip()
if entry:
origins.add(_origin_of(entry))
origins.discard("")
return origins
def _origin_of(url: str) -> str:
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
return ""
return f"{parsed.scheme}://{parsed.netloc}"
def sanitize_redirect_to(redirect_to: str | None) -> str:
"""Return a safe post-login redirect URL.
Falls back to DASHBOARD_BASE_URL when the supplied URL's origin isn't
explicitly allowed. This blocks the open-redirect / phishing primitive
where an attacker drops their own URL into `?redirect_to=`.
"""
fallback = os.environ.get("DASHBOARD_BASE_URL", "").strip()
if not redirect_to:
return fallback
candidate_origin = _origin_of(redirect_to)
if not candidate_origin:
return fallback
if candidate_origin in _allowed_redirect_origins():
return redirect_to
logger.warning("Rejected redirect_to=%r — origin not in allowlist", redirect_to)
return fallback
def issue_session(*, login: str, email: str | None, avatar_url: str | None) -> str:
now = int(time.time())
payload = {
"sub": login,
"email": email,
"avatar_url": avatar_url,
"iat": now,
"exp": now + SESSION_TTL_SECONDS,
}
return jwt.encode(payload, _secret(), algorithm=JWT_ALG)
def decode_session(token: str) -> dict[str, Any]:
try:
return jwt.decode(token, _secret(), algorithms=[JWT_ALG])
except jwt.PyJWTError as e:
raise HTTPException(401, f"invalid session: {e}") from e
def new_state_nonce() -> str:
"""A fresh random nonce used to bind state JWT ↔ browser cookie."""
return secrets.token_urlsafe(32)
def hash_state_nonce(nonce: str) -> str:
"""HMAC the nonce so the value stored on the wire isn't reversible.
We compare ``hash_state_nonce(cookie_nonce) == state.nonce_hash`` at
callback time. Using HMAC over a constant-time digest also gives us
timing-attack resistance via :func:`hmac.compare_digest` at the call
site.
"""
return hmac.new(_secret().encode(), nonce.encode(), hashlib.sha256).hexdigest()
def issue_state(*, redirect_to: str, nonce_hash: str) -> str:
now = int(time.time())
payload = {
"nonce_hash": nonce_hash,
"redirect_to": redirect_to,
"iat": now,
"exp": now + STATE_TTL_SECONDS,
}
return jwt.encode(payload, _secret(), algorithm=JWT_ALG)
def decode_state(state: str) -> dict[str, Any]:
try:
return jwt.decode(state, _secret(), algorithms=[JWT_ALG])
except jwt.PyJWTError as e:
raise HTTPException(400, f"invalid state: {e}") from e
def require_session(request: Request) -> dict[str, Any]:
token = request.cookies.get(COOKIE_NAME)
if not token:
raise HTTPException(401, "not authenticated")
return decode_session(token)
async def exchange_code(code: str) -> str:
"""Exchange an OAuth authorization code for a user-to-server access token."""
if not GITHUB_APP_CLIENT_ID or not GITHUB_APP_CLIENT_SECRET:
raise HTTPException(500, "GitHub App OAuth not configured")
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://github.com/login/oauth/access_token",
headers={"Accept": "application/json"},
data={
"client_id": GITHUB_APP_CLIENT_ID,
"client_secret": GITHUB_APP_CLIENT_SECRET,
"code": code,
},
)
resp.raise_for_status()
data = resp.json()
token = data.get("access_token")
if not token:
raise HTTPException(400, f"oauth exchange failed: {data}")
return token
async def fetch_github_user(access_token: str) -> tuple[dict[str, Any], str | None]:
"""Return ``(user, primary_email)`` for the authenticated user."""
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async with httpx.AsyncClient() as client:
u = await client.get("https://api.github.com/user", headers=headers)
u.raise_for_status()
user = u.json()
email = user.get("email")
if not email:
e = await client.get("https://api.github.com/user/emails", headers=headers)
if e.status_code == 200:
primary = next((x for x in e.json() if x.get("primary")), None)
if primary:
email = primary.get("email")
return user, email

View file

@ -0,0 +1,37 @@
"""Supported models and reasoning efforts surfaced in the profile editor."""
from __future__ import annotations
from typing import TypedDict
class ModelOption(TypedDict):
id: str
label: str
efforts: list[str]
default_effort: str
SUPPORTED_MODELS: list[ModelOption] = [
{
"id": "anthropic:claude-opus-4-7",
"label": "Opus 4.7",
"efforts": ["low", "medium", "high", "xhigh", "max"],
"default_effort": "high",
},
{
"id": "openai:gpt-5.5",
"label": "GPT-5.5",
"efforts": ["low", "medium", "high", "xhigh"],
"default_effort": "xhigh",
},
]
SUPPORTED_MODEL_IDS: frozenset[str] = frozenset(m["id"] for m in SUPPORTED_MODELS)
def model_supports_effort(model_id: str, effort: str) -> bool:
for m in SUPPORTED_MODELS:
if m["id"] == model_id:
return effort in m["efforts"]
return False

128
agent/dashboard/profiles.py Normal file
View file

@ -0,0 +1,128 @@
"""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
@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,
"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

327
agent/dashboard/routes.py Normal file
View file

@ -0,0 +1,327 @@
"""FastAPI router for the dashboard backend."""
from __future__ import annotations
import hmac
import logging
import os
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, Response
from .admin import is_admin
from .oauth import (
COOKIE_NAME,
SESSION_TTL_SECONDS,
STATE_COOKIE_NAME,
STATE_TTL_SECONDS,
decode_state,
exchange_code,
fetch_github_user,
hash_state_nonce,
issue_session,
issue_state,
new_state_nonce,
require_session,
sanitize_redirect_to,
)
from .options import SUPPORTED_MODELS
from .profiles import (
ProfileUpdate,
get_access_token,
get_profile,
list_profiles,
upsert_access_token,
upsert_profile,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/dashboard/api", tags=["dashboard"])
def _require_admin(session: dict[str, Any]) -> dict[str, Any]:
if not is_admin(session.get("email")):
raise HTTPException(403, "admin only")
return session
_SESSION_DEP = Depends(require_session)
def _admin_session(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
return _require_admin(session)
_ADMIN_DEP = Depends(_admin_session)
def _api_base_url() -> str:
v = os.environ.get("DASHBOARD_API_BASE_URL", "").rstrip("/")
if not v:
raise HTTPException(500, "DASHBOARD_API_BASE_URL not configured")
return v
def _frontend_base_url() -> str:
v = os.environ.get("DASHBOARD_BASE_URL", "").rstrip("/")
if not v:
raise HTTPException(500, "DASHBOARD_BASE_URL not configured")
return v
def _set_session_cookie(response: Response, jwt_token: str) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=jwt_token,
max_age=SESSION_TTL_SECONDS,
httponly=True,
secure=True,
samesite="none",
path="/",
)
def _set_state_cookie(response: Response, nonce: str) -> None:
# SameSite=Lax so GitHub's top-level redirect back to /auth/callback
# still presents this cookie; the cookie is single-purpose and lives
# only for the duration of one OAuth round-trip.
response.set_cookie(
key=STATE_COOKIE_NAME,
value=nonce,
max_age=STATE_TTL_SECONDS,
httponly=True,
secure=True,
samesite="lax",
path="/dashboard/api/auth",
)
def _clear_state_cookie(response: Response) -> None:
response.delete_cookie(
STATE_COOKIE_NAME, path="/dashboard/api/auth", samesite="lax", secure=True
)
@router.get("/auth/login")
async def auth_login(request: Request, redirect_to: str | None = None) -> RedirectResponse:
client_id = os.environ.get("GITHUB_APP_CLIENT_ID", "")
if not client_id:
raise HTTPException(500, "GITHUB_APP_CLIENT_ID not configured")
safe_redirect = sanitize_redirect_to(redirect_to) or _frontend_base_url()
nonce = new_state_nonce()
state = issue_state(redirect_to=safe_redirect, nonce_hash=hash_state_nonce(nonce))
redirect_uri = f"{_api_base_url()}/dashboard/api/auth/callback"
url = (
"https://github.com/login/oauth/authorize"
f"?client_id={client_id}"
f"&redirect_uri={redirect_uri}"
f"&state={state}"
)
response = RedirectResponse(url, status_code=302)
_set_state_cookie(response, nonce)
return response
@router.get("/auth/callback")
async def auth_callback(request: Request, code: str, state: str) -> RedirectResponse:
state_payload = decode_state(state)
state_nonce_hash = state_payload.get("nonce_hash")
cookie_nonce = request.cookies.get(STATE_COOKIE_NAME)
if (
not isinstance(state_nonce_hash, str)
or not cookie_nonce
or not hmac.compare_digest(hash_state_nonce(cookie_nonce), state_nonce_hash)
):
# Either the cookie went missing (different browser, expired,
# cookies blocked) or the state was issued for a different session.
raise HTTPException(400, "oauth state mismatch — please retry login")
redirect_to = sanitize_redirect_to(state_payload.get("redirect_to")) or _frontend_base_url()
access_token = await exchange_code(code)
user, email = await fetch_github_user(access_token)
login = user.get("login")
if not login:
raise HTTPException(400, "could not resolve GitHub login")
await upsert_access_token(login, email or "", access_token)
session_jwt = issue_session(login=login, email=email, avatar_url=user.get("avatar_url"))
response = RedirectResponse(redirect_to, status_code=302)
_set_session_cookie(response, session_jwt)
_clear_state_cookie(response)
return response
@router.post("/auth/logout")
async def auth_logout() -> Response:
response = Response(status_code=204)
response.delete_cookie(COOKIE_NAME, path="/", samesite="none", secure=True)
return response
@router.get("/me")
async def me(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]:
return {
"login": session["sub"],
"email": session.get("email"),
"avatar_url": session.get("avatar_url"),
"is_admin": is_admin(session.get("email")),
}
@router.get("/options")
async def options() -> dict[str, Any]:
return {"models": SUPPORTED_MODELS}
@router.get("/profile")
async def get_my_profile(
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
profile = await get_profile(session["sub"])
return profile or {}
@router.put("/profile")
async def put_my_profile(
update: ProfileUpdate,
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
update.validate_pairing()
return await upsert_profile(session["sub"], session.get("email") or "", update)
@router.get("/admin/profiles")
async def admin_list_profiles(
_admin: dict[str, Any] = _ADMIN_DEP,
) -> list[dict[str, Any]]:
return await list_profiles()
class AdminProfileUpdate(ProfileUpdate):
email: str | None = None
@router.put("/admin/profiles/{login}")
async def admin_put_profile(
login: str,
update: AdminProfileUpdate,
_admin: dict[str, Any] = _ADMIN_DEP,
) -> dict[str, Any]:
update.validate_pairing()
existing = await get_profile(login) or {}
email = update.email or existing.get("email") or ""
base = ProfileUpdate(
default_model=update.default_model,
reasoning_effort=update.reasoning_effort,
default_repo=update.default_repo,
)
return await upsert_profile(login, email, base)
def _next_link_url(link_header: str | None) -> str | None:
if not link_header:
return None
# GitHub Link header is comma-separated: '<url>; rel="next", <url>; rel="last"'
for part in link_header.split(","):
segments = [s.strip() for s in part.split(";")]
if len(segments) >= 2 and 'rel="next"' in segments[1] and segments[0].startswith("<"):
return segments[0][1:-1]
return None
async def _paginate(
client: httpx.AsyncClient,
url: str,
*,
headers: dict[str, str],
items_key: str | None,
cap: int = 1000,
) -> list[dict[str, Any]]:
"""Follow ``Link: rel="next"`` until exhausted (or cap reached).
``items_key`` is the JSON key holding the list when the endpoint returns
a wrapper object (e.g. ``/user/installations`` returns
``{"total_count": N, "installations": [...]}``). When ``None`` the
response body itself is treated as the list.
"""
out: list[dict[str, Any]] = []
next_url: str | None = url
first = True
while next_url and len(out) < cap:
params = {"per_page": "100"} if first else None
r = await client.get(next_url, headers=headers, params=params)
if r.status_code == 401:
raise HTTPException(401, "github token expired, re-login required")
r.raise_for_status()
body = r.json()
page = body.get(items_key, []) if items_key else body
if isinstance(page, list):
out.extend(page)
next_url = _next_link_url(r.headers.get("Link"))
first = False
return out
@router.get("/repos")
async def list_repos(
session: dict[str, Any] = _SESSION_DEP,
) -> dict[str, Any]:
"""List repos where open-swe is installed and the user has access.
Paginates both ``/user/installations`` and per-installation
``/user/installations/{id}/repositories`` so users with multiple
installations or >30 accessible repos get the complete set.
"""
token = await get_access_token(session["sub"])
if not token:
raise HTTPException(401, "github token unavailable, re-login required")
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
async with httpx.AsyncClient() as client:
installations = await _paginate(
client,
"https://api.github.com/user/installations",
headers=headers,
items_key="installations",
)
repositories: list[dict[str, Any]] = []
for inst in installations:
inst_id = inst.get("id")
if inst_id is None:
continue
try:
repos = await _paginate(
client,
f"https://api.github.com/user/installations/{inst_id}/repositories",
headers=headers,
items_key="repositories",
)
except HTTPException:
raise
except httpx.HTTPStatusError:
continue
repositories.extend(repos)
return {
"installations": [
{
"id": i.get("id"),
"account": (i.get("account") or {}).get("login"),
"account_type": (i.get("account") or {}).get("type"),
}
for i in installations
],
"repositories": [
{"full_name": r.get("full_name"), "private": r.get("private", False)}
for r in repositories
if r.get("full_name")
],
}

View file

@ -28,6 +28,11 @@ from deepagents.backends.protocol import SandboxBackendProtocol
from langchain.agents.middleware import ModelCallLimitMiddleware
from langsmith.sandbox import SandboxClientError
from .dashboard.agent_overrides import (
load_profile,
normalize_profile_overrides,
resolve_github_login,
)
from .integrations.langsmith import _configure_github_proxy
from .middleware import (
ModelFallbackMiddleware,
@ -58,7 +63,13 @@ from .tools import (
from .utils.auth import resolve_github_token
from .utils.authorship import resolve_triggering_user_identity
from .utils.github_app import get_github_app_installation_token
from .utils.model import ModelKwargs, OpenAIReasoning, fallback_model_id_for, make_model
from .utils.model import (
AnthropicThinking,
ModelKwargs,
OpenAIReasoning,
fallback_model_id_for,
make_model,
)
from .utils.sandbox import create_sandbox
from .utils.sandbox_paths import aresolve_sandbox_work_dir
@ -327,6 +338,53 @@ DEFAULT_RECURSION_LIMIT = 9_999
MODEL_CALL_RECURSION_LIMIT = 5_000 # ~half the recursion limit to account for tool calls
def _openai_reasoning_for(profile_effort: str | None) -> OpenAIReasoning | None:
"""Return an OpenAI reasoning kwarg from a (validated) profile effort.
Anthropic-only efforts like ``"max"`` are dropped — OpenAI's effort
Literal doesn't accept them. Falls back to the default effort when the
profile didn't override.
"""
effort = profile_effort or DEFAULT_LLM_REASONING.get("effort")
if effort == "none":
return {"effort": "none"}
if effort == "low":
return {"effort": "low"}
if effort == "medium":
return {"effort": "medium"}
if effort == "high":
return {"effort": "high"}
if effort == "xhigh":
return {"effort": "xhigh"}
return None
# Mapping mirrors Claude Code's effort levels for Opus 4.7. Numbers are tuned
# so each level meaningfully separates from the next while leaving headroom
# under DEFAULT_LLM_MAX_TOKENS (64k) for the model's actual output.
_ANTHROPIC_THINKING_BUDGETS: dict[str, int] = {
"low": 1_024,
"medium": 4_000,
"high": 12_000,
"xhigh": 32_000,
"max": 60_000,
}
def _anthropic_thinking_for(profile_effort: str | None) -> AnthropicThinking | None:
"""Map a profile effort string to an Anthropic thinking kwarg.
Returns ``None`` when the effort doesn't have a known budget so we leave
the model's default thinking behaviour alone.
"""
if not profile_effort:
return None
budget = _ANTHROPIC_THINKING_BUDGETS.get(profile_effort)
if budget is None:
return None
return {"type": "enabled", "budget_tokens": budget}
def _get_cached_sandbox_backend(thread_id: str) -> SandboxBackendProtocol:
sandbox_backend = SANDBOX_BACKENDS.get(thread_id)
if sandbox_backend is None:
@ -367,9 +425,31 @@ async def get_agent(config: RunnableConfig) -> Pregel:
return _get_cached_sandbox_backend(_thread_id)
model_id = os.environ.get("LLM_MODEL_ID", DEFAULT_LLM_MODEL_ID)
profile_effort: str | None = None
profile_login = resolve_github_login(config)
if profile_login:
profile = await load_profile(profile_login)
if profile:
overridden_model, overridden_effort = normalize_profile_overrides(profile)
if overridden_model:
logger.info(
"Applying dashboard profile override for %s: model=%s effort=%s",
profile_login,
overridden_model,
overridden_effort,
)
model_id = overridden_model
profile_effort = overridden_effort
model_kwargs: ModelKwargs = {"max_tokens": DEFAULT_LLM_MAX_TOKENS}
if model_id == DEFAULT_LLM_MODEL_ID:
model_kwargs["reasoning"] = DEFAULT_LLM_REASONING
if model_id.startswith("openai:"):
reasoning = _openai_reasoning_for(profile_effort)
if reasoning is not None:
model_kwargs["reasoning"] = reasoning
elif model_id.startswith("anthropic:"):
thinking = _anthropic_thinking_for(profile_effort)
if thinking is not None:
model_kwargs["thinking"] = thinking
fallback_model_id = os.environ.get("LLM_FALLBACK_MODEL_ID") or fallback_model_id_for(model_id)
fallback_middleware: list[Any] = []

View file

@ -10,15 +10,22 @@ DEFAULT_MAX_RETRIES = 6
OpenAIReasoningEffort = Literal["none", "low", "medium", "high", "xhigh"]
AnthropicThinkingType = Literal["enabled", "disabled"]
class OpenAIReasoning(TypedDict, total=False):
effort: OpenAIReasoningEffort
class AnthropicThinking(TypedDict, total=False):
type: AnthropicThinkingType
budget_tokens: int
class ModelKwargs(TypedDict, total=False):
max_tokens: int | None
reasoning: OpenAIReasoning | None
thinking: AnthropicThinking | None
temperature: float | None
max_retries: int | None

View file

@ -12,10 +12,16 @@ from typing import Any
import httpx
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from langchain_core.messages.content import create_text_block
from langgraph_sdk import get_client
from langgraph_sdk.client import LangGraphClient
from .dashboard import router as dashboard_router
from .dashboard.agent_overrides import (
get_profile_default_repo,
resolve_login_from_email,
)
from .reviewer_findings import (
REVIEWER_THREAD_KIND,
ReviewerPRMeta,
@ -90,6 +96,20 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
app = FastAPI(lifespan=lifespan)
DASHBOARD_ALLOWED_ORIGINS: list[str] = [
o.strip() for o in os.environ.get("DASHBOARD_ALLOWED_ORIGINS", "").split(",") if o.strip()
]
if DASHBOARD_ALLOWED_ORIGINS:
app.add_middleware(
CORSMiddleware,
allow_origins=DASHBOARD_ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
app.include_router(dashboard_router)
LINEAR_WEBHOOK_SECRET = os.environ.get("LINEAR_WEBHOOK_SECRET", "")
GITHUB_WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "")
SLACK_SIGNING_SECRET = os.environ.get("SLACK_SIGNING_SECRET", "")
@ -465,8 +485,21 @@ async def _upsert_slack_thread_repo_metadata(
)
async def get_slack_repo_config(message: str, channel_id: str, thread_ts: str) -> dict[str, str]:
"""Resolve repository configuration for Slack-triggered runs."""
async def get_slack_repo_config(
message: str,
channel_id: str,
thread_ts: str,
slack_user_id: str | None = None,
) -> dict[str, str]:
"""Resolve repository configuration for Slack-triggered runs.
Priority:
1. Explicit ``owner/repo`` mention in the message body.
2. Repo carried over from the existing Slack thread's metadata.
3. The triggering user's dashboard ``default_repo`` (if they have a
profile and their Slack email maps to a known GitHub login).
4. ``SLACK_REPO_*`` env defaults.
"""
default_owner = SLACK_REPO_OWNER.strip() or DEFAULT_REPO_OWNER
default_name = SLACK_REPO_NAME.strip() or DEFAULT_REPO_NAME
thread_id = generate_thread_id_from_slack_thread(channel_id, thread_ts)
@ -487,6 +520,26 @@ async def get_slack_repo_config(message: str, channel_id: str, thread_ts: str) -
thread_id,
)
if not repo_config and slack_user_id:
try:
slack_user = await get_slack_user_info(slack_user_id)
slack_email = (
(slack_user or {}).get("profile", {}).get("email")
if isinstance(slack_user, dict)
else None
)
profile_repo = await get_profile_default_repo(resolve_login_from_email(slack_email))
if profile_repo:
logger.info(
"Applying dashboard default_repo for Slack user %s: %s/%s",
slack_user_id,
profile_repo["owner"],
profile_repo["name"],
)
repo_config = profile_repo
except Exception: # noqa: BLE001
logger.exception("Failed to apply dashboard default_repo for Slack user")
if not repo_config:
repo_config = {"owner": default_owner, "name": default_name}
@ -1127,6 +1180,24 @@ async def linear_webhook( # noqa: PLR0911, PLR0912, PLR0915
repo_config["name"],
)
else:
comment_user_email = (data.get("user") or {}).get("email")
try:
profile_repo = await get_profile_default_repo(
resolve_login_from_email(comment_user_email)
)
except Exception: # noqa: BLE001
logger.exception("Failed to apply dashboard default_repo for Linear user")
profile_repo = None
if profile_repo:
logger.info(
"Applying dashboard default_repo for Linear user %s: %s/%s",
comment_user_email,
profile_repo["owner"],
profile_repo["name"],
)
repo_config = profile_repo
if not repo_config:
team = full_issue.get("team", {})
team_name = team.get("name", "") if team else ""
project = full_issue.get("project")
@ -1299,7 +1370,7 @@ async def slack_webhook(request: Request, background_tasks: BackgroundTasks) ->
"text": text,
"bot_user_id": bot_user_id,
}
repo_config = await get_slack_repo_config(text, channel_id, thread_ts)
repo_config = await get_slack_repo_config(text, channel_id, thread_ts, slack_user_id=user_id)
if not _is_repo_allowed(repo_config):
logger.warning(

View file

@ -485,12 +485,13 @@ def test_slack_webhook_non_pr_review_request_starts_agent(monkeypatch) -> None:
captured: dict[str, object] = {}
async def fake_get_slack_repo_config(
text: str, channel_id: str, thread_ts: str
text: str, channel_id: str, thread_ts: str, slack_user_id: str | None = None
) -> dict[str, str]:
captured["repo_config_request"] = {
"text": text,
"channel_id": channel_id,
"thread_ts": thread_ts,
"slack_user_id": slack_user_id,
}
return {"owner": "langchain-ai", "name": "open-swe"}
@ -534,12 +535,13 @@ def test_slack_webhook_threaded_followup_uses_parent_thread_ts(monkeypatch) -> N
captured: dict[str, object] = {}
async def fake_get_slack_repo_config(
text: str, channel_id: str, thread_ts: str
text: str, channel_id: str, thread_ts: str, slack_user_id: str | None = None
) -> dict[str, str]:
captured["repo_config_request"] = {
"text": text,
"channel_id": channel_id,
"thread_ts": thread_ts,
"slack_user_id": slack_user_id,
}
return {"owner": "langchain-ai", "name": "open-swe"}
@ -578,6 +580,7 @@ def test_slack_webhook_threaded_followup_uses_parent_thread_ts(monkeypatch) -> N
"text": "<@UBOT> continue on the branch",
"channel_id": "C123",
"thread_ts": "1700000000.000100",
"slack_user_id": "U123",
}
event_data = captured["event_data"]
assert isinstance(event_data, dict)

16
ui/.cta.json Normal file
View file

@ -0,0 +1,16 @@
{
"projectName": "start-app",
"mode": "file-router",
"typescript": true,
"tailwind": true,
"packageManager": "pnpm",
"addOnOptions": {},
"git": true,
"version": 1,
"framework": "react-cra",
"chosenAddOns": [
"eslint",
"nitro",
"start"
]
}

14
ui/.gitignore vendored Normal file
View file

@ -0,0 +1,14 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
count.txt
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
__unconfig*
todos.json

3
ui/.prettierignore Normal file
View file

@ -0,0 +1,3 @@
package-lock.json
pnpm-lock.yaml
yarn.lock

11
ui/.prettierrc Normal file
View file

@ -0,0 +1,11 @@
{
"endOfLine": "lf",
"semi": false,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 80,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindStylesheet": "src/styles.css",
"tailwindFunctions": ["cn", "cva"]
}

21
ui/README.md Normal file
View file

@ -0,0 +1,21 @@
# TanStack Start + shadcn/ui
This is a template for a new TanStack Start project with React, TypeScript, and shadcn/ui.
## Adding components
To add components to your app, run the following command:
```bash
npx shadcn@latest add button
```
This will place the ui components in the `components` directory.
## Using components
To use the components in your app, import them as follows:
```tsx
import { Button } from "@/components/ui/button";
```

1683
ui/bun.lock Normal file

File diff suppressed because it is too large Load diff

25
ui/components.json Normal file
View file

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-mira",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles.css",
"baseColor": "mist",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "phosphor",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

17
ui/eslint.config.js Normal file
View file

@ -0,0 +1,17 @@
// @ts-check
import { tanstackConfig } from "@tanstack/eslint-config"
export default [
{
ignores: [
".output/**",
".nitro/**",
".tanstack/**",
"dist/**",
"src/routeTree.gen.ts",
"src/components/ui/**",
],
},
...tanstackConfig,
]

55
ui/package.json Normal file
View file

@ -0,0 +1,55 @@
{
"name": "open-swe-dashboard",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint",
"format": "prettier --write \"**/*.{ts,tsx,js,jsx}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@fontsource-variable/inter": "^5.2.8",
"@phosphor-icons/react": "^2.1.10",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-devtools": "^0.10.0",
"@tanstack/react-query": "^5.100.10",
"@tanstack/react-query-devtools": "^5.100.10",
"@tanstack/react-router": "^1.167.4",
"@tanstack/react-router-devtools": "^1.166.9",
"@tanstack/react-router-ssr-query": "^1.166.9",
"@tanstack/react-start": "^1.166.15",
"@tanstack/router-plugin": "^1.166.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"nitro": "latest",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0",
"vite-tsconfig-paths": "^5.1.4"
},
"devDependencies": {
"@tanstack/devtools-vite": "^0.6.0",
"@tanstack/eslint-config": "^0.4.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^22.19.15",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"jsdom": "^27.4.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^3.2.4",
"web-vitals": "^5.1.0"
}
}

BIN
ui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

25
ui/public/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
ui/public/robots.txt Normal file
View file

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View file

@ -0,0 +1,60 @@
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>
{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,132 @@
import { useEffect, useState } from "react";
import type { ModelOption, Profile, ProfileUpdate, Repository } from "@/lib/api";
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
interface ProfileFormProps {
models: Array<ModelOption>;
repos: Array<Repository>;
initial: Profile;
onSubmit: (body: ProfileUpdate) => Promise<unknown>;
saving: boolean;
error: string | null;
}
export function ProfileForm({ models, repos, initial, onSubmit, saving, error }: ProfileFormProps) {
const first: ModelOption | undefined = models[0];
const [modelId, setModelId] = useState<string>(initial.default_model ?? first?.id ?? "");
const currentModel: ModelOption | undefined = models.find((m) => m.id === modelId) ?? first;
const [effort, setEffort] = useState<string>(
initial.reasoning_effort ?? currentModel?.default_effort ?? "",
);
const [defaultRepo, setDefaultRepo] = useState<string>(initial.default_repo ?? "");
useEffect(() => {
if (currentModel !== undefined && !currentModel.efforts.includes(effort)) {
setEffort(currentModel.default_effort);
}
}, [modelId, currentModel, effort]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
void onSubmit({
default_model: modelId,
reasoning_effort: effort,
default_repo: defaultRepo || null,
});
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="model">Default model</Label>
<Select value={modelId} onValueChange={(v) => v && setModelId(v)}>
<SelectTrigger id="model">
<SelectValue placeholder="Pick a model" />
</SelectTrigger>
<SelectContent>
{models.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="effort">Reasoning effort</Label>
<Select value={effort} onValueChange={(v) => v && setEffort(v)}>
<SelectTrigger id="effort">
<SelectValue placeholder="Pick an effort level" />
</SelectTrigger>
<SelectContent>
{currentModel?.efforts.map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="repo">Default repo</Label>
{repos.length > 0 ? (
<Combobox
items={repos.map((r) => r.full_name)}
value={defaultRepo}
onValueChange={(v) => setDefaultRepo(typeof v === "string" ? v : "")}
>
<ComboboxInput id="repo" placeholder="Search repos…" showClear />
<ComboboxContent className="min-w-[28rem]">
<ComboboxList className="max-h-80">
<ComboboxEmpty>No repos match</ComboboxEmpty>
{repos.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 pr-5 text-[10px]">private</span>
)}
</ComboboxItem>
))}
</ComboboxList>
</ComboboxContent>
</Combobox>
) : (
<Input
id="repo"
placeholder="owner/repo"
value={defaultRepo}
onChange={(e) => setDefaultRepo(e.target.value)}
/>
)}
</div>
{error && <p className="text-destructive text-sm">{error}</p>}
<div className="flex justify-end">
<Button type="submit" disabled={saving || !modelId || !effort}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
</form>
);
}

View file

@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs/relaxed text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View file

@ -0,0 +1,53 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva } from "class-variance-authority"
import type {VariantProps} from "class-variance-authority";
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-[0.625rem] font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-2.5!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border bg-input/20 text-foreground dark:bg-input/30 [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,57 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva } from "class-variance-authority"
import type {VariantProps} from "class-variance-authority";
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-xs/relaxed font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-7 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
xs: "h-5 gap-1 rounded-sm px-2 text-[0.625rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-2.5",
sm: "h-6 gap-1 px-2 text-xs/relaxed has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
lg: "h-8 gap-1 px-2.5 text-xs/relaxed has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-4",
icon: "size-7 [&_svg:not([class*='size-'])]:size-3.5",
"icon-xs": "size-5 rounded-sm [&_svg:not([class*='size-'])]:size-2.5",
"icon-sm": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-lg": "size-8 [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,100 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-lg bg-card py-4 text-xs/relaxed text-card-foreground ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 *:[img:first-child]:rounded-t-lg *:[img:last-child]:rounded-b-lg",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-lg px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("font-heading text-sm font-medium", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-xs/relaxed text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-lg px-4 group-data-[size=sm]/card:px-3 [.border-t]:pt-4 group-data-[size=sm]/card:[.border-t]:pt-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,297 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@/components/ui/input-group"
import { CaretDownIcon, XIcon, CheckIcon } from "@phosphor-icons/react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-3.5", className)}
{...props}
>
{children}
<CaretDownIcon className="pointer-events-none size-3.5 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-7 *:data-[slot=input-group]:border-none *:data-[slot=input-group]:bg-input/20 *:data-[slot=input-group]:shadow-none dark:bg-popover data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-xs/relaxed text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border/50", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-7 flex-wrap items-center gap-1 rounded-md border border-input bg-input/20 bg-clip-padding px-2 py-0.5 text-xs/relaxed transition-colors focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30 has-aria-invalid:border-destructive has-aria-invalid:ring-2 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(4.75))] w-fit items-center justify-center gap-1 rounded-[calc(var(--radius-sm)-2px)] bg-muted-foreground/10 px-1.5 text-xs/relaxed font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}

View file

@ -0,0 +1,155 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group relative flex h-7 w-full min-w-0 items-center rounded-md border border-input bg-input/20 transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-data-[align=block-end]:rounded-md has-data-[align=block-start]:rounded-md has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-2 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[textarea]:rounded-md has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-1 py-2 text-xs/relaxed font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-[calc(var(--radius-sm)-2px)] **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1 **:data-[slot=kbd]:text-[0.625rem] [&>svg:not([class*='size-'])]:size-3.5",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.275rem] has-[>kbd]:ml-[-0.275rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.275rem] has-[>kbd]:mr-[-0.275rem]",
"block-start":
"order-first w-full justify-start px-2 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 rounded-md text-xs/relaxed shadow-none",
{
variants: {
size: {
xs: "h-5 gap-1 rounded-[calc(var(--radius-sm)-2px)] px-1 [&>svg:not([class*='size-'])]:size-3",
sm: "gap-1",
"icon-xs": "size-6 p-0 has-[>svg]:p-0",
"icon-sm": "size-7 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"flex items-center gap-2 text-xs/relaxed text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 aria-invalid:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}

View file

@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-7 w-full min-w-0 rounded-md border border-input bg-input/20 px-2 py-0.5 text-sm transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-xs/relaxed file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 md:text-xs/relaxed dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-xs/relaxed leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,204 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { CaretDownIcon, CaretUpIcon, CheckIcon } from "@phosphor-icons/react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-input/20 px-2 py-1.5 text-xs/relaxed whitespace-nowrap transition-colors outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-7 data-[size=sm]:h-6 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<CaretDownIcon className="pointer-events-none size-3.5 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex min-h-7 w-full cursor-default items-center gap-2 rounded-md px-2 py-1 text-xs/relaxed outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"pointer-events-none -mx-1 my-1 h-px bg-border/50",
className
)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
{...props}
>
<CaretUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-3.5",
className
)}
{...props}
>
<CaretDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View file

@ -0,0 +1,23 @@
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full resize-none rounded-md border border-input bg-input/20 px-2 py-2 text-sm transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 md:text-xs/relaxed dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

109
ui/src/lib/api.ts Normal file
View file

@ -0,0 +1,109 @@
/**
* Typed client for the open-swe dashboard backend.
*
* All requests are sent with credentials so the httpOnly `osw_session`
* cookie set by the OAuth callback rides along on cross-origin calls.
*/
const API_BASE = (import.meta.env.VITE_DASHBOARD_API_BASE_URL ?? "").replace(/\/$/, "");
if (!API_BASE && typeof window !== "undefined") {
console.warn("VITE_DASHBOARD_API_BASE_URL is not set");
}
export class ApiError extends Error {
constructor(public readonly status: number, message: string) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}/dashboard/api${path}`, {
...init,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (!res.ok) {
let message = res.statusText;
try {
const body = await res.json();
if (body?.detail) message = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail);
} catch {
/* ignore */
}
throw new ApiError(res.status, message);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export interface SessionUser {
login: string;
email: string | null;
avatar_url: string | null;
is_admin: boolean;
}
export interface ModelOption {
id: string;
label: string;
efforts: Array<string>;
default_effort: string;
}
export interface Profile {
login?: string;
email?: string;
default_model?: string;
reasoning_effort?: string;
default_repo?: string | null;
updated_at?: string;
}
export interface ProfileUpdate {
default_model: string;
reasoning_effort: string;
default_repo?: string | null;
}
export interface Repository {
full_name: string;
private: boolean;
}
export interface Installation {
id: number;
account: string | null;
account_type: string | null;
}
export interface ReposPayload {
installations: Array<Installation>;
repositories: Array<Repository>;
}
export const api = {
me: () => request<SessionUser>("/me"),
options: () => request<{ models: Array<ModelOption> }>("/options"),
profile: () => request<Profile>("/profile"),
saveProfile: (body: ProfileUpdate) =>
request<Profile>("/profile", { method: "PUT", body: JSON.stringify(body) }),
repos: () => request<ReposPayload>("/repos"),
adminListProfiles: () => request<Array<Profile>>("/admin/profiles"),
adminSaveProfile: (login: string, body: ProfileUpdate & { email?: string }) =>
request<Profile>(`/admin/profiles/${encodeURIComponent(login)}`, {
method: "PUT",
body: JSON.stringify(body),
}),
logout: () => request<void>("/auth/logout", { method: "POST" }),
};
export function loginUrl(redirectTo?: string): string {
const target = redirectTo ?? (typeof window !== "undefined" ? window.location.origin : "");
const qs = target ? `?redirect_to=${encodeURIComponent(target)}` : "";
return `${API_BASE}/dashboard/api/auth/login${qs}`;
}

13
ui/src/lib/query.ts Normal file
View file

@ -0,0 +1,13 @@
import { QueryClient } from "@tanstack/react-query";
export function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
});
}

19
ui/src/lib/session.ts Normal file
View file

@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import { ApiError, api } from "./api";
import type {SessionUser} from "./api";
export function useSession() {
return useQuery<SessionUser | null>({
queryKey: ["session"],
queryFn: async () => {
try {
return await api.me();
} catch (e) {
if (e instanceof ApiError && e.status === 401) return null;
throw e;
}
},
staleTime: 60_000,
retry: false,
});
}

7
ui/src/lib/utils.ts Normal file
View file

@ -0,0 +1,7 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import type {ClassValue} from "clsx";
export function cn(...inputs: Array<ClassValue>) {
return twMerge(clsx(inputs))
}

12
ui/src/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

122
ui/src/routeTree.gen.ts Normal file
View file

@ -0,0 +1,122 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// 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 ProfileRouteImport } from './routes/profile'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AdminRouteImport } from './routes/admin'
import { Route as IndexRouteImport } from './routes/index'
const ProfileRoute = ProfileRouteImport.update({
id: '/profile',
path: '/profile',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AdminRoute = AdminRouteImport.update({
id: '/admin',
path: '/admin',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/login': typeof LoginRoute
'/profile': typeof ProfileRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/login': typeof LoginRoute
'/profile': typeof ProfileRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/admin': typeof AdminRoute
'/login': typeof LoginRoute
'/profile': typeof ProfileRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/admin' | '/login' | '/profile'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/admin' | '/login' | '/profile'
id: '__root__' | '/' | '/admin' | '/login' | '/profile'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AdminRoute: typeof AdminRoute
LoginRoute: typeof LoginRoute
ProfileRoute: typeof ProfileRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/profile': {
id: '/profile'
path: '/profile'
fullPath: '/profile'
preLoaderRoute: typeof ProfileRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/admin': {
id: '/admin'
path: '/admin'
fullPath: '/admin'
preLoaderRoute: typeof AdminRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AdminRoute: AdminRoute,
LoginRoute: LoginRoute,
ProfileRoute: ProfileRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

20
ui/src/router.tsx Normal file
View file

@ -0,0 +1,20 @@
import { createRouter as createTanStackRouter } from "@tanstack/react-router"
import { routeTree } from "./routeTree.gen"
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: "intent",
defaultPreloadStaleTime: 0,
})
return router
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>
}
}

51
ui/src/routes/__root.tsx Normal file
View file

@ -0,0 +1,51 @@
import { HeadContent, Outlet, Scripts, createRootRoute } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { TanStackDevtools } from "@tanstack/react-devtools";
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react";
import appCss from "../styles.css?url";
import { makeQueryClient } from "@/lib/query";
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "open-swe" },
],
links: [{ rel: "stylesheet", href: appCss }],
}),
notFoundComponent: () => (
<main className="container mx-auto p-4 pt-16">
<h1 className="text-2xl font-medium">404</h1>
<p className="text-muted-foreground">The requested page could not be found.</p>
</main>
),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => makeQueryClient());
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<QueryClientProvider client={queryClient}>
{children ?? <Outlet />}
<TanStackDevtools
config={{ position: "bottom-right" }}
plugins={[
{ name: "Tanstack Router", render: <TanStackRouterDevtoolsPanel /> },
]}
/>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
<Scripts />
</body>
</html>
);
}

111
ui/src/routes/admin.tsx Normal file
View file

@ -0,0 +1,111 @@
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 { 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 { useSession } from "@/lib/session";
export const Route = createFileRoute("/admin")({ component: AdminPage });
function AdminPage() {
const session = useSession();
const qc = useQueryClient();
const [selected, setSelected] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const options = useQuery({
queryKey: ["options"],
queryFn: api.options,
enabled: !!session.data?.is_admin,
});
const profiles = useQuery({
queryKey: ["adminProfiles"],
queryFn: api.adminListProfiles,
enabled: !!session.data?.is_admin,
});
const save = useMutation({
mutationFn: ({ login, body }: { login: string; body: ProfileUpdate }) =>
api.adminSaveProfile(login, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["adminProfiles"] });
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" />;
if (!session.data.is_admin) return <Navigate to="/profile" />;
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">
{profiles.isLoading ? (
<Skeleton className="h-32" />
) : (
profiles.data?.map((p) => (
<Button
key={p.login}
variant={selected === p.login ? "secondary" : "ghost"}
className="justify-start"
onClick={() => setSelected(p.login ?? null)}
>
<span className="truncate">{p.login}</span>
</Button>
))
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{activeProfile?.login ?? "Select a user"}</CardTitle>
<CardDescription>{activeProfile?.email ?? ""}</CardDescription>
</CardHeader>
<CardContent>
{!activeProfile ? (
<p className="text-muted-foreground text-sm">Pick a user on the left to edit.</p>
) : options.isLoading ? (
<Skeleton className="h-48" />
) : (
<ProfileForm
models={options.data?.models ?? []}
repos={[]}
initial={activeProfile}
onSubmit={(body) =>
save.mutateAsync({ login: activeProfile.login!, body })
}
saving={save.isPending}
error={error}
/>
)}
</CardContent>
</Card>
</main>
</div>
);
}

7
ui/src/routes/index.tsx Normal file
View file

@ -0,0 +1,7 @@
import { Navigate, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ component: Index });
function Index() {
return <Navigate to="/profile" />;
}

45
ui/src/routes/login.tsx Normal file
View file

@ -0,0 +1,45 @@
import { Navigate, createFileRoute } from "@tanstack/react-router";
import { buttonVariants } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { loginUrl } from "@/lib/api";
import { useSession } from "@/lib/session";
import { cn } from "@/lib/utils";
export const Route = createFileRoute("/login")({ component: Login });
function Login() {
const session = useSession();
if (session.isLoading) {
return (
<main className="flex min-h-svh items-center justify-center p-6">
<Skeleton className="h-40 w-80" />
</main>
);
}
if (session.data) {
return <Navigate to="/profile" />;
}
return (
<main className="flex min-h-svh items-center justify-center p-6">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Sign in to open-swe</CardTitle>
<CardDescription>
Use your GitHub account. We'll configure your default model, reasoning effort, and
default repo for Slack/Linear/GitHub triggered runs.
</CardDescription>
</CardHeader>
<CardContent>
<a href={loginUrl()} className={cn(buttonVariants({ size: "lg" }), "w-full")}>
Continue with GitHub
</a>
</CardContent>
</Card>
</main>
);
}

93
ui/src/routes/profile.tsx Normal file
View file

@ -0,0 +1,93 @@
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>
);
}

130
ui/src/styles.css Normal file
View file

@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Inter Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.148 0.004 228.8);
--card: oklch(1 0 0);
--card-foreground: oklch(0.148 0.004 228.8);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.148 0.004 228.8);
--primary: oklch(0.52 0.105 223.128);
--primary-foreground: oklch(0.984 0.019 200.873);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.963 0.002 197.1);
--muted-foreground: oklch(0.56 0.021 213.5);
--accent: oklch(0.963 0.002 197.1);
--accent-foreground: oklch(0.218 0.008 223.9);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.005 214.3);
--input: oklch(0.925 0.005 214.3);
--ring: oklch(0.723 0.014 214.4);
--chart-1: oklch(0.872 0.007 219.6);
--chart-2: oklch(0.56 0.021 213.5);
--chart-3: oklch(0.45 0.017 213.2);
--chart-4: oklch(0.378 0.015 216);
--chart-5: oklch(0.275 0.011 216.9);
--radius: 0.625rem;
--sidebar: oklch(0.987 0.002 197.1);
--sidebar-foreground: oklch(0.148 0.004 228.8);
--sidebar-primary: oklch(0.609 0.126 221.723);
--sidebar-primary-foreground: oklch(0.984 0.019 200.873);
--sidebar-accent: oklch(0.963 0.002 197.1);
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
--sidebar-border: oklch(0.925 0.005 214.3);
--sidebar-ring: oklch(0.723 0.014 214.4);
}
.dark {
--background: oklch(0.148 0.004 228.8);
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.218 0.008 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.218 0.008 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.45 0.085 224.283);
--primary-foreground: oklch(0.984 0.019 200.873);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.275 0.011 216.9);
--muted-foreground: oklch(0.723 0.014 214.4);
--accent: oklch(0.275 0.011 216.9);
--accent-foreground: oklch(0.987 0.002 197.1);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.56 0.021 213.5);
--chart-1: oklch(0.872 0.007 219.6);
--chart-2: oklch(0.56 0.021 213.5);
--chart-3: oklch(0.45 0.017 213.2);
--chart-4: oklch(0.378 0.015 216);
--chart-5: oklch(0.275 0.011 216.9);
--sidebar: oklch(0.218 0.008 223.9);
--sidebar-foreground: oklch(0.987 0.002 197.1);
--sidebar-primary: oklch(0.715 0.143 215.221);
--sidebar-primary-foreground: oklch(0.302 0.056 229.695);
--sidebar-accent: oklch(0.275 0.011 216.9);
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.56 0.021 213.5);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

31
ui/tsconfig.json Normal file
View file

@ -0,0 +1,31 @@
{
"include": ["**/*.ts", "**/*.tsx", "eslint.config.js", "prettier.config.js", "vite.config.js"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"allowJs": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

23
ui/vite.config.ts Normal file
View file

@ -0,0 +1,23 @@
import { defineConfig } from "vite"
import { devtools } from "@tanstack/devtools-vite"
import { tanstackStart } from "@tanstack/react-start/plugin/vite"
import viteReact from "@vitejs/plugin-react"
import viteTsConfigPaths from "vite-tsconfig-paths"
import tailwindcss from "@tailwindcss/vite"
import { nitro } from "nitro/vite"
const config = defineConfig({
plugins: [
devtools(),
nitro(),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ["./tsconfig.json"],
}),
tailwindcss(),
tanstackStart(),
viteReact(),
],
})
export default config