diff --git a/index.html b/index.html
index 6e51dd4..5944a44 100644
--- a/index.html
+++ b/index.html
@@ -159,6 +159,95 @@
@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); }
+ }
@@ -183,6 +272,35 @@
diff --git a/js/agent-defs.js b/js/agent-defs.js
index d5572a3..8ff0620 100644
--- a/js/agent-defs.js
+++ b/js/agent-defs.js
@@ -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 },
- { 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 },
+ { 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 },
];
/**
diff --git a/js/agent-panel.js b/js/agent-panel.js
new file mode 100644
index 0000000..ccfb4ff
--- /dev/null
+++ b/js/agent-panel.js
@@ -0,0 +1,202 @@
+/**
+ * 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 = '
LOADING...';
+ $issue.innerHTML = loading;
+ $branch.innerHTML = loading;
+ $commits.innerHTML = loading;
+ $pr.innerHTML = loading;
+
+ $panel.classList.add('visible');
+
+ if (!Config.giteaUrl) {
+ const msg = '
Gitea URL not configured';
+ $issue.innerHTML = msg;
+ $branch.innerHTML = msg;
+ $commits.innerHTML = msg;
+ $pr.innerHTML = msg;
+ return;
+ }
+
+ if (!def.gitLogin) {
+ const msg = '
No Gitea user mapped to this agent';
+ $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 = '
UNAVAILABLE';
+ $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 = `
PR #${recent.number} (merged): ${esc(recent.title)}`;
+ } else {
+ $pr.innerHTML = '
No open PR';
+ }
+ $issue.innerHTML = '
No active issue';
+ $branch.innerHTML = '
—';
+ $commits.innerHTML = '
—';
+ return;
+ }
+
+ // PR found
+ $pr.innerHTML = `
PR #${pr.number}: ${esc(pr.title)}`;
+
+ const branch = pr.head?.ref || pr.head?.label?.split(':')[1] || '';
+ $branch.innerHTML = branch
+ ? `
${esc(branch)}`
+ : '
—';
+
+ // 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 = `
#${issue.number}: ${esc(issue.title)}`;
+ } else {
+ $issue.innerHTML = `
Issue #${issueMatch[1]}`;
+ }
+ } else {
+ $issue.innerHTML = '
No linked issue';
+ }
+
+ // 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 = '
No commits';
+ } else {
+ $commits.innerHTML = commits.map(c => {
+ const sha = (c.sha || '').slice(0, 7);
+ const msg = (c.commit?.message || '').split('\n')[0];
+ return `
${esc(sha)}${esc(msg)}
`;
+ }).join('');
+ }
+ } else {
+ $commits.innerHTML = '
Could not load commits';
+ }
+ } else {
+ $commits.innerHTML = '
—';
+ }
+}
+
+function esc(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
diff --git a/js/agents.js b/js/agents.js
index dcef57c..9d13ae3 100644
--- a/js/agents.js
+++ b/js/agents.js
@@ -31,6 +31,7 @@ class Agent {
this.group = new THREE.Group();
this.group.position.copy(this.position);
+ this.group.userData.agentId = this.id;
this._buildMeshes();
this._buildLabel();
@@ -174,6 +175,10 @@ 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,
diff --git a/js/config.js b/js/config.js
index a263c70..2e2f8a0 100644
--- a/js/config.js
+++ b/js/config.js
@@ -35,6 +35,15 @@ 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,
diff --git a/js/interaction.js b/js/interaction.js
index ae76212..23f14db 100644
--- a/js/interaction.js
+++ b/js/interaction.js
@@ -1,3 +1,4 @@
+import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
let controls;
@@ -29,3 +30,66 @@ 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;
+}
diff --git a/js/main.js b/js/main.js
index a9185e2..92f553b 100644
--- a/js/main.js
+++ b/js/main.js
@@ -1,13 +1,14 @@
import { initWorld, onWindowResize, disposeWorld } from './world.js';
import {
initAgents, updateAgents, getAgentCount,
- disposeAgents, getAgentStates, applyAgentStates,
+ disposeAgents, getAgentStates, applyAgentStates, getAgentGroups,
} from './agents.js';
import { initEffects, updateEffects, disposeEffects } from './effects.js';
import { initUI, updateUI } from './ui.js';
-import { initInteraction, updateControls, disposeInteraction } from './interaction.js';
+import { initInteraction, updateControls, disposeInteraction, initClickDetection } from './interaction.js';
import { initWebSocket, getConnectionState, getJobCount } from './websocket.js';
import { initVisitor } from './visitor.js';
+import { initAgentPanel, showAgentPanel } from './agent-panel.js';
let running = false;
let canvas = null;
@@ -33,11 +34,13 @@ function buildWorld(firstInit, stateSnapshot) {
}
initInteraction(camera, renderer);
+ initClickDetection(camera, renderer, getAgentGroups(), showAgentPanel);
if (firstInit) {
initUI();
initWebSocket(scene);
initVisitor();
+ initAgentPanel();
// Dismiss loading screen
const loadingScreen = document.getElementById('loading-screen');