feat: orchestration room UI — side panels, bob animation, demo sequencer
- Three-column layout: left panel (conductor/secretary), canvas, right panel
(5 agent presets). Canvas CSS-scales to fill available width.
- Side badges: opacity 0.3 when agent not alive, full when alive.
Dot color: green=alive, orange=toolcall (fast blink), blue=waiting,
yellow=talking.
- Bob animation: 4-frame cycle [0,-1,-2,-1]px, speed set per state:
idle 450ms/frame (1.8s cycle, calm)
waiting 650ms/frame (2.6s cycle, lazy — LLM response pending)
toolcall 90ms/frame (360ms cycle, rapid — tool executing)
talking 280ms/frame
- Speech bubbles: yellow box with pixel tail, auto-expire.
- Status ring: orange halo on toolcall, blue halo on waiting.
- Demo sequencer plays a full scenario (spawn → toolcall → wait →
converse → gc → repeat) to demonstrate all states without a live
WebSocket connection.
WebSocket event contract (for future backend wiring):
{ type: "agent_spawn", id, task }
{ type: "agent_state", id, state } // toolcall | waiting | idle
{ type: "conversation", from, to, text }
{ type: "agent_gc", id }
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
932a4149e5
commit
02e5b6b645
1 changed files with 416 additions and 56 deletions
|
|
@ -3,10 +3,10 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Orchestration Room — Preview</title>
|
||||
<title>Orchestration Room</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Silkscreen&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #060810;
|
||||
|
|
@ -15,83 +15,443 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
padding: 16px 0;
|
||||
font-family: 'Silkscreen', monospace;
|
||||
}
|
||||
|
||||
/* ── three-column layout ── */
|
||||
.room-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding-top: 24px;
|
||||
width: 44px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
.badge.alive { opacity: 1; }
|
||||
.badge.talking { opacity: 1; }
|
||||
|
||||
.badge-emoji { font-size: 18px; line-height: 1; }
|
||||
|
||||
.badge-label {
|
||||
font-size: 7px;
|
||||
color: #3a4259;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.badge.alive .badge-label { color: #4a6ac0; }
|
||||
.badge.talking .badge-label { color: #facc15; }
|
||||
|
||||
.badge-dot {
|
||||
width: 4px; height: 4px;
|
||||
border-radius: 50%;
|
||||
background: #1a1f2e;
|
||||
}
|
||||
.badge.alive .badge-dot { background: #4ade80; }
|
||||
.badge.toolcall .badge-dot { background: #fb923c; animation: blink 0.2s step-end infinite; }
|
||||
.badge.waiting .badge-dot { background: #60a5fa; }
|
||||
.badge.talking .badge-dot { background: #facc15; animation: blink 0.6s step-end infinite; }
|
||||
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
/* ── canvas ── */
|
||||
.canvas-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
canvas {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
/* 2× for retina preview */
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
border: 1px solid #1a1f2e;
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.legend {
|
||||
font-family: 'Silkscreen', monospace;
|
||||
font-size: 9px;
|
||||
color: #3a4259;
|
||||
/* ── demo state indicator (bottom) ── */
|
||||
.demo-bar {
|
||||
margin-top: 10px;
|
||||
font-size: 8px;
|
||||
color: #2a3259;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.legend span { color: #64748b; }
|
||||
.demo-bar span { color: #3a4a80; }
|
||||
.demo-bar span.on { color: #4ade80; }
|
||||
</style>
|
||||
<script src="map.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="c" width="320" height="320"></canvas>
|
||||
|
||||
<div class="legend">
|
||||
<span>👑 conductor</span>
|
||||
<span>👩💼 secretary</span>
|
||||
<span>🔍 scout</span>
|
||||
<span>📊 analyst</span>
|
||||
<span>💻 coder</span>
|
||||
<span>🔧 worker</span>
|
||||
<span>🎯 coordinator</span>
|
||||
<span>🚪 door</span>
|
||||
<div class="room-row">
|
||||
|
||||
<!-- left panel: permanent characters -->
|
||||
<div class="side-panel" id="panel-left">
|
||||
<div class="badge alive" id="badge-conductor" data-id="conductor">
|
||||
<div class="badge-emoji">👑</div>
|
||||
<div class="badge-label">CNDR</div>
|
||||
<div class="badge-dot"></div>
|
||||
</div>
|
||||
<div class="badge alive" id="badge-secretary" data-id="secretary">
|
||||
<div class="badge-emoji">👩💼</div>
|
||||
<div class="badge-label">SEC</div>
|
||||
<div class="badge-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- canvas -->
|
||||
<div class="canvas-wrap">
|
||||
<canvas id="c" width="320" height="320"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- right panel: subagent presets -->
|
||||
<div class="side-panel" id="panel-right">
|
||||
<div class="badge" id="badge-s0" data-id="s0">
|
||||
<div class="badge-emoji">🔍</div>
|
||||
<div class="badge-label">SCOUT</div>
|
||||
<div class="badge-dot"></div>
|
||||
</div>
|
||||
<div class="badge" id="badge-s1" data-id="s1">
|
||||
<div class="badge-emoji">📊</div>
|
||||
<div class="badge-label">ANLY</div>
|
||||
<div class="badge-dot"></div>
|
||||
</div>
|
||||
<div class="badge" id="badge-s2" data-id="s2">
|
||||
<div class="badge-emoji">💻</div>
|
||||
<div class="badge-label">CODE</div>
|
||||
<div class="badge-dot"></div>
|
||||
</div>
|
||||
<div class="badge" id="badge-s3" data-id="s3">
|
||||
<div class="badge-emoji">🔧</div>
|
||||
<div class="badge-label">WRKR</div>
|
||||
<div class="badge-dot"></div>
|
||||
</div>
|
||||
<div class="badge" id="badge-s4" data-id="s4">
|
||||
<div class="badge-emoji">🎯</div>
|
||||
<div class="badge-label">CORD</div>
|
||||
<div class="badge-dot"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="demo-bar">
|
||||
<span id="demo-label">demo: idle</span>
|
||||
<span>⬡ fast = toolcall</span>
|
||||
<span>⬡ slow = llm wait</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
// ─── canvas setup ─────────────────────────────────────────────────────────
|
||||
var canvas = document.getElementById('c');
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
var EMOJIS = {
|
||||
conductor: '👑',
|
||||
secretary: '👩💼',
|
||||
meeting: null,
|
||||
stations: ['🔍', '📊', '💻', '🔧', '🎯'],
|
||||
// ─── bob frames: 4-step cycle [down, mid, up, mid] ───────────────────────
|
||||
// カクカクした待機モーション — yオフセット (px, canvas logical units)
|
||||
var BOB = [0, -1, -2, -1];
|
||||
|
||||
// frame duration per state (ms/frame → full cycle = 4×)
|
||||
var FRAME_MS = {
|
||||
idle: 450, // 1.8s cycle — very calm
|
||||
waiting: 650, // 2.6s cycle — lazy, LLM response pending
|
||||
toolcall: 90, // 360ms cycle — rapid, tool executing
|
||||
talking: 280, // 1.1s cycle — attentive
|
||||
entering: 220,
|
||||
exiting: 220,
|
||||
};
|
||||
|
||||
function drawCharacters() {
|
||||
ctx.font = '20px serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
// move speed px/s
|
||||
var WALK_SPEED = 55;
|
||||
|
||||
// permanent characters
|
||||
ctx.fillText(EMOJIS.conductor, MAP_POSITIONS.conductor.x, MAP_POSITIONS.conductor.y);
|
||||
ctx.fillText(EMOJIS.secretary, MAP_POSITIONS.secretary.x, MAP_POSITIONS.secretary.y);
|
||||
// ─── character definitions ────────────────────────────────────────────────
|
||||
var PRESETS = [
|
||||
{ id: 's0', emoji: '🔍', label: 'scout' },
|
||||
{ id: 's1', emoji: '📊', label: 'analyst' },
|
||||
{ id: 's2', emoji: '💻', label: 'coder' },
|
||||
{ id: 's3', emoji: '🔧', label: 'worker' },
|
||||
{ id: 's4', emoji: '🎯', label: 'coordinator' },
|
||||
];
|
||||
|
||||
// agents at their stations
|
||||
MAP_POSITIONS.stations.forEach(function(pos, i) {
|
||||
ctx.fillText(EMOJIS.stations[i], pos.x, pos.y);
|
||||
});
|
||||
|
||||
// door marker
|
||||
ctx.font = '14px serif';
|
||||
ctx.fillText('🚪', MAP_POSITIONS.door.x, MAP_POSITIONS.door.y - 8);
|
||||
function makeChar(id, emoji, home) {
|
||||
return {
|
||||
id: id,
|
||||
emoji: emoji,
|
||||
x: home.x,
|
||||
y: home.y,
|
||||
home: home,
|
||||
target: null,
|
||||
state: 'idle',
|
||||
frame: 0,
|
||||
frameTimer:0,
|
||||
bubble: null, // { text, ttl }
|
||||
alive: false,
|
||||
};
|
||||
}
|
||||
|
||||
// load map.png if it exists, otherwise use procedural fallback
|
||||
loadMapAsset(function() {
|
||||
var conductor = makeChar('conductor', '👑', MAP_POSITIONS.conductor);
|
||||
var secretary = makeChar('secretary', '👩💼', MAP_POSITIONS.secretary);
|
||||
conductor.alive = true;
|
||||
secretary.alive = true;
|
||||
|
||||
// subagents start at door (off-screen), not alive
|
||||
var agents = PRESETS.map(function(p, i) {
|
||||
var c = makeChar(p.id, p.emoji, MAP_POSITIONS.stations[i]);
|
||||
c.x = MAP_POSITIONS.door.x;
|
||||
c.y = MAP_POSITIONS.door.y;
|
||||
return c;
|
||||
});
|
||||
|
||||
function allChars() {
|
||||
return [conductor, secretary].concat(agents);
|
||||
}
|
||||
|
||||
// ─── character helpers ────────────────────────────────────────────────────
|
||||
function setState(c, state) {
|
||||
c.state = state;
|
||||
syncBadge(c.id, state, c.alive);
|
||||
}
|
||||
|
||||
function moveTo(c, pos, onArrive) {
|
||||
c.target = pos;
|
||||
c._onArrive = onArrive || null;
|
||||
}
|
||||
|
||||
function say(c, text, ttl) {
|
||||
c.bubble = { text: text, ttl: ttl || 2200 };
|
||||
}
|
||||
|
||||
function syncBadge(id, state, alive) {
|
||||
var el = document.getElementById('badge-' + id);
|
||||
if (!el) return;
|
||||
el.className = 'badge'
|
||||
+ (alive ? ' alive' : '')
|
||||
+ (state === 'talking' ? ' talking' : '')
|
||||
+ (state === 'toolcall' ? ' toolcall' : '')
|
||||
+ (state === 'waiting' ? ' waiting' : '');
|
||||
}
|
||||
|
||||
// ─── update loop ──────────────────────────────────────────────────────────
|
||||
var lastTs = null;
|
||||
|
||||
function update(dt) {
|
||||
allChars().forEach(function(c) {
|
||||
if (!c.alive && c.state !== 'entering') return;
|
||||
|
||||
// advance bob frame
|
||||
c.frameTimer += dt;
|
||||
var dur = FRAME_MS[c.state] || 450;
|
||||
if (c.frameTimer >= dur) {
|
||||
c.frame = (c.frame + 1) % 4;
|
||||
c.frameTimer -= dur;
|
||||
}
|
||||
|
||||
// move toward target
|
||||
if (c.target) {
|
||||
var dx = c.target.x - c.x;
|
||||
var dy = c.target.y - c.y;
|
||||
var dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist > 1.5) {
|
||||
var spd = WALK_SPEED * dt / 1000;
|
||||
c.x += dx / dist * spd;
|
||||
c.y += dy / dist * spd;
|
||||
} else {
|
||||
c.x = c.target.x;
|
||||
c.y = c.target.y;
|
||||
c.target = null;
|
||||
if (c._onArrive) { c._onArrive(); c._onArrive = null; }
|
||||
}
|
||||
}
|
||||
|
||||
// bubble timeout
|
||||
if (c.bubble) {
|
||||
c.bubble.ttl -= dt;
|
||||
if (c.bubble.ttl <= 0) c.bubble = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── draw loop ────────────────────────────────────────────────────────────
|
||||
function drawBubble(c) {
|
||||
if (!c.bubble) return;
|
||||
var text = c.bubble.text;
|
||||
var yOff = BOB[c.frame];
|
||||
var bx = c.x;
|
||||
var by = c.y + yOff - 18;
|
||||
|
||||
ctx.font = '7px Silkscreen, monospace';
|
||||
var tw = ctx.measureText(text).width;
|
||||
var pw = tw + 8;
|
||||
var ph = 12;
|
||||
|
||||
// clamp to canvas
|
||||
var lx = Math.max(4, Math.min(316 - pw, bx - pw / 2));
|
||||
|
||||
// box
|
||||
ctx.fillStyle = '#facc15';
|
||||
ctx.fillRect(Math.floor(lx), Math.floor(by - ph), Math.ceil(pw), Math.ceil(ph));
|
||||
// tail pixel
|
||||
ctx.fillRect(Math.floor(bx) - 1, Math.floor(by), 3, 3);
|
||||
// text
|
||||
ctx.fillStyle = '#0a0a00';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, Math.floor(lx + 4), Math.floor(by - ph / 2));
|
||||
}
|
||||
|
||||
function drawChar(c) {
|
||||
if (!c.alive && c.state !== 'entering' && c.state !== 'exiting') return;
|
||||
var yOff = BOB[c.frame];
|
||||
var cx = Math.floor(c.x);
|
||||
var cy = Math.floor(c.y + yOff);
|
||||
|
||||
// status ring (toolcall = orange, waiting = blue)
|
||||
if (c.state === 'toolcall') {
|
||||
ctx.fillStyle = 'rgba(251,146,60,0.35)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 13, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (c.state === 'waiting') {
|
||||
ctx.fillStyle = 'rgba(96,165,250,0.25)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, 11, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// emoji
|
||||
ctx.font = '18px serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(c.emoji, cx, cy);
|
||||
|
||||
// name label
|
||||
ctx.font = '6px Silkscreen, monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillStyle = c.state === 'talking' ? '#facc15' : '#3a4a7a';
|
||||
ctx.fillText(c.id.toUpperCase(), cx, cy + 11);
|
||||
|
||||
drawBubble(c);
|
||||
}
|
||||
|
||||
function render(ts) {
|
||||
if (lastTs === null) lastTs = ts;
|
||||
var dt = Math.min(ts - lastTs, 80); // cap at 80ms to avoid spiral
|
||||
lastTs = ts;
|
||||
|
||||
update(dt);
|
||||
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
drawMap(ctx);
|
||||
drawCharacters();
|
||||
|
||||
allChars().forEach(drawChar);
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
// ─── demo sequencer (replaces WebSocket in preview) ──────────────────────
|
||||
// WebSocket events will look like:
|
||||
// { type: "agent_spawn", id: "s0", task: "find APIs" }
|
||||
// { type: "agent_state", id: "s0", state: "toolcall" }
|
||||
// { type: "agent_state", id: "s0", state: "waiting" }
|
||||
// { type: "conversation", from: "conductor", to: "s0", text: "summary?" }
|
||||
// { type: "agent_gc", id: "s0" }
|
||||
|
||||
var demoLabel = document.getElementById('demo-label');
|
||||
|
||||
function demoSpawn(agent) {
|
||||
agent.alive = true;
|
||||
agent.x = MAP_POSITIONS.door.x;
|
||||
agent.y = MAP_POSITIONS.door.y;
|
||||
setState(agent, 'entering');
|
||||
syncBadge(agent.id, 'entering', true);
|
||||
moveTo(agent, agent.home, function() {
|
||||
setState(agent, 'idle');
|
||||
});
|
||||
}
|
||||
|
||||
function demoConverse(from, to, text, reply) {
|
||||
// move toward each other
|
||||
var mid = {
|
||||
x: (from.x + to.x) / 2,
|
||||
y: (from.y + to.y) / 2,
|
||||
};
|
||||
setState(from, 'talking');
|
||||
setState(to, 'talking');
|
||||
moveTo(from, { x: mid.x - 18, y: mid.y }, function() {
|
||||
say(from, text, 2400);
|
||||
});
|
||||
moveTo(to, { x: mid.x + 18, y: mid.y }, function() {
|
||||
if (reply) setTimeout(function() { say(to, reply, 2200); }, 1600);
|
||||
setTimeout(function() {
|
||||
moveTo(from, from.home, function() { setState(from, 'idle'); });
|
||||
moveTo(to, to.home, function() { setState(to, 'idle'); });
|
||||
}, reply ? 3800 : 2600);
|
||||
});
|
||||
}
|
||||
|
||||
function demoGC(agent) {
|
||||
setState(agent, 'exiting');
|
||||
moveTo(agent, MAP_POSITIONS.door, function() {
|
||||
agent.alive = false;
|
||||
setState(agent, 'idle');
|
||||
syncBadge(agent.id, 'idle', false);
|
||||
});
|
||||
}
|
||||
|
||||
// demo timeline
|
||||
var demo = [
|
||||
[ 400, function() { demoLabel.textContent = 'demo: spawning scout…'; demoSpawn(agents[0]); }],
|
||||
[ 1600, function() { demoLabel.textContent = 'demo: spawning coder…'; demoSpawn(agents[2]); }],
|
||||
[ 2600, function() { demoLabel.textContent = 'demo: toolcall (fast bob)'; setState(agents[0], 'toolcall'); setState(agents[2], 'toolcall'); }],
|
||||
[ 4800, function() { demoLabel.textContent = 'demo: llm wait (slow bob)'; setState(agents[0], 'waiting'); setState(agents[2], 'waiting'); }],
|
||||
[ 7200, function() { demoLabel.textContent = 'demo: conversation';
|
||||
demoConverse(conductor, agents[0], 'found anything?', 'yes — 3 hits'); }],
|
||||
[11800, function() { demoLabel.textContent = 'demo: secretary plans with coder';
|
||||
demoConverse(secretary, agents[2], 'review plan?', 'looks good'); }],
|
||||
[16200, function() { demoLabel.textContent = 'demo: agent exits (gc)'; demoGC(agents[0]); }],
|
||||
[18400, function() { demoLabel.textContent = 'demo: agent exits (gc)'; demoGC(agents[2]); }],
|
||||
[20000, function() {
|
||||
// restart
|
||||
demo.forEach(function(e) { e[2] = false; });
|
||||
demoStart = performance.now();
|
||||
demoLabel.textContent = 'demo: restarting…';
|
||||
}],
|
||||
];
|
||||
|
||||
var demoStart = null;
|
||||
function tickDemo(ts) {
|
||||
if (demoStart === null) demoStart = ts;
|
||||
var elapsed = ts - demoStart;
|
||||
demo.forEach(function(e) {
|
||||
if (!e[2] && elapsed >= e[0]) { e[2] = true; e[1](); }
|
||||
});
|
||||
requestAnimationFrame(tickDemo);
|
||||
}
|
||||
|
||||
// ─── init ─────────────────────────────────────────────────────────────────
|
||||
loadMapAsset(function() {
|
||||
requestAnimationFrame(render);
|
||||
requestAnimationFrame(tickDemo);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue