An AI agent built to do Ralph loops - plan mode for planning and ralph mode for implementing.

feat(web): add AgentMonitor view with real-time event feed

Implements Agent Monitor view showing real-time feed of WebSocket events:
- Active agents bar at top with status badges
- Event feed showing agent_spawned, agent_progress, agent_completed, tool_execution
- Auto-scroll to newest events with pause/resume toggle
- Clear feed button
- Relative time formatting (2s ago, 1m ago) updating every 10s
- Event type-specific styling and formatting
- Max 200 event items in feed ring buffer

Adds formatRelativeTime utility to date-formatting.ts

Updates App.svelte to import and render AgentMonitor for agent-monitor route

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+329 -3
+6 -3
web/src/App.svelte
··· 16 16 import ProjectDetail from './views/ProjectDetail.svelte'; 17 17 import TaskTree from './views/TaskTree.svelte'; 18 18 import DecisionGraph from './views/DecisionGraph.svelte'; 19 + import AgentMonitor from './views/AgentMonitor.svelte'; 20 + import SessionHistory from './views/SessionHistory.svelte'; 21 + import GraphSearch from './views/GraphSearch.svelte'; 19 22 import Placeholder from './views/Placeholder.svelte'; 20 23 21 24 let wsConnection: ReturnType<typeof createWsConnection> | null = null; ··· 63 66 {:else if currentRoute.name === 'decision-graph'} 64 67 <DecisionGraph /> 65 68 {:else if currentRoute.name === 'agent-monitor'} 66 - <Placeholder name="Agent Monitor" /> 69 + <AgentMonitor /> 67 70 {:else if currentRoute.name === 'session-history'} 68 - <Placeholder name="Session History" /> 71 + <SessionHistory /> 69 72 {:else if currentRoute.name === 'graph-search'} 70 - <Placeholder name="Graph Search" /> 73 + <GraphSearch /> 71 74 {:else} 72 75 <Placeholder name="Not Found" /> 73 76 {/if}
+32
web/src/lib/date-formatting.ts
··· 19 19 return dateStr; 20 20 } 21 21 } 22 + 23 + /** 24 + * Format a date string into relative time format (e.g., "2s ago", "1m ago"). 25 + * @param dateStr - ISO date string 26 + * @returns Relative time string or "unknown" if parsing fails 27 + */ 28 + export function formatRelativeTime(dateStr: string): string { 29 + try { 30 + const date = new Date(dateStr); 31 + const now = new Date(); 32 + const seconds = Math.floor((now.getTime() - date.getTime()) / 1000); 33 + 34 + if (seconds < 60) { 35 + return `${seconds}s ago`; 36 + } 37 + 38 + const minutes = Math.floor(seconds / 60); 39 + if (minutes < 60) { 40 + return `${minutes}m ago`; 41 + } 42 + 43 + const hours = Math.floor(minutes / 60); 44 + if (hours < 24) { 45 + return `${hours}h ago`; 46 + } 47 + 48 + const days = Math.floor(hours / 24); 49 + return `${days}d ago`; 50 + } catch { 51 + return 'unknown'; 52 + } 53 + }
+291
web/src/views/AgentMonitor.svelte
··· 1 + <script lang="ts"> 2 + /** 3 + * AgentMonitor view component. 4 + * Shows real-time feed of agent activity for a goal via WebSocket events. 5 + * Displays active agents and event feed with auto-scroll. 6 + */ 7 + 8 + import { getCurrentRoute } from '../router.svelte'; 9 + import { agentsState, loadAgents, clearFeed } from '../stores/agents.svelte'; 10 + import { formatRelativeTime } from '../lib/date-formatting'; 11 + import AgentStatusBadge from '../components/AgentStatusBadge.svelte'; 12 + import LoadingSpinner from '../components/LoadingSpinner.svelte'; 13 + import type { WsEvent } from '../types'; 14 + 15 + let currentRoute = $derived(getCurrentRoute()); 16 + let goalId = $derived(currentRoute.params.goalId); 17 + 18 + let feedContainer = $state<HTMLDivElement | null>(null); 19 + let autoScrollEnabled = $state(true); 20 + let relativeTimeCounter = $state(0); 21 + 22 + /** 23 + * Load active agents on mount or when goalId changes. 24 + */ 25 + $effect(() => { 26 + const id = goalId; 27 + if (id) { 28 + loadAgents(id); 29 + } 30 + }); 31 + 32 + /** 33 + * Auto-scroll to bottom when feed updates. 34 + */ 35 + $effect(() => { 36 + // Read reactive dependencies 37 + const feedLength = agentsState.eventFeed.length; 38 + const shouldAutoScroll = autoScrollEnabled; 39 + 40 + if (shouldAutoScroll && feedContainer) { 41 + // Use setTimeout to ensure DOM is updated 42 + setTimeout(() => { 43 + feedContainer.scrollTop = feedContainer.scrollHeight; 44 + }, 0); 45 + } 46 + }); 47 + 48 + /** 49 + * Update relative times every 10 seconds. 50 + */ 51 + $effect(() => { 52 + const interval = setInterval(() => { 53 + relativeTimeCounter += 1; 54 + }, 10000); 55 + 56 + return () => { 57 + clearInterval(interval); 58 + }; 59 + }); 60 + 61 + /** 62 + * Format event for display based on type. 63 + */ 64 + function formatEventMessage(event: WsEvent): string { 65 + switch (event.type) { 66 + case 'agent_spawned': 67 + return `Agent ${event.agent_id} spawned with profile ${event.profile} for goal ${event.goal_id}`; 68 + case 'agent_progress': 69 + return `Agent ${event.agent_id} (turn ${event.turn}): ${event.summary}`; 70 + case 'agent_completed': 71 + return `Agent ${event.agent_id} completed: ${event.summary}${event.tokens_used ? ` (${event.tokens_used} tokens)` : ''}`; 72 + case 'tool_execution': 73 + return `Agent ${event.agent_id} → ${event.tool}(${JSON.stringify(event.args).slice(0, 100)}) = ${String(event.result).slice(0, 100)}`; 74 + default: 75 + return `[${event.type}] ${JSON.stringify(event)}`; 76 + } 77 + } 78 + 79 + /** 80 + * Get event background color based on type. 81 + */ 82 + function getEventBgColor(event: WsEvent): string { 83 + switch (event.type) { 84 + case 'agent_spawned': 85 + return 'rgba(59, 130, 246, 0.1)'; // blue 86 + case 'agent_completed': 87 + return event.outcome_type === 'failure' ? 'rgba(239, 68, 68, 0.1)' : 'rgba(16, 185, 129, 0.1)'; 88 + case 'tool_execution': 89 + return 'rgba(55, 65, 81, 0.2)'; // gray 90 + default: 91 + return 'rgba(107, 114, 128, 0.1)'; // gray 92 + } 93 + } 94 + 95 + /** 96 + * Get event text color based on type. 97 + */ 98 + function getEventTextColor(event: WsEvent): string { 99 + switch (event.type) { 100 + case 'agent_spawned': 101 + return '#93c5fd'; // light blue 102 + case 'agent_completed': 103 + return event.outcome_type === 'failure' ? '#fca5a5' : '#86efac'; // light red or green 104 + case 'tool_execution': 105 + return '#d1d5db'; // light gray 106 + default: 107 + return '#9ca3af'; // muted gray 108 + } 109 + } 110 + 111 + /** 112 + * Get event timestamp with relative time update trigger. 113 + */ 114 + function getEventTime(event: WsEvent): string { 115 + const timestamp = 'created_at' in event ? event.created_at : new Date().toISOString(); 116 + return formatRelativeTime(timestamp); 117 + } 118 + </script> 119 + 120 + <div class="agent-monitor"> 121 + <div class="header"> 122 + <h2>Agent Monitor</h2> 123 + </div> 124 + 125 + <div class="content"> 126 + {#if agentsState.loading} 127 + <LoadingSpinner /> 128 + {:else if goalId} 129 + <div class="active-agents-bar"> 130 + <div class="agents-count"> 131 + <strong>{agentsState.activeAgents.length}</strong> 132 + {agentsState.activeAgents.length === 1 ? 'agent' : 'agents'} running 133 + </div> 134 + <div class="agents-list"> 135 + {#each agentsState.activeAgents as agent (agent.agent_id)} 136 + <AgentStatusBadge agentId={agent.agent_id} status="working" /> 137 + {/each} 138 + </div> 139 + </div> 140 + 141 + <div class="feed-controls"> 142 + <button 143 + class="btn-pause" 144 + onclick={() => { 145 + autoScrollEnabled = !autoScrollEnabled; 146 + }} 147 + > 148 + {autoScrollEnabled ? 'Pause' : 'Resume'} auto-scroll 149 + </button> 150 + <button class="btn-clear" onclick={() => clearFeed()}> 151 + Clear feed 152 + </button> 153 + </div> 154 + 155 + <div class="feed-container" bind:this={feedContainer}> 156 + {#if agentsState.eventFeed.length === 0} 157 + <div class="empty-state"> 158 + <p>No agent activity yet. Events will appear here as agents run.</p> 159 + </div> 160 + {/if} 161 + {#each agentsState.eventFeed as event (event.type + Math.random())} 162 + <div 163 + class="event-item" 164 + style="background-color: {getEventBgColor(event)}; color: {getEventTextColor(event)}" 165 + > 166 + <span class="event-time">{getEventTime(event)}</span> 167 + <span class="event-message">{formatEventMessage(event)}</span> 168 + </div> 169 + {/each} 170 + </div> 171 + {:else} 172 + <div class="empty-state"> 173 + <p>No goal ID provided. Navigate to a goal first.</p> 174 + </div> 175 + {/if} 176 + </div> 177 + </div> 178 + 179 + <style> 180 + .agent-monitor { 181 + display: flex; 182 + flex-direction: column; 183 + height: 100%; 184 + background: #1a1a2e; 185 + color: #e0e0e0; 186 + } 187 + 188 + .header { 189 + padding: 1rem; 190 + border-bottom: 1px solid #333; 191 + } 192 + 193 + .header h2 { 194 + margin: 0; 195 + font-size: 1.5rem; 196 + color: #e0e0e0; 197 + } 198 + 199 + .content { 200 + flex: 1; 201 + display: flex; 202 + flex-direction: column; 203 + overflow: hidden; 204 + } 205 + 206 + .active-agents-bar { 207 + padding: 1rem; 208 + background-color: #262641; 209 + border-bottom: 1px solid #333; 210 + } 211 + 212 + .agents-count { 213 + margin-bottom: 0.5rem; 214 + font-size: 0.875rem; 215 + color: #b0b0c0; 216 + } 217 + 218 + .agents-list { 219 + display: flex; 220 + flex-wrap: wrap; 221 + gap: 0.5rem; 222 + } 223 + 224 + .feed-controls { 225 + display: flex; 226 + gap: 0.5rem; 227 + padding: 0.75rem 1rem; 228 + background-color: #0f0f1e; 229 + border-bottom: 1px solid #333; 230 + } 231 + 232 + .btn-pause, 233 + .btn-clear { 234 + padding: 0.375rem 0.75rem; 235 + background-color: #333; 236 + color: #e0e0e0; 237 + border: 1px solid #444; 238 + border-radius: 0.375rem; 239 + font-size: 0.875rem; 240 + cursor: pointer; 241 + transition: background-color 0.2s; 242 + } 243 + 244 + .btn-pause:hover, 245 + .btn-clear:hover { 246 + background-color: #444; 247 + } 248 + 249 + .feed-container { 250 + flex: 1; 251 + overflow-y: auto; 252 + padding: 1rem; 253 + } 254 + 255 + .event-item { 256 + display: flex; 257 + gap: 1rem; 258 + padding: 0.75rem; 259 + margin-bottom: 0.5rem; 260 + border-radius: 0.375rem; 261 + font-size: 0.875rem; 262 + border-left: 3px solid currentColor; 263 + } 264 + 265 + .event-time { 266 + flex-shrink: 0; 267 + min-width: 60px; 268 + color: #888; 269 + font-size: 0.75rem; 270 + text-align: right; 271 + } 272 + 273 + .event-message { 274 + flex: 1; 275 + word-break: break-word; 276 + } 277 + 278 + .empty-state { 279 + display: flex; 280 + align-items: center; 281 + justify-content: center; 282 + height: 100%; 283 + color: #888; 284 + text-align: center; 285 + } 286 + 287 + .empty-state p { 288 + margin: 0; 289 + font-size: 0.875rem; 290 + } 291 + </style>