[claude] Add agent activity feed overlay (#2) #15
90
index.html
90
index.html
@@ -147,12 +147,91 @@
|
|||||||
}
|
}
|
||||||
#connection-status.connected { color: #00ff41; text-shadow: 0 0 6px #00ff41; }
|
#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 */
|
/* Safe area padding for notched devices */
|
||||||
@supports (padding: env(safe-area-inset-top)) {
|
@supports (padding: env(safe-area-inset-top)) {
|
||||||
#hud { top: calc(16px + env(safe-area-inset-top)); left: calc(16px + env(safe-area-inset-left)); }
|
#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)); }
|
#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)); }
|
#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)); }
|
#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) */
|
/* Stack status panel below HUD on narrow viewports (must come AFTER @supports) */
|
||||||
@@ -182,6 +261,17 @@
|
|||||||
<button id="chat-clear-btn" title="Clear chat history" style="position:fixed;bottom:60px;right:16px;background:transparent;border:1px solid #003300;color:#00aa00;font-family:monospace;font-size:.7rem;padding:2px 6px;cursor:pointer;z-index:20;opacity:.6">✕ CLEAR</button>
|
<button id="chat-clear-btn" title="Clear chat history" style="position:fixed;bottom:60px;right:16px;background:transparent;border:1px solid #003300;color:#00aa00;font-family:monospace;font-size:.7rem;padding:2px 6px;cursor:pointer;z-index:20;opacity:.6">✕ CLEAR</button>
|
||||||
<div id="bark-container"></div>
|
<div id="bark-container"></div>
|
||||||
<div id="connection-status">OFFLINE</div>
|
<div id="connection-status">OFFLINE</div>
|
||||||
|
|
||||||
|
<!-- Activity feed overlay (#2) -->
|
||||||
|
<div id="activity-feed">
|
||||||
|
<div id="activity-feed-header">
|
||||||
|
<span>AGENT ACTIVITY</span>
|
||||||
|
<span id="activity-feed-status">LOADING...</span>
|
||||||
|
<button id="activity-feed-close" title="Collapse feed" style="background:transparent;border:none;color:#007722;font-family:monospace;font-size:10px;cursor:pointer;padding:0 2px">✕</button>
|
||||||
|
</div>
|
||||||
|
<div id="activity-feed-list"></div>
|
||||||
|
</div>
|
||||||
|
<button id="activity-feed-toggle">ACTIVITY</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="chat-input-bar">
|
<div id="chat-input-bar">
|
||||||
<input id="chat-input" type="text" placeholder="Say something to the Workshop..." autocomplete="off" />
|
<input id="chat-input" type="text" placeholder="Say something to the Workshop..." autocomplete="off" />
|
||||||
|
|||||||
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; }
|
||||||
|
}
|
||||||
13
js/main.js
13
js/main.js
@@ -8,6 +8,7 @@ import { initUI, updateUI } from './ui.js';
|
|||||||
import { initInteraction, updateControls, disposeInteraction } from './interaction.js';
|
import { initInteraction, updateControls, disposeInteraction } from './interaction.js';
|
||||||
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
|
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
|
||||||
import { initVisitor } from './visitor.js';
|
import { initVisitor } from './visitor.js';
|
||||||
|
import { initActivityFeed } from './activity-feed.js';
|
||||||
|
|
||||||
let running = false;
|
let running = false;
|
||||||
let canvas = null;
|
let canvas = null;
|
||||||
@@ -38,6 +39,18 @@ function buildWorld(firstInit, stateSnapshot) {
|
|||||||
initUI();
|
initUI();
|
||||||
initWebSocket(scene);
|
initWebSocket(scene);
|
||||||
initVisitor();
|
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
|
// Dismiss loading screen
|
||||||
const loadingScreen = document.getElementById('loading-screen');
|
const loadingScreen = document.getElementById('loading-screen');
|
||||||
|
|||||||
Reference in New Issue
Block a user