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:
Auto
2026-01-17 14:11:24 +02:00
parent 85f6940a54
commit bf3a6b0b73
10 changed files with 387 additions and 49 deletions

View File

@@ -336,6 +336,7 @@ function App() {
<AgentMissionControl
agents={wsState.activeAgents}
recentActivity={wsState.recentActivity}
getAgentLogs={wsState.getAgentLogs}
/>
)}

View File

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

View File

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

View File

@@ -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,
}
}

View File

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