mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 06:53:14 +00:00
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:
parent
74f5df5df8
commit
88856a04fa
52 changed files with 4875 additions and 8 deletions
5
agent/dashboard/__init__.py
Normal file
5
agent/dashboard/__init__.py
Normal 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
16
agent/dashboard/admin.py
Normal 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()
|
||||
86
agent/dashboard/agent_overrides.py
Normal file
86
agent/dashboard/agent_overrides.py
Normal 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
182
agent/dashboard/oauth.py
Normal 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
|
||||
37
agent/dashboard/options.py
Normal file
37
agent/dashboard/options.py
Normal 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
128
agent/dashboard/profiles.py
Normal 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
327
agent/dashboard/routes.py
Normal 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")
|
||||
],
|
||||
}
|
||||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
16
ui/.cta.json
Normal 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
14
ui/.gitignore
vendored
Normal 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
3
ui/.prettierignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
11
ui/.prettierrc
Normal file
11
ui/.prettierrc
Normal 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
21
ui/README.md
Normal 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
1683
ui/bun.lock
Normal file
File diff suppressed because it is too large
Load diff
25
ui/components.json
Normal file
25
ui/components.json
Normal 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
17
ui/eslint.config.js
Normal 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
55
ui/package.json
Normal 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
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
25
ui/public/manifest.json
Normal 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
3
ui/public/robots.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
60
ui/src/components/AppHeader.tsx
Normal file
60
ui/src/components/AppHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
ui/src/components/ProfileForm.tsx
Normal file
132
ui/src/components/ProfileForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
ui/src/components/ui/avatar.tsx
Normal file
109
ui/src/components/ui/avatar.tsx
Normal 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,
|
||||
}
|
||||
53
ui/src/components/ui/badge.tsx
Normal file
53
ui/src/components/ui/badge.tsx
Normal 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 }
|
||||
57
ui/src/components/ui/button.tsx
Normal file
57
ui/src/components/ui/button.tsx
Normal 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 }
|
||||
100
ui/src/components/ui/card.tsx
Normal file
100
ui/src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
297
ui/src/components/ui/combobox.tsx
Normal file
297
ui/src/components/ui/combobox.tsx
Normal 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,
|
||||
}
|
||||
155
ui/src/components/ui/input-group.tsx
Normal file
155
ui/src/components/ui/input-group.tsx
Normal 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,
|
||||
}
|
||||
20
ui/src/components/ui/input.tsx
Normal file
20
ui/src/components/ui/input.tsx
Normal 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 }
|
||||
18
ui/src/components/ui/label.tsx
Normal file
18
ui/src/components/ui/label.tsx
Normal 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 }
|
||||
204
ui/src/components/ui/select.tsx
Normal file
204
ui/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
23
ui/src/components/ui/separator.tsx
Normal file
23
ui/src/components/ui/separator.tsx
Normal 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 }
|
||||
13
ui/src/components/ui/skeleton.tsx
Normal file
13
ui/src/components/ui/skeleton.tsx
Normal 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 }
|
||||
18
ui/src/components/ui/textarea.tsx
Normal file
18
ui/src/components/ui/textarea.tsx
Normal 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
109
ui/src/lib/api.ts
Normal 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
13
ui/src/lib/query.ts
Normal 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
19
ui/src/lib/session.ts
Normal 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
7
ui/src/lib/utils.ts
Normal 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
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
122
ui/src/routeTree.gen.ts
Normal 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
20
ui/src/router.tsx
Normal 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
51
ui/src/routes/__root.tsx
Normal 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
111
ui/src/routes/admin.tsx
Normal 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
7
ui/src/routes/index.tsx
Normal 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
45
ui/src/routes/login.tsx
Normal 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
93
ui/src/routes/profile.tsx
Normal 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
130
ui/src/styles.css
Normal 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
31
ui/tsconfig.json
Normal 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
23
ui/vite.config.ts
Normal 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
|
||||
Loading…
Add table
Reference in a new issue