forked from Rockachopa/the-matrix
Compare commits
1 Commits
claude/iss
...
claude/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd840cc33 |
208
index.html
208
index.html
@@ -147,107 +147,97 @@
|
||||
}
|
||||
#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) */
|
||||
@media (max-width: 500px) {
|
||||
#status-panel { top: 100px !important; left: 16px; right: auto; }
|
||||
}
|
||||
|
||||
/* ── Agent info panel (#8) ── */
|
||||
#agent-panel {
|
||||
position: fixed; top: 50%; right: -360px;
|
||||
transform: translateY(-50%);
|
||||
width: 320px; max-height: 80vh;
|
||||
background: rgba(0, 8, 0, 0.92);
|
||||
border: 1px solid #003300;
|
||||
border-right: none;
|
||||
color: #00ff41;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: clamp(10px, 1.3vw, 12px);
|
||||
z-index: 30;
|
||||
pointer-events: auto;
|
||||
transition: right 0.3s ease;
|
||||
display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
#agent-panel.visible { right: 0; }
|
||||
|
||||
.ap-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid #002200;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#ap-agent-name {
|
||||
font-size: clamp(14px, 2vw, 18px);
|
||||
font-weight: bold;
|
||||
letter-spacing: 3px;
|
||||
text-shadow: 0 0 10px currentColor;
|
||||
}
|
||||
#ap-agent-role {
|
||||
color: #007722;
|
||||
font-size: clamp(9px, 1vw, 11px);
|
||||
letter-spacing: 2px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
#agent-panel-close {
|
||||
background: transparent;
|
||||
border: 1px solid #003300;
|
||||
color: #005500;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
#agent-panel-close:hover { color: #00ff41; border-color: #00ff41; }
|
||||
|
||||
.ap-body {
|
||||
padding: 12px 16px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.ap-section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.ap-label {
|
||||
color: #005500;
|
||||
font-size: clamp(8px, 1vw, 10px);
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ap-loading { color: #004400; }
|
||||
.ap-error { color: #aa2200; }
|
||||
.ap-dim { color: #004400; }
|
||||
.ap-code { color: #00aaff; }
|
||||
.ap-link { color: #00ff88; text-decoration: none; word-break: break-word; }
|
||||
.ap-link:hover { text-decoration: underline; }
|
||||
.ap-commit {
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid #001800;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.ap-sha {
|
||||
color: #007722;
|
||||
margin-right: 6px;
|
||||
font-size: clamp(9px, 1vw, 11px);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
#agent-panel { width: 85vw; max-height: 70vh; }
|
||||
}
|
||||
@supports (padding: env(safe-area-inset-right)) {
|
||||
#agent-panel.visible { right: env(safe-area-inset-right, 0px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -271,36 +261,18 @@
|
||||
<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="connection-status">OFFLINE</div>
|
||||
</div>
|
||||
<!-- Agent info panel (Issue #8) -->
|
||||
<div id="agent-panel">
|
||||
<div class="ap-header">
|
||||
<div>
|
||||
<div id="ap-agent-name">AGENT</div>
|
||||
<div id="ap-agent-role">ROLE</div>
|
||||
</div>
|
||||
<button id="agent-panel-close" title="Close [Esc]">✕</button>
|
||||
</div>
|
||||
<div class="ap-body">
|
||||
<div class="ap-section">
|
||||
<div class="ap-label">CURRENT ISSUE</div>
|
||||
<div id="ap-issue"><span class="ap-dim">—</span></div>
|
||||
</div>
|
||||
<div class="ap-section">
|
||||
<div class="ap-label">BRANCH</div>
|
||||
<div id="ap-branch"><span class="ap-dim">—</span></div>
|
||||
</div>
|
||||
<div class="ap-section">
|
||||
<div class="ap-label">PULL REQUEST</div>
|
||||
<div id="ap-pr"><span class="ap-dim">—</span></div>
|
||||
</div>
|
||||
<div class="ap-section">
|
||||
<div class="ap-label">RECENT COMMITS</div>
|
||||
<div id="ap-commits"><span class="ap-dim">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</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 id="chat-input-bar">
|
||||
<input id="chat-input" type="text" placeholder="Say something to the Workshop..." autocomplete="off" />
|
||||
<button id="chat-send">></button>
|
||||
|
||||
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,10 +13,10 @@
|
||||
* x, z — world-space position on the horizontal plane (y is always 0)
|
||||
*/
|
||||
export const AGENT_DEFS = [
|
||||
{ id: 'alpha', label: 'ALPHA', color: 0x00ff88, role: 'orchestrator', direction: 'north', x: 0, z: -6, gitLogin: null },
|
||||
{ id: 'beta', label: 'BETA', color: 0x00aaff, role: 'worker', direction: 'east', x: 6, z: 0, gitLogin: null },
|
||||
{ id: 'gamma', label: 'GAMMA', color: 0xff6600, role: 'validator', direction: 'south', x: 0, z: 6, gitLogin: null },
|
||||
{ id: 'delta', label: 'DELTA', color: 0xaa00ff, role: 'monitor', direction: 'west', x: -6, z: 0, gitLogin: null },
|
||||
{ id: 'alpha', label: 'ALPHA', color: 0x00ff88, role: 'orchestrator', direction: 'north', x: 0, z: -6 },
|
||||
{ id: 'beta', label: 'BETA', color: 0x00aaff, role: 'worker', direction: 'east', x: 6, z: 0 },
|
||||
{ id: 'gamma', label: 'GAMMA', color: 0xff6600, role: 'validator', direction: 'south', x: 0, z: 6 },
|
||||
{ id: 'delta', label: 'DELTA', color: 0xaa00ff, role: 'monitor', direction: 'west', x: -6, z: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* agent-panel.js — Click-to-view-PR panel (Issue #8).
|
||||
*
|
||||
* Shows a panel when an agent is clicked in the 3D world, displaying:
|
||||
* - Current open issue
|
||||
* - Active branch
|
||||
* - Recent commits
|
||||
* - Link to open PR on Gitea
|
||||
*
|
||||
* Requires VITE_GITEA_URL (and optionally VITE_GITEA_TOKEN, VITE_GITEA_REPO)
|
||||
* to be set. Agents must have a `gitLogin` field in AGENT_DEFS to enable
|
||||
* Gitea lookups; if absent the panel shows a placeholder.
|
||||
*/
|
||||
|
||||
import { AGENT_DEFS, colorToCss } from './agent-defs.js';
|
||||
import { Config } from './config.js';
|
||||
|
||||
let $panel = null;
|
||||
let $name = null;
|
||||
let $role = null;
|
||||
let $issue = null;
|
||||
let $branch = null;
|
||||
let $commits = null;
|
||||
let $pr = null;
|
||||
let cleanupFn = null; // AbortController cleanup for in-flight fetches
|
||||
|
||||
export function initAgentPanel() {
|
||||
$panel = document.getElementById('agent-panel');
|
||||
$name = document.getElementById('ap-agent-name');
|
||||
$role = document.getElementById('ap-agent-role');
|
||||
$issue = document.getElementById('ap-issue');
|
||||
$branch = document.getElementById('ap-branch');
|
||||
$commits = document.getElementById('ap-commits');
|
||||
$pr = document.getElementById('ap-pr');
|
||||
|
||||
const $close = document.getElementById('agent-panel-close');
|
||||
if ($close) $close.addEventListener('click', hideAgentPanel);
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') hideAgentPanel();
|
||||
});
|
||||
}
|
||||
|
||||
export function showAgentPanel(agentId) {
|
||||
if (!$panel) return;
|
||||
|
||||
const def = AGENT_DEFS.find(d => d.id === agentId);
|
||||
if (!def) return;
|
||||
|
||||
// Cancel any previous in-flight request
|
||||
if (cleanupFn) { cleanupFn(); cleanupFn = null; }
|
||||
|
||||
const cssColor = colorToCss(def.color);
|
||||
$name.textContent = def.label;
|
||||
$name.style.color = cssColor;
|
||||
$role.textContent = def.role.toUpperCase();
|
||||
|
||||
// Show loading state
|
||||
const loading = '<span class="ap-loading">LOADING...</span>';
|
||||
$issue.innerHTML = loading;
|
||||
$branch.innerHTML = loading;
|
||||
$commits.innerHTML = loading;
|
||||
$pr.innerHTML = loading;
|
||||
|
||||
$panel.classList.add('visible');
|
||||
|
||||
if (!Config.giteaUrl) {
|
||||
const msg = '<span class="ap-dim">Gitea URL not configured</span>';
|
||||
$issue.innerHTML = msg;
|
||||
$branch.innerHTML = msg;
|
||||
$commits.innerHTML = msg;
|
||||
$pr.innerHTML = msg;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!def.gitLogin) {
|
||||
const msg = '<span class="ap-dim">No Gitea user mapped to this agent</span>';
|
||||
$issue.innerHTML = msg;
|
||||
$branch.innerHTML = msg;
|
||||
$commits.innerHTML = msg;
|
||||
$pr.innerHTML = msg;
|
||||
return;
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
cleanupFn = () => ac.abort();
|
||||
|
||||
fetchAgentData(def, ac.signal).catch(err => {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.warn('[AgentPanel] Fetch error:', err);
|
||||
const msg = '<span class="ap-error">UNAVAILABLE</span>';
|
||||
$issue.innerHTML = msg;
|
||||
$branch.innerHTML = msg;
|
||||
$commits.innerHTML = msg;
|
||||
$pr.innerHTML = msg;
|
||||
});
|
||||
}
|
||||
|
||||
export function hideAgentPanel() {
|
||||
if (!$panel) return;
|
||||
if (cleanupFn) { cleanupFn(); cleanupFn = null; }
|
||||
$panel.classList.remove('visible');
|
||||
}
|
||||
|
||||
/* ── Gitea data fetching ──────────────────────────────────────── */
|
||||
|
||||
async function fetchAgentData(def, signal) {
|
||||
const base = Config.giteaUrl.replace(/\/$/, '');
|
||||
const repo = Config.giteaRepo;
|
||||
const headers = {};
|
||||
if (Config.giteaToken) headers['Authorization'] = 'token ' + Config.giteaToken;
|
||||
|
||||
const api = `${base}/api/v1`;
|
||||
|
||||
// Fetch open PRs for this repo, look for ones authored by the agent user
|
||||
const prRes = await fetch(`${api}/repos/${repo}/pulls?state=open&limit=20`, { headers, signal });
|
||||
if (!prRes.ok) throw new Error(`PR list failed: ${prRes.status}`);
|
||||
const pulls = await prRes.json();
|
||||
|
||||
const pr = pulls.find(p =>
|
||||
p.user?.login === def.gitLogin ||
|
||||
p.head?.label?.startsWith(def.gitLogin + ':')
|
||||
);
|
||||
|
||||
if (!pr) {
|
||||
// No open PR — try looking for recently closed ones
|
||||
const closedRes = await fetch(
|
||||
`${api}/repos/${repo}/pulls?state=closed&limit=5`,
|
||||
{ headers, signal }
|
||||
);
|
||||
const closed = closedRes.ok ? await closedRes.json() : [];
|
||||
const recent = closed.find(p =>
|
||||
p.user?.login === def.gitLogin ||
|
||||
p.head?.label?.startsWith(def.gitLogin + ':')
|
||||
);
|
||||
|
||||
if (recent) {
|
||||
$pr.innerHTML = `<a href="${esc(recent.html_url)}" target="_blank" rel="noopener" class="ap-link">PR #${recent.number} (merged): ${esc(recent.title)}</a>`;
|
||||
} else {
|
||||
$pr.innerHTML = '<span class="ap-dim">No open PR</span>';
|
||||
}
|
||||
$issue.innerHTML = '<span class="ap-dim">No active issue</span>';
|
||||
$branch.innerHTML = '<span class="ap-dim">—</span>';
|
||||
$commits.innerHTML = '<span class="ap-dim">—</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
// PR found
|
||||
$pr.innerHTML = `<a href="${esc(pr.html_url)}" target="_blank" rel="noopener" class="ap-link">PR #${pr.number}: ${esc(pr.title)}</a>`;
|
||||
|
||||
const branch = pr.head?.ref || pr.head?.label?.split(':')[1] || '';
|
||||
$branch.innerHTML = branch
|
||||
? `<span class="ap-code">${esc(branch)}</span>`
|
||||
: '<span class="ap-dim">—</span>';
|
||||
|
||||
// Extract issue number from branch (e.g. claude/issue-8 → 8)
|
||||
const issueMatch = branch.match(/issue[/-](\d+)/i);
|
||||
if (issueMatch) {
|
||||
const issueRes = await fetch(`${api}/repos/${repo}/issues/${issueMatch[1]}`, { headers, signal });
|
||||
if (issueRes.ok) {
|
||||
const issue = await issueRes.json();
|
||||
$issue.innerHTML = `<a href="${esc(issue.html_url)}" target="_blank" rel="noopener" class="ap-link">#${issue.number}: ${esc(issue.title)}</a>`;
|
||||
} else {
|
||||
$issue.innerHTML = `<span class="ap-dim">Issue #${issueMatch[1]}</span>`;
|
||||
}
|
||||
} else {
|
||||
$issue.innerHTML = '<span class="ap-dim">No linked issue</span>';
|
||||
}
|
||||
|
||||
// Recent commits on the branch
|
||||
if (branch) {
|
||||
const commitsRes = await fetch(
|
||||
`${api}/repos/${repo}/commits?sha=${encodeURIComponent(branch)}&limit=5`,
|
||||
{ headers, signal }
|
||||
);
|
||||
if (commitsRes.ok) {
|
||||
const commits = await commitsRes.json();
|
||||
if (!commits.length) {
|
||||
$commits.innerHTML = '<span class="ap-dim">No commits</span>';
|
||||
} else {
|
||||
$commits.innerHTML = commits.map(c => {
|
||||
const sha = (c.sha || '').slice(0, 7);
|
||||
const msg = (c.commit?.message || '').split('\n')[0];
|
||||
return `<div class="ap-commit"><span class="ap-sha">${esc(sha)}</span>${esc(msg)}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
} else {
|
||||
$commits.innerHTML = '<span class="ap-dim">Could not load commits</span>';
|
||||
}
|
||||
} else {
|
||||
$commits.innerHTML = '<span class="ap-dim">—</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
@@ -31,7 +31,6 @@ class Agent {
|
||||
|
||||
this.group = new THREE.Group();
|
||||
this.group.position.copy(this.position);
|
||||
this.group.userData.agentId = this.id;
|
||||
|
||||
this._buildMeshes();
|
||||
this._buildLabel();
|
||||
@@ -175,10 +174,6 @@ export function setAgentState(agentId, state) {
|
||||
if (agent) agent.setState(state);
|
||||
}
|
||||
|
||||
export function getAgentGroups() {
|
||||
return [...agents.values()].map(a => a.group);
|
||||
}
|
||||
|
||||
export function getAgentDefs() {
|
||||
return [...agents.values()].map(a => ({
|
||||
id: a.id, label: a.label, role: a.role, color: a.color, state: a.state,
|
||||
|
||||
@@ -35,15 +35,6 @@ export const Config = Object.freeze({
|
||||
/** Force mock mode even if wsUrl is set. Useful for local dev. */
|
||||
mockMode: param('mock', 'VITE_MOCK_MODE', 'false') === 'true',
|
||||
|
||||
/** Gitea base URL (no trailing slash), e.g. http://gitea.example.com */
|
||||
giteaUrl: param('gitea', 'VITE_GITEA_URL', ''),
|
||||
|
||||
/** Gitea API token for authenticated requests (read-only is sufficient). */
|
||||
giteaToken: param('gitea_token', 'VITE_GITEA_TOKEN', ''),
|
||||
|
||||
/** Gitea repo in owner/repo format, e.g. rockachopa/the-matrix */
|
||||
giteaRepo: param('gitea_repo', 'VITE_GITEA_REPO', 'rockachopa/the-matrix'),
|
||||
|
||||
/** Reconnection timing */
|
||||
reconnectBaseMs: 2000,
|
||||
reconnectMaxMs: 30000,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
|
||||
let controls;
|
||||
@@ -30,66 +29,3 @@ export function disposeInteraction() {
|
||||
controls = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up pointer-based click detection for agent meshes.
|
||||
* Distinguishes clicks from orbit-control drags (pointer must move < 6px).
|
||||
*
|
||||
* @param {THREE.Camera} camera
|
||||
* @param {THREE.WebGLRenderer} renderer
|
||||
* @param {THREE.Object3D[]} agentGroups — array of agent group objects to raycast against
|
||||
* @param {function} onAgentClick — called with agentId string on hit
|
||||
* @returns {function} cleanup — call to remove event listeners
|
||||
*/
|
||||
export function initClickDetection(camera, renderer, agentGroups, onAgentClick) {
|
||||
const raycaster = new THREE.Raycaster();
|
||||
const pointer = new THREE.Vector2();
|
||||
let downX = 0, downY = 0;
|
||||
|
||||
function toNDC(clientX, clientY) {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
return {
|
||||
x: ((clientX - rect.left) / rect.width) * 2 - 1,
|
||||
y: -((clientY - rect.top) / rect.height) * 2 + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function onPointerDown(e) {
|
||||
downX = e.clientX;
|
||||
downY = e.clientY;
|
||||
}
|
||||
|
||||
function onPointerUp(e) {
|
||||
const dx = e.clientX - downX;
|
||||
const dy = e.clientY - downY;
|
||||
if (dx * dx + dy * dy > 36) return; // drag threshold 6px
|
||||
|
||||
const ndc = toNDC(e.clientX, e.clientY);
|
||||
pointer.set(ndc.x, ndc.y);
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
|
||||
const hits = raycaster.intersectObjects(agentGroups, true);
|
||||
if (hits.length > 0) {
|
||||
const agentId = findAgentId(hits[0].object);
|
||||
if (agentId) onAgentClick(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
const el = renderer.domElement;
|
||||
el.addEventListener('pointerdown', onPointerDown);
|
||||
el.addEventListener('pointerup', onPointerUp);
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('pointerdown', onPointerDown);
|
||||
el.removeEventListener('pointerup', onPointerUp);
|
||||
};
|
||||
}
|
||||
|
||||
function findAgentId(object) {
|
||||
let o = object;
|
||||
while (o) {
|
||||
if (o.userData?.agentId) return o.userData.agentId;
|
||||
o = o.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
20
js/main.js
20
js/main.js
@@ -1,14 +1,14 @@
|
||||
import { initWorld, onWindowResize, disposeWorld } from './world.js';
|
||||
import {
|
||||
initAgents, updateAgents, getAgentCount,
|
||||
disposeAgents, getAgentStates, applyAgentStates, getAgentGroups,
|
||||
disposeAgents, getAgentStates, applyAgentStates,
|
||||
} from './agents.js';
|
||||
import { initEffects, updateEffects, disposeEffects } from './effects.js';
|
||||
import { initUI, updateUI } from './ui.js';
|
||||
import { initInteraction, updateControls, disposeInteraction, initClickDetection } from './interaction.js';
|
||||
import { initInteraction, updateControls, disposeInteraction } from './interaction.js';
|
||||
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
|
||||
import { initVisitor } from './visitor.js';
|
||||
import { initAgentPanel, showAgentPanel } from './agent-panel.js';
|
||||
import { initActivityFeed } from './activity-feed.js';
|
||||
|
||||
let running = false;
|
||||
let canvas = null;
|
||||
@@ -34,13 +34,23 @@ function buildWorld(firstInit, stateSnapshot) {
|
||||
}
|
||||
|
||||
initInteraction(camera, renderer);
|
||||
initClickDetection(camera, renderer, getAgentGroups(), showAgentPanel);
|
||||
|
||||
if (firstInit) {
|
||||
initUI();
|
||||
initWebSocket(scene);
|
||||
initVisitor();
|
||||
initAgentPanel();
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user