mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-02-01 15:03:36 +00:00
feat: add per-agent logging UI and fix stuck agent issues
Changes: - Add per-agent log viewer with copy-to-clipboard functionality - New AgentLogEntry type for structured log entries - Logs stored per-agent in WebSocket state (up to 500 entries) - Log modal rendered via React Portal to avoid overflow issues - Click log icon on agent card to view full activity history - Fix agents getting stuck in "failed" state - Wrap client context manager in try/except (agent.py) - Remove failed agents from UI on error state (useWebSocket.ts) - Handle permanently failed features in get_all_complete() - Add friendlier agent state labels - "Hit an issue" → "Trying plan B..." - "Retrying..." → "Being persistent..." - Softer colors (yellow/orange instead of red) - Add scheduling scores for smarter feature ordering - compute_scheduling_scores() in dependency_resolver.py - Features that unblock others get higher priority - Update CLAUDE.md with parallel mode documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
45
CLAUDE.md
45
CLAUDE.md
@@ -45,6 +45,9 @@ python autonomous_agent_demo.py --project-dir my-app # if registered
|
|||||||
|
|
||||||
# YOLO mode: rapid prototyping without browser testing
|
# YOLO mode: rapid prototyping without browser testing
|
||||||
python autonomous_agent_demo.py --project-dir my-app --yolo
|
python autonomous_agent_demo.py --project-dir my-app --yolo
|
||||||
|
|
||||||
|
# Parallel mode: run multiple agents concurrently (1-5 agents)
|
||||||
|
python autonomous_agent_demo.py --project-dir my-app --parallel --max-concurrency 3
|
||||||
```
|
```
|
||||||
|
|
||||||
### YOLO Mode (Rapid Prototyping)
|
### YOLO Mode (Rapid Prototyping)
|
||||||
@@ -95,6 +98,8 @@ npm run lint # Run ESLint
|
|||||||
- `prompts.py` - Prompt template loading with project-specific fallback
|
- `prompts.py` - Prompt template loading with project-specific fallback
|
||||||
- `progress.py` - Progress tracking, database queries, webhook notifications
|
- `progress.py` - Progress tracking, database queries, webhook notifications
|
||||||
- `registry.py` - Project registry for mapping names to paths (cross-platform)
|
- `registry.py` - Project registry for mapping names to paths (cross-platform)
|
||||||
|
- `parallel_orchestrator.py` - Concurrent agent execution with dependency-aware scheduling
|
||||||
|
- `api/dependency_resolver.py` - Cycle detection (Kahn's algorithm + DFS) and dependency validation
|
||||||
|
|
||||||
### Project Registry
|
### Project Registry
|
||||||
|
|
||||||
@@ -121,26 +126,40 @@ The FastAPI server provides REST endpoints for the UI:
|
|||||||
Features are stored in SQLite (`features.db`) via SQLAlchemy. The agent interacts with features through an MCP server:
|
Features are stored in SQLite (`features.db`) via SQLAlchemy. The agent interacts with features through an MCP server:
|
||||||
|
|
||||||
- `mcp_server/feature_mcp.py` - MCP server exposing feature management tools
|
- `mcp_server/feature_mcp.py` - MCP server exposing feature management tools
|
||||||
- `api/database.py` - SQLAlchemy models (Feature table with priority, category, name, description, steps, passes)
|
- `api/database.py` - SQLAlchemy models (Feature table with priority, category, name, description, steps, passes, dependencies)
|
||||||
|
|
||||||
MCP tools available to the agent:
|
MCP tools available to the agent:
|
||||||
- `feature_get_stats` - Progress statistics
|
- `feature_get_stats` - Progress statistics
|
||||||
- `feature_get_next` - Get highest-priority pending feature
|
- `feature_get_next` - Get highest-priority pending feature (respects dependencies)
|
||||||
|
- `feature_claim_next` - Atomically claim next available feature (for parallel mode)
|
||||||
- `feature_get_for_regression` - Random passing features for regression testing
|
- `feature_get_for_regression` - Random passing features for regression testing
|
||||||
- `feature_mark_passing` - Mark feature complete
|
- `feature_mark_passing` - Mark feature complete
|
||||||
- `feature_skip` - Move feature to end of queue
|
- `feature_skip` - Move feature to end of queue
|
||||||
- `feature_create_bulk` - Initialize all features (used by initializer)
|
- `feature_create_bulk` - Initialize all features (used by initializer)
|
||||||
|
- `feature_add_dependency` - Add dependency between features (with cycle detection)
|
||||||
|
- `feature_remove_dependency` - Remove a dependency
|
||||||
|
|
||||||
### React UI (ui/)
|
### React UI (ui/)
|
||||||
|
|
||||||
- Tech stack: React 18, TypeScript, TanStack Query, Tailwind CSS v4, Radix UI
|
- Tech stack: React 18, TypeScript, TanStack Query, Tailwind CSS v4, Radix UI, dagre (graph layout)
|
||||||
- `src/App.tsx` - Main app with project selection, kanban board, agent controls
|
- `src/App.tsx` - Main app with project selection, kanban board, agent controls
|
||||||
- `src/hooks/useWebSocket.ts` - Real-time updates via WebSocket
|
- `src/hooks/useWebSocket.ts` - Real-time updates via WebSocket (progress, agent status, logs, agent updates)
|
||||||
- `src/hooks/useProjects.ts` - React Query hooks for API calls
|
- `src/hooks/useProjects.ts` - React Query hooks for API calls
|
||||||
- `src/lib/api.ts` - REST API client
|
- `src/lib/api.ts` - REST API client
|
||||||
- `src/lib/types.ts` - TypeScript type definitions
|
- `src/lib/types.ts` - TypeScript type definitions
|
||||||
- `src/components/FolderBrowser.tsx` - Server-side filesystem browser for project folder selection
|
|
||||||
- `src/components/NewProjectModal.tsx` - Multi-step project creation wizard
|
Key components:
|
||||||
|
- `AgentMissionControl.tsx` - Dashboard showing active agents with mascots (Spark, Fizz, Octo, Hoot, Buzz)
|
||||||
|
- `DependencyGraph.tsx` - Interactive node graph visualization with dagre layout
|
||||||
|
- `CelebrationOverlay.tsx` - Confetti animation on feature completion
|
||||||
|
- `FolderBrowser.tsx` - Server-side filesystem browser for project folder selection
|
||||||
|
|
||||||
|
Keyboard shortcuts (press `?` for help):
|
||||||
|
- `D` - Toggle debug panel
|
||||||
|
- `G` - Toggle Kanban/Graph view
|
||||||
|
- `N` - Add new feature
|
||||||
|
- `A` - Toggle AI assistant
|
||||||
|
- `,` - Open settings
|
||||||
|
|
||||||
### Project Structure for Generated Apps
|
### Project Structure for Generated Apps
|
||||||
|
|
||||||
@@ -181,10 +200,20 @@ Defense-in-depth approach configured in `client.py`:
|
|||||||
### Real-time UI Updates
|
### Real-time UI Updates
|
||||||
|
|
||||||
The UI receives updates via WebSocket (`/ws/projects/{project_name}`):
|
The UI receives updates via WebSocket (`/ws/projects/{project_name}`):
|
||||||
- `progress` - Test pass counts
|
- `progress` - Test pass counts (passing, in_progress, total)
|
||||||
- `agent_status` - Running/paused/stopped/crashed
|
- `agent_status` - Running/paused/stopped/crashed
|
||||||
- `log` - Agent output lines (streamed from subprocess stdout)
|
- `log` - Agent output lines with optional featureId/agentIndex for attribution
|
||||||
- `feature_update` - Feature status changes
|
- `feature_update` - Feature status changes
|
||||||
|
- `agent_update` - Multi-agent state updates (thinking/working/testing/success/error) with mascot names
|
||||||
|
|
||||||
|
### Parallel Mode
|
||||||
|
|
||||||
|
When running with `--parallel`, the orchestrator:
|
||||||
|
1. Spawns multiple Claude agents as subprocesses (up to `--max-concurrency`)
|
||||||
|
2. Each agent claims features atomically via `feature_claim_next`
|
||||||
|
3. Features blocked by unmet dependencies are skipped
|
||||||
|
4. Browser contexts are isolated per agent using `--isolated` flag
|
||||||
|
5. AgentTracker parses output and emits `agent_update` messages for UI
|
||||||
|
|
||||||
### Design System
|
### Design System
|
||||||
|
|
||||||
|
|||||||
10
agent.py
10
agent.py
@@ -203,8 +203,14 @@ async def run_autonomous_agent(
|
|||||||
prompt = get_coding_prompt(project_dir)
|
prompt = get_coding_prompt(project_dir)
|
||||||
|
|
||||||
# Run session with async context manager
|
# Run session with async context manager
|
||||||
async with client:
|
# Wrap in try/except to handle MCP server startup failures gracefully
|
||||||
status, response = await run_agent_session(client, prompt, project_dir)
|
try:
|
||||||
|
async with client:
|
||||||
|
status, response = await run_agent_session(client, prompt, project_dir)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Client/MCP server error: {e}")
|
||||||
|
# Don't crash - return error status so the loop can retry
|
||||||
|
status, response = "error", str(e)
|
||||||
|
|
||||||
# Handle status
|
# Handle status
|
||||||
if status == "continue":
|
if status == "continue":
|
||||||
|
|||||||
@@ -245,6 +245,81 @@ def _detect_cycles(features: list[dict], feature_map: dict) -> list[list[int]]:
|
|||||||
return cycles
|
return cycles
|
||||||
|
|
||||||
|
|
||||||
|
def compute_scheduling_scores(features: list[dict]) -> dict[int, float]:
|
||||||
|
"""Compute scheduling scores for all features.
|
||||||
|
|
||||||
|
Higher scores mean higher priority for scheduling. The algorithm considers:
|
||||||
|
1. Unblocking potential - Features that unblock more downstream work score higher
|
||||||
|
2. Depth in graph - Features with no dependencies (roots) are "shovel-ready"
|
||||||
|
3. User priority - Existing priority field as tiebreaker
|
||||||
|
|
||||||
|
Score formula: (1000 * unblock) + (100 * depth_score) + (10 * priority_factor)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
features: List of feature dicts with id, priority, dependencies fields
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping feature_id -> score (higher = schedule first)
|
||||||
|
"""
|
||||||
|
if not features:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Build adjacency lists
|
||||||
|
children: dict[int, list[int]] = {f["id"]: [] for f in features} # who depends on me
|
||||||
|
parents: dict[int, list[int]] = {f["id"]: [] for f in features} # who I depend on
|
||||||
|
|
||||||
|
for f in features:
|
||||||
|
for dep_id in (f.get("dependencies") or []):
|
||||||
|
if dep_id in children: # Only valid deps
|
||||||
|
children[dep_id].append(f["id"])
|
||||||
|
parents[f["id"]].append(dep_id)
|
||||||
|
|
||||||
|
# Calculate depths via BFS from roots
|
||||||
|
depths: dict[int, int] = {}
|
||||||
|
roots = [f["id"] for f in features if not parents[f["id"]]]
|
||||||
|
queue = [(root, 0) for root in roots]
|
||||||
|
while queue:
|
||||||
|
node_id, depth = queue.pop(0)
|
||||||
|
if node_id not in depths or depth > depths[node_id]:
|
||||||
|
depths[node_id] = depth
|
||||||
|
for child_id in children[node_id]:
|
||||||
|
queue.append((child_id, depth + 1))
|
||||||
|
|
||||||
|
# Handle orphaned nodes (shouldn't happen but be safe)
|
||||||
|
for f in features:
|
||||||
|
if f["id"] not in depths:
|
||||||
|
depths[f["id"]] = 0
|
||||||
|
|
||||||
|
# Calculate transitive downstream counts (reverse topo order)
|
||||||
|
downstream: dict[int, int] = {f["id"]: 0 for f in features}
|
||||||
|
# Process in reverse depth order (leaves first)
|
||||||
|
for fid in sorted(depths.keys(), key=lambda x: -depths[x]):
|
||||||
|
for parent_id in parents[fid]:
|
||||||
|
downstream[parent_id] += 1 + downstream[fid]
|
||||||
|
|
||||||
|
# Normalize and compute scores
|
||||||
|
max_depth = max(depths.values()) if depths else 0
|
||||||
|
max_downstream = max(downstream.values()) if downstream else 0
|
||||||
|
|
||||||
|
scores: dict[int, float] = {}
|
||||||
|
for f in features:
|
||||||
|
fid = f["id"]
|
||||||
|
|
||||||
|
# Unblocking score: 0-1, higher = unblocks more
|
||||||
|
unblock = downstream[fid] / max_downstream if max_downstream > 0 else 0
|
||||||
|
|
||||||
|
# Depth score: 0-1, higher = closer to root (no deps)
|
||||||
|
depth_score = 1 - (depths[fid] / max_depth) if max_depth > 0 else 1
|
||||||
|
|
||||||
|
# Priority factor: 0-1, lower priority number = higher factor
|
||||||
|
priority = f.get("priority", 999)
|
||||||
|
priority_factor = (10 - min(priority, 10)) / 10
|
||||||
|
|
||||||
|
scores[fid] = (1000 * unblock) + (100 * depth_score) + (10 * priority_factor)
|
||||||
|
|
||||||
|
return scores
|
||||||
|
|
||||||
|
|
||||||
def get_ready_features(features: list[dict], limit: int = 10) -> list[dict]:
|
def get_ready_features(features: list[dict], limit: int = 10) -> list[dict]:
|
||||||
"""Get features that are ready to be worked on.
|
"""Get features that are ready to be worked on.
|
||||||
|
|
||||||
@@ -270,8 +345,9 @@ def get_ready_features(features: list[dict], limit: int = 10) -> list[dict]:
|
|||||||
if all(dep_id in passing_ids for dep_id in deps):
|
if all(dep_id in passing_ids for dep_id in deps):
|
||||||
ready.append(f)
|
ready.append(f)
|
||||||
|
|
||||||
# Sort by priority
|
# Sort by scheduling score (higher = first), then priority, then id
|
||||||
ready.sort(key=lambda f: (f.get("priority", 999), f["id"]))
|
scores = compute_scheduling_scores(features)
|
||||||
|
ready.sort(key=lambda f: (-scores.get(f["id"], 0), f.get("priority", 999), f["id"]))
|
||||||
|
|
||||||
return ready[:limit]
|
return ready[:limit]
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from api.dependency_resolver import (
|
|||||||
would_create_circular_dependency,
|
would_create_circular_dependency,
|
||||||
are_dependencies_satisfied,
|
are_dependencies_satisfied,
|
||||||
get_blocking_dependencies,
|
get_blocking_dependencies,
|
||||||
|
compute_scheduling_scores,
|
||||||
MAX_DEPENDENCIES_PER_FEATURE,
|
MAX_DEPENDENCIES_PER_FEATURE,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,7 +179,11 @@ def feature_get_next() -> str:
|
|||||||
|
|
||||||
# Get pending, non-in-progress features
|
# Get pending, non-in-progress features
|
||||||
pending = [f for f in all_features if not f.passes and not f.in_progress]
|
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))
|
|
||||||
|
# Sort by scheduling score (higher = first), then priority, then id
|
||||||
|
all_dicts = [f.to_dict() for f in all_features]
|
||||||
|
scores = compute_scheduling_scores(all_dicts)
|
||||||
|
pending.sort(key=lambda f: (-scores.get(f.id, 0), f.priority, f.id))
|
||||||
|
|
||||||
if not pending:
|
if not pending:
|
||||||
if any(f.in_progress for f in all_features if not f.passes):
|
if any(f.in_progress for f in all_features if not f.passes):
|
||||||
@@ -247,7 +252,11 @@ def _feature_claim_next_internal(attempt: int = 0) -> str:
|
|||||||
|
|
||||||
# Get pending, non-in-progress features
|
# Get pending, non-in-progress features
|
||||||
pending = [f for f in all_features if not f.passes and not f.in_progress]
|
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))
|
|
||||||
|
# Sort by scheduling score (higher = first), then priority, then id
|
||||||
|
all_dicts = [f.to_dict() for f in all_features]
|
||||||
|
scores = compute_scheduling_scores(all_dicts)
|
||||||
|
pending.sort(key=lambda f: (-scores.get(f.id, 0), f.priority, f.id))
|
||||||
|
|
||||||
if not pending:
|
if not pending:
|
||||||
if any(f.in_progress for f in all_features if not f.passes):
|
if any(f.in_progress for f in all_features if not f.passes):
|
||||||
@@ -814,6 +823,7 @@ def feature_get_ready(
|
|||||||
passing_ids = {f.id for f in all_features if f.passes}
|
passing_ids = {f.id for f in all_features if f.passes}
|
||||||
|
|
||||||
ready = []
|
ready = []
|
||||||
|
all_dicts = [f.to_dict() for f in all_features]
|
||||||
for f in all_features:
|
for f in all_features:
|
||||||
if f.passes or f.in_progress:
|
if f.passes or f.in_progress:
|
||||||
continue
|
continue
|
||||||
@@ -821,8 +831,9 @@ def feature_get_ready(
|
|||||||
if all(dep_id in passing_ids for dep_id in deps):
|
if all(dep_id in passing_ids for dep_id in deps):
|
||||||
ready.append(f.to_dict())
|
ready.append(f.to_dict())
|
||||||
|
|
||||||
# Sort by priority
|
# Sort by scheduling score (higher = first), then priority, then id
|
||||||
ready.sort(key=lambda f: (f["priority"], f["id"]))
|
scores = compute_scheduling_scores(all_dicts)
|
||||||
|
ready.sort(key=lambda f: (-scores.get(f["id"], 0), f["priority"], f["id"]))
|
||||||
|
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"features": ready[:limit],
|
"features": ready[:limit],
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from pathlib import Path
|
|||||||
from typing import Callable, Awaitable
|
from typing import Callable, Awaitable
|
||||||
|
|
||||||
from api.database import Feature, create_database
|
from api.database import Feature, create_database
|
||||||
from api.dependency_resolver import are_dependencies_satisfied
|
from api.dependency_resolver import are_dependencies_satisfied, compute_scheduling_scores
|
||||||
|
|
||||||
# Root directory of autocoder (where this script and autonomous_agent_demo.py live)
|
# Root directory of autocoder (where this script and autonomous_agent_demo.py live)
|
||||||
AUTOCODER_ROOT = Path(__file__).parent.resolve()
|
AUTOCODER_ROOT = Path(__file__).parent.resolve()
|
||||||
@@ -103,8 +103,10 @@ class ParallelOrchestrator:
|
|||||||
continue
|
continue
|
||||||
resumable.append(f.to_dict())
|
resumable.append(f.to_dict())
|
||||||
|
|
||||||
# Sort by priority (highest priority first)
|
# Sort by scheduling score (higher = first), then priority, then id
|
||||||
resumable.sort(key=lambda f: (f["priority"], f["id"]))
|
all_dicts = [f.to_dict() for f in session.query(Feature).all()]
|
||||||
|
scores = compute_scheduling_scores(all_dicts)
|
||||||
|
resumable.sort(key=lambda f: (-scores.get(f["id"], 0), f["priority"], f["id"]))
|
||||||
return resumable
|
return resumable
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
@@ -131,18 +133,25 @@ class ParallelOrchestrator:
|
|||||||
if are_dependencies_satisfied(f.to_dict(), all_dicts):
|
if are_dependencies_satisfied(f.to_dict(), all_dicts):
|
||||||
ready.append(f.to_dict())
|
ready.append(f.to_dict())
|
||||||
|
|
||||||
# Sort by priority
|
# Sort by scheduling score (higher = first), then priority, then id
|
||||||
ready.sort(key=lambda f: (f["priority"], f["id"]))
|
scores = compute_scheduling_scores(all_dicts)
|
||||||
|
ready.sort(key=lambda f: (-scores.get(f["id"], 0), f["priority"], f["id"]))
|
||||||
return ready
|
return ready
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
def get_all_complete(self) -> bool:
|
def get_all_complete(self) -> bool:
|
||||||
"""Check if all features are complete."""
|
"""Check if all features are complete or permanently failed."""
|
||||||
session = self.get_session()
|
session = self.get_session()
|
||||||
try:
|
try:
|
||||||
pending = session.query(Feature).filter(Feature.passes == False).count()
|
all_features = session.query(Feature).all()
|
||||||
return pending == 0
|
for f in all_features:
|
||||||
|
if f.passes:
|
||||||
|
continue # Completed successfully
|
||||||
|
if self._failure_counts.get(f.id, 0) >= MAX_FEATURE_RETRIES:
|
||||||
|
continue # Permanently failed, count as "done"
|
||||||
|
return False # Still workable
|
||||||
|
return True
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
|
|
||||||
@@ -289,6 +298,7 @@ class ParallelOrchestrator:
|
|||||||
status = "completed" if return_code == 0 else "failed"
|
status = "completed" if return_code == 0 else "failed"
|
||||||
if self.on_status:
|
if self.on_status:
|
||||||
self.on_status(feature_id, status)
|
self.on_status(feature_id, status)
|
||||||
|
# CRITICAL: This print triggers the WebSocket to emit agent_update with state='error' or 'success'
|
||||||
print(f"Feature #{feature_id} {status}", flush=True)
|
print(f"Feature #{feature_id} {status}", flush=True)
|
||||||
|
|
||||||
def stop_feature(self, feature_id: int) -> tuple[bool, str]:
|
def stop_feature(self, feature_id: int) -> tuple[bool, str]:
|
||||||
|
|||||||
@@ -336,6 +336,7 @@ function App() {
|
|||||||
<AgentMissionControl
|
<AgentMissionControl
|
||||||
agents={wsState.activeAgents}
|
agents={wsState.activeAgents}
|
||||||
recentActivity={wsState.recentActivity}
|
recentActivity={wsState.recentActivity}
|
||||||
|
getAgentLogs={wsState.getAgentLogs}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
import { MessageCircle } from 'lucide-react'
|
import { MessageCircle, ScrollText, X, Copy, Check } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
import { AgentAvatar } from './AgentAvatar'
|
import { AgentAvatar } from './AgentAvatar'
|
||||||
import type { ActiveAgent } from '../lib/types'
|
import type { ActiveAgent, AgentLogEntry } from '../lib/types'
|
||||||
|
|
||||||
interface AgentCardProps {
|
interface AgentCardProps {
|
||||||
agent: ActiveAgent
|
agent: ActiveAgent
|
||||||
|
onShowLogs?: (agentIndex: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get a friendly state description
|
// Get a friendly state description
|
||||||
function getStateText(state: ActiveAgent['state']): string {
|
function getStateText(state: ActiveAgent['state']): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'idle':
|
case 'idle':
|
||||||
return 'Waiting...'
|
return 'Standing by...'
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
return 'Thinking...'
|
return 'Pondering...'
|
||||||
case 'working':
|
case 'working':
|
||||||
return 'Coding...'
|
return 'Coding away...'
|
||||||
case 'testing':
|
case 'testing':
|
||||||
return 'Testing...'
|
return 'Checking work...'
|
||||||
case 'success':
|
case 'success':
|
||||||
return 'Done!'
|
return 'Nailed it!'
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'Hit an issue'
|
return 'Trying plan B...'
|
||||||
case 'struggling':
|
case 'struggling':
|
||||||
return 'Retrying...'
|
return 'Being persistent...'
|
||||||
default:
|
default:
|
||||||
return 'Working...'
|
return 'Busy...'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,8 +37,9 @@ function getStateColor(state: ActiveAgent['state']): string {
|
|||||||
case 'success':
|
case 'success':
|
||||||
return 'text-neo-done'
|
return 'text-neo-done'
|
||||||
case 'error':
|
case 'error':
|
||||||
|
return 'text-neo-pending' // Yellow - just pivoting, not a real error
|
||||||
case 'struggling':
|
case 'struggling':
|
||||||
return 'text-neo-danger'
|
return 'text-orange-500' // Orange - working hard, being persistent
|
||||||
case 'working':
|
case 'working':
|
||||||
case 'testing':
|
case 'testing':
|
||||||
return 'text-neo-progress'
|
return 'text-neo-progress'
|
||||||
@@ -46,8 +50,9 @@ function getStateColor(state: ActiveAgent['state']): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentCard({ agent }: AgentCardProps) {
|
export function AgentCard({ agent, onShowLogs }: AgentCardProps) {
|
||||||
const isActive = ['thinking', 'working', 'testing'].includes(agent.state)
|
const isActive = ['thinking', 'working', 'testing'].includes(agent.state)
|
||||||
|
const hasLogs = agent.logs && agent.logs.length > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -68,6 +73,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
{getStateText(agent.state)}
|
{getStateText(agent.state)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Log button */}
|
||||||
|
{hasLogs && onShowLogs && (
|
||||||
|
<button
|
||||||
|
onClick={() => onShowLogs(agent.agentIndex)}
|
||||||
|
className="p-1 hover:bg-neo-bg-secondary rounded transition-colors"
|
||||||
|
title={`View logs (${agent.logs?.length || 0} entries)`}
|
||||||
|
>
|
||||||
|
<ScrollText size={14} className="text-neo-text-secondary" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature info */}
|
{/* Feature info */}
|
||||||
@@ -97,3 +112,103 @@ export function AgentCard({ agent }: AgentCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log viewer modal component
|
||||||
|
interface AgentLogModalProps {
|
||||||
|
agent: ActiveAgent
|
||||||
|
logs: AgentLogEntry[]
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentLogModal({ agent, logs, onClose }: AgentLogModalProps) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const logText = logs
|
||||||
|
.map(log => `[${log.timestamp}] ${log.line}`)
|
||||||
|
.join('\n')
|
||||||
|
await navigator.clipboard.writeText(logText)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLogColor = (type: AgentLogEntry['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'error':
|
||||||
|
return 'text-neo-danger'
|
||||||
|
case 'state_change':
|
||||||
|
return 'text-neo-progress'
|
||||||
|
default:
|
||||||
|
return 'text-neo-text'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use portal to render modal at document body level (avoids overflow:hidden issues)
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 flex items-center justify-center p-4 bg-black/50"
|
||||||
|
style={{ zIndex: 9999 }}
|
||||||
|
onClick={(e) => {
|
||||||
|
// Close when clicking backdrop
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="neo-card w-full max-w-4xl max-h-[80vh] flex flex-col bg-neo-bg">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b-3 border-neo-border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AgentAvatar name={agent.agentName} state={agent.state} size="sm" />
|
||||||
|
<div>
|
||||||
|
<h2 className="font-display font-bold text-lg">
|
||||||
|
{agent.agentName} Logs
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neo-text-secondary">
|
||||||
|
Feature #{agent.featureId}: {agent.featureName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="neo-button neo-button-sm flex items-center gap-1"
|
||||||
|
title="Copy all logs"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-neo-bg-secondary rounded transition-colors"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Log content */}
|
||||||
|
<div className="flex-1 overflow-auto p-4 bg-neo-bg-secondary font-mono text-xs">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<p className="text-neo-text-secondary italic">No logs available</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{logs.map((log, idx) => (
|
||||||
|
<div key={idx} className={`${getLogColor(log.type)} whitespace-pre-wrap break-all`}>
|
||||||
|
<span className="text-neo-muted">
|
||||||
|
[{new Date(log.timestamp).toLocaleTimeString()}]
|
||||||
|
</span>{' '}
|
||||||
|
{log.line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="p-3 border-t-2 border-neo-border/30 text-xs text-neo-text-secondary">
|
||||||
|
{logs.length} log entries
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Rocket, ChevronDown, ChevronUp, Activity } from 'lucide-react'
|
import { Rocket, ChevronDown, ChevronUp, Activity } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AgentCard } from './AgentCard'
|
import { AgentCard, AgentLogModal } from './AgentCard'
|
||||||
import { ActivityFeed } from './ActivityFeed'
|
import { ActivityFeed } from './ActivityFeed'
|
||||||
import type { ActiveAgent } from '../lib/types'
|
import type { ActiveAgent, AgentLogEntry } from '../lib/types'
|
||||||
|
|
||||||
const ACTIVITY_COLLAPSED_KEY = 'autocoder-activity-collapsed'
|
const ACTIVITY_COLLAPSED_KEY = 'autocoder-activity-collapsed'
|
||||||
|
|
||||||
@@ -15,12 +15,14 @@ interface AgentMissionControlProps {
|
|||||||
featureId: number
|
featureId: number
|
||||||
}>
|
}>
|
||||||
isExpanded?: boolean
|
isExpanded?: boolean
|
||||||
|
getAgentLogs?: (agentIndex: number) => AgentLogEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentMissionControl({
|
export function AgentMissionControl({
|
||||||
agents,
|
agents,
|
||||||
recentActivity,
|
recentActivity,
|
||||||
isExpanded: defaultExpanded = true,
|
isExpanded: defaultExpanded = true,
|
||||||
|
getAgentLogs,
|
||||||
}: AgentMissionControlProps) {
|
}: AgentMissionControlProps) {
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
||||||
const [activityCollapsed, setActivityCollapsed] = useState(() => {
|
const [activityCollapsed, setActivityCollapsed] = useState(() => {
|
||||||
@@ -30,6 +32,8 @@ export function AgentMissionControl({
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// State for log modal
|
||||||
|
const [selectedAgentForLogs, setSelectedAgentForLogs] = useState<ActiveAgent | null>(null)
|
||||||
|
|
||||||
const toggleActivityCollapsed = () => {
|
const toggleActivityCollapsed = () => {
|
||||||
const newValue = !activityCollapsed
|
const newValue = !activityCollapsed
|
||||||
@@ -80,7 +84,16 @@ export function AgentMissionControl({
|
|||||||
{/* Agent Cards Row */}
|
{/* Agent Cards Row */}
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin">
|
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin">
|
||||||
{agents.map((agent) => (
|
{agents.map((agent) => (
|
||||||
<AgentCard key={`agent-${agent.agentIndex}`} agent={agent} />
|
<AgentCard
|
||||||
|
key={`agent-${agent.agentIndex}`}
|
||||||
|
agent={agent}
|
||||||
|
onShowLogs={(agentIndex) => {
|
||||||
|
const agentToShow = agents.find(a => a.agentIndex === agentIndex)
|
||||||
|
if (agentToShow) {
|
||||||
|
setSelectedAgentForLogs(agentToShow)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,6 +129,15 @@ export function AgentMissionControl({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Log Modal */}
|
||||||
|
{selectedAgentForLogs && getAgentLogs && (
|
||||||
|
<AgentLogModal
|
||||||
|
agent={selectedAgentForLogs}
|
||||||
|
logs={getAgentLogs(selectedAgentForLogs.agentIndex)}
|
||||||
|
onClose={() => setSelectedAgentForLogs(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
DevServerStatus,
|
DevServerStatus,
|
||||||
ActiveAgent,
|
ActiveAgent,
|
||||||
AgentMascot,
|
AgentMascot,
|
||||||
|
AgentLogEntry,
|
||||||
} from '../lib/types'
|
} from '../lib/types'
|
||||||
|
|
||||||
// Activity item for the feed
|
// Activity item for the feed
|
||||||
@@ -42,6 +43,8 @@ interface WebSocketState {
|
|||||||
// Multi-agent state
|
// Multi-agent state
|
||||||
activeAgents: ActiveAgent[]
|
activeAgents: ActiveAgent[]
|
||||||
recentActivity: ActivityItem[]
|
recentActivity: ActivityItem[]
|
||||||
|
// Per-agent logs for debugging (indexed by agentIndex)
|
||||||
|
agentLogs: Map<number, AgentLogEntry[]>
|
||||||
// Celebration queue to handle rapid successes without race conditions
|
// Celebration queue to handle rapid successes without race conditions
|
||||||
celebrationQueue: CelebrationTrigger[]
|
celebrationQueue: CelebrationTrigger[]
|
||||||
celebration: CelebrationTrigger | null
|
celebration: CelebrationTrigger | null
|
||||||
@@ -49,6 +52,7 @@ interface WebSocketState {
|
|||||||
|
|
||||||
const MAX_LOGS = 100 // Keep last 100 log lines
|
const MAX_LOGS = 100 // Keep last 100 log lines
|
||||||
const MAX_ACTIVITY = 20 // Keep last 20 activity items
|
const MAX_ACTIVITY = 20 // Keep last 20 activity items
|
||||||
|
const MAX_AGENT_LOGS = 500 // Keep last 500 log lines per agent
|
||||||
|
|
||||||
export function useProjectWebSocket(projectName: string | null) {
|
export function useProjectWebSocket(projectName: string | null) {
|
||||||
const [state, setState] = useState<WebSocketState>({
|
const [state, setState] = useState<WebSocketState>({
|
||||||
@@ -61,6 +65,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
devLogs: [],
|
devLogs: [],
|
||||||
activeAgents: [],
|
activeAgents: [],
|
||||||
recentActivity: [],
|
recentActivity: [],
|
||||||
|
agentLogs: new Map(),
|
||||||
celebrationQueue: [],
|
celebrationQueue: [],
|
||||||
celebration: null,
|
celebration: null,
|
||||||
})
|
})
|
||||||
@@ -111,9 +116,9 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'log':
|
case 'log':
|
||||||
setState(prev => ({
|
setState(prev => {
|
||||||
...prev,
|
// Update global logs
|
||||||
logs: [
|
const newLogs = [
|
||||||
...prev.logs.slice(-MAX_LOGS + 1),
|
...prev.logs.slice(-MAX_LOGS + 1),
|
||||||
{
|
{
|
||||||
line: message.line,
|
line: message.line,
|
||||||
@@ -121,8 +126,26 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
featureId: message.featureId,
|
featureId: message.featureId,
|
||||||
agentIndex: message.agentIndex,
|
agentIndex: message.agentIndex,
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
}))
|
|
||||||
|
// Also store in per-agent logs if we have an agentIndex
|
||||||
|
let newAgentLogs = prev.agentLogs
|
||||||
|
if (message.agentIndex !== undefined) {
|
||||||
|
newAgentLogs = new Map(prev.agentLogs)
|
||||||
|
const existingLogs = newAgentLogs.get(message.agentIndex) || []
|
||||||
|
const logEntry: AgentLogEntry = {
|
||||||
|
line: message.line,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
type: 'output',
|
||||||
|
}
|
||||||
|
newAgentLogs.set(
|
||||||
|
message.agentIndex,
|
||||||
|
[...existingLogs.slice(-MAX_AGENT_LOGS + 1), logEntry]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...prev, logs: newLogs, agentLogs: newAgentLogs }
|
||||||
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'feature_update':
|
case 'feature_update':
|
||||||
@@ -131,21 +154,38 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
|
|
||||||
case 'agent_update':
|
case 'agent_update':
|
||||||
setState(prev => {
|
setState(prev => {
|
||||||
|
// Log state change to per-agent logs
|
||||||
|
const newAgentLogs = new Map(prev.agentLogs)
|
||||||
|
const existingLogs = newAgentLogs.get(message.agentIndex) || []
|
||||||
|
const stateLogEntry: AgentLogEntry = {
|
||||||
|
line: `[STATE] ${message.state}${message.thought ? `: ${message.thought}` : ''}`,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
type: message.state === 'error' ? 'error' : 'state_change',
|
||||||
|
}
|
||||||
|
newAgentLogs.set(
|
||||||
|
message.agentIndex,
|
||||||
|
[...existingLogs.slice(-MAX_AGENT_LOGS + 1), stateLogEntry]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get current logs for this agent to attach to ActiveAgent
|
||||||
|
const agentLogsArray = newAgentLogs.get(message.agentIndex) || []
|
||||||
|
|
||||||
// Update or add the agent in activeAgents
|
// Update or add the agent in activeAgents
|
||||||
const agentIndex = prev.activeAgents.findIndex(
|
const existingAgentIdx = prev.activeAgents.findIndex(
|
||||||
a => a.agentIndex === message.agentIndex
|
a => a.agentIndex === message.agentIndex
|
||||||
)
|
)
|
||||||
|
|
||||||
let newAgents: ActiveAgent[]
|
let newAgents: ActiveAgent[]
|
||||||
if (message.state === 'success') {
|
if (message.state === 'success' || message.state === 'error') {
|
||||||
// Remove agent from active list on success
|
// Remove agent from active list on completion (success or failure)
|
||||||
|
// But keep the logs in agentLogs map for debugging
|
||||||
newAgents = prev.activeAgents.filter(
|
newAgents = prev.activeAgents.filter(
|
||||||
a => a.agentIndex !== message.agentIndex
|
a => a.agentIndex !== message.agentIndex
|
||||||
)
|
)
|
||||||
} else if (agentIndex >= 0) {
|
} else if (existingAgentIdx >= 0) {
|
||||||
// Update existing agent
|
// Update existing agent
|
||||||
newAgents = [...prev.activeAgents]
|
newAgents = [...prev.activeAgents]
|
||||||
newAgents[agentIndex] = {
|
newAgents[existingAgentIdx] = {
|
||||||
agentIndex: message.agentIndex,
|
agentIndex: message.agentIndex,
|
||||||
agentName: message.agentName,
|
agentName: message.agentName,
|
||||||
featureId: message.featureId,
|
featureId: message.featureId,
|
||||||
@@ -153,6 +193,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
state: message.state,
|
state: message.state,
|
||||||
thought: message.thought,
|
thought: message.thought,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
|
logs: agentLogsArray,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add new agent
|
// Add new agent
|
||||||
@@ -166,6 +207,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
state: message.state,
|
state: message.state,
|
||||||
thought: message.thought,
|
thought: message.thought,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
|
logs: agentLogsArray,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -207,6 +249,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
activeAgents: newAgents,
|
activeAgents: newAgents,
|
||||||
|
agentLogs: newAgentLogs,
|
||||||
recentActivity: newActivity,
|
recentActivity: newActivity,
|
||||||
celebrationQueue: newCelebrationQueue,
|
celebrationQueue: newCelebrationQueue,
|
||||||
celebration: newCelebration,
|
celebration: newCelebration,
|
||||||
@@ -295,6 +338,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
devLogs: [],
|
devLogs: [],
|
||||||
activeAgents: [],
|
activeAgents: [],
|
||||||
recentActivity: [],
|
recentActivity: [],
|
||||||
|
agentLogs: new Map(),
|
||||||
celebrationQueue: [],
|
celebrationQueue: [],
|
||||||
celebration: null,
|
celebration: null,
|
||||||
})
|
})
|
||||||
@@ -335,10 +379,26 @@ export function useProjectWebSocket(projectName: string | null) {
|
|||||||
setState(prev => ({ ...prev, devLogs: [] }))
|
setState(prev => ({ ...prev, devLogs: [] }))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Get logs for a specific agent (useful for debugging even after agent completes/fails)
|
||||||
|
const getAgentLogs = useCallback((agentIndex: number): AgentLogEntry[] => {
|
||||||
|
return state.agentLogs.get(agentIndex) || []
|
||||||
|
}, [state.agentLogs])
|
||||||
|
|
||||||
|
// Clear logs for a specific agent
|
||||||
|
const clearAgentLogs = useCallback((agentIndex: number) => {
|
||||||
|
setState(prev => {
|
||||||
|
const newAgentLogs = new Map(prev.agentLogs)
|
||||||
|
newAgentLogs.delete(agentIndex)
|
||||||
|
return { ...prev, agentLogs: newAgentLogs }
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
clearLogs,
|
clearLogs,
|
||||||
clearDevLogs,
|
clearDevLogs,
|
||||||
clearCelebration,
|
clearCelebration,
|
||||||
|
getAgentLogs,
|
||||||
|
clearAgentLogs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,13 @@ export type AgentMascot = typeof AGENT_MASCOTS[number]
|
|||||||
// Agent state for Mission Control
|
// Agent state for Mission Control
|
||||||
export type AgentState = 'idle' | 'thinking' | 'working' | 'testing' | 'success' | 'error' | 'struggling'
|
export type AgentState = 'idle' | 'thinking' | 'working' | 'testing' | 'success' | 'error' | 'struggling'
|
||||||
|
|
||||||
|
// Individual log entry for an agent
|
||||||
|
export interface AgentLogEntry {
|
||||||
|
line: string
|
||||||
|
timestamp: string
|
||||||
|
type: 'output' | 'state_change' | 'error'
|
||||||
|
}
|
||||||
|
|
||||||
// Agent update from backend
|
// Agent update from backend
|
||||||
export interface ActiveAgent {
|
export interface ActiveAgent {
|
||||||
agentIndex: number
|
agentIndex: number
|
||||||
@@ -186,6 +193,7 @@ export interface ActiveAgent {
|
|||||||
state: AgentState
|
state: AgentState
|
||||||
thought?: string
|
thought?: string
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
logs?: AgentLogEntry[] // Per-agent log history
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket message types
|
// WebSocket message types
|
||||||
|
|||||||
Reference in New Issue
Block a user