/** * activity-feed.js — Real-time agent activity feed overlay (Issue #2) * * Polls the Gitea API every 30 s and renders a scrolling list of recent * events (PR opens, PR merges, issue opens, issue closes) in a side panel. * * Configuration (URL params take priority, then env vars, then defaults): * ?gitea=http://host:3000 — Gitea base URL * ?repo=owner/name — repository slug * VITE_GITEA_URL — Gitea base URL env var * VITE_GITEA_REPO — repository slug env var */ const params = new URLSearchParams(window.location.search); const GITEA_URL = params.get('gitea') ?? (import.meta.env.VITE_GITEA_URL || 'http://143.198.27.163:3000'); const GITEA_REPO = params.get('repo') ?? (import.meta.env.VITE_GITEA_REPO || 'rockachopa/the-matrix'); const REFRESH_MS = 30_000; const MAX_ITEMS = 40; // DOM items to keep in the list const SHOWN_ITEMS = 12; // items visible without scrolling /* ── escape helpers (no DOMParser dep) ───────────────────────── */ function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /* ── time formatting ──────────────────────────────────────────── */ function relativeTime(isoString) { const delta = Math.floor((Date.now() - new Date(isoString).getTime()) / 1000); if (delta < 60) return `${delta}s ago`; if (delta < 3600) return `${Math.floor(delta / 60)}m ago`; if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`; return `${Math.floor(delta / 86400)}d ago`; } /* ── build a single feed row ──────────────────────────────────── */ function buildRow(event) { /* * event = { kind, number, title, actor, when } * kind: 'pr_merged' | 'pr_opened' | 'issue_opened' | 'issue_closed' */ const { kind, number, title, actor, when } = event; const icons = { pr_merged: '⟳', pr_opened: '↑', issue_opened: '+', issue_closed: '✓', }; const colors = { pr_merged: '#00ff88', pr_opened: '#00aaff', issue_opened: '#00ff41', issue_closed: '#007722', }; const labels = { pr_merged: 'PR MERGED', pr_opened: 'PR OPENED', issue_opened: 'ISSUE OPENED', issue_closed: 'ISSUE CLOSED', }; const icon = icons[kind] ?? '·'; const color = escapeHtml(colors[kind] ?? '#00ff41'); const label = escapeHtml(labels[kind] ?? kind.toUpperCase()); const safeNum = escapeHtml(String(number)); const safeTitle = escapeHtml(title.length > 40 ? title.slice(0, 38) + '…' : title); const safeActor = escapeHtml(actor); const safeWhen = escapeHtml(when); const row = document.createElement('div'); row.className = 'feed-row'; row.innerHTML = `${icon}` + `` + `${label}` + ` #${safeNum}` + `${safeTitle}` + `${safeActor} · ${safeWhen}` + ``; return row; } /* ── normalise API responses → feed events ────────────────────── */ function pullsToEvents(pulls) { const events = []; for (const p of pulls) { if (p.merged) { events.push({ kind: 'pr_merged', number: p.number, title: p.title, actor: p.merged_by?.login ?? p.user?.login ?? '?', when: relativeTime(p.merged_at), ts: new Date(p.merged_at).getTime(), }); } else if (p.state === 'open') { events.push({ kind: 'pr_opened', number: p.number, title: p.title, actor: p.user?.login ?? '?', when: relativeTime(p.created_at), ts: new Date(p.created_at).getTime(), }); } } return events; } function issuesToEvents(issues) { const events = []; for (const i of issues) { if (i.state === 'closed') { events.push({ kind: 'issue_closed', number: i.number, title: i.title, actor: i.assignee?.login ?? i.user?.login ?? '?', when: relativeTime(i.updated_at), ts: new Date(i.updated_at).getTime(), }); } else { events.push({ kind: 'issue_opened', number: i.number, title: i.title, actor: i.assignee?.login ?? i.user?.login ?? '?', when: relativeTime(i.created_at), ts: new Date(i.created_at).getTime(), }); } } return events; } /* ── Gitea API fetch ──────────────────────────────────────────── */ async function fetchEvents() { const base = `${GITEA_URL}/api/v1/repos/${GITEA_REPO}`; const headers = { Accept: 'application/json' }; const [pullsRes, issuesRes] = await Promise.all([ fetch(`${base}/pulls?state=all&limit=20`, { headers }), fetch(`${base}/issues?state=all&limit=20&type=issues`, { headers }), ]); if (!pullsRes.ok || !issuesRes.ok) throw new Error('Gitea API error'); const [pulls, issues] = await Promise.all([pullsRes.json(), issuesRes.json()]); const events = [ ...pullsToEvents(Array.isArray(pulls) ? pulls : []), ...issuesToEvents(Array.isArray(issues) ? issues : []), ]; events.sort((a, b) => b.ts - a.ts); return events.slice(0, MAX_ITEMS); } /* ── DOM management ───────────────────────────────────────────── */ let $feed = null; let $status = null; let rowsInDom = []; function renderEvents(events) { if (!$feed) return; // Clear while ($feed.firstChild) $feed.removeChild($feed.firstChild); rowsInDom = []; for (const ev of events.slice(0, MAX_ITEMS)) { const row = buildRow(ev); $feed.appendChild(row); rowsInDom.push(row); } // Scroll to top (newest first) $feed.scrollTop = 0; } function setFeedStatus(text) { if ($status) $status.textContent = text; } /* ── poll loop ────────────────────────────────────────────────── */ let _pollTimer = null; async function poll() { setFeedStatus('SYNCING...'); try { const events = await fetchEvents(); renderEvents(events); setFeedStatus(`LIVE · ${events.length} events`); } catch (err) { setFeedStatus('ERR · retrying'); console.warn('[activity-feed] fetch error:', err); } } /* ── public init ──────────────────────────────────────────────── */ export function initActivityFeed() { $feed = document.getElementById('activity-feed-list'); $status = document.getElementById('activity-feed-status'); if (!$feed) return; // element not in DOM poll(); _pollTimer = setInterval(poll, REFRESH_MS); } export function disposeActivityFeed() { if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } }