mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-01 06:53:36 +00:00
feat: Add global settings modal and simplify agent controls
Adds a settings system for global configuration with YOLO mode toggle and
model selection. Simplifies the agent control UI by removing redundant
status indicator and pause functionality.
## Settings System
- New SettingsModal with YOLO mode toggle and model selection
- Settings persisted in SQLite (registry.db) - shared across all projects
- Models fetched from API endpoint (/api/settings/models)
- Single source of truth for models in registry.py - easy to add new models
- Optimistic UI updates with rollback on error
## Agent Control Simplification
- Removed StatusIndicator ("STOPPED"/"RUNNING" label) - redundant
- Removed Pause/Resume buttons - just Start/Stop toggle now
- Start button shows flame icon with fiery gradient when YOLO mode enabled
## Code Review Fixes
- Added focus trap to SettingsModal for accessibility
- Fixed YOLO button color contrast (WCAG AA compliance)
- Added model validation to AgentStartRequest schema
- Added model to AgentStatus response
- Added aria-labels to all icon-only buttons
- Added role="radiogroup" to model selection
- Added loading indicator during settings save
- Added SQLite timeout (30s) and retry logic with exponential backoff
- Added thread-safe database engine initialization
- Added orphaned lock file cleanup on server startup
## Files Changed
- registry.py: Model config, Settings CRUD, SQLite improvements
- server/routers/settings.py: New settings API
- server/schemas.py: Settings schemas with validation
- server/services/process_manager.py: Model param, orphan cleanup
- ui/src/components/SettingsModal.tsx: New modal component
- ui/src/components/AgentControl.tsx: Simplified to Start/Stop only
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,11 +32,7 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
from agent import run_autonomous_agent
|
from agent import run_autonomous_agent
|
||||||
from registry import get_project_path
|
from registry import DEFAULT_MODEL, get_project_path
|
||||||
|
|
||||||
# Configuration
|
|
||||||
# DEFAULT_MODEL = "claude-sonnet-4-5-20250929"
|
|
||||||
DEFAULT_MODEL = "claude-opus-4-5-20251101"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
|
|||||||
168
registry.py
168
registry.py
@@ -9,6 +9,8 @@ Uses SQLite database stored at ~/.autocoder/registry.db.
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -22,6 +24,29 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Model Configuration (Single Source of Truth)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Available models with display names
|
||||||
|
# To add a new model: add an entry here with {"id": "model-id", "name": "Display Name"}
|
||||||
|
AVAILABLE_MODELS = [
|
||||||
|
{"id": "claude-opus-4-5-20251101", "name": "Claude Opus 4.5"},
|
||||||
|
{"id": "claude-sonnet-4-5-20250929", "name": "Claude Sonnet 4.5"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# List of valid model IDs (derived from AVAILABLE_MODELS)
|
||||||
|
VALID_MODELS = [m["id"] for m in AVAILABLE_MODELS]
|
||||||
|
|
||||||
|
# Default model and settings
|
||||||
|
DEFAULT_MODEL = "claude-opus-4-5-20251101"
|
||||||
|
DEFAULT_YOLO_MODE = False
|
||||||
|
|
||||||
|
# SQLite connection settings
|
||||||
|
SQLITE_TIMEOUT = 30 # seconds to wait for database lock
|
||||||
|
SQLITE_MAX_RETRIES = 3 # number of retry attempts on busy database
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Exceptions
|
# Exceptions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -62,13 +87,23 @@ class Project(Base):
|
|||||||
created_at = Column(DateTime, nullable=False)
|
created_at = Column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(Base):
|
||||||
|
"""SQLAlchemy model for global settings (key-value store)."""
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
key = Column(String(50), primary_key=True)
|
||||||
|
value = Column(String(500), nullable=False)
|
||||||
|
updated_at = Column(DateTime, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Database Connection
|
# Database Connection
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Module-level singleton for database engine
|
# Module-level singleton for database engine with thread-safe initialization
|
||||||
_engine = None
|
_engine = None
|
||||||
_SessionLocal = None
|
_SessionLocal = None
|
||||||
|
_engine_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_config_dir() -> Path:
|
def get_config_dir() -> Path:
|
||||||
@@ -90,20 +125,29 @@ def get_registry_path() -> Path:
|
|||||||
|
|
||||||
def _get_engine():
|
def _get_engine():
|
||||||
"""
|
"""
|
||||||
Get or create the database engine (singleton pattern).
|
Get or create the database engine (thread-safe singleton pattern).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (engine, SessionLocal)
|
Tuple of (engine, SessionLocal)
|
||||||
"""
|
"""
|
||||||
global _engine, _SessionLocal
|
global _engine, _SessionLocal
|
||||||
|
|
||||||
|
# Double-checked locking for thread safety
|
||||||
if _engine is None:
|
if _engine is None:
|
||||||
db_path = get_registry_path()
|
with _engine_lock:
|
||||||
db_url = f"sqlite:///{db_path.as_posix()}"
|
if _engine is None:
|
||||||
_engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
db_path = get_registry_path()
|
||||||
Base.metadata.create_all(bind=_engine)
|
db_url = f"sqlite:///{db_path.as_posix()}"
|
||||||
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
|
_engine = create_engine(
|
||||||
logger.debug("Initialized registry database at: %s", db_path)
|
db_url,
|
||||||
|
connect_args={
|
||||||
|
"check_same_thread": False,
|
||||||
|
"timeout": SQLITE_TIMEOUT,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=_engine)
|
||||||
|
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
|
||||||
|
logger.debug("Initialized registry database at: %s", db_path)
|
||||||
|
|
||||||
return _engine, _SessionLocal
|
return _engine, _SessionLocal
|
||||||
|
|
||||||
@@ -113,6 +157,8 @@ def _get_session():
|
|||||||
"""
|
"""
|
||||||
Context manager for database sessions with automatic commit/rollback.
|
Context manager for database sessions with automatic commit/rollback.
|
||||||
|
|
||||||
|
Includes retry logic for SQLite busy database errors.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
SQLAlchemy session
|
SQLAlchemy session
|
||||||
"""
|
"""
|
||||||
@@ -128,6 +174,40 @@ def _get_session():
|
|||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _with_retry(func, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Execute a database operation with retry logic for busy database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
func: Function to execute
|
||||||
|
*args, **kwargs: Arguments to pass to the function
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result of the function
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Last exception if all retries fail
|
||||||
|
"""
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(SQLITE_MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
error_str = str(e).lower()
|
||||||
|
if "database is locked" in error_str or "sqlite_busy" in error_str:
|
||||||
|
if attempt < SQLITE_MAX_RETRIES - 1:
|
||||||
|
wait_time = (2 ** attempt) * 0.1 # Exponential backoff: 0.1s, 0.2s, 0.4s
|
||||||
|
logger.warning(
|
||||||
|
"Database busy, retrying in %.1fs (attempt %d/%d)",
|
||||||
|
wait_time, attempt + 1, SQLITE_MAX_RETRIES
|
||||||
|
)
|
||||||
|
time.sleep(wait_time)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
raise last_error
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Project CRUD Functions
|
# Project CRUD Functions
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -364,3 +444,75 @@ def list_valid_projects() -> list[dict[str, Any]]:
|
|||||||
return valid
|
return valid
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Settings CRUD Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def get_setting(key: str, default: str | None = None) -> str | None:
|
||||||
|
"""
|
||||||
|
Get a setting value by key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The setting key.
|
||||||
|
default: Default value if setting doesn't exist or on DB error.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The setting value, or default if not found or on error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_, SessionLocal = _get_engine()
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
setting = session.query(Settings).filter(Settings.key == key).first()
|
||||||
|
return setting.value if setting else default
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to read setting '%s': %s", key, e)
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def set_setting(key: str, value: str) -> None:
|
||||||
|
"""
|
||||||
|
Set a setting value (creates or updates).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: The setting key.
|
||||||
|
value: The setting value.
|
||||||
|
"""
|
||||||
|
with _get_session() as session:
|
||||||
|
setting = session.query(Settings).filter(Settings.key == key).first()
|
||||||
|
if setting:
|
||||||
|
setting.value = value
|
||||||
|
setting.updated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
setting = Settings(
|
||||||
|
key=key,
|
||||||
|
value=value,
|
||||||
|
updated_at=datetime.now()
|
||||||
|
)
|
||||||
|
session.add(setting)
|
||||||
|
|
||||||
|
logger.debug("Set setting '%s' = '%s'", key, value)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_settings() -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Get all settings as a dictionary.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping setting keys to values.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
_, SessionLocal = _get_engine()
|
||||||
|
session = SessionLocal()
|
||||||
|
try:
|
||||||
|
settings = session.query(Settings).all()
|
||||||
|
return {s.key: s.value for s in settings}
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to read settings: %s", e)
|
||||||
|
return {}
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ from .routers import (
|
|||||||
features_router,
|
features_router,
|
||||||
filesystem_router,
|
filesystem_router,
|
||||||
projects_router,
|
projects_router,
|
||||||
|
settings_router,
|
||||||
spec_creation_router,
|
spec_creation_router,
|
||||||
)
|
)
|
||||||
from .schemas import SetupStatus
|
from .schemas import SetupStatus
|
||||||
from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
|
from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
|
||||||
from .services.process_manager import cleanup_all_managers
|
from .services.process_manager import cleanup_all_managers, cleanup_orphaned_locks
|
||||||
from .websocket import project_websocket
|
from .websocket import project_websocket
|
||||||
|
|
||||||
# Paths
|
# Paths
|
||||||
@@ -36,7 +37,8 @@ UI_DIST_DIR = ROOT_DIR / "ui" / "dist"
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Lifespan context manager for startup and shutdown."""
|
"""Lifespan context manager for startup and shutdown."""
|
||||||
# Startup
|
# Startup - clean up orphaned lock files from previous runs
|
||||||
|
cleanup_orphaned_locks()
|
||||||
yield
|
yield
|
||||||
# Shutdown - cleanup all running agents and assistant sessions
|
# Shutdown - cleanup all running agents and assistant sessions
|
||||||
await cleanup_all_managers()
|
await cleanup_all_managers()
|
||||||
@@ -92,6 +94,7 @@ app.include_router(agent_router)
|
|||||||
app.include_router(spec_creation_router)
|
app.include_router(spec_creation_router)
|
||||||
app.include_router(filesystem_router)
|
app.include_router(filesystem_router)
|
||||||
app.include_router(assistant_chat_router)
|
app.include_router(assistant_chat_router)
|
||||||
|
app.include_router(settings_router)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .assistant_chat import router as assistant_chat_router
|
|||||||
from .features import router as features_router
|
from .features import router as features_router
|
||||||
from .filesystem import router as filesystem_router
|
from .filesystem import router as filesystem_router
|
||||||
from .projects import router as projects_router
|
from .projects import router as projects_router
|
||||||
|
from .settings import router as settings_router
|
||||||
from .spec_creation import router as spec_creation_router
|
from .spec_creation import router as spec_creation_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -19,4 +20,5 @@ __all__ = [
|
|||||||
"spec_creation_router",
|
"spec_creation_router",
|
||||||
"filesystem_router",
|
"filesystem_router",
|
||||||
"assistant_chat_router",
|
"assistant_chat_router",
|
||||||
|
"settings_router",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ def _get_project_path(project_name: str) -> Path:
|
|||||||
return get_project_path(project_name)
|
return get_project_path(project_name)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_settings_defaults() -> tuple[bool, str]:
|
||||||
|
"""Get YOLO mode and model defaults from global settings."""
|
||||||
|
import sys
|
||||||
|
root = Path(__file__).parent.parent.parent
|
||||||
|
if str(root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
|
||||||
|
from registry import DEFAULT_MODEL, get_all_settings
|
||||||
|
|
||||||
|
settings = get_all_settings()
|
||||||
|
yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
|
||||||
|
model = settings.get("model", DEFAULT_MODEL)
|
||||||
|
return yolo_mode, model
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
|
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
|
||||||
|
|
||||||
# Root directory for process manager
|
# Root directory for process manager
|
||||||
@@ -69,6 +84,7 @@ async def get_agent_status(project_name: str):
|
|||||||
pid=manager.pid,
|
pid=manager.pid,
|
||||||
started_at=manager.started_at,
|
started_at=manager.started_at,
|
||||||
yolo_mode=manager.yolo_mode,
|
yolo_mode=manager.yolo_mode,
|
||||||
|
model=manager.model,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -80,7 +96,12 @@ async def start_agent(
|
|||||||
"""Start the agent for a project."""
|
"""Start the agent for a project."""
|
||||||
manager = get_project_manager(project_name)
|
manager = get_project_manager(project_name)
|
||||||
|
|
||||||
success, message = await manager.start(yolo_mode=request.yolo_mode)
|
# Get defaults from global settings if not provided in request
|
||||||
|
default_yolo, default_model = _get_settings_defaults()
|
||||||
|
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
|
||||||
|
model = request.model if request.model else default_model
|
||||||
|
|
||||||
|
success, message = await manager.start(yolo_mode=yolo_mode, model=model)
|
||||||
|
|
||||||
return AgentActionResponse(
|
return AgentActionResponse(
|
||||||
success=success,
|
success=success,
|
||||||
|
|||||||
75
server/routers/settings.py
Normal file
75
server/routers/settings.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""
|
||||||
|
Settings Router
|
||||||
|
===============
|
||||||
|
|
||||||
|
API endpoints for global settings management.
|
||||||
|
Settings are stored in the registry database and shared across all projects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from ..schemas import ModelInfo, ModelsResponse, SettingsResponse, SettingsUpdate
|
||||||
|
|
||||||
|
# Add root to path for registry import
|
||||||
|
ROOT_DIR = Path(__file__).parent.parent.parent
|
||||||
|
if str(ROOT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT_DIR))
|
||||||
|
|
||||||
|
from registry import (
|
||||||
|
AVAILABLE_MODELS,
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
DEFAULT_YOLO_MODE,
|
||||||
|
get_all_settings,
|
||||||
|
set_setting,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_yolo_mode(value: str | None) -> bool:
|
||||||
|
"""Parse YOLO mode string to boolean."""
|
||||||
|
return (value or "false").lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/models", response_model=ModelsResponse)
|
||||||
|
async def get_available_models():
|
||||||
|
"""Get list of available models.
|
||||||
|
|
||||||
|
Frontend should call this to get the current list of models
|
||||||
|
instead of hardcoding them.
|
||||||
|
"""
|
||||||
|
return ModelsResponse(
|
||||||
|
models=[ModelInfo(id=m["id"], name=m["name"]) for m in AVAILABLE_MODELS],
|
||||||
|
default=DEFAULT_MODEL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SettingsResponse)
|
||||||
|
async def get_settings():
|
||||||
|
"""Get current global settings."""
|
||||||
|
all_settings = get_all_settings()
|
||||||
|
|
||||||
|
return SettingsResponse(
|
||||||
|
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
||||||
|
model=all_settings.get("model", DEFAULT_MODEL),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("", response_model=SettingsResponse)
|
||||||
|
async def update_settings(update: SettingsUpdate):
|
||||||
|
"""Update global settings."""
|
||||||
|
if update.yolo_mode is not None:
|
||||||
|
set_setting("yolo_mode", "true" if update.yolo_mode else "false")
|
||||||
|
|
||||||
|
if update.model is not None:
|
||||||
|
set_setting("model", update.model)
|
||||||
|
|
||||||
|
# Return updated settings
|
||||||
|
all_settings = get_all_settings()
|
||||||
|
return SettingsResponse(
|
||||||
|
yolo_mode=_parse_yolo_mode(all_settings.get("yolo_mode")),
|
||||||
|
model=all_settings.get("model", DEFAULT_MODEL),
|
||||||
|
)
|
||||||
@@ -6,11 +6,20 @@ Request/Response models for the API endpoints.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
# Import model constants from registry (single source of truth)
|
||||||
|
_root = Path(__file__).parent.parent
|
||||||
|
if str(_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_root))
|
||||||
|
|
||||||
|
from registry import AVAILABLE_MODELS, DEFAULT_MODEL, VALID_MODELS
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Project Schemas
|
# Project Schemas
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -102,7 +111,16 @@ class FeatureListResponse(BaseModel):
|
|||||||
|
|
||||||
class AgentStartRequest(BaseModel):
|
class AgentStartRequest(BaseModel):
|
||||||
"""Request schema for starting the agent."""
|
"""Request schema for starting the agent."""
|
||||||
yolo_mode: bool = False
|
yolo_mode: bool | None = None # None means use global settings
|
||||||
|
model: str | None = None # None means use global settings
|
||||||
|
|
||||||
|
@field_validator('model')
|
||||||
|
@classmethod
|
||||||
|
def validate_model(cls, v: str | None) -> str | None:
|
||||||
|
"""Validate model is in the allowed list."""
|
||||||
|
if v is not None and v not in VALID_MODELS:
|
||||||
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class AgentStatus(BaseModel):
|
class AgentStatus(BaseModel):
|
||||||
@@ -111,6 +129,7 @@ class AgentStatus(BaseModel):
|
|||||||
pid: int | None = None
|
pid: int | None = None
|
||||||
started_at: datetime | None = None
|
started_at: datetime | None = None
|
||||||
yolo_mode: bool = False
|
yolo_mode: bool = False
|
||||||
|
model: str | None = None # Model being used by running agent
|
||||||
|
|
||||||
|
|
||||||
class AgentActionResponse(BaseModel):
|
class AgentActionResponse(BaseModel):
|
||||||
@@ -239,3 +258,41 @@ class CreateDirectoryRequest(BaseModel):
|
|||||||
"""Request to create a new directory."""
|
"""Request to create a new directory."""
|
||||||
parent_path: str
|
parent_path: str
|
||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Settings Schemas
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Note: VALID_MODELS and DEFAULT_MODEL are imported from registry at the top of this file
|
||||||
|
|
||||||
|
|
||||||
|
class ModelInfo(BaseModel):
|
||||||
|
"""Information about an available model."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsResponse(BaseModel):
|
||||||
|
"""Response schema for global settings."""
|
||||||
|
yolo_mode: bool = False
|
||||||
|
model: str = DEFAULT_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
class ModelsResponse(BaseModel):
|
||||||
|
"""Response schema for available models list."""
|
||||||
|
models: list[ModelInfo]
|
||||||
|
default: str
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsUpdate(BaseModel):
|
||||||
|
"""Request schema for updating global settings."""
|
||||||
|
yolo_mode: bool | None = None
|
||||||
|
model: str | None = None
|
||||||
|
|
||||||
|
@field_validator('model')
|
||||||
|
@classmethod
|
||||||
|
def validate_model(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None and v not in VALID_MODELS:
|
||||||
|
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
|
||||||
|
return v
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ class AgentProcessManager:
|
|||||||
self.started_at: datetime | None = None
|
self.started_at: datetime | None = None
|
||||||
self._output_task: asyncio.Task | None = None
|
self._output_task: asyncio.Task | None = None
|
||||||
self.yolo_mode: bool = False # YOLO mode for rapid prototyping
|
self.yolo_mode: bool = False # YOLO mode for rapid prototyping
|
||||||
|
self.model: str | None = None # Model being used
|
||||||
|
|
||||||
# Support multiple callbacks (for multiple WebSocket clients)
|
# Support multiple callbacks (for multiple WebSocket clients)
|
||||||
self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
|
self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
|
||||||
@@ -214,12 +215,13 @@ class AgentProcessManager:
|
|||||||
self.status = "stopped"
|
self.status = "stopped"
|
||||||
self._remove_lock()
|
self._remove_lock()
|
||||||
|
|
||||||
async def start(self, yolo_mode: bool = False) -> tuple[bool, str]:
|
async def start(self, yolo_mode: bool = False, model: str | None = None) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Start the agent as a subprocess.
|
Start the agent as a subprocess.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
yolo_mode: If True, run in YOLO mode (no browser testing)
|
yolo_mode: If True, run in YOLO mode (no browser testing)
|
||||||
|
model: Model to use (e.g., claude-opus-4-5-20251101)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (success, message)
|
Tuple of (success, message)
|
||||||
@@ -230,8 +232,9 @@ class AgentProcessManager:
|
|||||||
if not self._check_lock():
|
if not self._check_lock():
|
||||||
return False, "Another agent instance is already running for this project"
|
return False, "Another agent instance is already running for this project"
|
||||||
|
|
||||||
# Store YOLO mode for status queries
|
# Store for status queries
|
||||||
self.yolo_mode = yolo_mode
|
self.yolo_mode = yolo_mode
|
||||||
|
self.model = model
|
||||||
|
|
||||||
# Build command - pass absolute path to project directory
|
# Build command - pass absolute path to project directory
|
||||||
cmd = [
|
cmd = [
|
||||||
@@ -241,6 +244,10 @@ class AgentProcessManager:
|
|||||||
str(self.project_dir.resolve()),
|
str(self.project_dir.resolve()),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Add --model flag if model is specified
|
||||||
|
if model:
|
||||||
|
cmd.extend(["--model", model])
|
||||||
|
|
||||||
# Add --yolo flag if YOLO mode is enabled
|
# Add --yolo flag if YOLO mode is enabled
|
||||||
if yolo_mode:
|
if yolo_mode:
|
||||||
cmd.append("--yolo")
|
cmd.append("--yolo")
|
||||||
@@ -306,6 +313,7 @@ class AgentProcessManager:
|
|||||||
self.process = None
|
self.process = None
|
||||||
self.started_at = None
|
self.started_at = None
|
||||||
self.yolo_mode = False # Reset YOLO mode
|
self.yolo_mode = False # Reset YOLO mode
|
||||||
|
self.model = None # Reset model
|
||||||
|
|
||||||
return True, "Agent stopped"
|
return True, "Agent stopped"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -387,6 +395,7 @@ class AgentProcessManager:
|
|||||||
"pid": self.pid,
|
"pid": self.pid,
|
||||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||||
"yolo_mode": self.yolo_mode,
|
"yolo_mode": self.yolo_mode,
|
||||||
|
"model": self.model,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -423,3 +432,73 @@ async def cleanup_all_managers() -> None:
|
|||||||
|
|
||||||
with _managers_lock:
|
with _managers_lock:
|
||||||
_managers.clear()
|
_managers.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_orphaned_locks() -> int:
|
||||||
|
"""
|
||||||
|
Clean up orphaned lock files from previous server runs.
|
||||||
|
|
||||||
|
Scans all registered projects for .agent.lock files and removes them
|
||||||
|
if the referenced process is no longer running.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of orphaned lock files cleaned up
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
root = Path(__file__).parent.parent.parent
|
||||||
|
if str(root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(root))
|
||||||
|
|
||||||
|
from registry import list_registered_projects
|
||||||
|
|
||||||
|
cleaned = 0
|
||||||
|
try:
|
||||||
|
projects = list_registered_projects()
|
||||||
|
for name, info in projects.items():
|
||||||
|
project_path = Path(info.get("path", ""))
|
||||||
|
if not project_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
lock_file = project_path / ".agent.lock"
|
||||||
|
if not lock_file.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
pid_str = lock_file.read_text().strip()
|
||||||
|
pid = int(pid_str)
|
||||||
|
|
||||||
|
# Check if process is still running
|
||||||
|
if psutil.pid_exists(pid):
|
||||||
|
try:
|
||||||
|
proc = psutil.Process(pid)
|
||||||
|
cmdline = " ".join(proc.cmdline())
|
||||||
|
if "autonomous_agent_demo.py" in cmdline:
|
||||||
|
# Process is still running, don't remove
|
||||||
|
logger.info(
|
||||||
|
"Found running agent for project '%s' (PID %d)",
|
||||||
|
name, pid
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Process not running or not our agent - remove stale lock
|
||||||
|
lock_file.unlink(missing_ok=True)
|
||||||
|
cleaned += 1
|
||||||
|
logger.info("Removed orphaned lock file for project '%s'", name)
|
||||||
|
|
||||||
|
except (ValueError, OSError) as e:
|
||||||
|
# Invalid lock file content - remove it
|
||||||
|
logger.warning(
|
||||||
|
"Removing invalid lock file for project '%s': %s", name, e
|
||||||
|
)
|
||||||
|
lock_file.unlink(missing_ok=True)
|
||||||
|
cleaned += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error during orphan cleanup: %s", e)
|
||||||
|
|
||||||
|
if cleaned:
|
||||||
|
logger.info("Cleaned up %d orphaned lock file(s)", cleaned)
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { DebugLogViewer } from './components/DebugLogViewer'
|
|||||||
import { AgentThought } from './components/AgentThought'
|
import { AgentThought } from './components/AgentThought'
|
||||||
import { AssistantFAB } from './components/AssistantFAB'
|
import { AssistantFAB } from './components/AssistantFAB'
|
||||||
import { AssistantPanel } from './components/AssistantPanel'
|
import { AssistantPanel } from './components/AssistantPanel'
|
||||||
import { Plus, Loader2 } from 'lucide-react'
|
import { SettingsModal } from './components/SettingsModal'
|
||||||
|
import { Plus, Loader2, Settings } from 'lucide-react'
|
||||||
import type { Feature } from './lib/types'
|
import type { Feature } from './lib/types'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -34,10 +35,11 @@ function App() {
|
|||||||
const [debugOpen, setDebugOpen] = useState(false)
|
const [debugOpen, setDebugOpen] = useState(false)
|
||||||
const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height
|
const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height
|
||||||
const [assistantOpen, setAssistantOpen] = useState(false)
|
const [assistantOpen, setAssistantOpen] = useState(false)
|
||||||
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
|
|
||||||
const { data: projects, isLoading: projectsLoading } = useProjects()
|
const { data: projects, isLoading: projectsLoading } = useProjects()
|
||||||
const { data: features } = useFeatures(selectedProject)
|
const { data: features } = useFeatures(selectedProject)
|
||||||
const { data: agentStatusData } = useAgentStatus(selectedProject)
|
useAgentStatus(selectedProject) // Keep polling for status updates
|
||||||
const wsState = useProjectWebSocket(selectedProject)
|
const wsState = useProjectWebSocket(selectedProject)
|
||||||
|
|
||||||
// Play sounds when features move between columns
|
// Play sounds when features move between columns
|
||||||
@@ -93,9 +95,17 @@ function App() {
|
|||||||
setAssistantOpen(prev => !prev)
|
setAssistantOpen(prev => !prev)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// , : Open settings
|
||||||
|
if (e.key === ',') {
|
||||||
|
e.preventDefault()
|
||||||
|
setShowSettings(true)
|
||||||
|
}
|
||||||
|
|
||||||
// Escape : Close modals
|
// Escape : Close modals
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
if (assistantOpen) {
|
if (showSettings) {
|
||||||
|
setShowSettings(false)
|
||||||
|
} else if (assistantOpen) {
|
||||||
setAssistantOpen(false)
|
setAssistantOpen(false)
|
||||||
} else if (showAddFeature) {
|
} else if (showAddFeature) {
|
||||||
setShowAddFeature(false)
|
setShowAddFeature(false)
|
||||||
@@ -109,7 +119,7 @@ function App() {
|
|||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [selectedProject, showAddFeature, selectedFeature, debugOpen, assistantOpen])
|
}, [selectedProject, showAddFeature, selectedFeature, debugOpen, assistantOpen, showSettings])
|
||||||
|
|
||||||
// Combine WebSocket progress with feature data
|
// Combine WebSocket progress with feature data
|
||||||
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
||||||
@@ -163,8 +173,16 @@ function App() {
|
|||||||
<AgentControl
|
<AgentControl
|
||||||
projectName={selectedProject}
|
projectName={selectedProject}
|
||||||
status={wsState.agentStatus}
|
status={wsState.agentStatus}
|
||||||
yoloMode={agentStatusData?.yolo_mode ?? false}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSettings(true)}
|
||||||
|
className="neo-btn text-sm py-2 px-3"
|
||||||
|
title="Settings (,)"
|
||||||
|
aria-label="Open Settings"
|
||||||
|
>
|
||||||
|
<Settings size={18} />
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -270,6 +288,11 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Settings Modal */}
|
||||||
|
{showSettings && (
|
||||||
|
<SettingsModal onClose={() => setShowSettings(false)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,170 +1,66 @@
|
|||||||
import { useState } from 'react'
|
import { Play, Square, Loader2, Flame } from 'lucide-react'
|
||||||
import { Play, Pause, Square, Loader2, Zap } from 'lucide-react'
|
|
||||||
import {
|
import {
|
||||||
useStartAgent,
|
useStartAgent,
|
||||||
useStopAgent,
|
useStopAgent,
|
||||||
usePauseAgent,
|
useSettings,
|
||||||
useResumeAgent,
|
|
||||||
} from '../hooks/useProjects'
|
} from '../hooks/useProjects'
|
||||||
import type { AgentStatus } from '../lib/types'
|
import type { AgentStatus } from '../lib/types'
|
||||||
|
|
||||||
interface AgentControlProps {
|
interface AgentControlProps {
|
||||||
projectName: string
|
projectName: string
|
||||||
status: AgentStatus
|
status: AgentStatus
|
||||||
yoloMode?: boolean // From server status - whether currently running in YOLO mode
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentControl({ projectName, status, yoloMode = false }: AgentControlProps) {
|
export function AgentControl({ projectName, status }: AgentControlProps) {
|
||||||
const [yoloEnabled, setYoloEnabled] = useState(false)
|
const { data: settings } = useSettings()
|
||||||
|
const yoloMode = settings?.yolo_mode ?? false
|
||||||
|
|
||||||
const startAgent = useStartAgent(projectName)
|
const startAgent = useStartAgent(projectName)
|
||||||
const stopAgent = useStopAgent(projectName)
|
const stopAgent = useStopAgent(projectName)
|
||||||
const pauseAgent = usePauseAgent(projectName)
|
|
||||||
const resumeAgent = useResumeAgent(projectName)
|
|
||||||
|
|
||||||
const isLoading =
|
const isLoading = startAgent.isPending || stopAgent.isPending
|
||||||
startAgent.isPending ||
|
|
||||||
stopAgent.isPending ||
|
|
||||||
pauseAgent.isPending ||
|
|
||||||
resumeAgent.isPending
|
|
||||||
|
|
||||||
const handleStart = () => startAgent.mutate(yoloEnabled)
|
const handleStart = () => startAgent.mutate(yoloMode)
|
||||||
const handleStop = () => stopAgent.mutate()
|
const handleStop = () => stopAgent.mutate()
|
||||||
const handlePause = () => pauseAgent.mutate()
|
|
||||||
const handleResume = () => resumeAgent.mutate()
|
// Simplified: either show Start (when stopped/crashed) or Stop (when running/paused)
|
||||||
|
const isStopped = status === 'stopped' || status === 'crashed'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center">
|
||||||
{/* Status Indicator */}
|
{isStopped ? (
|
||||||
<StatusIndicator status={status} />
|
<button
|
||||||
|
onClick={handleStart}
|
||||||
{/* YOLO Mode Indicator - shown when running in YOLO mode */}
|
disabled={isLoading}
|
||||||
{(status === 'running' || status === 'paused') && yoloMode && (
|
className={`neo-btn text-sm py-2 px-3 ${
|
||||||
<div className="flex items-center gap-1 px-2 py-1 bg-[var(--color-neo-pending)] border-3 border-[var(--color-neo-border)]">
|
yoloMode ? 'neo-btn-yolo' : 'neo-btn-success'
|
||||||
<Zap size={14} className="text-yellow-900" />
|
}`}
|
||||||
<span className="font-display font-bold text-xs uppercase text-yellow-900">
|
title={yoloMode ? 'Start Agent (YOLO Mode)' : 'Start Agent'}
|
||||||
YOLO
|
aria-label={yoloMode ? 'Start Agent in YOLO Mode' : 'Start Agent'}
|
||||||
</span>
|
>
|
||||||
</div>
|
{isLoading ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : yoloMode ? (
|
||||||
|
<Flame size={18} />
|
||||||
|
) : (
|
||||||
|
<Play size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="neo-btn neo-btn-danger text-sm py-2 px-3"
|
||||||
|
title="Stop Agent"
|
||||||
|
aria-label="Stop Agent"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Square size={18} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Control Buttons */}
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{status === 'stopped' || status === 'crashed' ? (
|
|
||||||
<>
|
|
||||||
{/* YOLO Toggle - only shown when stopped */}
|
|
||||||
<button
|
|
||||||
onClick={() => setYoloEnabled(!yoloEnabled)}
|
|
||||||
className={`neo-btn text-sm py-2 px-3 ${
|
|
||||||
yoloEnabled ? 'neo-btn-warning' : 'neo-btn-secondary'
|
|
||||||
}`}
|
|
||||||
title="YOLO Mode: Skip testing for rapid prototyping"
|
|
||||||
>
|
|
||||||
<Zap size={18} className={yoloEnabled ? 'text-yellow-900' : ''} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleStart}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="neo-btn neo-btn-success text-sm py-2 px-3"
|
|
||||||
title={yoloEnabled ? "Start Agent (YOLO Mode)" : "Start Agent"}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play size={18} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : status === 'running' ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={handlePause}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="neo-btn neo-btn-warning text-sm py-2 px-3"
|
|
||||||
title="Pause Agent"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Pause size={18} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleStop}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="neo-btn neo-btn-danger text-sm py-2 px-3"
|
|
||||||
title="Stop Agent"
|
|
||||||
>
|
|
||||||
<Square size={18} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : status === 'paused' ? (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
onClick={handleResume}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="neo-btn neo-btn-success text-sm py-2 px-3"
|
|
||||||
title="Resume Agent"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 size={18} className="animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Play size={18} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleStop}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="neo-btn neo-btn-danger text-sm py-2 px-3"
|
|
||||||
title="Stop Agent"
|
|
||||||
>
|
|
||||||
<Square size={18} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusIndicator({ status }: { status: AgentStatus }) {
|
|
||||||
const statusConfig = {
|
|
||||||
stopped: {
|
|
||||||
color: 'var(--color-neo-text-secondary)',
|
|
||||||
label: 'Stopped',
|
|
||||||
pulse: false,
|
|
||||||
},
|
|
||||||
running: {
|
|
||||||
color: 'var(--color-neo-done)',
|
|
||||||
label: 'Running',
|
|
||||||
pulse: true,
|
|
||||||
},
|
|
||||||
paused: {
|
|
||||||
color: 'var(--color-neo-pending)',
|
|
||||||
label: 'Paused',
|
|
||||||
pulse: false,
|
|
||||||
},
|
|
||||||
crashed: {
|
|
||||||
color: 'var(--color-neo-danger)',
|
|
||||||
label: 'Crashed',
|
|
||||||
pulse: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = statusConfig[status]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 px-3 py-2 bg-white border-3 border-[var(--color-neo-border)]">
|
|
||||||
<span
|
|
||||||
className={`w-3 h-3 rounded-full ${config.pulse ? 'animate-pulse' : ''}`}
|
|
||||||
style={{ backgroundColor: config.color }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="font-display font-bold text-sm uppercase"
|
|
||||||
style={{ color: config.color }}
|
|
||||||
>
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
213
ui/src/components/SettingsModal.tsx
Normal file
213
ui/src/components/SettingsModal.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { X, Loader2, AlertCircle } from 'lucide-react'
|
||||||
|
import { useSettings, useUpdateSettings, useAvailableModels } from '../hooks/useProjects'
|
||||||
|
|
||||||
|
interface SettingsModalProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsModal({ onClose }: SettingsModalProps) {
|
||||||
|
const { data: settings, isLoading, isError, refetch } = useSettings()
|
||||||
|
const { data: modelsData } = useAvailableModels()
|
||||||
|
const updateSettings = useUpdateSettings()
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null)
|
||||||
|
const closeButtonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
// Focus trap - keep focus within modal
|
||||||
|
useEffect(() => {
|
||||||
|
const modal = modalRef.current
|
||||||
|
if (!modal) return
|
||||||
|
|
||||||
|
// Focus the close button when modal opens
|
||||||
|
closeButtonRef.current?.focus()
|
||||||
|
|
||||||
|
const focusableElements = modal.querySelectorAll<HTMLElement>(
|
||||||
|
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
)
|
||||||
|
const firstElement = focusableElements[0]
|
||||||
|
const lastElement = focusableElements[focusableElements.length - 1]
|
||||||
|
|
||||||
|
const handleTabKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
if (document.activeElement === firstElement) {
|
||||||
|
e.preventDefault()
|
||||||
|
lastElement?.focus()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (document.activeElement === lastElement) {
|
||||||
|
e.preventDefault()
|
||||||
|
firstElement?.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleTabKey)
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleTabKey)
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
}
|
||||||
|
}, [onClose])
|
||||||
|
|
||||||
|
const handleYoloToggle = () => {
|
||||||
|
if (settings && !updateSettings.isPending) {
|
||||||
|
updateSettings.mutate({ yolo_mode: !settings.yolo_mode })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleModelChange = (modelId: string) => {
|
||||||
|
if (!updateSettings.isPending) {
|
||||||
|
updateSettings.mutate({ model: modelId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const models = modelsData?.models ?? []
|
||||||
|
const isSaving = updateSettings.isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="neo-modal-backdrop"
|
||||||
|
onClick={onClose}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="neo-modal w-full max-w-sm p-6"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="settings-title"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 id="settings-title" className="font-display text-xl font-bold">
|
||||||
|
Settings
|
||||||
|
{isSaving && (
|
||||||
|
<Loader2 className="inline-block ml-2 animate-spin" size={16} />
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
ref={closeButtonRef}
|
||||||
|
onClick={onClose}
|
||||||
|
className="neo-btn neo-btn-ghost p-2"
|
||||||
|
aria-label="Close settings"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="animate-spin" size={24} />
|
||||||
|
<span className="ml-2">Loading settings...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{isError && (
|
||||||
|
<div className="p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)] mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>Failed to load settings</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="mt-2 underline text-sm"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings Content */}
|
||||||
|
{settings && !isLoading && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* YOLO Mode Toggle */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
id="yolo-label"
|
||||||
|
className="font-display font-bold text-base"
|
||||||
|
>
|
||||||
|
YOLO Mode
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-[var(--color-neo-text-secondary)] mt-1">
|
||||||
|
Skip testing for rapid prototyping
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleYoloToggle}
|
||||||
|
disabled={isSaving}
|
||||||
|
className={`relative w-14 h-8 rounded-none border-3 border-[var(--color-neo-border)] transition-colors ${
|
||||||
|
settings.yolo_mode
|
||||||
|
? 'bg-[var(--color-neo-pending)]'
|
||||||
|
: 'bg-white'
|
||||||
|
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
role="switch"
|
||||||
|
aria-checked={settings.yolo_mode}
|
||||||
|
aria-labelledby="yolo-label"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-1 w-5 h-5 bg-[var(--color-neo-border)] transition-transform ${
|
||||||
|
settings.yolo_mode ? 'left-7' : 'left-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Selection - Radio Group */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
id="model-label"
|
||||||
|
className="font-display font-bold text-base block mb-2"
|
||||||
|
>
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className="flex border-3 border-[var(--color-neo-border)]"
|
||||||
|
role="radiogroup"
|
||||||
|
aria-labelledby="model-label"
|
||||||
|
>
|
||||||
|
{models.map((model) => (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
onClick={() => handleModelChange(model.id)}
|
||||||
|
disabled={isSaving}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={settings.model === model.id}
|
||||||
|
className={`flex-1 py-3 px-4 font-display font-bold text-sm transition-colors ${
|
||||||
|
settings.model === model.id
|
||||||
|
? 'bg-[var(--color-neo-accent)] text-white'
|
||||||
|
: 'bg-white text-[var(--color-neo-text)] hover:bg-gray-100'
|
||||||
|
} ${isSaving ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
{model.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Update Error */}
|
||||||
|
{updateSettings.isError && (
|
||||||
|
<div className="p-3 bg-red-50 border-3 border-red-200 text-red-700 text-sm">
|
||||||
|
Failed to save settings. Please try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import * as api from '../lib/api'
|
import * as api from '../lib/api'
|
||||||
import type { FeatureCreate } from '../lib/types'
|
import type { FeatureCreate, ModelsResponse, Settings, SettingsUpdate } from '../lib/types'
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Projects
|
// Projects
|
||||||
@@ -200,3 +200,74 @@ export function useValidatePath() {
|
|||||||
mutationFn: (path: string) => api.validatePath(path),
|
mutationFn: (path: string) => api.validatePath(path),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Settings
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Default models response for placeholder (until API responds)
|
||||||
|
const DEFAULT_MODELS: ModelsResponse = {
|
||||||
|
models: [
|
||||||
|
{ id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5' },
|
||||||
|
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' },
|
||||||
|
],
|
||||||
|
default: 'claude-opus-4-5-20251101',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: Settings = {
|
||||||
|
yolo_mode: false,
|
||||||
|
model: 'claude-opus-4-5-20251101',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAvailableModels() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['available-models'],
|
||||||
|
queryFn: api.getAvailableModels,
|
||||||
|
staleTime: 300000, // Cache for 5 minutes - models don't change often
|
||||||
|
retry: 1,
|
||||||
|
placeholderData: DEFAULT_MODELS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['settings'],
|
||||||
|
queryFn: api.getSettings,
|
||||||
|
staleTime: 60000, // Cache for 1 minute
|
||||||
|
retry: 1,
|
||||||
|
placeholderData: DEFAULT_SETTINGS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSettings() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (settings: SettingsUpdate) => api.updateSettings(settings),
|
||||||
|
onMutate: async (newSettings) => {
|
||||||
|
// Cancel outgoing refetches
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['settings'] })
|
||||||
|
|
||||||
|
// Snapshot previous value
|
||||||
|
const previous = queryClient.getQueryData<Settings>(['settings'])
|
||||||
|
|
||||||
|
// Optimistically update
|
||||||
|
queryClient.setQueryData<Settings>(['settings'], (old) => ({
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
...old,
|
||||||
|
...newSettings,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return { previous }
|
||||||
|
},
|
||||||
|
onError: (_err, _newSettings, context) => {
|
||||||
|
// Rollback on error
|
||||||
|
if (context?.previous) {
|
||||||
|
queryClient.setQueryData(['settings'], context.previous)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import type {
|
|||||||
PathValidationResponse,
|
PathValidationResponse,
|
||||||
AssistantConversation,
|
AssistantConversation,
|
||||||
AssistantConversationDetail,
|
AssistantConversationDetail,
|
||||||
|
Settings,
|
||||||
|
SettingsUpdate,
|
||||||
|
ModelsResponse,
|
||||||
} from './types'
|
} from './types'
|
||||||
|
|
||||||
const API_BASE = '/api'
|
const API_BASE = '/api'
|
||||||
@@ -267,3 +270,22 @@ export async function deleteAssistantConversation(
|
|||||||
{ method: 'DELETE' }
|
{ method: 'DELETE' }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Settings API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getAvailableModels(): Promise<ModelsResponse> {
|
||||||
|
return fetchJSON('/settings/models')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSettings(): Promise<Settings> {
|
||||||
|
return fetchJSON('/settings')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettings(settings: SettingsUpdate): Promise<Settings> {
|
||||||
|
return fetchJSON('/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(settings),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export interface AgentStatusResponse {
|
|||||||
pid: number | null
|
pid: number | null
|
||||||
started_at: string | null
|
started_at: string | null
|
||||||
yolo_mode: boolean
|
yolo_mode: boolean
|
||||||
|
model: string | null // Model being used by running agent
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentActionResponse {
|
export interface AgentActionResponse {
|
||||||
@@ -295,3 +296,27 @@ export type AssistantChatServerMessage =
|
|||||||
| AssistantChatErrorMessage
|
| AssistantChatErrorMessage
|
||||||
| AssistantChatConversationCreatedMessage
|
| AssistantChatConversationCreatedMessage
|
||||||
| AssistantChatPongMessage
|
| AssistantChatPongMessage
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Settings Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface ModelInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelsResponse {
|
||||||
|
models: ModelInfo[]
|
||||||
|
default: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
yolo_mode: boolean
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsUpdate {
|
||||||
|
yolo_mode?: boolean
|
||||||
|
model?: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -163,6 +163,23 @@
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* YOLO Mode Button - Fiery gradient for when YOLO mode is enabled */
|
||||||
|
/* Uses darker orange colors for better contrast with white text (WCAG AA) */
|
||||||
|
.neo-btn-yolo {
|
||||||
|
background: linear-gradient(135deg, #d64500, #e65c00);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow:
|
||||||
|
4px 4px 0 var(--color-neo-border),
|
||||||
|
0 0 12px rgba(255, 84, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.neo-btn-yolo:hover {
|
||||||
|
background: linear-gradient(135deg, #ff5400, #ff6a00);
|
||||||
|
box-shadow:
|
||||||
|
6px 6px 0 var(--color-neo-border),
|
||||||
|
0 0 16px rgba(255, 84, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
/* Inputs */
|
/* Inputs */
|
||||||
.neo-input {
|
.neo-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}
|
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/agentthought.tsx","./src/components/assistantchat.tsx","./src/components/assistantfab.tsx","./src/components/assistantpanel.tsx","./src/components/chatmessage.tsx","./src/components/debuglogviewer.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/folderbrowser.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/newprojectmodal.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/questionoptions.tsx","./src/components/settingsmodal.tsx","./src/components/setupwizard.tsx","./src/components/speccreationchat.tsx","./src/components/typingindicator.tsx","./src/hooks/useassistantchat.ts","./src/hooks/usecelebration.ts","./src/hooks/usefeaturesound.ts","./src/hooks/useprojects.ts","./src/hooks/usespecchat.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}
|
||||||
Reference in New Issue
Block a user