Merge branch 'master' into feature/expand-project-with-ai

This commit is contained in:
Leon van Zyl
2026-01-10 10:22:12 +02:00
committed by GitHub
23 changed files with 985 additions and 183 deletions

View File

@@ -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

View File

@@ -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:

View File

@@ -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 {}

View File

@@ -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)
# ============================================================================

View File

@@ -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",
]

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

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 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

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

View File

@@ -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..."

View File

@@ -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
)

View File

@@ -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..."

28
ui/eslint.config.js Normal file
View File

@@ -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 },
],
},
},
)

View File

@@ -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() {
<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>
@@ -311,6 +329,11 @@ function App() {
/>
</>
)}
{/* Settings Modal */}
{showSettings && (
<SettingsModal onClose={() => setShowSettings(false)} />
)}
</div>
)
}

View File

@@ -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 (
<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>
<div className="flex items-center">
{isStopped ? (
<button
onClick={handleStart}
disabled={isLoading}
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" />
) : (
<Play size={18} />
)}
</button>
) : (
<button
onClick={handleStop}
disabled={isLoading}
className={`neo-btn text-sm py-2 px-3 ${
yoloMode ? 'neo-btn-yolo' : 'neo-btn-danger'
}`}
title={yoloMode ? 'Stop Agent (YOLO Mode)' : 'Stop Agent'}
aria-label={yoloMode ? 'Stop Agent in YOLO Mode' : 'Stop Agent'}
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Square size={18} />
)}
</button>
)}
{/* Control Buttons */}
<div className="flex gap-1">
{status === 'stopped' || status === 'crashed' ? (
<>
{/* YOLO Toggle - only shown when stopped */}
<button
onClick={() => setYoloEnabled(!yoloEnabled)}
className={`neo-btn text-sm py-2 px-3 ${
yoloEnabled ? 'neo-btn-warning' : 'neo-btn-secondary'
}`}
title="YOLO Mode: Skip testing for rapid prototyping"
>
<Zap size={18} className={yoloEnabled ? 'text-yellow-900' : ''} />
</button>
<button
onClick={handleStart}
disabled={isLoading}
className="neo-btn neo-btn-success text-sm py-2 px-3"
title={yoloEnabled ? "Start Agent (YOLO Mode)" : "Start Agent"}
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Play size={18} />
)}
</button>
</>
) : status === 'running' ? (
<>
<button
onClick={handlePause}
disabled={isLoading}
className="neo-btn neo-btn-warning text-sm py-2 px-3"
title="Pause Agent"
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Pause size={18} />
)}
</button>
<button
onClick={handleStop}
disabled={isLoading}
className="neo-btn neo-btn-danger text-sm py-2 px-3"
title="Stop Agent"
>
<Square size={18} />
</button>
</>
) : status === 'paused' ? (
<>
<button
onClick={handleResume}
disabled={isLoading}
className="neo-btn neo-btn-success text-sm py-2 px-3"
title="Resume Agent"
>
{isLoading ? (
<Loader2 size={18} className="animate-spin" />
) : (
<Play size={18} />
)}
</button>
<button
onClick={handleStop}
disabled={isLoading}
className="neo-btn neo-btn-danger text-sm py-2 px-3"
title="Stop Agent"
>
<Square size={18} />
</button>
</>
) : null}
</div>
</div>
)
}
function StatusIndicator({ status }: { status: AgentStatus }) {
const statusConfig = {
stopped: {
color: 'var(--color-neo-text-secondary)',
label: 'Stopped',
pulse: false,
},
running: {
color: 'var(--color-neo-done)',
label: 'Running',
pulse: true,
},
paused: {
color: 'var(--color-neo-pending)',
label: 'Paused',
pulse: false,
},
crashed: {
color: 'var(--color-neo-danger)',
label: 'Crashed',
pulse: true,
},
}
const config = statusConfig[status]
return (
<div className="flex items-center gap-2 px-3 py-2 bg-white border-3 border-[var(--color-neo-border)]">
<span
className={`w-3 h-3 rounded-full ${config.pulse ? 'animate-pulse' : ''}`}
style={{ backgroundColor: config.color }}
/>
<span
className="font-display font-bold text-sm uppercase"
style={{ color: config.color }}
>
{config.label}
</span>
</div>
)
}

View File

@@ -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

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

View File

@@ -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'] })
},
})
}

View File

@@ -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<ChatMessage[]>([])
@@ -346,7 +346,7 @@ export function useSpecChat({
console.error('Failed to parse WebSocket message:', e)
}
}
}, [projectName, onComplete, onError])
}, [projectName, onError])
const start = useCallback(() => {
connect()

View File

@@ -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<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),
})
}

View File

@@ -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
}

View File

@@ -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
============================================================================ */

View File

@@ -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"}