<script lang="ts">
import { onMount } from 'svelte';
import { agentGraphStore } from '$lib/stores/agent-graph.svelte.js';
let canvas: HTMLCanvasElement | undefined = $state();
let ctx: CanvasRenderingContext2D | undefined = $state();
let width = $state(800);
let height = $state(600);
let hoveredId: string | null = $state(null);
let dragging: { id: string; offsetX: number; offsetY: number } | null = $state(null);
let animFrame: number | undefined;
let pulsePhase = $state(0);
let pan = $state({ x: 0, y: 0 });
let zoom = $state(1);
const NODE_RADIUS = 32;
const COLORS = {
running: '#7aa2f7',
done: '#9ece6a',
error: '#f7768e',
paused: '#e0af68',
edge: '#414868',
edgeActive: '#7aa2f7',
text: '#c0caf5',
textDim: '#565f89',
bg: '#1a1b26',
glow: 'rgba(122, 162, 247, 0.15)',
};
onMount(() => {
if (canvas) {
ctx = canvas.getContext('2d') ?? undefined;
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
animLoop();
return () => {
window.removeEventListener('resize', resizeCanvas);
if (animFrame) cancelAnimationFrame(animFrame);
};
});
function resizeCanvas() {
if (!canvas) return;
const rect = canvas.parentElement?.getBoundingClientRect();
if (rect) {
width = rect.width;
height = rect.height;
canvas.width = width * devicePixelRatio;
canvas.height = height * devicePixelRatio;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
}
}
function animLoop() {
pulsePhase = (Date.now() % 2000) / 2000;
draw();
animFrame = requestAnimationFrame(animLoop);
}
function draw() {
if (!ctx || !canvas) return;
const c = ctx;
const dpr = devicePixelRatio;
c.save();
c.scale(dpr, dpr);
c.clearRect(0, 0, width, height);
c.save();
c.translate(pan.x, pan.y);
c.scale(zoom, zoom);
const agents = [...agentGraphStore.agents.values()];
// Draw edges
for (const edge of agentGraphStore.edges) {
const from = agentGraphStore.agents.get(edge.from);
const to = agentGraphStore.agents.get(edge.to);
if (!from || !to) continue;
c.beginPath();
c.moveTo(from.x, from.y);
// Curved edge
const midX = (from.x + to.x) / 2;
const midY = (from.y + to.y) / 2 - 20;
c.quadraticCurveTo(midX, midY, to.x, to.y);
c.strokeStyle = COLORS.edge;
c.lineWidth = 2;
c.stroke();
// Arrow
drawArrow(c, midX, midY, to.x, to.y);
}
// Draw nodes
for (const agent of agents) {
const isHovered = hoveredId === agent.id;
const isSelected = agentGraphStore.selectedAgentId === agent.id;
const isRunning = agent.status === 'running';
const color = COLORS[agent.status] ?? COLORS.running;
// Glow effect for running agents
if (isRunning) {
const pulseSize = 8 + Math.sin(pulsePhase * Math.PI * 2) * 6;
const gradient = c.createRadialGradient(agent.x, agent.y, NODE_RADIUS, agent.x, agent.y, NODE_RADIUS + pulseSize);
gradient.addColorStop(0, color + '40');
gradient.addColorStop(1, 'transparent');
c.beginPath();
c.arc(agent.x, agent.y, NODE_RADIUS + pulseSize, 0, Math.PI * 2);
c.fillStyle = gradient;
c.fill();
}
// Selection ring
if (isSelected || isHovered) {
c.beginPath();
c.arc(agent.x, agent.y, NODE_RADIUS + 4, 0, Math.PI * 2);
c.strokeStyle = isHovered ? '#fff' : color;
c.lineWidth = 2;
c.setLineDash(isSelected ? [4, 4] : []);
c.stroke();
c.setLineDash([]);
}
// Node circle
c.beginPath();
c.arc(agent.x, agent.y, NODE_RADIUS, 0, Math.PI * 2);
const nodeGrad = c.createRadialGradient(agent.x - 8, agent.y - 8, 0, agent.x, agent.y, NODE_RADIUS);
nodeGrad.addColorStop(0, color);
nodeGrad.addColorStop(1, adjustColor(color, -30));
c.fillStyle = nodeGrad;
c.fill();
c.strokeStyle = adjustColor(color, -60);
c.lineWidth = 1.5;
c.stroke();
// Icon inside node
c.fillStyle = '#1a1b26';
c.font = 'bold 16px monospace';
c.textAlign = 'center';
c.textBaseline = 'middle';
if (agent.status === 'done' && agent.success) {
c.fillText('✓', agent.x, agent.y);
} else if (agent.status === 'done' && !agent.success) {
c.fillText('✗', agent.x, agent.y);
} else if (agent.status === 'running') {
const rotAngle = pulsePhase * Math.PI * 2;
c.save();
c.translate(agent.x, agent.y);
c.rotate(rotAngle);
c.fillText('⟳', 0, 0);
c.restore();
} else {
c.fillText('●', agent.x, agent.y);
}
// Agent name below
c.fillStyle = COLORS.text;
c.font = '11px monospace';
c.textAlign = 'center';
c.textBaseline = 'top';
c.fillText(agent.name, agent.x, agent.y + NODE_RADIUS + 6);
// Task preview below name
c.fillStyle = COLORS.textDim;
c.font = '9px monospace';
const taskPreview = agent.task.length > 30 ? agent.task.slice(0, 30) + '…' : agent.task;
c.fillText(taskPreview, agent.x, agent.y + NODE_RADIUS + 20);
// Tool call count badge
if (agent.toolCalls > 0) {
const badgeX = agent.x + NODE_RADIUS - 4;
const badgeY = agent.y - NODE_RADIUS + 4;
c.beginPath();
c.arc(badgeX, badgeY, 10, 0, Math.PI * 2);
c.fillStyle = '#24253a';
c.fill();
c.fillStyle = color;
c.font = 'bold 9px monospace';
c.textAlign = 'center';
c.textBaseline = 'middle';
c.fillText(String(agent.toolCalls), badgeX, badgeY);
}
}
c.restore();
c.restore();
}
function drawArrow(c: CanvasRenderingContext2D, fromX: number, fromY: number, toX: number, toY: number) {
const angle = Math.atan2(toY - fromY, toX - fromX);
const arrowLen = 8;
const r = NODE_RADIUS;
const endX = toX - Math.cos(angle) * r;
const endY = toY - Math.sin(angle) * r;
c.beginPath();
c.moveTo(endX, endY);
c.lineTo(endX - arrowLen * Math.cos(angle - Math.PI / 6), endY - arrowLen * Math.sin(angle - Math.PI / 6));
c.moveTo(endX, endY);
c.lineTo(endX - arrowLen * Math.cos(angle + Math.PI / 6), endY - arrowLen * Math.sin(angle + Math.PI / 6));
c.strokeStyle = COLORS.edge;
c.lineWidth = 2;
c.stroke();
}
function adjustColor(hex: string, amount: number): string {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.min(255, Math.max(0, ((num >> 16) & 0xff) + amount));
const g = Math.min(255, Math.max(0, ((num >> 8) & 0xff) + amount));
const b = Math.min(255, Math.max(0, (num & 0xff) + amount));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
function getNodeAt(x: number, y: number): string | null {
const worldX = (x - pan.x) / zoom;
const worldY = (y - pan.y) / zoom;
for (const [id, agent] of agentGraphStore.agents) {
const dx = worldX - agent.x;
const dy = worldY - agent.y;
if (dx * dx + dy * dy <= NODE_RADIUS * NODE_RADIUS) {
return id;
}
}
return null;
}
function handleClick(e: MouseEvent) {
const rect = canvas?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const nodeId = getNodeAt(x, y);
if (nodeId) {
agentGraphStore.selectAgent(nodeId);
}
}
function handleMouseMove(e: MouseEvent) {
const rect = canvas?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (dragging) {
const agent = agentGraphStore.agents.get(dragging.id);
if (agent) {
agent.x = (x - pan.x) / zoom - dragging.offsetX;
agent.y = (y - pan.y) / zoom - dragging.offsetY;
}
return;
}
const nodeId = getNodeAt(x, y);
hoveredId = nodeId;
if (canvas) {
canvas.style.cursor = nodeId ? 'pointer' : 'grab';
}
}
function handleMouseDown(e: MouseEvent) {
const rect = canvas?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const nodeId = getNodeAt(x, y);
if (nodeId) {
const agent = agentGraphStore.agents.get(nodeId);
if (agent) {
dragging = {
id: nodeId,
offsetX: (x - pan.x) / zoom - agent.x,
offsetY: (y - pan.y) / zoom - agent.y,
};
if (canvas) canvas.style.cursor = 'grabbing';
}
}
}
function handleMouseUp() {
dragging = null;
if (canvas) canvas.style.cursor = 'grab';
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
zoom = Math.max(0.3, Math.min(3, zoom * delta));
}
</script>
<div class="graph-container">
{#if agentGraphStore.agents.size === 0}
<div class="empty-state">
<div class="empty-icon">◇</div>
<p class="empty-text">No swarm agents active</p>
<p class="empty-hint">Send a message to start the hive</p>
</div>
{:else}
<canvas
bind:this={canvas}
{width}
{height}
onclick={handleClick}
onmousemove={handleMouseMove}
onmousedown={handleMouseDown}
onmouseup={handleMouseUp}
onmouseleave={handleMouseUp}
onwheel={handleWheel}
></canvas>
<div class="graph-legend">
<span class="legend-item"><span class="dot running"></span> Running</span>
<span class="legend-item"><span class="dot done"></span> Done</span>
<span class="legend-item"><span class="dot error"></span> Failed</span>
<span class="legend-item"><span class="dot paused"></span> Paused</span>
</div>
{#if agentGraphStore.phase}
<div class="phase-label">{agentGraphStore.phase}</div>
{/if}
{/if}
</div>
<style>
.graph-container {
width: 100%;
height: 100%;
position: relative;
background: var(--bg);
border-radius: var(--radius, 6px);
overflow: hidden;
}
canvas {
display: block;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 0.5rem;
}
.empty-icon {
font-size: 2rem;
color: var(--accent);
opacity: 0.5;
}
.empty-text {
color: var(--fg-dim);
font-size: 0.9rem;
margin: 0;
}
.empty-hint {
color: var(--fg-dim);
font-size: 0.75rem;
margin: 0;
opacity: 0.6;
}
.graph-legend {
position: absolute;
top: 0.5rem;
right: 0.5rem;
display: flex;
gap: 0.75rem;
font-size: 0.7rem;
color: var(--fg-dim);
background: var(--bg-surface);
padding: 0.25rem 0.5rem;
border-radius: 4px;
border: 1px solid var(--border);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot.running { background: #7aa2f7; }
.dot.done { background: #9ece6a; }
.dot.error { background: #f7768e; }
.dot.paused { background: #e0af68; }
.phase-label {
position: absolute;
bottom: 0.5rem;
left: 50%;
transform: translateX(-50%);
font-size: 0.7rem;
color: var(--fg-dim);
background: var(--bg-surface);
padding: 0.2rem 0.5rem;
border-radius: 4px;
border: 1px solid var(--border);
max-width: 80%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>