Files
the-nexus/modules/panels/agent-board.js
Claude (Opus 4.6) 481a0790d2
Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
[claude] Phase 3: Panel modules — Heatmap, Agent Board, Dual-Brain, LoRA, Sovereignty, Earth (#422) (#446)
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local>
Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
2026-03-24 18:22:34 +00:00

192 lines
5.4 KiB
JavaScript

// modules/panels/agent-board.js — Agent status holographic board
// Reads state.agentStatus (populated by data/gitea.js) and renders one floating
// sprite panel per agent. Board arcs behind the platform on the negative-Z side.
//
// Data category: REAL
// Data source: state.agentStatus (Gitea commits + open PRs via data/gitea.js)
import * as THREE from 'three';
import { state } from '../core/state.js';
import { NEXUS } from '../core/theme.js';
import { subscribe } from '../core/ticker.js';
const BOARD_RADIUS = 9.5;
const BOARD_Y = 4.2;
const BOARD_SPREAD = Math.PI * 0.75; // 135° arc, centred on -Z
const STATUS_COLOR = {
working: NEXUS.theme.agentWorking,
idle: NEXUS.theme.agentIdle,
dormant: NEXUS.theme.agentDormant,
dead: NEXUS.theme.agentDead,
unreachable: NEXUS.theme.agentDead,
};
let _group, _scene;
let _lastAgentStatus = null;
let _sprites = [];
/**
* Builds a canvas texture for a single agent holo-panel.
* @param {{ name: string, status: string, issue: string|null, prs_today: number, local: boolean }} agent
* @returns {THREE.CanvasTexture}
*/
function _makeTexture(agent) {
const W = 400, H = 200;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d');
const sc = STATUS_COLOR[agent.status] || NEXUS.theme.accentStr;
const font = NEXUS.theme.fontMono;
ctx.fillStyle = 'rgba(0, 8, 24, 0.88)';
ctx.fillRect(0, 0, W, H);
ctx.strokeStyle = sc;
ctx.lineWidth = 2;
ctx.strokeRect(1, 1, W - 2, H - 2);
ctx.globalAlpha = 0.3;
ctx.strokeRect(4, 4, W - 8, H - 8);
ctx.globalAlpha = 1.0;
// Agent name
ctx.font = `bold 28px ${font}`;
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'left';
ctx.fillText(agent.name.toUpperCase(), 16, 44);
// Status dot
ctx.beginPath();
ctx.arc(W - 30, 26, 10, 0, Math.PI * 2);
ctx.fillStyle = sc;
ctx.fill();
// Status label
ctx.font = `13px ${font}`;
ctx.fillStyle = sc;
ctx.textAlign = 'right';
ctx.fillText(agent.status.toUpperCase(), W - 16, 60);
// Separator
ctx.strokeStyle = NEXUS.theme.panelBorderFaint;
ctx.lineWidth = 1;
ctx.textAlign = 'left';
ctx.beginPath(); ctx.moveTo(16, 70); ctx.lineTo(W - 16, 70); ctx.stroke();
// Current issue
ctx.font = `10px ${font}`;
ctx.fillStyle = NEXUS.theme.panelDim;
ctx.fillText('CURRENT ISSUE', 16, 90);
ctx.font = `13px ${font}`;
ctx.fillStyle = NEXUS.theme.panelText;
const raw = agent.issue || '\u2014 none \u2014';
ctx.fillText(raw.length > 40 ? raw.slice(0, 40) + '\u2026' : raw, 16, 110);
// Separator
ctx.strokeStyle = NEXUS.theme.panelBorderFaint;
ctx.beginPath(); ctx.moveTo(16, 128); ctx.lineTo(W - 16, 128); ctx.stroke();
// PRs label + count
ctx.font = `10px ${font}`;
ctx.fillStyle = NEXUS.theme.panelDim;
ctx.fillText('PRs MERGED TODAY', 16, 148);
ctx.font = `bold 28px ${font}`;
ctx.fillStyle = NEXUS.theme.accentStr;
ctx.fillText(String(agent.prs_today), 16, 182);
// Runtime indicator
const isLocal = agent.local === true;
const rtColor = isLocal ? NEXUS.theme.agentWorking : NEXUS.theme.agentDead;
const rtLabel = isLocal ? 'LOCAL' : 'CLOUD';
ctx.font = `10px ${font}`;
ctx.fillStyle = NEXUS.theme.panelDim;
ctx.textAlign = 'right';
ctx.fillText('RUNTIME', W - 16, 148);
ctx.font = `bold 13px ${font}`;
ctx.fillStyle = rtColor;
ctx.fillText(rtLabel, W - 28, 172);
ctx.textAlign = 'left';
ctx.beginPath();
ctx.arc(W - 16, 167, 6, 0, Math.PI * 2);
ctx.fillStyle = rtColor;
ctx.fill();
return new THREE.CanvasTexture(canvas);
}
function _rebuild(statusData) {
// Remove old sprites
while (_group.children.length) _group.remove(_group.children[0]);
for (const s of _sprites) {
if (s.material.map) s.material.map.dispose();
s.material.dispose();
}
_sprites = [];
const agents = statusData.agents;
const n = agents.length;
agents.forEach((agent, i) => {
const t = n === 1 ? 0.5 : i / (n - 1);
const angle = Math.PI + (t - 0.5) * BOARD_SPREAD;
const x = Math.cos(angle) * BOARD_RADIUS;
const z = Math.sin(angle) * BOARD_RADIUS;
const texture = _makeTexture(agent);
const material = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.93, depthWrite: false });
const sprite = new THREE.Sprite(material);
sprite.scale.set(6.4, 3.2, 1);
sprite.position.set(x, BOARD_Y, z);
sprite.userData = {
baseY: BOARD_Y,
floatPhase: (i / n) * Math.PI * 2,
floatSpeed: 0.18 + i * 0.04,
zoomLabel: `Agent: ${agent.name}`,
};
_group.add(sprite);
_sprites.push(sprite);
});
}
/** @param {THREE.Scene} scene */
export function init(scene) {
_scene = scene;
_group = new THREE.Group();
scene.add(_group);
// If state already has agent data (unlikely on first load, but handle it)
if (state.agentStatus) {
_rebuild(state.agentStatus);
_lastAgentStatus = state.agentStatus;
}
subscribe(update);
}
/**
* @param {number} elapsed
* @param {number} delta
*/
export function update(elapsed, delta) {
// Rebuild board when state.agentStatus changes
if (state.agentStatus && state.agentStatus !== _lastAgentStatus) {
_rebuild(state.agentStatus);
_lastAgentStatus = state.agentStatus;
}
// Animate gentle float
for (const sprite of _sprites) {
const ud = sprite.userData;
sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.15;
}
}
export function dispose() {
if (_group) _scene.remove(_group);
}