mirror of
https://github.com/leonvanzyl/autocoder.git
synced 2026-01-30 06:12:06 +00:00
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>
This commit is contained in:
@@ -54,6 +54,21 @@ THOUGHT_PATTERNS = [
|
||||
(re.compile(r'(?:FAIL|failed|error)', re.I), 'struggling'),
|
||||
]
|
||||
|
||||
# Orchestrator event patterns for Mission Control observability
|
||||
ORCHESTRATOR_PATTERNS = {
|
||||
'init_start': re.compile(r'Running initializer agent'),
|
||||
'init_complete': re.compile(r'INITIALIZATION COMPLETE'),
|
||||
'capacity_check': re.compile(r'\[DEBUG\] Spawning loop: (\d+) ready, (\d+) slots'),
|
||||
'at_capacity': re.compile(r'At max capacity|at max testing agents|At max total agents'),
|
||||
'feature_start': re.compile(r'Starting feature \d+/\d+: #(\d+) - (.+)'),
|
||||
'coding_spawn': re.compile(r'Started coding agent for feature #(\d+)'),
|
||||
'testing_spawn': re.compile(r'Started testing agent for feature #(\d+)'),
|
||||
'coding_complete': re.compile(r'Feature #(\d+) (completed|failed)'),
|
||||
'testing_complete': re.compile(r'Feature #(\d+) testing (completed|failed)'),
|
||||
'all_complete': re.compile(r'All features complete'),
|
||||
'blocked_features': re.compile(r'(\d+) blocked by dependencies'),
|
||||
}
|
||||
|
||||
|
||||
class AgentTracker:
|
||||
"""Tracks active agents and their states for multi-agent mode.
|
||||
@@ -250,6 +265,194 @@ class AgentTracker:
|
||||
return result
|
||||
|
||||
|
||||
class OrchestratorTracker:
|
||||
"""Tracks orchestrator state for Mission Control observability.
|
||||
|
||||
Parses orchestrator stdout for key events and emits orchestrator_update
|
||||
WebSocket messages showing what decisions the orchestrator is making.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.state = 'idle'
|
||||
self.coding_agents = 0
|
||||
self.testing_agents = 0
|
||||
self.max_concurrency = 3 # Default, will be updated from output
|
||||
self.ready_count = 0
|
||||
self.blocked_count = 0
|
||||
self.recent_events: list[dict] = []
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def process_line(self, line: str) -> dict | None:
|
||||
"""
|
||||
Process an output line and return an orchestrator_update message if relevant.
|
||||
|
||||
Returns None if no update should be emitted.
|
||||
"""
|
||||
async with self._lock:
|
||||
update = None
|
||||
|
||||
# Check for initializer start
|
||||
if ORCHESTRATOR_PATTERNS['init_start'].search(line):
|
||||
self.state = 'initializing'
|
||||
update = self._create_update(
|
||||
'init_start',
|
||||
'Initializing project features...'
|
||||
)
|
||||
|
||||
# Check for initializer complete
|
||||
elif ORCHESTRATOR_PATTERNS['init_complete'].search(line):
|
||||
self.state = 'scheduling'
|
||||
update = self._create_update(
|
||||
'init_complete',
|
||||
'Initialization complete, preparing to schedule features'
|
||||
)
|
||||
|
||||
# Check for capacity status
|
||||
elif match := ORCHESTRATOR_PATTERNS['capacity_check'].search(line):
|
||||
self.ready_count = int(match.group(1))
|
||||
slots = int(match.group(2))
|
||||
self.state = 'scheduling' if self.ready_count > 0 else 'monitoring'
|
||||
update = self._create_update(
|
||||
'capacity_check',
|
||||
f'{self.ready_count} features ready, {slots} slots available'
|
||||
)
|
||||
|
||||
# Check for at capacity
|
||||
elif ORCHESTRATOR_PATTERNS['at_capacity'].search(line):
|
||||
self.state = 'monitoring'
|
||||
update = self._create_update(
|
||||
'at_capacity',
|
||||
'At maximum capacity, monitoring active agents'
|
||||
)
|
||||
|
||||
# Check for feature start
|
||||
elif match := ORCHESTRATOR_PATTERNS['feature_start'].search(line):
|
||||
feature_id = int(match.group(1))
|
||||
feature_name = match.group(2).strip()
|
||||
self.state = 'spawning'
|
||||
update = self._create_update(
|
||||
'feature_start',
|
||||
f'Preparing Feature #{feature_id}: {feature_name}',
|
||||
feature_id=feature_id,
|
||||
feature_name=feature_name
|
||||
)
|
||||
|
||||
# Check for coding agent spawn
|
||||
elif match := ORCHESTRATOR_PATTERNS['coding_spawn'].search(line):
|
||||
feature_id = int(match.group(1))
|
||||
self.coding_agents += 1
|
||||
self.state = 'spawning'
|
||||
update = self._create_update(
|
||||
'coding_spawn',
|
||||
f'Spawned coding agent for Feature #{feature_id}',
|
||||
feature_id=feature_id
|
||||
)
|
||||
|
||||
# Check for testing agent spawn
|
||||
elif match := ORCHESTRATOR_PATTERNS['testing_spawn'].search(line):
|
||||
feature_id = int(match.group(1))
|
||||
self.testing_agents += 1
|
||||
self.state = 'spawning'
|
||||
update = self._create_update(
|
||||
'testing_spawn',
|
||||
f'Spawned testing agent for Feature #{feature_id}',
|
||||
feature_id=feature_id
|
||||
)
|
||||
|
||||
# Check for coding agent complete
|
||||
elif match := ORCHESTRATOR_PATTERNS['coding_complete'].search(line):
|
||||
# Only match if "testing" is not in the line
|
||||
if 'testing' not in line.lower():
|
||||
feature_id = int(match.group(1))
|
||||
self.coding_agents = max(0, self.coding_agents - 1)
|
||||
self.state = 'monitoring'
|
||||
update = self._create_update(
|
||||
'coding_complete',
|
||||
f'Coding agent finished Feature #{feature_id}',
|
||||
feature_id=feature_id
|
||||
)
|
||||
|
||||
# Check for testing agent complete
|
||||
elif match := ORCHESTRATOR_PATTERNS['testing_complete'].search(line):
|
||||
feature_id = int(match.group(1))
|
||||
self.testing_agents = max(0, self.testing_agents - 1)
|
||||
self.state = 'monitoring'
|
||||
update = self._create_update(
|
||||
'testing_complete',
|
||||
f'Testing agent finished Feature #{feature_id}',
|
||||
feature_id=feature_id
|
||||
)
|
||||
|
||||
# Check for blocked features count
|
||||
elif match := ORCHESTRATOR_PATTERNS['blocked_features'].search(line):
|
||||
self.blocked_count = int(match.group(1))
|
||||
|
||||
# Check for all complete
|
||||
elif ORCHESTRATOR_PATTERNS['all_complete'].search(line):
|
||||
self.state = 'complete'
|
||||
self.coding_agents = 0
|
||||
self.testing_agents = 0
|
||||
update = self._create_update(
|
||||
'all_complete',
|
||||
'All features complete!'
|
||||
)
|
||||
|
||||
return update
|
||||
|
||||
def _create_update(
|
||||
self,
|
||||
event_type: str,
|
||||
message: str,
|
||||
feature_id: int | None = None,
|
||||
feature_name: str | None = None
|
||||
) -> dict:
|
||||
"""Create an orchestrator_update WebSocket message."""
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# Add to recent events (keep last 5)
|
||||
event = {
|
||||
'eventType': event_type,
|
||||
'message': message,
|
||||
'timestamp': timestamp,
|
||||
}
|
||||
if feature_id is not None:
|
||||
event['featureId'] = feature_id
|
||||
if feature_name is not None:
|
||||
event['featureName'] = feature_name
|
||||
|
||||
self.recent_events = [event] + self.recent_events[:4]
|
||||
|
||||
update = {
|
||||
'type': 'orchestrator_update',
|
||||
'eventType': event_type,
|
||||
'state': self.state,
|
||||
'message': message,
|
||||
'timestamp': timestamp,
|
||||
'codingAgents': self.coding_agents,
|
||||
'testingAgents': self.testing_agents,
|
||||
'maxConcurrency': self.max_concurrency,
|
||||
'readyCount': self.ready_count,
|
||||
'blockedCount': self.blocked_count,
|
||||
}
|
||||
|
||||
if feature_id is not None:
|
||||
update['featureId'] = feature_id
|
||||
if feature_name is not None:
|
||||
update['featureName'] = feature_name
|
||||
|
||||
return update
|
||||
|
||||
async def reset(self):
|
||||
"""Reset tracker state when orchestrator stops or crashes."""
|
||||
async with self._lock:
|
||||
self.state = 'idle'
|
||||
self.coding_agents = 0
|
||||
self.testing_agents = 0
|
||||
self.ready_count = 0
|
||||
self.blocked_count = 0
|
||||
self.recent_events.clear()
|
||||
|
||||
|
||||
def _get_project_path(project_name: str) -> Path:
|
||||
"""Get project path from registry."""
|
||||
import sys
|
||||
@@ -400,6 +603,9 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
||||
# Create agent tracker for multi-agent mode
|
||||
agent_tracker = AgentTracker()
|
||||
|
||||
# Create orchestrator tracker for observability
|
||||
orchestrator_tracker = OrchestratorTracker()
|
||||
|
||||
async def on_output(line: str):
|
||||
"""Handle agent output - broadcast to this WebSocket."""
|
||||
try:
|
||||
@@ -429,6 +635,11 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
||||
agent_update = await agent_tracker.process_line(line)
|
||||
if agent_update:
|
||||
await websocket.send_json(agent_update)
|
||||
|
||||
# Also check for orchestrator events and emit orchestrator_update messages
|
||||
orch_update = await orchestrator_tracker.process_line(line)
|
||||
if orch_update:
|
||||
await websocket.send_json(orch_update)
|
||||
except Exception:
|
||||
pass # Connection may be closed
|
||||
|
||||
@@ -439,9 +650,10 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
||||
"type": "agent_status",
|
||||
"status": status,
|
||||
})
|
||||
# Reset tracker when agent stops OR crashes to prevent ghost agents on restart
|
||||
# Reset trackers when agent stops OR crashes to prevent ghost agents on restart
|
||||
if status in ("stopped", "crashed"):
|
||||
await agent_tracker.reset()
|
||||
await orchestrator_tracker.reset()
|
||||
except Exception:
|
||||
pass # Connection may be closed
|
||||
|
||||
|
||||
@@ -348,14 +348,13 @@ function App() {
|
||||
isConnected={wsState.isConnected}
|
||||
/>
|
||||
|
||||
{/* Agent Mission Control - shows active agents in parallel mode */}
|
||||
{wsState.activeAgents.length > 0 && (
|
||||
<AgentMissionControl
|
||||
agents={wsState.activeAgents}
|
||||
recentActivity={wsState.recentActivity}
|
||||
getAgentLogs={wsState.getAgentLogs}
|
||||
/>
|
||||
)}
|
||||
{/* Agent Mission Control - shows orchestrator status and active agents in parallel mode */}
|
||||
<AgentMissionControl
|
||||
agents={wsState.activeAgents}
|
||||
orchestratorStatus={wsState.orchestratorStatus}
|
||||
recentActivity={wsState.recentActivity}
|
||||
getAgentLogs={wsState.getAgentLogs}
|
||||
/>
|
||||
|
||||
{/* Agent Thought - shows latest agent narrative (single agent mode) */}
|
||||
{wsState.activeAgents.length === 0 && (
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Rocket, ChevronDown, ChevronUp, Activity } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { AgentCard, AgentLogModal } from './AgentCard'
|
||||
import { ActivityFeed } from './ActivityFeed'
|
||||
import type { ActiveAgent, AgentLogEntry } from '../lib/types'
|
||||
import { OrchestratorStatusCard } from './OrchestratorStatusCard'
|
||||
import type { ActiveAgent, AgentLogEntry, OrchestratorStatus } from '../lib/types'
|
||||
|
||||
const ACTIVITY_COLLAPSED_KEY = 'autocoder-activity-collapsed'
|
||||
|
||||
interface AgentMissionControlProps {
|
||||
agents: ActiveAgent[]
|
||||
orchestratorStatus: OrchestratorStatus | null
|
||||
recentActivity: Array<{
|
||||
agentName: string
|
||||
thought: string
|
||||
@@ -20,6 +22,7 @@ interface AgentMissionControlProps {
|
||||
|
||||
export function AgentMissionControl({
|
||||
agents,
|
||||
orchestratorStatus,
|
||||
recentActivity,
|
||||
isExpanded: defaultExpanded = true,
|
||||
getAgentLogs,
|
||||
@@ -45,8 +48,8 @@ export function AgentMissionControl({
|
||||
}
|
||||
}
|
||||
|
||||
// Don't render if no agents
|
||||
if (agents.length === 0) {
|
||||
// Don't render if no orchestrator status and no agents
|
||||
if (!orchestratorStatus && agents.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -63,7 +66,14 @@ export function AgentMissionControl({
|
||||
Mission Control
|
||||
</span>
|
||||
<span className="neo-badge neo-badge-sm bg-white text-neo-text ml-2">
|
||||
{agents.length} {agents.length === 1 ? 'agent' : 'agents'} active
|
||||
{agents.length > 0
|
||||
? `${agents.length} ${agents.length === 1 ? 'agent' : 'agents'} active`
|
||||
: orchestratorStatus?.state === 'initializing'
|
||||
? 'Initializing'
|
||||
: orchestratorStatus?.state === 'complete'
|
||||
? 'Complete'
|
||||
: 'Orchestrating'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
@@ -77,25 +87,32 @@ export function AgentMissionControl({
|
||||
<div
|
||||
className={`
|
||||
transition-all duration-300 ease-out overflow-hidden
|
||||
${isExpanded ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0'}
|
||||
${isExpanded ? 'max-h-[600px] opacity-100' : 'max-h-0 opacity-0'}
|
||||
`}
|
||||
>
|
||||
<div className="p-4">
|
||||
{/* Orchestrator Status Card */}
|
||||
{orchestratorStatus && (
|
||||
<OrchestratorStatusCard status={orchestratorStatus} />
|
||||
)}
|
||||
|
||||
{/* 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}
|
||||
onShowLogs={(agentIndex) => {
|
||||
const agentToShow = agents.find(a => a.agentIndex === agentIndex)
|
||||
if (agentToShow) {
|
||||
setSelectedAgentForLogs(agentToShow)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{agents.length > 0 && (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-thin">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard
|
||||
key={`agent-${agent.agentIndex}`}
|
||||
agent={agent}
|
||||
onShowLogs={(agentIndex) => {
|
||||
const agentToShow = agents.find(a => a.agentIndex === agentIndex)
|
||||
if (agentToShow) {
|
||||
setSelectedAgentForLogs(agentToShow)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible Activity Feed */}
|
||||
{recentActivity.length > 0 && (
|
||||
|
||||
178
ui/src/components/OrchestratorAvatar.tsx
Normal file
178
ui/src/components/OrchestratorAvatar.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { OrchestratorState } from '../lib/types'
|
||||
|
||||
interface OrchestratorAvatarProps {
|
||||
state: OrchestratorState
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: { svg: 32, font: 'text-xs' },
|
||||
md: { svg: 48, font: 'text-sm' },
|
||||
lg: { svg: 64, font: 'text-base' },
|
||||
}
|
||||
|
||||
// Maestro color scheme - Deep violet
|
||||
const MAESTRO_COLORS = {
|
||||
primary: '#7C3AED', // Violet-600
|
||||
secondary: '#A78BFA', // Violet-400
|
||||
accent: '#EDE9FE', // Violet-100
|
||||
baton: '#FBBF24', // Amber-400 for the baton
|
||||
gold: '#F59E0B', // Amber-500 for accents
|
||||
}
|
||||
|
||||
// Maestro SVG - Robot conductor with baton
|
||||
function MaestroSVG({ size, state }: { size: number; state: OrchestratorState }) {
|
||||
// Animation transform based on state
|
||||
const batonAnimation = state === 'spawning' ? 'animate-conducting' :
|
||||
state === 'scheduling' ? 'animate-baton-tap' : ''
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 64 64" fill="none">
|
||||
{/* Conductor's podium hint */}
|
||||
<rect x="22" y="54" width="20" height="6" rx="2" fill={MAESTRO_COLORS.primary} opacity="0.3" />
|
||||
|
||||
{/* Robot body - formal conductor style */}
|
||||
<rect x="18" y="28" width="28" height="26" rx="4" fill={MAESTRO_COLORS.primary} />
|
||||
|
||||
{/* Tuxedo front / formal vest */}
|
||||
<rect x="26" y="32" width="12" height="18" fill={MAESTRO_COLORS.accent} />
|
||||
<rect x="30" y="32" width="4" height="18" fill={MAESTRO_COLORS.secondary} />
|
||||
|
||||
{/* Bow tie */}
|
||||
<path d="M27,30 L32,33 L37,30 L32,32 Z" fill={MAESTRO_COLORS.gold} />
|
||||
|
||||
{/* Robot head */}
|
||||
<rect x="16" y="6" width="32" height="24" rx="4" fill={MAESTRO_COLORS.secondary} />
|
||||
|
||||
{/* Conductor's cap */}
|
||||
<rect x="14" y="2" width="36" height="8" rx="2" fill={MAESTRO_COLORS.primary} />
|
||||
<rect x="20" y="0" width="24" height="4" rx="2" fill={MAESTRO_COLORS.primary} />
|
||||
<circle cx="32" cy="2" r="3" fill={MAESTRO_COLORS.gold} />
|
||||
|
||||
{/* Eyes */}
|
||||
<circle cx="24" cy="16" r="4" fill="white" />
|
||||
<circle cx="40" cy="16" r="4" fill="white" />
|
||||
<circle cx="25" cy="16" r="2" fill={MAESTRO_COLORS.primary} />
|
||||
<circle cx="41" cy="16" r="2" fill={MAESTRO_COLORS.primary} />
|
||||
|
||||
{/* Smile */}
|
||||
<path d="M26,24 Q32,28 38,24" stroke="white" strokeWidth="2" fill="none" strokeLinecap="round" />
|
||||
|
||||
{/* Arms */}
|
||||
<rect x="8" y="32" width="10" height="4" rx="2" fill={MAESTRO_COLORS.primary} />
|
||||
<rect x="46" y="28" width="10" height="4" rx="2" fill={MAESTRO_COLORS.primary}
|
||||
className={batonAnimation}
|
||||
style={{ transformOrigin: '46px 30px' }} />
|
||||
|
||||
{/* Hand holding baton */}
|
||||
<circle cx="56" cy="30" r="4" fill={MAESTRO_COLORS.secondary}
|
||||
className={batonAnimation}
|
||||
style={{ transformOrigin: '46px 30px' }} />
|
||||
|
||||
{/* Baton */}
|
||||
<g className={batonAnimation} style={{ transformOrigin: '56px 30px' }}>
|
||||
<line x1="56" y1="26" x2="62" y2="10" stroke={MAESTRO_COLORS.baton} strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="62" cy="10" r="2" fill={MAESTRO_COLORS.gold} />
|
||||
</g>
|
||||
|
||||
{/* Subtle music notes when active */}
|
||||
{(state === 'spawning' || state === 'monitoring') && (
|
||||
<>
|
||||
<text x="4" y="20" fontSize="8" fill={MAESTRO_COLORS.secondary} className="animate-pulse">
|
||||
♪
|
||||
</text>
|
||||
<text x="58" y="48" fontSize="8" fill={MAESTRO_COLORS.secondary} className="animate-pulse" style={{ animationDelay: '0.3s' }}>
|
||||
♫
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// Animation classes based on orchestrator state
|
||||
function getStateAnimation(state: OrchestratorState): string {
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
return 'animate-bounce-gentle'
|
||||
case 'initializing':
|
||||
return 'animate-thinking'
|
||||
case 'scheduling':
|
||||
return 'animate-thinking'
|
||||
case 'spawning':
|
||||
return 'animate-working'
|
||||
case 'monitoring':
|
||||
return 'animate-bounce-gentle'
|
||||
case 'complete':
|
||||
return 'animate-celebrate'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Glow effect based on state
|
||||
function getStateGlow(state: OrchestratorState): string {
|
||||
switch (state) {
|
||||
case 'initializing':
|
||||
return 'shadow-[0_0_12px_rgba(124,58,237,0.4)]'
|
||||
case 'scheduling':
|
||||
return 'shadow-[0_0_10px_rgba(167,139,250,0.5)]'
|
||||
case 'spawning':
|
||||
return 'shadow-[0_0_16px_rgba(124,58,237,0.6)]'
|
||||
case 'monitoring':
|
||||
return 'shadow-[0_0_8px_rgba(167,139,250,0.4)]'
|
||||
case 'complete':
|
||||
return 'shadow-[0_0_20px_rgba(112,224,0,0.6)]'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// Get human-readable state description for accessibility
|
||||
function getStateDescription(state: OrchestratorState): string {
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
return 'waiting'
|
||||
case 'initializing':
|
||||
return 'initializing features'
|
||||
case 'scheduling':
|
||||
return 'selecting next features'
|
||||
case 'spawning':
|
||||
return 'spawning agents'
|
||||
case 'monitoring':
|
||||
return 'monitoring progress'
|
||||
case 'complete':
|
||||
return 'all features complete'
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
export function OrchestratorAvatar({ state, size = 'md' }: OrchestratorAvatarProps) {
|
||||
const { svg: svgSize } = SIZES[size]
|
||||
const stateDesc = getStateDescription(state)
|
||||
const ariaLabel = `Orchestrator Maestro is ${stateDesc}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center gap-1"
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
rounded-full p-1 transition-all duration-300
|
||||
${getStateAnimation(state)}
|
||||
${getStateGlow(state)}
|
||||
`}
|
||||
style={{ backgroundColor: MAESTRO_COLORS.accent }}
|
||||
title={ariaLabel}
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<MaestroSVG size={svgSize} state={state} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
152
ui/src/components/OrchestratorStatusCard.tsx
Normal file
152
ui/src/components/OrchestratorStatusCard.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, Code, FlaskConical, Clock, Lock, Sparkles } from 'lucide-react'
|
||||
import { OrchestratorAvatar } from './OrchestratorAvatar'
|
||||
import type { OrchestratorStatus, OrchestratorState } from '../lib/types'
|
||||
|
||||
interface OrchestratorStatusCardProps {
|
||||
status: OrchestratorStatus
|
||||
}
|
||||
|
||||
// Get a friendly state description
|
||||
function getStateText(state: OrchestratorState): string {
|
||||
switch (state) {
|
||||
case 'idle':
|
||||
return 'Standing by...'
|
||||
case 'initializing':
|
||||
return 'Setting up features...'
|
||||
case 'scheduling':
|
||||
return 'Planning next moves...'
|
||||
case 'spawning':
|
||||
return 'Deploying agents...'
|
||||
case 'monitoring':
|
||||
return 'Watching progress...'
|
||||
case 'complete':
|
||||
return 'Mission accomplished!'
|
||||
default:
|
||||
return 'Orchestrating...'
|
||||
}
|
||||
}
|
||||
|
||||
// Get state color
|
||||
function getStateColor(state: OrchestratorState): string {
|
||||
switch (state) {
|
||||
case 'complete':
|
||||
return 'text-neo-done'
|
||||
case 'spawning':
|
||||
return 'text-[#7C3AED]' // Violet
|
||||
case 'scheduling':
|
||||
case 'monitoring':
|
||||
return 'text-neo-progress'
|
||||
case 'initializing':
|
||||
return 'text-neo-pending'
|
||||
default:
|
||||
return 'text-neo-text-secondary'
|
||||
}
|
||||
}
|
||||
|
||||
// Format timestamp to relative time
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const now = new Date()
|
||||
const then = new Date(timestamp)
|
||||
const diffMs = now.getTime() - then.getTime()
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
|
||||
if (diffSecs < 5) return 'just now'
|
||||
if (diffSecs < 60) return `${diffSecs}s ago`
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
return `${Math.floor(diffMins / 60)}h ago`
|
||||
}
|
||||
|
||||
export function OrchestratorStatusCard({ status }: OrchestratorStatusCardProps) {
|
||||
const [showEvents, setShowEvents] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="neo-card p-4 bg-gradient-to-r from-[#EDE9FE] to-[#F3E8FF] border-[#7C3AED]/30 mb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Avatar */}
|
||||
<OrchestratorAvatar state={status.state} size="md" />
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-display font-bold text-lg text-[#7C3AED]">
|
||||
Maestro
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${getStateColor(status.state)}`}>
|
||||
{getStateText(status.state)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Current message */}
|
||||
<p className="text-sm text-neo-text mb-3 line-clamp-2">
|
||||
{status.message}
|
||||
</p>
|
||||
|
||||
{/* Status badges row */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Coding agents badge */}
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-blue-100 text-blue-700 rounded border border-blue-300 text-xs font-bold">
|
||||
<Code size={12} />
|
||||
<span>Coding: {status.codingAgents}</span>
|
||||
</div>
|
||||
|
||||
{/* Testing agents badge */}
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-purple-100 text-purple-700 rounded border border-purple-300 text-xs font-bold">
|
||||
<FlaskConical size={12} />
|
||||
<span>Testing: {status.testingAgents}</span>
|
||||
</div>
|
||||
|
||||
{/* Ready queue badge */}
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-green-100 text-green-700 rounded border border-green-300 text-xs font-bold">
|
||||
<Clock size={12} />
|
||||
<span>Ready: {status.readyCount}</span>
|
||||
</div>
|
||||
|
||||
{/* Blocked badge (only show if > 0) */}
|
||||
{status.blockedCount > 0 && (
|
||||
<div className="inline-flex items-center gap-1.5 px-2 py-1 bg-amber-100 text-amber-700 rounded border border-amber-300 text-xs font-bold">
|
||||
<Lock size={12} />
|
||||
<span>Blocked: {status.blockedCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent events toggle */}
|
||||
{status.recentEvents.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowEvents(!showEvents)}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs font-medium text-[#7C3AED] hover:bg-[#7C3AED]/10 rounded transition-colors"
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
<span>Activity</span>
|
||||
{showEvents ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapsible recent events */}
|
||||
{showEvents && status.recentEvents.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-[#7C3AED]/20">
|
||||
<div className="space-y-1.5">
|
||||
{status.recentEvents.map((event, idx) => (
|
||||
<div
|
||||
key={`${event.timestamp}-${idx}`}
|
||||
className="flex items-start gap-2 text-xs"
|
||||
>
|
||||
<span className="text-[#A78BFA] shrink-0 font-mono">
|
||||
{formatRelativeTime(event.timestamp)}
|
||||
</span>
|
||||
<span className="text-neo-text">
|
||||
{event.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import type {
|
||||
ActiveAgent,
|
||||
AgentMascot,
|
||||
AgentLogEntry,
|
||||
OrchestratorStatus,
|
||||
OrchestratorEvent,
|
||||
} from '../lib/types'
|
||||
|
||||
// Activity item for the feed
|
||||
@@ -48,6 +50,8 @@ interface WebSocketState {
|
||||
// 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
|
||||
@@ -68,6 +72,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
agentLogs: new Map(),
|
||||
celebrationQueue: [],
|
||||
celebration: null,
|
||||
orchestratorStatus: null,
|
||||
})
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
@@ -112,8 +117,12 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
agentStatus: message.status,
|
||||
// Clear active agents when process stops OR crashes to prevent stale UI
|
||||
...((message.status === 'stopped' || message.status === 'crashed') && { activeAgents: [], recentActivity: [] }),
|
||||
// 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
|
||||
|
||||
@@ -261,6 +270,33 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
})
|
||||
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,
|
||||
@@ -346,6 +382,7 @@ export function useProjectWebSocket(projectName: string | null) {
|
||||
agentLogs: new Map(),
|
||||
celebrationQueue: [],
|
||||
celebration: null,
|
||||
orchestratorStatus: null,
|
||||
})
|
||||
|
||||
if (!projectName) {
|
||||
|
||||
@@ -206,8 +206,39 @@ export interface ActiveAgent {
|
||||
logs?: AgentLogEntry[] // Per-agent log history
|
||||
}
|
||||
|
||||
// Orchestrator state for Mission Control
|
||||
export type OrchestratorState =
|
||||
| 'idle'
|
||||
| 'initializing'
|
||||
| 'scheduling'
|
||||
| 'spawning'
|
||||
| 'monitoring'
|
||||
| 'complete'
|
||||
|
||||
// Orchestrator event for recent activity
|
||||
export interface OrchestratorEvent {
|
||||
eventType: string
|
||||
message: string
|
||||
timestamp: string
|
||||
featureId?: number
|
||||
featureName?: string
|
||||
}
|
||||
|
||||
// Orchestrator status for Mission Control
|
||||
export interface OrchestratorStatus {
|
||||
state: OrchestratorState
|
||||
message: string
|
||||
codingAgents: number
|
||||
testingAgents: number
|
||||
maxConcurrency: number
|
||||
readyCount: number
|
||||
blockedCount: number
|
||||
timestamp: string
|
||||
recentEvents: OrchestratorEvent[]
|
||||
}
|
||||
|
||||
// WebSocket message types
|
||||
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' | 'agent_update'
|
||||
export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_status' | 'pong' | 'dev_log' | 'dev_server_status' | 'agent_update' | 'orchestrator_update'
|
||||
|
||||
export interface WSProgressMessage {
|
||||
type: 'progress'
|
||||
@@ -265,6 +296,21 @@ export interface WSDevServerStatusMessage {
|
||||
url: string | null
|
||||
}
|
||||
|
||||
export interface WSOrchestratorUpdateMessage {
|
||||
type: 'orchestrator_update'
|
||||
eventType: string
|
||||
state: OrchestratorState
|
||||
message: string
|
||||
timestamp: string
|
||||
codingAgents?: number
|
||||
testingAgents?: number
|
||||
maxConcurrency?: number
|
||||
readyCount?: number
|
||||
blockedCount?: number
|
||||
featureId?: number
|
||||
featureName?: string
|
||||
}
|
||||
|
||||
export type WSMessage =
|
||||
| WSProgressMessage
|
||||
| WSFeatureUpdateMessage
|
||||
@@ -274,6 +320,7 @@ export type WSMessage =
|
||||
| WSPongMessage
|
||||
| WSDevLogMessage
|
||||
| WSDevServerStatusMessage
|
||||
| WSOrchestratorUpdateMessage
|
||||
|
||||
// ============================================================================
|
||||
// Spec Chat Types
|
||||
|
||||
@@ -960,6 +960,67 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Orchestrator (Maestro) Animations
|
||||
============================================================================ */
|
||||
|
||||
@keyframes conducting {
|
||||
0%, 100% {
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes baton-tap {
|
||||
0%, 100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translateY(-3px) rotate(-2deg);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
75% {
|
||||
transform: translateY(-3px) rotate(2deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes maestro-idle {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes maestro-complete {
|
||||
0% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.05) rotate(-3deg);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1) rotate(0deg);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.05) rotate(3deg);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
Utilities Layer
|
||||
============================================================================ */
|
||||
@@ -1089,6 +1150,23 @@
|
||||
.animate-confetti {
|
||||
animation: confetti 2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Orchestrator (Maestro) animation utilities */
|
||||
.animate-conducting {
|
||||
animation: conducting 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-baton-tap {
|
||||
animation: baton-tap 0.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-maestro-idle {
|
||||
animation: maestro-idle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-maestro-complete {
|
||||
animation: maestro-complete 0.8s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user