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:
Auto
2026-01-07 12:29:07 +02:00
parent 122f03dc21
commit 45ba266f71
16 changed files with 825 additions and 173 deletions

View File

@@ -21,11 +21,12 @@ from .routers import (
features_router,
filesystem_router,
projects_router,
settings_router,
spec_creation_router,
)
from .schemas import SetupStatus
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
# Paths
@@ -36,7 +37,8 @@ UI_DIST_DIR = ROOT_DIR / "ui" / "dist"
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown."""
# Startup
# Startup - clean up orphaned lock files from previous runs
cleanup_orphaned_locks()
yield
# Shutdown - cleanup all running agents and assistant sessions
await cleanup_all_managers()
@@ -92,6 +94,7 @@ app.include_router(agent_router)
app.include_router(spec_creation_router)
app.include_router(filesystem_router)
app.include_router(assistant_chat_router)
app.include_router(settings_router)
# ============================================================================

View File

@@ -10,6 +10,7 @@ from .assistant_chat import router as assistant_chat_router
from .features import router as features_router
from .filesystem import router as filesystem_router
from .projects import router as projects_router
from .settings import router as settings_router
from .spec_creation import router as spec_creation_router
__all__ = [
@@ -19,4 +20,5 @@ __all__ = [
"spec_creation_router",
"filesystem_router",
"assistant_chat_router",
"settings_router",
]

View File

@@ -26,6 +26,21 @@ def _get_project_path(project_name: str) -> Path:
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"])
# Root directory for process manager
@@ -69,6 +84,7 @@ async def get_agent_status(project_name: str):
pid=manager.pid,
started_at=manager.started_at,
yolo_mode=manager.yolo_mode,
model=manager.model,
)
@@ -80,7 +96,12 @@ async def start_agent(
"""Start the agent for a project."""
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(
success=success,

View 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),
)

View File

@@ -6,11 +6,20 @@ Request/Response models for the API endpoints.
"""
import base64
import sys
from datetime import datetime
from pathlib import Path
from typing import Literal
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
# ============================================================================
@@ -102,7 +111,16 @@ class FeatureListResponse(BaseModel):
class AgentStartRequest(BaseModel):
"""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):
@@ -111,6 +129,7 @@ class AgentStatus(BaseModel):
pid: int | None = None
started_at: datetime | None = None
yolo_mode: bool = False
model: str | None = None # Model being used by running agent
class AgentActionResponse(BaseModel):
@@ -239,3 +258,41 @@ class CreateDirectoryRequest(BaseModel):
"""Request to create a new directory."""
parent_path: str
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

View File

@@ -74,6 +74,7 @@ class AgentProcessManager:
self.started_at: datetime | None = None
self._output_task: asyncio.Task | None = None
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)
self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
@@ -214,12 +215,13 @@ class AgentProcessManager:
self.status = "stopped"
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.
Args:
yolo_mode: If True, run in YOLO mode (no browser testing)
model: Model to use (e.g., claude-opus-4-5-20251101)
Returns:
Tuple of (success, message)
@@ -230,8 +232,9 @@ class AgentProcessManager:
if not self._check_lock():
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.model = model
# Build command - pass absolute path to project directory
cmd = [
@@ -241,6 +244,10 @@ class AgentProcessManager:
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
if yolo_mode:
cmd.append("--yolo")
@@ -306,6 +313,7 @@ class AgentProcessManager:
self.process = None
self.started_at = None
self.yolo_mode = False # Reset YOLO mode
self.model = None # Reset model
return True, "Agent stopped"
except Exception as e:
@@ -387,6 +395,7 @@ class AgentProcessManager:
"pid": self.pid,
"started_at": self.started_at.isoformat() if self.started_at else None,
"yolo_mode": self.yolo_mode,
"model": self.model,
}
@@ -423,3 +432,73 @@ async def cleanup_all_managers() -> None:
with _managers_lock:
_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