This commit is contained in:
Auto
2025-12-30 11:56:39 +02:00
parent dd7c1ddd82
commit a2efec159d
40 changed files with 9112 additions and 3 deletions

131
.gitignore vendored
View File

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

View File

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

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

View File

@@ -0,0 +1,10 @@
"""
Backend Services
================
Business logic and process management services.
"""
from .process_manager import AgentProcessManager
__all__ = ["AgentProcessManager"]

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

File diff suppressed because it is too large Load Diff

37
ui/package.json Normal file
View 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
View 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
View 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

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

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

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

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

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

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

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

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

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

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

@@ -0,0 +1 @@
/// <reference types="vite/client" />

30
ui/tsconfig.json Normal file
View 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
View 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
View 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
View 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,
},
},
},
})