// modules/panels/agent-board.js — Agent status board with canvas textures import * as THREE from 'three'; import { fetchAgentStatus } from '../data/gitea.js'; import { state } from '../core/state.js'; const AGENT_STATUS_COLORS = { working: '#00ff88', idle: '#4488ff', dormant: '#334466', dead: '#ff4444', unreachable: '#ff4444' }; const BOARD_RADIUS = 9.5; const BOARD_Y = 4.2; const BOARD_SPREAD = Math.PI * 0.75; const AGENT_STATUS_CACHE_MS = 5 * 60 * 1000; let agentBoardGroup; const agentPanelSprites = []; function createAgentPanelTexture(agent) { const W = 400, H = 200; const canvas = document.createElement('canvas'); canvas.width = W; canvas.height = H; const ctx = canvas.getContext('2d'); const sc = AGENT_STATUS_COLORS[agent.status] || '#4488ff'; 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.strokeStyle = sc; ctx.lineWidth = 1; ctx.globalAlpha = 0.3; ctx.strokeRect(4, 4, W - 8, H - 8); ctx.globalAlpha = 1.0; ctx.font = 'bold 28px "Courier New", monospace'; ctx.fillStyle = '#ffffff'; ctx.fillText(agent.name.toUpperCase(), 16, 44); ctx.beginPath(); ctx.arc(W - 30, 26, 10, 0, Math.PI * 2); ctx.fillStyle = sc; ctx.fill(); ctx.font = '13px "Courier New", monospace'; ctx.fillStyle = sc; ctx.textAlign = 'right'; ctx.fillText(agent.status.toUpperCase(), W - 16, 60); ctx.textAlign = 'left'; ctx.strokeStyle = '#1a3a6a'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(16, 70); ctx.lineTo(W - 16, 70); ctx.stroke(); ctx.font = '10px "Courier New", monospace'; ctx.fillStyle = '#556688'; ctx.fillText('CURRENT ISSUE', 16, 90); ctx.font = '13px "Courier New", monospace'; ctx.fillStyle = '#ccd6f6'; const issueText = agent.issue || '\u2014 none \u2014'; const displayIssue = issueText.length > 40 ? issueText.slice(0, 40) + '\u2026' : issueText; ctx.fillText(displayIssue, 16, 110); ctx.strokeStyle = '#1a3a6a'; ctx.beginPath(); ctx.moveTo(16, 128); ctx.lineTo(W - 16, 128); ctx.stroke(); ctx.font = '10px "Courier New", monospace'; ctx.fillStyle = '#556688'; ctx.fillText('PRs MERGED TODAY', 16, 148); ctx.font = 'bold 28px "Courier New", monospace'; ctx.fillStyle = '#4488ff'; ctx.fillText(String(agent.prs_today), 16, 182); const isLocal = agent.local === true; const indicatorColor = isLocal ? '#00ff88' : '#ff4444'; const indicatorLabel = isLocal ? 'LOCAL' : 'CLOUD'; ctx.font = '10px "Courier New", monospace'; ctx.fillStyle = '#556688'; ctx.textAlign = 'right'; ctx.fillText('RUNTIME', W - 16, 148); ctx.font = 'bold 13px "Courier New", monospace'; ctx.fillStyle = indicatorColor; ctx.fillText(indicatorLabel, W - 28, 172); ctx.textAlign = 'left'; ctx.beginPath(); ctx.arc(W - 16, 167, 6, 0, Math.PI * 2); ctx.fillStyle = indicatorColor; ctx.fill(); return new THREE.CanvasTexture(canvas); } function rebuildAgentPanels(statusData) { while (agentBoardGroup.children.length) agentBoardGroup.remove(agentBoardGroup.children[0]); agentPanelSprites.length = 0; const n = statusData.agents.length; statusData.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 = createAgentPanelTexture(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}` }; agentBoardGroup.add(sprite); agentPanelSprites.push(sprite); }); } export function init(scene) { agentBoardGroup = new THREE.Group(); scene.add(agentBoardGroup); refreshAgentBoard(); setInterval(refreshAgentBoard, AGENT_STATUS_CACHE_MS); } async function refreshAgentBoard() { let data; try { data = await fetchAgentStatus(); } catch { data = { agents: ['Claude', 'Kimi', 'Perplexity', 'Groq', 'Grok', 'Ollama'].map(n => ({ name: n.toLowerCase(), status: 'unreachable', issue: null, prs_today: 0, local: false, })) }; } rebuildAgentPanels(data); state.activeAgentCount = data.agents.filter(a => a.status === 'working').length; } export function update(elapsed) { for (const sprite of agentPanelSprites) { const ud = sprite.userData; sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22; } }