Some checks failed
Deploy Nexus / deploy (push) Failing after 4s
Co-authored-by: Claude (Opus 4.6) <claude@hermes.local> Co-committed-by: Claude (Opus 4.6) <claude@hermes.local>
192 lines
5.4 KiB
JavaScript
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);
|
|
}
|