diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index bc353adc..2d701336 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -44,8 +44,8 @@ export function drawAgents( // Hexagonal body with interior fill drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered); - // Avatar: first letter of name centered inside hexagon - drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead'); + // Avatar: robohash image or fallback letter + drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl); // Breathing animation + spawn/waiting effects drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); @@ -210,6 +210,30 @@ function drawBreathing( } } +// ─── Avatar image cache ───────────────────────────────────────────────────── + +const avatarCache = new Map(); +const avatarLoading = new Set(); + +function getAvatarImage(url: string): HTMLImageElement | null { + const cached = avatarCache.get(url); + if (cached) return cached; + if (avatarLoading.has(url)) return null; + + avatarLoading.add(url); + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + avatarCache.set(url, img); + avatarLoading.delete(url); + }; + img.onerror = () => { + avatarLoading.delete(url); + }; + img.src = url; + return null; +} + function drawAvatar( ctx: CanvasRenderingContext2D, x: number, @@ -218,10 +242,28 @@ function drawAvatar( name: string, color: string, isLead: boolean, + avatarUrl?: string, ): void { + const avatarR = r * 0.6; + + // Try to draw avatar image + if (avatarUrl) { + const img = getAvatarImage(avatarUrl); + if (img) { + ctx.save(); + // Clip to circle inside hexagon + ctx.beginPath(); + ctx.arc(x, y, avatarR, 0, Math.PI * 2); + ctx.clip(); + ctx.drawImage(img, x - avatarR, y - avatarR, avatarR * 2, avatarR * 2); + ctx.restore(); + return; + } + } + + // Fallback: first letter const letter = name.charAt(0).toUpperCase(); const fontSize = isLead ? Math.round(r * 0.6) : Math.round(r * 0.7); - ctx.font = `bold ${fontSize}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index 1c5a2623..5be02b2c 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -8,7 +8,7 @@ */ import type { GraphNode } from '../ports/types'; -import { KANBAN_ZONE, TASK_PILL } from '../constants/canvas-constants'; +import { KANBAN_ZONE } from '../constants/canvas-constants'; /** Column header info for rendering */ export interface KanbanColumnHeader { @@ -125,10 +125,10 @@ export class KanbanLayoutEngine { const colX = baseX + colIdx * columnWidth; const config = COLUMN_LABELS[col.name] ?? { label: col.name, color: '#888' }; - // Column header — centered over pill area + // Column header — centered over pill area (pill center = colX since drawTaskPill translates to x,y) headers.push({ label: config.label, - x: colX + TASK_PILL.width / 2, // horizontally centered over pills + x: colX, // pill center = task.x = colX y: baseY, color: config.color, }); diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 2ff862c8..4bafef3b 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -43,6 +43,8 @@ export interface GraphNode { // ─── Member/Lead-specific ────────────────────────────────────────────── /** Agent role description */ role?: string; + /** Avatar image URL (e.g., robohash) */ + avatarUrl?: string; /** Spawn lifecycle status */ spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; /** Context window usage ratio (0..1), available for lead only */ diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 52eaebc6..71b3cd40 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -8,6 +8,7 @@ */ import { isLeadMember } from '@shared/utils/leadDetection'; +import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import type { GraphDataPort, @@ -128,6 +129,7 @@ export class TeamGraphAdapter { state: data.isAlive ? 'active' : 'idle', color: data.config.color ?? undefined, contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, + avatarUrl: agentAvatarUrl('team-lead', 64), domainRef: { kind: 'lead', teamName }, }); } @@ -155,6 +157,7 @@ export class TeamGraphAdapter { color: member.color ?? undefined, role: member.role ?? undefined, spawnStatus: spawn?.status, + avatarUrl: agentAvatarUrl(member.name, 64), domainRef: { kind: 'member', teamName, memberName: member.name }, });