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 @@
OFFLINE
+ +
+
+
+
AGENT
+
ROLE
+
+ +
+
+
+
CURRENT ISSUE
+
+
+
+
BRANCH
+
+
+
+
PULL REQUEST
+
+
+
+
RECENT COMMITS
+
+
+
+
+
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');