diff --git a/server/websocket.py b/server/websocket.py index 635094b..713916f 100644 --- a/server/websocket.py +++ b/server/websocket.py @@ -238,31 +238,41 @@ class AgentTracker: } async def _handle_agent_complete(self, feature_id: int, is_success: bool) -> dict | None: - """Handle agent completion message from orchestrator.""" + """Handle agent completion - ALWAYS emits a message, even if agent wasn't tracked.""" async with self._lock: - if feature_id not in self.active_agents: - return None - - agent = self.active_agents[feature_id] state = 'success' if is_success else 'error' - agent_type = agent.get('agent_type', 'coding') - result = { - 'type': 'agent_update', - 'agentIndex': agent['agent_index'], - 'agentName': agent['name'], - 'agentType': agent_type, - 'featureId': feature_id, - 'featureName': agent['feature_name'], - 'state': state, - 'thought': 'Completed successfully!' if is_success else 'Failed to complete', - 'timestamp': datetime.now().isoformat(), - } - - # Remove from active agents - del self.active_agents[feature_id] - - return result + if feature_id in self.active_agents: + # Normal case: agent was tracked + agent = self.active_agents[feature_id] + result = { + 'type': 'agent_update', + 'agentIndex': agent['agent_index'], + 'agentName': agent['name'], + 'agentType': agent.get('agent_type', 'coding'), + 'featureId': feature_id, + 'featureName': agent['feature_name'], + 'state': state, + 'thought': 'Completed successfully!' if is_success else 'Failed to complete', + 'timestamp': datetime.now().isoformat(), + } + del self.active_agents[feature_id] + return result + else: + # Synthetic completion for untracked agent + # This ensures UI always receives completion messages + return { + 'type': 'agent_update', + 'agentIndex': -1, # Sentinel for untracked + 'agentName': 'Unknown', + 'agentType': 'coding', + 'featureId': feature_id, + 'featureName': f'Feature #{feature_id}', + 'state': state, + 'thought': 'Completed successfully!' if is_success else 'Failed to complete', + 'timestamp': datetime.now().isoformat(), + 'synthetic': True, + } class OrchestratorTracker: diff --git a/ui/src/components/AgentAvatar.tsx b/ui/src/components/AgentAvatar.tsx index 3ed4b88..8831016 100644 --- a/ui/src/components/AgentAvatar.tsx +++ b/ui/src/components/AgentAvatar.tsx @@ -1,12 +1,15 @@ import { type AgentMascot, type AgentState } from '../lib/types' interface AgentAvatarProps { - name: AgentMascot + name: AgentMascot | 'Unknown' state: AgentState size?: 'sm' | 'md' | 'lg' showName?: boolean } +// Fallback colors for unknown agents (neutral gray) +const UNKNOWN_COLORS = { primary: '#6B7280', secondary: '#9CA3AF', accent: '#F3F4F6' } + const AVATAR_COLORS: Record = { // Original 5 Spark: { primary: '#3B82F6', secondary: '#60A5FA', accent: '#DBEAFE' }, // Blue robot @@ -473,6 +476,19 @@ function FluxSVG({ colors, size }: { colors: typeof AVATAR_COLORS.Flux; size: nu ) } +// Unknown agent fallback - simple question mark icon +function UnknownSVG({ colors, size }: { colors: typeof UNKNOWN_COLORS; size: number }) { + return ( + + {/* Circle background */} + + + {/* Question mark */} + ? + + ) +} + const MASCOT_SVGS: Record = { // Original 5 Spark: SparkSVG, @@ -561,9 +577,11 @@ function getStateDescription(state: AgentState): string { } export function AgentAvatar({ name, state, size = 'md', showName = false }: AgentAvatarProps) { - const colors = AVATAR_COLORS[name] + // Handle 'Unknown' agents (synthetic completions from untracked agents) + const isUnknown = name === 'Unknown' + const colors = isUnknown ? UNKNOWN_COLORS : AVATAR_COLORS[name] const { svg: svgSize, font } = SIZES[size] - const SvgComponent = MASCOT_SVGS[name] + const SvgComponent = isUnknown ? UnknownSVG : MASCOT_SVGS[name] const stateDesc = getStateDescription(state) const ariaLabel = `Agent ${name} is ${stateDesc}` diff --git a/ui/src/components/CelebrationOverlay.tsx b/ui/src/components/CelebrationOverlay.tsx index a6c9eab..55f9d66 100644 --- a/ui/src/components/CelebrationOverlay.tsx +++ b/ui/src/components/CelebrationOverlay.tsx @@ -4,7 +4,7 @@ import { AgentAvatar } from './AgentAvatar' import type { AgentMascot } from '../lib/types' interface CelebrationOverlayProps { - agentName: AgentMascot + agentName: AgentMascot | 'Unknown' featureName: string onComplete?: () => void } diff --git a/ui/src/components/DependencyGraph.tsx b/ui/src/components/DependencyGraph.tsx index 96f1c72..689892b 100644 --- a/ui/src/components/DependencyGraph.tsx +++ b/ui/src/components/DependencyGraph.tsx @@ -32,7 +32,7 @@ interface DependencyGraphProps { // Agent info to display on a node interface NodeAgentInfo { - name: AgentMascot + name: AgentMascot | 'Unknown' state: AgentState } diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index 648e365..18b117e 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -24,7 +24,7 @@ interface ActivityItem { // Celebration trigger for overlay interface CelebrationTrigger { - agentName: AgentMascot + agentName: AgentMascot | 'Unknown' featureName: string featureId: number } @@ -190,9 +190,18 @@ export function useProjectWebSocket(projectName: string | null) { 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 - ) + if (message.agentIndex === -1) { + // Synthetic completion: remove by featureId + // This handles agents that weren't tracked but still completed + newAgents = prev.activeAgents.filter( + a => a.featureId !== message.featureId + ) + } else { + // Normal completion: remove by agentIndex + newAgents = prev.activeAgents.filter( + a => a.agentIndex !== message.agentIndex + ) + } } else if (existingAgentIdx >= 0) { // Update existing agent newAgents = [...prev.activeAgents] @@ -411,6 +420,27 @@ export function useProjectWebSocket(projectName: string | null) { } }, [projectName, connect, sendPing]) + // Defense-in-depth: cleanup stale agents for users who leave UI open for hours + // This catches edge cases where completion messages are missed + useEffect(() => { + const STALE_THRESHOLD_MS = 30 * 60 * 1000 // 30 minutes + + const cleanup = setInterval(() => { + setState(prev => { + const now = Date.now() + const fresh = prev.activeAgents.filter(a => + now - new Date(a.timestamp).getTime() < STALE_THRESHOLD_MS + ) + if (fresh.length !== prev.activeAgents.length) { + return { ...prev, activeAgents: fresh } + } + return prev + }) + }, 60000) // Check every minute + + return () => clearInterval(cleanup) + }, []) + // Clear logs function const clearLogs = useCallback(() => { setState(prev => ({ ...prev, logs: [] })) diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 5f8b9c2..0189ba0 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -195,8 +195,8 @@ export interface AgentLogEntry { // Agent update from backend export interface ActiveAgent { - agentIndex: number - agentName: AgentMascot + agentIndex: number // -1 for synthetic completions + agentName: AgentMascot | 'Unknown' agentType: AgentType // "coding" or "testing" featureId: number featureName: string @@ -265,14 +265,15 @@ export interface WSLogMessage { export interface WSAgentUpdateMessage { type: 'agent_update' - agentIndex: number - agentName: AgentMascot + agentIndex: number // -1 for synthetic completions (untracked agents) + agentName: AgentMascot | 'Unknown' agentType: AgentType // "coding" or "testing" featureId: number featureName: string state: AgentState thought?: string timestamp: string + synthetic?: boolean // True for synthetic completions from untracked agents } export interface WSAgentStatusMessage {