feat: add concurrent agents with dependency system and delightful UI

Major feature implementation for parallel agent execution with dependency-aware
scheduling and an engaging multi-agent UI experience.

Backend Changes:
- Add parallel_orchestrator.py for concurrent feature processing
- Add api/dependency_resolver.py with cycle detection (Kahn's algorithm + DFS)
- Add atomic feature_claim_next() with retry limit and exponential backoff
- Fix circular dependency check arguments in 4 locations
- Add AgentTracker class for parsing agent output and emitting updates
- Add browser isolation with --isolated flag for Playwright MCP
- Extend WebSocket protocol with agent_update messages and log attribution
- Add WSAgentUpdateMessage schema with agent states and mascot names
- Fix WSProgressMessage to include in_progress field

New UI Components:
- AgentMissionControl: Dashboard showing active agents with collapsible activity
- AgentCard: Individual agent status with avatar and thought bubble
- AgentAvatar: SVG mascots (Spark, Fizz, Octo, Hoot, Buzz) with animations
- ActivityFeed: Recent activity stream with stable keys (no flickering)
- CelebrationOverlay: Confetti animation with click/Escape dismiss
- DependencyGraph: Interactive node graph visualization with dagre layout
- DependencyBadge: Visual indicator for feature dependencies
- ViewToggle: Switch between Kanban and Graph views
- KeyboardShortcutsHelp: Help overlay accessible via ? key

UI/UX Improvements:
- Celebration queue system to handle rapid success messages
- Accessibility attributes on AgentAvatar (role, aria-label, aria-live)
- Collapsible Recent Activity section with persisted preference
- Agent count display in header
- Keyboard shortcut G to toggle Kanban/Graph view
- Real-time thought bubbles and state animations

Bug Fixes:
- Fix circular dependency validation (swapped source/target arguments)
- Add MAX_CLAIM_RETRIES=10 to prevent stack overflow under contention
- Fix THOUGHT_PATTERNS to match actual [Tool: name] format
- Fix ActivityFeed key prop to prevent re-renders on new items
- Add featureId/agentIndex to log messages for proper attribution

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-01-17 12:59:42 +02:00
parent 91cc00a9d0
commit 85f6940a54
39 changed files with 4532 additions and 157 deletions

View File

@@ -32,23 +32,35 @@ Use the feature_create_bulk tool to add all features at once:
Use the feature_create_bulk tool with features=[
{
"category": "functional",
"name": "Brief feature name",
"description": "Brief description of the feature and what this test verifies",
"name": "User can create an account",
"description": "Basic user registration functionality",
"steps": [
"Step 1: Navigate to relevant page",
"Step 2: Perform action",
"Step 3: Verify expected result"
"Step 1: Navigate to registration page",
"Step 2: Fill in required fields",
"Step 3: Submit form and verify account created"
]
},
{
"category": "style",
"name": "Brief feature name",
"description": "Brief description of UI/UX requirement",
"category": "functional",
"name": "User can log in",
"description": "Authentication with existing credentials",
"steps": [
"Step 1: Navigate to page",
"Step 2: Take screenshot",
"Step 3: Verify visual requirements"
]
"Step 1: Navigate to login page",
"Step 2: Enter credentials",
"Step 3: Verify successful login and redirect"
],
"depends_on_indices": [0]
},
{
"category": "functional",
"name": "User can view dashboard",
"description": "Protected dashboard requires authentication",
"steps": [
"Step 1: Log in as user",
"Step 2: Navigate to dashboard",
"Step 3: Verify personalized content displays"
],
"depends_on_indices": [1]
}
]
```
@@ -57,6 +69,7 @@ Use the feature_create_bulk tool with features=[
- IDs and priorities are assigned automatically based on order
- All features start with `passes: false` by default
- You can create features in batches if there are many (e.g., 50 at a time)
- Use `depends_on_indices` to specify dependencies (see FEATURE DEPENDENCIES section below)
**Requirements for features:**
@@ -75,6 +88,86 @@ Use the feature_create_bulk tool with features=[
---
## FEATURE DEPENDENCIES
Dependencies enable **parallel execution** of independent features. When you specify dependencies correctly, multiple agents can work on unrelated features simultaneously, dramatically speeding up development.
### Why Dependencies Matter
1. **Parallel Execution**: Features without dependencies can run in parallel
2. **Logical Ordering**: Ensures features are built in the right order
3. **Blocking Prevention**: An agent won't start a feature until its dependencies pass
### How to Determine Dependencies
Ask yourself: "What MUST be working before this feature can be tested?"
| Dependency Type | Example |
|-----------------|---------|
| **Data dependencies** | "Edit item" depends on "Create item" |
| **Auth dependencies** | "View dashboard" depends on "User can log in" |
| **Navigation dependencies** | "Modal close works" depends on "Modal opens" |
| **UI dependencies** | "Filter results" depends on "Display results list" |
| **API dependencies** | "Fetch user data" depends on "API authentication" |
### Using `depends_on_indices`
Since feature IDs aren't assigned until after creation, use **array indices** (0-based) to reference dependencies:
```json
{
"features": [
{ "name": "Create account", ... }, // Index 0
{ "name": "Login", "depends_on_indices": [0] }, // Index 1, depends on 0
{ "name": "View profile", "depends_on_indices": [1] }, // Index 2, depends on 1
{ "name": "Edit profile", "depends_on_indices": [2] } // Index 3, depends on 2
]
}
```
### Rules for Dependencies
1. **Can only depend on EARLIER features**: Index must be less than current feature's position
2. **No circular dependencies**: A cannot depend on B if B depends on A
3. **Maximum 20 dependencies** per feature
4. **Foundation features have NO dependencies**: First features in each category typically have none
5. **Don't over-depend**: Only add dependencies that are truly required for testing
### Best Practices
1. **Start with foundation features** (index 0-10): Core setup, basic navigation, authentication
2. **Group related features together**: Keep CRUD operations adjacent
3. **Chain complex flows**: Registration → Login → Dashboard → Settings
4. **Keep dependencies shallow**: Prefer 1-2 dependencies over deep chains
5. **Skip dependencies for independent features**: Visual tests often have no dependencies
### Example: Todo App Feature Chain
```json
[
// Foundation (no dependencies)
{ "name": "App loads without errors", "category": "functional" },
{ "name": "Navigation bar displays", "category": "style" },
// Auth chain
{ "name": "User can register", "depends_on_indices": [0] },
{ "name": "User can login", "depends_on_indices": [2] },
{ "name": "User can logout", "depends_on_indices": [3] },
// Todo CRUD (depends on auth)
{ "name": "User can create todo", "depends_on_indices": [3] },
{ "name": "User can view todos", "depends_on_indices": [5] },
{ "name": "User can edit todo", "depends_on_indices": [5] },
{ "name": "User can delete todo", "depends_on_indices": [5] },
// Advanced features (multiple dependencies)
{ "name": "User can filter todos", "depends_on_indices": [6] },
{ "name": "User can search todos", "depends_on_indices": [6] }
]
```
---
## MANDATORY TEST CATEGORIES
The feature_list.json **MUST** include tests from ALL of these categories. The minimum counts scale by complexity tier.

4
.gitignore vendored
View File

@@ -1,9 +1,13 @@
# Agent-generated output directories
generations/
automaker/
nul
issues/
# Browser profiles for parallel agent execution
.browser-profiles/
# Log files
logs/
*.log

View File

@@ -19,8 +19,8 @@ from claude_agent_sdk import ClaudeSDKClient
# Fix Windows console encoding for Unicode characters (emoji, etc.)
# Without this, print() crashes when Claude outputs emoji like ✅
if sys.platform == "win32":
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace", line_buffering=True)
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace", line_buffering=True)
from client import create_client
from progress import has_features, print_progress_summary, print_session_header
@@ -29,6 +29,7 @@ from prompts import (
get_coding_prompt,
get_coding_prompt_yolo,
get_initializer_prompt,
get_single_feature_prompt,
)
# Configuration
@@ -114,6 +115,7 @@ async def run_autonomous_agent(
model: str,
max_iterations: Optional[int] = None,
yolo_mode: bool = False,
feature_id: Optional[int] = None,
) -> None:
"""
Run the autonomous agent loop.
@@ -123,6 +125,7 @@ async def run_autonomous_agent(
model: Claude model to use
max_iterations: Maximum number of iterations (None for unlimited)
yolo_mode: If True, skip browser testing and use YOLO prompt
feature_id: If set, work only on this specific feature (used by parallel orchestrator)
"""
print("\n" + "=" * 70)
print(" AUTONOMOUS CODING AGENT DEMO")
@@ -133,6 +136,8 @@ async def run_autonomous_agent(
print("Mode: YOLO (testing disabled)")
else:
print("Mode: Standard (full testing)")
if feature_id:
print(f"Single-feature mode: Feature #{feature_id}")
if max_iterations:
print(f"Max iterations: {max_iterations}")
else:
@@ -178,13 +183,18 @@ async def run_autonomous_agent(
print_session_header(iteration, is_first_run)
# Create client (fresh context)
client = create_client(project_dir, model, yolo_mode=yolo_mode)
# In single-feature mode, pass agent_id for browser isolation
agent_id = f"feature-{feature_id}" if feature_id else None
client = create_client(project_dir, model, yolo_mode=yolo_mode, agent_id=agent_id)
# Choose prompt based on session type
# Pass project_dir to enable project-specific prompts
if is_first_run:
prompt = get_initializer_prompt(project_dir)
is_first_run = False # Only use initializer once
elif feature_id:
# Single-feature mode (used by parallel orchestrator)
prompt = get_single_feature_prompt(feature_id, project_dir, yolo_mode)
else:
# Use YOLO prompt if in YOLO mode
if yolo_mode:

View File

@@ -8,7 +8,7 @@ SQLite database schema for feature storage using SQLAlchemy.
from pathlib import Path
from typing import Optional
from sqlalchemy import Boolean, Column, Integer, String, Text, create_engine
from sqlalchemy import Boolean, Column, Integer, String, Text, create_engine, text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.types import JSON
@@ -29,6 +29,9 @@ class Feature(Base):
steps = Column(JSON, nullable=False) # Stored as JSON array
passes = Column(Boolean, nullable=False, default=False, index=True)
in_progress = Column(Boolean, nullable=False, default=False, index=True)
# Dependencies: list of feature IDs that must be completed before this feature
# NULL/empty = no dependencies (backwards compatible)
dependencies = Column(JSON, nullable=True, default=None)
def to_dict(self) -> dict:
"""Convert feature to dictionary for JSON serialization."""
@@ -42,8 +45,18 @@ class Feature(Base):
# Handle legacy NULL values gracefully - treat as False
"passes": self.passes if self.passes is not None else False,
"in_progress": self.in_progress if self.in_progress is not None else False,
# Dependencies: NULL/empty treated as empty list for backwards compat
"dependencies": self.dependencies if self.dependencies else [],
}
def get_dependencies_safe(self) -> list[int]:
"""Safely extract dependencies, handling NULL and malformed data."""
if self.dependencies is None:
return []
if isinstance(self.dependencies, list):
return [d for d in self.dependencies if isinstance(d, int)]
return []
def get_database_path(project_dir: Path) -> Path:
"""Return the path to the SQLite database for a project."""
@@ -61,8 +74,6 @@ def get_database_url(project_dir: Path) -> str:
def _migrate_add_in_progress_column(engine) -> None:
"""Add in_progress column to existing databases that don't have it."""
from sqlalchemy import text
with engine.connect() as conn:
# Check if column exists
result = conn.execute(text("PRAGMA table_info(features)"))
@@ -76,8 +87,6 @@ def _migrate_add_in_progress_column(engine) -> None:
def _migrate_fix_null_boolean_fields(engine) -> None:
"""Fix NULL values in passes and in_progress columns."""
from sqlalchemy import text
with engine.connect() as conn:
# Fix NULL passes values
conn.execute(text("UPDATE features SET passes = 0 WHERE passes IS NULL"))
@@ -86,6 +95,23 @@ def _migrate_fix_null_boolean_fields(engine) -> None:
conn.commit()
def _migrate_add_dependencies_column(engine) -> None:
"""Add dependencies column to existing databases that don't have it.
Uses NULL default for backwards compatibility - existing features
without dependencies will have NULL which is treated as empty list.
"""
with engine.connect() as conn:
# Check if column exists
result = conn.execute(text("PRAGMA table_info(features)"))
columns = [row[1] for row in result.fetchall()]
if "dependencies" not in columns:
# Use TEXT for SQLite JSON storage, NULL default for backwards compat
conn.execute(text("ALTER TABLE features ADD COLUMN dependencies TEXT DEFAULT NULL"))
conn.commit()
def create_database(project_dir: Path) -> tuple:
"""
Create database and return engine + session maker.
@@ -97,12 +123,22 @@ def create_database(project_dir: Path) -> tuple:
Tuple of (engine, SessionLocal)
"""
db_url = get_database_url(project_dir)
engine = create_engine(db_url, connect_args={"check_same_thread": False})
engine = create_engine(db_url, connect_args={
"check_same_thread": False,
"timeout": 30 # Wait up to 30s for locks
})
Base.metadata.create_all(bind=engine)
# Enable WAL mode for better concurrent read/write performance
with engine.connect() as conn:
conn.execute(text("PRAGMA journal_mode=WAL"))
conn.execute(text("PRAGMA busy_timeout=30000"))
conn.commit()
# Migrate existing databases
_migrate_add_in_progress_column(engine)
_migrate_fix_null_boolean_fields(engine)
_migrate_add_dependencies_column(engine)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
return engine, SessionLocal

341
api/dependency_resolver.py Normal file
View File

@@ -0,0 +1,341 @@
"""
Dependency Resolver
===================
Provides dependency resolution using Kahn's algorithm for topological sorting.
Includes cycle detection, validation, and helper functions for dependency management.
"""
from typing import TypedDict
# Security: Prevent DoS via excessive dependencies
MAX_DEPENDENCIES_PER_FEATURE = 20
MAX_DEPENDENCY_DEPTH = 50 # Prevent stack overflow in cycle detection
class DependencyResult(TypedDict):
"""Result from dependency resolution."""
ordered_features: list[dict]
circular_dependencies: list[list[int]]
blocked_features: dict[int, list[int]] # feature_id -> [blocking_ids]
missing_dependencies: dict[int, list[int]] # feature_id -> [missing_ids]
def resolve_dependencies(features: list[dict]) -> DependencyResult:
"""Topological sort using Kahn's algorithm with priority-aware ordering.
Returns ordered features respecting dependencies, plus metadata about
cycles, blocked features, and missing dependencies.
Args:
features: List of feature dicts with id, priority, passes, and dependencies fields
Returns:
DependencyResult with ordered_features, circular_dependencies,
blocked_features, and missing_dependencies
"""
feature_map = {f["id"]: f for f in features}
in_degree = {f["id"]: 0 for f in features}
adjacency: dict[int, list[int]] = {f["id"]: [] for f in features}
blocked: dict[int, list[int]] = {}
missing: dict[int, list[int]] = {}
# Build graph
for feature in features:
deps = feature.get("dependencies") or []
for dep_id in deps:
if dep_id not in feature_map:
missing.setdefault(feature["id"], []).append(dep_id)
else:
adjacency[dep_id].append(feature["id"])
in_degree[feature["id"]] += 1
# Track blocked features
dep = feature_map[dep_id]
if not dep.get("passes"):
blocked.setdefault(feature["id"], []).append(dep_id)
# Kahn's algorithm with priority-aware selection
queue = [f for f in features if in_degree[f["id"]] == 0]
queue.sort(key=lambda f: (f.get("priority", 999), f["id"]))
ordered: list[dict] = []
while queue:
current = queue.pop(0)
ordered.append(current)
for dependent_id in adjacency[current["id"]]:
in_degree[dependent_id] -= 1
if in_degree[dependent_id] == 0:
queue.append(feature_map[dependent_id])
queue.sort(key=lambda f: (f.get("priority", 999), f["id"]))
# Detect cycles (features not in ordered = part of cycle)
cycles: list[list[int]] = []
if len(ordered) < len(features):
remaining = [f for f in features if f not in ordered]
cycles = _detect_cycles(remaining, feature_map)
ordered.extend(remaining) # Add cyclic features at end
return {
"ordered_features": ordered,
"circular_dependencies": cycles,
"blocked_features": blocked,
"missing_dependencies": missing,
}
def are_dependencies_satisfied(feature: dict, all_features: list[dict]) -> bool:
"""Check if all dependencies have passes=True.
Args:
feature: Feature dict to check
all_features: List of all feature dicts
Returns:
True if all dependencies are satisfied (or no dependencies)
"""
deps = feature.get("dependencies") or []
if not deps:
return True
passing_ids = {f["id"] for f in all_features if f.get("passes")}
return all(dep_id in passing_ids for dep_id in deps)
def get_blocking_dependencies(feature: dict, all_features: list[dict]) -> list[int]:
"""Get list of incomplete dependency IDs.
Args:
feature: Feature dict to check
all_features: List of all feature dicts
Returns:
List of feature IDs that are blocking this feature
"""
deps = feature.get("dependencies") or []
passing_ids = {f["id"] for f in all_features if f.get("passes")}
return [dep_id for dep_id in deps if dep_id not in passing_ids]
def would_create_circular_dependency(
features: list[dict], source_id: int, target_id: int
) -> bool:
"""Check if adding a dependency from target to source would create a cycle.
Uses DFS with visited set for efficient cycle detection.
Args:
features: List of all feature dicts
source_id: The feature that would gain the dependency
target_id: The feature that would become a dependency
Returns:
True if adding the dependency would create a cycle
"""
if source_id == target_id:
return True # Self-reference is a cycle
feature_map = {f["id"]: f for f in features}
source = feature_map.get(source_id)
if not source:
return False
# Check if target already depends on source (direct or indirect)
target = feature_map.get(target_id)
if not target:
return False
# DFS from target to see if we can reach source
visited: set[int] = set()
def can_reach(current_id: int, depth: int = 0) -> bool:
# Security: Prevent stack overflow with depth limit
if depth > MAX_DEPENDENCY_DEPTH:
return True # Assume cycle if too deep (fail-safe)
if current_id == source_id:
return True
if current_id in visited:
return False
visited.add(current_id)
current = feature_map.get(current_id)
if not current:
return False
deps = current.get("dependencies") or []
for dep_id in deps:
if can_reach(dep_id, depth + 1):
return True
return False
return can_reach(target_id)
def validate_dependencies(
feature_id: int, dependency_ids: list[int], all_feature_ids: set[int]
) -> tuple[bool, str]:
"""Validate dependency list.
Args:
feature_id: ID of the feature being validated
dependency_ids: List of proposed dependency IDs
all_feature_ids: Set of all valid feature IDs
Returns:
Tuple of (is_valid, error_message)
"""
# Security: Check limits
if len(dependency_ids) > MAX_DEPENDENCIES_PER_FEATURE:
return False, f"Maximum {MAX_DEPENDENCIES_PER_FEATURE} dependencies allowed"
# Check self-reference
if feature_id in dependency_ids:
return False, "A feature cannot depend on itself"
# Check all dependencies exist
missing = [d for d in dependency_ids if d not in all_feature_ids]
if missing:
return False, f"Dependencies not found: {missing}"
# Check for duplicates
if len(dependency_ids) != len(set(dependency_ids)):
return False, "Duplicate dependencies not allowed"
return True, ""
def _detect_cycles(features: list[dict], feature_map: dict) -> list[list[int]]:
"""Detect cycles using DFS with recursion tracking.
Args:
features: List of features to check for cycles
feature_map: Map of feature_id -> feature dict
Returns:
List of cycles, where each cycle is a list of feature IDs
"""
cycles: list[list[int]] = []
visited: set[int] = set()
rec_stack: set[int] = set()
path: list[int] = []
def dfs(fid: int) -> bool:
visited.add(fid)
rec_stack.add(fid)
path.append(fid)
feature = feature_map.get(fid)
if feature:
for dep_id in feature.get("dependencies") or []:
if dep_id not in visited:
if dfs(dep_id):
return True
elif dep_id in rec_stack:
cycle_start = path.index(dep_id)
cycles.append(path[cycle_start:])
return True
path.pop()
rec_stack.remove(fid)
return False
for f in features:
if f["id"] not in visited:
dfs(f["id"])
return cycles
def get_ready_features(features: list[dict], limit: int = 10) -> list[dict]:
"""Get features that are ready to be worked on.
A feature is ready if:
- It is not passing
- It is not in progress
- All its dependencies are satisfied
Args:
features: List of all feature dicts
limit: Maximum number of features to return
Returns:
List of ready features, sorted by priority
"""
passing_ids = {f["id"] for f in features if f.get("passes")}
ready = []
for f in features:
if f.get("passes") or f.get("in_progress"):
continue
deps = f.get("dependencies") or []
if all(dep_id in passing_ids for dep_id in deps):
ready.append(f)
# Sort by priority
ready.sort(key=lambda f: (f.get("priority", 999), f["id"]))
return ready[:limit]
def get_blocked_features(features: list[dict]) -> list[dict]:
"""Get features that are blocked by unmet dependencies.
Args:
features: List of all feature dicts
Returns:
List of blocked features with 'blocked_by' field added
"""
passing_ids = {f["id"] for f in features if f.get("passes")}
blocked = []
for f in features:
if f.get("passes"):
continue
deps = f.get("dependencies") or []
blocking = [d for d in deps if d not in passing_ids]
if blocking:
blocked.append({**f, "blocked_by": blocking})
return blocked
def build_graph_data(features: list[dict]) -> dict:
"""Build graph data structure for visualization.
Args:
features: List of all feature dicts
Returns:
Dict with 'nodes' and 'edges' for graph visualization
"""
passing_ids = {f["id"] for f in features if f.get("passes")}
nodes = []
edges = []
for f in features:
deps = f.get("dependencies") or []
blocking = [d for d in deps if d not in passing_ids]
if f.get("passes"):
status = "done"
elif blocking:
status = "blocked"
elif f.get("in_progress"):
status = "in_progress"
else:
status = "pending"
nodes.append({
"id": f["id"],
"name": f["name"],
"category": f["category"],
"status": status,
"priority": f.get("priority", 999),
"dependencies": deps,
})
for dep_id in deps:
edges.append({"source": dep_id, "target": f["id"]})
return {"nodes": nodes, "edges": edges}

View File

@@ -19,6 +19,12 @@ Example Usage:
# YOLO mode: rapid prototyping without browser testing
python autonomous_agent_demo.py --project-dir my-app --yolo
# Parallel execution with 3 concurrent agents (default)
python autonomous_agent_demo.py --project-dir my-app --parallel
# Parallel execution with 5 concurrent agents
python autonomous_agent_demo.py --project-dir my-app --parallel 5
"""
import argparse
@@ -91,6 +97,24 @@ Authentication:
help="Enable YOLO mode: rapid prototyping without browser testing",
)
parser.add_argument(
"--parallel",
"-p",
type=int,
nargs="?",
const=3,
default=None,
metavar="N",
help="Enable parallel execution with N concurrent agents (default: 3, max: 5)",
)
parser.add_argument(
"--feature-id",
type=int,
default=None,
help="Work on a specific feature ID only (used by parallel orchestrator)",
)
return parser.parse_args()
@@ -123,15 +147,30 @@ def main() -> None:
return
try:
# Run the agent (MCP server handles feature database)
asyncio.run(
run_autonomous_agent(
project_dir=project_dir,
model=args.model,
max_iterations=args.max_iterations,
yolo_mode=args.yolo,
if args.parallel is not None:
# Parallel execution mode
from parallel_orchestrator import run_parallel_orchestrator
print(f"Running in parallel mode with {args.parallel} concurrent agents")
asyncio.run(
run_parallel_orchestrator(
project_dir=project_dir,
max_concurrency=args.parallel,
model=args.model,
yolo_mode=args.yolo,
)
)
else:
# Standard single-agent mode (MCP server handles feature database)
asyncio.run(
run_autonomous_agent(
project_dir=project_dir,
model=args.model,
max_iterations=args.max_iterations,
yolo_mode=args.yolo,
feature_id=args.feature_id,
)
)
)
except KeyboardInterrupt:
print("\n\nInterrupted by user")
print("To resume, run the same command again")

View File

@@ -52,13 +52,25 @@ def get_playwright_headless() -> bool:
# Feature MCP tools for feature/test management
FEATURE_MCP_TOOLS = [
# Core feature operations
"mcp__features__feature_get_stats",
"mcp__features__feature_get_next",
"mcp__features__feature_claim_next", # Atomic get+claim for parallel execution
"mcp__features__feature_get_for_regression",
"mcp__features__feature_mark_in_progress",
"mcp__features__feature_mark_passing",
"mcp__features__feature_skip",
"mcp__features__feature_create_bulk",
"mcp__features__feature_create",
"mcp__features__feature_clear_in_progress",
# Dependency management
"mcp__features__feature_add_dependency",
"mcp__features__feature_remove_dependency",
"mcp__features__feature_set_dependencies",
# Parallel execution support
"mcp__features__feature_get_ready",
"mcp__features__feature_get_blocked",
"mcp__features__feature_get_graph",
]
# Playwright MCP tools for browser automation
@@ -107,7 +119,12 @@ BUILTIN_TOOLS = [
]
def create_client(project_dir: Path, model: str, yolo_mode: bool = False):
def create_client(
project_dir: Path,
model: str,
yolo_mode: bool = False,
agent_id: str | None = None,
):
"""
Create a Claude Agent SDK client with multi-layered security.
@@ -115,6 +132,8 @@ def create_client(project_dir: Path, model: str, yolo_mode: bool = False):
project_dir: Directory for the project
model: Claude model to use
yolo_mode: If True, skip Playwright MCP server for rapid prototyping
agent_id: Optional unique identifier for browser isolation in parallel mode.
When provided, each agent gets its own browser profile.
Returns:
Configured ClaudeSDKClient (from claude_agent_sdk)
@@ -211,6 +230,16 @@ def create_client(project_dir: Path, model: str, yolo_mode: bool = False):
playwright_args = ["@playwright/mcp@latest", "--viewport-size", "1280x720"]
if get_playwright_headless():
playwright_args.append("--headless")
# Browser isolation for parallel execution
# Each agent gets its own isolated browser context to prevent tab conflicts
if agent_id:
# Use --isolated for ephemeral browser context
# This creates a fresh, isolated context without persistent state
# Note: --isolated and --user-data-dir are mutually exclusive
playwright_args.append("--isolated")
print(f" - Browser isolation enabled for agent: {agent_id}")
mcp_servers["playwright"] = {
"command": "npx",
"args": playwright_args,

View File

@@ -22,12 +22,14 @@ import json
import os
import sys
import threading
import time as _time
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
from sqlalchemy import text
from sqlalchemy.sql.expression import func
# Add parent directory to path so we can import from api module
@@ -35,6 +37,12 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from api.database import Feature, create_database
from api.migration import migrate_json_to_sqlite
from api.dependency_resolver import (
would_create_circular_dependency,
are_dependencies_satisfied,
get_blocking_dependencies,
MAX_DEPENDENCIES_PER_FEATURE,
)
# Configuration from environment
PROJECT_DIR = Path(os.environ.get("PROJECT_DIR", ".")).resolve()
@@ -148,32 +156,192 @@ def feature_get_stats() -> str:
@mcp.tool()
def feature_get_next() -> str:
"""Get the highest-priority pending feature to work on.
"""Get the highest-priority pending feature that has all dependencies satisfied.
Returns the feature with the lowest priority number that has passes=false.
Use this at the start of each coding session to determine what to implement next.
Returns the feature with the lowest priority number that:
1. Has passes=false and in_progress=false
2. Has all dependency features already passing (or no dependencies)
3. All dependency IDs actually exist (orphaned dependencies are ignored)
For backwards compatibility: if all pending features are blocked by dependencies,
falls back to returning the first pending feature (same as before dependencies).
Returns:
JSON with feature details (id, priority, category, name, description, steps, passes, in_progress)
or error message if all features are passing.
JSON with feature details (id, priority, category, name, description, steps, passes,
in_progress, dependencies) or error message if all features are passing.
"""
session = get_session()
try:
feature = (
session.query(Feature)
.filter(Feature.passes == False)
.order_by(Feature.priority.asc(), Feature.id.asc())
.first()
)
all_features = session.query(Feature).all()
all_feature_ids = {f.id for f in all_features}
passing_ids = {f.id for f in all_features if f.passes}
if feature is None:
# Get pending, non-in-progress features
pending = [f for f in all_features if not f.passes and not f.in_progress]
pending.sort(key=lambda f: (f.priority, f.id))
if not pending:
if any(f.in_progress for f in all_features if not f.passes):
return json.dumps({"error": "All pending features are in progress by other agents"})
return json.dumps({"error": "All features are passing! No more work to do."})
return json.dumps(feature.to_dict(), indent=2)
# Find first feature with satisfied dependencies
for feature in pending:
deps = feature.dependencies or []
# Filter out orphaned dependencies (IDs that no longer exist)
valid_deps = [d for d in deps if d in all_feature_ids]
if all(dep_id in passing_ids for dep_id in valid_deps):
return json.dumps(feature.to_dict(), indent=2)
# All pending features are blocked by unmet dependencies
# Return error with details about what's blocking progress
blocking_info = []
for feature in pending[:3]: # Show first 3 blocked features
deps = feature.dependencies or []
valid_deps = [d for d in deps if d in all_feature_ids]
orphaned = [d for d in deps if d not in all_feature_ids]
unmet = [d for d in valid_deps if d not in passing_ids]
info = f"#{feature.id} '{feature.name}'"
if unmet:
info += f" blocked by: {unmet}"
if orphaned:
info += f" (orphaned deps ignored: {orphaned})"
blocking_info.append(info)
return json.dumps({
"error": "All pending features are blocked by unmet dependencies",
"blocked_features": len(pending),
"examples": blocking_info,
"hint": "Complete the blocking dependencies first, or remove invalid dependencies"
}, indent=2)
finally:
session.close()
# Maximum retry attempts for feature claiming under contention
MAX_CLAIM_RETRIES = 10
def _feature_claim_next_internal(attempt: int = 0) -> str:
"""Internal implementation of feature claiming with retry tracking.
Args:
attempt: Current retry attempt (0-indexed)
Returns:
JSON with claimed feature details, or error message if no feature available.
"""
if attempt >= MAX_CLAIM_RETRIES:
return json.dumps({
"error": "Failed to claim feature after maximum retries",
"hint": "High contention detected - try again or reduce parallel agents"
})
session = get_session()
try:
# Use a lock to prevent concurrent claims within this process
with _priority_lock:
all_features = session.query(Feature).all()
all_feature_ids = {f.id for f in all_features}
passing_ids = {f.id for f in all_features if f.passes}
# Get pending, non-in-progress features
pending = [f for f in all_features if not f.passes and not f.in_progress]
pending.sort(key=lambda f: (f.priority, f.id))
if not pending:
if any(f.in_progress for f in all_features if not f.passes):
return json.dumps({"error": "All pending features are in progress by other agents"})
return json.dumps({"error": "All features are passing! No more work to do."})
# Find first feature with satisfied dependencies
candidate_id = None
for feature in pending:
deps = feature.dependencies or []
# Filter out orphaned dependencies (IDs that no longer exist)
valid_deps = [d for d in deps if d in all_feature_ids]
if all(dep_id in passing_ids for dep_id in valid_deps):
candidate_id = feature.id
break
if candidate_id is None:
# All pending features are blocked by unmet dependencies
blocking_info = []
for feature in pending[:3]:
deps = feature.dependencies or []
valid_deps = [d for d in deps if d in all_feature_ids]
orphaned = [d for d in deps if d not in all_feature_ids]
unmet = [d for d in valid_deps if d not in passing_ids]
info = f"#{feature.id} '{feature.name}'"
if unmet:
info += f" blocked by: {unmet}"
if orphaned:
info += f" (orphaned deps ignored: {orphaned})"
blocking_info.append(info)
return json.dumps({
"error": "All pending features are blocked by unmet dependencies",
"blocked_features": len(pending),
"examples": blocking_info,
"hint": "Complete the blocking dependencies first, or remove invalid dependencies"
}, indent=2)
# Atomic claim: UPDATE only if still claimable
# This prevents race conditions even across processes
result = session.execute(
text("""
UPDATE features
SET in_progress = 1
WHERE id = :feature_id
AND in_progress = 0
AND passes = 0
"""),
{"feature_id": candidate_id}
)
session.commit()
# Check if we actually claimed it
if result.rowcount == 0:
# Another process claimed it first - retry with backoff
session.close()
# Exponential backoff: 0.1s, 0.2s, 0.4s, ... up to 1.0s
backoff = min(0.1 * (2 ** attempt), 1.0)
_time.sleep(backoff)
return _feature_claim_next_internal(attempt + 1)
# Fetch the claimed feature
session.expire_all() # Clear cache to get fresh data
claimed_feature = session.query(Feature).filter(Feature.id == candidate_id).first()
return json.dumps(claimed_feature.to_dict(), indent=2)
except Exception as e:
session.rollback()
return json.dumps({"error": f"Failed to claim feature: {str(e)}"})
finally:
session.close()
@mcp.tool()
def feature_claim_next() -> str:
"""Atomically get and claim the next available feature.
This combines feature_get_next() and feature_mark_in_progress() in a single
atomic operation, preventing race conditions where two agents could claim
the same feature.
Returns the feature with the lowest priority number that:
1. Has passes=false and in_progress=false
2. Has all dependency features already passing (or no dependencies)
3. All dependency IDs actually exist (orphaned dependencies are ignored)
On success, the feature's in_progress flag is set to True.
Returns:
JSON with claimed feature details, or error message if no feature available.
"""
return _feature_claim_next_internal(attempt=0)
@mcp.tool()
def feature_get_for_regression(
limit: Annotated[int, Field(default=3, ge=1, le=10, description="Maximum number of passing features to return")] = 3
@@ -382,9 +550,13 @@ def feature_create_bulk(
- name (str): Feature name
- description (str): Detailed description
- steps (list[str]): Implementation/test steps
- depends_on_indices (list[int], optional): Array indices (0-based) of
features in THIS batch that this feature depends on. Use this instead
of 'dependencies' since IDs aren't known until after creation.
Example: [0, 2] means this feature depends on features at index 0 and 2.
Returns:
JSON with: created (int) - number of features created
JSON with: created (int) - number of features created, with_dependencies (int)
"""
session = get_session()
try:
@@ -394,7 +566,7 @@ def feature_create_bulk(
max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
start_priority = (max_priority_result[0] + 1) if max_priority_result else 1
created_count = 0
# First pass: validate all features and their index-based dependencies
for i, feature_data in enumerate(features):
# Validate required fields
if not all(key in feature_data for key in ["category", "name", "description", "steps"]):
@@ -402,6 +574,33 @@ def feature_create_bulk(
"error": f"Feature at index {i} missing required fields (category, name, description, steps)"
})
# Validate depends_on_indices
indices = feature_data.get("depends_on_indices", [])
if indices:
# Check max dependencies
if len(indices) > MAX_DEPENDENCIES_PER_FEATURE:
return json.dumps({
"error": f"Feature at index {i} has {len(indices)} dependencies, max is {MAX_DEPENDENCIES_PER_FEATURE}"
})
# Check for duplicates
if len(indices) != len(set(indices)):
return json.dumps({
"error": f"Feature at index {i} has duplicate dependencies"
})
# Check for forward references (can only depend on earlier features)
for idx in indices:
if not isinstance(idx, int) or idx < 0:
return json.dumps({
"error": f"Feature at index {i} has invalid dependency index: {idx}"
})
if idx >= i:
return json.dumps({
"error": f"Feature at index {i} cannot depend on feature at index {idx} (forward reference not allowed)"
})
# Second pass: create all features
created_features: list[Feature] = []
for i, feature_data in enumerate(features):
db_feature = Feature(
priority=start_priority + i,
category=feature_data["category"],
@@ -412,11 +611,27 @@ def feature_create_bulk(
in_progress=False,
)
session.add(db_feature)
created_count += 1
created_features.append(db_feature)
# Flush to get IDs assigned
session.flush()
# Third pass: resolve index-based dependencies to actual IDs
deps_count = 0
for i, feature_data in enumerate(features):
indices = feature_data.get("depends_on_indices", [])
if indices:
# Convert indices to actual feature IDs
dep_ids = [created_features[idx].id for idx in indices]
created_features[i].dependencies = sorted(dep_ids)
deps_count += 1
session.commit()
return json.dumps({"created": created_count}, indent=2)
return json.dumps({
"created": len(created_features),
"with_dependencies": deps_count
}, indent=2)
except Exception as e:
session.rollback()
return json.dumps({"error": str(e)})
@@ -479,5 +694,298 @@ def feature_create(
session.close()
@mcp.tool()
def feature_add_dependency(
feature_id: Annotated[int, Field(ge=1, description="Feature to add dependency to")],
dependency_id: Annotated[int, Field(ge=1, description="ID of the dependency feature")]
) -> str:
"""Add a dependency relationship between features.
The dependency_id feature must be completed before feature_id can be started.
Validates: self-reference, existence, circular dependencies, max limit.
Args:
feature_id: The ID of the feature that will depend on another feature
dependency_id: The ID of the feature that must be completed first
Returns:
JSON with success status and updated dependencies list, or error message
"""
session = get_session()
try:
# Security: Self-reference check
if feature_id == dependency_id:
return json.dumps({"error": "A feature cannot depend on itself"})
feature = session.query(Feature).filter(Feature.id == feature_id).first()
dependency = session.query(Feature).filter(Feature.id == dependency_id).first()
if not feature:
return json.dumps({"error": f"Feature {feature_id} not found"})
if not dependency:
return json.dumps({"error": f"Dependency feature {dependency_id} not found"})
current_deps = feature.dependencies or []
# Security: Max dependencies limit
if len(current_deps) >= MAX_DEPENDENCIES_PER_FEATURE:
return json.dumps({"error": f"Maximum {MAX_DEPENDENCIES_PER_FEATURE} dependencies allowed per feature"})
# Check if already exists
if dependency_id in current_deps:
return json.dumps({"error": "Dependency already exists"})
# Security: Circular dependency check
# would_create_circular_dependency(features, source_id, target_id)
# source_id = feature gaining the dependency, target_id = feature being depended upon
all_features = [f.to_dict() for f in session.query(Feature).all()]
if would_create_circular_dependency(all_features, feature_id, dependency_id):
return json.dumps({"error": "Cannot add: would create circular dependency"})
# Add dependency
current_deps.append(dependency_id)
feature.dependencies = sorted(current_deps)
session.commit()
return json.dumps({
"success": True,
"feature_id": feature_id,
"dependencies": feature.dependencies
})
finally:
session.close()
@mcp.tool()
def feature_remove_dependency(
feature_id: Annotated[int, Field(ge=1, description="Feature to remove dependency from")],
dependency_id: Annotated[int, Field(ge=1, description="ID of dependency to remove")]
) -> str:
"""Remove a dependency from a feature.
Args:
feature_id: The ID of the feature to remove a dependency from
dependency_id: The ID of the dependency to remove
Returns:
JSON with success status and updated dependencies list, or error message
"""
session = get_session()
try:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if not feature:
return json.dumps({"error": f"Feature {feature_id} not found"})
current_deps = feature.dependencies or []
if dependency_id not in current_deps:
return json.dumps({"error": "Dependency does not exist"})
current_deps.remove(dependency_id)
feature.dependencies = current_deps if current_deps else None
session.commit()
return json.dumps({
"success": True,
"feature_id": feature_id,
"dependencies": feature.dependencies or []
})
finally:
session.close()
@mcp.tool()
def feature_get_ready(
limit: Annotated[int, Field(default=10, ge=1, le=50, description="Max features to return")] = 10
) -> str:
"""Get all features ready to start (dependencies satisfied, not in progress).
Useful for parallel execution - returns multiple features that can run simultaneously.
A feature is ready if it is not passing, not in progress, and all dependencies are passing.
Args:
limit: Maximum number of features to return (1-50, default 10)
Returns:
JSON with: features (list), count (int), total_ready (int)
"""
session = get_session()
try:
all_features = session.query(Feature).all()
passing_ids = {f.id for f in all_features if f.passes}
ready = []
for f in all_features:
if f.passes or f.in_progress:
continue
deps = f.dependencies or []
if all(dep_id in passing_ids for dep_id in deps):
ready.append(f.to_dict())
# Sort by priority
ready.sort(key=lambda f: (f["priority"], f["id"]))
return json.dumps({
"features": ready[:limit],
"count": len(ready[:limit]),
"total_ready": len(ready)
}, indent=2)
finally:
session.close()
@mcp.tool()
def feature_get_blocked() -> str:
"""Get all features that are blocked by unmet dependencies.
Returns features that have dependencies which are not yet passing.
Each feature includes a 'blocked_by' field listing the blocking feature IDs.
Returns:
JSON with: features (list with blocked_by field), count (int)
"""
session = get_session()
try:
all_features = session.query(Feature).all()
passing_ids = {f.id for f in all_features if f.passes}
blocked = []
for f in all_features:
if f.passes:
continue
deps = f.dependencies or []
blocking = [d for d in deps if d not in passing_ids]
if blocking:
blocked.append({
**f.to_dict(),
"blocked_by": blocking
})
return json.dumps({
"features": blocked,
"count": len(blocked)
}, indent=2)
finally:
session.close()
@mcp.tool()
def feature_get_graph() -> str:
"""Get dependency graph data for visualization.
Returns nodes (features) and edges (dependencies) for rendering a graph.
Each node includes status: 'pending', 'in_progress', 'done', or 'blocked'.
Returns:
JSON with: nodes (list), edges (list of {source, target})
"""
session = get_session()
try:
all_features = session.query(Feature).all()
passing_ids = {f.id for f in all_features if f.passes}
nodes = []
edges = []
for f in all_features:
deps = f.dependencies or []
blocking = [d for d in deps if d not in passing_ids]
if f.passes:
status = "done"
elif blocking:
status = "blocked"
elif f.in_progress:
status = "in_progress"
else:
status = "pending"
nodes.append({
"id": f.id,
"name": f.name,
"category": f.category,
"status": status,
"priority": f.priority,
"dependencies": deps
})
for dep_id in deps:
edges.append({"source": dep_id, "target": f.id})
return json.dumps({
"nodes": nodes,
"edges": edges
}, indent=2)
finally:
session.close()
@mcp.tool()
def feature_set_dependencies(
feature_id: Annotated[int, Field(ge=1, description="Feature to set dependencies for")],
dependency_ids: Annotated[list[int], Field(description="List of dependency feature IDs")]
) -> str:
"""Set all dependencies for a feature at once, replacing any existing dependencies.
Validates: self-reference, existence of all dependencies, circular dependencies, max limit.
Args:
feature_id: The ID of the feature to set dependencies for
dependency_ids: List of feature IDs that must be completed first
Returns:
JSON with success status and updated dependencies list, or error message
"""
session = get_session()
try:
# Security: Self-reference check
if feature_id in dependency_ids:
return json.dumps({"error": "A feature cannot depend on itself"})
# Security: Max dependencies limit
if len(dependency_ids) > MAX_DEPENDENCIES_PER_FEATURE:
return json.dumps({"error": f"Maximum {MAX_DEPENDENCIES_PER_FEATURE} dependencies allowed"})
# Check for duplicates
if len(dependency_ids) != len(set(dependency_ids)):
return json.dumps({"error": "Duplicate dependencies not allowed"})
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if not feature:
return json.dumps({"error": f"Feature {feature_id} not found"})
# Validate all dependencies exist
all_feature_ids = {f.id for f in session.query(Feature).all()}
missing = [d for d in dependency_ids if d not in all_feature_ids]
if missing:
return json.dumps({"error": f"Dependencies not found: {missing}"})
# Check for circular dependencies
all_features = [f.to_dict() for f in session.query(Feature).all()]
# Temporarily update the feature's dependencies for cycle check
test_features = []
for f in all_features:
if f["id"] == feature_id:
test_features.append({**f, "dependencies": dependency_ids})
else:
test_features.append(f)
for dep_id in dependency_ids:
# source_id = feature_id (gaining dep), target_id = dep_id (being depended upon)
if would_create_circular_dependency(test_features, feature_id, dep_id):
return json.dumps({"error": f"Cannot add dependency {dep_id}: would create circular dependency"})
# Set dependencies
feature.dependencies = sorted(dependency_ids) if dependency_ids else None
session.commit()
return json.dumps({
"success": True,
"feature_id": feature_id,
"dependencies": feature.dependencies or []
})
finally:
session.close()
if __name__ == "__main__":
mcp.run()

504
parallel_orchestrator.py Normal file
View File

@@ -0,0 +1,504 @@
"""
Parallel Orchestrator
=====================
Coordinates parallel execution of independent features using multiple agent processes.
Uses dependency-aware scheduling to ensure features are only started when their
dependencies are satisfied.
Usage:
python parallel_orchestrator.py --project-dir my-app --max-concurrency 3
"""
import asyncio
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Callable, Awaitable
from api.database import Feature, create_database
from api.dependency_resolver import are_dependencies_satisfied
# Root directory of autocoder (where this script and autonomous_agent_demo.py live)
AUTOCODER_ROOT = Path(__file__).parent.resolve()
# Performance: Limit parallel agents to prevent memory exhaustion
MAX_PARALLEL_AGENTS = 5
DEFAULT_CONCURRENCY = 3
POLL_INTERVAL = 5 # seconds between checking for ready features
MAX_FEATURE_RETRIES = 3 # Maximum times to retry a failed feature
class ParallelOrchestrator:
"""Orchestrates parallel execution of independent features."""
def __init__(
self,
project_dir: Path,
max_concurrency: int = DEFAULT_CONCURRENCY,
model: str = None,
yolo_mode: bool = False,
on_output: Callable[[int, str], None] = None,
on_status: Callable[[int, str], None] = None,
):
"""Initialize the orchestrator.
Args:
project_dir: Path to the project directory
max_concurrency: Maximum number of concurrent agents (1-5)
model: Claude model to use (or None for default)
yolo_mode: Whether to run in YOLO mode (skip browser testing)
on_output: Callback for agent output (feature_id, line)
on_status: Callback for agent status changes (feature_id, status)
"""
self.project_dir = project_dir
self.max_concurrency = min(max(max_concurrency, 1), MAX_PARALLEL_AGENTS)
self.model = model
self.yolo_mode = yolo_mode
self.on_output = on_output
self.on_status = on_status
# Thread-safe state
self._lock = threading.Lock()
self.running_agents: dict[int, subprocess.Popen] = {}
self.abort_events: dict[int, threading.Event] = {}
self.is_running = False
# Track feature failures to prevent infinite retry loops
self._failure_counts: dict[int, int] = {}
# Database session for this orchestrator
self._engine, self._session_maker = create_database(project_dir)
def get_session(self):
"""Get a new database session."""
return self._session_maker()
def get_resumable_features(self) -> list[dict]:
"""Get features that were left in_progress from a previous session.
These are features where in_progress=True but passes=False, and they're
not currently being worked on by this orchestrator. This handles the case
where a previous session was interrupted before completing the feature.
"""
session = self.get_session()
try:
# Find features that are in_progress but not complete
stale = session.query(Feature).filter(
Feature.in_progress == True,
Feature.passes == False
).all()
resumable = []
for f in stale:
# Skip if already running in this orchestrator instance
with self._lock:
if f.id in self.running_agents:
continue
# Skip if feature has failed too many times
if self._failure_counts.get(f.id, 0) >= MAX_FEATURE_RETRIES:
continue
resumable.append(f.to_dict())
# Sort by priority (highest priority first)
resumable.sort(key=lambda f: (f["priority"], f["id"]))
return resumable
finally:
session.close()
def get_ready_features(self) -> list[dict]:
"""Get features with satisfied dependencies, not already running."""
session = self.get_session()
try:
all_features = session.query(Feature).all()
all_dicts = [f.to_dict() for f in all_features]
ready = []
for f in all_features:
if f.passes or f.in_progress:
continue
# Skip if already running in this orchestrator
with self._lock:
if f.id in self.running_agents:
continue
# Skip if feature has failed too many times
if self._failure_counts.get(f.id, 0) >= MAX_FEATURE_RETRIES:
continue
# Check dependencies
if are_dependencies_satisfied(f.to_dict(), all_dicts):
ready.append(f.to_dict())
# Sort by priority
ready.sort(key=lambda f: (f["priority"], f["id"]))
return ready
finally:
session.close()
def get_all_complete(self) -> bool:
"""Check if all features are complete."""
session = self.get_session()
try:
pending = session.query(Feature).filter(Feature.passes == False).count()
return pending == 0
finally:
session.close()
def start_feature(self, feature_id: int, resume: bool = False) -> tuple[bool, str]:
"""Start a single feature agent.
Args:
feature_id: ID of the feature to start
resume: If True, resume a feature that's already in_progress from a previous session
Returns:
Tuple of (success, message)
"""
with self._lock:
if feature_id in self.running_agents:
return False, "Feature already running"
if len(self.running_agents) >= self.max_concurrency:
return False, "At max concurrency"
# Mark as in_progress in database (or verify it's resumable)
session = self.get_session()
try:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if not feature:
return False, "Feature not found"
if feature.passes:
return False, "Feature already complete"
if resume:
# Resuming: feature should already be in_progress
if not feature.in_progress:
return False, "Feature not in progress, cannot resume"
else:
# Starting fresh: feature should not be in_progress
if feature.in_progress:
return False, "Feature already in progress"
feature.in_progress = True
session.commit()
finally:
session.close()
# Create abort event
abort_event = threading.Event()
# Start subprocess for this feature
cmd = [
sys.executable,
"-u", # Force unbuffered stdout/stderr
str(AUTOCODER_ROOT / "autonomous_agent_demo.py"),
"--project-dir", str(self.project_dir),
"--max-iterations", "1", # Single feature mode
"--feature-id", str(feature_id), # Work on this specific feature only
]
if self.model:
cmd.extend(["--model", self.model])
if self.yolo_mode:
cmd.append("--yolo")
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
cwd=str(AUTOCODER_ROOT), # Run from autocoder root for proper imports
env={**os.environ, "PYTHONUNBUFFERED": "1"},
)
except Exception as e:
# Reset in_progress on failure
session = self.get_session()
try:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if feature:
feature.in_progress = False
session.commit()
finally:
session.close()
return False, f"Failed to start agent: {e}"
with self._lock:
self.running_agents[feature_id] = proc
self.abort_events[feature_id] = abort_event
# Start output reader thread
threading.Thread(
target=self._read_output,
args=(feature_id, proc, abort_event),
daemon=True
).start()
if self.on_status:
self.on_status(feature_id, "running")
print(f"Started agent for feature #{feature_id}", flush=True)
return True, f"Started feature {feature_id}"
def _read_output(self, feature_id: int, proc: subprocess.Popen, abort: threading.Event):
"""Read output from subprocess and emit events."""
try:
for line in proc.stdout:
if abort.is_set():
break
line = line.rstrip()
if self.on_output:
self.on_output(feature_id, line)
else:
print(f"[Feature #{feature_id}] {line}", flush=True)
proc.wait()
finally:
self._on_feature_complete(feature_id, proc.returncode)
def _on_feature_complete(self, feature_id: int, return_code: int):
"""Handle feature completion.
ALWAYS clears in_progress when agent exits, regardless of success/failure.
This prevents features from getting stuck if an agent crashes or is killed.
The agent marks features as passing BEFORE clearing in_progress, so this
is safe - we won't accidentally clear a feature that's being worked on.
"""
with self._lock:
self.running_agents.pop(feature_id, None)
self.abort_events.pop(feature_id, None)
# ALWAYS clear in_progress when agent exits to prevent stuck features
# The agent marks features as passing before clearing in_progress,
# so if in_progress is still True here, the feature didn't complete successfully
session = self.get_session()
try:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
if feature and feature.in_progress and not feature.passes:
feature.in_progress = False
session.commit()
finally:
session.close()
# Track failures to prevent infinite retry loops
if return_code != 0:
with self._lock:
self._failure_counts[feature_id] = self._failure_counts.get(feature_id, 0) + 1
failure_count = self._failure_counts[feature_id]
if failure_count >= MAX_FEATURE_RETRIES:
print(f"Feature #{feature_id} has failed {failure_count} times, will not retry", flush=True)
status = "completed" if return_code == 0 else "failed"
if self.on_status:
self.on_status(feature_id, status)
print(f"Feature #{feature_id} {status}", flush=True)
def stop_feature(self, feature_id: int) -> tuple[bool, str]:
"""Stop a running feature agent."""
with self._lock:
if feature_id not in self.running_agents:
return False, "Feature not running"
abort = self.abort_events.get(feature_id)
proc = self.running_agents.get(feature_id)
if abort:
abort.set()
if proc:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
return True, f"Stopped feature {feature_id}"
def stop_all(self) -> None:
"""Stop all running feature agents."""
self.is_running = False
with self._lock:
feature_ids = list(self.running_agents.keys())
for fid in feature_ids:
self.stop_feature(fid)
async def run_loop(self):
"""Main orchestration loop."""
self.is_running = True
print(f"Starting parallel orchestrator with max_concurrency={self.max_concurrency}", flush=True)
print(f"Project: {self.project_dir}", flush=True)
print(flush=True)
# Check for features to resume from previous session
resumable = self.get_resumable_features()
if resumable:
print(f"Found {len(resumable)} feature(s) to resume from previous session:", flush=True)
for f in resumable:
print(f" - Feature #{f['id']}: {f['name']}", flush=True)
print(flush=True)
while self.is_running:
try:
# Check if all complete
if self.get_all_complete():
print("\nAll features complete!", flush=True)
break
# Check capacity
with self._lock:
current = len(self.running_agents)
if current >= self.max_concurrency:
await asyncio.sleep(POLL_INTERVAL)
continue
# Priority 1: Resume features from previous session
resumable = self.get_resumable_features()
if resumable:
slots = self.max_concurrency - current
for feature in resumable[:slots]:
print(f"Resuming feature #{feature['id']}: {feature['name']}", flush=True)
self.start_feature(feature["id"], resume=True)
await asyncio.sleep(2)
continue
# Priority 2: Start new ready features
ready = self.get_ready_features()
if not ready:
# Wait for running features to complete
if current > 0:
await asyncio.sleep(POLL_INTERVAL)
continue
else:
# No ready features and nothing running - might be blocked
print("No ready features available. All remaining features may be blocked by dependencies.", flush=True)
await asyncio.sleep(POLL_INTERVAL * 2)
continue
# Start features up to capacity
slots = self.max_concurrency - current
for feature in ready[:slots]:
print(f"Starting feature #{feature['id']}: {feature['name']}", flush=True)
self.start_feature(feature["id"])
await asyncio.sleep(2) # Brief pause between starts
except Exception as e:
print(f"Orchestrator error: {e}", flush=True)
await asyncio.sleep(POLL_INTERVAL)
# Wait for remaining agents to complete
print("Waiting for running agents to complete...", flush=True)
while True:
with self._lock:
if not self.running_agents:
break
await asyncio.sleep(1)
print("Orchestrator finished.", flush=True)
def get_status(self) -> dict:
"""Get current orchestrator status."""
with self._lock:
return {
"running_features": list(self.running_agents.keys()),
"count": len(self.running_agents),
"max_concurrency": self.max_concurrency,
"is_running": self.is_running,
}
async def run_parallel_orchestrator(
project_dir: Path,
max_concurrency: int = DEFAULT_CONCURRENCY,
model: str = None,
yolo_mode: bool = False,
) -> None:
"""Run the parallel orchestrator.
Args:
project_dir: Path to the project directory
max_concurrency: Maximum number of concurrent agents
model: Claude model to use
yolo_mode: Whether to run in YOLO mode
"""
orchestrator = ParallelOrchestrator(
project_dir=project_dir,
max_concurrency=max_concurrency,
model=model,
yolo_mode=yolo_mode,
)
try:
await orchestrator.run_loop()
except KeyboardInterrupt:
print("\n\nInterrupted by user. Stopping agents...", flush=True)
orchestrator.stop_all()
def main():
"""Main entry point for parallel orchestration."""
import argparse
from dotenv import load_dotenv
from registry import DEFAULT_MODEL, get_project_path
load_dotenv()
parser = argparse.ArgumentParser(
description="Parallel Feature Orchestrator - Run multiple agent instances",
)
parser.add_argument(
"--project-dir",
type=str,
required=True,
help="Project directory path (absolute) or registered project name",
)
parser.add_argument(
"--max-concurrency",
"-p",
type=int,
default=DEFAULT_CONCURRENCY,
help=f"Maximum concurrent agents (1-{MAX_PARALLEL_AGENTS}, default: {DEFAULT_CONCURRENCY})",
)
parser.add_argument(
"--model",
type=str,
default=DEFAULT_MODEL,
help=f"Claude model to use (default: {DEFAULT_MODEL})",
)
parser.add_argument(
"--yolo",
action="store_true",
default=False,
help="Enable YOLO mode: rapid prototyping without browser testing",
)
args = parser.parse_args()
# Resolve project directory
project_dir_input = args.project_dir
project_dir = Path(project_dir_input)
if project_dir.is_absolute():
if not project_dir.exists():
print(f"Error: Project directory does not exist: {project_dir}", flush=True)
sys.exit(1)
else:
registered_path = get_project_path(project_dir_input)
if registered_path:
project_dir = registered_path
else:
print(f"Error: Project '{project_dir_input}' not found in registry", flush=True)
sys.exit(1)
try:
asyncio.run(run_parallel_orchestrator(
project_dir=project_dir,
max_concurrency=args.max_concurrency,
model=args.model,
yolo_mode=args.yolo,
))
except KeyboardInterrupt:
print("\n\nInterrupted by user", flush=True)
if __name__ == "__main__":
main()

View File

@@ -79,6 +79,56 @@ def get_coding_prompt_yolo(project_dir: Path | None = None) -> str:
return load_prompt("coding_prompt_yolo", project_dir)
def get_single_feature_prompt(feature_id: int, project_dir: Path | None = None, yolo_mode: bool = False) -> str:
"""
Load the coding prompt with single-feature focus instructions prepended.
When the parallel orchestrator assigns a specific feature to an agent,
this prompt ensures the agent works ONLY on that feature.
Args:
feature_id: The specific feature ID to work on
project_dir: Optional project directory for project-specific prompts
yolo_mode: If True, use the YOLO prompt variant
Returns:
The prompt with single-feature instructions prepended
"""
# Get the base prompt
if yolo_mode:
base_prompt = get_coding_prompt_yolo(project_dir)
else:
base_prompt = get_coding_prompt(project_dir)
# Prepend single-feature instructions
single_feature_header = f"""## SINGLE FEATURE MODE
**CRITICAL: You are assigned to work on Feature #{feature_id} ONLY.**
This session is part of a parallel execution where multiple agents work on different features simultaneously. You MUST:
1. **Skip the `feature_get_next` step** - Your feature is already assigned: #{feature_id}
2. **Immediately mark feature #{feature_id} as in-progress** using `feature_mark_in_progress`
3. **Focus ONLY on implementing and testing feature #{feature_id}**
4. **Do NOT work on any other features** - other agents are handling them
When you complete feature #{feature_id}:
- Mark it as passing with `feature_mark_passing`
- Commit your changes
- End the session
If you cannot complete feature #{feature_id} due to a blocker:
- Use `feature_skip` to move it to the end of the queue
- Document the blocker in claude-progress.txt
- End the session
---
"""
return single_feature_header + base_prompt
def get_app_spec(project_dir: Path) -> str:
"""
Load the app spec from the project.

View File

@@ -85,6 +85,8 @@ async def get_agent_status(project_name: str):
started_at=manager.started_at,
yolo_mode=manager.yolo_mode,
model=manager.model,
parallel_mode=manager.parallel_mode,
max_concurrency=manager.max_concurrency,
)
@@ -100,8 +102,15 @@ async def start_agent(
default_yolo, default_model = _get_settings_defaults()
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
model = request.model if request.model else default_model
parallel_mode = request.parallel_mode or False
max_concurrency = request.max_concurrency
success, message = await manager.start(yolo_mode=yolo_mode, model=model)
success, message = await manager.start(
yolo_mode=yolo_mode,
model=model,
parallel_mode=parallel_mode,
max_concurrency=max_concurrency,
)
return AgentActionResponse(
success=success,

View File

@@ -12,6 +12,9 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException
from ..schemas import (
DependencyGraphNode,
DependencyGraphResponse,
DependencyUpdate,
FeatureBulkCreate,
FeatureBulkCreateResponse,
FeatureCreate,
@@ -72,11 +75,27 @@ def get_db_session(project_dir: Path):
session.close()
def feature_to_response(f) -> FeatureResponse:
def feature_to_response(f, passing_ids: set[int] | None = None) -> FeatureResponse:
"""Convert a Feature model to a FeatureResponse.
Handles legacy NULL values in boolean fields by treating them as False.
Computes blocked status if passing_ids is provided.
Args:
f: Feature model instance
passing_ids: Optional set of feature IDs that are passing (for computing blocked status)
Returns:
FeatureResponse with computed blocked status
"""
deps = f.dependencies or []
if passing_ids is None:
blocking = []
blocked = False
else:
blocking = [d for d in deps if d not in passing_ids]
blocked = len(blocking) > 0
return FeatureResponse(
id=f.id,
priority=f.priority,
@@ -84,9 +103,12 @@ def feature_to_response(f) -> FeatureResponse:
name=f.name,
description=f.description,
steps=f.steps if isinstance(f.steps, list) else [],
dependencies=deps,
# Handle legacy NULL values gracefully - treat as False
passes=f.passes if f.passes is not None else False,
in_progress=f.in_progress if f.in_progress is not None else False,
blocked=blocked,
blocking_dependencies=blocking,
)
@@ -119,12 +141,15 @@ async def list_features(project_name: str):
with get_db_session(project_dir) as session:
all_features = session.query(Feature).order_by(Feature.priority).all()
# Compute passing IDs for blocked status calculation
passing_ids = {f.id for f in all_features if f.passes}
pending = []
in_progress = []
done = []
for f in all_features:
feature_response = feature_to_response(f)
feature_response = feature_to_response(f, passing_ids)
if f.passes:
done.append(feature_response)
elif f.in_progress:
@@ -174,6 +199,7 @@ async def create_feature(project_name: str, feature: FeatureCreate):
name=feature.name,
description=feature.description,
steps=feature.steps,
dependencies=feature.dependencies if feature.dependencies else None,
passes=False,
in_progress=False,
)
@@ -190,6 +216,167 @@ async def create_feature(project_name: str, feature: FeatureCreate):
raise HTTPException(status_code=500, detail="Failed to create feature")
# ============================================================================
# Static path endpoints - MUST be declared before /{feature_id} routes
# ============================================================================
@router.post("/bulk", response_model=FeatureBulkCreateResponse)
async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate):
"""
Create multiple features at once.
Features are assigned sequential priorities starting from:
- starting_priority if specified (must be >= 1)
- max(existing priorities) + 1 if not specified
This is useful for:
- Expanding a project with new features via AI
- Importing features from external sources
- Batch operations
Returns:
{"created": N, "features": [...]}
"""
project_name = validate_project_name(project_name)
project_dir = _get_project_path(project_name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
if not bulk.features:
return FeatureBulkCreateResponse(created=0, features=[])
# Validate starting_priority if provided
if bulk.starting_priority is not None and bulk.starting_priority < 1:
raise HTTPException(status_code=400, detail="starting_priority must be >= 1")
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
# Determine starting priority with row-level lock to prevent race conditions
if bulk.starting_priority is not None:
current_priority = bulk.starting_priority
else:
# Lock the max priority row to prevent concurrent inserts from getting same priority
max_priority_feature = (
session.query(Feature)
.order_by(Feature.priority.desc())
.with_for_update()
.first()
)
current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1
created_ids = []
for feature_data in bulk.features:
db_feature = Feature(
priority=current_priority,
category=feature_data.category,
name=feature_data.name,
description=feature_data.description,
steps=feature_data.steps,
dependencies=feature_data.dependencies if feature_data.dependencies else None,
passes=False,
in_progress=False,
)
session.add(db_feature)
session.flush() # Flush to get the ID immediately
created_ids.append(db_feature.id)
current_priority += 1
session.commit()
# Query created features by their IDs (avoids relying on priority range)
created_features = []
for db_feature in session.query(Feature).filter(
Feature.id.in_(created_ids)
).order_by(Feature.priority).all():
created_features.append(feature_to_response(db_feature))
return FeatureBulkCreateResponse(
created=len(created_features),
features=created_features
)
except HTTPException:
raise
except Exception:
logger.exception("Failed to bulk create features")
raise HTTPException(status_code=500, detail="Failed to bulk create features")
@router.get("/graph", response_model=DependencyGraphResponse)
async def get_dependency_graph(project_name: str):
"""Return dependency graph data for visualization.
Returns nodes (features) and edges (dependencies) suitable for
rendering with React Flow or similar graph libraries.
"""
project_name = validate_project_name(project_name)
project_dir = _get_project_path(project_name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
db_file = project_dir / "features.db"
if not db_file.exists():
return DependencyGraphResponse(nodes=[], edges=[])
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
all_features = session.query(Feature).all()
passing_ids = {f.id for f in all_features if f.passes}
nodes = []
edges = []
for f in all_features:
deps = f.dependencies or []
blocking = [d for d in deps if d not in passing_ids]
if f.passes:
status = "done"
elif blocking:
status = "blocked"
elif f.in_progress:
status = "in_progress"
else:
status = "pending"
nodes.append(DependencyGraphNode(
id=f.id,
name=f.name,
category=f.category,
status=status,
priority=f.priority,
dependencies=deps
))
for dep_id in deps:
edges.append({"source": dep_id, "target": f.id})
return DependencyGraphResponse(nodes=nodes, edges=edges)
except HTTPException:
raise
except Exception:
logger.exception("Failed to get dependency graph")
raise HTTPException(status_code=500, detail="Failed to get dependency graph")
# ============================================================================
# Parameterized path endpoints - /{feature_id} routes
# ============================================================================
@router.get("/{feature_id}", response_model=FeatureResponse)
async def get_feature(project_name: str, feature_id: int):
"""Get details of a specific feature."""
@@ -268,11 +455,17 @@ async def update_feature(project_name: str, feature_id: int, update: FeatureUpda
feature.steps = update.steps
if update.priority is not None:
feature.priority = update.priority
if update.dependencies is not None:
feature.dependencies = update.dependencies if update.dependencies else None
session.commit()
session.refresh(feature)
return feature_to_response(feature)
# Compute passing IDs for response
all_features = session.query(Feature).all()
passing_ids = {f.id for f in all_features if f.passes}
return feature_to_response(feature, passing_ids)
except HTTPException:
raise
except Exception:
@@ -282,7 +475,12 @@ async def update_feature(project_name: str, feature_id: int, update: FeatureUpda
@router.delete("/{feature_id}")
async def delete_feature(project_name: str, feature_id: int):
"""Delete a feature."""
"""Delete a feature and clean up references in other features' dependencies.
When a feature is deleted, any other features that depend on it will have
that dependency removed from their dependencies list. This prevents orphaned
dependencies that would permanently block features.
"""
project_name = validate_project_name(project_name)
project_dir = _get_project_path(project_name)
@@ -301,10 +499,24 @@ async def delete_feature(project_name: str, feature_id: int):
if not feature:
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
# Clean up dependency references in other features
# This prevents orphaned dependencies that would block features forever
affected_features = []
for f in session.query(Feature).all():
if f.dependencies and feature_id in f.dependencies:
# Remove the deleted feature from this feature's dependencies
deps = [d for d in f.dependencies if d != feature_id]
f.dependencies = deps if deps else None
affected_features.append(f.id)
session.delete(feature)
session.commit()
return {"success": True, "message": f"Feature {feature_id} deleted"}
message = f"Feature {feature_id} deleted"
if affected_features:
message += f". Removed from dependencies of features: {affected_features}"
return {"success": True, "message": message, "affected_features": affected_features}
except HTTPException:
raise
except Exception:
@@ -352,22 +564,123 @@ async def skip_feature(project_name: str, feature_id: int):
raise HTTPException(status_code=500, detail="Failed to skip feature")
@router.post("/bulk", response_model=FeatureBulkCreateResponse)
async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate):
# ============================================================================
# Dependency Management Endpoints
# ============================================================================
def _get_dependency_resolver():
"""Lazy import of dependency resolver."""
import sys
root = Path(__file__).parent.parent.parent
if str(root) not in sys.path:
sys.path.insert(0, str(root))
from api.dependency_resolver import would_create_circular_dependency, MAX_DEPENDENCIES_PER_FEATURE
return would_create_circular_dependency, MAX_DEPENDENCIES_PER_FEATURE
@router.post("/{feature_id}/dependencies/{dep_id}")
async def add_dependency(project_name: str, feature_id: int, dep_id: int):
"""Add a dependency relationship between features.
The dep_id feature must be completed before feature_id can be started.
Validates: self-reference, existence, circular dependencies, max limit.
"""
Create multiple features at once.
project_name = validate_project_name(project_name)
Features are assigned sequential priorities starting from:
- starting_priority if specified (must be >= 1)
- max(existing priorities) + 1 if not specified
# Security: Self-reference check
if feature_id == dep_id:
raise HTTPException(status_code=400, detail="A feature cannot depend on itself")
This is useful for:
- Expanding a project with new features via AI
- Importing features from external sources
- Batch operations
project_dir = _get_project_path(project_name)
Returns:
{"created": N, "features": [...]}
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
would_create_circular_dependency, MAX_DEPENDENCIES_PER_FEATURE = _get_dependency_resolver()
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
feature = session.query(Feature).filter(Feature.id == feature_id).first()
dependency = session.query(Feature).filter(Feature.id == dep_id).first()
if not feature:
raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found")
if not dependency:
raise HTTPException(status_code=404, detail=f"Dependency {dep_id} not found")
current_deps = feature.dependencies or []
# Security: Limit check
if len(current_deps) >= MAX_DEPENDENCIES_PER_FEATURE:
raise HTTPException(status_code=400, detail=f"Maximum {MAX_DEPENDENCIES_PER_FEATURE} dependencies allowed")
if dep_id in current_deps:
raise HTTPException(status_code=400, detail="Dependency already exists")
# Security: Circular dependency check
# source_id = feature_id (gaining dep), target_id = dep_id (being depended upon)
all_features = [f.to_dict() for f in session.query(Feature).all()]
if would_create_circular_dependency(all_features, feature_id, dep_id):
raise HTTPException(status_code=400, detail="Would create circular dependency")
current_deps.append(dep_id)
feature.dependencies = sorted(current_deps)
session.commit()
return {"success": True, "feature_id": feature_id, "dependencies": feature.dependencies}
except HTTPException:
raise
except Exception:
logger.exception("Failed to add dependency")
raise HTTPException(status_code=500, detail="Failed to add dependency")
@router.delete("/{feature_id}/dependencies/{dep_id}")
async def remove_dependency(project_name: str, feature_id: int, dep_id: int):
"""Remove a dependency from a feature."""
project_name = validate_project_name(project_name)
project_dir = _get_project_path(project_name)
if not project_dir:
raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory 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")
current_deps = feature.dependencies or []
if dep_id not in current_deps:
raise HTTPException(status_code=400, detail="Dependency does not exist")
current_deps.remove(dep_id)
feature.dependencies = current_deps if current_deps else None
session.commit()
return {"success": True, "feature_id": feature_id, "dependencies": feature.dependencies or []}
except HTTPException:
raise
except Exception:
logger.exception("Failed to remove dependency")
raise HTTPException(status_code=500, detail="Failed to remove dependency")
@router.put("/{feature_id}/dependencies")
async def set_dependencies(project_name: str, feature_id: int, update: DependencyUpdate):
"""Set all dependencies for a feature at once, replacing any existing.
Validates: self-reference, existence of all dependencies, circular dependencies, max limit.
"""
project_name = validate_project_name(project_name)
project_dir = _get_project_path(project_name)
@@ -378,62 +691,56 @@ async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate):
if not project_dir.exists():
raise HTTPException(status_code=404, detail="Project directory not found")
if not bulk.features:
return FeatureBulkCreateResponse(created=0, features=[])
dependency_ids = update.dependency_ids
# Validate starting_priority if provided
if bulk.starting_priority is not None and bulk.starting_priority < 1:
raise HTTPException(status_code=400, detail="starting_priority must be >= 1")
# Security: Self-reference check
if feature_id in dependency_ids:
raise HTTPException(status_code=400, detail="A feature cannot depend on itself")
# Check for duplicates
if len(dependency_ids) != len(set(dependency_ids)):
raise HTTPException(status_code=400, detail="Duplicate dependencies not allowed")
would_create_circular_dependency, _ = _get_dependency_resolver()
_, Feature = _get_db_classes()
try:
with get_db_session(project_dir) as session:
# Determine starting priority with row-level lock to prevent race conditions
if bulk.starting_priority is not None:
current_priority = bulk.starting_priority
else:
# Lock the max priority row to prevent concurrent inserts from getting same priority
max_priority_feature = (
session.query(Feature)
.order_by(Feature.priority.desc())
.with_for_update()
.first()
)
current_priority = (max_priority_feature.priority + 1) if max_priority_feature else 1
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")
created_ids = []
# Validate all dependencies exist
all_feature_ids = {f.id for f in session.query(Feature).all()}
missing = [d for d in dependency_ids if d not in all_feature_ids]
if missing:
raise HTTPException(status_code=400, detail=f"Dependencies not found: {missing}")
for feature_data in bulk.features:
db_feature = Feature(
priority=current_priority,
category=feature_data.category,
name=feature_data.name,
description=feature_data.description,
steps=feature_data.steps,
passes=False,
in_progress=False,
)
session.add(db_feature)
session.flush() # Flush to get the ID immediately
created_ids.append(db_feature.id)
current_priority += 1
# Check for circular dependencies
all_features = [f.to_dict() for f in session.query(Feature).all()]
# Temporarily update the feature's dependencies for cycle check
test_features = []
for f in all_features:
if f["id"] == feature_id:
test_features.append({**f, "dependencies": dependency_ids})
else:
test_features.append(f)
for dep_id in dependency_ids:
# source_id = feature_id (gaining dep), target_id = dep_id (being depended upon)
if would_create_circular_dependency(test_features, feature_id, dep_id):
raise HTTPException(
status_code=400,
detail=f"Cannot add dependency {dep_id}: would create circular dependency"
)
# Set dependencies
feature.dependencies = sorted(dependency_ids) if dependency_ids else None
session.commit()
# Query created features by their IDs (avoids relying on priority range)
created_features = []
for db_feature in session.query(Feature).filter(
Feature.id.in_(created_ids)
).order_by(Feature.priority).all():
created_features.append(feature_to_response(db_feature))
return FeatureBulkCreateResponse(
created=len(created_features),
features=created_features
)
return {"success": True, "feature_id": feature_id, "dependencies": feature.dependencies or []}
except HTTPException:
raise
except Exception:
logger.exception("Failed to bulk create features")
raise HTTPException(status_code=500, detail="Failed to bulk create features")
logger.exception("Failed to set dependencies")
raise HTTPException(status_code=500, detail="Failed to set dependencies")

View File

@@ -80,6 +80,7 @@ class FeatureBase(BaseModel):
name: str
description: str
steps: list[str]
dependencies: list[int] = Field(default_factory=list) # Optional dependencies
class FeatureCreate(FeatureBase):
@@ -94,6 +95,7 @@ class FeatureUpdate(BaseModel):
description: str | None = None
steps: list[str] | None = None
priority: int | None = None
dependencies: list[int] | None = None # Optional - can update dependencies
class FeatureResponse(FeatureBase):
@@ -102,6 +104,8 @@ class FeatureResponse(FeatureBase):
priority: int
passes: bool
in_progress: bool
blocked: bool = False # Computed: has unmet dependencies
blocking_dependencies: list[int] = Field(default_factory=list) # Computed
class Config:
from_attributes = True
@@ -126,6 +130,37 @@ class FeatureBulkCreateResponse(BaseModel):
features: list[FeatureResponse]
# ============================================================================
# Dependency Graph Schemas
# ============================================================================
class DependencyGraphNode(BaseModel):
"""Minimal node for graph visualization (no description exposed for security)."""
id: int
name: str
category: str
status: Literal["pending", "in_progress", "done", "blocked"]
priority: int
dependencies: list[int]
class DependencyGraphEdge(BaseModel):
"""Edge in the dependency graph."""
source: int
target: int
class DependencyGraphResponse(BaseModel):
"""Response for dependency graph visualization."""
nodes: list[DependencyGraphNode]
edges: list[DependencyGraphEdge]
class DependencyUpdate(BaseModel):
"""Request schema for updating a feature's dependencies."""
dependency_ids: list[int] = Field(..., max_length=20) # Security: limit
# ============================================================================
# Agent Schemas
# ============================================================================
@@ -134,6 +169,8 @@ class AgentStartRequest(BaseModel):
"""Request schema for starting the agent."""
yolo_mode: bool | None = None # None means use global settings
model: str | None = None # None means use global settings
parallel_mode: bool | None = None # Enable parallel execution
max_concurrency: int | None = None # Max concurrent agents (1-5)
@field_validator('model')
@classmethod
@@ -143,6 +180,14 @@ class AgentStartRequest(BaseModel):
raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
return v
@field_validator('max_concurrency')
@classmethod
def validate_concurrency(cls, v: int | None) -> int | None:
"""Validate max_concurrency is between 1 and 5."""
if v is not None and (v < 1 or v > 5):
raise ValueError("max_concurrency must be between 1 and 5")
return v
class AgentStatus(BaseModel):
"""Current agent status."""
@@ -151,6 +196,8 @@ class AgentStatus(BaseModel):
started_at: datetime | None = None
yolo_mode: bool = False
model: str | None = None # Model being used by running agent
parallel_mode: bool = False
max_concurrency: int | None = None
class AgentActionResponse(BaseModel):
@@ -180,6 +227,7 @@ class WSProgressMessage(BaseModel):
"""WebSocket message for progress updates."""
type: Literal["progress"] = "progress"
passing: int
in_progress: int
total: int
percentage: float
@@ -196,6 +244,8 @@ class WSLogMessage(BaseModel):
type: Literal["log"] = "log"
line: str
timestamp: datetime
featureId: int | None = None
agentIndex: int | None = None
class WSAgentStatusMessage(BaseModel):
@@ -204,6 +254,25 @@ class WSAgentStatusMessage(BaseModel):
status: str
# Agent state for multi-agent tracking
AgentState = Literal["idle", "thinking", "working", "testing", "success", "error", "struggling"]
# Agent mascot names assigned by index
AGENT_MASCOTS = ["Spark", "Fizz", "Octo", "Hoot", "Buzz"]
class WSAgentUpdateMessage(BaseModel):
"""WebSocket message for multi-agent status updates."""
type: Literal["agent_update"] = "agent_update"
agentIndex: int
agentName: str # One of AGENT_MASCOTS
featureId: int
featureName: str
state: AgentState
thought: str | None = None
timestamp: datetime
# ============================================================================
# Spec Chat Schemas
# ============================================================================

View File

@@ -80,6 +80,8 @@ class AgentProcessManager:
self._output_task: asyncio.Task | None = None
self.yolo_mode: bool = False # YOLO mode for rapid prototyping
self.model: str | None = None # Model being used
self.parallel_mode: bool = False # Parallel execution mode
self.max_concurrency: int | None = None # Max concurrent agents
# Support multiple callbacks (for multiple WebSocket clients)
self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
@@ -241,13 +243,21 @@ class AgentProcessManager:
self.status = "stopped"
self._remove_lock()
async def start(self, yolo_mode: bool = False, model: str | None = None) -> tuple[bool, str]:
async def start(
self,
yolo_mode: bool = False,
model: str | None = None,
parallel_mode: bool = False,
max_concurrency: int | None = None,
) -> tuple[bool, str]:
"""
Start the agent as a subprocess.
Args:
yolo_mode: If True, run in YOLO mode (no browser testing)
model: Model to use (e.g., claude-opus-4-5-20251101)
parallel_mode: If True, run multiple features in parallel
max_concurrency: Max concurrent agents (default 3 if parallel enabled)
Returns:
Tuple of (success, message)
@@ -261,6 +271,8 @@ class AgentProcessManager:
# Store for status queries
self.yolo_mode = yolo_mode
self.model = model
self.parallel_mode = parallel_mode
self.max_concurrency = max_concurrency
# Build command - pass absolute path to project directory
cmd = [
@@ -278,6 +290,11 @@ class AgentProcessManager:
if yolo_mode:
cmd.append("--yolo")
# Add --parallel flag if parallel mode is enabled
if parallel_mode:
cmd.append("--parallel")
cmd.append(str(max_concurrency or 3)) # Default to 3 concurrent agents
try:
# Start subprocess with piped stdout/stderr
# Use project_dir as cwd so Claude SDK sandbox allows access to project files
@@ -340,6 +357,8 @@ class AgentProcessManager:
self.started_at = None
self.yolo_mode = False # Reset YOLO mode
self.model = None # Reset model
self.parallel_mode = False # Reset parallel mode
self.max_concurrency = None # Reset concurrency
return True, "Agent stopped"
except Exception as e:
@@ -422,6 +441,8 @@ class AgentProcessManager:
"started_at": self.started_at.isoformat() if self.started_at else None,
"yolo_mode": self.yolo_mode,
"model": self.model,
"parallel_mode": self.parallel_mode,
"max_concurrency": self.max_concurrency,
}

View File

@@ -15,6 +15,7 @@ from typing import Set
from fastapi import WebSocket, WebSocketDisconnect
from .schemas import AGENT_MASCOTS
from .services.dev_server_manager import get_devserver_manager
from .services.process_manager import get_manager
@@ -23,6 +24,177 @@ _count_passing_tests = None
logger = logging.getLogger(__name__)
# Pattern to extract feature ID from parallel orchestrator output
FEATURE_ID_PATTERN = re.compile(r'\[Feature #(\d+)\]\s*(.*)')
# Patterns for detecting agent activity and thoughts
THOUGHT_PATTERNS = [
# Claude's tool usage patterns (actual format: [Tool: name])
(re.compile(r'\[Tool:\s*Read\]', re.I), 'thinking'),
(re.compile(r'\[Tool:\s*(?:Write|Edit|NotebookEdit)\]', re.I), 'working'),
(re.compile(r'\[Tool:\s*Bash\]', re.I), 'testing'),
(re.compile(r'\[Tool:\s*(?:Glob|Grep)\]', re.I), 'thinking'),
(re.compile(r'\[Tool:\s*(\w+)\]', re.I), 'working'), # Fallback for other tools
# Claude's internal thoughts
(re.compile(r'(?:Reading|Analyzing|Checking|Looking at|Examining)\s+(.+)', re.I), 'thinking'),
(re.compile(r'(?:Creating|Writing|Adding|Implementing|Building)\s+(.+)', re.I), 'working'),
(re.compile(r'(?:Testing|Verifying|Running tests|Validating)\s+(.+)', re.I), 'testing'),
(re.compile(r'(?:Error|Failed|Cannot|Unable to|Exception)\s+(.+)', re.I), 'struggling'),
# Test results
(re.compile(r'(?:PASS|passed|success)', re.I), 'success'),
(re.compile(r'(?:FAIL|failed|error)', re.I), 'struggling'),
]
class AgentTracker:
"""Tracks active agents and their states for multi-agent mode."""
def __init__(self):
# feature_id -> {name, state, last_thought, agent_index}
self.active_agents: dict[int, dict] = {}
self._next_agent_index = 0
self._lock = asyncio.Lock()
async def process_line(self, line: str) -> dict | None:
"""
Process an output line and return an agent_update message if relevant.
Returns None if no update should be emitted.
"""
# Check for feature-specific output
match = FEATURE_ID_PATTERN.match(line)
if not match:
# Also check for orchestrator status messages
if line.startswith("Started agent for feature #"):
try:
feature_id = int(re.search(r'#(\d+)', line).group(1))
return await self._handle_agent_start(feature_id, line)
except (AttributeError, ValueError):
pass
elif line.startswith("Feature #") and ("completed" in line or "failed" in line):
try:
feature_id = int(re.search(r'#(\d+)', line).group(1))
is_success = "completed" in line
return await self._handle_agent_complete(feature_id, is_success)
except (AttributeError, ValueError):
pass
return None
feature_id = int(match.group(1))
content = match.group(2)
async with self._lock:
# Ensure agent is tracked
if feature_id not in self.active_agents:
agent_index = self._next_agent_index
self._next_agent_index += 1
self.active_agents[feature_id] = {
'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
'agent_index': agent_index,
'state': 'thinking',
'feature_name': f'Feature #{feature_id}',
'last_thought': None,
}
agent = self.active_agents[feature_id]
# Detect state and thought from content
state = 'working'
thought = None
for pattern, detected_state in THOUGHT_PATTERNS:
m = pattern.search(content)
if m:
state = detected_state
thought = m.group(1) if m.lastindex else content[:100]
break
# Only emit update if state changed or we have a new thought
if state != agent['state'] or thought != agent['last_thought']:
agent['state'] = state
if thought:
agent['last_thought'] = thought
return {
'type': 'agent_update',
'agentIndex': agent['agent_index'],
'agentName': agent['name'],
'featureId': feature_id,
'featureName': agent['feature_name'],
'state': state,
'thought': thought,
'timestamp': datetime.now().isoformat(),
}
return None
def get_agent_info(self, feature_id: int) -> tuple[int | None, str | None]:
"""Get agent index and name for a feature ID.
Returns:
Tuple of (agentIndex, agentName) or (None, None) if not tracked.
"""
agent = self.active_agents.get(feature_id)
if agent:
return agent['agent_index'], agent['name']
return None, None
async def _handle_agent_start(self, feature_id: int, line: str) -> dict | None:
"""Handle agent start message from orchestrator."""
async with self._lock:
agent_index = self._next_agent_index
self._next_agent_index += 1
# Try to extract feature name from line
feature_name = f'Feature #{feature_id}'
name_match = re.search(r'#\d+:\s*(.+)$', line)
if name_match:
feature_name = name_match.group(1)
self.active_agents[feature_id] = {
'name': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
'agent_index': agent_index,
'state': 'thinking',
'feature_name': feature_name,
'last_thought': 'Starting work...',
}
return {
'type': 'agent_update',
'agentIndex': agent_index,
'agentName': AGENT_MASCOTS[agent_index % len(AGENT_MASCOTS)],
'featureId': feature_id,
'featureName': feature_name,
'state': 'thinking',
'thought': 'Starting work...',
'timestamp': datetime.now().isoformat(),
}
async def _handle_agent_complete(self, feature_id: int, is_success: bool) -> dict | None:
"""Handle agent completion message from orchestrator."""
async with self._lock:
if feature_id not in self.active_agents:
return None
agent = self.active_agents[feature_id]
state = 'success' if is_success else 'error'
result = {
'type': 'agent_update',
'agentIndex': agent['agent_index'],
'agentName': agent['name'],
'featureId': feature_id,
'featureName': agent['feature_name'],
'state': state,
'thought': 'Completed successfully!' if is_success else 'Failed to complete',
'timestamp': datetime.now().isoformat(),
}
# Remove from active agents
del self.active_agents[feature_id]
return result
def _get_project_path(project_name: str) -> Path:
"""Get project path from registry."""
@@ -171,14 +343,38 @@ async def project_websocket(websocket: WebSocket, project_name: str):
# Get agent manager and register callbacks
agent_manager = get_manager(project_name, project_dir, ROOT_DIR)
# Create agent tracker for multi-agent mode
agent_tracker = AgentTracker()
async def on_output(line: str):
"""Handle agent output - broadcast to this WebSocket."""
try:
await websocket.send_json({
# Extract feature ID from line if present
feature_id = None
agent_index = None
match = FEATURE_ID_PATTERN.match(line)
if match:
feature_id = int(match.group(1))
agent_index, _ = agent_tracker.get_agent_info(feature_id)
# Send the raw log line with optional feature/agent attribution
log_msg = {
"type": "log",
"line": line,
"timestamp": datetime.now().isoformat(),
})
}
if feature_id is not None:
log_msg["featureId"] = feature_id
if agent_index is not None:
log_msg["agentIndex"] = agent_index
await websocket.send_json(log_msg)
# Check if this line indicates agent activity (parallel mode)
# and emit agent_update messages if so
agent_update = await agent_tracker.process_line(line)
if agent_update:
await websocket.send_json(agent_update)
except Exception:
pass # Connection may be closed

264
ui/package-lock.json generated
View File

@@ -15,8 +15,10 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.0",
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -25,6 +27,7 @@
"@eslint/js": "^9.13.0",
"@tailwindcss/vite": "^4.0.0-beta.4",
"@types/canvas-confetti": "^1.9.0",
"@types/dagre": "^0.7.53",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
@@ -2299,6 +2302,62 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.53",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
"integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2652,6 +2711,38 @@
"addons/*"
]
},
"node_modules/@xyflow/react": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz",
"integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.74",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.74",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz",
"integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2847,6 +2938,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -2912,6 +3009,121 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3370,6 +3582,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -3824,6 +4045,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -4503,6 +4730,15 @@
}
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -4608,6 +4844,34 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@@ -17,8 +17,10 @@
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.10.0",
"canvas-confetti": "^1.9.4",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
@@ -27,6 +29,7 @@
"@eslint/js": "^9.13.0",
"@tailwindcss/vite": "^4.0.0-beta.4",
"@types/canvas-confetti": "^1.9.0",
"@types/dagre": "^0.7.53",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useQueryClient, useQuery } from '@tanstack/react-query'
import { useProjects, useFeatures, useAgentStatus, useSettings } from './hooks/useProjects'
import { useProjectWebSocket } from './hooks/useWebSocket'
import { useFeatureSound } from './hooks/useFeatureSound'
@@ -13,16 +13,23 @@ import { AddFeatureForm } from './components/AddFeatureForm'
import { FeatureModal } from './components/FeatureModal'
import { DebugLogViewer, type TabType } from './components/DebugLogViewer'
import { AgentThought } from './components/AgentThought'
import { AgentMissionControl } from './components/AgentMissionControl'
import { CelebrationOverlay } from './components/CelebrationOverlay'
import { AssistantFAB } from './components/AssistantFAB'
import { AssistantPanel } from './components/AssistantPanel'
import { ExpandProjectModal } from './components/ExpandProjectModal'
import { SettingsModal } from './components/SettingsModal'
import { DevServerControl } from './components/DevServerControl'
import { ViewToggle, type ViewMode } from './components/ViewToggle'
import { DependencyGraph } from './components/DependencyGraph'
import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp'
import { getDependencyGraph } from './lib/api'
import { Loader2, Settings, Moon, Sun } from 'lucide-react'
import type { Feature } from './lib/types'
const STORAGE_KEY = 'autocoder-selected-project'
const DARK_MODE_KEY = 'autocoder-dark-mode'
const VIEW_MODE_KEY = 'autocoder-view-mode'
function App() {
// Initialize selected project from localStorage
@@ -42,6 +49,7 @@ function App() {
const [debugActiveTab, setDebugActiveTab] = useState<TabType>('agent')
const [assistantOpen, setAssistantOpen] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false)
const [isSpecCreating, setIsSpecCreating] = useState(false)
const [darkMode, setDarkMode] = useState(() => {
try {
@@ -50,6 +58,14 @@ function App() {
return false
}
})
const [viewMode, setViewMode] = useState<ViewMode>(() => {
try {
const stored = localStorage.getItem(VIEW_MODE_KEY)
return (stored === 'graph' ? 'graph' : 'kanban') as ViewMode
} catch {
return 'kanban'
}
})
const queryClient = useQueryClient()
const { data: projects, isLoading: projectsLoading } = useProjects()
@@ -58,6 +74,14 @@ function App() {
useAgentStatus(selectedProject) // Keep polling for status updates
const wsState = useProjectWebSocket(selectedProject)
// Fetch graph data when in graph view
const { data: graphData } = useQuery({
queryKey: ['dependencyGraph', selectedProject],
queryFn: () => getDependencyGraph(selectedProject!),
enabled: !!selectedProject && viewMode === 'graph',
refetchInterval: 5000, // Refresh every 5 seconds
})
// Apply dark mode class to document
useEffect(() => {
if (darkMode) {
@@ -72,6 +96,15 @@ function App() {
}
}, [darkMode])
// Persist view mode to localStorage
useEffect(() => {
try {
localStorage.setItem(VIEW_MODE_KEY, viewMode)
} catch {
// localStorage not available
}
}, [viewMode])
// Play sounds when features move between columns
useFeatureSound(features)
@@ -154,9 +187,23 @@ function App() {
setShowSettings(true)
}
// G : Toggle between Kanban and Graph view (when project selected)
if ((e.key === 'g' || e.key === 'G') && selectedProject) {
e.preventDefault()
setViewMode(prev => prev === 'kanban' ? 'graph' : 'kanban')
}
// ? : Show keyboard shortcuts help
if (e.key === '?') {
e.preventDefault()
setShowKeyboardHelp(true)
}
// Escape : Close modals
if (e.key === 'Escape') {
if (showExpandProject) {
if (showKeyboardHelp) {
setShowKeyboardHelp(false)
} else if (showExpandProject) {
setShowExpandProject(false)
} else if (showSettings) {
setShowSettings(false)
@@ -174,7 +221,7 @@ function App() {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, isSpecCreating])
}, [selectedProject, showAddFeature, showExpandProject, selectedFeature, debugOpen, debugActiveTab, assistantOpen, features, showSettings, showKeyboardHelp, isSpecCreating, viewMode])
// Combine WebSocket progress with feature data
const progress = wsState.progress.total > 0 ? wsState.progress : {
@@ -284,11 +331,21 @@ function App() {
isConnected={wsState.isConnected}
/>
{/* Agent Thought - shows latest agent narrative */}
<AgentThought
logs={wsState.logs}
agentStatus={wsState.agentStatus}
/>
{/* Agent Mission Control - shows active agents in parallel mode */}
{wsState.activeAgents.length > 0 && (
<AgentMissionControl
agents={wsState.activeAgents}
recentActivity={wsState.recentActivity}
/>
)}
{/* Agent Thought - shows latest agent narrative (single agent mode) */}
{wsState.activeAgents.length === 0 && (
<AgentThought
logs={wsState.logs}
agentStatus={wsState.agentStatus}
/>
)}
{/* Initializing Features State - show when agent is running but no features yet */}
{features &&
@@ -307,13 +364,45 @@ function App() {
</div>
)}
{/* Kanban Board */}
<KanbanBoard
features={features}
onFeatureClick={setSelectedFeature}
onAddFeature={() => setShowAddFeature(true)}
onExpandProject={() => setShowExpandProject(true)}
/>
{/* View Toggle - only show when there are features */}
{features && (features.pending.length + features.in_progress.length + features.done.length) > 0 && (
<div className="flex justify-center">
<ViewToggle viewMode={viewMode} onViewModeChange={setViewMode} />
</div>
)}
{/* Kanban Board or Dependency Graph based on view mode */}
{viewMode === 'kanban' ? (
<KanbanBoard
features={features}
onFeatureClick={setSelectedFeature}
onAddFeature={() => setShowAddFeature(true)}
onExpandProject={() => setShowExpandProject(true)}
activeAgents={wsState.activeAgents}
/>
) : (
<div className="neo-card overflow-hidden" style={{ height: '600px' }}>
{graphData ? (
<DependencyGraph
graphData={graphData}
onNodeClick={(nodeId) => {
// Find the feature and open the modal
const allFeatures = [
...(features?.pending ?? []),
...(features?.in_progress ?? []),
...(features?.done ?? [])
]
const feature = allFeatures.find(f => f.id === nodeId)
if (feature) setSelectedFeature(feature)
}}
/>
) : (
<div className="h-full flex items-center justify-center">
<Loader2 size={32} className="animate-spin text-neo-progress" />
</div>
)}
</div>
)}
</div>
)}
</main>
@@ -383,6 +472,20 @@ function App() {
{showSettings && (
<SettingsModal onClose={() => setShowSettings(false)} />
)}
{/* Keyboard Shortcuts Help */}
{showKeyboardHelp && (
<KeyboardShortcutsHelp onClose={() => setShowKeyboardHelp(false)} />
)}
{/* Celebration Overlay - shows when a feature is completed by an agent */}
{wsState.celebration && (
<CelebrationOverlay
agentName={wsState.celebration.agentName}
featureName={wsState.celebration.featureName}
onComplete={wsState.clearCelebration}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,93 @@
import { Activity } from 'lucide-react'
import { AgentAvatar } from './AgentAvatar'
import type { AgentMascot } from '../lib/types'
interface ActivityItem {
agentName: string
thought: string
timestamp: string
featureId: number
}
interface ActivityFeedProps {
activities: ActivityItem[]
maxItems?: number
showHeader?: boolean
}
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
if (diffSec < 5) return 'just now'
if (diffSec < 60) return `${diffSec}s ago`
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
export function ActivityFeed({ activities, maxItems = 5, showHeader = true }: ActivityFeedProps) {
const displayedActivities = activities.slice(0, maxItems)
if (displayedActivities.length === 0) {
return null
}
return (
<div>
{showHeader && (
<div className="flex items-center gap-2 mb-2">
<Activity size={14} className="text-neo-text-secondary" />
<span className="text-xs font-bold text-neo-text-secondary uppercase tracking-wide">
Recent Activity
</span>
</div>
)}
<div className="space-y-2">
{displayedActivities.map((activity) => (
<div
key={`${activity.featureId}-${activity.timestamp}-${activity.thought.slice(0, 20)}`}
className="flex items-start gap-2 py-1.5 px-2 rounded bg-[var(--color-neo-bg)] border border-neo-border/20"
>
<AgentAvatar
name={activity.agentName as AgentMascot}
state="working"
size="sm"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-bold" style={{
color: getMascotColor(activity.agentName as AgentMascot)
}}>
{activity.agentName}
</span>
<span className="text-[10px] text-neo-text-muted">
#{activity.featureId}
</span>
<span className="text-[10px] text-neo-text-muted ml-auto">
{formatTimestamp(activity.timestamp)}
</span>
</div>
<p className="text-xs text-neo-text-secondary truncate" title={activity.thought}>
{activity.thought}
</p>
</div>
</div>
))}
</div>
</div>
)
}
function getMascotColor(name: AgentMascot): string {
const colors: Record<AgentMascot, string> = {
Spark: '#3B82F6',
Fizz: '#F97316',
Octo: '#8B5CF6',
Hoot: '#22C55E',
Buzz: '#EAB308',
}
return colors[name] || '#6B7280'
}

View File

@@ -0,0 +1,261 @@
import { type AgentMascot, type AgentState } from '../lib/types'
interface AgentAvatarProps {
name: AgentMascot
state: AgentState
size?: 'sm' | 'md' | 'lg'
showName?: boolean
}
const AVATAR_COLORS: Record<AgentMascot, { primary: string; secondary: string; accent: string }> = {
Spark: { primary: '#3B82F6', secondary: '#60A5FA', accent: '#DBEAFE' }, // Blue robot
Fizz: { primary: '#F97316', secondary: '#FB923C', accent: '#FFEDD5' }, // Orange fox
Octo: { primary: '#8B5CF6', secondary: '#A78BFA', accent: '#EDE9FE' }, // Purple octopus
Hoot: { primary: '#22C55E', secondary: '#4ADE80', accent: '#DCFCE7' }, // Green owl
Buzz: { primary: '#EAB308', secondary: '#FACC15', accent: '#FEF9C3' }, // Yellow bee
}
const SIZES = {
sm: { svg: 32, font: 'text-xs' },
md: { svg: 48, font: 'text-sm' },
lg: { svg: 64, font: 'text-base' },
}
// SVG mascot definitions - simple cute characters
function SparkSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Spark; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Robot body */}
<rect x="16" y="20" width="32" height="28" rx="4" fill={colors.primary} />
{/* Robot head */}
<rect x="12" y="8" width="40" height="24" rx="4" fill={colors.secondary} />
{/* Antenna */}
<circle cx="32" cy="4" r="4" fill={colors.primary} className="animate-pulse" />
<rect x="30" y="4" width="4" height="8" fill={colors.primary} />
{/* Eyes */}
<circle cx="24" cy="18" r="4" fill="white" />
<circle cx="40" cy="18" r="4" fill="white" />
<circle cx="25" cy="18" r="2" fill={colors.primary} />
<circle cx="41" cy="18" r="2" fill={colors.primary} />
{/* Mouth */}
<rect x="26" y="24" width="12" height="2" rx="1" fill="white" />
{/* Arms */}
<rect x="6" y="24" width="8" height="4" rx="2" fill={colors.primary} />
<rect x="50" y="24" width="8" height="4" rx="2" fill={colors.primary} />
</svg>
)
}
function FizzSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Fizz; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Ears */}
<polygon points="12,12 20,28 4,28" fill={colors.primary} />
<polygon points="52,12 60,28 44,28" fill={colors.primary} />
<polygon points="14,14 18,26 8,26" fill={colors.accent} />
<polygon points="50,14 56,26 44,26" fill={colors.accent} />
{/* Head */}
<ellipse cx="32" cy="36" rx="24" ry="22" fill={colors.primary} />
{/* Face */}
<ellipse cx="32" cy="40" rx="18" ry="14" fill={colors.accent} />
{/* Eyes */}
<ellipse cx="24" cy="32" rx="4" ry="5" fill="white" />
<ellipse cx="40" cy="32" rx="4" ry="5" fill="white" />
<circle cx="25" cy="33" r="2" fill="#1a1a1a" />
<circle cx="41" cy="33" r="2" fill="#1a1a1a" />
{/* Nose */}
<ellipse cx="32" cy="42" rx="4" ry="3" fill={colors.primary} />
{/* Whiskers */}
<line x1="8" y1="38" x2="18" y2="40" stroke={colors.primary} strokeWidth="2" />
<line x1="8" y1="44" x2="18" y2="44" stroke={colors.primary} strokeWidth="2" />
<line x1="46" y1="40" x2="56" y2="38" stroke={colors.primary} strokeWidth="2" />
<line x1="46" y1="44" x2="56" y2="44" stroke={colors.primary} strokeWidth="2" />
</svg>
)
}
function OctoSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Octo; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Tentacles */}
<path d="M12,48 Q8,56 12,60 Q16,64 20,58" fill={colors.secondary} />
<path d="M22,50 Q20,58 24,62" fill={colors.secondary} />
<path d="M32,52 Q32,60 36,62" fill={colors.secondary} />
<path d="M42,50 Q44,58 40,62" fill={colors.secondary} />
<path d="M52,48 Q56,56 52,60 Q48,64 44,58" fill={colors.secondary} />
{/* Head */}
<ellipse cx="32" cy="32" rx="22" ry="24" fill={colors.primary} />
{/* Eyes */}
<ellipse cx="24" cy="28" rx="6" ry="8" fill="white" />
<ellipse cx="40" cy="28" rx="6" ry="8" fill="white" />
<ellipse cx="25" cy="30" rx="3" ry="4" fill={colors.primary} />
<ellipse cx="41" cy="30" rx="3" ry="4" fill={colors.primary} />
{/* Smile */}
<path d="M24,42 Q32,48 40,42" stroke={colors.accent} strokeWidth="2" fill="none" strokeLinecap="round" />
</svg>
)
}
function HootSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Hoot; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Ear tufts */}
<polygon points="14,8 22,24 6,20" fill={colors.primary} />
<polygon points="50,8 58,20 42,24" fill={colors.primary} />
{/* Body */}
<ellipse cx="32" cy="40" rx="20" ry="18" fill={colors.primary} />
{/* Head */}
<circle cx="32" cy="28" r="20" fill={colors.secondary} />
{/* Eye circles */}
<circle cx="24" cy="26" r="10" fill={colors.accent} />
<circle cx="40" cy="26" r="10" fill={colors.accent} />
{/* Eyes */}
<circle cx="24" cy="26" r="6" fill="white" />
<circle cx="40" cy="26" r="6" fill="white" />
<circle cx="25" cy="27" r="3" fill="#1a1a1a" />
<circle cx="41" cy="27" r="3" fill="#1a1a1a" />
{/* Beak */}
<polygon points="32,32 28,40 36,40" fill="#F97316" />
{/* Belly */}
<ellipse cx="32" cy="46" rx="10" ry="8" fill={colors.accent} />
</svg>
)
}
function BuzzSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Buzz; size: number }) {
return (
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
{/* Wings */}
<ellipse cx="14" cy="32" rx="10" ry="14" fill={colors.accent} opacity="0.8" className="animate-pulse" />
<ellipse cx="50" cy="32" rx="10" ry="14" fill={colors.accent} opacity="0.8" className="animate-pulse" />
{/* Body stripes */}
<ellipse cx="32" cy="36" rx="14" ry="20" fill={colors.primary} />
<ellipse cx="32" cy="30" rx="12" ry="6" fill="#1a1a1a" />
<ellipse cx="32" cy="44" rx="12" ry="6" fill="#1a1a1a" />
{/* Head */}
<circle cx="32" cy="16" r="12" fill={colors.primary} />
{/* Antennae */}
<line x1="26" y1="8" x2="22" y2="2" stroke="#1a1a1a" strokeWidth="2" />
<line x1="38" y1="8" x2="42" y2="2" stroke="#1a1a1a" strokeWidth="2" />
<circle cx="22" cy="2" r="2" fill="#1a1a1a" />
<circle cx="42" cy="2" r="2" fill="#1a1a1a" />
{/* Eyes */}
<circle cx="28" cy="14" r="4" fill="white" />
<circle cx="36" cy="14" r="4" fill="white" />
<circle cx="29" cy="15" r="2" fill="#1a1a1a" />
<circle cx="37" cy="15" r="2" fill="#1a1a1a" />
{/* Smile */}
<path d="M28,20 Q32,24 36,20" stroke="#1a1a1a" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
)
}
const MASCOT_SVGS: Record<AgentMascot, typeof SparkSVG> = {
Spark: SparkSVG,
Fizz: FizzSVG,
Octo: OctoSVG,
Hoot: HootSVG,
Buzz: BuzzSVG,
}
// Animation classes based on state
function getStateAnimation(state: AgentState): string {
switch (state) {
case 'idle':
return 'animate-bounce-gentle'
case 'thinking':
return 'animate-thinking'
case 'working':
return 'animate-working'
case 'testing':
return 'animate-testing'
case 'success':
return 'animate-celebrate'
case 'error':
case 'struggling':
return 'animate-shake-gentle'
default:
return ''
}
}
// Glow effect based on state
function getStateGlow(state: AgentState): string {
switch (state) {
case 'working':
return 'shadow-[0_0_12px_rgba(0,180,216,0.5)]'
case 'thinking':
return 'shadow-[0_0_8px_rgba(255,214,10,0.4)]'
case 'success':
return 'shadow-[0_0_16px_rgba(112,224,0,0.6)]'
case 'error':
case 'struggling':
return 'shadow-[0_0_12px_rgba(255,84,0,0.5)]'
default:
return ''
}
}
// Get human-readable state description for accessibility
function getStateDescription(state: AgentState): string {
switch (state) {
case 'idle':
return 'waiting'
case 'thinking':
return 'analyzing'
case 'working':
return 'coding'
case 'testing':
return 'running tests'
case 'success':
return 'completed successfully'
case 'error':
return 'encountered an error'
case 'struggling':
return 'having difficulty'
default:
return state
}
}
export function AgentAvatar({ name, state, size = 'md', showName = false }: AgentAvatarProps) {
const colors = AVATAR_COLORS[name]
const { svg: svgSize, font } = SIZES[size]
const SvgComponent = MASCOT_SVGS[name]
const stateDesc = getStateDescription(state)
const ariaLabel = `Agent ${name} is ${stateDesc}`
return (
<div
className="flex flex-col items-center gap-1"
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div
className={`
rounded-full p-1 transition-all duration-300
${getStateAnimation(state)}
${getStateGlow(state)}
`}
style={{ backgroundColor: colors.accent }}
title={ariaLabel}
role="img"
aria-hidden="true"
>
<SvgComponent colors={colors} size={svgSize} />
</div>
{showName && (
<span className={`${font} font-bold text-neo-text`} style={{ color: colors.primary }}>
{name}
</span>
)}
</div>
)
}
// Get mascot name by index (cycles through available mascots)
export function getMascotName(index: number): AgentMascot {
const mascots: AgentMascot[] = ['Spark', 'Fizz', 'Octo', 'Hoot', 'Buzz']
return mascots[index % mascots.length]
}

View File

@@ -0,0 +1,99 @@
import { MessageCircle } from 'lucide-react'
import { AgentAvatar } from './AgentAvatar'
import type { ActiveAgent } from '../lib/types'
interface AgentCardProps {
agent: ActiveAgent
}
// Get a friendly state description
function getStateText(state: ActiveAgent['state']): string {
switch (state) {
case 'idle':
return 'Waiting...'
case 'thinking':
return 'Thinking...'
case 'working':
return 'Coding...'
case 'testing':
return 'Testing...'
case 'success':
return 'Done!'
case 'error':
return 'Hit an issue'
case 'struggling':
return 'Retrying...'
default:
return 'Working...'
}
}
// Get state color
function getStateColor(state: ActiveAgent['state']): string {
switch (state) {
case 'success':
return 'text-neo-done'
case 'error':
case 'struggling':
return 'text-neo-danger'
case 'working':
case 'testing':
return 'text-neo-progress'
case 'thinking':
return 'text-neo-pending'
default:
return 'text-neo-text-secondary'
}
}
export function AgentCard({ agent }: AgentCardProps) {
const isActive = ['thinking', 'working', 'testing'].includes(agent.state)
return (
<div
className={`
neo-card p-3 min-w-[180px] max-w-[220px]
${isActive ? 'animate-pulse-neo' : ''}
transition-all duration-300
`}
>
{/* Header with avatar and name */}
<div className="flex items-center gap-2 mb-2">
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
<div className="flex-1 min-w-0">
<div className="font-display font-bold text-sm truncate">
{agent.agentName}
</div>
<div className={`text-xs ${getStateColor(agent.state)}`}>
{getStateText(agent.state)}
</div>
</div>
</div>
{/* Feature info */}
<div className="mb-2">
<div className="text-xs text-neo-text-secondary mb-0.5">
Feature #{agent.featureId}
</div>
<div className="text-sm font-medium truncate" title={agent.featureName}>
{agent.featureName}
</div>
</div>
{/* Thought bubble */}
{agent.thought && (
<div className="relative mt-2 pt-2 border-t-2 border-neo-border/30">
<div className="flex items-start gap-1.5">
<MessageCircle size={14} className="text-neo-progress shrink-0 mt-0.5" />
<p
className="text-xs text-neo-text-secondary line-clamp-2 italic"
title={agent.thought}
>
{agent.thought}
</p>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { Play, Square, Loader2 } from 'lucide-react'
import { useState } from 'react'
import { Play, Square, Loader2, GitBranch } from 'lucide-react'
import {
useStartAgent,
useStopAgent,
@@ -15,19 +16,57 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
const { data: settings } = useSettings()
const yoloMode = settings?.yolo_mode ?? false
// Concurrency: 1 = single agent, 2-5 = parallel
const [concurrency, setConcurrency] = useState(3)
const startAgent = useStartAgent(projectName)
const stopAgent = useStopAgent(projectName)
const isLoading = startAgent.isPending || stopAgent.isPending
const isRunning = status === 'running' || status === 'paused'
const isParallel = concurrency > 1
const handleStart = () => startAgent.mutate(yoloMode)
const handleStart = () => startAgent.mutate({
yoloMode,
parallelMode: isParallel,
maxConcurrency: isParallel ? concurrency : undefined,
})
const handleStop = () => stopAgent.mutate()
// Simplified: either show Start (when stopped/crashed) or Stop (when running/paused)
const isStopped = status === 'stopped' || status === 'crashed'
return (
<div className="flex items-center">
<div className="flex items-center gap-2">
{/* Concurrency slider - always visible when stopped */}
{isStopped && (
<div className="flex items-center gap-2">
<GitBranch size={16} className={isParallel ? 'text-[var(--color-neo-primary)]' : 'text-gray-400'} />
<input
type="range"
min={1}
max={5}
value={concurrency}
onChange={(e) => setConcurrency(Number(e.target.value))}
disabled={isLoading}
className="w-16 h-2 accent-[var(--color-neo-primary)] cursor-pointer"
title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`}
aria-label="Set number of concurrent agents"
/>
<span className="text-xs font-bold min-w-[1.5rem] text-center">
{concurrency}x
</span>
</div>
)}
{/* Show concurrency indicator when running with multiple agents */}
{isRunning && isParallel && (
<div className="flex items-center gap-1 text-xs text-[var(--color-neo-primary)] font-bold">
<GitBranch size={14} />
<span>{concurrency}x</span>
</div>
)}
{isStopped ? (
<button
onClick={handleStart}

View File

@@ -0,0 +1,121 @@
import { Rocket, ChevronDown, ChevronUp, Activity } from 'lucide-react'
import { useState } from 'react'
import { AgentCard } from './AgentCard'
import { ActivityFeed } from './ActivityFeed'
import type { ActiveAgent } from '../lib/types'
const ACTIVITY_COLLAPSED_KEY = 'autocoder-activity-collapsed'
interface AgentMissionControlProps {
agents: ActiveAgent[]
recentActivity: Array<{
agentName: string
thought: string
timestamp: string
featureId: number
}>
isExpanded?: boolean
}
export function AgentMissionControl({
agents,
recentActivity,
isExpanded: defaultExpanded = true,
}: AgentMissionControlProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
const [activityCollapsed, setActivityCollapsed] = useState(() => {
try {
return localStorage.getItem(ACTIVITY_COLLAPSED_KEY) === 'true'
} catch {
return false
}
})
const toggleActivityCollapsed = () => {
const newValue = !activityCollapsed
setActivityCollapsed(newValue)
try {
localStorage.setItem(ACTIVITY_COLLAPSED_KEY, String(newValue))
} catch {
// localStorage not available
}
}
// Don't render if no agents
if (agents.length === 0) {
return null
}
return (
<div className="neo-card mb-6 overflow-hidden">
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--color-neo-progress)] hover:brightness-105 transition-all"
>
<div className="flex items-center gap-2">
<Rocket size={20} className="text-neo-text-on-bright" />
<span className="font-display font-bold text-neo-text-on-bright uppercase tracking-wide">
Mission Control
</span>
<span className="neo-badge neo-badge-sm bg-white text-neo-text ml-2">
{agents.length} {agents.length === 1 ? 'agent' : 'agents'} active
</span>
</div>
{isExpanded ? (
<ChevronUp size={20} className="text-neo-text-on-bright" />
) : (
<ChevronDown size={20} className="text-neo-text-on-bright" />
)}
</button>
{/* Content */}
<div
className={`
transition-all duration-300 ease-out overflow-hidden
${isExpanded ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'}
`}
>
<div className="p-4">
{/* Agent Cards Row */}
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin">
{agents.map((agent) => (
<AgentCard key={`agent-${agent.agentIndex}`} agent={agent} />
))}
</div>
{/* Collapsible Activity Feed */}
{recentActivity.length > 0 && (
<div className="mt-4 pt-4 border-t-2 border-neo-border/30">
<button
onClick={toggleActivityCollapsed}
className="flex items-center gap-2 mb-2 hover:opacity-80 transition-opacity"
>
<Activity size={14} className="text-neo-text-secondary" />
<span className="text-xs font-bold text-neo-text-secondary uppercase tracking-wide">
Recent Activity
</span>
<span className="text-xs text-neo-muted">
({recentActivity.length})
</span>
{activityCollapsed ? (
<ChevronDown size={14} className="text-neo-text-secondary" />
) : (
<ChevronUp size={14} className="text-neo-text-secondary" />
)}
</button>
<div
className={`
transition-all duration-200 ease-out overflow-hidden
${activityCollapsed ? 'max-h-0 opacity-0' : 'max-h-[300px] opacity-100'}
`}
>
<ActivityFeed activities={recentActivity} maxItems={5} showHeader={false} />
</div>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -25,14 +25,14 @@ function isAgentThought(line: string): boolean {
// Skip JSON and very short lines
if (/^[[{]/.test(trimmed)) return false
if (trimmed.length < 15) return false
if (trimmed.length < 10) return false
// Skip lines that are just paths or technical output
if (/^[A-Za-z]:\\/.test(trimmed)) return false
if (/^\/[a-z]/.test(trimmed)) return false
// Keep narrative text (starts with capital, looks like a sentence)
return /^[A-Z]/.test(trimmed) && trimmed.length > 20
// Keep narrative text (looks like a sentence, relaxed filter)
return trimmed.length > 10
}
/**

View File

@@ -0,0 +1,120 @@
import { useCallback, useEffect, useState } from 'react'
import { Sparkles, PartyPopper } from 'lucide-react'
import { AgentAvatar } from './AgentAvatar'
import type { AgentMascot } from '../lib/types'
interface CelebrationOverlayProps {
agentName: AgentMascot
featureName: string
onComplete?: () => void
}
// Generate random confetti particles
function generateConfetti(count: number) {
return Array.from({ length: count }, (_, i) => ({
id: i,
x: Math.random() * 100,
delay: Math.random() * 0.5,
duration: 1 + Math.random() * 1,
color: ['#ff006e', '#ffd60a', '#70e000', '#00b4d8', '#8338ec'][Math.floor(Math.random() * 5)],
rotation: Math.random() * 360,
}))
}
export function CelebrationOverlay({ agentName, featureName, onComplete }: CelebrationOverlayProps) {
const [isVisible, setIsVisible] = useState(true)
const [confetti] = useState(() => generateConfetti(30))
const dismiss = useCallback(() => {
setIsVisible(false)
setTimeout(() => onComplete?.(), 300) // Wait for fade animation
}, [onComplete])
useEffect(() => {
// Auto-dismiss after 3 seconds
const timer = setTimeout(dismiss, 3000)
// Escape key to dismiss early
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
dismiss()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
clearTimeout(timer)
window.removeEventListener('keydown', handleKeyDown)
}
}, [dismiss])
if (!isVisible) {
return null
}
return (
<div
className={`
fixed inset-0 z-50 flex items-center justify-center
pointer-events-none
transition-opacity duration-300
${isVisible ? 'opacity-100' : 'opacity-0'}
`}
>
{/* Confetti particles */}
<div className="absolute inset-0 overflow-hidden">
{confetti.map((particle) => (
<div
key={particle.id}
className="absolute w-3 h-3 animate-confetti"
style={{
left: `${particle.x}%`,
top: '-20px',
backgroundColor: particle.color,
animationDelay: `${particle.delay}s`,
animationDuration: `${particle.duration}s`,
transform: `rotate(${particle.rotation}deg)`,
}}
/>
))}
</div>
{/* Celebration card - click to dismiss */}
<button
type="button"
onClick={dismiss}
className="neo-card p-6 bg-[var(--color-neo-done)] animate-bounce-in pointer-events-auto cursor-pointer hover:scale-105 transition-transform focus:outline-none focus:ring-2 focus:ring-neo-accent"
>
<div className="flex flex-col items-center gap-4">
{/* Icons */}
<div className="flex items-center gap-2">
<Sparkles size={24} className="text-neo-pending animate-pulse" />
<PartyPopper size={28} className="text-neo-accent" />
<Sparkles size={24} className="text-neo-pending animate-pulse" />
</div>
{/* Avatar celebrating */}
<AgentAvatar name={agentName} state="success" size="lg" />
{/* Message */}
<div className="text-center">
<h3 className="font-display text-lg font-bold text-neo-text-on-bright mb-1">
Feature Complete!
</h3>
<p className="text-sm text-neo-text-on-bright/80 max-w-[200px] truncate">
{featureName}
</p>
<p className="text-xs text-neo-text-on-bright/60 mt-2">
Great job, {agentName}!
</p>
</div>
{/* Dismiss hint */}
<p className="text-xs text-neo-text-on-bright/40 mt-1">
Click or press Esc to dismiss
</p>
</div>
</button>
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { AlertTriangle, GitBranch, Check } from 'lucide-react'
import type { Feature } from '../lib/types'
interface DependencyBadgeProps {
feature: Feature
allFeatures?: Feature[]
compact?: boolean
}
/**
* Badge component showing dependency status for a feature.
* Shows:
* - Blocked status with count of blocking dependencies
* - Dependency count for features with satisfied dependencies
* - Nothing if feature has no dependencies
*/
export function DependencyBadge({ feature, allFeatures = [], compact = false }: DependencyBadgeProps) {
const dependencies = feature.dependencies || []
if (dependencies.length === 0) {
return null
}
// Use API-computed blocked status if available, otherwise compute locally
const isBlocked = feature.blocked ??
(feature.blocking_dependencies && feature.blocking_dependencies.length > 0) ??
false
const blockingCount = feature.blocking_dependencies?.length ?? 0
// Compute satisfied count from allFeatures if available
let satisfiedCount = dependencies.length - blockingCount
if (allFeatures.length > 0 && !feature.blocking_dependencies) {
const passingIds = new Set(allFeatures.filter(f => f.passes).map(f => f.id))
satisfiedCount = dependencies.filter(d => passingIds.has(d)).length
}
if (compact) {
// Compact view for card displays
return (
<div
className={`
inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-mono
${isBlocked
? 'bg-neo-danger/20 text-neo-danger'
: 'bg-neo-neutral-200 text-neo-text-secondary'
}
`}
title={isBlocked
? `Blocked by ${blockingCount} ${blockingCount === 1 ? 'dependency' : 'dependencies'}`
: `${satisfiedCount}/${dependencies.length} dependencies satisfied`
}
>
{isBlocked ? (
<>
<AlertTriangle size={12} />
<span>{blockingCount}</span>
</>
) : (
<>
<GitBranch size={12} />
<span>{satisfiedCount}/{dependencies.length}</span>
</>
)}
</div>
)
}
// Full view with more details
return (
<div className="flex items-center gap-2">
{isBlocked ? (
<div className="flex items-center gap-1.5 text-sm text-neo-danger">
<AlertTriangle size={14} />
<span className="font-medium">
Blocked by {blockingCount} {blockingCount === 1 ? 'dependency' : 'dependencies'}
</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-sm text-neo-text-secondary">
<Check size={14} className="text-neo-done" />
<span>
All {dependencies.length} {dependencies.length === 1 ? 'dependency' : 'dependencies'} satisfied
</span>
</div>
)}
</div>
)
}
/**
* Small inline indicator for dependency status
*/
export function DependencyIndicator({ feature }: { feature: Feature }) {
const dependencies = feature.dependencies || []
const isBlocked = feature.blocked || (feature.blocking_dependencies && feature.blocking_dependencies.length > 0)
if (dependencies.length === 0) {
return null
}
if (isBlocked) {
return (
<span
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-neo-danger/20 text-neo-danger"
title={`Blocked by ${feature.blocking_dependencies?.length || 0} dependencies`}
>
<AlertTriangle size={12} />
</span>
)
}
return (
<span
className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-neo-neutral-200 text-neo-text-secondary"
title={`${dependencies.length} dependencies (all satisfied)`}
>
<GitBranch size={12} />
</span>
)
}

View File

@@ -0,0 +1,289 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
Node,
Edge,
Position,
MarkerType,
ConnectionMode,
Handle,
} from '@xyflow/react'
import dagre from 'dagre'
import { CheckCircle2, Circle, Loader2, AlertTriangle } from 'lucide-react'
import type { DependencyGraph as DependencyGraphData, GraphNode } from '../lib/types'
import '@xyflow/react/dist/style.css'
// Node dimensions
const NODE_WIDTH = 220
const NODE_HEIGHT = 80
interface DependencyGraphProps {
graphData: DependencyGraphData
onNodeClick?: (nodeId: number) => void
}
// Custom node component
function FeatureNode({ data }: { data: GraphNode & { onClick?: () => void } }) {
const statusColors = {
pending: 'bg-neo-pending border-neo-border',
in_progress: 'bg-neo-progress border-neo-border',
done: 'bg-neo-done border-neo-border',
blocked: 'bg-neo-danger/20 border-neo-danger',
}
const StatusIcon = () => {
switch (data.status) {
case 'done':
return <CheckCircle2 size={16} className="text-neo-text-on-bright" />
case 'in_progress':
return <Loader2 size={16} className="text-neo-text-on-bright animate-spin" />
case 'blocked':
return <AlertTriangle size={16} className="text-neo-danger" />
default:
return <Circle size={16} className="text-neo-text-on-bright" />
}
}
return (
<>
<Handle type="target" position={Position.Left} className="!bg-neo-border !w-2 !h-2" />
<div
className={`
px-4 py-3 rounded-lg border-2 cursor-pointer
transition-all hover:shadow-neo-md
${statusColors[data.status]}
`}
onClick={data.onClick}
style={{ minWidth: NODE_WIDTH - 20, maxWidth: NODE_WIDTH }}
>
<div className="flex items-center gap-2 mb-1">
<StatusIcon />
<span className="text-xs font-mono text-neo-text-on-bright/70">
#{data.priority}
</span>
</div>
<div className="font-bold text-sm text-neo-text-on-bright truncate" title={data.name}>
{data.name}
</div>
<div className="text-xs text-neo-text-on-bright/70 truncate" title={data.category}>
{data.category}
</div>
</div>
<Handle type="source" position={Position.Right} className="!bg-neo-border !w-2 !h-2" />
</>
)
}
const nodeTypes = {
feature: FeatureNode,
}
// Layout nodes using dagre
function getLayoutedElements(
nodes: Node[],
edges: Edge[],
direction: 'TB' | 'LR' = 'LR'
): { nodes: Node[]; edges: Edge[] } {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
const isHorizontal = direction === 'LR'
dagreGraph.setGraph({
rankdir: direction,
nodesep: 50,
ranksep: 100,
marginx: 50,
marginy: 50,
})
nodes.forEach((node) => {
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT })
})
edges.forEach((edge) => {
dagreGraph.setEdge(edge.source, edge.target)
})
dagre.layout(dagreGraph)
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
return {
...node,
position: {
x: nodeWithPosition.x - NODE_WIDTH / 2,
y: nodeWithPosition.y - NODE_HEIGHT / 2,
},
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
targetPosition: isHorizontal ? Position.Left : Position.Top,
}
})
return { nodes: layoutedNodes, edges }
}
export function DependencyGraph({ graphData, onNodeClick }: DependencyGraphProps) {
const [direction, setDirection] = useState<'TB' | 'LR'>('LR')
// Convert graph data to React Flow format
const initialElements = useMemo(() => {
const nodes: Node[] = graphData.nodes.map((node) => ({
id: String(node.id),
type: 'feature',
position: { x: 0, y: 0 },
data: {
...node,
onClick: () => onNodeClick?.(node.id),
},
}))
const edges: Edge[] = graphData.edges.map((edge, index) => ({
id: `e${edge.source}-${edge.target}-${index}`,
source: String(edge.source),
target: String(edge.target),
type: 'smoothstep',
animated: false,
style: { stroke: 'var(--color-neo-border)', strokeWidth: 2 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: 'var(--color-neo-border)',
},
}))
return getLayoutedElements(nodes, edges, direction)
}, [graphData, direction, onNodeClick])
const [nodes, setNodes, onNodesChange] = useNodesState(initialElements.nodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialElements.edges)
// Update layout when data or direction changes
useEffect(() => {
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(
initialElements.nodes,
initialElements.edges,
direction
)
setNodes(layoutedNodes)
setEdges(layoutedEdges)
}, [graphData, direction, setNodes, setEdges, initialElements])
const onLayout = useCallback(
(newDirection: 'TB' | 'LR') => {
setDirection(newDirection)
},
[]
)
// Color nodes for minimap
const nodeColor = useCallback((node: Node) => {
const status = (node.data as unknown as GraphNode).status
switch (status) {
case 'done':
return 'var(--color-neo-done)'
case 'in_progress':
return 'var(--color-neo-progress)'
case 'blocked':
return 'var(--color-neo-danger)'
default:
return 'var(--color-neo-pending)'
}
}, [])
if (graphData.nodes.length === 0) {
return (
<div className="h-full w-full flex items-center justify-center bg-neo-neutral-100">
<div className="text-center">
<div className="text-neo-text-secondary mb-2">No features to display</div>
<div className="text-sm text-neo-text-muted">
Create features to see the dependency graph
</div>
</div>
</div>
)
}
return (
<div className="h-full w-full relative bg-neo-neutral-50">
{/* Layout toggle */}
<div className="absolute top-4 left-4 z-10 flex gap-2">
<button
onClick={() => onLayout('LR')}
className={`
px-3 py-1.5 text-sm font-medium rounded border-2 border-neo-border transition-all
${direction === 'LR'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'bg-white text-neo-text hover:bg-neo-neutral-100'
}
`}
>
Horizontal
</button>
<button
onClick={() => onLayout('TB')}
className={`
px-3 py-1.5 text-sm font-medium rounded border-2 border-neo-border transition-all
${direction === 'TB'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'bg-white text-neo-text hover:bg-neo-neutral-100'
}
`}
>
Vertical
</button>
</div>
{/* Legend */}
<div className="absolute top-4 right-4 z-10 bg-white border-2 border-neo-border rounded-lg p-3 shadow-neo-sm">
<div className="text-xs font-bold mb-2">Status</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-neo-pending border border-neo-border" />
<span>Pending</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-neo-progress border border-neo-border" />
<span>In Progress</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-neo-done border border-neo-border" />
<span>Done</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-3 h-3 rounded bg-neo-danger/20 border border-neo-danger" />
<span>Blocked</span>
</div>
</div>
</div>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
connectionMode={ConnectionMode.Loose}
fitView
fitViewOptions={{ padding: 0.2 }}
attributionPosition="bottom-left"
minZoom={0.1}
maxZoom={2}
>
<Background color="var(--color-neo-neutral-300)" gap={20} size={1} />
<Controls
className="!bg-white !border-2 !border-neo-border !rounded-lg !shadow-neo-sm"
showInteractive={false}
/>
<MiniMap
nodeColor={nodeColor}
className="!bg-white !border-2 !border-neo-border !rounded-lg !shadow-neo-sm"
maskColor="rgba(0, 0, 0, 0.1)"
/>
</ReactFlow>
</div>
)
}

