mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
basic ui
This commit is contained in:
131
.gitignore
vendored
131
.gitignore
vendored
@@ -3,8 +3,133 @@ generations/
|
||||
|
||||
# Log files
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
venv/
|
||||
.env
|
||||
# ===================
|
||||
# Node.js
|
||||
# ===================
|
||||
node_modules/
|
||||
.npm
|
||||
.yarn-integrity
|
||||
.node_repl_history
|
||||
|
||||
# ===================
|
||||
# Python
|
||||
# ===================
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
eggs/
|
||||
.eggs/
|
||||
*.manifest
|
||||
*.spec
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Virtual environments
|
||||
venv/
|
||||
.venv/
|
||||
ENV/
|
||||
env/
|
||||
.env.bak/
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
|
||||
# Unit test / coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
coverage/
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
nosetests.xml
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# ===================
|
||||
# IDE / Editors
|
||||
# ===================
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
.spyderproject
|
||||
.spyproject
|
||||
.ropeproject
|
||||
|
||||
# ===================
|
||||
# OS generated files
|
||||
# ===================
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# ===================
|
||||
# Build outputs
|
||||
# ===================
|
||||
ui/dist/
|
||||
ui/.vite/
|
||||
.vite/
|
||||
|
||||
# ===================
|
||||
# Environment files
|
||||
# ===================
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.*.local
|
||||
|
||||
# ===================
|
||||
# Cache directories
|
||||
# ===================
|
||||
.cache/
|
||||
.parcel-cache/
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
|
||||
# ===================
|
||||
# Lock files (except package-lock.json)
|
||||
# ===================
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
poetry.lock
|
||||
Pipfile.lock
|
||||
|
||||
# ===================
|
||||
# Misc
|
||||
# ===================
|
||||
*.bak
|
||||
*.tmp
|
||||
*.temp
|
||||
.tmp/
|
||||
.temp/
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
claude-agent-sdk>=0.1.0
|
||||
python-dotenv>=1.0.0
|
||||
sqlalchemy>=2.0.0
|
||||
fastapi>=0.115.0
|
||||
uvicorn[standard]>=0.32.0
|
||||
websockets>=13.0
|
||||
python-multipart>=0.0.17
|
||||
psutil>=6.0.0
|
||||
aiofiles>=24.0.0
|
||||
|
||||
8
server/__init__.py
Normal file
8
server/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
FastAPI Backend Server
|
||||
======================
|
||||
|
||||
Web UI server for the Autonomous Coding Agent.
|
||||
Provides REST API and WebSocket endpoints for project management,
|
||||
feature tracking, and agent control.
|
||||
"""
|
||||
171
server/main.py
Normal file
171
server/main.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
FastAPI Main Application
|
||||
========================
|
||||
|
||||
Main entry point for the Autonomous Coding UI server.
|
||||
Provides REST API, WebSocket, and static file serving.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, WebSocket, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from .routers import projects_router, features_router, agent_router
|
||||
from .websocket import project_websocket
|
||||
from .services.process_manager import cleanup_all_managers
|
||||
from .schemas import SetupStatus
|
||||
|
||||
|
||||
# Paths
|
||||
ROOT_DIR = Path(__file__).parent.parent
|
||||
UI_DIST_DIR = ROOT_DIR / "ui" / "dist"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Lifespan context manager for startup and shutdown."""
|
||||
# Startup
|
||||
yield
|
||||
# Shutdown - cleanup all running agents
|
||||
await cleanup_all_managers()
|
||||
|
||||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Autonomous Coding UI",
|
||||
description="Web UI for the Autonomous Coding Agent",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS - allow only localhost origins for security
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=[
|
||||
"http://localhost:5173", # Vite dev server
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:8000", # Production
|
||||
"http://127.0.0.1:8000",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Security Middleware
|
||||
# ============================================================================
|
||||
|
||||
@app.middleware("http")
|
||||
async def require_localhost(request: Request, call_next):
|
||||
"""Only allow requests from localhost."""
|
||||
client_host = request.client.host if request.client else None
|
||||
|
||||
# Allow localhost connections
|
||||
if client_host not in ("127.0.0.1", "::1", "localhost", None):
|
||||
raise HTTPException(status_code=403, detail="Localhost access only")
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Include Routers
|
||||
# ============================================================================
|
||||
|
||||
app.include_router(projects_router)
|
||||
app.include_router(features_router)
|
||||
app.include_router(agent_router)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WebSocket Endpoint
|
||||
# ============================================================================
|
||||
|
||||
@app.websocket("/ws/projects/{project_name}")
|
||||
async def websocket_endpoint(websocket: WebSocket, project_name: str):
|
||||
"""WebSocket endpoint for real-time project updates."""
|
||||
await project_websocket(websocket, project_name)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Setup & Health Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "healthy"}
|
||||
|
||||
|
||||
@app.get("/api/setup/status", response_model=SetupStatus)
|
||||
async def setup_status():
|
||||
"""Check system setup status."""
|
||||
# Check for Claude CLI
|
||||
claude_cli = shutil.which("claude") is not None
|
||||
|
||||
# Check for credentials file
|
||||
credentials_path = Path.home() / ".claude" / ".credentials.json"
|
||||
credentials = credentials_path.exists()
|
||||
|
||||
# Check for Node.js and npm
|
||||
node = shutil.which("node") is not None
|
||||
npm = shutil.which("npm") is not None
|
||||
|
||||
return SetupStatus(
|
||||
claude_cli=claude_cli,
|
||||
credentials=credentials,
|
||||
node=node,
|
||||
npm=npm,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Static File Serving (Production)
|
||||
# ============================================================================
|
||||
|
||||
# Serve React build files if they exist
|
||||
if UI_DIST_DIR.exists():
|
||||
# Mount static assets
|
||||
app.mount("/assets", StaticFiles(directory=UI_DIST_DIR / "assets"), name="assets")
|
||||
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
"""Serve the React app index.html."""
|
||||
return FileResponse(UI_DIST_DIR / "index.html")
|
||||
|
||||
@app.get("/{path:path}")
|
||||
async def serve_spa(path: str):
|
||||
"""
|
||||
Serve static files or fall back to index.html for SPA routing.
|
||||
"""
|
||||
# Check if the path is an API route (shouldn't hit this due to router ordering)
|
||||
if path.startswith("api/") or path.startswith("ws/"):
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
# Try to serve the file directly
|
||||
file_path = UI_DIST_DIR / path
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
|
||||
# Fall back to index.html for SPA routing
|
||||
return FileResponse(UI_DIST_DIR / "index.html")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(
|
||||
"server.main:app",
|
||||
host="127.0.0.1", # Localhost only for security
|
||||
port=8000,
|
||||
reload=True,
|
||||
)
|
||||
12
server/routers/__init__.py
Normal file
12
server/routers/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
API Routers
|
||||
===========
|
||||
|
||||
FastAPI routers for different API endpoints.
|
||||
"""
|
||||
|
||||
from .projects import router as projects_router
|
||||
from .features import router as features_router
|
||||
from .agent import router as agent_router
|
||||
|
||||
__all__ = ["projects_router", "features_router", "agent_router"]
|
||||
128
server/routers/agent.py
Normal file
128
server/routers/agent.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Agent Router
|
||||
============
|
||||
|
||||
API endpoints for agent control (start/stop/pause/resume).
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..schemas import AgentStatus, AgentActionResponse
|
||||
from ..services.process_manager import get_manager
|
||||
|
||||
# Lazy import to avoid sys.path manipulation at module level
|
||||
_GENERATIONS_DIR = None
|
||||
|
||||
|
||||
def _get_generations_dir():
|
||||
"""Lazy import of GENERATIONS_DIR."""
|
||||
global _GENERATIONS_DIR
|
||||
if _GENERATIONS_DIR is None:
|
||||
import sys
|
||||
root = Path(__file__).parent.parent.parent
|
||||
if str(root) not in sys.path:
|
||||
sys.path.insert(0, str(root))
|
||||
from start import GENERATIONS_DIR
|
||||
_GENERATIONS_DIR = GENERATIONS_DIR
|
||||
return _GENERATIONS_DIR
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
|
||||
|
||||
# Root directory for process manager
|
||||
ROOT_DIR = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
def validate_project_name(name: str) -> str:
|
||||
"""Validate and sanitize project name to prevent path traversal."""
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid project name"
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def get_project_manager(project_name: str):
|
||||
"""Get the process manager for a project."""
|
||||
project_name = validate_project_name(project_name)
|
||||
project_dir = _get_generations_dir() / project_name
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
|
||||
|
||||
return get_manager(project_name, ROOT_DIR)
|
||||
|
||||
|
||||
@router.get("/status", response_model=AgentStatus)
|
||||
async def get_agent_status(project_name: str):
|
||||
"""Get the current status of the agent for a project."""
|
||||
manager = get_project_manager(project_name)
|
||||
|
||||
# Run healthcheck to detect crashed processes
|
||||
await manager.healthcheck()
|
||||
|
||||
return AgentStatus(
|
||||
status=manager.status,
|
||||
pid=manager.pid,
|
||||
started_at=manager.started_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/start", response_model=AgentActionResponse)
|
||||
async def start_agent(project_name: str):
|
||||
"""Start the agent for a project."""
|
||||
manager = get_project_manager(project_name)
|
||||
|
||||
success, message = await manager.start()
|
||||
|
||||
return AgentActionResponse(
|
||||
success=success,
|
||||
status=manager.status,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stop", response_model=AgentActionResponse)
|
||||
async def stop_agent(project_name: str):
|
||||
"""Stop the agent for a project."""
|
||||
manager = get_project_manager(project_name)
|
||||
|
||||
success, message = await manager.stop()
|
||||
|
||||
return AgentActionResponse(
|
||||
success=success,
|
||||
status=manager.status,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/pause", response_model=AgentActionResponse)
|
||||
async def pause_agent(project_name: str):
|
||||
"""Pause the agent for a project."""
|
||||
manager = get_project_manager(project_name)
|
||||
|
||||
success, message = await manager.pause()
|
||||
|
||||
return AgentActionResponse(
|
||||
success=success,
|
||||
status=manager.status,
|
||||
message=message,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/resume", response_model=AgentActionResponse)
|
||||
async def resume_agent(project_name: str):
|
||||
"""Resume a paused agent."""
|
||||
manager = get_project_manager(project_name)
|
||||
|
||||
success, message = await manager.resume()
|
||||
|
||||
return AgentActionResponse(
|
||||
success=success,
|
||||
status=manager.status,
|
||||
message=message,
|
||||
)
|
||||
282
server/routers/features.py
Normal file
282
server/routers/features.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Features Router
|
||||
===============
|
||||
|
||||
API endpoints for feature/test case management.
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from contextlib import contextmanager
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..schemas import (
|
||||
FeatureCreate,
|
||||
FeatureResponse,
|
||||
FeatureListResponse,
|
||||
)
|
||||
|
||||
# Lazy imports to avoid circular dependencies
|
||||
_GENERATIONS_DIR = None
|
||||
_create_database = None
|
||||
_Feature = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_generations_dir():
|
||||
"""Lazy import of GENERATIONS_DIR."""
|
||||
global _GENERATIONS_DIR
|
||||
if _GENERATIONS_DIR is None:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
root = Path(__file__).parent.parent.parent
|
||||
if str(root) not in sys.path:
|
||||
sys.path.insert(0, str(root))
|
||||
from start import GENERATIONS_DIR
|
||||
_GENERATIONS_DIR = GENERATIONS_DIR
|
||||
return _GENERATIONS_DIR
|
||||
|
||||
|
||||
def _get_db_classes():
|
||||
"""Lazy import of database classes."""
|
||||
global _create_database, _Feature
|
||||
if _create_database is None:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
root = Path(__file__).parent.parent.parent
|
||||
if str(root) not in sys.path:
|
||||
sys.path.insert(0, str(root))
|
||||
from api.database import create_database, Feature
|
||||
_create_database = create_database
|
||||
_Feature = Feature
|
||||
return _create_database, _Feature
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_name}/features", tags=["features"])
|
||||
|
||||
|
||||
def validate_project_name(name: str) -> str:
|
||||
"""Validate and sanitize project name to prevent path traversal."""
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid project name"
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_session(project_dir: Path):
|
||||
"""
|
||||
Context manager for database sessions.
|
||||
Ensures session is always closed, even on exceptions.
|
||||
"""
|
||||
create_database, _ = _get_db_classes()
|
||||
_, SessionLocal = create_database(project_dir)
|
||||
session = SessionLocal()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def feature_to_response(f) -> FeatureResponse:
|
||||
"""Convert a Feature model to a FeatureResponse."""
|
||||
return FeatureResponse(
|
||||
id=f.id,
|
||||
priority=f.priority,
|
||||
category=f.category,
|
||||
name=f.name,
|
||||
description=f.description,
|
||||
steps=f.steps if isinstance(f.steps, list) else [],
|
||||
passes=f.passes,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=FeatureListResponse)
|
||||
async def list_features(project_name: str):
|
||||
"""
|
||||
List all features for a project organized by status.
|
||||
|
||||
Returns features in three lists:
|
||||
- pending: passes=False, not currently being worked on
|
||||
- in_progress: features currently being worked on (tracked via agent output)
|
||||
- done: passes=True
|
||||
"""
|
||||
project_name = validate_project_name(project_name)
|
||||
project_dir = _get_generations_dir() / project_name
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
|
||||
|
||||
db_file = project_dir / "features.db"
|
||||
if not db_file.exists():
|
||||
return FeatureListResponse(pending=[], in_progress=[], done=[])
|
||||
|
||||
_, Feature = _get_db_classes()
|
||||
|
||||
try:
|
||||
with get_db_session(project_dir) as session:
|
||||
all_features = session.query(Feature).order_by(Feature.priority).all()
|
||||
|
||||
pending = []
|
||||
done = []
|
||||
|
||||
for f in all_features:
|
||||
feature_response = feature_to_response(f)
|
||||
if f.passes:
|
||||
done.append(feature_response)
|
||||
else:
|
||||
pending.append(feature_response)
|
||||
|
||||
return FeatureListResponse(
|
||||
pending=pending,
|
||||
in_progress=[],
|
||||
done=done,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Database error in list_features")
|
||||
raise HTTPException(status_code=500, detail="Database error occurred")
|
||||
|
||||
|
||||
@router.post("", response_model=FeatureResponse)
|
||||
async def create_feature(project_name: str, feature: FeatureCreate):
|
||||
"""Create a new feature/test case manually."""
|
||||
project_name = validate_project_name(project_name)
|
||||
project_dir = _get_generations_dir() / project_name
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
|
||||
|
||||
_, Feature = _get_db_classes()
|
||||
|
||||
try:
|
||||
with get_db_session(project_dir) as session:
|
||||
# Get next priority if not specified
|
||||
if feature.priority is None:
|
||||
max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
|
||||
priority = (max_priority.priority + 1) if max_priority else 1
|
||||
else:
|
||||
priority = feature.priority
|
||||
|
||||
# Create new feature
|
||||
db_feature = Feature(
|
||||
priority=priority,
|
||||
category=feature.category,
|
||||
name=feature.name,
|
||||
description=feature.description,
|
||||
steps=feature.steps,
|
||||
passes=False,
|
||||
)
|
||||
|
||||
session.add(db_feature)
|
||||
session.commit()
|
||||
session.refresh(db_feature)
|
||||
|
||||
return feature_to_response(db_feature)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create feature")
|
||||
raise HTTPException(status_code=500, detail="Failed to create feature")
|
||||
|
||||
|
||||
@router.get("/{feature_id}", response_model=FeatureResponse)
|
||||
async def get_feature(project_name: str, feature_id: int):
|
||||
"""Get details of a specific feature."""
|
||||
project_name = validate_project_name(project_name)
|
||||
project_dir = _get_generations_dir() / project_name
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
|
||||
|
||||
db_file = project_dir / "features.db"
|
||||
if not db_file.exists():
|
||||
raise HTTPException(status_code=404, detail="No features database found")
|
||||
|
||||
_, Feature = _get_db_classes()
|
||||
|
||||
try:
|
||||
with get_db_session(project_dir) as session:
|
||||
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||
|
||||
if not feature:
|
||||
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
||||
|
||||
return feature_to_response(feature)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Database error in get_feature")
|
||||
raise HTTPException(status_code=500, detail="Database error occurred")
|
||||
|
||||
|
||||
@router.delete("/{feature_id}")
|
||||
async def delete_feature(project_name: str, feature_id: int):
|
||||
"""Delete a feature."""
|
||||
project_name = validate_project_name(project_name)
|
||||
project_dir = _get_generations_dir() / project_name
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
|
||||
|
||||
_, Feature = _get_db_classes()
|
||||
|
||||
try:
|
||||
with get_db_session(project_dir) as session:
|
||||
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||
|
||||
if not feature:
|
||||
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
||||
|
||||
session.delete(feature)
|
||||
session.commit()
|
||||
|
||||
return {"success": True, "message": f"Feature {feature_id} deleted"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Failed to delete feature")
|
||||
raise HTTPException(status_code=500, detail="Failed to delete feature")
|
||||
|
||||
|
||||
@router.patch("/{feature_id}/skip")
|
||||
async def skip_feature(project_name: str, feature_id: int):
|
||||
"""
|
||||
Mark a feature as skipped by moving it to the end of the priority queue.
|
||||
|
||||
This doesn't delete the feature but gives it a very high priority number
|
||||
so it will be processed last.
|
||||
"""
|
||||
project_name = validate_project_name(project_name)
|
||||
project_dir = _get_generations_dir() / project_name
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found")
|
||||
|
||||
_, Feature = _get_db_classes()
|
||||
|
||||
try:
|
||||
with get_db_session(project_dir) as session:
|
||||
feature = session.query(Feature).filter(Feature.id == feature_id).first()
|
||||
|
||||
if not feature:
|
||||
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
|
||||
|
||||
# Set priority to max + 1000 to push to end
|
||||
max_priority = session.query(Feature).order_by(Feature.priority.desc()).first()
|
||||
feature.priority = (max_priority.priority if max_priority else 0) + 1000
|
||||
|
||||
session.commit()
|
||||
|
||||
return {"success": True, "message": f"Feature {feature_id} moved to end of queue"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Failed to skip feature")
|
||||
raise HTTPException(status_code=500, detail="Failed to skip feature")
|
||||
239
server/routers/projects.py
Normal file
239
server/routers/projects.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Projects Router
|
||||
===============
|
||||
|
||||
API endpoints for project management.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from ..schemas import (
|
||||
ProjectCreate,
|
||||
ProjectSummary,
|
||||
ProjectDetail,
|
||||
ProjectPrompts,
|
||||
ProjectPromptsUpdate,
|
||||
ProjectStats,
|
||||
)
|
||||
|
||||
# Lazy imports to avoid sys.path manipulation at module level
|
||||
_imports_initialized = False
|
||||
_GENERATIONS_DIR = None
|
||||
_get_existing_projects = None
|
||||
_check_spec_exists = None
|
||||
_scaffold_project_prompts = None
|
||||
_get_project_prompts_dir = None
|
||||
_count_passing_tests = None
|
||||
|
||||
|
||||
def _init_imports():
|
||||
"""Lazy import of project-level modules."""
|
||||
global _imports_initialized, _GENERATIONS_DIR, _get_existing_projects
|
||||
global _check_spec_exists, _scaffold_project_prompts, _get_project_prompts_dir
|
||||
global _count_passing_tests
|
||||
|
||||
if _imports_initialized:
|
||||
return
|
||||
|
||||
import sys
|
||||
root = Path(__file__).parent.parent.parent
|
||||
if str(root) not in sys.path:
|
||||
sys.path.insert(0, str(root))
|
||||
|
||||
from start import GENERATIONS_DIR, get_existing_projects, check_spec_exists
|
||||
from prompts import scaffold_project_prompts, get_project_prompts_dir
|
||||
from progress import count_passing_tests
|
||||
|
||||
_GENERATIONS_DIR = GENERATIONS_DIR
|
||||
_get_existing_projects = get_existing_projects
|
||||
_check_spec_exists = check_spec_exists
|
||||
_scaffold_project_prompts = scaffold_project_prompts
|
||||
_get_project_prompts_dir = get_project_prompts_dir
|
||||
_count_passing_tests = count_passing_tests
|
||||
_imports_initialized = True
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
||||
|
||||
|
||||
def validate_project_name(name: str) -> str:
|
||||
"""Validate and sanitize project name to prevent path traversal."""
|
||||
if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid project name. Use only letters, numbers, hyphens, and underscores (1-50 chars)."
|
||||
)
|
||||
return name
|
||||
|
||||
|
||||
def get_project_stats(project_dir: Path) -> ProjectStats:
|
||||
"""Get statistics for a project."""
|
||||
_init_imports()
|
||||
passing, total = _count_passing_tests(project_dir)
|
||||
percentage = (passing / total * 100) if total > 0 else 0.0
|
||||
return ProjectStats(passing=passing, total=total, percentage=round(percentage, 1))
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProjectSummary])
|
||||
async def list_projects():
|
||||
"""List all projects in the generations directory."""
|
||||
_init_imports()
|
||||
projects = _get_existing_projects()
|
||||
result = []
|
||||
|
||||
for name in projects:
|
||||
project_dir = _GENERATIONS_DIR / name
|
||||
has_spec = _check_spec_exists(project_dir)
|
||||
stats = get_project_stats(project_dir)
|
||||
|
||||
result.append(ProjectSummary(
|
||||
name=name,
|
||||
has_spec=has_spec,
|
||||
stats=stats,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectSummary)
|
||||
async def create_project(project: ProjectCreate):
|
||||
"""Create a new project with scaffolded prompts."""
|
||||
_init_imports()
|
||||
name = validate_project_name(project.name)
|
||||
|
||||
project_dir = _GENERATIONS_DIR / name
|
||||
|
||||
if project_dir.exists():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Project '{name}' already exists"
|
||||
)
|
||||
|
||||
# Create project directory
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Scaffold prompts
|
||||
_scaffold_project_prompts(project_dir)
|
||||
|
||||
return ProjectSummary(
|
||||
name=name,
|
||||
has_spec=False, # Just created, no spec yet
|
||||
stats=ProjectStats(passing=0, total=0, percentage=0.0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=ProjectDetail)
|
||||
async def get_project(name: str):
|
||||
"""Get detailed information about a project."""
|
||||
_init_imports()
|
||||
name = validate_project_name(name)
|
||||
project_dir = _GENERATIONS_DIR / name
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
||||
|
||||
has_spec = _check_spec_exists(project_dir)
|
||||
stats = get_project_stats(project_dir)
|
||||
prompts_dir = _get_project_prompts_dir(project_dir)
|
||||
|
||||
return ProjectDetail(
|
||||
name=name,
|
||||
has_spec=has_spec,
|
||||
stats=stats,
|
||||
prompts_dir=str(prompts_dir),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{name}")
|
||||
async def delete_project(name: str):
|
||||
"""Delete a project and all its files."""
|
||||
_init_imports()
|
||||
name = validate_project_name(name)
|
||||
project_dir = _GENERATIONS_DIR / name
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
||||
|
||||
# Check if agent is running
|
||||
lock_file = project_dir / ".agent.lock"
|
||||
if lock_file.exists():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot delete project while agent is running. Stop the agent first."
|
||||
)
|
||||
|
||||
try:
|
||||
shutil.rmtree(project_dir)
|
||||
return {"success": True, "message": f"Project '{name}' deleted"}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete project: {e}")
|
||||
|
||||
|
||||
@router.get("/{name}/prompts", response_model=ProjectPrompts)
|
||||
async def get_project_prompts(name: str):
|
||||
"""Get the content of project prompt files."""
|
||||
_init_imports()
|
||||
name = validate_project_name(name)
|
||||
project_dir = _GENERATIONS_DIR / name
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
||||
|
||||
prompts_dir = _get_project_prompts_dir(project_dir)
|
||||
|
||||
def read_file(filename: str) -> str:
|
||||
filepath = prompts_dir / filename
|
||||
if filepath.exists():
|
||||
try:
|
||||
return filepath.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
return ProjectPrompts(
|
||||
app_spec=read_file("app_spec.txt"),
|
||||
initializer_prompt=read_file("initializer_prompt.md"),
|
||||
coding_prompt=read_file("coding_prompt.md"),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{name}/prompts")
|
||||
async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
|
||||
"""Update project prompt files."""
|
||||
_init_imports()
|
||||
name = validate_project_name(name)
|
||||
project_dir = _GENERATIONS_DIR / name
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
||||
|
||||
prompts_dir = _get_project_prompts_dir(project_dir)
|
||||
prompts_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def write_file(filename: str, content: str | None):
|
||||
if content is not None:
|
||||
filepath = prompts_dir / filename
|
||||
filepath.write_text(content, encoding="utf-8")
|
||||
|
||||
write_file("app_spec.txt", prompts.app_spec)
|
||||
write_file("initializer_prompt.md", prompts.initializer_prompt)
|
||||
write_file("coding_prompt.md", prompts.coding_prompt)
|
||||
|
||||
return {"success": True, "message": "Prompts updated"}
|
||||
|
||||
|
||||
@router.get("/{name}/stats", response_model=ProjectStats)
|
||||
async def get_project_stats_endpoint(name: str):
|
||||
"""Get current progress statistics for a project."""
|
||||
_init_imports()
|
||||
name = validate_project_name(name)
|
||||
project_dir = _GENERATIONS_DIR / name
|
||||
|
||||
if not project_dir.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
|
||||
|
||||
return get_project_stats(project_dir)
|
||||
152
server/schemas.py
Normal file
152
server/schemas.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
Pydantic Schemas
|
||||
================
|
||||
|
||||
Request/Response models for the API endpoints.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Project Schemas
|
||||
# ============================================================================
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
"""Request schema for creating a new project."""
|
||||
name: str = Field(..., min_length=1, max_length=50, pattern=r'^[a-zA-Z0-9_-]+$')
|
||||
spec_method: Literal["claude", "manual"] = "claude"
|
||||
|
||||
|
||||
class ProjectStats(BaseModel):
|
||||
"""Project statistics."""
|
||||
passing: int = 0
|
||||
total: int = 0
|
||||
percentage: float = 0.0
|
||||
|
||||
|
||||
class ProjectSummary(BaseModel):
|
||||
"""Summary of a project for list view."""
|
||||
name: str
|
||||
has_spec: bool
|
||||
stats: ProjectStats
|
||||
|
||||
|
||||
class ProjectDetail(BaseModel):
|
||||
"""Detailed project information."""
|
||||
name: str
|
||||
has_spec: bool
|
||||
stats: ProjectStats
|
||||
prompts_dir: str
|
||||
|
||||
|
||||
class ProjectPrompts(BaseModel):
|
||||
"""Project prompt files content."""
|
||||
app_spec: str = ""
|
||||
initializer_prompt: str = ""
|
||||
coding_prompt: str = ""
|
||||
|
||||
|
||||
class ProjectPromptsUpdate(BaseModel):
|
||||
"""Request schema for updating project prompts."""
|
||||
app_spec: str | None = None
|
||||
initializer_prompt: str | None = None
|
||||
coding_prompt: str | None = None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Feature Schemas
|
||||
# ============================================================================
|
||||
|
||||
class FeatureBase(BaseModel):
|
||||
"""Base feature attributes."""
|
||||
category: str
|
||||
name: str
|
||||
description: str
|
||||
steps: list[str]
|
||||
|
||||
|
||||
class FeatureCreate(FeatureBase):
|
||||
"""Request schema for creating a new feature."""
|
||||
priority: int | None = None
|
||||
|
||||
|
||||
class FeatureResponse(FeatureBase):
|
||||
"""Response schema for a feature."""
|
||||
id: int
|
||||
priority: int
|
||||
passes: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class FeatureListResponse(BaseModel):
|
||||
"""Response containing list of features organized by status."""
|
||||
pending: list[FeatureResponse]
|
||||
in_progress: list[FeatureResponse]
|
||||
done: list[FeatureResponse]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Agent Schemas
|
||||
# ============================================================================
|
||||
|
||||
class AgentStatus(BaseModel):
|
||||
"""Current agent status."""
|
||||
status: Literal["stopped", "running", "paused", "crashed"]
|
||||
pid: int | None = None
|
||||
started_at: datetime | None = None
|
||||
|
||||
|
||||
class AgentActionResponse(BaseModel):
|
||||
"""Response for agent control actions."""
|
||||
success: bool
|
||||
status: str
|
||||
message: str = ""
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Setup Schemas
|
||||
# ============================================================================
|
||||
|
||||
class SetupStatus(BaseModel):
|
||||
"""System setup status."""
|
||||
claude_cli: bool
|
||||
credentials: bool
|
||||
node: bool
|
||||
npm: bool
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# WebSocket Message Schemas
|
||||
# ============================================================================
|
||||
|
||||
class WSProgressMessage(BaseModel):
|
||||
"""WebSocket message for progress updates."""
|
||||
type: Literal["progress"] = "progress"
|
||||
passing: int
|
||||
total: int
|
||||
percentage: float
|
||||
|
||||
|
||||
class WSFeatureUpdateMessage(BaseModel):
|
||||
"""WebSocket message for feature status updates."""
|
||||
type: Literal["feature_update"] = "feature_update"
|
||||
feature_id: int
|
||||
passes: bool
|
||||
|
||||
|
||||
class WSLogMessage(BaseModel):
|
||||
"""WebSocket message for agent log output."""
|
||||
type: Literal["log"] = "log"
|
||||
line: str
|
||||
timestamp: datetime
|
||||
|
||||
|
||||
class WSAgentStatusMessage(BaseModel):
|
||||
"""WebSocket message for agent status changes."""
|
||||
type: Literal["agent_status"] = "agent_status"
|
||||
status: str
|
||||
10
server/services/__init__.py
Normal file
10
server/services/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Backend Services
|
||||
================
|
||||
|
||||
Business logic and process management services.
|
||||
"""
|
||||
|
||||
from .process_manager import AgentProcessManager
|
||||
|
||||
__all__ = ["AgentProcessManager"]
|
||||
403
server/services/process_manager.py
Normal file
403
server/services/process_manager.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
Agent Process Manager
|
||||
=====================
|
||||
|
||||
Manages the lifecycle of agent subprocesses per project.
|
||||
Provides start/stop/pause/resume functionality with cross-platform support.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal, Callable, Awaitable, Set
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Patterns for sensitive data that should be redacted from output
|
||||
SENSITIVE_PATTERNS = [
|
||||
r'sk-[a-zA-Z0-9]{20,}', # Anthropic API keys
|
||||
r'ANTHROPIC_API_KEY=[^\s]+',
|
||||
r'api[_-]?key[=:][^\s]+',
|
||||
r'token[=:][^\s]+',
|
||||
r'password[=:][^\s]+',
|
||||
r'secret[=:][^\s]+',
|
||||
r'ghp_[a-zA-Z0-9]{36,}', # GitHub personal access tokens
|
||||
r'gho_[a-zA-Z0-9]{36,}', # GitHub OAuth tokens
|
||||
r'ghs_[a-zA-Z0-9]{36,}', # GitHub server tokens
|
||||
r'ghr_[a-zA-Z0-9]{36,}', # GitHub refresh tokens
|
||||
r'aws[_-]?access[_-]?key[=:][^\s]+', # AWS keys
|
||||
r'aws[_-]?secret[=:][^\s]+',
|
||||
]
|
||||
|
||||
|
||||
def sanitize_output(line: str) -> str:
|
||||
"""Remove sensitive information from output lines."""
|
||||
for pattern in SENSITIVE_PATTERNS:
|
||||
line = re.sub(pattern, '[REDACTED]', line, flags=re.IGNORECASE)
|
||||
return line
|
||||
|
||||
|
||||
class AgentProcessManager:
|
||||
"""
|
||||
Manages agent subprocess lifecycle for a single project.
|
||||
|
||||
Provides start/stop/pause/resume with cross-platform support via psutil.
|
||||
Supports multiple output callbacks for WebSocket clients.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_name: str,
|
||||
root_dir: Path,
|
||||
):
|
||||
"""
|
||||
Initialize the process manager.
|
||||
|
||||
Args:
|
||||
project_name: Name of the project
|
||||
root_dir: Root directory of the autonomous-coding-ui project
|
||||
"""
|
||||
self.project_name = project_name
|
||||
self.root_dir = root_dir
|
||||
self.process: subprocess.Popen | None = None
|
||||
self._status: Literal["stopped", "running", "paused", "crashed"] = "stopped"
|
||||
self.started_at: datetime | None = None
|
||||
self._output_task: asyncio.Task | None = None
|
||||
|
||||
# Support multiple callbacks (for multiple WebSocket clients)
|
||||
self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
|
||||
self._status_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
|
||||
self._callbacks_lock = threading.Lock()
|
||||
|
||||
# Lock file to prevent multiple instances
|
||||
self.lock_file = self.root_dir / "generations" / project_name / ".agent.lock"
|
||||
|
||||
@property
|
||||
def status(self) -> Literal["stopped", "running", "paused", "crashed"]:
|
||||
return self._status
|
||||
|
||||
@status.setter
|
||||
def status(self, value: Literal["stopped", "running", "paused", "crashed"]):
|
||||
old_status = self._status
|
||||
self._status = value
|
||||
if old_status != value:
|
||||
self._notify_status_change(value)
|
||||
|
||||
def _notify_status_change(self, status: str) -> None:
|
||||
"""Notify all registered callbacks of status change."""
|
||||
with self._callbacks_lock:
|
||||
callbacks = list(self._status_callbacks)
|
||||
|
||||
for callback in callbacks:
|
||||
try:
|
||||
# Schedule the callback in the event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.create_task(self._safe_callback(callback, status))
|
||||
except RuntimeError:
|
||||
# No running event loop
|
||||
pass
|
||||
|
||||
async def _safe_callback(self, callback: Callable, *args) -> None:
|
||||
"""Safely execute a callback, catching and logging any errors."""
|
||||
try:
|
||||
await callback(*args)
|
||||
except Exception as e:
|
||||
logger.warning(f"Callback error: {e}")
|
||||
|
||||
def add_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
|
||||
"""Add a callback for output lines."""
|
||||
with self._callbacks_lock:
|
||||
self._output_callbacks.add(callback)
|
||||
|
||||
def remove_output_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
|
||||
"""Remove an output callback."""
|
||||
with self._callbacks_lock:
|
||||
self._output_callbacks.discard(callback)
|
||||
|
||||
def add_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
|
||||
"""Add a callback for status changes."""
|
||||
with self._callbacks_lock:
|
||||
self._status_callbacks.add(callback)
|
||||
|
||||
def remove_status_callback(self, callback: Callable[[str], Awaitable[None]]) -> None:
|
||||
"""Remove a status callback."""
|
||||
with self._callbacks_lock:
|
||||
self._status_callbacks.discard(callback)
|
||||
|
||||
@property
|
||||
def pid(self) -> int | None:
|
||||
return self.process.pid if self.process else None
|
||||
|
||||
def _check_lock(self) -> bool:
|
||||
"""Check if another agent is already running for this project."""
|
||||
if not self.lock_file.exists():
|
||||
return True
|
||||
|
||||
try:
|
||||
pid = int(self.lock_file.read_text().strip())
|
||||
if psutil.pid_exists(pid):
|
||||
# Check if it's actually our agent process
|
||||
try:
|
||||
proc = psutil.Process(pid)
|
||||
cmdline = " ".join(proc.cmdline())
|
||||
if "autonomous_agent_demo.py" in cmdline:
|
||||
return False # Another agent is running
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
pass
|
||||
# Stale lock file
|
||||
self.lock_file.unlink(missing_ok=True)
|
||||
return True
|
||||
except (ValueError, OSError):
|
||||
self.lock_file.unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
def _create_lock(self) -> None:
|
||||
"""Create lock file with current process PID."""
|
||||
self.lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
if self.process:
|
||||
self.lock_file.write_text(str(self.process.pid))
|
||||
|
||||
def _remove_lock(self) -> None:
|
||||
"""Remove lock file."""
|
||||
self.lock_file.unlink(missing_ok=True)
|
||||
|
||||
async def _broadcast_output(self, line: str) -> None:
|
||||
"""Broadcast output line to all registered callbacks."""
|
||||
with self._callbacks_lock:
|
||||
callbacks = list(self._output_callbacks)
|
||||
|
||||
for callback in callbacks:
|
||||
await self._safe_callback(callback, line)
|
||||
|
||||
async def _stream_output(self) -> None:
|
||||
"""Stream process output to callbacks."""
|
||||
if not self.process or not self.process.stdout:
|
||||
return
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
# Use run_in_executor for blocking readline
|
||||
line = await loop.run_in_executor(
|
||||
None, self.process.stdout.readline
|
||||
)
|
||||
if not line:
|
||||
break
|
||||
|
||||
decoded = line.decode("utf-8", errors="replace").rstrip()
|
||||
sanitized = sanitize_output(decoded)
|
||||
|
||||
await self._broadcast_output(sanitized)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Output streaming error: {e}")
|
||||
finally:
|
||||
# Check if process ended
|
||||
if self.process and self.process.poll() is not None:
|
||||
exit_code = self.process.returncode
|
||||
if exit_code != 0 and self.status == "running":
|
||||
self.status = "crashed"
|
||||
elif self.status == "running":
|
||||
self.status = "stopped"
|
||||
self._remove_lock()
|
||||
|
||||
async def start(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Start the agent as a subprocess.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
if self.status in ("running", "paused"):
|
||||
return False, f"Agent is already {self.status}"
|
||||
|
||||
if not self._check_lock():
|
||||
return False, "Another agent instance is already running for this project"
|
||||
|
||||
# Build command
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(self.root_dir / "autonomous_agent_demo.py"),
|
||||
"--project-dir",
|
||||
self.project_name,
|
||||
]
|
||||
|
||||
try:
|
||||
# Start subprocess with piped stdout/stderr
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=str(self.root_dir),
|
||||
)
|
||||
|
||||
self._create_lock()
|
||||
self.started_at = datetime.now()
|
||||
self.status = "running"
|
||||
|
||||
# Start output streaming task
|
||||
self._output_task = asyncio.create_task(self._stream_output())
|
||||
|
||||
return True, f"Agent started with PID {self.process.pid}"
|
||||
except Exception as e:
|
||||
logger.exception("Failed to start agent")
|
||||
return False, f"Failed to start agent: {e}"
|
||||
|
||||
async def stop(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Stop the agent (SIGTERM then SIGKILL if needed).
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
if not self.process or self.status == "stopped":
|
||||
return False, "Agent is not running"
|
||||
|
||||
try:
|
||||
# Cancel output streaming
|
||||
if self._output_task:
|
||||
self._output_task.cancel()
|
||||
try:
|
||||
await self._output_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Terminate gracefully first
|
||||
self.process.terminate()
|
||||
|
||||
# Wait up to 5 seconds for graceful shutdown
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
loop.run_in_executor(None, self.process.wait),
|
||||
timeout=5.0
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Force kill if still running
|
||||
self.process.kill()
|
||||
await loop.run_in_executor(None, self.process.wait)
|
||||
|
||||
self._remove_lock()
|
||||
self.status = "stopped"
|
||||
self.process = None
|
||||
self.started_at = None
|
||||
|
||||
return True, "Agent stopped"
|
||||
except Exception as e:
|
||||
logger.exception("Failed to stop agent")
|
||||
return False, f"Failed to stop agent: {e}"
|
||||
|
||||
async def pause(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Pause the agent using psutil for cross-platform support.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
if not self.process or self.status != "running":
|
||||
return False, "Agent is not running"
|
||||
|
||||
try:
|
||||
proc = psutil.Process(self.process.pid)
|
||||
proc.suspend()
|
||||
self.status = "paused"
|
||||
return True, "Agent paused"
|
||||
except psutil.NoSuchProcess:
|
||||
self.status = "crashed"
|
||||
self._remove_lock()
|
||||
return False, "Agent process no longer exists"
|
||||
except Exception as e:
|
||||
logger.exception("Failed to pause agent")
|
||||
return False, f"Failed to pause agent: {e}"
|
||||
|
||||
async def resume(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Resume a paused agent.
|
||||
|
||||
Returns:
|
||||
Tuple of (success, message)
|
||||
"""
|
||||
if not self.process or self.status != "paused":
|
||||
return False, "Agent is not paused"
|
||||
|
||||
try:
|
||||
proc = psutil.Process(self.process.pid)
|
||||
proc.resume()
|
||||
self.status = "running"
|
||||
return True, "Agent resumed"
|
||||
except psutil.NoSuchProcess:
|
||||
self.status = "crashed"
|
||||
self._remove_lock()
|
||||
return False, "Agent process no longer exists"
|
||||
except Exception as e:
|
||||
logger.exception("Failed to resume agent")
|
||||
return False, f"Failed to resume agent: {e}"
|
||||
|
||||
async def healthcheck(self) -> bool:
|
||||
"""
|
||||
Check if the agent process is still alive.
|
||||
|
||||
Updates status to 'crashed' if process has died unexpectedly.
|
||||
|
||||
Returns:
|
||||
True if healthy, False otherwise
|
||||
"""
|
||||
if not self.process:
|
||||
return self.status == "stopped"
|
||||
|
||||
poll = self.process.poll()
|
||||
if poll is not None:
|
||||
# Process has terminated
|
||||
if self.status in ("running", "paused"):
|
||||
self.status = "crashed"
|
||||
self._remove_lock()
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def get_status_dict(self) -> dict:
|
||||
"""Get current status as a dictionary."""
|
||||
return {
|
||||
"status": self.status,
|
||||
"pid": self.pid,
|
||||
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||
}
|
||||
|
||||
|
||||
# Global registry of process managers per project with thread safety
|
||||
_managers: dict[str, AgentProcessManager] = {}
|
||||
_managers_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_manager(project_name: str, root_dir: Path) -> AgentProcessManager:
|
||||
"""Get or create a process manager for a project (thread-safe)."""
|
||||
with _managers_lock:
|
||||
if project_name not in _managers:
|
||||
_managers[project_name] = AgentProcessManager(project_name, root_dir)
|
||||
return _managers[project_name]
|
||||
|
||||
|
||||
async def cleanup_all_managers() -> None:
|
||||
"""Stop all running agents. Called on server shutdown."""
|
||||
with _managers_lock:
|
||||
managers = list(_managers.values())
|
||||
|
||||
for manager in managers:
|
||||
try:
|
||||
if manager.status != "stopped":
|
||||
await manager.stop()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error stopping manager for {manager.project_name}: {e}")
|
||||
|
||||
with _managers_lock:
|
||||
_managers.clear()
|
||||
248
server/websocket.py
Normal file
248
server/websocket.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""
|
||||
WebSocket Handlers
|
||||
==================
|
||||
|
||||
Real-time updates for project progress and agent output.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Set
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from .services.process_manager import get_manager
|
||||
|
||||
# Lazy imports
|
||||
_GENERATIONS_DIR = None
|
||||
_count_passing_tests = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_generations_dir():
|
||||
"""Lazy import of GENERATIONS_DIR."""
|
||||
global _GENERATIONS_DIR
|
||||
if _GENERATIONS_DIR is None:
|
||||
import sys
|
||||
root = Path(__file__).parent.parent
|
||||
if str(root) not in sys.path:
|
||||
sys.path.insert(0, str(root))
|
||||
from start import GENERATIONS_DIR
|
||||
_GENERATIONS_DIR = GENERATIONS_DIR
|
||||
return _GENERATIONS_DIR
|
||||
|
||||
|
||||
def _get_count_passing_tests():
|
||||
"""Lazy import of count_passing_tests."""
|
||||
global _count_passing_tests
|
||||
if _count_passing_tests is None:
|
||||
import sys
|
||||
root = Path(__file__).parent.parent
|
||||
if str(root) not in sys.path:
|
||||
sys.path.insert(0, str(root))
|
||||
from progress import count_passing_tests
|
||||
_count_passing_tests = count_passing_tests
|
||||
return _count_passing_tests
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages WebSocket connections per project."""
|
||||
|
||||
def __init__(self):
|
||||
# project_name -> set of WebSocket connections
|
||||
self.active_connections: dict[str, Set[WebSocket]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def connect(self, websocket: WebSocket, project_name: str):
|
||||
"""Accept a WebSocket connection for a project."""
|
||||
await websocket.accept()
|
||||
|
||||
async with self._lock:
|
||||
if project_name not in self.active_connections:
|
||||
self.active_connections[project_name] = set()
|
||||
self.active_connections[project_name].add(websocket)
|
||||
|
||||
async def disconnect(self, websocket: WebSocket, project_name: str):
|
||||
"""Remove a WebSocket connection."""
|
||||
async with self._lock:
|
||||
if project_name in self.active_connections:
|
||||
self.active_connections[project_name].discard(websocket)
|
||||
if not self.active_connections[project_name]:
|
||||
del self.active_connections[project_name]
|
||||
|
||||
async def broadcast_to_project(self, project_name: str, message: dict):
|
||||
"""Broadcast a message to all connections for a project."""
|
||||
async with self._lock:
|
||||
connections = list(self.active_connections.get(project_name, set()))
|
||||
|
||||
dead_connections = []
|
||||
|
||||
for connection in connections:
|
||||
try:
|
||||
await connection.send_json(message)
|
||||
except Exception:
|
||||
dead_connections.append(connection)
|
||||
|
||||
# Clean up dead connections
|
||||
if dead_connections:
|
||||
async with self._lock:
|
||||
for connection in dead_connections:
|
||||
if project_name in self.active_connections:
|
||||
self.active_connections[project_name].discard(connection)
|
||||
|
||||
def get_connection_count(self, project_name: str) -> int:
|
||||
"""Get number of active connections for a project."""
|
||||
return len(self.active_connections.get(project_name, set()))
|
||||
|
||||
|
||||
# Global connection manager
|
||||
manager = ConnectionManager()
|
||||
|
||||
# Root directory
|
||||
ROOT_DIR = Path(__file__).parent.parent
|
||||
|
||||
|
||||
def validate_project_name(name: str) -> bool:
|
||||
"""Validate project name to prevent path traversal."""
|
||||
return bool(re.match(r'^[a-zA-Z0-9_-]{1,50}$', name))
|
||||
|
||||
|
||||
async def poll_progress(websocket: WebSocket, project_name: str):
|
||||
"""Poll database for progress changes and send updates."""
|
||||
project_dir = _get_generations_dir() / project_name
|
||||
count_passing_tests = _get_count_passing_tests()
|
||||
last_passing = -1
|
||||
last_total = -1
|
||||
|
||||
while True:
|
||||
try:
|
||||
passing, total = count_passing_tests(project_dir)
|
||||
|
||||
# Only send if changed
|
||||
if passing != last_passing or total != last_total:
|
||||
last_passing = passing
|
||||
last_total = total
|
||||
percentage = (passing / total * 100) if total > 0 else 0
|
||||
|
||||
await websocket.send_json({
|
||||
"type": "progress",
|
||||
"passing": passing,
|
||||
"total": total,
|
||||
"percentage": round(percentage, 1),
|
||||
})
|
||||
|
||||
await asyncio.sleep(2) # Poll every 2 seconds
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Progress polling error: {e}")
|
||||
break
|
||||
|
||||
|
||||
async def project_websocket(websocket: WebSocket, project_name: str):
|
||||
"""
|
||||
WebSocket endpoint for project updates.
|
||||
|
||||
Streams:
|
||||
- Progress updates (passing/total counts)
|
||||
- Agent status changes
|
||||
- Agent stdout/stderr lines
|
||||
"""
|
||||
if not validate_project_name(project_name):
|
||||
await websocket.close(code=4000, reason="Invalid project name")
|
||||
return
|
||||
|
||||
project_dir = _get_generations_dir() / project_name
|
||||
if not project_dir.exists():
|
||||
await websocket.close(code=4004, reason="Project not found")
|
||||
return
|
||||
|
||||
await manager.connect(websocket, project_name)
|
||||
|
||||
# Get agent manager and register callbacks
|
||||
agent_manager = get_manager(project_name, ROOT_DIR)
|
||||
|
||||
async def on_output(line: str):
|
||||
"""Handle agent output - broadcast to this WebSocket."""
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "log",
|
||||
"line": line,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
})
|
||||
except Exception:
|
||||
pass # Connection may be closed
|
||||
|
||||
async def on_status_change(status: str):
|
||||
"""Handle status change - broadcast to this WebSocket."""
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"type": "agent_status",
|
||||
"status": status,
|
||||
})
|
||||
except Exception:
|
||||
pass # Connection may be closed
|
||||
|
||||
# Register callbacks
|
||||
agent_manager.add_output_callback(on_output)
|
||||
agent_manager.add_status_callback(on_status_change)
|
||||
|
||||
# Start progress polling task
|
||||
poll_task = asyncio.create_task(poll_progress(websocket, project_name))
|
||||
|
||||
try:
|
||||
# Send initial status
|
||||
await websocket.send_json({
|
||||
"type": "agent_status",
|
||||
"status": agent_manager.status,
|
||||
})
|
||||
|
||||
# Send initial progress
|
||||
count_passing_tests = _get_count_passing_tests()
|
||||
passing, total = count_passing_tests(project_dir)
|
||||
percentage = (passing / total * 100) if total > 0 else 0
|
||||
await websocket.send_json({
|
||||
"type": "progress",
|
||||
"passing": passing,
|
||||
"total": total,
|
||||
"percentage": round(percentage, 1),
|
||||
})
|
||||
|
||||
# Keep connection alive and handle incoming messages
|
||||
while True:
|
||||
try:
|
||||
# Wait for any incoming messages (ping/pong, commands, etc.)
|
||||
data = await websocket.receive_text()
|
||||
message = json.loads(data)
|
||||
|
||||
# Handle ping
|
||||
if message.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Invalid JSON from WebSocket: {data[:100] if data else 'empty'}")
|
||||
except Exception as e:
|
||||
logger.warning(f"WebSocket error: {e}")
|
||||
break
|
||||
|
||||
finally:
|
||||
# Clean up
|
||||
poll_task.cancel()
|
||||
try:
|
||||
await poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Unregister callbacks
|
||||
agent_manager.remove_output_callback(on_output)
|
||||
agent_manager.remove_status_callback(on_status_change)
|
||||
|
||||
# Disconnect from manager
|
||||
await manager.disconnect(websocket, project_name)
|
||||
23
start_ui.bat
Normal file
23
start_ui.bat
Normal file
@@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
REM Autonomous Coder UI Launcher for Windows
|
||||
REM This script launches the web UI for the autonomous coding agent.
|
||||
|
||||
echo.
|
||||
echo ====================================
|
||||
echo Autonomous Coder UI
|
||||
echo ====================================
|
||||
echo.
|
||||
|
||||
REM Check if Python is available
|
||||
where python >nul 2>&1
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo ERROR: Python not found in PATH
|
||||
echo Please install Python from https://python.org
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Run the Python launcher
|
||||
python "%~dp0start_ui.py" %*
|
||||
|
||||
pause
|
||||
294
start_ui.py
Normal file
294
start_ui.py
Normal file
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Autonomous Coder UI Launcher
|
||||
=============================
|
||||
|
||||
Automated launcher that handles all setup:
|
||||
1. Creates/activates Python virtual environment
|
||||
2. Installs Python dependencies
|
||||
3. Checks for Node.js
|
||||
4. Installs npm dependencies
|
||||
5. Builds React frontend (if needed)
|
||||
6. Starts FastAPI server
|
||||
7. Opens browser to the UI
|
||||
|
||||
Usage:
|
||||
python start_ui.py [--dev]
|
||||
|
||||
Options:
|
||||
--dev Run in development mode with Vite hot reload
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).parent.absolute()
|
||||
VENV_DIR = ROOT / "venv"
|
||||
UI_DIR = ROOT / "ui"
|
||||
|
||||
|
||||
def print_step(step: int, total: int, message: str) -> None:
|
||||
"""Print a formatted step message."""
|
||||
print(f"\n[{step}/{total}] {message}")
|
||||
print("-" * 50)
|
||||
|
||||
|
||||
def find_available_port(start: int = 8000, max_attempts: int = 10) -> int:
|
||||
"""Find an available port starting from the given port."""
|
||||
for port in range(start, start + max_attempts):
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", port))
|
||||
return port
|
||||
except OSError:
|
||||
continue
|
||||
raise RuntimeError(f"No available ports found in range {start}-{start + max_attempts}")
|
||||
|
||||
|
||||
def get_venv_python() -> Path:
|
||||
"""Get the path to the virtual environment Python executable."""
|
||||
if sys.platform == "win32":
|
||||
return VENV_DIR / "Scripts" / "python.exe"
|
||||
return VENV_DIR / "bin" / "python"
|
||||
|
||||
|
||||
def run_command(cmd: list, cwd: Path | None = None, check: bool = True) -> bool:
|
||||
"""Run a command and return success status."""
|
||||
try:
|
||||
subprocess.run(cmd, cwd=str(cwd) if cwd else None, check=check)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def setup_python_venv() -> bool:
|
||||
"""Create Python virtual environment if it doesn't exist."""
|
||||
if VENV_DIR.exists() and get_venv_python().exists():
|
||||
print(" Virtual environment already exists")
|
||||
return True
|
||||
|
||||
print(" Creating virtual environment...")
|
||||
return run_command([sys.executable, "-m", "venv", str(VENV_DIR)])
|
||||
|
||||
|
||||
def install_python_deps() -> bool:
|
||||
"""Install Python dependencies."""
|
||||
venv_python = get_venv_python()
|
||||
requirements = ROOT / "requirements.txt"
|
||||
|
||||
if not requirements.exists():
|
||||
print(" ERROR: requirements.txt not found")
|
||||
return False
|
||||
|
||||
print(" Installing Python dependencies...")
|
||||
return run_command([
|
||||
str(venv_python), "-m", "pip", "install",
|
||||
"-q", "--upgrade", "pip"
|
||||
]) and run_command([
|
||||
str(venv_python), "-m", "pip", "install",
|
||||
"-q", "-r", str(requirements)
|
||||
])
|
||||
|
||||
|
||||
def check_node() -> bool:
|
||||
"""Check if Node.js is installed."""
|
||||
node = shutil.which("node")
|
||||
npm = shutil.which("npm")
|
||||
|
||||
if not node:
|
||||
print(" ERROR: Node.js not found")
|
||||
print(" Please install Node.js from https://nodejs.org")
|
||||
return False
|
||||
|
||||
if not npm:
|
||||
print(" ERROR: npm not found")
|
||||
print(" Please install Node.js from https://nodejs.org")
|
||||
return False
|
||||
|
||||
# Get version
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["node", "--version"],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
print(f" Node.js version: {result.stdout.strip()}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def install_npm_deps() -> bool:
|
||||
"""Install npm dependencies if node_modules doesn't exist."""
|
||||
node_modules = UI_DIR / "node_modules"
|
||||
|
||||
if node_modules.exists():
|
||||
print(" npm dependencies already installed")
|
||||
return True
|
||||
|
||||
print(" Installing npm dependencies (this may take a few minutes)...")
|
||||
npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm"
|
||||
return run_command([npm_cmd, "install"], cwd=UI_DIR)
|
||||
|
||||
|
||||
def build_frontend() -> bool:
|
||||
"""Build the React frontend if dist doesn't exist."""
|
||||
dist_dir = UI_DIR / "dist"
|
||||
|
||||
if dist_dir.exists():
|
||||
print(" Frontend already built")
|
||||
return True
|
||||
|
||||
print(" Building React frontend...")
|
||||
npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm"
|
||||
return run_command([npm_cmd, "run", "build"], cwd=UI_DIR)
|
||||
|
||||
|
||||
def start_dev_server(port: int) -> tuple:
|
||||
"""Start both Vite and FastAPI in development mode."""
|
||||
venv_python = get_venv_python()
|
||||
|
||||
print(f"\n Starting development servers...")
|
||||
print(f" - FastAPI backend: http://127.0.0.1:{port}")
|
||||
print(f" - Vite frontend: http://127.0.0.1:5173")
|
||||
|
||||
# Start FastAPI
|
||||
backend = subprocess.Popen([
|
||||
str(venv_python), "-m", "uvicorn",
|
||||
"server.main:app",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(port),
|
||||
"--reload"
|
||||
], cwd=str(ROOT))
|
||||
|
||||
# Start Vite with API port env var for proxy configuration
|
||||
npm_cmd = "npm.cmd" if sys.platform == "win32" else "npm"
|
||||
vite_env = os.environ.copy()
|
||||
vite_env["VITE_API_PORT"] = str(port)
|
||||
frontend = subprocess.Popen([
|
||||
npm_cmd, "run", "dev"
|
||||
], cwd=str(UI_DIR), env=vite_env)
|
||||
|
||||
return backend, frontend
|
||||
|
||||
|
||||
def start_production_server(port: int):
|
||||
"""Start FastAPI server in production mode."""
|
||||
venv_python = get_venv_python()
|
||||
|
||||
print(f"\n Starting server at http://127.0.0.1:{port}")
|
||||
|
||||
return subprocess.Popen([
|
||||
str(venv_python), "-m", "uvicorn",
|
||||
"server.main:app",
|
||||
"--host", "127.0.0.1",
|
||||
"--port", str(port)
|
||||
], cwd=str(ROOT))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
dev_mode = "--dev" in sys.argv
|
||||
|
||||
print("=" * 50)
|
||||
print(" Autonomous Coder UI Setup")
|
||||
print("=" * 50)
|
||||
|
||||
total_steps = 6 if not dev_mode else 5
|
||||
|
||||
# Step 1: Python venv
|
||||
print_step(1, total_steps, "Setting up Python environment")
|
||||
if not setup_python_venv():
|
||||
print("ERROR: Failed to create virtual environment")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 2: Python dependencies
|
||||
print_step(2, total_steps, "Installing Python dependencies")
|
||||
if not install_python_deps():
|
||||
print("ERROR: Failed to install Python dependencies")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 3: Check Node.js
|
||||
print_step(3, total_steps, "Checking Node.js")
|
||||
if not check_node():
|
||||
sys.exit(1)
|
||||
|
||||
# Step 4: npm dependencies
|
||||
print_step(4, total_steps, "Installing npm dependencies")
|
||||
if not install_npm_deps():
|
||||
print("ERROR: Failed to install npm dependencies")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 5: Build frontend (production only)
|
||||
if not dev_mode:
|
||||
print_step(5, total_steps, "Building frontend")
|
||||
if not build_frontend():
|
||||
print("ERROR: Failed to build frontend")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 6: Start server
|
||||
step = 5 if dev_mode else 6
|
||||
print_step(step, total_steps, "Starting server")
|
||||
|
||||
port = find_available_port()
|
||||
|
||||
try:
|
||||
if dev_mode:
|
||||
backend, frontend = start_dev_server(port)
|
||||
|
||||
# Open browser to Vite dev server
|
||||
time.sleep(3)
|
||||
webbrowser.open("http://127.0.0.1:5173")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(" Development mode active")
|
||||
print(" Press Ctrl+C to stop")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
# Wait for either process to exit
|
||||
while backend.poll() is None and frontend.poll() is None:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nShutting down...")
|
||||
finally:
|
||||
backend.terminate()
|
||||
frontend.terminate()
|
||||
backend.wait()
|
||||
frontend.wait()
|
||||
else:
|
||||
server = start_production_server(port)
|
||||
|
||||
# Open browser
|
||||
time.sleep(2)
|
||||
webbrowser.open(f"http://127.0.0.1:{port}")
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(f" Server running at http://127.0.0.1:{port}")
|
||||
print(" Press Ctrl+C to stop")
|
||||
print("=" * 50)
|
||||
|
||||
try:
|
||||
server.wait()
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nShutting down...")
|
||||
server.terminate()
|
||||
server.wait()
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nERROR: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
27
start_ui.sh
Normal file
27
start_ui.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Autonomous Coder UI Launcher for Unix/Linux/macOS
|
||||
# This script launches the web UI for the autonomous coding agent.
|
||||
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo " Autonomous Coder UI"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Check if Python is available
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
if ! command -v python &> /dev/null; then
|
||||
echo "ERROR: Python not found"
|
||||
echo "Please install Python from https://python.org"
|
||||
exit 1
|
||||
fi
|
||||
PYTHON_CMD="python"
|
||||
else
|
||||
PYTHON_CMD="python3"
|
||||
fi
|
||||
|
||||
# Run the Python launcher
|
||||
$PYTHON_CMD "$SCRIPT_DIR/start_ui.py" "$@"
|
||||
16
ui/index.html
Normal file
16
ui/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Autonomous Coder</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4570
ui/package-lock.json
generated
Normal file
4570
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
ui/package.json
Normal file
37
ui/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "autonomous-coding-ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@tanstack/react-query": "^5.60.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@tailwindcss/vite": "^4.0.0-beta.4",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.11.0",
|
||||
"tailwindcss": "^4.0.0-beta.4",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.11.0",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
6
ui/public/vite.svg
Normal file
6
ui/public/vite.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect x="10" y="10" width="80" height="80" fill="#ffd60a" stroke="#1a1a1a" stroke-width="6"/>
|
||||
<rect x="25" y="30" width="50" height="10" fill="#1a1a1a"/>
|
||||
<rect x="25" y="50" width="35" height="10" fill="#70e000"/>
|
||||
<rect x="25" y="70" width="20" height="10" fill="#00b4d8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 352 B |
130
ui/src/App.tsx
Normal file
130
ui/src/App.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
import { useProjects, useFeatures } from './hooks/useProjects'
|
||||
import { useProjectWebSocket } from './hooks/useWebSocket'
|
||||
import { ProjectSelector } from './components/ProjectSelector'
|
||||
import { KanbanBoard } from './components/KanbanBoard'
|
||||
import { AgentControl } from './components/AgentControl'
|
||||
import { ProgressDashboard } from './components/ProgressDashboard'
|
||||
import { SetupWizard } from './components/SetupWizard'
|
||||
import { AddFeatureForm } from './components/AddFeatureForm'
|
||||
import { FeatureModal } from './components/FeatureModal'
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { Feature } from './lib/types'
|
||||
|
||||
function App() {
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>(null)
|
||||
const [showAddFeature, setShowAddFeature] = useState(false)
|
||||
const [selectedFeature, setSelectedFeature] = useState<Feature | null>(null)
|
||||
const [setupComplete, setSetupComplete] = useState(true) // Start optimistic
|
||||
|
||||
const { data: projects, isLoading: projectsLoading } = useProjects()
|
||||
const { data: features } = useFeatures(selectedProject)
|
||||
const wsState = useProjectWebSocket(selectedProject)
|
||||
|
||||
// Combine WebSocket progress with feature data
|
||||
const progress = wsState.progress.total > 0 ? wsState.progress : {
|
||||
passing: features?.done.length ?? 0,
|
||||
total: (features?.pending.length ?? 0) + (features?.in_progress.length ?? 0) + (features?.done.length ?? 0),
|
||||
percentage: 0,
|
||||
}
|
||||
|
||||
if (progress.total > 0 && progress.percentage === 0) {
|
||||
progress.percentage = Math.round((progress.passing / progress.total) * 100 * 10) / 10
|
||||
}
|
||||
|
||||
if (!setupComplete) {
|
||||
return <SetupWizard onComplete={() => setSetupComplete(true)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-neo-bg)]">
|
||||
{/* Header */}
|
||||
<header className="bg-[var(--color-neo-text)] text-white border-b-4 border-[var(--color-neo-border)]">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo and Title */}
|
||||
<h1 className="font-display text-2xl font-bold tracking-tight uppercase">
|
||||
Autonomous Coder
|
||||
</h1>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
<ProjectSelector
|
||||
projects={projects ?? []}
|
||||
selectedProject={selectedProject}
|
||||
onSelectProject={setSelectedProject}
|
||||
isLoading={projectsLoading}
|
||||
/>
|
||||
|
||||
{selectedProject && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowAddFeature(true)}
|
||||
className="neo-btn neo-btn-primary text-sm"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Add Feature
|
||||
</button>
|
||||
|
||||
<AgentControl
|
||||
projectName={selectedProject}
|
||||
status={wsState.agentStatus}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||
{!selectedProject ? (
|
||||
<div className="neo-empty-state mt-12">
|
||||
<h2 className="font-display text-2xl font-bold mb-2">
|
||||
Welcome to Autonomous Coder
|
||||
</h2>
|
||||
<p className="text-[var(--color-neo-text-secondary)] mb-4">
|
||||
Select a project from the dropdown above or create a new one to get started.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Progress Dashboard */}
|
||||
<ProgressDashboard
|
||||
passing={progress.passing}
|
||||
total={progress.total}
|
||||
percentage={progress.percentage}
|
||||
isConnected={wsState.isConnected}
|
||||
/>
|
||||
|
||||
{/* Kanban Board */}
|
||||
<KanbanBoard
|
||||
features={features}
|
||||
onFeatureClick={setSelectedFeature}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Add Feature Modal */}
|
||||
{showAddFeature && selectedProject && (
|
||||
<AddFeatureForm
|
||||
projectName={selectedProject}
|
||||
onClose={() => setShowAddFeature(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Feature Detail Modal */}
|
||||
{selectedFeature && selectedProject && (
|
||||
<FeatureModal
|
||||
feature={selectedFeature}
|
||||
projectName={selectedProject}
|
||||
onClose={() => setSelectedFeature(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
213
ui/src/components/AddFeatureForm.tsx
Normal file
213
ui/src/components/AddFeatureForm.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useId } from 'react'
|
||||
import { X, Plus, Trash2, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useCreateFeature } from '../hooks/useProjects'
|
||||
|
||||
interface Step {
|
||||
id: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface AddFeatureFormProps {
|
||||
projectName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function AddFeatureForm({ projectName, onClose }: AddFeatureFormProps) {
|
||||
const formId = useId()
|
||||
const [category, setCategory] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [steps, setSteps] = useState<Step[]>([{ id: `${formId}-step-0`, value: '' }])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [stepCounter, setStepCounter] = useState(1)
|
||||
|
||||
const createFeature = useCreateFeature(projectName)
|
||||
|
||||
const handleAddStep = () => {
|
||||
setSteps([...steps, { id: `${formId}-step-${stepCounter}`, value: '' }])
|
||||
setStepCounter(stepCounter + 1)
|
||||
}
|
||||
|
||||
const handleRemoveStep = (id: string) => {
|
||||
setSteps(steps.filter(step => step.id !== id))
|
||||
}
|
||||
|
||||
const handleStepChange = (id: string, value: string) => {
|
||||
setSteps(steps.map(step =>
|
||||
step.id === id ? { ...step, value } : step
|
||||
))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Filter out empty steps
|
||||
const filteredSteps = steps
|
||||
.map(s => s.value.trim())
|
||||
.filter(s => s.length > 0)
|
||||
|
||||
try {
|
||||
await createFeature.mutateAsync({
|
||||
category: category.trim(),
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
steps: filteredSteps,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create feature')
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = category.trim() && name.trim() && description.trim()
|
||||
|
||||
return (
|
||||
<div className="neo-modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="neo-modal w-full max-w-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b-3 border-[var(--color-neo-border)]">
|
||||
<h2 className="font-display text-2xl font-bold">
|
||||
Add Feature
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)]">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Category
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="e.g., Authentication, UI, API"
|
||||
className="neo-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Feature Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g., User login form"
|
||||
className="neo-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Describe what this feature should do..."
|
||||
className="neo-input min-h-[100px] resize-y"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div>
|
||||
<label className="block font-display font-bold mb-2 uppercase text-sm">
|
||||
Test Steps (Optional)
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex gap-2">
|
||||
<span className="neo-input w-12 text-center flex-shrink-0 flex items-center justify-center">
|
||||
{index + 1}
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={step.value}
|
||||
onChange={(e) => handleStepChange(step.id, e.target.value)}
|
||||
placeholder="Describe this step..."
|
||||
className="neo-input flex-1"
|
||||
/>
|
||||
{steps.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveStep(step.id)}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddStep}
|
||||
className="neo-btn neo-btn-ghost mt-2 text-sm"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Add Step
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t-3 border-[var(--color-neo-border)]">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || createFeature.isPending}
|
||||
className="neo-btn neo-btn-success flex-1"
|
||||
>
|
||||
{createFeature.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus size={18} />
|
||||
Create Feature
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="neo-btn neo-btn-ghost"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
ui/src/components/AgentControl.tsx
Normal file
144
ui/src/components/AgentControl.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { Play, Pause, Square, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
useStartAgent,
|
||||
useStopAgent,
|
||||
usePauseAgent,
|
||||
useResumeAgent,
|
||||
} from '../hooks/useProjects'
|
||||
import type { AgentStatus } from '../lib/types'
|
||||
|
||||
interface AgentControlProps {
|
||||
projectName: string
|
||||
status: AgentStatus
|
||||
}
|
||||
|
||||
export function AgentControl({ projectName, status }: AgentControlProps) {
|
||||
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 handleStart = () => startAgent.mutate()
|
||||
const handleStop = () => stopAgent.mutate()
|
||||
const handlePause = () => pauseAgent.mutate()
|
||||
const handleResume = () => resumeAgent.mutate()
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status Indicator */}
|
||||
<StatusIndicator status={status} />
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex gap-1">
|
||||
{status === 'stopped' || status === 'crashed' ? (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={isLoading}
|
||||
className="neo-btn neo-btn-success text-sm py-2 px-3"
|
||||
title="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>
|
||||
)
|
||||
}
|
||||
86
ui/src/components/FeatureCard.tsx
Normal file
86
ui/src/components/FeatureCard.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { CheckCircle2, Circle, Loader2 } from 'lucide-react'
|
||||
import type { Feature } from '../lib/types'
|
||||
|
||||
interface FeatureCardProps {
|
||||
feature: Feature
|
||||
onClick: () => void
|
||||
isInProgress?: boolean
|
||||
}
|
||||
|
||||
// Generate consistent color for category
|
||||
function getCategoryColor(category: string): string {
|
||||
const colors = [
|
||||
'#ff006e', // pink
|
||||
'#00b4d8', // cyan
|
||||
'#70e000', // green
|
||||
'#ffd60a', // yellow
|
||||
'#ff5400', // orange
|
||||
'#8338ec', // purple
|
||||
'#3a86ff', // blue
|
||||
]
|
||||
|
||||
let hash = 0
|
||||
for (let i = 0; i < category.length; i++) {
|
||||
hash = category.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
|
||||
return colors[Math.abs(hash) % colors.length]
|
||||
}
|
||||
|
||||
export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps) {
|
||||
const categoryColor = getCategoryColor(feature.category)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
w-full text-left neo-card p-4 cursor-pointer
|
||||
${isInProgress ? 'animate-pulse-neo' : ''}
|
||||
${feature.passes ? 'border-[var(--color-neo-done)]' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<span
|
||||
className="neo-badge"
|
||||
style={{ backgroundColor: categoryColor, color: 'white' }}
|
||||
>
|
||||
{feature.category}
|
||||
</span>
|
||||
<span className="font-mono text-sm text-[var(--color-neo-text-secondary)]">
|
||||
#{feature.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<h3 className="font-display font-bold mb-1 line-clamp-2">
|
||||
{feature.name}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-[var(--color-neo-text-secondary)] line-clamp-2 mb-3">
|
||||
{feature.description}
|
||||
</p>
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{isInProgress ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin text-[var(--color-neo-progress)]" />
|
||||
<span className="text-[var(--color-neo-progress)] font-bold">Processing...</span>
|
||||
</>
|
||||
) : feature.passes ? (
|
||||
<>
|
||||
<CheckCircle2 size={16} className="text-[var(--color-neo-done)]" />
|
||||
<span className="text-[var(--color-neo-done)] font-bold">Complete</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Circle size={16} className="text-[var(--color-neo-text-secondary)]" />
|
||||
<span className="text-[var(--color-neo-text-secondary)]">Pending</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
190
ui/src/components/FeatureModal.tsx
Normal file
190
ui/src/components/FeatureModal.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react'
|
||||
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle } from 'lucide-react'
|
||||
import { useSkipFeature, useDeleteFeature } from '../hooks/useProjects'
|
||||
import type { Feature } from '../lib/types'
|
||||
|
||||
interface FeatureModalProps {
|
||||
feature: Feature
|
||||
projectName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function FeatureModal({ feature, projectName, onClose }: FeatureModalProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
const skipFeature = useSkipFeature(projectName)
|
||||
const deleteFeature = useDeleteFeature(projectName)
|
||||
|
||||
const handleSkip = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
await skipFeature.mutateAsync(feature.id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to skip feature')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
setError(null)
|
||||
try {
|
||||
await deleteFeature.mutateAsync(feature.id)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete feature')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="neo-modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="neo-modal w-full max-w-2xl p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between p-6 border-b-3 border-[var(--color-neo-border)]">
|
||||
<div>
|
||||
<span className="neo-badge bg-[var(--color-neo-accent)] text-white mb-2">
|
||||
{feature.category}
|
||||
</span>
|
||||
<h2 className="font-display text-2xl font-bold">
|
||||
{feature.name}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="neo-btn neo-btn-ghost p-2"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)]">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]">
|
||||
{feature.passes ? (
|
||||
<>
|
||||
<CheckCircle2 size={24} className="text-[var(--color-neo-done)]" />
|
||||
<span className="font-display font-bold text-[var(--color-neo-done)]">
|
||||
COMPLETE
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Circle size={24} className="text-[var(--color-neo-text-secondary)]" />
|
||||
<span className="font-display font-bold text-[var(--color-neo-text-secondary)]">
|
||||
PENDING
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto font-mono text-sm">
|
||||
Priority: #{feature.priority}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="font-display font-bold mb-2 uppercase text-sm">
|
||||
Description
|
||||
</h3>
|
||||
<p className="text-[var(--color-neo-text-secondary)]">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
{feature.steps.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-display font-bold mb-2 uppercase text-sm">
|
||||
Test Steps
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2">
|
||||
{feature.steps.map((step, index) => (
|
||||
<li
|
||||
key={index}
|
||||
className="p-3 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]"
|
||||
>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!feature.passes && (
|
||||
<div className="p-6 border-t-3 border-[var(--color-neo-border)] bg-[var(--color-neo-bg)]">
|
||||
{showDeleteConfirm ? (
|
||||
<div className="space-y-4">
|
||||
<p className="font-bold text-center">
|
||||
Are you sure you want to delete this feature?
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteFeature.isPending}
|
||||
className="neo-btn neo-btn-danger flex-1"
|
||||
>
|
||||
{deleteFeature.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
'Yes, Delete'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={deleteFeature.isPending}
|
||||
className="neo-btn neo-btn-ghost flex-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={skipFeature.isPending}
|
||||
className="neo-btn neo-btn-warning flex-1"
|
||||
>
|
||||
{skipFeature.isPending ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<SkipForward size={18} />
|
||||
Skip (Move to End)
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={skipFeature.isPending}
|
||||
className="neo-btn neo-btn-danger"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
ui/src/components/KanbanBoard.tsx
Normal file
52
ui/src/components/KanbanBoard.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { KanbanColumn } from './KanbanColumn'
|
||||
import type { Feature, FeatureListResponse } from '../lib/types'
|
||||
|
||||
interface KanbanBoardProps {
|
||||
features: FeatureListResponse | undefined
|
||||
onFeatureClick: (feature: Feature) => void
|
||||
}
|
||||
|
||||
export function KanbanBoard({ features, onFeatureClick }: KanbanBoardProps) {
|
||||
if (!features) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{['Pending', 'In Progress', 'Done'].map(title => (
|
||||
<div key={title} className="neo-card p-4">
|
||||
<div className="h-8 bg-[var(--color-neo-bg)] animate-pulse mb-4" />
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-24 bg-[var(--color-neo-bg)] animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<KanbanColumn
|
||||
title="Pending"
|
||||
count={features.pending.length}
|
||||
features={features.pending}
|
||||
color="pending"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
<KanbanColumn
|
||||
title="In Progress"
|
||||
count={features.in_progress.length}
|
||||
features={features.in_progress}
|
||||
color="progress"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
<KanbanColumn
|
||||
title="Done"
|
||||
count={features.done.length}
|
||||
features={features.done}
|
||||
color="done"
|
||||
onFeatureClick={onFeatureClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
ui/src/components/KanbanColumn.tsx
Normal file
65
ui/src/components/KanbanColumn.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { FeatureCard } from './FeatureCard'
|
||||
import type { Feature } from '../lib/types'
|
||||
|
||||
interface KanbanColumnProps {
|
||||
title: string
|
||||
count: number
|
||||
features: Feature[]
|
||||
color: 'pending' | 'progress' | 'done'
|
||||
onFeatureClick: (feature: Feature) => void
|
||||
}
|
||||
|
||||
const colorMap = {
|
||||
pending: 'var(--color-neo-pending)',
|
||||
progress: 'var(--color-neo-progress)',
|
||||
done: 'var(--color-neo-done)',
|
||||
}
|
||||
|
||||
export function KanbanColumn({
|
||||
title,
|
||||
count,
|
||||
features,
|
||||
color,
|
||||
onFeatureClick,
|
||||
}: KanbanColumnProps) {
|
||||
return (
|
||||
<div
|
||||
className="neo-card overflow-hidden"
|
||||
style={{ borderColor: colorMap[color] }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 border-b-3 border-[var(--color-neo-border)]"
|
||||
style={{ backgroundColor: colorMap[color] }}
|
||||
>
|
||||
<h2 className="font-display text-lg font-bold uppercase flex items-center justify-between text-[var(--color-neo-text)]">
|
||||
{title}
|
||||
<span className="neo-badge bg-white text-[var(--color-neo-text)]">{count}</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="p-4 space-y-3 max-h-[600px] overflow-y-auto bg-[var(--color-neo-bg)]">
|
||||
{features.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--color-neo-text-secondary)]">
|
||||
No features
|
||||
</div>
|
||||
) : (
|
||||
features.map((feature, index) => (
|
||||
<div
|
||||
key={feature.id}
|
||||
className="animate-slide-in"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
<FeatureCard
|
||||
feature={feature}
|
||||
onClick={() => onFeatureClick(feature)}
|
||||
isInProgress={color === 'progress'}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
77
ui/src/components/ProgressDashboard.tsx
Normal file
77
ui/src/components/ProgressDashboard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { Wifi, WifiOff } from 'lucide-react'
|
||||
|
||||
interface ProgressDashboardProps {
|
||||
passing: number
|
||||
total: number
|
||||
percentage: number
|
||||
isConnected: boolean
|
||||
}
|
||||
|
||||
export function ProgressDashboard({
|
||||
passing,
|
||||
total,
|
||||
percentage,
|
||||
isConnected,
|
||||
}: ProgressDashboardProps) {
|
||||
return (
|
||||
<div className="neo-card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-display text-xl font-bold uppercase">
|
||||
Progress
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Wifi size={16} className="text-[var(--color-neo-done)]" />
|
||||
<span className="text-sm text-[var(--color-neo-done)]">Live</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff size={16} className="text-[var(--color-neo-danger)]" />
|
||||
<span className="text-sm text-[var(--color-neo-danger)]">Offline</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Large Percentage */}
|
||||
<div className="text-center mb-6">
|
||||
<span className="font-display text-6xl font-bold">
|
||||
{percentage.toFixed(1)}
|
||||
</span>
|
||||
<span className="font-display text-3xl font-bold text-[var(--color-neo-text-secondary)]">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="neo-progress mb-4">
|
||||
<div
|
||||
className="neo-progress-fill"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex justify-center gap-8 text-center">
|
||||
<div>
|
||||
<span className="font-mono text-3xl font-bold text-[var(--color-neo-done)]">
|
||||
{passing}
|
||||
</span>
|
||||
<span className="block text-sm text-[var(--color-neo-text-secondary)] uppercase">
|
||||
Passing
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-4xl text-[var(--color-neo-text-secondary)]">/</div>
|
||||
<div>
|
||||
<span className="font-mono text-3xl font-bold">
|
||||
{total}
|
||||
</span>
|
||||
<span className="block text-sm text-[var(--color-neo-text-secondary)] uppercase">
|
||||
Total
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
ui/src/components/ProjectSelector.tsx
Normal file
172
ui/src/components/ProjectSelector.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, Plus, FolderOpen, Loader2 } from 'lucide-react'
|
||||
import { useCreateProject } from '../hooks/useProjects'
|
||||
import type { ProjectSummary } from '../lib/types'
|
||||
|
||||
interface ProjectSelectorProps {
|
||||
projects: ProjectSummary[]
|
||||
selectedProject: string | null
|
||||
onSelectProject: (name: string | null) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function ProjectSelector({
|
||||
projects,
|
||||
selectedProject,
|
||||
onSelectProject,
|
||||
isLoading,
|
||||
}: ProjectSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
|
||||
const createProject = useCreateProject()
|
||||
|
||||
const handleCreateProject = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newProjectName.trim()) return
|
||||
|
||||
try {
|
||||
const project = await createProject.mutateAsync({
|
||||
name: newProjectName.trim(),
|
||||
specMethod: 'manual',
|
||||
})
|
||||
onSelectProject(project.name)
|
||||
setNewProjectName('')
|
||||
setShowCreate(false)
|
||||
setIsOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProjectData = projects.find(p => p.name === selectedProject)
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Dropdown Trigger */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="neo-btn bg-white text-[var(--color-neo-text)] min-w-[200px] justify-between"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : selectedProject ? (
|
||||
<>
|
||||
<span className="flex items-center gap-2">
|
||||
<FolderOpen size={18} />
|
||||
{selectedProject}
|
||||
</span>
|
||||
{selectedProjectData && selectedProjectData.stats.total > 0 && (
|
||||
<span className="neo-badge bg-[var(--color-neo-done)] ml-2">
|
||||
{selectedProjectData.stats.percentage}%
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[var(--color-neo-text-secondary)]">
|
||||
Select Project
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown size={18} className={`transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Menu */}
|
||||
<div className="absolute top-full left-0 mt-2 w-full neo-dropdown z-50 min-w-[280px]">
|
||||
{projects.length > 0 ? (
|
||||
<div className="max-h-[300px] overflow-auto">
|
||||
{projects.map(project => (
|
||||
<button
|
||||
key={project.name}
|
||||
onClick={() => {
|
||||
onSelectProject(project.name)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`w-full neo-dropdown-item flex items-center justify-between ${
|
||||
project.name === selectedProject
|
||||
? 'bg-[var(--color-neo-pending)]'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<FolderOpen size={16} />
|
||||
{project.name}
|
||||
</span>
|
||||
{project.stats.total > 0 && (
|
||||
<span className="text-sm font-mono">
|
||||
{project.stats.passing}/{project.stats.total}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 text-center text-[var(--color-neo-text-secondary)]">
|
||||
No projects yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t-3 border-[var(--color-neo-border)]" />
|
||||
|
||||
{/* Create New */}
|
||||
{showCreate ? (
|
||||
<form onSubmit={handleCreateProject} className="p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="project-name"
|
||||
className="neo-input text-sm mb-2"
|
||||
pattern="^[a-zA-Z0-9_-]+$"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="neo-btn neo-btn-success text-xs flex-1"
|
||||
disabled={createProject.isPending || !newProjectName.trim()}
|
||||
>
|
||||
{createProject.isPending ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreate(false)
|
||||
setNewProjectName('')
|
||||
}}
|
||||
className="neo-btn neo-btn-ghost text-xs"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="w-full neo-dropdown-item flex items-center gap-2 font-bold"
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Project
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
ui/src/components/SetupWizard.tsx
Normal file
183
ui/src/components/SetupWizard.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useCallback } from 'react'
|
||||
import { CheckCircle2, XCircle, Loader2, ExternalLink } from 'lucide-react'
|
||||
import { useSetupStatus, useHealthCheck } from '../hooks/useProjects'
|
||||
|
||||
interface SetupWizardProps {
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function SetupWizard({ onComplete }: SetupWizardProps) {
|
||||
const { data: setupStatus, isLoading: setupLoading, error: setupError } = useSetupStatus()
|
||||
const { data: health, error: healthError } = useHealthCheck()
|
||||
|
||||
const isApiHealthy = health?.status === 'healthy' && !healthError
|
||||
const isReady = isApiHealthy && setupStatus?.claude_cli && setupStatus?.credentials
|
||||
|
||||
// Memoize the completion check to avoid infinite loops
|
||||
const checkAndComplete = useCallback(() => {
|
||||
if (isReady) {
|
||||
onComplete()
|
||||
}
|
||||
}, [isReady, onComplete])
|
||||
|
||||
// Auto-complete if everything is ready
|
||||
useEffect(() => {
|
||||
checkAndComplete()
|
||||
}, [checkAndComplete])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[var(--color-neo-bg)] flex items-center justify-center p-4">
|
||||
<div className="neo-card w-full max-w-lg p-8">
|
||||
<h1 className="font-display text-3xl font-bold text-center mb-2">
|
||||
Setup Wizard
|
||||
</h1>
|
||||
<p className="text-center text-[var(--color-neo-text-secondary)] mb-8">
|
||||
Let's make sure everything is ready to go
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* API Health */}
|
||||
<SetupItem
|
||||
label="Backend Server"
|
||||
description="FastAPI server is running"
|
||||
status={healthError ? 'error' : isApiHealthy ? 'success' : 'loading'}
|
||||
/>
|
||||
|
||||
{/* Claude CLI */}
|
||||
<SetupItem
|
||||
label="Claude CLI"
|
||||
description="Claude Code CLI is installed"
|
||||
status={
|
||||
setupLoading
|
||||
? 'loading'
|
||||
: setupError
|
||||
? 'error'
|
||||
: setupStatus?.claude_cli
|
||||
? 'success'
|
||||
: 'error'
|
||||
}
|
||||
helpLink="https://docs.anthropic.com/claude/claude-code"
|
||||
helpText="Install Claude Code"
|
||||
/>
|
||||
|
||||
{/* Credentials */}
|
||||
<SetupItem
|
||||
label="Anthropic Credentials"
|
||||
description="API credentials are configured"
|
||||
status={
|
||||
setupLoading
|
||||
? 'loading'
|
||||
: setupError
|
||||
? 'error'
|
||||
: setupStatus?.credentials
|
||||
? 'success'
|
||||
: 'error'
|
||||
}
|
||||
helpLink="https://console.anthropic.com/account/keys"
|
||||
helpText="Get API Key"
|
||||
/>
|
||||
|
||||
{/* Node.js */}
|
||||
<SetupItem
|
||||
label="Node.js"
|
||||
description="Node.js is installed (for UI dev)"
|
||||
status={
|
||||
setupLoading
|
||||
? 'loading'
|
||||
: setupError
|
||||
? 'error'
|
||||
: setupStatus?.node
|
||||
? 'success'
|
||||
: 'warning'
|
||||
}
|
||||
helpLink="https://nodejs.org"
|
||||
helpText="Install Node.js"
|
||||
optional
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Continue Button */}
|
||||
{isReady && (
|
||||
<button
|
||||
onClick={onComplete}
|
||||
className="neo-btn neo-btn-success w-full mt-8"
|
||||
>
|
||||
Continue to Dashboard
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{(healthError || setupError) && (
|
||||
<div className="mt-6 p-4 bg-[var(--color-neo-danger)] text-white border-3 border-[var(--color-neo-border)]">
|
||||
<p className="font-bold mb-2">Setup Error</p>
|
||||
<p className="text-sm">
|
||||
{healthError
|
||||
? 'Cannot connect to the backend server. Make sure to run start_ui.py first.'
|
||||
: 'Failed to check setup status.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SetupItemProps {
|
||||
label: string
|
||||
description: string
|
||||
status: 'success' | 'error' | 'warning' | 'loading'
|
||||
helpLink?: string
|
||||
helpText?: string
|
||||
optional?: boolean
|
||||
}
|
||||
|
||||
function SetupItem({
|
||||
label,
|
||||
description,
|
||||
status,
|
||||
helpLink,
|
||||
helpText,
|
||||
optional,
|
||||
}: SetupItemProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 p-4 bg-[var(--color-neo-bg)] border-3 border-[var(--color-neo-border)]">
|
||||
{/* Status Icon */}
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{status === 'success' ? (
|
||||
<CheckCircle2 size={24} className="text-[var(--color-neo-done)]" />
|
||||
) : status === 'error' ? (
|
||||
<XCircle size={24} className="text-[var(--color-neo-danger)]" />
|
||||
) : status === 'warning' ? (
|
||||
<XCircle size={24} className="text-[var(--color-neo-pending)]" />
|
||||
) : (
|
||||
<Loader2 size={24} className="animate-spin text-[var(--color-neo-progress)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-display font-bold">{label}</span>
|
||||
{optional && (
|
||||
<span className="text-xs text-[var(--color-neo-text-secondary)]">
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-neo-text-secondary)]">
|
||||
{description}
|
||||
</p>
|
||||
{(status === 'error' || status === 'warning') && helpLink && (
|
||||
<a
|
||||
href={helpLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 mt-2 text-sm text-[var(--color-neo-accent)] hover:underline"
|
||||
>
|
||||
{helpText} <ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
ui/src/hooks/useProjects.ts
Normal file
172
ui/src/hooks/useProjects.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* React Query hooks for project data
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import * as api from '../lib/api'
|
||||
import type { FeatureCreate } from '../lib/types'
|
||||
|
||||
// ============================================================================
|
||||
// Projects
|
||||
// ============================================================================
|
||||
|
||||
export function useProjects() {
|
||||
return useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: api.listProjects,
|
||||
})
|
||||
}
|
||||
|
||||
export function useProject(name: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['project', name],
|
||||
queryFn: () => api.getProject(name!),
|
||||
enabled: !!name,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateProject() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ name, specMethod }: { name: string; specMethod?: 'claude' | 'manual' }) =>
|
||||
api.createProject(name, specMethod),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteProject() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => api.deleteProject(name),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['projects'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Features
|
||||
// ============================================================================
|
||||
|
||||
export function useFeatures(projectName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['features', projectName],
|
||||
queryFn: () => api.listFeatures(projectName!),
|
||||
enabled: !!projectName,
|
||||
refetchInterval: 5000, // Refetch every 5 seconds for real-time updates
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateFeature(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (feature: FeatureCreate) => api.createFeature(projectName, feature),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteFeature(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (featureId: number) => api.deleteFeature(projectName, featureId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSkipFeature(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (featureId: number) => api.skipFeature(projectName, featureId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['features', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent
|
||||
// ============================================================================
|
||||
|
||||
export function useAgentStatus(projectName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['agent-status', projectName],
|
||||
queryFn: () => api.getAgentStatus(projectName!),
|
||||
enabled: !!projectName,
|
||||
refetchInterval: 3000, // Poll every 3 seconds
|
||||
})
|
||||
}
|
||||
|
||||
export function useStartAgent(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.startAgent(projectName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useStopAgent(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.stopAgent(projectName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function usePauseAgent(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.pauseAgent(projectName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useResumeAgent(projectName: string) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => api.resumeAgent(projectName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Setup
|
||||
// ============================================================================
|
||||
|
||||
export function useSetupStatus() {
|
||||
return useQuery({
|
||||
queryKey: ['setup-status'],
|
||||
queryFn: api.getSetupStatus,
|
||||
staleTime: 60000, // Cache for 1 minute
|
||||
})
|
||||
}
|
||||
|
||||
export function useHealthCheck() {
|
||||
return useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: api.healthCheck,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
161
ui/src/hooks/useWebSocket.ts
Normal file
161
ui/src/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* WebSocket Hook for Real-time Updates
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import type { WSMessage, AgentStatus } from '../lib/types'
|
||||
|
||||
interface WebSocketState {
|
||||
progress: {
|
||||
passing: number
|
||||
total: number
|
||||
percentage: number
|
||||
}
|
||||
agentStatus: AgentStatus
|
||||
logs: Array<{ line: string; timestamp: string }>
|
||||
isConnected: boolean
|
||||
}
|
||||
|
||||
const MAX_LOGS = 100 // Keep last 100 log lines
|
||||
|
||||
export function useProjectWebSocket(projectName: string | null) {
|
||||
const [state, setState] = useState<WebSocketState>({
|
||||
progress: { passing: 0, total: 0, percentage: 0 },
|
||||
agentStatus: 'stopped',
|
||||
logs: [],
|
||||
isConnected: false,
|
||||
})
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<number | null>(null)
|
||||
const reconnectAttempts = useRef(0)
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!projectName) return
|
||||
|
||||
// Build WebSocket URL
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
const wsUrl = `${protocol}//${host}/ws/projects/${encodeURIComponent(projectName)}`
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setState(prev => ({ ...prev, isConnected: true }))
|
||||
reconnectAttempts.current = 0
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: WSMessage = JSON.parse(event.data)
|
||||
|
||||
switch (message.type) {
|
||||
case 'progress':
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
progress: {
|
||||
passing: message.passing,
|
||||
total: message.total,
|
||||
percentage: message.percentage,
|
||||
},
|
||||
}))
|
||||
break
|
||||
|
||||
case 'agent_status':
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
agentStatus: message.status,
|
||||
}))
|
||||
break
|
||||
|
||||
case 'log':
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
logs: [
|
||||
...prev.logs.slice(-MAX_LOGS + 1),
|
||||
{ line: message.line, timestamp: message.timestamp },
|
||||
],
|
||||
}))
|
||||
break
|
||||
|
||||
case 'feature_update':
|
||||
// Feature updates will trigger a refetch via React Query
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat response
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
console.error('Failed to parse WebSocket message')
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setState(prev => ({ ...prev, isConnected: false }))
|
||||
wsRef.current = null
|
||||
|
||||
// Exponential backoff reconnection
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000)
|
||||
reconnectAttempts.current++
|
||||
|
||||
reconnectTimeoutRef.current = window.setTimeout(() => {
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close()
|
||||
}
|
||||
} catch {
|
||||
// Failed to connect, will retry via onclose
|
||||
}
|
||||
}, [projectName])
|
||||
|
||||
// Send ping to keep connection alive
|
||||
const sendPing = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect when project changes
|
||||
useEffect(() => {
|
||||
if (!projectName) {
|
||||
// Disconnect if no project
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
// Ping every 30 seconds
|
||||
const pingInterval = setInterval(sendPing, 30000)
|
||||
|
||||
return () => {
|
||||
clearInterval(pingInterval)
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}
|
||||
}, [projectName, connect, sendPing])
|
||||
|
||||
// Clear logs function
|
||||
const clearLogs = useCallback(() => {
|
||||
setState(prev => ({ ...prev, logs: [] }))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
...state,
|
||||
clearLogs,
|
||||
}
|
||||
}
|
||||
148
ui/src/lib/api.ts
Normal file
148
ui/src/lib/api.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* API Client for the Autonomous Coding UI
|
||||
*/
|
||||
|
||||
import type {
|
||||
ProjectSummary,
|
||||
ProjectDetail,
|
||||
ProjectPrompts,
|
||||
FeatureListResponse,
|
||||
Feature,
|
||||
FeatureCreate,
|
||||
AgentStatusResponse,
|
||||
AgentActionResponse,
|
||||
SetupStatus,
|
||||
} from './types'
|
||||
|
||||
const API_BASE = '/api'
|
||||
|
||||
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
throw new Error(error.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Projects API
|
||||
// ============================================================================
|
||||
|
||||
export async function listProjects(): Promise<ProjectSummary[]> {
|
||||
return fetchJSON('/projects')
|
||||
}
|
||||
|
||||
export async function createProject(name: string, specMethod: 'claude' | 'manual' = 'manual'): Promise<ProjectSummary> {
|
||||
return fetchJSON('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, spec_method: specMethod }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getProject(name: string): Promise<ProjectDetail> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(name)}`)
|
||||
}
|
||||
|
||||
export async function deleteProject(name: string): Promise<void> {
|
||||
await fetchJSON(`/projects/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getProjectPrompts(name: string): Promise<ProjectPrompts> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(name)}/prompts`)
|
||||
}
|
||||
|
||||
export async function updateProjectPrompts(
|
||||
name: string,
|
||||
prompts: Partial<ProjectPrompts>
|
||||
): Promise<void> {
|
||||
await fetchJSON(`/projects/${encodeURIComponent(name)}/prompts`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(prompts),
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Features API
|
||||
// ============================================================================
|
||||
|
||||
export async function listFeatures(projectName: string): Promise<FeatureListResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features`)
|
||||
}
|
||||
|
||||
export async function createFeature(projectName: string, feature: FeatureCreate): Promise<Feature> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(feature),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getFeature(projectName: string, featureId: number): Promise<Feature> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}`)
|
||||
}
|
||||
|
||||
export async function deleteFeature(projectName: string, featureId: number): Promise<void> {
|
||||
await fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export async function skipFeature(projectName: string, featureId: number): Promise<void> {
|
||||
await fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/${featureId}/skip`, {
|
||||
method: 'PATCH',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent API
|
||||
// ============================================================================
|
||||
|
||||
export async function getAgentStatus(projectName: string): Promise<AgentStatusResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/status`)
|
||||
}
|
||||
|
||||
export async function startAgent(projectName: string): Promise<AgentActionResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/start`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function stopAgent(projectName: string): Promise<AgentActionResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/stop`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function pauseAgent(projectName: string): Promise<AgentActionResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/pause`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function resumeAgent(projectName: string): Promise<AgentActionResponse> {
|
||||
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/resume`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Setup API
|
||||
// ============================================================================
|
||||
|
||||
export async function getSetupStatus(): Promise<SetupStatus> {
|
||||
return fetchJSON('/setup/status')
|
||||
}
|
||||
|
||||
export async function healthCheck(): Promise<{ status: string }> {
|
||||
return fetchJSON('/health')
|
||||
}
|
||||
112
ui/src/lib/types.ts
Normal file
112
ui/src/lib/types.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* TypeScript types for the Autonomous Coding UI
|
||||
*/
|
||||
|
||||
// Project types
|
||||
export interface ProjectStats {
|
||||
passing: number
|
||||
total: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
name: string
|
||||
has_spec: boolean
|
||||
stats: ProjectStats
|
||||
}
|
||||
|
||||
export interface ProjectDetail extends ProjectSummary {
|
||||
prompts_dir: string
|
||||
}
|
||||
|
||||
export interface ProjectPrompts {
|
||||
app_spec: string
|
||||
initializer_prompt: string
|
||||
coding_prompt: string
|
||||
}
|
||||
|
||||
// Feature types
|
||||
export interface Feature {
|
||||
id: number
|
||||
priority: number
|
||||
category: string
|
||||
name: string
|
||||
description: string
|
||||
steps: string[]
|
||||
passes: boolean
|
||||
}
|
||||
|
||||
export interface FeatureListResponse {
|
||||
pending: Feature[]
|
||||
in_progress: Feature[]
|
||||
done: Feature[]
|
||||
}
|
||||
|
||||
export interface FeatureCreate {
|
||||
category: string
|
||||
name: string
|
||||
description: string
|
||||
steps: string[]
|
||||
priority?: number
|
||||
}
|
||||
|
||||
// Agent types
|
||||
export type AgentStatus = 'stopped' | 'running' | 'paused' | 'crashed'
|
||||
|
||||
export interface AgentStatusResponse {
|
||||
status: AgentStatus
|
||||
pid: number | null
|
||||
started_at: string | null
|
||||
}
|
||||
|
||||
export interface AgentActionResponse {
|
||||
success: boolean
|
||||
status: AgentStatus
|
||||
message: string
|
||||
}
|
||||
|
||||
// Setup types
|
||||
export interface SetupStatus {
|
||||
claude_cli: boolean
|
||||
credentials: boolean
|
||||
node: boolean
|
||||
npm: boolean
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong'
|
||||
|
||||
export interface WSProgressMessage {
|
||||
type: 'progress'
|
||||
passing: number
|
||||
total: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export interface WSFeatureUpdateMessage {
|
||||
type: 'feature_update'
|
||||
feature_id: number
|
||||
passes: boolean
|
||||
}
|
||||
|
||||
export interface WSLogMessage {
|
||||
type: 'log'
|
||||
line: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface WSAgentStatusMessage {
|
||||
type: 'agent_status'
|
||||
status: AgentStatus
|
||||
}
|
||||
|
||||
export interface WSPongMessage {
|
||||
type: 'pong'
|
||||
}
|
||||
|
||||
export type WSMessage =
|
||||
| WSProgressMessage
|
||||
| WSFeatureUpdateMessage
|
||||
| WSLogMessage
|
||||
| WSAgentStatusMessage
|
||||
| WSPongMessage
|
||||
22
ui/src/main.tsx
Normal file
22
ui/src/main.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './styles/globals.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
342
ui/src/styles/globals.css
Normal file
342
ui/src/styles/globals.css
Normal file
@@ -0,0 +1,342 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ============================================================================
|
||||
Neobrutalism Design System
|
||||
============================================================================ */
|
||||
|
||||
@theme {
|
||||
/* Colors */
|
||||
--color-neo-bg: #fffef5;
|
||||
--color-neo-card: #ffffff;
|
||||
--color-neo-pending: #ffd60a;
|
||||
--color-neo-progress: #00b4d8;
|
||||
--color-neo-done: #70e000;
|
||||
--color-neo-accent: #ff006e;
|
||||
--color-neo-danger: #ff5400;
|
||||
--color-neo-border: #1a1a1a;
|
||||
--color-neo-text: #1a1a1a;
|
||||
--color-neo-text-secondary: #4a4a4a;
|
||||
|
||||
/* Fonts */
|
||||
--font-neo-display: 'Space Grotesk', sans-serif;
|
||||
--font-neo-sans: 'DM Sans', sans-serif;
|
||||
--font-neo-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-neo-sm: 2px 2px 0px rgba(0, 0, 0, 1);
|
||||
--shadow-neo-md: 4px 4px 0px rgba(0, 0, 0, 1);
|
||||
--shadow-neo-lg: 6px 6px 0px rgba(0, 0, 0, 1);
|
||||
--shadow-neo-xl: 8px 8px 0px rgba(0, 0, 0, 1);
|
||||
|
||||
/* Transitions */
|
||||
--transition-neo-fast: 0.15s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--transition-neo-normal: 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
font-family: var(--font-neo-sans);
|
||||
background-color: var(--color-neo-bg);
|
||||
color: var(--color-neo-text);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Component Classes
|
||||
============================================================================ */
|
||||
|
||||
/* Cards */
|
||||
.neo-card {
|
||||
background: var(--color-neo-card);
|
||||
border: 3px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-md);
|
||||
transition: transform var(--transition-neo-fast), box-shadow var(--transition-neo-fast);
|
||||
}
|
||||
|
||||
.neo-card:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: var(--shadow-neo-lg);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.neo-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-family: var(--font-neo-display);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
color: var(--color-neo-text);
|
||||
background: white;
|
||||
border: 3px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-md);
|
||||
transition: transform var(--transition-neo-fast), box-shadow var(--transition-neo-fast);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.neo-btn:hover {
|
||||
transform: translate(-2px, -2px);
|
||||
box-shadow: var(--shadow-neo-lg);
|
||||
}
|
||||
|
||||
.neo-btn:active {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: var(--shadow-neo-sm);
|
||||
}
|
||||
|
||||
.neo-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: var(--shadow-neo-sm);
|
||||
}
|
||||
|
||||
.neo-btn-primary {
|
||||
background: var(--color-neo-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.neo-btn-success {
|
||||
background: var(--color-neo-done);
|
||||
color: var(--color-neo-text);
|
||||
}
|
||||
|
||||
.neo-btn-warning {
|
||||
background: var(--color-neo-pending);
|
||||
color: var(--color-neo-text);
|
||||
}
|
||||
|
||||
.neo-btn-danger {
|
||||
background: var(--color-neo-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.neo-btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--color-neo-text);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.neo-btn-ghost:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: var(--color-neo-text);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.neo-input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-family: var(--font-neo-sans);
|
||||
font-size: 1rem;
|
||||
color: var(--color-neo-text);
|
||||
background: white;
|
||||
border: 3px solid var(--color-neo-border);
|
||||
box-shadow: 3px 3px 0px rgba(0, 0, 0, 0.1);
|
||||
transition: transform var(--transition-neo-fast), box-shadow var(--transition-neo-fast);
|
||||
}
|
||||
|
||||
.neo-input::placeholder {
|
||||
color: var(--color-neo-text-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.neo-input:focus {
|
||||
outline: none;
|
||||
transform: translate(-1px, -1px);
|
||||
box-shadow: var(--shadow-neo-md);
|
||||
border-color: var(--color-neo-accent);
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.neo-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-family: var(--font-neo-display);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-neo-text);
|
||||
border: 2px solid var(--color-neo-border);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.neo-progress {
|
||||
width: 100%;
|
||||
height: 2rem;
|
||||
background: white;
|
||||
border: 3px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.neo-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--color-neo-done);
|
||||
transition: width 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.neo-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
backdrop-filter: blur(2px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.neo-modal {
|
||||
background: white;
|
||||
border: 4px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-xl);
|
||||
animation: popIn 0.3s var(--transition-neo-fast);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.neo-dropdown {
|
||||
background: white;
|
||||
border: 3px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-lg);
|
||||
}
|
||||
|
||||
.neo-dropdown-item {
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-neo-text);
|
||||
background: transparent;
|
||||
transition: background var(--transition-neo-fast), color var(--transition-neo-fast);
|
||||
}
|
||||
|
||||
.neo-dropdown-item:hover {
|
||||
background: var(--color-neo-pending);
|
||||
color: var(--color-neo-text);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.neo-tooltip {
|
||||
background: var(--color-neo-text);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border: 2px solid var(--color-neo-border);
|
||||
box-shadow: var(--shadow-neo-sm);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.neo-empty-state {
|
||||
background: var(--color-neo-bg);
|
||||
border: 4px dashed var(--color-neo-border);
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Animations
|
||||
============================================================================ */
|
||||
|
||||
@keyframes popIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes neoPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 6px 6px 0px rgba(0, 180, 216, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 6px 6px 0px rgba(0, 180, 216, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes completePop {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-pop-in {
|
||||
animation: popIn 0.3s var(--transition-neo-fast);
|
||||
}
|
||||
|
||||
.animate-pulse-neo {
|
||||
animation: neoPulse 2s infinite;
|
||||
}
|
||||
|
||||
.animate-complete {
|
||||
animation: completePop 0.5s var(--transition-neo-fast);
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Utilities
|
||||
============================================================================ */
|
||||
|
||||
.font-display {
|
||||
font-family: var(--font-neo-display);
|
||||
}
|
||||
|
||||
.font-sans {
|
||||
font-family: var(--font-neo-sans);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: var(--font-neo-mono);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-neo-bg);
|
||||
border: 2px solid var(--color-neo-border);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-neo-border);
|
||||
border: 2px solid var(--color-neo-border);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-neo-text-secondary);
|
||||
}
|
||||
1
ui/src/vite-env.d.ts
vendored
Normal file
1
ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
30
ui/tsconfig.json
Normal file
30
ui/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Paths */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
22
ui/tsconfig.node.json
Normal file
22
ui/tsconfig.node.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
ui/tsconfig.tsbuildinfo
Normal file
1
ui/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/addfeatureform.tsx","./src/components/agentcontrol.tsx","./src/components/featurecard.tsx","./src/components/featuremodal.tsx","./src/components/kanbanboard.tsx","./src/components/kanbancolumn.tsx","./src/components/progressdashboard.tsx","./src/components/projectselector.tsx","./src/components/setupwizard.tsx","./src/hooks/useprojects.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/types.ts"],"version":"5.6.3"}
|
||||
29
ui/vite.config.ts
Normal file
29
ui/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
// Backend port - can be overridden via VITE_API_PORT env var
|
||||
const apiPort = process.env.VITE_API_PORT || '8000'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: `http://127.0.0.1:${apiPort}`,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/ws': {
|
||||
target: `ws://127.0.0.1:${apiPort}`,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user