# Realtime Agents Activity View — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Replace the single-timeline Agents page with per-agent lanes showing live presence, in-progress operations, and scoped event feeds. **Architecture:** Pure frontend change. Replace `createAgentsState()`, `renderAgents()`, `handleAgentsWS()`, and related functions in `app.js`. Add new CSS classes in `style.css`. Reuse existing envelope helpers, WS subscription, and REST API. **Tech Stack:** Vanilla JS, CSS custom properties, existing WebSocket + REST API --- ### Task 1: CSS — Lane layout and active operation styles **Files:** - Modify: `cmd/web-ui/static/style.css` (append after existing agents styles, ~line 1430) **Step 1: Add lane layout CSS** ```css /* ── Agent lanes ──────────────────────────────────────────── */ .agents-summary-row { display: flex; gap: 1.25rem; margin-bottom: 1.25rem; } .agents-summary-stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 0.6rem 1rem; font-family: var(--font-mono); font-size: 0.78rem; color: var(--text-dim); display: flex; align-items: center; gap: 0.5rem; } .agents-summary-stat .value { color: var(--text-bright); font-weight: 600; } .agent-lanes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.25rem; } @media (max-width: 900px) { .agent-lanes { grid-template-columns: 1fr; } } .agent-lane { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); display: flex; flex-direction: column; overflow: hidden; } .agent-lane-header { display: flex; align-items: center; justify-content: space-between; padding: 0.875rem 1.125rem; border-bottom: 1px solid var(--border-soft); } .agent-lane-name { display: flex; align-items: center; gap: 0.5rem; font-family: var(--font-display); font-size: 0.9rem; font-weight: 700; color: var(--text-bright); text-transform: uppercase; letter-spacing: 0.06em; } .agent-lane-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } .agent-lane-dot.online { background: var(--success); box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); animation: livePulse 2s ease-in-out infinite; } .agent-lane-dot.offline { background: var(--text-dim); opacity: 0.5; } .agent-lane-status { font-size: 0.7rem; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; } .agent-lane-status.has-sessions { color: var(--success); } /* ── Active operations ────────────────────────────────────── */ .active-ops { padding: 0.625rem 1.125rem; display: flex; flex-direction: column; gap: 0.375rem; border-bottom: 1px solid var(--border-soft); } .active-op { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.625rem; background: var(--accent-dim); border: 1px solid rgba(34, 211, 238, 0.15); border-radius: var(--radius); font-size: 0.75rem; animation: fadeUp 0.2s ease both; } [data-theme="light"] .active-op { border-color: rgba(8, 145, 178, 0.2); } .active-op.stale { opacity: 0.5; } .active-op-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--success); box-shadow: 0 0 6px rgba(52, 211, 153, 0.5); animation: livePulse 1.5s ease-in-out infinite; flex-shrink: 0; } .active-op.stale .active-op-dot { background: var(--warning); box-shadow: none; animation: none; } .active-op-name { font-family: var(--font-mono); color: var(--accent); font-weight: 500; } .active-op-time { font-family: var(--font-mono); color: var(--text-dim); font-size: 0.7rem; margin-left: auto; } .active-op-stale { color: var(--warning); font-size: 0.65rem; font-weight: 600; } /* ── Lane event feed ──────────────────────────────────────── */ .agent-lane-events { flex: 1; overflow-y: auto; max-height: 520px; padding: 0.625rem; position: relative; } .agent-lane-events::after { content: ''; position: sticky; bottom: 0; left: 0; right: 0; height: 32px; background: linear-gradient(to bottom, transparent, var(--surface)); pointer-events: none; display: block; } .agent-lane-events .timeline-event { padding: 0.5rem 0.625rem; border-radius: var(--radius); margin-bottom: 0.25rem; border: 1px solid var(--border-soft); background: transparent; font-size: 0.82rem; } .agent-lane-events .timeline-event-header { margin-bottom: 0.2rem; } .agent-lane-events .timeline-event-time { font-size: 0.6rem; } .agent-lane-events .empty-state { padding: 2rem 1rem; font-size: 0.78rem; } ``` **Step 2: Commit** ```bash git add cmd/web-ui/static/style.css git commit -m "feat(agents): add CSS for per-agent lane layout and active operation pills" ``` --- ### Task 2: JS — Live state tracker data model **Files:** - Modify: `cmd/web-ui/static/app.js` — replace `createAgentsState()` (~line 665) and add `processAgentEvent()` and `getAgentDisplayOps()` **Step 1: Replace createAgentsState** Replace the existing `createAgentsState()` function (lines ~626-643) with: ```js function createAgentsState() { function agentBucket() { return { sessions: {}, operations: {}, events: [], eventIDs: new Set() }; } return { agents: { zap: agentBucket(), orb: agentBucket(), sun: agentBucket() }, stats: { messages: 0, tools: 0, errors: 0, toolCounts: {} }, timerInterval: null, }; } ``` **Step 2: Add processAgentEvent function** Add after `createAgentsState`: ```js function getAgentBucket(evt) { const name = getVMName(evt).toLowerCase(); return agentsState.agents[name] || null; } function processAgentEvent(evt) { const agent = getAgentBucket(evt); if (!agent) return; const eventType = getEnvelopeType(evt); const correlation = getEnvelopeCorrelation(evt); const attrs = getEnvelopeAttributes(evt); // Track active sessions if (eventType === 'session.start' && correlation.session_id) { agent.sessions[correlation.session_id] = { ts: getEnvelopeTS(evt) }; } if (eventType === 'session.end' && correlation.session_id) { delete agent.sessions[correlation.session_id]; } // Track active operations (span) if (eventType === 'span.start' && correlation.span_id) { agent.operations['s:' + correlation.span_id] = { type: 'span', name: attrs.name || attrs.span_kind || 'unknown', kind: attrs.span_kind || '', startedAt: Date.now(), }; } if (eventType === 'span.end' && correlation.span_id) { delete agent.operations['s:' + correlation.span_id]; } // Track active operations (run) if (eventType === 'run.start' && correlation.run_id) { agent.operations['r:' + correlation.run_id] = { type: 'run', name: 'Thinking\u2026', kind: 'run', startedAt: Date.now(), }; } if (eventType === 'run.end' && correlation.run_id) { delete agent.operations['r:' + correlation.run_id]; } // Add to recent events list const id = getRecordID(evt); if (id && !agent.eventIDs.has(id)) { agent.eventIDs.add(id); agent.events.push(evt); while (agent.events.length > 100) { const removed = agent.events.shift(); agent.eventIDs.delete(getRecordID(removed)); } } } function getAgentDisplayOps(agent) { const ops = Object.values(agent.operations); const hasTools = ops.some(op => op.kind === 'tool'); return hasTools ? ops.filter(op => op.kind === 'tool') : ops; } ``` **Step 3: Update recomputeAgentStats** Replace existing `recomputeAgentStats()` to iterate over all agent buckets: ```js function recomputeAgentStats() { const stats = { messages: 0, tools: 0, errors: 0, toolCounts: {} }; for (const agent of Object.values(agentsState.agents)) { for (const evt of agent.events) { const eventType = getEnvelopeType(evt); const attrs = getEnvelopeAttributes(evt); if (eventType === 'run.start' || eventType === 'run.end') stats.messages++; if (eventType === 'span.end' && attrs.span_kind === 'tool') { stats.tools++; const toolName = attrs.name || 'unknown'; stats.toolCounts[toolName] = (stats.toolCounts[toolName] || 0) + 1; } if (eventType === 'error') stats.errors++; } } agentsState.stats = stats; } ``` **Step 4: Commit** ```bash git add cmd/web-ui/static/app.js git commit -m "feat(agents): add live state tracker with session/operation pairing" ``` --- ### Task 3: JS — Rewrite renderAgents and lane rendering **Files:** - Modify: `cmd/web-ui/static/app.js` — replace `renderAgents()`, `renderAgentTimeline()`, `renderAgentStats()`, `addAgentEvents()` **Step 1: Replace addAgentEvents** Replace existing `addAgentEvents()` with a version that routes to processAgentEvent: ```js function addAgentEvents(events) { let changed = false; for (const evt of events) { const id = getRecordID(evt); const agent = getAgentBucket(evt); if (!id || !agent || agent.eventIDs.has(id)) continue; processAgentEvent(evt); changed = true; } if (changed) { // Sort each agent's events for (const agent of Object.values(agentsState.agents)) { agent.events.sort((a, b) => new Date(getEnvelopeTS(a)).getTime() - new Date(getEnvelopeTS(b)).getTime()); } recomputeAgentStats(); } } ``` **Step 2: Replace renderAgents with lane layout** Replace the existing `renderAgents()` function: ```js async function renderAgents() { agentsState = createAgentsState(); app.innerHTML = `
Loading...
Loading...
Loading...
Error loading agent activity: ${escapeHTML(e.message)}
`; } // Start elapsed timer agentsState.timerInterval = setInterval(updateAgentTimers, 1000); agentsUnsubscribe = subscribeWS(handleAgentsWS); } ``` **Step 3: Add renderAgentLanes** ```js function renderAgentLanes() { const lanesEl = document.getElementById('agents-lanes'); if (!lanesEl) return; const vmNames = ['zap', 'orb', 'sun']; lanesEl.innerHTML = vmNames.map(name => { const agent = agentsState.agents[name]; const vmStatus = getVMStatus().find(v => v.name === name); const isOnline = vmStatus && vmStatus.active; const sessionCount = Object.keys(agent.sessions).length; const ops = getAgentDisplayOps(agent); const statusClass = sessionCount > 0 ? ' has-sessions' : ''; const statusText = !isOnline ? 'offline' : sessionCount > 0 ? sessionCount + ' session' + (sessionCount > 1 ? 's' : '') : 'idle'; const opsHTML = ops.length > 0 ? `No recent activity
'; return `