Files
autocoder/ui/src/hooks/useWebSocket.ts
Auto a03d945fcd feat: add orchestrator observability to Mission Control
Add real-time visibility into the parallel orchestrator's decisions
and state in the Mission Control UI. The orchestrator now has its
own avatar ("Maestro") and displays capacity/queue information.

Backend changes (server/websocket.py):
- Add OrchestratorTracker class that parses orchestrator stdout
- Define regex patterns for key orchestrator events (spawn, complete, capacity)
- Track coding/testing agent counts, ready queue, blocked features
- Emit orchestrator_update WebSocket messages
- Reset tracker state when agent stops or crashes

Frontend changes:
- Add OrchestratorState, OrchestratorStatus, OrchestratorEvent types
- Add WSOrchestratorUpdateMessage to WSMessage union
- Handle orchestrator_update in useWebSocket hook
- Create OrchestratorAvatar component (Maestro - robot conductor)
- Create OrchestratorStatusCard with capacity badges and event ticker
- Update AgentMissionControl to show orchestrator above agent cards
- Add conducting/baton-tap CSS animations for Maestro

The orchestrator status card shows:
- Maestro avatar with state-based animations
- Current orchestrator state and message
- Coding agents, testing agents, ready queue badges
- Blocked features count (when > 0)
- Collapsible recent events list

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:02:36 +02:00

447 lines
15 KiB
TypeScript

