diff --git a/index.html b/index.html index 6e51dd4..957be13 100644 --- a/index.html +++ b/index.html @@ -147,12 +147,91 @@ } #connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; } + /* ── Activity feed overlay (#2) ── */ + #activity-feed { + position: fixed; top: 170px; right: 16px; + width: clamp(220px, 28vw, 300px); + max-height: calc(100vh - 280px); + display: flex; flex-direction: column; + background: rgba(0, 5, 0, 0.72); + border: 1px solid #003300; + border-radius: 2px; + z-index: 10; + pointer-events: auto; + backdrop-filter: blur(2px); + } + #activity-feed-header { + display: flex; align-items: center; justify-content: space-between; + padding: 5px 10px; + border-bottom: 1px solid #003300; + color: #007722; font-size: clamp(8px, 1vw, 10px); letter-spacing: 2px; + flex-shrink: 0; + } + #activity-feed-status { + color: #004400; font-size: clamp(7px, 0.9vw, 9px); letter-spacing: 1px; + } + #activity-feed-list { + overflow-y: auto; overflow-x: hidden; + flex: 1; + padding: 4px 0; + scrollbar-width: thin; + scrollbar-color: #003300 transparent; + } + #activity-feed-list::-webkit-scrollbar { width: 4px; } + #activity-feed-list::-webkit-scrollbar-thumb { background: #003300; } + .feed-row { + display: flex; align-items: flex-start; gap: 6px; + padding: 4px 10px; + border-bottom: 1px solid rgba(0, 40, 0, 0.4); + line-height: 1.4; + } + .feed-row:last-child { border-bottom: none; } + .feed-icon { + font-size: clamp(9px, 1.1vw, 11px); + flex-shrink: 0; margin-top: 1px; + } + .feed-body { + display: flex; flex-direction: column; gap: 1px; min-width: 0; + } + .feed-label { + font-size: clamp(7px, 0.9vw, 9px); letter-spacing: 1px; font-weight: bold; + } + .feed-num { + color: #005500; font-size: clamp(7px, 0.9vw, 9px); + } + .feed-title { + display: block; + color: #00cc33; font-size: clamp(8px, 1vw, 10px); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + max-width: 100%; + } + .feed-meta { + display: block; + color: #004400; font-size: clamp(7px, 0.85vw, 9px); + } + /* toggle button */ + #activity-feed-toggle { + position: fixed; right: 16px; top: 170px; + width: 18px; + background: rgba(0,5,0,0.8); border: 1px solid #003300; + color: #007722; font-family: 'Courier New', monospace; + font-size: 10px; cursor: pointer; z-index: 11; + writing-mode: vertical-rl; text-orientation: mixed; + padding: 6px 2px; letter-spacing: 2px; + pointer-events: auto; + display: none; /* shown only when feed is collapsed */ + } + #activity-feed.collapsed { display: none; } + #activity-feed.collapsed ~ #activity-feed-toggle { display: block; } + /* Safe area padding for notched devices */ @supports (padding: env(safe-area-inset-top)) { #hud { top: calc(16px + env(safe-area-inset-top)); left: calc(16px + env(safe-area-inset-left)); } #status-panel { top: calc(16px + env(safe-area-inset-top)); right: calc(16px + env(safe-area-inset-right)); } #chat-panel { bottom: calc(52px + env(safe-area-inset-bottom)); left: calc(16px + env(safe-area-inset-left)); right: calc(16px + env(safe-area-inset-right)); } #connection-status { bottom: calc(52px + env(safe-area-inset-bottom)); right: calc(16px + env(safe-area-inset-right)); } + #activity-feed { right: calc(16px + env(safe-area-inset-right)); } + #activity-feed-toggle { right: calc(16px + env(safe-area-inset-right)); } } /* Stack status panel below HUD on narrow viewports (must come AFTER @supports) */ @@ -182,6 +261,17 @@
OFFLINE
+ + +
+
+ AGENT ACTIVITY + LOADING... + +
+
+
+
diff --git a/js/activity-feed.js b/js/activity-feed.js new file mode 100644 index 0000000..a0a023c --- /dev/null +++ b/js/activity-feed.js @@ -0,0 +1,218 @@ +/** + * 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; } +} diff --git a/js/main.js b/js/main.js index a9185e2..70ffa90 100644 --- a/js/main.js +++ b/js/main.js @@ -8,6 +8,7 @@ import { initUI, updateUI } from './ui.js'; import { initInteraction, updateControls, disposeInteraction } from './interaction.js'; import { initWebSocket, getConnectionState, getJobCount } from './websocket.js'; import { initVisitor } from './visitor.js'; +import { initActivityFeed } from './activity-feed.js'; let running = false; let canvas = null; @@ -38,6 +39,18 @@ function buildWorld(firstInit, stateSnapshot) { initUI(); initWebSocket(scene); initVisitor(); + initActivityFeed(); + + // Activity feed collapse/expand toggle + const $feedPanel = document.getElementById('activity-feed'); + const $feedClose = document.getElementById('activity-feed-close'); + const $feedToggle = document.getElementById('activity-feed-toggle'); + if ($feedClose && $feedPanel) { + $feedClose.addEventListener('click', () => $feedPanel.classList.add('collapsed')); + } + if ($feedToggle && $feedPanel) { + $feedToggle.addEventListener('click', () => $feedPanel.classList.remove('collapsed')); + } // Dismiss loading screen const loadingScreen = document.getElementById('loading-screen');