# 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 = `
ZAP

Loading...

ORB

Loading...

SUN

Loading...

`; try { const [snapshots, events] = await Promise.all([ api('/v1/events?event_type=openclaw.snapshot&limit=100').catch(() => ({ events: [] })), api('/v1/events?framework=openclaw&limit=200'), ]); if (!isCurrentPath('/agents')) return; mergeOpenClawEvents(snapshots.events || []); addAgentEvents((events.events || []).slice().reverse()); renderAgentLanes(); renderAgentSummary(); } catch (e) { document.getElementById('agents-lanes').innerHTML = `

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 ? `
${ops.map(op => { const elapsed = Math.floor((Date.now() - op.startedAt) / 1000); const stale = elapsed > 300; return `
${escapeHTML(op.name)} ${formatElapsed(elapsed)} ${stale ? '(stale?)' : ''}
`; }).join('')}
` : ''; const recent = agent.events.slice(-40).reverse(); const eventsHTML = recent.length > 0 ? recent.map(evt => { const eventType = getEnvelopeType(evt); const vmClass = getVMClassName(name); const details = getEventDetails(evt); const detailHTML = details ? `
${escapeHTML(details)}
` : ''; const expandHTML = details ? '' : ''; return `
${getEventIcon(eventType)} ${escapeHTML(getEventLabel(eventType))} ${escapeHTML(new Date(getEnvelopeTS(evt)).toLocaleTimeString())}
${getEventBody(evt)} ${expandHTML} ${detailHTML}
`; }).join('') : '

No recent activity

'; return `
${escapeHTML(name.toUpperCase())}
${statusText}
${opsHTML}
${eventsHTML}
`; }).join(''); // Wire up detail expand buttons lanesEl.querySelectorAll('.timeline-expand-hint').forEach(button => { button.addEventListener('click', () => { button.parentElement.classList.toggle('expanded'); }); }); } ``` **Step 4: Add formatElapsed helper** Add near the other format helpers (around line 202): ```js function formatElapsed(seconds) { if (seconds < 60) return seconds + 's'; if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's'; return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm'; } ``` **Step 5: Add renderAgentSummary** ```js function renderAgentSummary() { const el = document.getElementById('agents-summary'); if (!el) return; const s = agentsState.stats; el.innerHTML = `
Messages ${s.messages}
Tool Calls ${s.tools}
Errors ${s.errors}
`; } ``` **Step 6: Commit** ```bash git add cmd/web-ui/static/app.js git commit -m "feat(agents): per-agent lane layout with active operations and scoped event feeds" ``` --- ### Task 4: JS — WS handler and elapsed timer **Files:** - Modify: `cmd/web-ui/static/app.js` — replace `handleAgentsWS()`, add `updateAgentTimers()` **Step 1: Replace handleAgentsWS** ```js function handleAgentsWS(msg) { if (msg.type !== 'message') return; const eventType = getEnvelopeType(msg.data); if (eventType === 'openclaw.snapshot') { mergeOpenClawEvents([msg.data]); renderAgentLanes(); return; } const framework = getEnvelopeSource(msg.data).framework || msg.data.source_framework; if (framework !== 'openclaw') return; addAgentEvents([msg.data]); renderAgentLanes(); renderAgentSummary(); } ``` **Step 2: Add updateAgentTimers** ```js function updateAgentTimers() { document.querySelectorAll('.active-op-time[data-start]').forEach(el => { const start = parseInt(el.dataset.start, 10); if (!start) return; const elapsed = Math.floor((Date.now() - start) / 1000); el.textContent = formatElapsed(elapsed); // Check stale const op = el.closest('.active-op'); if (op && elapsed > 300 && !op.classList.contains('stale')) { op.classList.add('stale'); if (!op.querySelector('.active-op-stale')) { op.insertAdjacentHTML('beforeend', '(stale?)'); } } }); } ``` **Step 3: Clean up timer on navigation** In `cleanupLiveViews()`, add timer cleanup: ```js if (agentsState && agentsState.timerInterval) { clearInterval(agentsState.timerInterval); agentsState.timerInterval = null; } ``` **Step 4: Remove old functions that are no longer needed** Delete: `renderAgentTimeline()`, `renderAgentStats()` (replaced by `renderAgentLanes()` and `renderAgentSummary()`), `renderAgentVMStrip()` (VM status is now in lane headers). **Step 5: Commit** ```bash git add cmd/web-ui/static/app.js git commit -m "feat(agents): WS handler, elapsed timer, and stale detection" ```