diff --git a/api/status.json b/api/status.json new file mode 100644 index 0000000..e5a9137 --- /dev/null +++ b/api/status.json @@ -0,0 +1,9 @@ +{ + "agents": [ + { "name": "claude", "status": "working", "issue": "Live agent status board (#199)", "prs_today": 3 }, + { "name": "gemini", "status": "idle", "issue": null, "prs_today": 1 }, + { "name": "kimi", "status": "working", "issue": "Portal system YAML registry (#5)", "prs_today": 2 }, + { "name": "groq", "status": "idle", "issue": null, "prs_today": 0 }, + { "name": "grok", "status": "dead", "issue": null, "prs_today": 0 } + ] +} diff --git a/app.js b/app.js index 485320f..6844d2d 100644 --- a/app.js +++ b/app.js @@ -408,6 +408,12 @@ function animate() { banner.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.4; }); + // Animate agent status panels — gentle float + for (const sprite of agentPanelSprites) { + const ud = sprite.userData; + sprite.position.y = ud.baseY + Math.sin(elapsed * ud.floatSpeed + ud.floatPhase) * 0.22; + } + composer.render(); } @@ -558,6 +564,10 @@ window.addEventListener('beforeunload', () => { // === COMMIT BANNERS === const commitBanners = []; +// === AGENT STATUS PANELS (declared early — populated after scene is ready) === +/** @type {THREE.Sprite[]} */ +const agentPanelSprites = []; + /** * Creates a canvas texture for a commit banner. * @param {string} hash - Short commit hash @@ -651,3 +661,169 @@ async function initCommitBanners() { } initCommitBanners(); + +// === AGENT STATUS BOARD === + +const AGENT_STATUS_STUB = { + agents: [ + { name: 'claude', status: 'working', issue: 'Live agent status board (#199)', prs_today: 3 }, + { name: 'gemini', status: 'idle', issue: null, prs_today: 1 }, + { name: 'kimi', status: 'working', issue: 'Portal system YAML registry (#5)', prs_today: 2 }, + { name: 'groq', status: 'idle', issue: null, prs_today: 0 }, + { name: 'grok', status: 'dead', issue: null, prs_today: 0 }, + ] +}; + +const AGENT_STATUS_COLORS = { working: '#00ff88', idle: '#4488ff', dead: '#ff4444' }; + +/** + * Builds a canvas texture for a single agent holo-panel. + * @param {{ name: string, status: string, issue: string|null, prs_today: number }} agent + * @returns {THREE.CanvasTexture} + */ +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'; + + // Dark background + ctx.fillStyle = 'rgba(0, 8, 24, 0.88)'; + ctx.fillRect(0, 0, W, H); + + // Outer border in status color + ctx.strokeStyle = sc; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, W - 2, H - 2); + + // Faint inner border + ctx.strokeStyle = sc; + ctx.lineWidth = 1; + ctx.globalAlpha = 0.3; + ctx.strokeRect(4, 4, W - 8, H - 8); + ctx.globalAlpha = 1.0; + + // Agent name + ctx.font = 'bold 28px "Courier New", monospace'; + ctx.fillStyle = '#ffffff'; + 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 "Courier New", monospace'; + ctx.fillStyle = sc; + ctx.textAlign = 'right'; + ctx.fillText(agent.status.toUpperCase(), W - 16, 60); + ctx.textAlign = 'left'; + + // Separator + ctx.strokeStyle = '#1a3a6a'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(16, 70); + ctx.lineTo(W - 16, 70); + ctx.stroke(); + + // Current issue label + 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); + + // Separator + ctx.strokeStyle = '#1a3a6a'; + ctx.beginPath(); + ctx.moveTo(16, 128); + ctx.lineTo(W - 16, 128); + ctx.stroke(); + + // PRs merged today + 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); + + return new THREE.CanvasTexture(canvas); +} + +/** Group holding all agent panels so they can be toggled/repositioned together. */ +const agentBoardGroup = new THREE.Group(); +scene.add(agentBoardGroup); + +const BOARD_RADIUS = 9.5; // distance from scene origin +const BOARD_Y = 4.2; // height above platform +const BOARD_SPREAD = Math.PI * 0.75; // 135° total arc, centred on negative-Z axis + +/** + * (Re)builds the agent panel sprites from fresh status data. + * @param {{ agents: Array<{ name: string, status: string, issue: string|null, prs_today: number }> }} statusData + */ +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); + // Spread in a semi-circle: angle=PI is directly behind (negative-Z) + 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, + }; + agentBoardGroup.add(sprite); + agentPanelSprites.push(sprite); + }); +} + +/** + * Fetches live agent status, falling back to the stub when the endpoint is unavailable. + * @returns {Promise} + */ +async function fetchAgentStatus() { + try { + const res = await fetch('/api/status.json'); + if (!res.ok) throw new Error('status ' + res.status); + return await res.json(); + } catch { + return AGENT_STATUS_STUB; + } +} + +async function refreshAgentBoard() { + const data = await fetchAgentStatus(); + rebuildAgentPanels(data); +} + +// Initial render, then poll every 30 s +refreshAgentBoard(); +setInterval(refreshAgentBoard, 30000);