var workspace = null;
function updateWorkspaceStatusPanel(data) {
var panel = document.getElementById('ws-status-panel');
if (!panel) return;
var agents = (data && data.agents) || [];
if (!agents.length) {
setHtml(panel, '<span style="font-size:0.75rem;color:var(--muted)">No agents in workspace.</span>');
return;
}
var now = Date.now();
function relTime(isoStr) {
if (!isoStr) return '';
var t = new Date(isoStr).getTime();
if (isNaN(t)) return '';
var diffS = Math.floor((now - t) / 1000);
if (diffS < 5) return 'just now';
if (diffS < 60) return diffS + 's ago';
if (diffS < 3600) return Math.floor(diffS / 60) + 'm ago';
if (diffS < 86400) return Math.floor(diffS / 3600) + 'h ago';
return Math.floor(diffS / 86400) + 'd ago';
}
function stateColor(st) {
var s = (st || '').toLowerCase();
if (s === 'running' || s === 'working' || s === 'inference' || s === 'tooling') return 'var(--success)';
if (s === 'failed' || s === 'error') return 'var(--error)';
return 'var(--muted)';
}
function stateLabel(st) {
var s = (st || 'idle').toLowerCase();
if (s === 'inference' || s === 'tooling' || s === 'tool_execution') return 'running';
return s;
}
var items = agents.map(function(a) {
var rawState = a.task_state || a.activity || a.state || 'idle';
var st = stateLabel(rawState);
var color = stateColor(rawState);
var rel = relTime(a.last_event_at || a.updated_at || '');
var summary = a.active_task_summary || '';
var pct = (a.active_task_percentage != null && a.active_task_percentage > 0)
? Math.round(a.active_task_percentage * 100) + '%' : '';
return '<div style="display:inline-flex;align-items:center;gap:0.35rem;padding:0.2rem 0.55rem;background:var(--surface-2);border:1px solid var(--border-ghost);border-radius:9999px">'
+ '<span style="width:6px;height:6px;border-radius:50%;background:' + color + ';flex-shrink:0"></span>'
+ '<span style="font-size:0.6875rem;color:var(--text);font-weight:500">' + esc(a.name || a.id || '?') + '</span>'
+ '<span style="font-size:0.625rem;color:' + color + ';font-family:var(--font-mono)">' + esc(st) + '</span>'
+ (pct ? '<span style="font-size:0.5625rem;color:var(--accent);font-family:var(--font-mono)">' + esc(pct) + '</span>' : '')
+ (summary ? '<span style="font-size:0.5625rem;color:var(--muted);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="' + esc(summary) + '">' + esc(summary) + '</span>' : '')
+ (rel ? '<span style="font-size:0.5625rem;color:var(--muted)">' + esc(rel) + '</span>' : '')
+ '</div>';
}).join('');
setHtml(panel, items);
}
function startWorkspaceEngine(data) { if (workspace) { workspace.stop(); workspace = null; } var canvas = document.getElementById('ws-canvas'); if (!canvas) return; workspace = new WorkspaceEngine(canvas, data); workspace.start(); updateWorkspaceStatusPanel(data); }
function WorkspaceEngine(canvas, data) {
this.canvas = canvas; this.ctx = canvas.getContext('2d');
this.running = false; this.animId = 0; this.time = 0;
this.bots = []; this.stations = []; this.tethers = []; this.interactions = []; this.dataStreams = [];
this.selectedBotId = null;
this.dpr = window.devicePixelRatio || 1;
var self = this;
this._onCanvasClick = function(e) { self.onCanvasClick(e); };
this._onCanvasMove = function(e) { self.onCanvasMove(e); };
this.canvas.addEventListener('click', this._onCanvasClick);
this.canvas.addEventListener('mousemove', this._onCanvasMove);
this._resizeObs = new ResizeObserver(function() { self.resize(); });
this._resizeObs.observe(canvas.parentElement);
this.initData(data);
}
WorkspaceEngine.prototype._stationById = function(id) {
if (!id) return null;
return this.stations.find(function(s) { return s.id === id; }) || null;
};
WorkspaceEngine.prototype._idleWarehouseStation = function() {
return this.stations.find(function(s) {
var k = (s.kind || '').toLowerCase();
return s.id === 'shelter' || s.id === 'standby' || k === 'shelter' || k === 'standby';
}) || null;
};
WorkspaceEngine.prototype._isRuntimeActive = function(activity) {
var a = (activity || '').toLowerCase();
return a === 'inference' || a === 'working' || a === 'tool_execution' || a === 'tooling' || a === 'moving' || a === 'walking' || a === 'talking';
};
WorkspaceEngine.prototype._buildBotFromAgent = function(a, idx, total) {
var cx = 0.5, cy = 0.5;
if (total > 1) {
var angle = (idx / total) * Math.PI * 2 - Math.PI / 2;
var radius = Math.min(0.22, 0.08 + total * 0.012);
cx = 0.5 + Math.cos(angle) * radius;
cy = 0.5 + Math.sin(angle) * radius;
}
return {
id: a.id, name: a.name, role: a.role || 'subagent',
model: a.model || '', state: (a.state || 'Idle').toString(), color: a.color || '#c180ff',
activity: (a.activity || '').toString().toLowerCase(),
subordinates: a.subordinates || [], supervisor: a.supervisor || null,
skills: a.skills || [], activeSkill: a.active_skill || null, skillFade: 0,
x: cx, y: cy,
targetX: cx, targetY: cy, vx: 0, vy: 0,
animState: (a.activity && a.activity !== 'idle') ? 'working' : 'idle', animFrame: 0, animTimer: 0,
headAngle: 0, headTarget: 0, headTimer: 2 + idx * 0.15,
lights: [0.6, 0.6, 0.8], lightTimer: 0.5,
blinkTimer: 3 + idx * 0.2, blinking: false,
workTimer: 0, currentStation: a.current_workstation || null,
taskState: a.task_state || null,
activeTaskSummary: a.active_task_summary || null,
activeTaskPercentage: a.active_task_percentage || null
};
};
WorkspaceEngine.prototype.initData = function(data) {
var agents = data.agents || [];
var systems = data.systems || data.workstations || [];
this.stations = systems.map(function(s) { return { id: s.id, name: s.name, kind: s.kind, x: s.x, y: s.y }; });
if (this.stations.length === 0) {
this.stations = [
{ id: 'llm', name: 'LLM Inference', kind: 'Inference', x: 0.18, y: 0.22 },
{ id: 'memory', name: 'Memory', kind: 'Storage', x: 0.82, y: 0.22 },
{ id: 'exec', name: 'Code Execution', kind: 'Execution', x: 0.18, y: 0.78 },
{ id: 'blockchain', name: 'Blockchain', kind: 'Blockchain', x: 0.82, y: 0.78 },
{ id: 'web', name: 'Web / APIs', kind: 'Tool', x: 0.50, y: 0.12 },
{ id: 'files', name: 'File System', kind: 'Tool', x: 0.50, y: 0.88 },
{ id: 'tools_plugins', name: 'Tools / Plugins', kind: 'Plugin', x: 0.965, y: 0.50 },
{ id: 'shelter', name: 'Idle Agents', kind: 'Shelter', x: 0.035, y: 0.50 }
];
}
var self = this;
this.bots = agents.map(function(a, idx) { return self._buildBotFromAgent(a, idx, agents.length); });
this.bots.forEach(function(bot) {
if (!bot.currentStation) return;
var st = self.stations.find(function(s) { return s.id === bot.currentStation; });
if (st) {
bot.targetX = st.x;
bot.targetY = st.y + 0.10;
}
});
this.layoutStandby();
this.tethers = [];
for (var i = 0; i < this.bots.length; i++) {
var bot = this.bots[i];
if (bot.subordinates && bot.subordinates.length > 0) {
for (var j = 0; j < bot.subordinates.length; j++) {
var sub = this.bots.find(function(b) { return b.id === bot.subordinates[j]; });
if (sub) this.tethers.push({ from: bot, to: sub, color: bot.color });
}
}
}
};
WorkspaceEngine.prototype.applySnapshot = function(data) {
if (!data) return;
var agents = data.agents || [];
var systems = data.systems || data.workstations || [];
if (systems.length > 0) {
this.stations = systems.map(function(s) { return { id: s.id, name: s.name, kind: s.kind, x: s.x, y: s.y }; });
}
var byId = {};
this.bots.forEach(function(b) { byId[b.id] = b; });
var nextBots = [];
for (var i = 0; i < agents.length; i++) {
var incoming = agents[i];
var existing = byId[incoming.id];
if (!existing) {
existing = this._buildBotFromAgent(incoming, i, agents.length);
} else {
existing.name = incoming.name || existing.name;
existing.role = incoming.role || existing.role;
existing.model = incoming.model || existing.model;
existing.state = (incoming.state || existing.state || 'Idle').toString();
existing.color = incoming.color || existing.color;
existing.activity = (incoming.activity || '').toString().toLowerCase();
existing.subordinates = incoming.subordinates || existing.subordinates || [];
existing.supervisor = incoming.supervisor || null;
existing.skills = incoming.skills || existing.skills || [];
existing.activeSkill = incoming.active_skill || null;
existing.currentStation = incoming.current_workstation || existing.currentStation || null;
existing.taskState = incoming.task_state || existing.taskState || null;
existing.activeTaskSummary = incoming.active_task_summary || existing.activeTaskSummary || null;
existing.activeTaskPercentage = incoming.active_task_percentage != null ? incoming.active_task_percentage : existing.activeTaskPercentage;
var runtimeActive = this._isRuntimeActive(existing.activity);
var stationedActive = !!existing.currentStation && existing.currentStation !== 'standby' && existing.currentStation !== 'shelter';
if (existing.animState !== 'walking') existing.animState = (existing.activeSkill || runtimeActive || stationedActive) ? 'working' : 'idle';
}
var st = this._stationById(existing.currentStation);
if (st) {
existing.targetX = st.x;
existing.targetY = st.y + 0.10;
}
nextBots.push(existing);
}
this.bots = nextBots;
this.tethers = [];
for (var t = 0; t < this.bots.length; t++) {
var bot = this.bots[t];
if (bot.subordinates && bot.subordinates.length > 0) {
for (var j = 0; j < bot.subordinates.length; j++) {
var sub = this.bots.find(function(b) { return b.id === bot.subordinates[j]; });
if (sub) this.tethers.push({ from: bot, to: sub, color: bot.color });
}
}
}
this.layoutStandby();
};
WorkspaceEngine.prototype.layoutStandby = function() {
var standby = this._idleWarehouseStation();
if (!standby) return;
var idleBots = this.bots.filter(function(b) {
return !b.activeSkill && (b.animState === 'idle' || b.animState === 'walking');
});
// Move idle bots toward the shelter station. The bot animates back
// via the standard lerp in tick() and drawBot() keeps drawing it
// at reduced opacity until it arrives, then hides it.
for (var i = 0; i < idleBots.length; i++) {
var b = idleBots[i];
b.targetX = standby.x;
b.targetY = standby.y;
b.currentStation = 'shelter';
}
};
WorkspaceEngine.prototype.resize = function() {
var parent = this.canvas.parentElement; if (!parent) return;
var w = parent.clientWidth, h = parent.clientHeight; if (w < 1 || h < 1) return;
this.canvas.width = w * this.dpr; this.canvas.height = h * this.dpr;
this.canvas.style.width = w + 'px'; this.canvas.style.height = h + 'px';
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
this.W = w; this.H = h;
};
WorkspaceEngine.prototype.start = function() {
this.running = true; this.resize(); this.lastTime = performance.now();
var self = this;
(function loop(ts) {
if (!self.running) return;
var dt = Math.min((ts - self.lastTime) / 1000, 0.05);
self.lastTime = ts; self.time += dt;
self.update(dt); self.render();
self.animId = requestAnimationFrame(loop);
})(performance.now());
};
WorkspaceEngine.prototype.stop = function() {
this.running = false; cancelAnimationFrame(this.animId);
if (this._onCanvasClick) this.canvas.removeEventListener('click', this._onCanvasClick);
if (this._onCanvasMove) this.canvas.removeEventListener('mousemove', this._onCanvasMove);
if (this._resizeObs) this._resizeObs.disconnect();
};
WorkspaceEngine.prototype._botAtScreenPoint = function(x, y) {
var sorted = this.bots.slice().sort(function(a, b) { return b.y - a.y; });
for (var i = 0; i < sorted.length; i++) {
var bot = sorted[i];
var isAgent = bot.role === 'agent';
var radius = isAgent ? 34 : 28;
var dx = x - (bot.x * this.W);
var dy = y - (bot.y * this.H);
if ((dx * dx + dy * dy) <= radius * radius) return bot;
}
return null;
};
WorkspaceEngine.prototype.onCanvasClick = function(evt) {
if (!this.W || !this.H) return;
var rect = this.canvas.getBoundingClientRect();
var x = evt.clientX - rect.left;
var y = evt.clientY - rect.top;
var hit = this._botAtScreenPoint(x, y);
this.selectedBotId = hit ? hit.id : null;
};
WorkspaceEngine.prototype.onCanvasMove = function(evt) {
if (!this.W || !this.H) return;
var rect = this.canvas.getBoundingClientRect();
var x = evt.clientX - rect.left;
var y = evt.clientY - rect.top;
var hit = this._botAtScreenPoint(x, y);
this.canvas.style.cursor = hit ? 'pointer' : 'default';
this.canvas.title = hit && hit.name ? hit.name : '';
};
WorkspaceEngine.prototype.describeBotTask = function(bot) {
if (!bot) return '';
// Prefer explicit task metadata over animation-derived labels.
if (bot.taskState === 'failed') {
return bot.activeTaskSummary
? 'Failed: ' + bot.activeTaskSummary
: 'Task failed.';
}
if (bot.activeTaskSummary) {
var pct = (bot.activeTaskPercentage != null && bot.activeTaskPercentage > 0)
? ' (' + Math.round(bot.activeTaskPercentage * 100) + '%)' : '';
return bot.activeTaskSummary + pct;
}
// Fall back to animation state when no task metadata is available.
if (bot.animState === 'working' && bot.activeSkill) return 'Working on ' + bot.activeSkill + '.';
if (bot.animState === 'working' && bot.currentStation) {
var ws = this.stations.find(function(s) { return s.id === bot.currentStation; });
return ws ? ('Working at ' + ws.name + '.') : 'Working on a task.';
}
if (bot.currentStation === 'standby' || bot.currentStation === 'shelter') return 'Idle.';
if (bot.animState === 'walking') return 'Moving to next workstation.';
return 'Idle.';
};
WorkspaceEngine.prototype.drawTaskBubble = function(ctx, bot, W, H) {
var tc = this._themeColors || { surface: '#081329', text: '#dee5ff', aR: 193, aG: 128, aB: 255 };
var text = this.describeBotTask(bot);
if (!text) return;
var px = bot.x * W;
var py = bot.y * H - 56;
ctx.save();
ctx.font = '10px ' + getComputedStyle(document.body).fontFamily;
var maxW = 240;
var words = text.split(' ');
var lines = [];
var current = '';
for (var i = 0; i < words.length; i++) {
var testLine = current ? (current + ' ' + words[i]) : words[i];
if (ctx.measureText(testLine).width > maxW && current) {
lines.push(current);
current = words[i];
} else {
current = testLine;
}
}
if (current) lines.push(current);
var bubbleW = 0;
for (var li = 0; li < lines.length; li++) bubbleW = Math.max(bubbleW, ctx.measureText(lines[li]).width);
bubbleW += 14;
var lineH = 12;
var bubbleH = lines.length * lineH + 10;
var bx = Math.max(10, Math.min(W - bubbleW - 10, px - bubbleW / 2));
var by = Math.max(8, py - bubbleH);
ctx.fillStyle = tc.surface;
ctx.globalAlpha = 0.96;
ctx.beginPath(); ctx.roundRect(bx, by, bubbleW, bubbleH, 8); ctx.fill();
ctx.strokeStyle = 'rgba(' + tc.aR + ',' + tc.aG + ',' + tc.aB + ',0.35)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, bubbleW, bubbleH, 8); ctx.stroke();
ctx.beginPath();
ctx.moveTo(px - 5, by + bubbleH);
ctx.lineTo(px, by + bubbleH + 8);
ctx.lineTo(px + 5, by + bubbleH);
ctx.closePath();
ctx.fillStyle = tc.surface;
ctx.fill();
ctx.stroke();
ctx.fillStyle = tc.text;
ctx.globalAlpha = 1;
ctx.textAlign = 'left';
for (var t = 0; t < lines.length; t++) ctx.fillText(lines[t], bx + 7, by + 14 + t * lineH);
ctx.restore();
};
WorkspaceEngine.prototype.update = function(dt) {
if (this.selectedBotId && !this.bots.some(function(b) { return b.id === this.selectedBotId; }, this)) {
this.selectedBotId = null;
}
var self = this;
this.bots.forEach(function(bot) {
var dx = bot.targetX - bot.x, dy = bot.targetY - bot.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0.005) {
var step = Math.min(0.35 * dt, dist);
bot.x += (dx / dist) * step; bot.y += (dy / dist) * step;
bot.animState = 'walking';
} else {
bot.x = bot.targetX; bot.y = bot.targetY;
if (bot.animState === 'walking') bot.animState = bot.activeSkill ? 'working' : 'idle';
}
bot.headTimer -= dt;
if (bot.headTimer <= 0) { bot.headTarget = Math.sin(bot.animFrame * 0.9) * 0.35; bot.headTimer = 2.8; }
bot.headAngle += (bot.headTarget - bot.headAngle) * 2 * dt;
bot.lightTimer -= dt;
if (bot.lightTimer <= 0) { bot.lights = bot.activeSkill ? [0.9, 0.6, 0.2] : [0.25, 0.45, 0.7]; bot.lightTimer = 0.5; }
bot.blinkTimer -= dt;
if (bot.blinkTimer <= 0) { bot.blinking = !bot.blinking; bot.blinkTimer = bot.blinking ? 0.12 : 3.2; }
bot.animTimer += dt;
if (bot.animTimer > 0.18) { bot.animFrame = (bot.animFrame + 1) % 4; bot.animTimer = 0; }
if (bot.activeSkill) { bot.skillFade = Math.min(1, bot.skillFade + dt * 3); }
else { bot.skillFade = Math.max(0, bot.skillFade - dt * 2); }
if (bot.animState !== 'walking') {
var runtimeActive = self._isRuntimeActive(bot.activity);
var stationedActive = !!bot.currentStation && bot.currentStation !== 'standby' && bot.currentStation !== 'shelter';
bot.animState = (bot.activeSkill || runtimeActive || stationedActive) ? 'working' : 'idle';
}
});
for (var i = this.dataStreams.length - 1; i >= 0; i--) {
var ds = this.dataStreams[i];
ds.progress += dt * 1.5;
ds.timer -= dt;
if (ds.timer <= 0) this.dataStreams.splice(i, 1);
}
for (var i = this.interactions.length - 1; i >= 0; i--) {
var ix = this.interactions[i];
ix.timer -= dt; ix.phase += dt;
ix.bubbleTimer += dt;
if (ix.timer <= 0) {
if (ix.botA._savedTarget) { ix.botA.targetX = ix.botA._savedTarget.x; ix.botA.targetY = ix.botA._savedTarget.y; delete ix.botA._savedTarget; }
if (ix.botB._savedTarget) { ix.botB.targetX = ix.botB._savedTarget.x; ix.botB.targetY = ix.botB._savedTarget.y; delete ix.botB._savedTarget; }
ix.botA.animState = 'idle'; ix.botB.animState = 'idle';
this.interactions.splice(i, 1);
}
}
this.layoutStandby();
};
function parseThemeColors() {
var cs = getComputedStyle(document.documentElement);
var bg = cs.getPropertyValue('--bg').trim() || '#060e20';
var surface = cs.getPropertyValue('--surface').trim() || '#081329';
var accent = cs.getPropertyValue('--accent').trim() || '#c180ff';
var muted = cs.getPropertyValue('--muted').trim() || '#9baad6';
var el = document.createElement('canvas'); el.width = el.height = 1;
var c2 = el.getContext('2d'); c2.fillStyle = accent; c2.fillRect(0, 0, 1, 1);
var d = c2.getImageData(0, 0, 1, 1).data;
var text = cs.getPropertyValue('--text').trim() || '#dee5ff';
return { bg: bg, surface: surface, accent: accent, muted: muted, text: text, aR: d[0], aG: d[1], aB: d[2] };
}
WorkspaceEngine.prototype.render = function() {
var ctx = this.ctx, W = this.W, H = this.H;
if (!W || !H) return;
var tc = parseThemeColors(); var aR = tc.aR, aG = tc.aG, aB = tc.aB;
this._themeColors = tc;
ctx.clearRect(0, 0, W, H); ctx.fillStyle = tc.bg; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.04)';
for (var gx = 30; gx < W; gx += 40) for (var gy = 30; gy < H; gy += 40) {
ctx.beginPath(); ctx.arc(gx, gy, 0.8, 0, Math.PI * 2); ctx.fill();
}
var self = this;
this.stations.forEach(function(s) {
var sx = s.x * W, sy = s.y * H;
ctx.save(); ctx.translate(sx, sy);
var pulse = 0.6 + 0.4 * Math.sin(self.time * 1.5 + sx * 0.01);
var nearBot = self.bots.some(function(b) { return b.currentStation === s.id && (b.animState === 'working' || b.animState === 'walking'); });
var glowAlpha = nearBot ? 0.08 : 0.03;
var ringAlpha = nearBot ? (0.2 + 0.1 * pulse) : (0.08 + 0.04 * pulse);
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + glowAlpha + ')';
ctx.beginPath(); ctx.arc(0, 8, 42, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + ringAlpha + ')';
ctx.lineWidth = nearBot ? 1.5 : 0.8;
ctx.beginPath(); ctx.arc(0, 8, 42, 0, Math.PI * 2); ctx.stroke();
var kind = (s.kind || '').toLowerCase();
if (kind === 'inference') {
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.3)'; ctx.lineWidth = 1;
var nodes = [[-12,-18],[12,-18],[0,-6],[-18,-4],[18,-4],[-8,6],[8,6],[0,16]];
var edges = [[0,2],[1,2],[0,3],[1,4],[2,5],[2,6],[3,5],[4,6],[5,7],[6,7]];
edges.forEach(function(e) { ctx.beginPath(); ctx.moveTo(nodes[e[0]][0], nodes[e[0]][1]); ctx.lineTo(nodes[e[1]][0], nodes[e[1]][1]); ctx.stroke(); });
nodes.forEach(function(n, i) { var r = i === 2 || i === 7 ? 4.5 : 3.5; ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.arc(n[0], n[1], r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.5 + 0.3 * pulse) + ')'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.3 + 0.4 * ((Math.sin(self.time * 3 + i * 1.2) + 1) / 2)) + ')'; ctx.beginPath(); ctx.arc(n[0], n[1], r - 1, 0, Math.PI * 2); ctx.fill(); });
} else if (kind === 'storage') {
for (var ci = 0; ci < 3; ci++) { var cy = -14 + ci * 11; ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.ellipse(0, cy + 8, 18, 4, 0, 0, Math.PI * 2); ctx.fill(); ctx.fillRect(-18, cy, 36, 8); ctx.beginPath(); ctx.ellipse(0, cy, 18, 4, 0, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.3 + 0.1 * ci) + ')'; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.ellipse(0, cy, 18, 4, 0, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-18, cy); ctx.lineTo(-18, cy + 8); ctx.stroke(); ctx.beginPath(); ctx.moveTo(18, cy); ctx.lineTo(18, cy + 8); ctx.stroke(); ctx.beginPath(); ctx.ellipse(0, cy + 8, 18, 4, 0, Math.PI, Math.PI * 2); ctx.stroke(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.08 + 0.06 * Math.sin(self.time * 2 + ci)) + ')'; ctx.fillRect(-16, cy + 1, 32, 6); }
} else if (kind === 'execution') {
ctx.fillStyle = tc.surface; ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.35)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.roundRect(-22, -18, 44, 34, 4); ctx.fill(); ctx.stroke(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.12)'; ctx.fillRect(-22, -18, 44, 8); ctx.fillStyle = '#ef4444'; ctx.beginPath(); ctx.arc(-16, -14, 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#eab308'; ctx.beginPath(); ctx.arc(-10, -14, 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#22c55e'; ctx.beginPath(); ctx.arc(-4, -14, 2, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.6)'; ctx.font = 'bold 8px monospace'; ctx.textAlign = 'left'; ctx.fillText('$_', -17, -2); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.25)'; ctx.fillRect(-8, -6, 20 * pulse, 2); ctx.fillRect(-17, 3, 26, 1.5); ctx.fillRect(-17, 8, 18, 1.5); if (Math.sin(self.time * 4) > 0) { ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.8)'; ctx.fillRect(-10, -6, 6, 8); }
} else if (kind === 'plugin') {
ctx.fillStyle = tc.surface; ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.35)'; ctx.lineWidth = 1.3;
ctx.beginPath(); ctx.roundRect(-20, -16, 40, 32, 5); ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.14)'; ctx.fillRect(-18, -14, 36, 9);
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.45 + 0.2 * pulse) + ')'; ctx.lineWidth = 1.4;
ctx.beginPath(); ctx.moveTo(-8, -2); ctx.lineTo(-8, 8); ctx.lineTo(8, 8); ctx.lineTo(8, -2); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-8, -2); ctx.lineTo(0, -10); ctx.lineTo(8, -2); ctx.stroke();
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.35 + 0.25 * pulse) + ')';
ctx.beginPath(); ctx.arc(-5, 3, 2.2, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(5, 3, 2.2, 0, Math.PI * 2); ctx.fill();
} else if (kind === 'blockchain') {
ctx.save(); var hexR = 16; ctx.beginPath(); for (var hi = 0; hi < 6; hi++) { var angle = Math.PI / 6 + hi * Math.PI / 3; var hx = Math.cos(angle) * hexR, hy = Math.sin(angle) * hexR; if (hi === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy); } ctx.closePath(); ctx.fillStyle = tc.surface; ctx.fill(); ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.35 + 0.15 * pulse) + ')'; ctx.lineWidth = 1.5; ctx.stroke(); var innerR = 10; ctx.beginPath(); for (var hi = 0; hi < 6; hi++) { var angle = Math.PI / 6 + hi * Math.PI / 3; ctx.lineTo(Math.cos(angle) * innerR, Math.sin(angle) * innerR); } ctx.closePath(); ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.2)'; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.5 + 0.3 * pulse) + ')'; ctx.font = 'bold 14px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('\u25c8', 0, 0); ctx.restore();
} else if (kind === 'standby' || kind === 'shelter') {
ctx.fillStyle = tc.surface; ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.35)'; ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.roundRect(-24, -12, 48, 24, 6); ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.16)';
ctx.fillRect(-22, -10, 44, 7);
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.45)';
ctx.beginPath(); ctx.arc(-10, 2, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(0, 2, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(10, 2, 3, 0, Math.PI * 2); ctx.fill();
var sheltered = self.bots.filter(function(b) {
var idle = !b.activeSkill && (b.animState === 'idle' || b.animState === 'walking');
return idle && (b.currentStation === 'shelter' || b.currentStation === 'standby');
}).length;
var badge = String(sheltered);
ctx.font = 'bold 9px ' + getComputedStyle(document.body).fontFamily;
var badgeW = Math.max(14, ctx.measureText(badge).width + 8);
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.22)';
ctx.beginPath(); ctx.roundRect(14, -22, badgeW, 12, 6); ctx.fill();
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.45)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(14, -22, badgeW, 12, 6); ctx.stroke();
ctx.fillStyle = tc.text;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(badge, 14 + badgeW / 2, -16);
} else {
ctx.fillStyle = tc.surface; ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.3)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.roundRect(-22, -18, 44, 34, 4); ctx.fill(); ctx.stroke(); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.1)'; ctx.fillRect(-22, -18, 44, 9); ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.25)'; ctx.beginPath(); ctx.arc(-17, -13.5, 1.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(-12, -13.5, 1.5, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + (0.3 + 0.15 * pulse) + ')'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(0, 3, 9, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.ellipse(0, 3, 4, 9, 0, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(-9, 3); ctx.lineTo(9, 3); ctx.stroke();
}
ctx.fillStyle = tc.muted; ctx.font = '10px ' + getComputedStyle(document.body).fontFamily;
ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(s.name, 0, 32);
if (s.kind === 'Inference') {
var activeModels = [];
self.bots.forEach(function(b) {
if (b.currentStation === s.id && b.animState === 'working' && b.model) {
if (activeModels.indexOf(b.model) === -1) activeModels.push(b.model);
}
});
if (activeModels.length > 0) {
ctx.font = '8px ' + getComputedStyle(document.body).fontFamily;
activeModels.forEach(function(m, mi) {
var my = 44 + mi * 12;
var mw = ctx.measureText(m).width + 8;
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.1)';
ctx.beginPath(); ctx.roundRect(-mw / 2, my - 5, mw, 11, 3); ctx.fill();
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.2)'; ctx.lineWidth = 0.5;
ctx.beginPath(); ctx.roundRect(-mw / 2, my - 5, mw, 11, 3); ctx.stroke();
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.7)';
ctx.fillText(m, 0, my + 3);
});
}
}
ctx.restore();
});
this.dataStreams.forEach(function(ds) {
var p = Math.min(1, ds.progress);
var x1 = ds.fromX * W, y1 = ds.fromY * H, x2 = ds.toX * W, y2 = ds.toY * H;
var cx = x1 + (x2 - x1) * p, cy = y1 + (y2 - y1) * p;
var alpha = ds.timer > 0.3 ? 0.6 : ds.timer / 0.3 * 0.6;
ctx.save();
ctx.setLineDash([3, 6]); ctx.lineDashOffset = -self.time * 40;
ctx.strokeStyle = ds.color + '30'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = ds.color; ctx.globalAlpha = alpha;
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(cx, cy, 6, 0, Math.PI * 2);
ctx.fillStyle = ds.color + '20'; ctx.fill();
if (ds.label) {
var midX = (x1 + x2) / 2, midY = (y1 + y2) / 2 - 10;
ctx.globalAlpha = alpha * 0.95;
ctx.font = 'bold 8px ' + getComputedStyle(document.body).fontFamily;
ctx.textAlign = 'center';
var lw = ctx.measureText(ds.label).width + 8;
ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.roundRect(midX - lw / 2, midY - 6, lw, 12, 4); ctx.fill();
ctx.strokeStyle = ds.color + '55'; ctx.lineWidth = 0.5; ctx.stroke();
ctx.fillStyle = ds.color; ctx.fillText(ds.label, midX, midY + 2.5);
}
ctx.globalAlpha = 1;
ctx.restore();
});
this.tethers.forEach(function(t) {
var subordinateActive = !!t.to && (
!!t.to.activeSkill ||
self._isRuntimeActive(t.to.activity) ||
t.to.animState === 'working' ||
t.to.animState === 'walking' ||
!!(t.to.currentStation && t.to.currentStation !== 'standby' && t.to.currentStation !== 'shelter')
);
if (!subordinateActive) return;
var fromX = t.from.x * W;
var fromY = t.from.y * H;
var toX = t.to.x * W;
var toY = t.to.y * H;
ctx.save(); ctx.setLineDash([4, 6]); ctx.lineDashOffset = -self.time * 15;
ctx.strokeStyle = t.color + '25'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(fromX, fromY); ctx.lineTo(toX, toY); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
});
this.interactions.forEach(function(ix) {
var ax = ix.botA.x * W, ay = ix.botA.y * H;
var bx = ix.botB.x * W, by = ix.botB.y * H;
var midX = (ax + bx) / 2, midY = (ay + by) / 2;
ctx.save();
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.2)';
ctx.lineWidth = 2; ctx.setLineDash([2, 4]); ctx.lineDashOffset = -self.time * 30;
ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke();
ctx.setLineDash([]);
var speakerA = Math.sin(ix.phase * 2.5) > 0;
var spkX = speakerA ? ax : bx, spkY = (speakerA ? ay : by) - 45;
var bubW = 28, bubH = 16;
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.12)';
ctx.beginPath(); ctx.roundRect(spkX - bubW / 2, spkY - bubH / 2, bubW, bubH, 6); ctx.fill();
ctx.strokeStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.25)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(spkX - bubW / 2, spkY - bubH / 2, bubW, bubH, 6); ctx.stroke();
ctx.beginPath(); ctx.moveTo(spkX - 3, spkY + bubH / 2);
ctx.lineTo(spkX, spkY + bubH / 2 + 5); ctx.lineTo(spkX + 3, spkY + bubH / 2);
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',0.12)'; ctx.fill();
var dotPhase = ix.bubbleTimer * 4;
for (var di = 0; di < 3; di++) {
var dotA = 0.3 + 0.7 * Math.max(0, Math.sin(dotPhase - di * 0.8));
ctx.fillStyle = 'rgba(' + aR + ',' + aG + ',' + aB + ',' + dotA + ')';
ctx.beginPath(); ctx.arc(spkX - 6 + di * 6, spkY, 2, 0, Math.PI * 2); ctx.fill();
}
ctx.restore();
});
var sortedBots = this.bots.slice().sort(function(a, b) { return a.y - b.y; });
sortedBots.forEach(function(bot) { self.drawBot(ctx, bot, W, H); });
if (this.selectedBotId) {
var selected = this.bots.find(function(b) { return b.id === self.selectedBotId; });
if (selected) this.drawTaskBubble(ctx, selected, W, H);
}
};
WorkspaceEngine.prototype.drawBot = function(ctx, bot, W, H) {
// Sheltered bots are hidden once they arrive at the shelter position.
// While en route (animating back), they remain visible so the transition
// looks smooth instead of bots vanishing mid-canvas.
var isSheltered = (bot.currentStation === 'shelter' || bot.currentStation === 'standby') && !bot.activeSkill;
if (isSheltered) {
var dx = Math.abs(bot.x - (bot.targetX || bot.x));
var dy = Math.abs(bot.y - (bot.targetY || bot.y));
var arrived = dx < 0.01 && dy < 0.01;
if (arrived) return; // fully at shelter — hide and show badge count
// still walking back — keep drawing at reduced opacity
}
var tc = this._themeColors || { surface: '#081329', muted: '#9baad6', text: '#dee5ff', aR: 193, aG: 128, aB: 255 };
var px = bot.x * W, py = bot.y * H, t = this.time, frame = bot.animFrame;
var isAgent = (bot.role === 'agent');
var scale = isAgent ? 1.0 : 0.7;
var bodyW = isAgent ? 20 : 14, bodyH = isAgent ? 24 : 18;
var walkBob = (bot.animState === 'walking') ? Math.sin(t * 12) * 2 : 0;
ctx.save(); ctx.translate(px, py + walkBob); ctx.scale(scale, scale);
if (this.selectedBotId === bot.id) {
ctx.strokeStyle = 'rgba(' + tc.aR + ',' + tc.aG + ',' + tc.aB + ',0.65)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(0, bodyH + 4, bodyW * 0.95, 6, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.beginPath(); ctx.ellipse(0, bodyH + 4, bodyW * 0.6, 3.5, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = bot.color; ctx.strokeStyle = bot.color + 'cc'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.roundRect(-bodyW, -bodyH * 0.3, bodyW * 2, bodyH, isAgent ? 4 : 8); ctx.fill(); ctx.stroke();
ctx.fillStyle = 'rgba(0,0,0,0.2)'; ctx.fillRect(-bodyW + 3, -bodyH * 0.3 + 3, bodyW * 2 - 6, bodyH * 0.35);
var lightColors = ['#22c55e', '#eab308', '#ef4444'];
for (var li = 0; li < 3; li++) { var lx = -bodyW + 6 + li * 7, ly = -bodyH * 0.3 + 7; ctx.fillStyle = lightColors[li]; ctx.globalAlpha = 0.3 + 0.7 * bot.lights[li]; ctx.beginPath(); ctx.arc(lx, ly, 2, 0, Math.PI * 2); ctx.fill(); }
ctx.globalAlpha = 1;
if (isAgent) {
var legOffset = (bot.animState === 'walking') ? Math.sin(t * 10) * 4 : 0;
ctx.fillStyle = bot.color + 'bb';
ctx.fillRect(-bodyW + 4, bodyH * 0.7 - 2, 7, 10 + (legOffset > 0 ? legOffset : 0));
ctx.fillRect(bodyW - 11, bodyH * 0.7 - 2, 7, 10 + (legOffset < 0 ? -legOffset : 0));
ctx.fillStyle = tc.surface;
ctx.fillRect(-bodyW + 2, bodyH * 0.7 + 7 + Math.max(0, legOffset), 11, 4);
ctx.fillRect(bodyW - 13, bodyH * 0.7 + 7 + Math.max(0, -legOffset), 11, 4);
} else {
var treadOffset = (bot.animState === 'walking') ? (frame * 3) % 12 : 0;
ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.roundRect(-bodyW - 2, bodyH * 0.5, bodyW * 2 + 4, 8, 4); ctx.fill();
ctx.strokeStyle = bot.color + '88'; ctx.lineWidth = 1; ctx.stroke();
ctx.strokeStyle = bot.color + '44';
for (var ti = 0; ti < 5; ti++) { var tx = -bodyW + 2 + (ti * 9 + treadOffset) % (bodyW * 2); ctx.beginPath(); ctx.moveTo(tx, bodyH * 0.5 + 1); ctx.lineTo(tx, bodyH * 0.5 + 7); ctx.stroke(); }
}
if (bot.animState === 'working') {
var armAngle = Math.sin(t * 4) * 0.3;
ctx.save(); ctx.translate(bodyW + 2, 0); ctx.rotate(-0.5 + armAngle);
ctx.fillStyle = bot.color + 'cc'; ctx.fillRect(0, -2, 14, 4);
ctx.fillStyle = '#eab308'; ctx.beginPath(); ctx.arc(14, 0, 3, 0, Math.PI * 2); ctx.fill();
if (frame % 2 === 0) {
ctx.fillStyle = '#eab308';
ctx.globalAlpha = 0.7;
for (var si = 0; si < 3; si++) {
var sa = ((frame + si * 5) * 0.9) % (Math.PI * 2);
ctx.beginPath();
ctx.arc(14 + Math.cos(sa) * 5, Math.sin(sa) * 5, 1.5, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
ctx.restore();
}
ctx.save(); ctx.translate(0, -bodyH * 0.3); ctx.rotate(bot.headAngle);
var headW = isAgent ? 18 : 14, headH = isAgent ? 16 : 12;
ctx.fillStyle = bot.color + 'aa'; ctx.fillRect(-3, -4, 6, 5);
ctx.fillStyle = bot.color; ctx.strokeStyle = bot.color + 'cc'; ctx.lineWidth = 1.5;
ctx.beginPath();
if (isAgent) { ctx.roundRect(-headW, -headH - 4, headW * 2, headH, 5); }
else { ctx.arc(0, -headH * 0.5 - 2, headW * 0.85, 0, Math.PI * 2); }
ctx.fill(); ctx.stroke();
if (isAgent) {
ctx.strokeStyle = bot.color + 'cc'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(0, -headH - 4); ctx.lineTo(0, -headH - 14); ctx.stroke();
var antGlow = 0.5 + 0.5 * Math.sin(t * 3);
ctx.fillStyle = bot.color; ctx.globalAlpha = 0.5 + 0.5 * antGlow;
ctx.beginPath(); ctx.arc(0, -headH - 15, 3, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
var eyeY = -headH * 0.5 - 2;
if (bot.blinking) { ctx.fillStyle = tc.surface; ctx.fillRect(-headW + 4, eyeY - 1, headW - 6, 2); ctx.fillRect(3, eyeY - 1, headW - 6, 2); }
else { ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.arc(-headW * 0.4, eyeY, 4, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(headW * 0.4, eyeY, 4, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = tc.text; ctx.beginPath(); ctx.arc(-headW * 0.4 + bot.headAngle * 3, eyeY, 2, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(headW * 0.4 + bot.headAngle * 3, eyeY, 2, 0, Math.PI * 2); ctx.fill(); }
} else {
var visorY = -headH * 0.5 - 2, visorGlow = 0.6 + 0.4 * Math.sin(t * 2.5 + px * 0.01);
ctx.fillStyle = tc.surface; ctx.beginPath(); ctx.roundRect(-headW * 0.6, visorY - 3, headW * 1.2, 6, 3); ctx.fill();
ctx.fillStyle = bot.color; ctx.globalAlpha = visorGlow; ctx.beginPath(); ctx.roundRect(-headW * 0.5, visorY - 2, headW, 4, 2); ctx.fill(); ctx.globalAlpha = 1;
var scanX = Math.sin(t * 3 + px * 0.01) * headW * 0.35;
ctx.fillStyle = '#fff'; ctx.globalAlpha = 0.6; ctx.beginPath(); ctx.arc(scanX, visorY, 1.5, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1;
}
ctx.restore();
if (isAgent && bot.subordinates && bot.subordinates.length > 0) {
ctx.save(); ctx.translate(0, -bodyH * 0.3 - (isAgent ? 24 : 18));
ctx.strokeStyle = bot.color; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(-6, -18); ctx.lineTo(0, -22); ctx.lineTo(6, -18); ctx.stroke();
ctx.restore();
}
ctx.save();
var tagY = -bodyH * 0.3 - (isAgent ? 30 : 20) - (isAgent && bot.subordinates && bot.subordinates.length > 0 ? 10 : 0);
ctx.font = 'bold 10px ' + getComputedStyle(document.body).fontFamily; ctx.textAlign = 'center';
var nameW = ctx.measureText(bot.name).width + 12;
ctx.globalAlpha = 0.88; ctx.fillStyle = tc.surface;
ctx.beginPath(); ctx.roundRect(-nameW / 2, tagY - 7, nameW, 14, 7); ctx.fill();
ctx.globalAlpha = 1; ctx.strokeStyle = bot.color + '66'; ctx.lineWidth = 1; ctx.stroke();
ctx.fillStyle = tc.text; ctx.fillText(bot.name, 0, tagY + 3);
var dotColor = bot.state === 'Running' ? '#22c55e' : (bot.state === 'Error' ? '#ef4444' : '#eab308');
ctx.fillStyle = dotColor; ctx.beginPath(); ctx.arc(nameW / 2 - 4, tagY, 3, 0, Math.PI * 2); ctx.fill();
ctx.restore();
if (bot.activeSkill && bot.skillFade > 0) {
ctx.save();
var skillY = bodyH + (isAgent ? 16 : 12);
ctx.globalAlpha = bot.skillFade * 0.85;
ctx.font = '8px ' + getComputedStyle(document.body).fontFamily; ctx.textAlign = 'center';
var sw = ctx.measureText(bot.activeSkill).width + 10;
ctx.fillStyle = bot.color + '20';
ctx.beginPath(); ctx.roundRect(-sw / 2, skillY - 5, sw, 12, 4); ctx.fill();
ctx.strokeStyle = bot.color + '50'; ctx.lineWidth = 0.5; ctx.stroke();
ctx.fillStyle = bot.color; ctx.globalAlpha = bot.skillFade * 0.9;
ctx.fillText(bot.activeSkill, 0, skillY + 3);
ctx.globalAlpha = 1; ctx.restore();
}
ctx.restore();
};
WorkspaceEngine.prototype.handleEvent = function(ev) {
if (!ev || !ev.type) return;
var self = this;
function primaryBot() {
return self.bots.find(function(b) { return b.role === 'agent'; }) || self.bots[0] || null;
}
function llmStation() {
return self.stations.find(function(s) { return s.id === 'llm'; }) || null;
}
if (ev.type === 'agent_started' || ev.type === 'agent_stopped' || ev.type === 'agent_error') {
var bot = this.bots.find(function(b) { return b.id === ev.agent_id; });
if (bot) { bot.state = ev.type === 'agent_started' ? 'Running' : (ev.type === 'agent_stopped' ? 'Stopped' : 'Error'); }
}
if (ev.type === 'agent_moved') {
var bot = this.bots.find(function(b) { return b.id === ev.agent_id; });
var station = this.stations.find(function(s) { return s.id === ev.workstation; });
if (bot && station) { bot.targetX = station.x; bot.targetY = station.y + 0.10; bot.currentStation = station.id; }
}
if (ev.type === 'agent_working') {
var bot = this.bots.find(function(b) { return b.id === ev.agent_id; });
if (bot) {
if (ev.workstation) {
var st = this.stations.find(function(s) { return s.id === ev.workstation; });
if (st) { bot.targetX = st.x; bot.targetY = st.y + 0.10; bot.currentStation = st.id; }
}
bot.animState = 'working';
if (ev.skill) bot.activeSkill = ev.skill;
}
}
if (ev.type === 'agent_idle') {
var bot = this.bots.find(function(b) { return b.id === ev.agent_id; });
if (bot) { bot.animState = 'idle'; bot.activeSkill = null; bot.currentStation = 'standby'; }
this.layoutStandby();
}
if (ev.type === 'skill_activated') {
var bot = this.bots.find(function(b) { return b.id === ev.agent_id; });
if (bot) { bot.activeSkill = ev.skill || ev.skill_name; bot.animState = 'working'; }
}
if (ev.type === 'a2a_interaction') {
var botA = this.bots.find(function(b) { return b.id === ev.agent_a; });
var botB = this.bots.find(function(b) { return b.id === ev.agent_b; });
if (botA && botB) {
var meetX = (botA.x + botB.x) / 2, meetY = (botA.y + botB.y) / 2;
botA._savedTarget = { x: botA.targetX, y: botA.targetY };
botB._savedTarget = { x: botB.targetX, y: botB.targetY };
botA.targetX = meetX - 0.03; botA.targetY = meetY;
botB.targetX = meetX + 0.03; botB.targetY = meetY;
botA.animState = 'talking'; botB.animState = 'talking';
self.interactions.push({ botA: botA, botB: botB, timer: ev.duration || 4, phase: 0, bubbleTimer: 0 });
}
}
if (ev.type === 'stream_start') {
var bot = primaryBot();
var st = llmStation();
if (bot) {
if (st) { bot.targetX = st.x; bot.targetY = st.y + 0.10; bot.currentStation = st.id; }
bot.animState = 'working';
bot.activeSkill = ev.model || 'inference';
}
}
if (ev.type === 'stream_chunk') {
var bot = primaryBot();
var st = llmStation();
if (bot && st) {
self.dataStreams.push({
fromX: bot.x, fromY: bot.y, toX: st.x, toY: st.y,
color: bot.color, timer: 0.8, progress: 0, label: ''
});
}
if (ev.done) {
var b = primaryBot();
if (b) { b.animState = 'idle'; b.activeSkill = null; b.currentStation = 'standby'; }
this.layoutStandby();
}
}
if (ev.type === 'stream_end') {
var bot = primaryBot();
if (bot) { bot.animState = 'idle'; bot.activeSkill = null; bot.currentStation = 'standby'; }
this.layoutStandby();
}
};
// BUG-002: WebSocket connection with exponential backoff reconnection.
// S-HIGH-2: Uses short-lived tickets instead of persistent API key in URL.
App._wsReconnectDelay = 1000;
App._wsReconnectTimer = null;
App._bindWsEvents = function(ws) {
ws.onopen = function() {
App._wsReconnectDelay = 1000;
var d = document.getElementById('ws-dot'); if (d) d.classList.remove('off');
var l = document.getElementById('ws-label'); if (l) l.textContent = 'Connected';
refreshSidebarIdentity();
};
ws.onclose = function() {
var d = document.getElementById('ws-dot'); if (d) d.classList.add('off');
var l = document.getElementById('ws-label'); if (l) l.textContent = 'Reconnecting...';
clearTimeout(App._wsReconnectTimer);
App._wsReconnectTimer = setTimeout(function() { App._connectWs(); }, App._wsReconnectDelay);
App._wsReconnectDelay = Math.min(App._wsReconnectDelay * 2, 30000);
};
ws.onerror = function() {
var d = document.getElementById('ws-dot'); if (d) d.classList.add('off');
};
ws.onmessage = function(ev) {
try {
var event = JSON.parse(ev.data);
if (workspace && workspace.handleEvent) workspace.handleEvent(event);
if (event && event.type === 'stream_start') {
App._liveStreamTurn = {
turn_id: event.turn_id || '',
session_id: event.session_id || '',
model: event.model || ''
};
if (App.page === 'context') {
App.navigate('context');
}
} else if (event && event.type === 'stream_end') {
if (!event.turn_id || (App._liveStreamTurn && App._liveStreamTurn.turn_id === event.turn_id)) {
App._liveStreamTurn = null;
if (App.page === 'context') {
App.navigate('context');
}
}
}
if (event && (event.type === 'model_selection' || event.type === 'model_shift')) {
if (App.page === 'efficiency') {
App.navigate('efficiency');
} else if (App.page === 'context' && App._ctxActiveTurn && event.turn_id === App._ctxActiveTurn.id) {
App.navigate('context');
}
}
} catch(e) {}
};
};
App._connectWs = function() {
try {
if (App.ws && (App.ws.readyState === WebSocket.CONNECTING || App.ws.readyState === WebSocket.OPEN)) return;
var proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var wsUrl = proto + '//' + window.location.host + '/ws';
if (!API_KEY) {
// No auth configured — connect directly
App.ws = new WebSocket(wsUrl);
App._bindWsEvents(App.ws);
return;
}
// Fetch a short-lived ticket, then connect with it
fetch(BASE + '/api/ws-ticket', {
method: 'POST',
headers: authHeaders({ 'Accept': 'application/json' })
}).then(function(r) {
if (!r.ok) throw new Error('ticket fetch failed: ' + r.status);
return r.json();
}).then(function(data) {
App.ws = new WebSocket(wsUrl + '?ticket=' + encodeURIComponent(data.ticket));
App._bindWsEvents(App.ws);
}).catch(function() {
var d = document.getElementById('ws-dot'); if (d) d.classList.add('off');
var l = document.getElementById('ws-label'); if (l) l.textContent = 'Reconnecting...';
clearTimeout(App._wsReconnectTimer);
App._wsReconnectTimer = setTimeout(function() { App._connectWs(); }, App._wsReconnectDelay);
App._wsReconnectDelay = Math.min(App._wsReconnectDelay * 2, 30000);
});
} catch (err) { var d = document.getElementById('ws-dot'); if (d) d.classList.add('off'); var l = document.getElementById('ws-label'); if (l) l.textContent = 'No WebSocket'; }
};
App._connectWs();
setInterval(refreshSidebarIdentity, 30000);
startModelsBackgroundRefresh(App);
App._loadAvailableModels({ nonBlocking: true, validationLevel: 'zero' }).catch(function() {});
// BUG-FIX: call hint preference prompt only once on startup, not on every navigation
ensureAuth().then(function() { maybePromptHintPreference(); }).catch(function() {});
onHash();