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
+
+
+
+
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');