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:
parent
70b5f1962f
commit
e2000d0900
4 changed files with 60 additions and 7 deletions
|
|
@ -50,6 +50,27 @@ export function drawAgents(
|
||||||
// Breathing animation + spawn/waiting effects
|
// Breathing animation + spawn/waiting effects
|
||||||
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus);
|
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
|
// Working indicator: subtle spinning arc when member has active task
|
||||||
if (node.currentTaskId && (node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling')) {
|
if (node.currentTaskId && (node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling')) {
|
||||||
const ringR = r + 4;
|
const ringR = r + 4;
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,8 @@ export interface GraphNode {
|
||||||
currentTaskId?: string | null;
|
currentTaskId?: string | null;
|
||||||
/** Current task subject (for display in popover) */
|
/** Current task subject (for display in popover) */
|
||||||
currentTaskSubject?: string;
|
currentTaskSubject?: string;
|
||||||
|
/** Agent is awaiting tool approval from the user */
|
||||||
|
pendingApproval?: boolean;
|
||||||
|
|
||||||
// ─── Task-specific ─────────────────────────────────────────────────────
|
// ─── Task-specific ─────────────────────────────────────────────────────
|
||||||
/** Short display ID (e.g., "#3") */
|
/** Short display ID (e.g., "#3") */
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ export class TeamGraphAdapter {
|
||||||
teamData: TeamData | null,
|
teamData: TeamData | null,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||||
leadContext?: LeadContextUsage
|
leadContext?: LeadContextUsage,
|
||||||
|
pendingApprovalAgents?: Set<string>
|
||||||
): GraphDataPort {
|
): GraphDataPort {
|
||||||
if (teamData?.teamName !== teamName) {
|
if (teamData?.teamName !== teamName) {
|
||||||
return TeamGraphAdapter.#emptyResult(teamName);
|
return TeamGraphAdapter.#emptyResult(teamName);
|
||||||
|
|
@ -58,7 +59,10 @@ export class TeamGraphAdapter {
|
||||||
|
|
||||||
// Simple hash for change detection (avoids full deep equality)
|
// Simple hash for change detection (avoids full deep equality)
|
||||||
const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0);
|
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) {
|
if (hash === this.#lastDataHash && teamName === this.#lastTeamName) {
|
||||||
return this.#cachedResult;
|
return this.#cachedResult;
|
||||||
}
|
}
|
||||||
|
|
@ -82,7 +86,15 @@ export class TeamGraphAdapter {
|
||||||
const leadId = `lead:${teamName}`;
|
const leadId = `lead:${teamName}`;
|
||||||
|
|
||||||
this.#buildLeadNode(nodes, leadId, teamData, teamName, leadContext);
|
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.#buildTaskNodes(nodes, edges, teamData, teamName);
|
||||||
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
||||||
this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges);
|
this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges);
|
||||||
|
|
@ -140,7 +152,8 @@ export class TeamGraphAdapter {
|
||||||
leadId: string,
|
leadId: string,
|
||||||
data: TeamData,
|
data: TeamData,
|
||||||
teamName: string,
|
teamName: string,
|
||||||
spawnStatuses?: Record<string, MemberSpawnStatusEntry>
|
spawnStatuses?: Record<string, MemberSpawnStatusEntry>,
|
||||||
|
pendingApprovalAgents?: Set<string>
|
||||||
): void {
|
): void {
|
||||||
for (const member of data.members) {
|
for (const member of data.members) {
|
||||||
if (member.removedAt) continue;
|
if (member.removedAt) continue;
|
||||||
|
|
@ -162,6 +175,7 @@ export class TeamGraphAdapter {
|
||||||
currentTaskSubject: member.currentTaskId
|
currentTaskSubject: member.currentTaskId
|
||||||
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
||||||
: undefined,
|
: undefined,
|
||||||
|
pendingApproval: pendingApprovalAgents?.has(member.name) ?? false,
|
||||||
domainRef: { kind: 'member', teamName, memberName: member.name },
|
domainRef: { kind: 'member', teamName, memberName: member.name },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,32 @@ import type { GraphDataPort } from '@claude-teams/agent-graph';
|
||||||
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
export function useTeamGraphAdapter(teamName: string): GraphDataPort {
|
||||||
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
|
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
|
||||||
|
|
||||||
const { teamData, spawnStatuses, leadContext } = useStore(
|
const { teamData, spawnStatuses, leadContext, pendingApprovals } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
teamData: s.selectedTeamData,
|
teamData: s.selectedTeamData,
|
||||||
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
|
||||||
leadContext: teamName ? s.leadContextByTeam[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(
|
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]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue