diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 0f5a46f0..cbae35bb 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -50,6 +50,27 @@ export function drawAgents( // Breathing animation + spawn/waiting effects drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); + // Pending approval indicator: pulsing amber ring + if (node.pendingApproval) { + const pulseAlpha = 0.3 + 0.35 * Math.sin(time * 3); + const ringR = r + 5; + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = hexWithAlpha('#f59e0b', pulseAlpha); + ctx.lineWidth = 2; + ctx.stroke(); + + // Subtle amber glow + const glowR = r + 12; + const grad = ctx.createRadialGradient(x, y, r, x, y, glowR); + grad.addColorStop(0, hexWithAlpha('#f59e0b', pulseAlpha * 0.25)); + grad.addColorStop(1, 'transparent'); + ctx.beginPath(); + ctx.arc(x, y, glowR, 0, Math.PI * 2); + ctx.fillStyle = grad; + ctx.fill(); + } + // Working indicator: subtle spinning arc when member has active task if (node.currentTaskId && (node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling')) { const ringR = r + 4; diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index 3b50c13e..9cbe403a 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -53,6 +53,8 @@ export interface GraphNode { currentTaskId?: string | null; /** Current task subject (for display in popover) */ currentTaskSubject?: string; + /** Agent is awaiting tool approval from the user */ + pendingApproval?: boolean; // ─── Task-specific ───────────────────────────────────────────────────── /** Short display ID (e.g., "#3") */ diff --git a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts index 4f319801..483edf73 100644 --- a/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/TeamGraphAdapter.ts @@ -50,7 +50,8 @@ export class TeamGraphAdapter { teamData: TeamData | null, teamName: string, spawnStatuses?: Record, - leadContext?: LeadContextUsage + leadContext?: LeadContextUsage, + pendingApprovalAgents?: Set ): GraphDataPort { if (teamData?.teamName !== teamName) { return TeamGraphAdapter.#emptyResult(teamName); @@ -58,7 +59,10 @@ export class TeamGraphAdapter { // Simple hash for change detection (avoids full deep equality) 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}`; + const approvalKey = pendingApprovalAgents?.size + ? Array.from(pendingApprovalAgents).sort().join(',') + : ''; + const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}:${approvalKey}`; if (hash === this.#lastDataHash && teamName === this.#lastTeamName) { return this.#cachedResult; } @@ -82,7 +86,15 @@ export class TeamGraphAdapter { const leadId = `lead:${teamName}`; this.#buildLeadNode(nodes, leadId, teamData, teamName, leadContext); - this.#buildMemberNodes(nodes, edges, leadId, teamData, teamName, spawnStatuses); + this.#buildMemberNodes( + nodes, + edges, + leadId, + teamData, + teamName, + spawnStatuses, + pendingApprovalAgents + ); this.#buildTaskNodes(nodes, edges, teamData, teamName); this.#buildProcessNodes(nodes, edges, teamData, teamName); this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges); @@ -140,7 +152,8 @@ export class TeamGraphAdapter { leadId: string, data: TeamData, teamName: string, - spawnStatuses?: Record + spawnStatuses?: Record, + pendingApprovalAgents?: Set ): void { for (const member of data.members) { if (member.removedAt) continue; @@ -162,6 +175,7 @@ export class TeamGraphAdapter { currentTaskSubject: member.currentTaskId ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject : undefined, + pendingApproval: pendingApprovalAgents?.has(member.name) ?? false, domainRef: { kind: 'member', teamName, memberName: member.name }, }); diff --git a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts index ec11d302..55f36f6f 100644 --- a/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts +++ b/src/renderer/features/agent-graph/adapters/useTeamGraphAdapter.ts @@ -15,16 +15,32 @@ import type { GraphDataPort } from '@claude-teams/agent-graph'; export function useTeamGraphAdapter(teamName: string): GraphDataPort { const adapterRef = useRef(TeamGraphAdapter.create()); - const { teamData, spawnStatuses, leadContext } = useStore( + const { teamData, spawnStatuses, leadContext, pendingApprovals } = useStore( useShallow((s) => ({ teamData: s.selectedTeamData, spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, + pendingApprovals: s.pendingApprovals, })) ); + const pendingApprovalAgents = useMemo(() => { + const agents = new Set(); + for (const a of pendingApprovals) { + if (a.source !== 'lead') agents.add(a.source); + } + return agents; + }, [pendingApprovals]); + return useMemo( - () => adapterRef.current.adapt(teamData, teamName, spawnStatuses, leadContext), - [teamData, teamName, spawnStatuses, leadContext] + () => + adapterRef.current.adapt( + teamData, + teamName, + spawnStatuses, + leadContext, + pendingApprovalAgents + ), + [teamData, teamName, spawnStatuses, leadContext, pendingApprovalAgents] ); }