<script lang="ts">
import { appStore, type HiveAgent } from '$lib/store.svelte.js';
const RADIUS = 160;
const NODE_R = 32;
const CENTER = { x: 200, y: 200 };
const SVG_SIZE = 400;
let agents = $derived(Array.from(appStore.hiveAgents.values()));
// Position agents in a circle around the coordinator node
function nodePos(index: number, total: number) {
const angle = (2 * Math.PI * index) / Math.max(total, 1) - Math.PI / 2;
return {
x: CENTER.x + RADIUS * Math.cos(angle),
y: CENTER.y + RADIUS * Math.sin(angle),
};
}
function statusColor(agent: HiveAgent): string {
if (agent.done && agent.success) return 'var(--green)';
if (agent.done && !agent.success) return 'var(--red)';
if (agent.approaching) return 'var(--yellow)';
if (agent.status === 'started' || agent.iteration > 0) return 'var(--accent)';
return 'var(--fg-dim)';
}
function statusIcon(agent: HiveAgent): string {
if (agent.done && agent.success) return '\u2713';
if (agent.done && !agent.success) return '\u2717';
if (agent.approaching) return '!';
if (agent.status === 'started' || agent.iteration > 0) return '\u25B6';
return '\u23F3';
}
function isRunning(agent: HiveAgent): boolean {
return !agent.done && (agent.status === 'started' || agent.iteration > 0);
}
function selectAgent(id: string) {
appStore.selectedAgentId = appStore.selectedAgentId === id ? null : id;
}
function shortId(id: string): string {
return id.length > 6 ? id.slice(0, 6) : id;
}
function shortTask(task: string): string {
return task.length > 20 ? task.slice(0, 20) + '\u2026' : task;
}
</script>
<div class="swarm-graph">
<div class="graph-header">
<span class="graph-title">Swarm</span>
<span class="agent-count">{agents.length} agents</span>
<button class="close-btn" onclick={() => (appStore.showSwarmGraph = false)}>\u00D7</button>
</div>
<svg
viewBox="0 0 {SVG_SIZE} {SVG_SIZE}"
xmlns="http://www.w3.org/2000/svg"
class="graph-svg"
>
<defs>
<marker id="arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<path d="M0,0 L8,3 L0,6" fill="var(--fg-dim)" opacity="0.4" />
</marker>
</defs>
<!-- Dependency edges -->
{#each agents as agent, i}
{@const pos = nodePos(i, agents.length)}
{#each agent.dependencies as depId}
{@const depIdx = agents.findIndex((a) => a.id === depId)}
{#if depIdx >= 0}
{@const depPos = nodePos(depIdx, agents.length)}
<line
x1={depPos.x}
y1={depPos.y}
x2={pos.x}
y2={pos.y}
stroke="var(--fg-dim)"
stroke-width="1.5"
opacity="0.3"
marker-end="url(#arrow)"
/>
{/if}
{/each}
{/each}
<!-- Connector lines from coordinator to agents -->
{#each agents as _, i}
{@const pos = nodePos(i, agents.length)}
<line
x1={CENTER.x}
y1={CENTER.y}
x2={pos.x}
y2={pos.y}
stroke="var(--fg-dim)"
stroke-width="1"
opacity="0.15"
stroke-dasharray="4,4"
/>
{/each}
<!-- Coordinator center node -->
<circle cx={CENTER.x} cy={CENTER.y} r={NODE_R * 0.7} fill="var(--bg-elevated)" stroke="var(--accent)" stroke-width="2" />
<text x={CENTER.x} y={CENTER.y + 4} text-anchor="middle" fill="var(--accent)" font-size="10" font-weight="bold">COORD</text>
<!-- Agent nodes -->
{#each agents as agent, i}
{@const pos = nodePos(i, agents.length)}
{@const color = statusColor(agent)}
{@const selected = appStore.selectedAgentId === agent.id}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<g class="agent-node" class:running={isRunning(agent)} onclick={() => selectAgent(agent.id)}>
<!-- Selection ring -->
{#if selected}
<circle cx={pos.x} cy={pos.y} r={NODE_R + 4} fill="none" stroke={color} stroke-width="2" opacity="0.6" />
{/if}
<!-- Main circle -->
<circle
cx={pos.x}
cy={pos.y}
r={NODE_R}
fill="var(--bg-elevated)"
stroke={color}
stroke-width={selected ? 3 : 2}
/>
<!-- Status icon -->
<text x={pos.x} y={pos.y - 6} text-anchor="middle" fill={color} font-size="14" font-weight="bold">
{statusIcon(agent)}
</text>
<!-- Agent name / short ID -->
<text x={pos.x} y={pos.y + 8} text-anchor="middle" fill="var(--fg)" font-size="8" font-weight="600">
{agent.name || shortId(agent.id)}
</text>
<!-- Iteration count -->
{#if agent.iteration > 0}
<text x={pos.x} y={pos.y + 18} text-anchor="middle" fill="var(--fg-dim)" font-size="7">
#{agent.iteration}
</text>
{/if}
<!-- Approaching warning badge -->
{#if agent.approaching && !agent.done}
<circle cx={pos.x + NODE_R * 0.7} cy={pos.y - NODE_R * 0.7} r="8" fill="var(--yellow)" />
<text x={pos.x + NODE_R * 0.7} y={pos.y - NODE_R * 0.7 + 3} text-anchor="middle" fill="var(--bg)" font-size="8" font-weight="bold">
{agent.approachingRemaining}
</text>
{/if}
</g>
{/each}
</svg>
<!-- Agent task labels below graph -->
{#if agents.length > 0}
<div class="agent-list">
{#each agents as agent}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="agent-label"
class:selected={appStore.selectedAgentId === agent.id}
onclick={() => selectAgent(agent.id)}
>
<span class="dot" style="background: {statusColor(agent)}"></span>
<span class="name">{agent.name || shortId(agent.id)}</span>
<span class="task">{shortTask(agent.task)}</span>
</div>
{/each}
</div>
{/if}
</div>
<style>
.swarm-graph {
padding: 8px;
background: var(--bg-surface);
border-radius: 8px;
border: 1px solid var(--fg-dim, #333);
margin-bottom: 8px;
}
.graph-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
padding: 0 4px;
}
.graph-title {
font-weight: 700;
font-size: 13px;
color: var(--accent);
}
.agent-count {
font-size: 11px;
color: var(--fg-dim);
flex: 1;
}
.close-btn {
background: none;
border: none;
color: var(--fg-dim);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
}
.close-btn:hover {
color: var(--fg);
}
.graph-svg {
width: 100%;
max-height: 360px;
}
.agent-node {
cursor: pointer;
transition: opacity 0.15s;
}
.agent-node:hover {
opacity: 0.85;
}
/* Pulsing animation for running nodes */
.agent-node.running circle:nth-child(2) {
animation: pulse 1.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.agent-list {
display: flex;
flex-direction: column;
gap: 3px;
padding: 4px;
max-height: 120px;
overflow-y: auto;
}
.agent-label {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
border-radius: 4px;
font-size: 11px;
cursor: pointer;
transition: background 0.1s;
}
.agent-label:hover,
.agent-label.selected {
background: var(--bg-elevated);
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.name {
color: var(--cyan);
font-weight: 600;
min-width: 40px;
}
.task {
color: var(--fg-dim);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>