diff --git a/.claude/commands/check-code.md b/.claude/commands/check-code.md new file mode 100644 index 0000000..5549261 --- /dev/null +++ b/.claude/commands/check-code.md @@ -0,0 +1,32 @@ +--- +description: +--- + +Run the following commands and ensure the code is clean. + +From project root: + +# Python linting + +ruff check . + +# Security tests + +python test_security.py + +From ui/ directory: +cd ui + +# ESLint (will fail until we add the config) + +npm run lint + +# TypeScript check + build + +npm run build + +One-liner to run everything: +ruff check . && python test_security.py && cd ui && npm run lint && npm run build + +Or if you want to see all failures at once (doesn't stop on first error): +ruff check .; python test_security.py; cd ui && npm run lint; npm run build diff --git a/autonomous_agent_demo.py b/autonomous_agent_demo.py index f240cc2..71151cb 100644 --- a/autonomous_agent_demo.py +++ b/autonomous_agent_demo.py @@ -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: diff --git a/registry.py b/registry.py index 5d48a1c..20d31df 100644 --- a/registry.py +++ b/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,20 +125,29 @@ 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: - db_path = get_registry_path() - db_url = f"sqlite:///{db_path.as_posix()}" - _engine = create_engine(db_url, connect_args={"check_same_thread": False}) - Base.metadata.create_all(bind=_engine) - _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine) - logger.debug("Initialized registry database at: %s", db_path) + 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, + "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 @@ -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 {} diff --git a/server/main.py b/server/main.py index 596650d..e717a53 100644 --- a/server/main.py +++ b/server/main.py @@ -22,12 +22,13 @@ 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.expand_chat_session import cleanup_all_expand_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 @@ -38,7 +39,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 sessions await cleanup_all_managers() @@ -96,6 +98,7 @@ app.include_router(spec_creation_router) app.include_router(expand_project_router) app.include_router(filesystem_router) app.include_router(assistant_chat_router) +app.include_router(settings_router) # ============================================================================ diff --git a/server/routers/__init__.py b/server/routers/__init__.py index 71a6013..36b0fb5 100644 --- a/server/routers/__init__.py +++ b/server/routers/__init__.py @@ -11,6 +11,7 @@ from .expand_project import router as expand_project_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__ = [ @@ -21,4 +22,5 @@ __all__ = [ "expand_project_router", "filesystem_router", "assistant_chat_router", + "settings_router", ] diff --git a/server/routers/agent.py b/server/routers/agent.py index d5631fa..309ab1c 100644 --- a/server/routers/agent.py +++ b/server/routers/agent.py @@ -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, diff --git a/server/routers/settings.py b/server/routers/settings.py new file mode 100644 index 0000000..18362ee --- /dev/null +++ b/server/routers/settings.py @@ -0,0 +1,74 @@ +""" +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, + 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), + ) diff --git a/server/schemas.py b/server/schemas.py index c1f384c..cb0a4ec 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -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 DEFAULT_MODEL, VALID_MODELS + # ============================================================================ # Project Schemas # ============================================================================ @@ -114,7 +123,16 @@ class FeatureBulkCreateResponse(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): @@ -123,6 +141,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): @@ -251,3 +270,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 diff --git a/server/services/process_manager.py b/server/services/process_manager.py index d2b4f0b..88ec2bd 100644 --- a/server/services/process_manager.py +++ b/server/services/process_manager.py @@ -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 diff --git a/start.sh b/start.sh index d90c097..666a11a 100644 --- a/start.sh +++ b/start.sh @@ -58,14 +58,39 @@ fi echo "" -# Check if venv exists, create if not -if [ ! -d "venv" ]; then - echo "Creating virtual environment..." +# Check if venv exists with correct structure for this platform +# Windows venvs have Scripts/, Linux/macOS have bin/ +if [ ! -f "venv/bin/activate" ]; then + if [ -d "venv" ]; then + echo "[INFO] Detected incompatible virtual environment (possibly created on Windows)" + echo "[INFO] Recreating virtual environment for this platform..." + rm -rf venv + if [ -d "venv" ]; then + echo "[ERROR] Failed to remove existing virtual environment" + echo "Please manually delete the 'venv' directory and try again:" + echo " rm -rf venv" + exit 1 + fi + else + echo "Creating virtual environment..." + fi python3 -m venv venv + if [ $? -ne 0 ]; then + echo "[ERROR] Failed to create virtual environment" + echo "Please ensure the venv module is installed:" + echo " Ubuntu/Debian: sudo apt install python3-venv" + echo " Or try: python3 -m ensurepip" + exit 1 + fi fi # Activate the virtual environment source venv/bin/activate +if [ $? -ne 0 ]; then + echo "[ERROR] Failed to activate virtual environment" + echo "The venv may be corrupted. Try: rm -rf venv && ./start.sh" + exit 1 +fi # Install dependencies echo "Installing dependencies..." diff --git a/start_ui.bat b/start_ui.bat index d53ca9c..8616b1a 100644 --- a/start_ui.bat +++ b/start_ui.bat @@ -18,8 +18,8 @@ if %ERRORLEVEL% neq 0 ( exit /b 1 ) -REM Check if venv exists, create if not -if not exist "venv" ( +REM Check if venv exists with correct activation script +if not exist "venv\Scripts\activate.bat" ( echo Creating virtual environment... python -m venv venv ) diff --git a/start_ui.sh b/start_ui.sh index 644db74..db3d6fa 100644 --- a/start_ui.sh +++ b/start_ui.sh @@ -21,14 +21,39 @@ else PYTHON_CMD="python3" fi -# Check if venv exists, create if not -if [ ! -d "venv" ]; then - echo "Creating virtual environment..." +# Check if venv exists with correct structure for this platform +# Windows venvs have Scripts/, Linux/macOS have bin/ +if [ ! -f "venv/bin/activate" ]; then + if [ -d "venv" ]; then + echo "[INFO] Detected incompatible virtual environment (possibly created on Windows)" + echo "[INFO] Recreating virtual environment for this platform..." + rm -rf venv + if [ -d "venv" ]; then + echo "[ERROR] Failed to remove existing virtual environment" + echo "Please manually delete the 'venv' directory and try again:" + echo " rm -rf venv" + exit 1 + fi + else + echo "Creating virtual environment..." + fi $PYTHON_CMD -m venv venv + if [ $? -ne 0 ]; then + echo "[ERROR] Failed to create virtual environment" + echo "Please ensure the venv module is installed:" + echo " Ubuntu/Debian: sudo apt install python3-venv" + echo " Or try: $PYTHON_CMD -m ensurepip" + exit 1 + fi fi # Activate the virtual environment source venv/bin/activate +if [ $? -ne 0 ]; then + echo "[ERROR] Failed to activate virtual environment" + echo "The venv may be corrupted. Try: rm -rf venv && ./start_ui.sh" + exit 1 +fi # Install dependencies echo "Installing dependencies..." diff --git a/ui/eslint.config.js b/ui/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/ui/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index cd39bdc..428a171 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,7 +18,8 @@ import { AgentThought } from './components/AgentThought' import { AssistantFAB } from './components/AssistantFAB' import { AssistantPanel } from './components/AssistantPanel' import { ExpandProjectModal } from './components/ExpandProjectModal' -import { Plus, Loader2, Sparkles } from 'lucide-react' +import { SettingsModal } from './components/SettingsModal' +import { Plus, Loader2, Sparkles, Settings } from 'lucide-react' import type { Feature } from './lib/types' function App() { @@ -37,11 +38,12 @@ 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 queryClient = useQueryClient() 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 @@ -104,10 +106,18 @@ function App() { setAssistantOpen(prev => !prev) } + // , : Open settings + if (e.key === ',') { + e.preventDefault() + setShowSettings(true) + } + // Escape : Close modals if (e.key === 'Escape') { if (showExpandProject) { setShowExpandProject(false) + if (showSettings) { + setShowSettings(false) } else if (assistantOpen) { setAssistantOpen(false) } else if (showAddFeature) { @@ -122,7 +132,7 @@ function App() { window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, assistantOpen, features]) + }, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, assistantOpen, features, showSettings]) // Combine WebSocket progress with feature data const progress = wsState.progress.total > 0 ? wsState.progress : { @@ -191,8 +201,16 @@ function App() { + + )} @@ -311,6 +329,11 @@ function App() { /> )} + + {/* Settings Modal */} + {showSettings && ( + setShowSettings(false)} /> + )} ) } diff --git a/ui/src/components/AgentControl.tsx b/ui/src/components/AgentControl.tsx index 1b94fdd..1ae77b3 100644 --- a/ui/src/components/AgentControl.tsx +++ b/ui/src/components/AgentControl.tsx @@ -1,170 +1,66 @@ -import { useState } from 'react' -import { Play, Pause, Square, Loader2, Zap } from 'lucide-react' +import { Play, Square, Loader2 } 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 ( -
- {/* Status Indicator */} - - - {/* YOLO Mode Indicator - shown when running in YOLO mode */} - {(status === 'running' || status === 'paused') && yoloMode && ( -
- - - YOLO - -
+
+ {isStopped ? ( + + ) : ( + )} - - {/* Control Buttons */} -
- {status === 'stopped' || status === 'crashed' ? ( - <> - {/* YOLO Toggle - only shown when stopped */} - - - - ) : status === 'running' ? ( - <> - - - - ) : status === 'paused' ? ( - <> - - - - ) : null} -
-
- ) -} - -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 ( -
- - - {config.label} -
) } diff --git a/ui/src/components/AgentThought.tsx b/ui/src/components/AgentThought.tsx index 8cc8508..65a50a1 100644 --- a/ui/src/components/AgentThought.tsx +++ b/ui/src/components/AgentThought.tsx @@ -24,7 +24,7 @@ function isAgentThought(line: string): boolean { if (/^Output:/.test(trimmed)) return false // Skip JSON and very short lines - if (/^[\[\{]/.test(trimmed)) return false + if (/^[[{]/.test(trimmed)) return false if (trimmed.length < 15) return false // Skip lines that are just paths or technical output diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx new file mode 100644 index 0000000..11608a7 --- /dev/null +++ b/ui/src/components/SettingsModal.tsx @@ -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(null) + const closeButtonRef = useRef(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( + '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 ( +
+
e.stopPropagation()} + role="dialog" + aria-labelledby="settings-title" + aria-modal="true" + > + {/* Header */} +
+

