mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +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()
|
||||
|
||||
from agent import run_autonomous_agent
|
||||
from registry import get_project_path
|
||||
|
||||
# Configuration
|
||||
# DEFAULT_MODEL = "claude-sonnet-4-5-20250929"
|
||||
DEFAULT_MODEL = "claude-opus-4-5-20251101"
|
||||
from registry import DEFAULT_MODEL, get_project_path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
|
||||
158
registry.py
158
registry.py
@@ -9,6 +9,8 @@ Uses SQLite database stored at ~/.autocoder/registry.db.
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -22,6 +24,29 @@ from sqlalchemy.orm import sessionmaker
|
||||
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
|
||||
# =============================================================================
|
||||
@@ -62,13 +87,23 @@ class Project(Base):
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
# Module-level singleton for database engine
|
||||
# Module-level singleton for database engine with thread-safe initialization
|
||||
_engine = None
|
||||
_SessionLocal = None
|
||||
_engine_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_config_dir() -> Path:
|
||||
@@ -90,17 +125,26 @@ def get_registry_path() -> Path:
|
||||
|
||||
def _get_engine():
|
||||
"""
|
||||
Get or create the database engine (singleton pattern).
|
||||
Get or create the database engine (thread-safe singleton pattern).
|
||||
|
||||
Returns:
|
||||
Tuple of (engine, SessionLocal)
|
||||
"""
|
||||
global _engine, _SessionLocal
|
||||
|
||||
# Double-checked locking for thread safety
|
||||
if _engine is None:
|
||||
with _engine_lock:
|
||||
if _engine is None:
|
||||
db_path = get_registry_path()
|
||||
db_url = f"sqlite:///{db_path.as_posix()}"
|
||||
_engine = create_engine(db_url, connect_args={"check_same_thread": False})
|
||||
_engine = create_engine(
|
||||
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)
|
||||
@@ -113,6 +157,8 @@ def _get_session():
|
||||
"""
|
||||
Context manager for database sessions with automatic commit/rollback.
|
||||
|
||||
Includes retry logic for SQLite busy database errors.
|
||||
|
||||
Yields:
|
||||
SQLAlchemy session
|
||||
"""
|
||||
@@ -128,6 +174,40 @@ def _get_session():
|
||||
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
|
||||
# =============================================================================
|
||||
@@ -364,3 +444,75 @@ def list_valid_projects() -> list[dict[str, Any]]:
|
||||
return valid
|
||||
finally:
|
||||
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,
|
||||
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)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
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 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,8 @@ import { DebugLogViewer } from './components/DebugLogViewer'
|
||||
import { AgentThought } from './components/AgentThought'
|
||||
import { AssistantFAB } from './components/AssistantFAB'
|
||||
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'
|
||||
|
||||
function App() {
|
||||
@@ -34,10 +35,11 @@ function App() {
|
||||
const [debugOpen, setDebugOpen] = useState(false)
|
||||
const [debugPanelHeight, setDebugPanelHeight] = useState(288) // Default height
|
||||
const [assistantOpen, setAssistantOpen] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
|
||||
const { data: projects, isLoading: projectsLoading } = useProjects()
|
||||
const { data: features } = useFeatures(selectedProject)
|
||||
const { data: agentStatusData } = useAgentStatus(selectedProject)
|
||||
useAgentStatus(selectedProject) // Keep polling for status updates
|
||||
const wsState = useProjectWebSocket(selectedProject)
|
||||
|
||||
// Play sounds when features move between columns
|
||||
@@ -93,9 +95,17 @@ function App() {
|
||||
setAssistantOpen(prev => !prev)
|
||||
}
|
||||
|
||||
// , : Open settings
|
||||
if (e.key === ',') {
|
||||
e.preventDefault()
|
||||
setShowSettings(true)
|
||||
}
|
||||
|
||||
// Escape : Close modals
|
||||
if (e.key === 'Escape') {
|
||||
if (assistantOpen) {
|
||||
if (showSettings) {
|
||||
setShowSettings(false)
|
||||
} else if (assistantOpen) {
|
||||
setAssistantOpen(false)
|
||||
} else if (showAddFeature) {
|
||||
setShowAddFeature(false)
|
||||
@@ -109,7 +119,7 @@ function App() {
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [selectedProject, showAddFeature, selectedFeature, debugOpen, assistantOpen])
|
||||
}, [selectedProject, showAddFeature, selectedFeature, debugOpen, assistantOpen, showSettings])
|
||||
|
||||
// Combine WebSocket progress with feature data
|
||||
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
||||
@@ -163,8 +173,16 @@ function App() {
|
||||
<AgentControl
|
||||
projectName={selectedProject}
|
||||
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>
|
||||
@@ -270,6 +288,11 @@ function App() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Settings Modal */}
|
||||
{showSettings && (
|
||||
<SettingsModal onClose={() => setShowSettings(false)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,170 +1,66 @@
|
||||
import { useState } from 'react'
|
||||
import { Play, Pause, Square, Loader2, Zap } from 'lucide-react'
|
||||
import { Play, Square, Loader2, Flame } from 'lucide-react'
|
||||
import {
|
||||
useStartAgent,
|
||||
useStopAgent,
|
||||
usePauseAgent,
|
||||
useResumeAgent,
|
||||
useSettings,
|
||||
} from '../hooks/useProjects'
|
||||
import type { AgentStatus } from '../lib/types'
|
||||
|
||||
interface AgentControlProps {
|
||||
projectName: string
|
||||
status: AgentStatus
|
||||
yoloMode?: boolean // From server status - whether currently running in YOLO mode
|
||||
}
|
||||
|
||||
export function AgentControl({ projectName, status, yoloMode = false }: AgentControlProps) {
|
||||
const [yoloEnabled, setYoloEnabled] = useState(false)
|
||||
export function AgentControl({ projectName, status }: AgentControlProps) {
|
||||
const { data: settings } = useSettings()
|
||||
const yoloMode = settings?.yolo_mode ?? false
|
||||
|
||||
const startAgent = useStartAgent(projectName)
|
||||
const stopAgent = useStopAgent(projectName)
|
||||
const pauseAgent = usePauseAgent(projectName)
|
||||
const resumeAgent = useResumeAgent(projectName)
|
||||
|
||||
const isLoading =
|
||||
startAgent.isPending ||
|
||||
stopAgent.isPending ||
|
||||
pauseAgent.isPending ||
|
||||
resumeAgent.isPending
|
||||
const isLoading = startAgent.isPending || stopAgent.isPending
|
||||
|
||||
const handleStart = () => startAgent.mutate(yoloEnabled)
|
||||
const handleStart = () => startAgent.mutate(yoloMode)
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status Indicator */}
|
||||
<StatusIndicator status={status} />
|
||||
|
||||
{/* YOLO Mode Indicator - shown when running in YOLO mode */}
|
||||
{(status === 'running' || status === 'paused') && yoloMode && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-[var(--color-neo-pending)] border-3 border-[var(--color-neo-border)]">
|
||||
<Zap size={14} className="text-yellow-900" />
|
||||
<span className="font-display font-bold text-xs uppercase text-yellow-900">
|
||||
YOLO
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<div className="flex items-center">
|
||||
{isStopped ? (
|
||||
<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"}
|
||||
className={`neo-btn text-sm py-2 px-3 ${
|
||||
yoloMode ? 'neo-btn-yolo' : 'neo-btn-success'
|
||||
}`}
|
||||
title={yoloMode ? 'Start Agent (YOLO Mode)' : 'Start Agent'}
|
||||
aria-label={yoloMode ? 'Start Agent in YOLO Mode' : 'Start Agent'}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : yoloMode ? (
|
||||
<Flame size={18} />
|
||||
) : (
|
||||
<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"
|
||||
aria-label="Stop Agent"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={18} />
|
||||
<Square 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>
|
||||
)
|
||||
}
|
||||
|
||||
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 * as api from '../lib/api'
|
||||
import type { FeatureCreate } from '../lib/types'
|
||||
import type { FeatureCreate, ModelsResponse, Settings, SettingsUpdate } from '../lib/types'
|
||||
|
||||
// ============================================================================
|
||||
// Projects
|
||||
@@ -200,3 +200,74 @@ export function useValidatePath() {
|
||||
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,
|
||||
AssistantConversation,
|
||||
AssistantConversationDetail,
|
||||
Settings,
|
||||
SettingsUpdate,
|
||||
ModelsResponse,
|
||||
} from './types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
@@ -267,3 +270,22 @@ export async function deleteAssistantConversation(
|
||||
{ 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
|
||||
started_at: string | null
|
||||
yolo_mode: boolean
|
||||
model: string | null // Model being used by running agent
|
||||
}
|
||||
|
||||
export interface AgentActionResponse {
|
||||
@@ -295,3 +296,27 @@ export type AssistantChatServerMessage =
|
||||
| AssistantChatErrorMessage
|
||||
| AssistantChatConversationCreatedMessage
|
||||
| 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;
|
||||
}
|
||||
|
||||
/* 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 */
|
||||
.neo-input {
|
||||
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