diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 5c97774b..6a4d4ed8 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); - // Breathing animation - drawBreathing(ctx, x, y, r, node.state, time); + // Breathing animation + spawn/waiting effects + drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); // Name label drawLabel(ctx, x, y, r, node.label, color); @@ -150,7 +150,34 @@ function drawBreathing( r: number, state: string, time: number, + spawnStatus?: GraphNode['spawnStatus'], ): void { + // Spawning: rotating dashed ring (loading spinner) + if (spawnStatus === 'spawning') { + const ringR = r + AGENT_DRAW.orbitParticleOffset; + const rotation = time * ANIM.orbitSpeed * 2; + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.4); + ctx.strokeStyle = COLORS.holoBase + alphaHex(0.5); + ctx.lineWidth = 2; + ctx.setLineDash([6, 4]); + ctx.stroke(); + ctx.setLineDash([]); + ctx.restore(); + return; + } + + // Waiting: pulsing hex outline (breathing border) + if (spawnStatus === 'waiting') { + const pulse = 0.12 + 0.12 * Math.sin(time * AGENT_DRAW.waitingBreatheSpeed); + drawHexagon(ctx, x, y, r + AGENT_DRAW.outerRingOffset); + ctx.strokeStyle = COLORS.holoBase + alphaHex(pulse); + ctx.lineWidth = 1.5; + ctx.stroke(); + return; + } + const isActive = state === 'active' || state === 'thinking' || state === 'tool_calling'; const speed = isActive ? ANIM.breathe.activeSpeed : ANIM.breathe.idleSpeed; const amp = isActive ? ANIM.breathe.activeAmp : ANIM.breathe.idleAmp; diff --git a/packages/agent-graph/src/canvas/draw-edges.ts b/packages/agent-graph/src/canvas/draw-edges.ts index 7b764797..9982168b 100644 --- a/packages/agent-graph/src/canvas/draw-edges.ts +++ b/packages/agent-graph/src/canvas/draw-edges.ts @@ -83,7 +83,10 @@ export function drawEdges( const style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child']; const isActive = hasActiveParticles.has(edge.id); - const alpha = isActive ? BEAM.activeAlpha : BEAM.idleAlpha; + // Pulse alpha when particles are travelling: base 0.3 + 0.2 * sin wave + const alpha = isActive + ? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6) + : BEAM.idleAlpha; if (alpha < MIN_VISIBLE_OPACITY) continue; @@ -92,6 +95,12 @@ export function drawEdges( ctx.save(); ctx.globalAlpha = alpha; + // Subtle glow pass when edge has active particles + if (isActive) { + ctx.shadowColor = edge.color ?? style.color; + ctx.shadowBlur = 12; + } + // Draw tapered bezier drawTaperedBezier( ctx, diff --git a/packages/agent-graph/src/layout/kanbanLayout.ts b/packages/agent-graph/src/layout/kanbanLayout.ts index 6528c542..b49b8b85 100644 --- a/packages/agent-graph/src/layout/kanbanLayout.ts +++ b/packages/agent-graph/src/layout/kanbanLayout.ts @@ -87,8 +87,11 @@ export class KanbanLayoutEngine { task.fy = task.y; continue; } - task.x = baseX + colIdx * columnWidth; - task.y = baseY + rowIdx * rowHeight; + const targetX = baseX + colIdx * columnWidth; + const targetY = baseY + rowIdx * rowHeight; + // Smooth slide: LERP toward target; instant on first appearance + task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; + task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; task.fx = task.x; task.fy = task.y; task.vx = 0; @@ -120,8 +123,10 @@ export class KanbanLayoutEngine { static #layoutUnassigned(tasks: GraphNode[]): void { const { columnWidth, rowHeight } = KANBAN_ZONE; for (const [idx, task] of tasks.entries()) { - task.x = -400 + (idx % 3) * columnWidth; - task.y = 400 + Math.floor(idx / 3) * rowHeight; + const targetX = -400 + (idx % 3) * columnWidth; + const targetY = 400 + Math.floor(idx / 3) * rowHeight; + task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX; + task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY; task.fx = task.x; task.fy = task.y; task.vx = 0; diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 1d2291ad..52eaebc6 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -27,6 +27,8 @@ export class TeamGraphAdapter { readonly #seenRelated = new Set(); readonly #seenMessageIds = new Set(); #initialMessagesSeen = false; + readonly #seenCommentCounts = new Map(); + #initialCommentsSeen = false; // ─── Static factory ────────────────────────────────────────────────────── static create(): TeamGraphAdapter { @@ -54,7 +56,8 @@ export class TeamGraphAdapter { } // Simple hash for change detection (avoids full deep equality) - const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}`; + const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0); + const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}`; if (hash === this.#lastDataHash && teamName === this.#lastTeamName) { return this.#cachedResult; } @@ -63,6 +66,8 @@ export class TeamGraphAdapter { if (teamName !== this.#lastTeamName) { this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; + this.#seenCommentCounts.clear(); + this.#initialCommentsSeen = false; } this.#lastTeamName = teamName; @@ -80,6 +85,7 @@ export class TeamGraphAdapter { this.#buildTaskNodes(nodes, edges, teamData, teamName); this.#buildProcessNodes(nodes, edges, teamData, teamName); this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges); + this.#buildCommentParticles(particles, teamData, teamName, edges); this.#cachedResult = { nodes, @@ -100,6 +106,8 @@ export class TeamGraphAdapter { this.#seenRelated.clear(); this.#seenMessageIds.clear(); this.#initialMessagesSeen = false; + this.#seenCommentCounts.clear(); + this.#initialCommentsSeen = false; this.#lastDataHash = ''; } @@ -299,6 +307,57 @@ export class TeamGraphAdapter { } } + #buildCommentParticles( + particles: GraphParticle[], + data: TeamData, + teamName: string, + edges: GraphEdge[] + ): void { + // First call: record current comment counts without creating particles. + // This prevents pre-existing comments from spawning particles when the graph opens. + if (!this.#initialCommentsSeen) { + this.#initialCommentsSeen = true; + for (const task of data.tasks) { + this.#seenCommentCounts.set(task.id, task.comments?.length ?? 0); + } + return; + } + + // Build a member color lookup for assigning particle colors + const memberColors = new Map(); + for (const member of data.members) { + if (member.color) memberColors.set(member.name, member.color); + } + + for (const task of data.tasks) { + if (task.status === 'deleted') continue; + + const prevCount = this.#seenCommentCounts.get(task.id) ?? 0; + const currentCount = task.comments?.length ?? 0; + + if (currentCount > prevCount && prevCount > 0) { + // New comment(s) detected — create a particle from the author to the task + const newComment = task.comments![currentCount - 1]; + const authorNodeId = `member:${teamName}:${newComment.author}`; + const taskNodeId = `task:${teamName}:${task.id}`; + const authorEdge = edges.find((e) => e.source === authorNodeId && e.target === taskNodeId); + + if (authorEdge) { + particles.push({ + id: `particle:comment:${task.id}:${currentCount}`, + edgeId: authorEdge.id, + progress: 0, + kind: 'message', + color: memberColors.get(newComment.author) ?? '#cc88ff', + label: '\u{1F4AC}', + }); + } + } + + this.#seenCommentCounts.set(task.id, currentCount); + } + } + // ─── Static mappers ────────────────────────────────────────────────────── static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState {