[claude] Live agent status board — 3D floating holo-panels (#199) (#218)
Some checks failed
Deploy Nexus / deploy (push) Has been cancelled

This commit was merged in pull request #218.
This commit is contained in:
2026-03-24 04:32:58 +00:00
parent b61f651226
commit 2ba19f4bc3
2 changed files with 185 additions and 0 deletions

9
api/status.json Normal file
View File

@@ -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 }
]
}

176
app.js
View File

@@ -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<typeof AGENT_STATUS_STUB>}
*/
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);