From a03d945fcdd8e7995dfe2b203c97fdbc4250b7fa Mon Sep 17 00:00:00 2001 From: Auto Date: Fri, 23 Jan 2026 13:02:36 +0200 Subject: [PATCH] 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 --- server/websocket.py | 214 ++++++++++++++++++- ui/src/App.tsx | 15 +- ui/src/components/AgentMissionControl.tsx | 55 +++-- ui/src/components/OrchestratorAvatar.tsx | 178 +++++++++++++++ ui/src/components/OrchestratorStatusCard.tsx | 152 +++++++++++++ ui/src/hooks/useWebSocket.ts | 41 +++- ui/src/lib/types.ts | 49 ++++- ui/src/styles/globals.css | 78 +++++++ 8 files changed, 751 insertions(+), 31 deletions(-) create mode 100644 ui/src/components/OrchestratorAvatar.tsx create mode 100644 ui/src/components/OrchestratorStatusCard.tsx diff --git a/server/websocket.py b/server/websocket.py index 18680ac..635094b 100644 --- a/server/websocket.py +++ b/server/websocket.py @@ -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 diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ef46cc9..59ed0ab 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -348,14 +348,13 @@ function App() { isConnected={wsState.isConnected} /> - {/* Agent Mission Control - shows active agents in parallel mode */} - {wsState.activeAgents.length > 0 && ( - - )} + {/* Agent Mission Control - shows orchestrator status and active agents in parallel mode */} + {/* Agent Thought - shows latest agent narrative (single agent mode) */} {wsState.activeAgents.length === 0 && ( diff --git a/ui/src/components/AgentMissionControl.tsx b/ui/src/components/AgentMissionControl.tsx index c4ed1b8..9fae801 100644 --- a/ui/src/components/AgentMissionControl.tsx +++ b/ui/src/components/AgentMissionControl.tsx @@ -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 - {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' + } {isExpanded ? ( @@ -77,25 +87,32 @@ export function AgentMissionControl({
+ {/* Orchestrator Status Card */} + {orchestratorStatus && ( + + )} + {/* Agent Cards Row */} -
- {agents.map((agent) => ( - { - const agentToShow = agents.find(a => a.agentIndex === agentIndex) - if (agentToShow) { - setSelectedAgentForLogs(agentToShow) - } - }} - /> - ))} -
+ {agents.length > 0 && ( +
+ {agents.map((agent) => ( + { + const agentToShow = agents.find(a => a.agentIndex === agentIndex) + if (agentToShow) { + setSelectedAgentForLogs(agentToShow) + } + }} + /> + ))} +
+ )} {/* Collapsible Activity Feed */} {recentActivity.length > 0 && ( diff --git a/ui/src/components/OrchestratorAvatar.tsx b/ui/src/components/OrchestratorAvatar.tsx new file mode 100644 index 0000000..bbf3dab --- /dev/null +++ b/ui/src/components/OrchestratorAvatar.tsx @@ -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 ( + + {/* Conductor's podium hint */} + + + {/* Robot body - formal conductor style */} + + + {/* Tuxedo front / formal vest */} + + + + {/* Bow tie */} + + + {/* Robot head */} + + + {/* Conductor's cap */} + + + + + {/* Eyes */} + + + + + + {/* Smile */} + + + {/* Arms */} + + + + {/* Hand holding baton */} + + + {/* Baton */} + + + + + + {/* Subtle music notes when active */} + {(state === 'spawning' || state === 'monitoring') && ( + <> + + ♪ + + + ♫ + + + )} + + ) +} + +// 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 ( +
+ +
+ ) +} diff --git a/ui/src/components/OrchestratorStatusCard.tsx b/ui/src/components/OrchestratorStatusCard.tsx new file mode 100644 index 0000000..90db078 --- /dev/null +++ b/ui/src/components/OrchestratorStatusCard.tsx @@ -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 ( +
+
+ {/* Avatar */} + + + {/* Main content */} +
+ {/* Header row */} +
+ + Maestro + + + {getStateText(status.state)} + +
+ + {/* Current message */} +

+ {status.message} +

+ + {/* Status badges row */} +
+ {/* Coding agents badge */} +
+ + Coding: {status.codingAgents} +
+ + {/* Testing agents badge */} +
+ + Testing: {status.testingAgents} +
+ + {/* Ready queue badge */} +
+ + Ready: {status.readyCount} +
+ + {/* Blocked badge (only show if > 0) */} + {status.blockedCount > 0 && ( +
+ + Blocked: {status.blockedCount} +
+ )} +
+
+ + {/* Recent events toggle */} + {status.recentEvents.length > 0 && ( + + )} +
+ + {/* Collapsible recent events */} + {showEvents && status.recentEvents.length > 0 && ( +
+
+ {status.recentEvents.map((event, idx) => ( +
+ + {formatRelativeTime(event.timestamp)} + + + {event.message} + +
+ ))} +
+
+ )} +
+ ) +} diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index 533c2ab..648e365 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -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(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) { diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 26e9c8b..5f8b9c2 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -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 diff --git a/ui/src/styles/globals.css b/ui/src/styles/globals.css index 5c8199a..e5a2e49 100644 --- a/ui/src/styles/globals.css +++ b/ui/src/styles/globals.css @@ -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; + } } /* ============================================================================