+ Settings + {isSaving && ( + + )} +

+ +
+ + {/* Loading State */} + {isLoading && ( +
+ + Loading settings... +
+ )} + + {/* Error State */} + {isError && ( +
+
+ + Failed to load settings +
+ +
+ )} + + {/* Settings Content */} + {settings && !isLoading && ( +
+ {/* YOLO Mode Toggle */} +
+
+
+ +

+ Skip testing for rapid prototyping +

+
+ +
+
+ + {/* Model Selection - Radio Group */} +
+ +
+ {models.map((model) => ( + + ))} +
+
+ + {/* Update Error */} + {updateSettings.isError && ( +
+ Failed to save settings. Please try again. +
+ )} +
+ )} +
+
+ ) +} diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts index 0cb61fa..6a1098f 100644 --- a/ui/src/hooks/useProjects.ts +++ b/ui/src/hooks/useProjects.ts @@ -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']) + + // Optimistically update + queryClient.setQueryData(['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'] }) + }, + }) +} diff --git a/ui/src/hooks/useSpecChat.ts b/ui/src/hooks/useSpecChat.ts index 7d9fd4b..b2bac62 100644 --- a/ui/src/hooks/useSpecChat.ts +++ b/ui/src/hooks/useSpecChat.ts @@ -33,7 +33,7 @@ function generateId(): string { export function useSpecChat({ projectName, - onComplete, + // onComplete intentionally not used - user clicks "Continue to Project" button instead onError, }: UseSpecChatOptions): UseSpecChatReturn { const [messages, setMessages] = useState([]) @@ -346,7 +346,7 @@ export function useSpecChat({ console.error('Failed to parse WebSocket message:', e) } } - }, [projectName, onComplete, onError]) + }, [projectName, onError]) const start = useCallback(() => { connect() diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 83cf1e5..dea0979 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -18,6 +18,9 @@ import type { PathValidationResponse, AssistantConversation, AssistantConversationDetail, + Settings, + SettingsUpdate, + ModelsResponse, } from './types' const API_BASE = '/api' @@ -279,3 +282,22 @@ export async function deleteAssistantConversation( { method: 'DELETE' } ) } + +// ============================================================================ +// Settings API +// ============================================================================ + +export async function getAvailableModels(): Promise { + return fetchJSON('/settings/models') +} + +export async function getSettings(): Promise { + return fetchJSON('/settings') +} + +export async function updateSettings(settings: SettingsUpdate): Promise { + return fetchJSON('/settings', { + method: 'PATCH', + body: JSON.stringify(settings), + }) +} diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 29931a0..c4d7812 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -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 { @@ -328,4 +329,25 @@ export interface FeatureBulkCreate { export interface FeatureBulkCreateResponse { created: number features: Feature[] +// 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 } diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css index a1047a2..274cb2f 100644 --- a/ui/src/styles/globals.css +++ b/ui/src/styles/globals.css @@ -163,6 +163,31 @@ transform: none; } + /* YOLO Mode Button - Animated fire effect for when YOLO mode is enabled */ + .neo-btn-yolo { + background: linear-gradient( + 0deg, + #8b0000 0%, + #d64500 30%, + #ff6a00 60%, + #ffa500 100% + ); + background-size: 100% 200%; + color: #ffffff; + animation: fireGlow 0.8s ease-in-out infinite, fireGradient 1.5s ease-in-out infinite; + } + + .neo-btn-yolo:hover { + background: linear-gradient( + 0deg, + #a00000 0%, + #e65c00 30%, + #ff7800 60%, + #ffb700 100% + ); + background-size: 100% 200%; + } + /* Inputs */ .neo-input { width: 100%; @@ -354,6 +379,42 @@ } } +@keyframes fireGlow { + 0%, 100% { + box-shadow: + 4px 4px 0 var(--color-neo-border), + 0 0 10px rgba(255, 100, 0, 0.5), + 0 0 20px rgba(255, 60, 0, 0.3); + } + 25% { + box-shadow: + 4px 4px 0 var(--color-neo-border), + 0 0 15px rgba(255, 80, 0, 0.6), + 0 0 30px rgba(255, 40, 0, 0.4); + } + 50% { + box-shadow: + 4px 4px 0 var(--color-neo-border), + 0 0 12px rgba(255, 120, 0, 0.7), + 0 0 25px rgba(255, 50, 0, 0.5); + } + 75% { + box-shadow: + 4px 4px 0 var(--color-neo-border), + 0 0 18px rgba(255, 70, 0, 0.55), + 0 0 35px rgba(255, 30, 0, 0.35); + } +} + +@keyframes fireGradient { + 0%, 100% { + background-position: 0% 100%; + } + 50% { + background-position: 100% 0%; + } +} + /* ============================================================================ Utilities Layer ============================================================================ */ diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index 8bf9c84..fd98d1f 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file