feat(agent-graph): show pulsing amber ring on agents awaiting approval

- Add pendingApproval field to GraphNode type
- Pass pendingApprovalAgents Set from store through adapter
- Draw pulsing amber ring + subtle glow on agent nodes that have
  pending tool approval requests
- Include approval state in adapter cache hash for reactivity
This commit is contained in:
iliya 2026-03-28 16:17:39 +02:00
parent 70b5f1962f
commit e2000d0900
4 changed files with 60 additions and 7 deletions

View file

@ -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;

View file

@ -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") */

View file

@ -50,7 +50,8 @@ export class TeamGraphAdapter {
teamData: TeamData | null,
teamName: string,
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
leadContext?: LeadContextUsage
leadContext?: LeadContextUsage,
pendingApprovalAgents?: Set<string>
): 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<string, MemberSpawnStatusEntry>
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
pendingApprovalAgents?: Set<string>
): 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 },
});

View file

@ -15,16 +15,32 @@ import type { GraphDataPort } from '@claude-teams/agent-graph';
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
const adapterRef = useRef<TeamGraphAdapter>(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<string>();
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]
);
}