/**
* WebSocket Hook for Real-time Updates
*/
import { useEffect, useRef, useState, useCallback } from 'react'
import type {
WSMessage,
AgentStatus,
DevServerStatus,
ActiveAgent,
AgentMascot,
AgentLogEntry,
OrchestratorStatus,
OrchestratorEvent,
} from '../lib/types'
// Activity item for the feed
interface ActivityItem {
agentName: string
thought: string
timestamp: string
featureId: number
}
// Celebration trigger for overlay
interface CelebrationTrigger {
agentName: AgentMascot
featureName: string
featureId: number
}
interface WebSocketState {
progress: {
passing: number
in_progress: number
total: number
percentage: number
}
agentStatus: AgentStatus
logs: Array<{ line: string; timestamp: string; featureId?: number; agentIndex?: number }>
isConnected: boolean
devServerStatus: DevServerStatus
devServerUrl: string | null
devLogs: Array<{ line: string; timestamp: string }>
// Multi-agent state
activeAgents: ActiveAgent[]
recentActivity: ActivityItem[]
// 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
// Orchestrator state for Mission Control
orchestratorStatus: OrchestratorStatus | null
}
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>({
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 },
agentStatus: 'loading',
logs: [],
isConnected: false,
devServerStatus: 'stopped',
devServerUrl: null,
devLogs: [],
activeAgents: [],
recentActivity: [],
agentLogs: new Map(),
celebrationQueue: [],
celebration: null,
orchestratorStatus: null,
})
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<number | null>(null)
const reconnectAttempts = useRef(0)
const connect = useCallback(() => {
if (!projectName) return
// Build WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
const wsUrl = `${protocol}//${host}/ws/projects/${encodeURIComponent(projectName)}`
try {
const ws = new WebSocket(wsUrl)
wsRef.current = ws
ws.onopen = () => {
setState(prev => ({ ...prev, isConnected: true }))
reconnectAttempts.current = 0
}
ws.onmessage = (event) => {
try {
const message: WSMessage = JSON.parse(event.data)
switch (message.type) {
case 'progress':
setState(prev => ({
...prev,
progress: {
passing: message.passing,
in_progress: message.in_progress,
total: message.total,
percentage: message.percentage,
},
}))
break
case 'agent_status':
setState(prev => ({
...prev,
agentStatus: message.status,
// Clear active agents and orchestrator status when process stops OR crashes to prevent stale UI
...((message.status === 'stopped' || message.status === 'crashed') && {
activeAgents: [],
recentActivity: [],
orchestratorStatus: null,
}),
}))
break
case 'log':
setState(prev => {
// Update global logs
const newLogs = [
...prev.logs.slice(-MAX_LOGS + 1),
{
line: message.line,
timestamp: message.timestamp,
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':
// Feature updates will trigger a refetch via React Query
break
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 existingAgentIdx = prev.activeAgents.findIndex(
a => a.agentIndex === message.agentIndex
)
let newAgents: ActiveAgent[]
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 (existingAgentIdx >= 0) {
// Update existing agent
newAgents = [...prev.activeAgents]
newAgents[existingAgentIdx] = {
agentIndex: message.agentIndex,
agentName: message.agentName,
agentType: message.agentType || 'coding', // Default to coding for backwards compat
featureId: message.featureId,
featureName: message.featureName,
state: message.state,
thought: message.thought,
timestamp: message.timestamp,
logs: agentLogsArray,
}
} else {
// Add new agent
newAgents = [
...prev.activeAgents,
{
agentIndex: message.agentIndex,
agentName: message.agentName,
agentType: message.agentType || 'coding', // Default to coding for backwards compat
featureId: message.featureId,
featureName: message.featureName,
state: message.state,
thought: message.thought,
timestamp: message.timestamp,
logs: agentLogsArray,
},
]
}
// Add to activity feed if there's a thought
let newActivity = prev.recentActivity
if (message.thought) {
newActivity = [
{
agentName: message.agentName,
thought: message.thought,
timestamp: message.timestamp,
featureId: message.featureId,
},
...prev.recentActivity.slice(0, MAX_ACTIVITY - 1),
]
}
// Handle celebration queue on success
let newCelebrationQueue = prev.celebrationQueue
let newCelebration = prev.celebration
if (message.state === 'success') {
const newCelebrationItem: CelebrationTrigger = {
agentName: message.agentName,
featureName: message.featureName,
featureId: message.featureId,
}
// If no celebration is showing, show this one immediately
// Otherwise, add to queue
if (!prev.celebration) {
newCelebration = newCelebrationItem
} else {
newCelebrationQueue = [...prev.celebrationQueue, newCelebrationItem]
}
}
return {
...prev,
activeAgents: newAgents,
agentLogs: newAgentLogs,
recentActivity: newActivity,
celebrationQueue: newCelebrationQueue,
celebration: newCelebration,
}
})
break
case 'orchestrator_update':
setState(prev => {
const newEvent: OrchestratorEvent = {
eventType: message.eventType,
message: message.message,
timestamp: message.timestamp,
featureId: message.featureId,
featureName: message.featureName,
}
return {
...prev,
orchestratorStatus: {
state: message.state,
message: message.message,
codingAgents: message.codingAgents ?? prev.orchestratorStatus?.codingAgents ?? 0,
testingAgents: message.testingAgents ?? prev.orchestratorStatus?.testingAgents ?? 0,
maxConcurrency: message.maxConcurrency ?? prev.orchestratorStatus?.maxConcurrency ?? 3,
readyCount: message.readyCount ?? prev.orchestratorStatus?.readyCount ?? 0,
blockedCount: message.blockedCount ?? prev.orchestratorStatus?.blockedCount ?? 0,
timestamp: message.timestamp,
recentEvents: [newEvent, ...(prev.orchestratorStatus?.recentEvents ?? []).slice(0, 4)],
},
}
})
break
case 'dev_log':
setState(prev => ({
...prev,
devLogs: [
...prev.devLogs.slice(-MAX_LOGS + 1),
{ line: message.line, timestamp: message.timestamp },
],
}))
break
case 'dev_server_status':
setState(prev => ({
...prev,
devServerStatus: message.status,
devServerUrl: message.url,
}))
break
case 'pong':
// Heartbeat response
break
}
} catch {
console.error('Failed to parse WebSocket message')
}
}
ws.onclose = () => {
setState(prev => ({ ...prev, isConnected: false }))
wsRef.current = null
// Exponential backoff reconnection
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000)
reconnectAttempts.current++
reconnectTimeoutRef.current = window.setTimeout(() => {
connect()
}, delay)
}
ws.onerror = () => {
ws.close()
}
} catch {
// Failed to connect, will retry via onclose
}
}, [projectName])
// Send ping to keep connection alive
const sendPing = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'ping' }))
}
}, [])
// Clear celebration and show next one from queue if available
const clearCelebration = useCallback(() => {
setState(prev => {
// Pop the next celebration from the queue if available
const [nextCelebration, ...remainingQueue] = prev.celebrationQueue
return {
...prev,
celebration: nextCelebration || null,
celebrationQueue: remainingQueue,
}
})
}, [])
// Connect when project changes
useEffect(() => {
// Reset state when project changes to clear stale data
// Use 'loading' for agentStatus to show loading indicator until WebSocket provides actual status
setState({
progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 },
agentStatus: 'loading',
logs: [],
isConnected: false,
devServerStatus: 'stopped',
devServerUrl: null,
devLogs: [],
activeAgents: [],
recentActivity: [],
agentLogs: new Map(),
celebrationQueue: [],
celebration: null,
orchestratorStatus: null,
})
if (!projectName) {
// Disconnect if no project
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
return
}
connect()
// Ping every 30 seconds
const pingInterval = setInterval(sendPing, 30000)
return () => {
clearInterval(pingInterval)
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
}
}, [projectName, connect, sendPing])
// Clear logs function
const clearLogs = useCallback(() => {
setState(prev => ({ ...prev, logs: [] }))
}, [])
// Clear dev logs function
const clearDevLogs = useCallback(() => {
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,
}
}