mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +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
|
||||
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)
|
||||
@@ -95,6 +98,8 @@ npm run lint # Run ESLint
|
||||
- `prompts.py` - Prompt template loading with project-specific fallback
|
||||
- `progress.py` - Progress tracking, database queries, webhook notifications
|
||||
- `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
|
||||
|
||||
@@ -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:
|
||||
|
||||
- `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:
|
||||
- `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_mark_passing` - Mark feature complete
|
||||
- `feature_skip` - Move feature to end of queue
|
||||
- `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/)
|
||||
|
||||
- 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/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/lib/api.ts` - REST API client
|
||||
- `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
|
||||
|
||||
@@ -181,10 +200,20 @@ Defense-in-depth approach configured in `client.py`:
|
||||
### Real-time UI Updates
|
||||
|
||||
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
|
||||
- `log` - Agent output lines (streamed from subprocess stdout)
|
||||
- `log` - Agent output lines with optional featureId/agentIndex for attribution
|
||||
- `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
|
||||
|
||||
|
||||
10
agent.py
10
agent.py
@@ -203,8 +203,14 @@ async def run_autonomous_agent(
|
||||
prompt = get_coding_prompt(project_dir)
|
||||
|
||||
# Run session with async context manager
|
||||
async with client:
|
||||
status, response = await run_agent_session(client, prompt, project_dir)
|
||||
# Wrap in try/except to handle MCP server startup failures gracefully
|
||||
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
|
||||
if status == "continue":
|
||||
|
||||
@@ -245,6 +245,81 @@ def _detect_cycles(features: list[dict], feature_map: dict) -> list[list[int]]:
|
||||
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]:
|
||||
"""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):
|
||||
ready.append(f)
|
||||
|
||||
# Sort by priority
|
||||
ready.sort(key=lambda f: (f.get("priority", 999), f["id"]))
|
||||
# Sort by scheduling score (higher = first), then priority, then 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]
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ from api.dependency_resolver import (
|
||||
would_create_circular_dependency,
|
||||
are_dependencies_satisfied,
|
||||
get_blocking_dependencies,
|
||||
compute_scheduling_scores,
|
||||
MAX_DEPENDENCIES_PER_FEATURE,
|
||||
)
|
||||
|
||||
@@ -178,7 +179,11 @@ def feature_get_next() -> str:
|
||||
|
||||
# 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))
|
||||
|
||||
# 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 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
|
||||
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 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}
|
||||
|
||||
ready = []
|
||||
all_dicts = [f.to_dict() for f in all_features]
|
||||
for f in all_features:
|
||||
if f.passes or f.in_progress:
|
||||
continue
|
||||
@@ -821,8 +831,9 @@ def feature_get_ready(
|
||||
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"]))
|
||||
# Sort by scheduling score (higher = first), then priority, then id
|
||||
scores = compute_scheduling_scores(all_dicts)
|
||||
ready.sort(key=lambda f: (-scores.get(f["id"], 0), f["priority"], f["id"]))
|
||||
|
||||
return json.dumps({
|
||||
"features": ready[:limit],
|
||||
|
||||
@@ -20,7 +20,7 @@ from pathlib import Path
|
||||
from typing import Callable, Awaitable
|
||||
|
||||
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)
|
||||
AUTOCODER_ROOT = Path(__file__).parent.resolve()
|
||||
@@ -103,8 +103,10 @@ class ParallelOrchestrator:
|
||||
continue
|
||||
resumable.append(f.to_dict())
|
||||
|
||||
# Sort by priority (highest priority first)
|
||||
resumable.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 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
|
||||
finally:
|
||||
session.close()
|
||||
@@ -131,18 +133,25 @@ class ParallelOrchestrator:
|
||||
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"]))
|
||||
# Sort by scheduling score (higher = first), then priority, then id
|
||||
scores = compute_scheduling_scores(all_dicts)
|
||||
ready.sort(key=lambda f: (-scores.get(f["id"], 0), f["priority"], f["id"]))
|
||||
return ready
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
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()
|
||||
try:
|
||||
pending = session.query(Feature).filter(Feature.passes == False).count()
|
||||
return pending == 0
|
||||
all_features = session.query(Feature).all()
|
||||
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:
|
||||
session.close()
|
||||
|
||||
@@ -289,6 +298,7 @@ class ParallelOrchestrator:
|
||||
status = "completed" if return_code == 0 else "failed"
|
||||
if self.on_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)
|
||||
|
||||
def stop_feature(self, feature_id: int) -> tuple[bool, str]:
|
||||
|
||||
@@ -336,6 +336,7 @@ function App() {
|
||||
<AgentMissionControl
|
||||
agents={wsState.activeAgents}
|
||||
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 type { ActiveAgent } from '../lib/types'
|
||||
import type { ActiveAgent, AgentLogEntry } from '../lib/types'
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: ActiveAgent
|
||||
onShowLogs?: (agentIndex: number) => void
|
||||
}
|
||||
|
||||
// Get a friendly state description
|
||||
function getStateText(state: ActiveAgent['state']): string {
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
return 'Waiting...'
|
||||
return 'Standing by...'
|
||||
case 'thinking':
|
||||
return 'Thinking...'
|
||||
return 'Pondering...'
|
||||
case 'working':
|
||||
return 'Coding...'
|
||||
return 'Coding away...'
|
||||
case 'testing':
|
||||
return 'Testing...'
|
||||
return 'Checking work...'
|
||||
case 'success':
|
||||
return 'Done!'
|
||||
return 'Nailed it!'
|
||||
case 'error':
|
||||
return 'Hit an issue'
|
||||
return 'Trying plan B...'
|
||||
case 'struggling':
|
||||
return 'Retrying...'
|
||||
return 'Being persistent...'
|
||||
default:
|
||||
return 'Working...'
|
||||
return 'Busy...'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +37,9 @@ function getStateColor(state: ActiveAgent['state']): string {
|
||||
case 'success':
|
||||
return 'text-neo-done'
|
||||
case 'error':
|
||||
return 'text-neo-pending' // Yellow - just pivoting, not a real error
|
||||
case 'struggling':
|
||||
return 'text-neo-danger'
|
||||
return 'text-orange-500' // Orange - working hard, being persistent
|
||||
case 'working':
|
||||
case 'testing':
|
||||
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 hasLogs = agent.logs && agent.logs.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -68,6 +73,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
{getStateText(agent.state)}
|
||||
</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>
|
||||
|
||||
{/* Feature info */}
|
||||
@@ -97,3 +112,103 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
</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 { useState } from 'react'
|
||||
import { AgentCard } from './AgentCard'
|
||||
import { AgentCard, AgentLogModal } from './AgentCard'
|
||||
import { ActivityFeed } from './ActivityFeed'
|
||||
import type { ActiveAgent } from '../lib/types'
|
||||
import type { ActiveAgent, AgentLogEntry } from '../lib/types'
|
||||
|
||||
const ACTIVITY_COLLAPSED_KEY = 'autocoder-activity-collapsed'
|
||||
|
||||
@@ -15,12 +15,14 @@ interface AgentMissionControlProps {
|
||||
featureId: number
|
||||
}>
|
||||
isExpanded?: boolean
|
||||
getAgentLogs?: (agentIndex: number) => AgentLogEntry[]
|
||||
}
|
||||
|
||||
export function AgentMissionControl({
|
||||
agents,
|
||||
recentActivity,
|
||||
isExpanded: defaultExpanded = true,
|
||||
getAgentLogs,
|
||||
}: AgentMissionControlProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded)
|
||||
const [activityCollapsed, setActivityCollapsed] = useState(() => {
|
||||
@@ -30,6 +32,8 @@ export function AgentMissionControl({
|
||||
return false
|
||||
}
|
||||
})
|
||||
// State for log modal
|
||||
const [selectedAgentForLogs, setSelectedAgentForLogs] = useState<ActiveAgent | null>(null)
|
||||
|
||||
const toggleActivityCollapsed = () => {
|
||||
const newValue = !activityCollapsed
|
||||
@@ -80,7 +84,16 @@ export function AgentMissionControl({
|
||||
{/* 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} />
|
||||
<AgentCard
|
||||
key={`agent-${agent.agentIndex}`}
|
||||
agent={agent}
|
||||
onShowLogs={(agentIndex) => {
|
||||
const agentToShow = agents.find(a => a.agentIndex === agentIndex)
|
||||
if (agentToShow) {
|
||||
setSelectedAgentForLogs(agentToShow)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -116,6 +129,15 @@ export function AgentMissionControl({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Log Modal */}
|
||||
{selectedAgentForLogs && getAgentLogs && (
|
||||
<AgentLogModal
|
||||
agent={selectedAgentForLogs}
|
||||
logs={getAgentLogs(selectedAgentForLogs.agentIndex)}
|
||||
onClose={() => setSelectedAgentForLogs(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
DevServerStatus,
|
||||
ActiveAgent,
|
||||
AgentMascot,
|
||||
AgentLogEntry,
|
||||
} from '../lib/types'
|
||||
|
||||
// Activity item for the feed
|
||||
@@ -42,6 +43,8 @@ interface WebSocketState {
|
||||
// Multi-agent state
|
||||
activeAgents: ActiveAgent[]
|
||||
recentActivity: ActivityItem[]
|
||||
// Per-agent logs for debugging (indexed by agentIndex)
|
||||
agentLogs: Map<number, AgentLogEntry[]>
|
||||
// Celebration queue to handle rapid successes without race conditions
|
||||
celebrationQueue: CelebrationTrigger[]
|
||||
celebration: CelebrationTrigger | null
|
||||
@@ -49,6 +52,7 @@ interface WebSocketState {
|
||||
|
||||
const MAX_LOGS = 100 // Keep last 100 log lines
|
||||
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) {
|
||||
const [state, setState] = useState<WebSocketState>({
|
||||
@@ -61,6 +65,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
devLogs: [],
|
||||
activeAgents: [],
|
||||
recentActivity: [],
|
||||
agentLogs: new Map(),
|
||||
celebrationQueue: [],
|
||||
celebration: null,
|
||||
})
|
||||
@@ -111,9 +116,9 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
break
|
||||
|
||||
case 'log':
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
logs: [
|
||||
setState(prev => {
|
||||
// Update global logs
|
||||
const newLogs = [
|
||||
...prev.logs.slice(-MAX_LOGS + 1),
|
||||
{
|
||||
line: message.line,
|
||||
@@ -121,8 +126,26 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
featureId: message.featureId,
|
||||
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
|
||||
|
||||
case 'feature_update':
|
||||
@@ -131,21 +154,38 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
|
||||
case 'agent_update':
|
||||
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
|
||||
const agentIndex = prev.activeAgents.findIndex(
|
||||
const existingAgentIdx = prev.activeAgents.findIndex(
|
||||
a => a.agentIndex === message.agentIndex
|
||||
)
|
||||
|
||||
let newAgents: ActiveAgent[]
|
||||
if (message.state === 'success') {
|
||||
// Remove agent from active list on success
|
||||
if (message.state === 'success' || message.state === 'error') {
|
||||
// Remove agent from active list on completion (success or failure)
|
||||
// But keep the logs in agentLogs map for debugging
|
||||
newAgents = prev.activeAgents.filter(
|
||||
a => a.agentIndex !== message.agentIndex
|
||||
)
|
||||
} else if (agentIndex >= 0) {
|
||||
} else if (existingAgentIdx >= 0) {
|
||||
// Update existing agent
|
||||
newAgents = [...prev.activeAgents]
|
||||
newAgents[agentIndex] = {
|
||||
newAgents[existingAgentIdx] = {
|
||||
agentIndex: message.agentIndex,
|
||||
agentName: message.agentName,
|
||||
featureId: message.featureId,
|
||||
@@ -153,6 +193,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
state: message.state,
|
||||
thought: message.thought,
|
||||
timestamp: message.timestamp,
|
||||
logs: agentLogsArray,
|
||||
}
|
||||
} else {
|
||||
// Add new agent
|
||||
@@ -166,6 +207,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
state: message.state,
|
||||
thought: message.thought,
|
||||
timestamp: message.timestamp,
|
||||
logs: agentLogsArray,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -207,6 +249,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
return {
|
||||
...prev,
|
||||
activeAgents: newAgents,
|
||||
agentLogs: newAgentLogs,
|
||||
recentActivity: newActivity,
|
||||
celebrationQueue: newCelebrationQueue,
|
||||
celebration: newCelebration,
|
||||
@@ -295,6 +338,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
devLogs: [],
|
||||
activeAgents: [],
|
||||
recentActivity: [],
|
||||
agentLogs: new Map(),
|
||||
celebrationQueue: [],
|
||||
celebration: null,
|
||||
})
|
||||
@@ -335,10 +379,26 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
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 {
|
||||
...state,
|
||||
clearLogs,
|
||||
clearDevLogs,
|
||||
clearCelebration,
|
||||
getAgentLogs,
|
||||
clearAgentLogs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +177,13 @@ export type AgentMascot = typeof AGENT_MASCOTS[number]
|
||||
// Agent state for Mission Control
|
||||
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
|
||||
export interface ActiveAgent {
|
||||
agentIndex: number
|
||||
@@ -186,6 +193,7 @@ export interface ActiveAgent {
|
||||
state: AgentState
|
||||
thought?: string
|
||||
timestamp: string
|
||||
logs?: AgentLogEntry[] // Per-agent log history
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
|
||||
Reference in New Issue
Block a user