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