forked from Rockachopa/the-matrix
feat: add agent activity feed overlay with Gitea API polling (Fixes #2)
Adds a scrolling right-side panel showing real-time agent work pulled from the Gitea API. Auto-refreshes every 30 s. Displays PR opens, PR merges, issue opens, and issue closes with relative timestamps and colour-coded event types. Panel is collapsible; collapses to a narrow vertical toggle button. Gitea base URL and repo slug are configurable via URL params (?gitea=, ?repo=) or Vite env vars (VITE_GITEA_URL, VITE_GITEA_REPO) with defaults pointing at the live Gitea instance. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
218
js/activity-feed.js
Normal file
218
js/activity-feed.js
Normal file
@@ -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, '"')
|
||||
.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 =
|
||||
`<span class="feed-icon" style="color:${color}">${icon}</span>` +
|
||||
`<span class="feed-body">` +
|
||||
`<span class="feed-label" style="color:${color}">${label}</span>` +
|
||||
` <span class="feed-num">#${safeNum}</span>` +
|
||||
`<span class="feed-title">${safeTitle}</span>` +
|
||||
`<span class="feed-meta">${safeActor} · ${safeWhen}</span>` +
|
||||
`</span>`;
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user