View File

@@ -1,10 +1,14 @@
import { CheckCircle2, Circle, Loader2 } from 'lucide-react'
import type { Feature } from '../lib/types'
import { CheckCircle2, Circle, Loader2, MessageCircle } from 'lucide-react'
import type { Feature, ActiveAgent } from '../lib/types'
import { DependencyBadge } from './DependencyBadge'
import { AgentAvatar } from './AgentAvatar'
interface FeatureCardProps {
feature: Feature
onClick: () => void
isInProgress?: boolean
allFeatures?: Feature[]
activeAgent?: ActiveAgent // Agent working on this feature
}
// Generate consistent color for category using CSS variable references
@@ -28,26 +32,33 @@ function getCategoryColor(category: string): string {
return colors[Math.abs(hash) % colors.length]
}
export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps) {
export function FeatureCard({ feature, onClick, isInProgress, allFeatures = [], activeAgent }: FeatureCardProps) {
const categoryColor = getCategoryColor(feature.category)
const isBlocked = feature.blocked || (feature.blocking_dependencies && feature.blocking_dependencies.length > 0)
const hasActiveAgent = !!activeAgent
return (
<button
onClick={onClick}
className={`
w-full text-left neo-card p-4 cursor-pointer
w-full text-left neo-card p-4 cursor-pointer relative
${isInProgress ? 'animate-pulse-neo' : ''}
${feature.passes ? 'border-neo-done' : ''}
${isBlocked && !feature.passes ? 'border-neo-danger opacity-80' : ''}
${hasActiveAgent ? 'ring-2 ring-neo-progress ring-offset-2' : ''}
`}
>
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-2">
<span
className="neo-badge"
style={{ backgroundColor: categoryColor, color: 'var(--color-neo-text-on-bright)' }}
>
{feature.category}
</span>
<div className="flex items-center gap-2">
<span
className="neo-badge"
style={{ backgroundColor: categoryColor, color: 'var(--color-neo-text-on-bright)' }}
>
{feature.category}
</span>
<DependencyBadge feature={feature} allFeatures={allFeatures} compact />
</div>
<span className="font-mono text-sm text-neo-text-secondary">
#{feature.priority}
</span>
@@ -63,6 +74,26 @@ export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps
{feature.description}
</p>
{/* Agent working on this feature */}
{activeAgent && (
<div className="flex items-center gap-2 mb-3 py-2 px-2 rounded bg-[var(--color-neo-progress)]/10 border border-[var(--color-neo-progress)]/30">
<AgentAvatar name={activeAgent.agentName} state={activeAgent.state} size="sm" />
<div className="flex-1 min-w-0">
<div className="text-xs font-bold text-neo-progress">
{activeAgent.agentName} is working on this!
</div>
{activeAgent.thought && (
<div className="flex items-center gap-1 mt-0.5">
<MessageCircle size={10} className="text-neo-text-secondary shrink-0" />
<p className="text-[10px] text-neo-text-secondary truncate italic">
{activeAgent.thought}
</p>
</div>
)}
</div>
</div>
)}
{/* Status */}
<div className="flex items-center gap-2 text-sm">
{isInProgress ? (
@@ -75,6 +106,11 @@ export function FeatureCard({ feature, onClick, isInProgress }: FeatureCardProps
<CheckCircle2 size={16} className="text-neo-done" />
<span className="text-neo-done font-bold">Complete</span>
</>
) : isBlocked ? (
<>
<Circle size={16} className="text-neo-danger" />
<span className="text-neo-danger">Blocked</span>
</>
) : (
<>
<Circle size={16} className="text-neo-text-secondary" />

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil } from 'lucide-react'
import { useSkipFeature, useDeleteFeature } from '../hooks/useProjects'
import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle, Pencil, Link2, AlertTriangle } from 'lucide-react'
import { useSkipFeature, useDeleteFeature, useFeatures } from '../hooks/useProjects'
import { EditFeatureForm } from './EditFeatureForm'
import type { Feature } from '../lib/types'
@@ -37,6 +37,25 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
const skipFeature = useSkipFeature(projectName)
const deleteFeature = useDeleteFeature(projectName)
const { data: allFeatures } = useFeatures(projectName)
// Build a map of feature ID to feature for looking up dependency names
const featureMap = new Map<number, Feature>()
if (allFeatures) {
;[...allFeatures.pending, ...allFeatures.in_progress, ...allFeatures.done].forEach(f => {
featureMap.set(f.id, f)
})
}
// Get dependency features
const dependencies = (feature.dependencies || [])
.map(id => featureMap.get(id))
.filter((f): f is Feature => f !== undefined)
// Get blocking dependencies (unmet dependencies)
const blockingDeps = (feature.blocking_dependencies || [])
.map(id => featureMap.get(id))
.filter((f): f is Feature => f !== undefined)
const handleSkip = async () => {
setError(null)
@@ -145,6 +164,57 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
</p>
</div>
{/* Blocked By Warning */}
{blockingDeps.length > 0 && (
<div className="p-4 bg-[var(--color-neo-warning-bg)] border-3 border-[var(--color-neo-warning-border)]">
<h3 className="font-display font-bold mb-2 uppercase text-sm flex items-center gap-2 text-[var(--color-neo-warning-text)]">
<AlertTriangle size={16} />
Blocked By
</h3>
<p className="text-sm text-[var(--color-neo-warning-text)] mb-2">
This feature cannot start until the following dependencies are complete:
</p>
<ul className="space-y-1">
{blockingDeps.map(dep => (
<li
key={dep.id}
className="flex items-center gap-2 text-sm"
>
<Circle size={14} className="text-[var(--color-neo-warning-text)]" />
<span className="font-mono text-xs text-[var(--color-neo-warning-text)]">#{dep.id}</span>
<span className="text-[var(--color-neo-warning-text)]">{dep.name}</span>
</li>
))}
</ul>
</div>
)}
{/* Dependencies */}
{dependencies.length > 0 && (
<div>
<h3 className="font-display font-bold mb-2 uppercase text-sm flex items-center gap-2">
<Link2 size={16} />
Depends On
</h3>
<ul className="space-y-1">
{dependencies.map(dep => (
<li
key={dep.id}
className="flex items-center gap-2 p-2 bg-[var(--color-neo-bg)] border-2 border-[var(--color-neo-border)]"
>
{dep.passes ? (
<CheckCircle2 size={16} className="text-[var(--color-neo-done)]" />
) : (
<Circle size={16} className="text-[var(--color-neo-text-secondary)]" />
)}
<span className="font-mono text-xs text-[var(--color-neo-text-secondary)]">#{dep.id}</span>
<span className={dep.passes ? 'text-[var(--color-neo-done)]' : ''}>{dep.name}</span>
</li>
))}
</ul>
</div>
)}
{/* Steps */}
{feature.steps.length > 0 && (
<div>

View File

@@ -1,16 +1,22 @@
import { KanbanColumn } from './KanbanColumn'
import type { Feature, FeatureListResponse } from '../lib/types'
import type { Feature, FeatureListResponse, ActiveAgent } from '../lib/types'
interface KanbanBoardProps {
features: FeatureListResponse | undefined
onFeatureClick: (feature: Feature) => void
onAddFeature?: () => void
onExpandProject?: () => void
activeAgents?: ActiveAgent[]
}
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject }: KanbanBoardProps) {
export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandProject, activeAgents = [] }: KanbanBoardProps) {
const hasFeatures = features && (features.pending.length + features.in_progress.length + features.done.length) > 0
// Combine all features for dependency status calculation
const allFeatures = features
? [...features.pending, ...features.in_progress, ...features.done]
: []
if (!features) {
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -34,6 +40,8 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
title="Pending"
count={features.pending.length}
features={features.pending}
allFeatures={allFeatures}
activeAgents={activeAgents}
color="pending"
onFeatureClick={onFeatureClick}
onAddFeature={onAddFeature}
@@ -44,6 +52,8 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
title="In Progress"
count={features.in_progress.length}
features={features.in_progress}
allFeatures={allFeatures}
activeAgents={activeAgents}
color="progress"
onFeatureClick={onFeatureClick}
/>
@@ -51,6 +61,8 @@ export function KanbanBoard({ features, onFeatureClick, onAddFeature, onExpandPr
title="Done"
count={features.done.length}
features={features.done}
allFeatures={allFeatures}
activeAgents={activeAgents}
color="done"
onFeatureClick={onFeatureClick}
/>

View File

@@ -1,11 +1,13 @@
import { FeatureCard } from './FeatureCard'
import { Plus, Sparkles } from 'lucide-react'
import type { Feature } from '../lib/types'
import type { Feature, ActiveAgent } from '../lib/types'
interface KanbanColumnProps {
title: string
count: number
features: Feature[]
allFeatures?: Feature[] // For dependency status calculation
activeAgents?: ActiveAgent[] // Active agents for showing which agent is working on a feature
color: 'pending' | 'progress' | 'done'
onFeatureClick: (feature: Feature) => void
onAddFeature?: () => void
@@ -23,12 +25,18 @@ export function KanbanColumn({
title,
count,
features,
allFeatures = [],
activeAgents = [],
color,
onFeatureClick,
onAddFeature,
onExpandProject,
showExpandButton,
}: KanbanColumnProps) {
// Create a map of feature ID to active agent for quick lookup
const agentByFeatureId = new Map(
activeAgents.map(agent => [agent.featureId, agent])
)
return (
<div
className="neo-card overflow-hidden"
@@ -86,6 +94,8 @@ export function KanbanColumn({
feature={feature}
onClick={() => onFeatureClick(feature)}
isInProgress={color === 'progress'}
allFeatures={allFeatures}
activeAgent={agentByFeatureId.get(feature.id)}
/>
</div>
))

View File

@@ -0,0 +1,93 @@
import { useEffect, useCallback } from 'react'
import { X, Keyboard } from 'lucide-react'
interface Shortcut {
key: string
description: string
context?: string
}
const shortcuts: Shortcut[] = [
{ key: '?', description: 'Show keyboard shortcuts' },
{ key: 'D', description: 'Toggle debug panel' },
{ key: 'T', description: 'Toggle terminal tab' },
{ key: 'N', description: 'Add new feature', context: 'with project' },
{ key: 'E', description: 'Expand project with AI', context: 'with features' },
{ key: 'A', description: 'Toggle AI assistant', context: 'with project' },
{ key: 'G', description: 'Toggle Kanban/Graph view', context: 'with project' },
{ key: ',', description: 'Open settings' },
{ key: 'Esc', description: 'Close modal/panel' },
]
interface KeyboardShortcutsHelpProps {
onClose: () => void
}
export function KeyboardShortcutsHelp({ onClose }: KeyboardShortcutsHelpProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape' || e.key === '?') {
e.preventDefault()
onClose()
}
},
[onClose]
)
useEffect(() => {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleKeyDown])
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={onClose}
>
<div
className="neo-card p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Keyboard size={20} className="text-neo-accent" />
<h2 className="font-display text-lg font-bold">Keyboard Shortcuts</h2>
</div>
<button
onClick={onClose}
className="neo-btn p-1.5"
aria-label="Close"
>
<X size={16} />
</button>
</div>
{/* Shortcuts list */}
<ul className="space-y-2">
{shortcuts.map((shortcut) => (
<li
key={shortcut.key}
className="flex items-center justify-between py-2 border-b border-neo-border/30 last:border-0"
>
<div className="flex items-center gap-2">
<kbd className="px-2 py-1 text-sm font-mono bg-neo-bg rounded border border-neo-border shadow-neo-sm min-w-[2rem] text-center">
{shortcut.key}
</kbd>
<span className="text-neo-text">{shortcut.description}</span>
</div>
{shortcut.context && (
<span className="text-xs text-neo-muted">{shortcut.context}</span>
)}
</li>
))}
</ul>
{/* Footer */}
<p className="text-xs text-neo-muted text-center mt-6">
Press ? or Esc to close
</p>
</div>
</div>
)
}

View File

@@ -129,7 +129,7 @@ export function NewProjectModal({
// Auto-start the initializer agent
setInitializerStatus('starting')
try {
await startAgent(projectName.trim(), yoloMode)
await startAgent(projectName.trim(), { yoloMode })
// Success - navigate to project
changeStep('complete')
setTimeout(() => {

View File

@@ -0,0 +1,46 @@
import { LayoutGrid, GitBranch } from 'lucide-react'
export type ViewMode = 'kanban' | 'graph'
interface ViewToggleProps {
viewMode: ViewMode
onViewModeChange: (mode: ViewMode) => void
}
/**
* Toggle button to switch between Kanban and Graph views
*/
export function ViewToggle({ viewMode, onViewModeChange }: ViewToggleProps) {
return (
<div className="inline-flex rounded-lg border-2 border-neo-border p-1 bg-white shadow-neo-sm">
<button
onClick={() => onViewModeChange('kanban')}
className={`
flex items-center gap-1.5 px-3 py-1.5 rounded-md font-medium text-sm transition-all
${viewMode === 'kanban'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'text-neo-text hover:bg-neo-neutral-100'
}
`}
title="Kanban View"
>
<LayoutGrid size={16} />
<span>Kanban</span>
</button>
<button
onClick={() => onViewModeChange('graph')}
className={`
flex items-center gap-1.5 px-3 py-1.5 rounded-md font-medium text-sm transition-all
${viewMode === 'graph'
? 'bg-neo-accent text-white shadow-neo-sm'
: 'text-neo-text hover:bg-neo-neutral-100'
}
`}
title="Dependency Graph View"
>
<GitBranch size={16} />
<span>Graph</span>
</button>
</div>
)
}

View File

@@ -123,7 +123,11 @@ export function useStartAgent(projectName: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (yoloMode: boolean = false) => api.startAgent(projectName, yoloMode),
mutationFn: (options: {
yoloMode?: boolean
parallelMode?: boolean
maxConcurrency?: number
} = {}) => api.startAgent(projectName, options),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-status', projectName] })
},

View File

@@ -3,7 +3,28 @@
*/
import { useEffect, useRef, useState, useCallback } from 'react'
import type { WSMessage, AgentStatus, DevServerStatus } from '../lib/types'
import type {
WSMessage,
AgentStatus,
DevServerStatus,
ActiveAgent,
AgentMascot,
} from '../lib/types'
// Activity item for the feed
interface ActivityItem {
agentName: string
thought: string
timestamp: string
featureId: number
}
// Celebration trigger for overlay
interface CelebrationTrigger {
agentName: AgentMascot
featureName: string
featureId: number
}
interface WebSocketState {
progress: {
@@ -13,14 +34,21 @@ interface WebSocketState {
percentage: number
}
agentStatus: AgentStatus
logs: Array<{ line: string; timestamp: string }>
logs: Array<{ line: string; timestamp: string; featureId?: number; agentIndex?: number }>
isConnected: boolean
devServerStatus: DevServerStatus
devServerUrl: string | null
devLogs: Array<{ line: string; timestamp: string }>
// Multi-agent state
activeAgents: ActiveAgent[]
recentActivity: ActivityItem[]
// Celebration queue to handle rapid successes without race conditions
celebrationQueue: CelebrationTrigger[]
celebration: CelebrationTrigger | null
}
const MAX_LOGS = 100 // Keep last 100 log lines
const MAX_ACTIVITY = 20 // Keep last 20 activity items
export function useProjectWebSocket(projectName: string | null) {
const [state, setState] = useState<WebSocketState>({
@@ -31,6 +59,10 @@ export function useProjectWebSocket(projectName: string | null) {
devServerStatus: 'stopped',
devServerUrl: null,
devLogs: [],
activeAgents: [],
recentActivity: [],
celebrationQueue: [],
celebration: null,
})
const wsRef = useRef<WebSocket | null>(null)
@@ -83,7 +115,12 @@ export function useProjectWebSocket(projectName: string | null) {
...prev,
logs: [
...prev.logs.slice(-MAX_LOGS + 1),
{ line: message.line, timestamp: message.timestamp },
{
line: message.line,
timestamp: message.timestamp,
featureId: message.featureId,
agentIndex: message.agentIndex,
},
],
}))
break
@@ -92,6 +129,91 @@ export function useProjectWebSocket(projectName: string | null) {
// Feature updates will trigger a refetch via React Query
break
case 'agent_update':
setState(prev => {
// Update or add the agent in activeAgents
const agentIndex = prev.activeAgents.findIndex(
a => a.agentIndex === message.agentIndex
)
let newAgents: ActiveAgent[]
if (message.state === 'success') {
// Remove agent from active list on success
newAgents = prev.activeAgents.filter(
a => a.agentIndex !== message.agentIndex
)
} else if (agentIndex >= 0) {
// Update existing agent
newAgents = [...prev.activeAgents]
newAgents[agentIndex] = {
agentIndex: message.agentIndex,
agentName: message.agentName,
featureId: message.featureId,
featureName: message.featureName,
state: message.state,
thought: message.thought,
timestamp: message.timestamp,
}
} else {
// Add new agent
newAgents = [
...prev.activeAgents,
{
agentIndex: message.agentIndex,
agentName: message.agentName,
featureId: message.featureId,
featureName: message.featureName,
state: message.state,
thought: message.thought,
timestamp: message.timestamp,
},
]
}
// Add to activity feed if there's a thought
let newActivity = prev.recentActivity
if (message.thought) {
newActivity = [
{
agentName: message.agentName,
thought: message.thought,
timestamp: message.timestamp,
featureId: message.featureId,
},
...prev.recentActivity.slice(0, MAX_ACTIVITY - 1),
]
}
// Handle celebration queue on success
let newCelebrationQueue = prev.celebrationQueue
let newCelebration = prev.celebration
if (message.state === 'success') {
const newCelebrationItem: CelebrationTrigger = {
agentName: message.agentName,
featureName: message.featureName,
featureId: message.featureId,
}
// If no celebration is showing, show this one immediately
// Otherwise, add to queue
if (!prev.celebration) {
newCelebration = newCelebrationItem
} else {
newCelebrationQueue = [...prev.celebrationQueue, newCelebrationItem]
}
}
return {
...prev,
activeAgents: newAgents,
recentActivity: newActivity,
celebrationQueue: newCelebrationQueue,
celebration: newCelebration,
}
})
break
case 'dev_log':
setState(prev => ({
...prev,
@@ -147,6 +269,19 @@ export function useProjectWebSocket(projectName: string | null) {
}
}, [])
// Clear celebration and show next one from queue if available
const clearCelebration = useCallback(() => {
setState(prev => {
// Pop the next celebration from the queue if available
const [nextCelebration, ...remainingQueue] = prev.celebrationQueue
return {
...prev,
celebration: nextCelebration || null,
celebrationQueue: remainingQueue,
}
})
}, [])
// Connect when project changes
useEffect(() => {
// Reset state when project changes to clear stale data
@@ -158,6 +293,10 @@ export function useProjectWebSocket(projectName: string | null) {
devServerStatus: 'stopped',
devServerUrl: null,
devLogs: [],
activeAgents: [],
recentActivity: [],
celebrationQueue: [],
celebration: null,
})
if (!projectName) {
@@ -200,5 +339,6 @@ export function useProjectWebSocket(projectName: string | null) {
...state,
clearLogs,
clearDevLogs,
clearCelebration,
}
}

View File

@@ -12,6 +12,7 @@ import type {
FeatureUpdate,
FeatureBulkCreate,
FeatureBulkCreateResponse,
DependencyGraph,
AgentStatusResponse,
AgentActionResponse,
SetupStatus,
@@ -141,6 +142,50 @@ export async function createFeaturesBulk(
})
}
// ============================================================================
// Dependency Graph API
// ============================================================================
export async function getDependencyGraph(projectName: string): Promise<DependencyGraph> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/features/graph`)
}
export async function addDependency(
projectName: string,
featureId: number,
dependencyId: number
): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> {
return fetchJSON(
`/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies/${dependencyId}`,
{ method: 'POST' }
)
}
export async function removeDependency(
projectName: string,
featureId: number,
dependencyId: number
): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> {
return fetchJSON(
`/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies/${dependencyId}`,
{ method: 'DELETE' }
)
}
export async function setDependencies(
projectName: string,
featureId: number,
dependencyIds: number[]
): Promise<{ success: boolean; feature_id: number; dependencies: number[] }> {
return fetchJSON(
`/projects/${encodeURIComponent(projectName)}/features/${featureId}/dependencies`,
{
method: 'PUT',
body: JSON.stringify({ dependency_ids: dependencyIds }),
}
)
}
// ============================================================================
// Agent API
// ============================================================================
@@ -151,11 +196,19 @@ export async function getAgentStatus(projectName: string): Promise<AgentStatusRe
export async function startAgent(
projectName: string,
yoloMode: boolean = false
options: {
yoloMode?: boolean
parallelMode?: boolean
maxConcurrency?: number
} = {}
): Promise<AgentActionResponse> {
return fetchJSON(`/projects/${encodeURIComponent(projectName)}/agent/start`, {
method: 'POST',
body: JSON.stringify({ yolo_mode: yoloMode }),
body: JSON.stringify({
yolo_mode: options.yoloMode ?? false,
parallel_mode: options.parallelMode ?? false,
max_concurrency: options.maxConcurrency,
}),
})
}

View File

@@ -66,6 +66,32 @@ export interface Feature {
steps: string[]
passes: boolean
in_progress: boolean
dependencies?: number[] // Optional for backwards compat
blocked?: boolean // Computed by API
blocking_dependencies?: number[] // Computed by API
}
// Status type for graph nodes
export type FeatureStatus = 'pending' | 'in_progress' | 'done' | 'blocked'
// Graph visualization types
export interface GraphNode {
id: number
name: string
category: string
status: FeatureStatus
priority: number
dependencies: number[]
}
export interface GraphEdge {
source: number
target: number
}
export interface DependencyGraph {
nodes: GraphNode[]
edges: GraphEdge[]
}
export interface FeatureListResponse {
@@ -80,6 +106,7 @@ export interface FeatureCreate {
description: string
steps: string[]
priority?: number
dependencies?: number[]
}
export interface FeatureUpdate {
@@ -88,6 +115,7 @@ export interface FeatureUpdate {
description?: string
steps?: string[]
priority?: number
dependencies?: number[]
}
// Agent types
@@ -99,6 +127,8 @@ export interface AgentStatusResponse {
started_at: string | null
yolo_mode: boolean
model: string | null // Model being used by running agent
parallel_mode: boolean
max_concurrency: number | null
}
export interface AgentActionResponse {
@@ -140,8 +170,26 @@ export interface TerminalInfo {
created_at: string
}
// Agent mascot names for multi-agent UI
export const AGENT_MASCOTS = ['Spark', 'Fizz', 'Octo', 'Hoot', 'Buzz'] as const
export type AgentMascot = typeof AGENT_MASCOTS[number]
// Agent state for Mission Control
export type AgentState = 'idle' | 'thinking' | 'working' | 'testing' | 'success' | 'error' | 'struggling'
// Agent update from backend
export interface ActiveAgent {
agentIndex: number
agentName: AgentMascot
featureId: number
featureName: string
state: AgentState
thought?: string
timestamp: string
}
// WebSocket message types
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status'
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' | 'agent_update'
export interface WSProgressMessage {
type: 'progress'
@@ -161,6 +209,20 @@ export interface WSLogMessage {
type: 'log'
line: string
timestamp: string
featureId?: number
agentIndex?: number
agentName?: AgentMascot
}
export interface WSAgentUpdateMessage {
type: 'agent_update'
agentIndex: number
agentName: AgentMascot
featureId: number
featureName: string
state: AgentState
thought?: string
timestamp: string
}
export interface WSAgentStatusMessage {
@@ -189,6 +251,7 @@ export type WSMessage =
| WSFeatureUpdateMessage
| WSLogMessage
| WSAgentStatusMessage
| WSAgentUpdateMessage
| WSPongMessage
| WSDevLogMessage
| WSDevServerStatusMessage

View File

@@ -870,6 +870,96 @@
}
}
/* ============================================================================
Agent Mascot Animations
============================================================================ */
@keyframes bounce-gentle {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
@keyframes thinking {
0%, 100% {
transform: translateY(0) scale(1);
}
25% {
transform: translateY(-2px) scale(1.02);
}
50% {
transform: translateY(0) scale(1);
}
75% {
transform: translateY(-2px) scale(0.98);
}
}
@keyframes working {
0%, 100% {
transform: translateX(0);
}
25% {
transform: translateX(-1px);
}
75% {
transform: translateX(1px);
}
}
@keyframes testing {
0%, 100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-3deg);
}
75% {
transform: rotate(3deg);
}
}
@keyframes celebrate {
0%, 100% {
transform: scale(1) rotate(0deg);
}
25% {
transform: scale(1.1) rotate(-5deg);
}
50% {
transform: scale(1.15) rotate(0deg);
}
75% {
transform: scale(1.1) rotate(5deg);
}
}
@keyframes shake-gentle {
0%, 100% {
transform: translateX(0);
}
20%, 60% {
transform: translateX(-2px);
}
40%, 80% {
transform: translateX(2px);
}
}
@keyframes confetti {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
/* ============================================================================
Utilities Layer
============================================================================ */
@@ -970,6 +1060,35 @@
.font-mono {
font-family: var(--font-neo-mono);
}
/* Agent mascot animation utilities */
.animate-bounce-gentle {
animation: bounce-gentle 2s ease-in-out infinite;
}
.animate-thinking {
animation: thinking 1.5s ease-in-out infinite;
}
.animate-working {
animation: working 0.3s ease-in-out infinite;
}
.animate-testing {
animation: testing 0.8s ease-in-out infinite;
}
.animate-celebrate {
animation: celebrate 0.6s ease-in-out;
}
.animate-shake-gentle {
animation: shake-gentle 0.5s ease-in-out infinite;
}
.animate-confetti {
animation: confetti 2s ease-out forwards;
}
}
/* ============================================================================