diff --git a/README.md b/README.md index 3b4655c..b6b2789 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,24 @@ You can also point Agent Flow at a JSONL event log file: - Claude Code CLI - For the VS Code extension: a VSCode-compatible IDE 1.85+ (e.g. [VS Code](https://code.visualstudio.com/), [Cursor](https://cursor.sh/), [Windsurf](https://windsurf.com/)) +## Query Parameters + +### Label Size + +Control agent name label width with the `labelSize` query parameter: + +- `?labelSize=compact` — Short labels (multiplier: 2x) +- `?labelSize=normal` — Default (multiplier: 3.5x) +- `?labelSize=wide` — Wider labels (multiplier: 5x) +- `?labelSize=full` — Full width labels (multiplier: 8x) + +Example: `http://127.0.0.1:4321?labelSize=wide` + +### Debug Overlays + +- `?perf` — Show performance overlay (FPS, frame times) +- `?stress` — Enable stress test mode with performance overlay + ## Development ```bash diff --git a/app/src/static.ts b/app/src/static.ts index 1fc666e..e06ae9b 100644 --- a/app/src/static.ts +++ b/app/src/static.ts @@ -31,15 +31,16 @@ const MIME_TYPES: Record = { export function serveStatic(req: http.IncomingMessage, res: http.ServerResponse) { const url = req.url || '/' + const pathname = url.split('?')[0] - if (url === '/' || url === '/index.html') { + if (pathname === '/' || pathname === '/index.html') { res.writeHead(200, { 'Content-Type': 'text/html' }) res.end(HTML_SHELL) return } // Only serve known asset files from the webview directory - const basename = path.basename(url) + const basename = path.basename(pathname) const ext = path.extname(basename) const mime = MIME_TYPES[ext] diff --git a/extension/src/constants.ts b/extension/src/constants.ts index 2ad7ab1..415a827 100644 --- a/extension/src/constants.ts +++ b/extension/src/constants.ts @@ -186,7 +186,7 @@ export function generateSubagentFallbackName(id: string, index: number): string /** Extract a child agent name from a tool_use input block (Agent or Task tool). * Used by both live processing and prescan to avoid duplicating the extraction logic. */ export function resolveSubagentChildName(input: Record): string { - return String(input.description || input.subagent_type || 'subagent').slice(0, CHILD_NAME_MAX) + return String(input.description || input.subagent_type || input.agentType || 'subagent').slice(0, CHILD_NAME_MAX) } /** Prefixes that identify system-injected content (not real user messages) */ diff --git a/extension/src/hook-server.ts b/extension/src/hook-server.ts index d3f389b..7f5060d 100644 --- a/extension/src/hook-server.ts +++ b/extension/src/hook-server.ts @@ -5,7 +5,7 @@ import { ORCHESTRATOR_NAME, PREVIEW_MAX, RESULT_MAX, SESSION_ID_DISPLAY, FAILED_RESULT_MAX, HOOK_MAX_BODY_SIZE, SUBAGENT_ID_SUFFIX_LENGTH, HOOK_SERVER_HOST, HOOK_SERVER_NOT_STARTED, - generateSubagentFallbackName, + generateSubagentFallbackName, resolveSubagentChildName, } from './constants' import { summarizeInput, summarizeResult, extractFilePath, buildDiscovery } from './tool-summarizer' import { estimateTokenCost } from './token-estimator' @@ -259,14 +259,51 @@ export class HookServer implements vscode.Disposable { const agentType = payload.agent_type || 'subagent' const agentId = payload.agent_id || '' const sessionAgents = this.getOrCreateSession(payload.session_id).agentNames - const childName = agentId ? `${agentType}-${agentId.slice(-SUBAGENT_ID_SUFFIX_LENGTH)}` : generateSubagentFallbackName(String(Date.now()), sessionAgents.size + 1) - sessionAgents.set(agentId, childName) + const self = this + const tryReadMeta = (): string | null => { + try { + if (agentId && payload.session_id && payload.cwd) { + const fs = require('fs') + const path = require('path') + const os = require('os') + const resolvedCwd = fs.realpathSync(payload.cwd) + const encoded = resolvedCwd.replace(/[/\\:.]/g, '-') + const metaPath = path.join( + os.homedir(), '.claude', 'projects', encoded, + payload.session_id, 'subagents', `agent-${agentId}.meta.json`, + ) + const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8')) + const name = resolveSubagentChildName(meta) + if (name && name !== 'subagent') return name + } + } catch { /* meta.json may not exist yet */ } + return null + } + + const doSpawn = (childName: string) => { + sessionAgents.set(agentId, childName) + emitSubagentSpawn( + { emit: (e, s) => self.emit(e, s), elapsed: (s) => self.elapsedSeconds(s) }, + parentName, childName, agentType, payload.session_id, agentType, + ) + } + + // Try immediately — meta.json may already exist + const immediate = tryReadMeta() + if (immediate) { + doSpawn(immediate) + return + } - emitSubagentSpawn( - { emit: (e, s) => this.emit(e, s), elapsed: (s) => this.elapsedSeconds(s) }, - parentName, childName, agentType, payload.session_id, - ) + // Retry after 250ms — Claude Code writes meta.json shortly after SubagentStart + setTimeout(() => { + const name = tryReadMeta() + || (agentId + ? `${agentType}-${agentId.slice(-SUBAGENT_ID_SUFFIX_LENGTH)}` + : generateSubagentFallbackName(String(Date.now()), sessionAgents.size + 1)) + doSpawn(name) + }, 250) } private handleSubagentStop(payload: HookPayload): void { diff --git a/extension/src/protocol.ts b/extension/src/protocol.ts index dc6eb06..f35b353 100644 --- a/extension/src/protocol.ts +++ b/extension/src/protocol.ts @@ -127,6 +127,7 @@ export function emitSubagentSpawn( child: string, task: string, sessionId?: string, + agentType?: string, ): void { emitter.emit({ time: emitter.elapsed(sessionId), @@ -136,7 +137,7 @@ export function emitSubagentSpawn( emitter.emit({ time: emitter.elapsed(sessionId), type: 'agent_spawn', - payload: { name: child, parent, task }, + payload: { name: child, parent, task, ...(agentType ? { agentType } : {}) }, }, sessionId) } diff --git a/extension/src/transcript-parser.ts b/extension/src/transcript-parser.ts index c84a89e..8821190 100644 --- a/extension/src/transcript-parser.ts +++ b/extension/src/transcript-parser.ts @@ -256,12 +256,13 @@ export class TranscriptParser { // Check if this is a subagent call (Task in older Claude Code, Agent in newer versions) if (toolName === 'Task' || toolName === 'Agent') { const childName = resolveSubagentChildName(block.input) + const agentType = String(block.input.subagent_type || block.input.agentType || '') this.subagentChildNames.set(block.id, childName) // Only emit spawn once per subagent name (file watcher may have already spawned it) const session = sessionId ? this.delegate.getSession(sessionId) : undefined if (!session?.spawnedSubagents.has(childName)) { session?.spawnedSubagents.add(childName) - emitSubagentSpawn(this.delegate, agentName, childName, args, sessionId) + emitSubagentSpawn(this.delegate, agentName, childName, args, sessionId, agentType || undefined) } } diff --git a/scripts/relay.ts b/scripts/relay.ts index f6b9a9d..6e0001a 100644 --- a/scripts/relay.ts +++ b/scripts/relay.ts @@ -241,7 +241,7 @@ function scanForActiveSessions(workspace: string) { let resolved = workspace try { resolved = fs.realpathSync(resolved) } catch {} - const encoded = resolved.replace(/[/\\:]/g, '-') + const encoded = resolved.replace(/[/\\:.]/g, '-') const dirsToScan: string[] = [] const projectDir = path.join(CLAUDE_DIR, encoded) @@ -353,7 +353,7 @@ export async function createRelay(options: RelayOptions): Promise { const scanInterval = setInterval(() => scanForActiveSessions(workspace), SCAN_INTERVAL_MS) const resolved = (() => { try { return fs.realpathSync(workspace) } catch { return workspace } })() - const encoded = resolved.replace(/[/\\:]/g, '-') + const encoded = resolved.replace(/[/\\:.]/g, '-') const projectDir = path.join(CLAUDE_DIR, encoded) let projectDirWatcher: fs.FSWatcher | null = null if (fs.existsSync(projectDir)) { diff --git a/web/components/agent-visualizer/canvas.tsx b/web/components/agent-visualizer/canvas.tsx index f8ad9fa..c5a935c 100644 --- a/web/components/agent-visualizer/canvas.tsx +++ b/web/components/agent-visualizer/canvas.tsx @@ -16,6 +16,7 @@ import { drawEdges, getActiveEdgeIds, drawParticles, buildEdgeMap, drawToolCalls, + drawServiceNodes, drawDiscoveries, drawDiscoveryConnections, drawCostLabels, drawCostSummaryPanel, detectStateChanges as detectStateChangesPure, @@ -90,7 +91,7 @@ export function AgentCanvas({ // at the top of each draw frame, so it's always fresh even without re-renders. const sim = simulationRef.current const makeDrawProps = (prev?: { isDragging: boolean }) => ({ - agents: sim.agents, toolCalls: sim.toolCalls, + agents: sim.agents, toolCalls: sim.toolCalls, serviceNodes: sim.serviceNodes, particles: sim.particles, edges: sim.edges, discoveries: sim.discoveries, selectedAgentId, hoveredAgentId, showStats, showHexGrid, showCostOverlay, selectedToolCallId, selectedDiscoveryId, @@ -182,6 +183,7 @@ export function AgentCanvas({ const p = drawPropsRef.current p.agents = s.agents p.toolCalls = s.toolCalls + p.serviceNodes = s.serviceNodes p.particles = s.particles p.edges = s.edges p.discoveries = s.discoveries @@ -189,7 +191,7 @@ export function AgentCanvas({ } const { - agents, toolCalls, particles, edges, discoveries, + agents, toolCalls, serviceNodes, particles, edges, discoveries, selectedAgentId, hoveredAgentId, showStats, showHexGrid, showCostOverlay, selectedToolCallId, selectedDiscoveryId, simTime, pauseAutoFit, dimensions, onAgentDrag, @@ -266,13 +268,14 @@ export function AgentCanvas({ } drawDiscoveryConnections(ctx, discoveries, agents) - drawEdges(ctx, edges, agents, toolCalls, activeEdgeIds, timeRef.current) + drawEdges(ctx, edges, agents, toolCalls, activeEdgeIds, timeRef.current, serviceNodes) + drawServiceNodes(ctx, serviceNodes, timeRef.current) drawToolCalls(ctx, toolCalls, timeRef.current, selectedToolCallId) drawDiscoveries(ctx, discoveries, agents, selectedDiscoveryId) drawAgents(ctx, agents, selectedAgentId, hoveredAgentId, showStats, timeRef.current) drawMessageBubblesWorld(ctx, agents, simTimeRef.current) if (showCostOverlay) drawCostLabels(ctx, agents, toolCalls) - drawParticles(ctx, particles, edgeMap, agents, toolCalls, timeRef.current) + drawParticles(ctx, particles, edgeMap, agents, toolCalls, timeRef.current, serviceNodes) drawEffects(ctx, effectsRef.current) if (selectedAgentId) { diff --git a/web/components/agent-visualizer/canvas/draw-agents.ts b/web/components/agent-visualizer/canvas/draw-agents.ts index eec787e..a334657 100644 --- a/web/components/agent-visualizer/canvas/draw-agents.ts +++ b/web/components/agent-visualizer/canvas/draw-agents.ts @@ -277,13 +277,25 @@ function drawWaitingRipples(ctx: CanvasRenderingContext2D, agent: Agent, r: numb } function drawAgentLabel(ctx: CanvasRenderingContext2D, agent: Agent, r: number, isHovered: boolean) { - ctx.fillStyle = isHovered ? COLORS.textPrimary : COLORS.textDim - ctx.font = '10px monospace' ctx.textAlign = 'center' ctx.textBaseline = 'top' const maxLabelW = r * AGENT_DRAW.labelWidthMultiplier - const agentLabel = truncateText(ctx, agent.name, maxLabelW) - ctx.fillText(agentLabel, agent.x, agent.y + r + AGENT_DRAW.labelYOffset) + let labelY = agent.y + r + AGENT_DRAW.labelYOffset + + // Line 1: agent type (for subagents) or agent name (for orchestrator) — bold, bright + const primaryLabel = agent.agentType || agent.name + ctx.fillStyle = isHovered ? COLORS.textPrimary : COLORS.textPrimary + ctx.font = 'bold 10px monospace' + ctx.fillText(truncateText(ctx, primaryLabel, maxLabelW), agent.x, labelY) + labelY += 13 + + // Line 2: description (for subagents) or task/user message (for orchestrator) — dim + const secondaryLabel = agent.agentType ? agent.name : agent.task + if (secondaryLabel) { + ctx.fillStyle = isHovered ? COLORS.textPrimary : COLORS.textDim + ctx.font = '10px monospace' + ctx.fillText(truncateText(ctx, secondaryLabel, maxLabelW), agent.x, labelY) + } } function drawStatsOverlay(ctx: CanvasRenderingContext2D, agent: Agent, r: number) { diff --git a/web/components/agent-visualizer/canvas/draw-edges.ts b/web/components/agent-visualizer/canvas/draw-edges.ts index 55b0e2d..fab4562 100644 --- a/web/components/agent-visualizer/canvas/draw-edges.ts +++ b/web/components/agent-visualizer/canvas/draw-edges.ts @@ -1,4 +1,4 @@ -import { Agent, ToolCallNode, Particle, Edge, BEAM, ANIM } from '@/lib/agent-types' +import { Agent, ToolCallNode, ServiceNode, Particle, Edge, BEAM, ANIM } from '@/lib/agent-types' import { COLORS } from '@/lib/colors' import { alphaHex } from '@/lib/utils' import { MIN_VISIBLE_OPACITY } from '@/lib/canvas-constants' @@ -8,15 +8,19 @@ export function bezierPoint(t: number, p0: number, p1: number, p2: number, p3: n return mt * mt * mt * p0 + 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t * p3 } -/** Resolve edge endpoint to {x, y} from either agents or toolCalls map */ +/** Resolve edge endpoint to {x, y} from agents, toolCalls, or serviceNodes map */ export function resolveEdgeTarget( edge: Edge, agents: Map, toolCalls: Map, - minOpacity = 0, + minOpacity = 0, serviceNodes?: Map, ): { x: number; y: number } | null { const toAgent = agents.get(edge.to) if (toAgent && toAgent.opacity >= minOpacity) return toAgent const toTool = toolCalls.get(edge.to) if (toTool && toTool.opacity >= minOpacity) return toTool + if (serviceNodes) { + const toService = serviceNodes.get(edge.to) + if (toService && toService.opacity >= minOpacity) return toService + } return null } @@ -105,12 +109,13 @@ export function drawEdges( toolCalls: Map, activeEdgeIds: Set, time: number, + serviceNodes?: Map, ) { for (const edge of edges) { const fromAgent = agents.get(edge.from) if (!fromAgent || fromAgent.opacity < MIN_VISIBLE_OPACITY) continue - const target = resolveEdgeTarget(edge, agents, toolCalls, MIN_VISIBLE_OPACITY) + const target = resolveEdgeTarget(edge, agents, toolCalls, MIN_VISIBLE_OPACITY, serviceNodes) if (!target) continue const toX = target.x, toY = target.y @@ -123,8 +128,8 @@ export function drawEdges( if (!cp) continue const { cp1x, cp1y, cp2x, cp2y } = cp - const beamColor = edge.type === 'tool' ? COLORS.tool : COLORS.holoBase - const bw = edge.type === 'tool' ? BEAM.tool : BEAM.parentChild + const beamColor = edge.type === 'service' ? COLORS.service : edge.type === 'tool' ? COLORS.tool : COLORS.holoBase + const bw = edge.type === 'tool' ? BEAM.tool : edge.type === 'service' ? BEAM.parentChild : BEAM.parentChild ctx.save() diff --git a/web/components/agent-visualizer/canvas/draw-particles.ts b/web/components/agent-visualizer/canvas/draw-particles.ts index 4997997..060703c 100644 --- a/web/components/agent-visualizer/canvas/draw-particles.ts +++ b/web/components/agent-visualizer/canvas/draw-particles.ts @@ -1,4 +1,4 @@ -import { Agent, ToolCallNode, Particle, Edge, BEAM, FX } from '@/lib/agent-types' +import { Agent, ToolCallNode, ServiceNode, Particle, Edge, BEAM, FX } from '@/lib/agent-types' import { COLORS } from '@/lib/colors' import { PARTICLE_DRAW } from '@/lib/canvas-constants' import { alphaHex } from '@/lib/utils' @@ -19,6 +19,7 @@ export function drawParticles( agents: Map, toolCalls: Map, time: number, + serviceNodes?: Map, ) { for (const particle of particles) { const edge = edgeMap.get(particle.edgeId) @@ -27,7 +28,7 @@ export function drawParticles( const fromAgent = agents.get(edge.from) if (!fromAgent) continue - const target = resolveEdgeTarget(edge, agents, toolCalls) + const target = resolveEdgeTarget(edge, agents, toolCalls, 0, serviceNodes) if (!target) continue const toX = target.x, toY = target.y diff --git a/web/components/agent-visualizer/canvas/draw-service-nodes.ts b/web/components/agent-visualizer/canvas/draw-service-nodes.ts new file mode 100644 index 0000000..47f2a63 --- /dev/null +++ b/web/components/agent-visualizer/canvas/draw-service-nodes.ts @@ -0,0 +1,177 @@ +import { ServiceNode } from '@/lib/agent-types' +import { COLORS } from '@/lib/colors' +import { SERVICE_NODE } from '@/lib/canvas-constants' +import { truncateText } from './draw-misc' + +/** Draw a hexagonal path centered at (cx, cy) with given radius */ +function hexPath(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number) { + ctx.beginPath() + for (let i = 0; i < 6; i++) { + const angle = (Math.PI / 3) * i - Math.PI / 6 + const x = cx + r * Math.cos(angle) + const y = cy + r * Math.sin(angle) + if (i === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) + } + ctx.closePath() +} + +// ─── Brand SVG Paths (Simple Icons / Bootstrap Icons, viewBox 0 0 24 24) ─── +// Sources: simpleicons.org, icons.getbootstrap.com + +const ICON_PATHS: Record = { + github: { + viewBox: 24, + d: 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12', + }, + 'azure-devops': { + viewBox: 24, + d: 'M0 8.877L2.247 5.91l8.405-3.416V.022l7.37 5.393L2.966 8.338v8.225L0 15.707zm24-4.45v14.651l-5.753 4.9-9.303-3.057v3.056l-5.978-7.416 15.057 1.798V5.415z', + }, + azure: { + viewBox: 24, + d: 'M0 8.877L2.247 5.91l8.405-3.416V.022l7.37 5.393L2.966 8.338v8.225L0 15.707zm24-4.45v14.651l-5.753 4.9-9.303-3.057v3.056l-5.978-7.416 15.057 1.798V5.415z', + }, + google: { + viewBox: 24, + d: 'M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z', + }, + gmail: { + viewBox: 24, + d: 'M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z', + }, + slack: { + viewBox: 16, // Bootstrap Icons uses 16x16 viewBox + d: 'M3.362 10.11c0 .926-.756 1.681-1.681 1.681S0 11.036 0 10.111.756 8.43 1.68 8.43h1.682zm.846 0c0-.924.756-1.68 1.681-1.68s1.681.756 1.681 1.68v4.21c0 .924-.756 1.68-1.68 1.68a1.685 1.685 0 0 1-1.682-1.68zM5.89 3.362c-.926 0-1.682-.756-1.682-1.681S4.964 0 5.89 0s1.68.756 1.68 1.68v1.682zm0 .846c.924 0 1.68.756 1.68 1.681S6.814 7.57 5.89 7.57H1.68C.757 7.57 0 6.814 0 5.89c0-.926.756-1.682 1.68-1.682zm6.749 1.682c0-.926.755-1.682 1.68-1.682S16 4.964 16 5.889s-.756 1.681-1.68 1.681h-1.681zm-.848 0c0 .924-.755 1.68-1.68 1.68A1.685 1.685 0 0 1 8.43 5.89V1.68C8.43.757 9.186 0 10.11 0c.926 0 1.681.756 1.681 1.68zm-1.681 6.748c.926 0 1.682.756 1.682 1.681S11.036 16 10.11 16s-1.681-.756-1.681-1.68v-1.682h1.68zm0-.847c-.924 0-1.68-.755-1.68-1.68s.756-1.681 1.68-1.681h4.21c.924 0 1.68.756 1.68 1.68 0 .926-.756 1.681-1.68 1.681z', + }, +} + +// ─── Path2D cache ────────────────────────────────────────────────────────── + +const pathCache = new Map() + +function getIconPath(key: string): Path2D | null { + const cached = pathCache.get(key) + if (cached) return cached + const entry = ICON_PATHS[key] + if (!entry) return null + const p = new Path2D(entry.d) + pathCache.set(key, p) + return p +} + +/** Resolve which icon key to use for a given MCP server name */ +function resolveIconKey(name: string): string | null { + if (name.includes('github')) return 'github' + if (name === 'azure-devops') return 'azure-devops' + if (name.includes('azure')) return 'azure' + if (name.includes('google') && !name.includes('gmail')) return 'google' + if (name.includes('gmail') || name === 'claude_ai_Gmail') return 'gmail' + if (name.includes('slack') || name === 'claude_ai_Slack') return 'slack' + return null +} + +/** Fallback: draw a simple globe with canvas API */ +function drawGlobeFallback(ctx: CanvasRenderingContext2D, s: number) { + const r = s * 0.55 + ctx.lineWidth = s * 0.08 + ctx.beginPath() + ctx.arc(0, 0, r, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.ellipse(0, 0, r, r * 0.3, 0, 0, Math.PI * 2) + ctx.stroke() + ctx.beginPath() + ctx.ellipse(0, 0, r * 0.3, r, 0, 0, Math.PI * 2) + ctx.stroke() +} + +// ─── Main draw function ──────────────────────────────────────────────────── + +export function drawServiceNodes( + ctx: CanvasRenderingContext2D, + serviceNodes: Map, + time: number, +) { + for (const [, svc] of serviceNodes) { + if (svc.opacity <= 0) continue + + const r = SERVICE_NODE.radius * svc.scale + const isActive = svc.activeCalls > 0 + const pulse = isActive + ? Math.sin(time * SERVICE_NODE.pulseSpeed) * 0.15 + 0.85 + : 1 + + ctx.save() + ctx.globalAlpha = svc.opacity + + // Outer glow when active + if (isActive) { + ctx.shadowColor = COLORS.serviceGlow + ctx.shadowBlur = SERVICE_NODE.glowPadding * pulse + } + + // Hexagonal body + hexPath(ctx, svc.x, svc.y, r) + ctx.fillStyle = `rgba(40, 15, 35, ${0.7 * pulse})` + ctx.fill() + ctx.strokeStyle = isActive ? COLORS.service : COLORS.serviceDim + ctx.lineWidth = isActive ? 2 : 1 + ctx.stroke() + + ctx.shadowBlur = 0 + + // Inner hex ring (decorative) + hexPath(ctx, svc.x, svc.y, r * 0.75) + ctx.strokeStyle = COLORS.service + '30' + ctx.lineWidth = 0.5 + ctx.stroke() + + // Brand icon via SVG Path2D, or fallback globe + const iconKey = resolveIconKey(svc.name) + const iconPath = iconKey ? getIconPath(iconKey) : null + + if (iconPath && iconKey) { + const entry = ICON_PATHS[iconKey] + const vb = entry.viewBox + const iconSize = r * 0.6 + const iconScale = (iconSize * 2) / vb + + ctx.save() + ctx.translate(svc.x - iconSize, svc.y - iconSize) + ctx.scale(iconScale, iconScale) + ctx.fillStyle = COLORS.serviceText + ctx.fill(iconPath) + ctx.restore() + } else { + // Fallback: draw globe with canvas API + ctx.save() + ctx.translate(svc.x, svc.y) + ctx.strokeStyle = COLORS.serviceText + ctx.fillStyle = 'transparent' + drawGlobeFallback(ctx, r * 0.55) + ctx.restore() + } + + // Label: service display name + const maxLabelW = r * SERVICE_NODE.labelWidthMultiplier + const labelY = svc.y + r + SERVICE_NODE.labelYOffset + + ctx.textAlign = 'center' + ctx.font = 'bold 9px monospace' + ctx.fillStyle = COLORS.serviceText + ctx.textBaseline = 'top' + ctx.fillText(truncateText(ctx, svc.displayName, maxLabelW), svc.x, labelY) + + // Stats: call count + const statsY = labelY + SERVICE_NODE.statsYOffset + ctx.font = `${SERVICE_NODE.statsFontSize}px monospace` + ctx.fillStyle = COLORS.serviceDim + const statsText = svc.activeCalls > 0 + ? `${svc.totalCalls} calls (${svc.activeCalls} active)` + : `${svc.totalCalls} calls` + ctx.fillText(truncateText(ctx, statsText, maxLabelW), svc.x, statsY) + + ctx.restore() + } +} diff --git a/web/components/agent-visualizer/canvas/index.ts b/web/components/agent-visualizer/canvas/index.ts index 9c0fbcc..341fbe8 100644 --- a/web/components/agent-visualizer/canvas/index.ts +++ b/web/components/agent-visualizer/canvas/index.ts @@ -6,6 +6,7 @@ export { drawMessageBubblesWorld } from './draw-bubbles' export { drawEdges, getActiveEdgeIds } from './draw-edges' export { drawParticles, buildEdgeMap } from './draw-particles' export { drawToolCalls } from './draw-tool-calls' +export { drawServiceNodes } from './draw-service-nodes' export { drawDiscoveries, drawDiscoveryConnections } from './draw-discoveries' export { drawCostLabels, drawCostSummaryPanel, agentCost } from './draw-cost' export { findAgentAt, findToolCallAt, findBubbleAgentAt, findDiscoveryAt } from './hit-detection' diff --git a/web/hooks/simulation/animate.ts b/web/hooks/simulation/animate.ts index dd08b7d..d37c461 100644 --- a/web/hooks/simulation/animate.ts +++ b/web/hooks/simulation/animate.ts @@ -83,6 +83,7 @@ function cleanupFaded( edges: SimulationState['edges'], originalAgents: SimulationState['agents'], originalToolCalls: SimulationState['toolCalls'], + serviceNodes?: SimulationState['serviceNodes'], ): { agents: SimulationState['agents']; toolCalls: SimulationState['toolCalls']; edges: SimulationState['edges'] } { let newAgents = agents let newToolCalls = toolCalls @@ -119,12 +120,48 @@ function cleanupFaded( filteredEdges = filteredEdges.filter(e => { const fromExists = newAgents.has(e.from) if (!fromExists) return false - const toExists = newAgents.has(e.to) || newToolCalls.has(e.to) + const toExists = newAgents.has(e.to) || newToolCalls.has(e.to) || (serviceNodes?.has(e.to) ?? false) return toExists }) return { agents: newAgents, toolCalls: newToolCalls, edges: filteredEdges } } +/** Seconds of inactivity before a service node starts fading out */ +const SERVICE_IDLE_TIMEOUT_S = 60 + +function animateServiceNodes(serviceNodes: SimulationState['serviceNodes'], deltaTime: number, currentTime: number): SimulationState['serviceNodes'] { + let newNodes = serviceNodes + for (const [id, svc] of serviceNodes) { + let updated = false + let opacity = svc.opacity + let scale = svc.scale + const idleTime = currentTime - svc.lastActiveTime + const shouldFadeOut = svc.activeCalls === 0 && idleTime > SERVICE_IDLE_TIMEOUT_S + + if (shouldFadeOut) { + // Fade out after idle timeout + if (opacity > 0) { opacity = Math.max(0, opacity - deltaTime * ANIM_SPEED.agentFadeOut); updated = true } + } else { + // Fade in + if (opacity < 1) { opacity = Math.min(1, opacity + deltaTime * ANIM_SPEED.agentFadeIn); updated = true } + if (scale < 1) { scale = Math.min(1, scale + deltaTime * ANIM_SPEED.agentScaleIn); updated = true } + } + + if (updated) { + if (newNodes === serviceNodes) newNodes = new Map(serviceNodes) + newNodes.set(id, { ...svc, opacity, scale }) + } + } + // Cleanup fully faded service nodes + for (const [id, svc] of newNodes) { + if (svc.opacity <= 0) { + if (newNodes === serviceNodes) newNodes = new Map(serviceNodes) + newNodes.delete(id) + } + } + return newNodes +} + function animateDiscoveries(discoveries: SimulationState['discoveries'], deltaTime: number, newTime: number): SimulationState['discoveries'] { return discoveries .map(d => { @@ -158,10 +195,10 @@ export function computeNextFrame(prev: SimulationState, deltaTime: number, newTi const newAgentsRaw = animateAgents(currentState.agents, deltaTime, currentState.currentTime) const newEdgesRaw = animateEdges(currentState.edges, deltaTime) const newToolCallsRaw = animateToolCalls(currentState.toolCalls, deltaTime, newTime) + const newServiceNodes = animateServiceNodes(currentState.serviceNodes, deltaTime, newTime) const { agents: newAgents, toolCalls: newToolCalls, edges: filteredEdges } = - cleanupFaded(newAgentsRaw, newToolCallsRaw, newEdgesRaw, currentState.agents, currentState.toolCalls) - + cleanupFaded(newAgentsRaw, newToolCallsRaw, newEdgesRaw, currentState.agents, currentState.toolCalls, newServiceNodes) const newDiscoveries = animateDiscoveries(currentState.discoveries, deltaTime, newTime) const newParticles = animateParticles(currentState.particles, deltaTime, currentState.speed) @@ -169,7 +206,7 @@ export function computeNextFrame(prev: SimulationState, deltaTime: number, newTi if (options.useMockData && currentState.eventIndex >= options.mockScenarioLength && newTime > options.mockScenarioEndTime + MOCK_END_BUFFER_S) { return { ...currentState, currentTime: newTime, eventIndex: currentState.eventIndex, - agents: newAgents, toolCalls: newToolCalls, + agents: newAgents, toolCalls: newToolCalls, serviceNodes: newServiceNodes, particles: newParticles, edges: filteredEdges, discoveries: newDiscoveries, maxTimeReached: maxT, @@ -179,7 +216,7 @@ export function computeNextFrame(prev: SimulationState, deltaTime: number, newTi return { ...currentState, currentTime: newTime, eventIndex: currentState.eventIndex, - agents: newAgents, toolCalls: newToolCalls, + agents: newAgents, toolCalls: newToolCalls, serviceNodes: newServiceNodes, particles: newParticles, edges: filteredEdges, discoveries: newDiscoveries, maxTimeReached: maxT, diff --git a/web/hooks/simulation/handle-agent-events.ts b/web/hooks/simulation/handle-agent-events.ts index f4eb4e0..cae5fc9 100644 --- a/web/hooks/simulation/handle-agent-events.ts +++ b/web/hooks/simulation/handle-agent-events.ts @@ -19,6 +19,7 @@ export function handleAgentSpawn( const isMain = asBoolean(payload.isMain) const task = typeof payload.task === 'string' ? payload.task : undefined const model = typeof payload.model === 'string' ? payload.model : undefined + const agentType = typeof payload.agentType === 'string' ? payload.agentType : undefined // If the agent already exists (e.g. session resuming after inactivity), // reactivate it instead of replacing — preserves accumulated stats. @@ -80,6 +81,7 @@ export function handleAgentSpawn( x, y, vx: 0, vy: 0, pinned: false, isMain, task, + agentType, spawnTime: currentTime, opacity: 0, scale: 0.3, messageBubbles: [], diff --git a/web/hooks/simulation/handle-message-events.ts b/web/hooks/simulation/handle-message-events.ts index 26a727e..c31dc6a 100644 --- a/web/hooks/simulation/handle-message-events.ts +++ b/web/hooks/simulation/handle-message-events.ts @@ -18,12 +18,12 @@ export function handleMessage( role === 'thinking' ? 'thinking' : 'assistant' - // Rename main agent to the first user message (more recognizable than "orchestrator") + // Set the first user message as the orchestrator's task (shown as secondary label) if (role === 'user') { const msgAgentForName = state.agents.get(agentName) if (msgAgentForName && msgAgentForName.isMain && msgAgentForName.name === agentName) { - const shortName = content.slice(0, LABEL_LEN_NAME).replace(/\n/g, ' ').trim() - state.agents.set(agentName, { ...msgAgentForName, name: shortName || agentName, task: content.slice(0, LABEL_LEN_TASK) }) + const shortTask = content.slice(0, LABEL_LEN_NAME).replace(/\n/g, ' ').trim() + state.agents.set(agentName, { ...msgAgentForName, task: shortTask || agentName }) } } diff --git a/web/hooks/simulation/handle-tool-events.ts b/web/hooks/simulation/handle-tool-events.ts index fe22292..138a552 100644 --- a/web/hooks/simulation/handle-tool-events.ts +++ b/web/hooks/simulation/handle-tool-events.ts @@ -1,7 +1,31 @@ import { COLORS } from '@/lib/colors' -import { TOOL_DEDUP_WINDOW_S } from '@/lib/canvas-constants' +import { type ServiceNode } from '@/lib/agent-types' +import { TOOL_DEDUP_WINDOW_S, SERVICE_NODE } from '@/lib/canvas-constants' import { pushTimelineBlock, type ProcessEventContext, type MutableEventState } from './process-event' -import { appendConversation, asString, asBoolean, LABEL_LEN_PARTICLE, LABEL_LEN_TIMELINE } from './types' +import { appendConversation, edgeId, asString, asBoolean, LABEL_LEN_PARTICLE, LABEL_LEN_TIMELINE } from './types' + +/** Well-known MCP server display names */ +const MCP_DISPLAY_NAMES: Record = { + 'azure-devops': 'Azure DevOps', + 'azure': 'Azure', + 'github': 'GitHub', + 'google-workspace': 'Google Workspace', + 'context7': 'Context7', + 'claude_ai_Gmail': 'Gmail', + 'claude_ai_Slack': 'Slack', + 'mercadopago': 'Mercado Pago', +} + +/** Parse MCP tool name to extract server. Returns null for non-MCP tools. */ +function parseMcpServer(toolName: string): { server: string; displayName: string } | null { + if (!toolName.startsWith('mcp__')) return null + const parts = toolName.split('__') + if (parts.length < 3) return null + const server = parts[1] + const displayName = MCP_DISPLAY_NAMES[server] + || server.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') + return { server, displayName } +} /** Extract file path from tool input data or fall back to first token of args */ function extractFilePath(inputData?: Record, args?: string): string { @@ -65,6 +89,63 @@ export function handleToolCallStart( label: `${toolName} ${args}`.slice(0, LABEL_LEN_PARTICLE), }) + // MCP service node: create/update when tool is an MCP call + const mcpInfo = parseMcpServer(toolName) + if (mcpInfo) { + const serviceId = `service-${mcpInfo.server}` + const existing = state.serviceNodes.get(serviceId) + if (existing) { + const connectedAgents = existing.connectedAgents.includes(agentName) + ? existing.connectedAgents + : [...existing.connectedAgents, agentName] + state.serviceNodes.set(serviceId, { + ...existing, + totalCalls: existing.totalCalls + 1, + activeCalls: existing.activeCalls + 1, + lastActiveTime: currentTime, + connectedAgents, + }) + } else { + // Spawn service node offset from the agent + const angle = Math.PI / 2 + Math.random() * Math.PI // bottom-ish hemisphere + const svc: ServiceNode = { + id: serviceId, + name: mcpInfo.server, + displayName: mcpInfo.displayName, + x: agent.x + Math.cos(angle) * SERVICE_NODE.spawnDistance, + y: agent.y + Math.sin(angle) * SERVICE_NODE.spawnDistance, + vx: 0, vy: 0, + totalCalls: 1, + activeCalls: 1, + lastActiveTime: currentTime, + opacity: 0, + scale: 0.3, + connectedAgents: [agentName], + } + state.serviceNodes.set(serviceId, svc) + } + + // Ensure edge from agent → service exists + const svcEdgeId = edgeId(agentName, serviceId) + if (!state.edges.some(e => e.id === svcEdgeId)) { + state.edges.push({ id: svcEdgeId, from: agentName, to: serviceId, type: 'service', opacity: 0 }) + } + + // Particle along agent → service edge + state.particles.push({ + id: `p-svc-${currentTime}-${toolId}`, + edgeId: edgeId(agentName, serviceId), progress: 0, + type: 'tool_call', color: COLORS.service, + size: 5, trailLength: 0.2, + label: mcpInfo.displayName, + }) + + // Sync force after adding service node + if (!ctx.skipForceSync) { + setTimeout(() => ctx.syncForceSimulation(state.agents, state.edges), 0) + } + } + // Timeline block const entry = state.timelineEntries.get(agentName) if (entry) { @@ -137,6 +218,30 @@ export function handleToolCallEnd( } } + // MCP service node: decrement active calls + const mcpInfoEnd = parseMcpServer(toolName) + if (mcpInfoEnd) { + const serviceId = `service-${mcpInfoEnd.server}` + const svc = state.serviceNodes.get(serviceId) + if (svc) { + state.serviceNodes.set(serviceId, { + ...svc, + activeCalls: Math.max(0, svc.activeCalls - 1), + lastActiveTime: currentTime, + }) + } + + // Return particle along service → agent edge + const svcEdgeId = edgeId(agentName, serviceId) + state.particles.push({ + id: `p-svcr-${currentTime}-${toolName}`, + edgeId: svcEdgeId, progress: 1, + type: 'tool_return', color: COLORS.service, + size: 5, trailLength: 0.2, + label: result.slice(0, LABEL_LEN_PARTICLE), + }) + } + // Timeline block end const entry = state.timelineEntries.get(agentName) if (entry) { diff --git a/web/hooks/simulation/process-event.ts b/web/hooks/simulation/process-event.ts index 737a3fc..f771ff0 100644 --- a/web/hooks/simulation/process-event.ts +++ b/web/hooks/simulation/process-event.ts @@ -1,6 +1,7 @@ import { Agent, ToolCallNode, + ServiceNode, Edge, SimulationEvent, type TimelineEntry, @@ -24,6 +25,7 @@ export interface ProcessEventContext { export interface MutableEventState { agents: Map toolCalls: Map + serviceNodes: Map particles: SimulationState['particles'] edges: Edge[] discoveries: SimulationState['discoveries'] @@ -62,6 +64,7 @@ export function processEvent(event: SimulationEvent, prev: SimulationState, ctx: const state: MutableEventState = { agents: new Map(prev.agents), toolCalls: new Map(prev.toolCalls), + serviceNodes: new Map(prev.serviceNodes), particles: [...prev.particles], edges: [...prev.edges], discoveries: [...prev.discoveries], @@ -88,7 +91,7 @@ export function processEvent(event: SimulationEvent, prev: SimulationState, ctx: // downstream React useMemo/re-render cascades (O(n log n) sorts etc.) return { ...prev, - agents: state.agents, toolCalls: state.toolCalls, + agents: state.agents, toolCalls: state.toolCalls, serviceNodes: state.serviceNodes, particles: state.particles, edges: state.edges, discoveries: state.discoveries, fileAttention: mapsEqual(prev.fileAttention, state.fileAttention) ? prev.fileAttention : state.fileAttention, diff --git a/web/hooks/simulation/snap-visual-state.ts b/web/hooks/simulation/snap-visual-state.ts index f8ab11c..1ef0dd5 100644 --- a/web/hooks/simulation/snap-visual-state.ts +++ b/web/hooks/simulation/snap-visual-state.ts @@ -37,14 +37,21 @@ export function snapVisualState(state: SimulationState, targetTime: number): Sim newToolCalls.set(id, snapped) } + // Snap service nodes + const newServiceNodes = new Map(state.serviceNodes) + for (const [id, svc] of newServiceNodes) { + newServiceNodes.set(id, { ...svc, opacity: 1, scale: 1 }) + } + // Filter edges: only keep edges where both endpoints are visible const newEdges = state.edges .map(e => { const fromAgent = newAgents.get(e.from) const toAgent = newAgents.get(e.to) const toTool = newToolCalls.get(e.to) + const toService = newServiceNodes.get(e.to) const fromVisible = fromAgent && fromAgent.opacity > MIN_VISIBLE_OPACITY - const toVisible = (toAgent && toAgent.opacity > MIN_VISIBLE_OPACITY) || (toTool && toTool.opacity > MIN_VISIBLE_OPACITY) + const toVisible = (toAgent && toAgent.opacity > MIN_VISIBLE_OPACITY) || (toTool && toTool.opacity > MIN_VISIBLE_OPACITY) || (toService && toService.opacity > MIN_VISIBLE_OPACITY) return { ...e, opacity: (fromVisible && toVisible) ? 1 : 0 } }) .filter(e => e.opacity > 0) @@ -56,7 +63,7 @@ export function snapVisualState(state: SimulationState, targetTime: number): Sim return { ...state, - agents: newAgents, toolCalls: newToolCalls, + agents: newAgents, toolCalls: newToolCalls, serviceNodes: newServiceNodes, edges: newEdges, particles: [], discoveries: newDiscoveries, } } diff --git a/web/hooks/simulation/types.ts b/web/hooks/simulation/types.ts index 80926b1..4765f61 100644 --- a/web/hooks/simulation/types.ts +++ b/web/hooks/simulation/types.ts @@ -1,6 +1,7 @@ import type { Agent, ToolCallNode, + ServiceNode, Particle, Edge, Discovery, @@ -13,6 +14,7 @@ import type { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force' export interface SimulationState { agents: Map toolCalls: Map + serviceNodes: Map particles: Particle[] edges: Edge[] discoveries: Discovery[] @@ -33,6 +35,7 @@ export function createEmptyState(overrides?: Partial): Simulati return { agents: new Map(), toolCalls: new Map(), + serviceNodes: new Map(), particles: [], edges: [], discoveries: [], diff --git a/web/hooks/use-agent-simulation.ts b/web/hooks/use-agent-simulation.ts index 3634dd5..8230f0b 100644 --- a/web/hooks/use-agent-simulation.ts +++ b/web/hooks/use-agent-simulation.ts @@ -3,6 +3,7 @@ import { useState, useCallback, useRef, useEffect } from 'react' import { Agent, + ServiceNode, ToolCallNode, Edge, SimulationEvent, @@ -63,6 +64,7 @@ export function useAgentSimulation(options: UseAgentSimulationOptions = {}) { // Force tick only updates positions — write to frameRef, no React render const prev = frameRef.current const newAgents = new Map(prev.agents) + let newServiceNodes = prev.serviceNodes let changed = false for (const node of sim.nodes()) { const agent = newAgents.get(node.id) @@ -72,9 +74,18 @@ export function useAgentSimulation(options: UseAgentSimulationOptions = {}) { changed = true } } + // Also update service node positions from force simulation + const svc = newServiceNodes.get(node.id) + if (svc && node.x !== undefined && node.y !== undefined) { + if (Math.abs(svc.x - node.x) > 0.1 || Math.abs(svc.y - node.y) > 0.1) { + if (newServiceNodes === prev.serviceNodes) newServiceNodes = new Map(prev.serviceNodes) + newServiceNodes.set(node.id, { ...svc, x: node.x, y: node.y }) + changed = true + } + } } if (changed) { - frameRef.current = { ...prev, agents: newAgents } + frameRef.current = { ...prev, agents: newAgents, serviceNodes: newServiceNodes } } }) @@ -96,8 +107,18 @@ export function useAgentSimulation(options: UseAgentSimulationOptions = {}) { fy: a.pinned ? a.y : undefined, })) + // Add service nodes to the force simulation + const serviceNodes = frameRef.current.serviceNodes + for (const svc of serviceNodes.values()) { + nodes.push({ + id: svc.id, + x: svc.x, y: svc.y, + vx: svc.vx, vy: svc.vy, + }) + } + const links: ForceLink[] = edges - .filter(e => e.type === 'parent-child') + .filter(e => e.type === 'parent-child' || e.type === 'service') .map(e => ({ id: e.id, source: e.from, target: e.to })) sim.nodes(nodes) diff --git a/web/lib/agent-types.ts b/web/lib/agent-types.ts index ec7965d..a5c128c 100644 --- a/web/lib/agent-types.ts +++ b/web/lib/agent-types.ts @@ -30,6 +30,7 @@ export interface Agent { isMain: boolean currentTool?: string task?: string + agentType?: string spawnTime: number completeTime?: number opacity: number @@ -51,6 +52,23 @@ export interface MessageBubble { _cachedWrappedFont?: string } +// MCP service node — persistent external service connection +export interface ServiceNode { + id: string // "service-azure-devops" + name: string // "azure-devops" + displayName: string // "Azure DevOps" + x: number + y: number + vx: number + vy: number + totalCalls: number + activeCalls: number + lastActiveTime: number + opacity: number + scale: number + connectedAgents: string[] +} + // Rich tool call with actual content export interface ToolCallNode { id: string @@ -126,7 +144,7 @@ export interface Edge { id: string from: string to: string - type: 'parent-child' | 'tool' + type: 'parent-child' | 'tool' | 'service' opacity: number } diff --git a/web/lib/canvas-constants.ts b/web/lib/canvas-constants.ts index 31313ec..e3218e0 100644 --- a/web/lib/canvas-constants.ts +++ b/web/lib/canvas-constants.ts @@ -163,7 +163,33 @@ export const COST_RATE = 6 // ─── Agent drawing constants ──────────────────────────────────────────────── -export const AGENT_DRAW = { +export const AGENT_DRAW: { + readonly bubbleAnchorOffset: number + readonly bubbleCursorY: number + readonly glowPadding: number + readonly outerRingOffset: number + readonly shadowBlur: number + readonly shadowOffsetX: number + readonly shadowOffsetY: number + readonly labelYOffset: number + readonly labelWidthMultiplier: number + readonly scanlineHalfH: number + readonly scanlineWidth: number + readonly waitingDashSpeed: number + readonly orbitParticleOffset: number + readonly orbitParticleSize: number + readonly rippleInnerOffset: number + readonly rippleMaxExpand: number + readonly rippleMaxAlpha: number + readonly waitingOrbitOffset: number + readonly waitingOrbitParticleSize: number + readonly waitingOrbitSpeed: number + readonly waitingBreatheSpeed: number + readonly waitingBreatheAmp: number + readonly sparkScale: number + readonly sparkViewBox: number + readonly subIconScale: number +} = { /** Offset from agent center to bubble anchor point */ bubbleAnchorOffset: 14, /** Initial cursor Y offset for bubbles */ @@ -178,8 +204,18 @@ export const AGENT_DRAW = { shadowOffsetY: 5, /** Agent name label Y offset from agent radius */ labelYOffset: 8, - /** Agent name label width multiplier of radius */ - labelWidthMultiplier: 3, + /** Agent name label width multiplier of radius — configurable via ?labelSize query param */ + labelWidthMultiplier: (() => { + if (typeof window === 'undefined') return 3 + const p = new URLSearchParams(window.location.search) + const size = p.get('labelSize') + switch (size) { + case 'compact': return 2 + case 'wide': return 5 + case 'full': return 8 + default: return 3.5 // 'normal' or unset — slightly wider than original + } + })(), /** Scanline gradient half-height */ scanlineHalfH: 4, /** Scanline width = 2 * scanlineHalfH */ @@ -206,7 +242,7 @@ export const AGENT_DRAW = { sparkViewBox: 256, /** Sub-agent icon font size relative to radius */ subIconScale: 0.45, -} as const +} export const CONTEXT_BAR = { /** Minimum bar width */ @@ -215,7 +251,7 @@ export const CONTEXT_BAR = { widthMultiplier: 2.2, barHeight: 6, /** Y offset from agent radius */ - yOffset: 22, + yOffset: 36, borderRadius: 3, /** Font for token count label */ fontSize: 7, @@ -250,6 +286,29 @@ export const STATS_OVERLAY = { textPaddingY: 4, } as const +// ─── Service node drawing constants ───────────────────────────────────────── + +export const SERVICE_NODE = { + /** Radius of the hexagonal service node */ + radius: 22, + /** Glow padding */ + glowPadding: 12, + /** Label Y offset below the hex */ + labelYOffset: 8, + /** Label width multiplier */ + labelWidthMultiplier: 4, + /** Stats font size */ + statsFontSize: 7, + /** Stats Y offset below label */ + statsYOffset: 12, + /** Icon font size (emoji) */ + iconFontSize: 14, + /** Pulse speed for active service */ + pulseSpeed: 2, + /** Spawn distance from connected agents */ + spawnDistance: 300, +} as const + // ─── Tool card drawing constants ──────────────────────────────────────────── export const TOOL_DRAW = { diff --git a/web/lib/colors.ts b/web/lib/colors.ts index 3f7c0b9..a46863e 100644 --- a/web/lib/colors.ts +++ b/web/lib/colors.ts @@ -75,6 +75,13 @@ export const COLORS = { liveResumeBg: 'rgba(255, 68, 68, 0.15)', liveResumeBorder: 'rgba(255, 68, 68, 0.35)', + // Service node colors + service: '#ff88cc', + serviceGlow: '#ff66bb', + serviceBg: 'rgba(255, 136, 204,', + serviceText: '#ffbbdd', + serviceDim: '#ff88cc80', + // Discovery type colors discoveryFile: '#66ccff', discoveryPattern: '#cc88ff',