From 2624666f9111cf74e9e0122bcb710e5e5485b27f Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 28 Mar 2026 14:20:59 +0200 Subject: [PATCH] fix(graph): no spawn replay on toggle + always bloom + rich popovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Tasks toggle no longer replays spawn animations — allKnownNodeIds tracks every node ever seen, spawn effects only for genuinely new nodes 2. Bloom always active (removed hasActivity condition) — no brightness flicker when tasks appear/disappear 3. Rich member popover: avatar image, status dot, role, context bar, spawn status badges, Message/Profile action buttons 4. Rich task popover: displayId, status badges, review state, Open button 5. Process popover: label, URL link, status 6. Glass-card styling with colored accent bar, outside-click-to-dismiss --- .../src/hooks/useGraphSimulation.ts | 13 +- packages/agent-graph/src/ui/GraphCanvas.tsx | 5 +- packages/agent-graph/src/ui/GraphOverlay.tsx | 474 ++++++++++++++++-- 3 files changed, 435 insertions(+), 57 deletions(-) diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index c7c2e3c2..23469f3a 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -167,11 +167,13 @@ export function useGraphSimulation(): UseGraphSimulationResult { // Track previous node IDs and states for effect spawning const prevNodeIdsRef = useRef(new Set()); const prevNodeStatesRef = useRef(new Map()); + // All node IDs ever seen — never shrinks. Prevents spawn effects replaying + // when nodes reappear after being filtered out (e.g. Tasks toggle OFF→ON). + const allKnownNodeIdsRef = useRef(new Set()); // Update data from adapter const updateData = useCallback((nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => { const state = stateRef.current; - const prevIds = prevNodeIdsRef.current; const prevStates = prevNodeStatesRef.current; // Preserve positions from previous frame @@ -193,9 +195,11 @@ export function useGraphSimulation(): UseGraphSimulationResult { } // Detect state transitions → spawn visual effects + const allKnown = allKnownNodeIdsRef.current; for (const node of nodes) { - // New node appeared → spawn effect - if (!prevIds.has(node.id) && node.x != null && node.y != null) { + // New node appeared → spawn effect (only if truly new, never seen before). + // Nodes returning from filter (e.g. Tasks toggle OFF→ON) are already in allKnown. + if (!allKnown.has(node.id) && node.x != null && node.y != null) { state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state))); } @@ -206,7 +210,8 @@ export function useGraphSimulation(): UseGraphSimulationResult { } } - // Update tracking refs + // Update tracking refs — allKnown only grows, never shrinks + for (const n of nodes) allKnown.add(n.id); prevNodeIdsRef.current = new Set(nodes.map((n) => n.id)); prevNodeStatesRef.current = new Map(nodes.map((n) => [n.id, n.state])); diff --git a/packages/agent-graph/src/ui/GraphCanvas.tsx b/packages/agent-graph/src/ui/GraphCanvas.tsx index 8fd1ba85..cfd2553a 100644 --- a/packages/agent-graph/src/ui/GraphCanvas.tsx +++ b/packages/agent-graph/src/ui/GraphCanvas.tsx @@ -219,9 +219,8 @@ export const GraphCanvas = forwardRef(funct ctx.restore(); // world space ctx.restore(); // DPR scale - // 3. Bloom post-processing — skip when scene is fully idle (saves 3 blur passes) - const hasActivity = state.particles.length > 0 || state.effects.length > 0; - if (bloomIntensity > 0 && hasActivity) { + // 3. Bloom post-processing — always active for space aesthetic + if (bloomIntensity > 0) { bloomRef.current.apply(canvas, ctx); } diff --git a/packages/agent-graph/src/ui/GraphOverlay.tsx b/packages/agent-graph/src/ui/GraphOverlay.tsx index bf4154f3..7922c619 100644 --- a/packages/agent-graph/src/ui/GraphOverlay.tsx +++ b/packages/agent-graph/src/ui/GraphOverlay.tsx @@ -1,12 +1,15 @@ /** * GraphOverlay — HTML popovers positioned over Canvas nodes. * Uses camera worldToScreen transform for positioning. + * + * Styled to match the host app's MemberHoverCard / MemberCard look: + * avatar + status dot, name, role, status badges, action buttons. */ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import type { GraphNode } from '../ports/types'; import type { GraphEventPort } from '../ports/GraphEventPort'; -import { getStateColor, getTaskStatusColor } from '../constants/colors'; +import { COLORS, getStateColor, getTaskStatusColor } from '../constants/colors'; export interface GraphOverlayProps { selectedNode: GraphNode | null; @@ -39,6 +42,84 @@ export function GraphOverlay({ ); } +// ─── SVG Icons (inline — package cannot import lucide-react) ──────────────── + +function IconMessage({ size = 13 }: { size?: number }): React.JSX.Element { + return ( + + + + ); +} + +function IconExternalLink({ size = 12 }: { size?: number }): React.JSX.Element { + return ( + + + + + + ); +} + +function IconGlobe({ size = 12 }: { size?: number }): React.JSX.Element { + return ( + + + + + + ); +} + +function IconClipboard({ size = 12 }: { size?: number }): React.JSX.Element { + return ( + + + + + ); +} + +// ─── State helpers ────────────────────────────────────────────────────────── + +function getPresenceLabel(state: GraphNode['state']): string { + switch (state) { + case 'active': return 'active'; + case 'thinking': return 'thinking'; + case 'tool_calling': return 'tool calling'; + case 'idle': return 'idle'; + case 'waiting': return 'waiting'; + case 'complete': return 'done'; + case 'error': return 'error'; + case 'terminated': return 'offline'; + } +} + +function getStatusDotClass(state: GraphNode['state']): string { + switch (state) { + case 'active': + case 'thinking': + case 'tool_calling': + return 'animate-pulse'; + default: + return ''; + } +} + +/** Capitalise first letter, replace underscores with spaces */ +function formatLabel(s: string): string { + return s.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()); +} + +/** Truncate display name: "team-lead" → "Team Lead", "alice" → "Alice" */ +function displayName(name: string): string { + return name + .split(/[-_]/) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + // ─── Node Popover ─────────────────────────────────────────────────────────── function NodePopover({ @@ -50,6 +131,23 @@ function NodePopover({ events?: GraphEventPort; onClose: () => void; }): React.JSX.Element { + const popoverRef = useRef(null); + + // Close on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + onClose(); + } + }; + // Delay to avoid closing immediately from the click that opened it + const timer = setTimeout(() => document.addEventListener('mousedown', handler), 50); + return () => { + clearTimeout(timer); + document.removeEventListener('mousedown', handler); + }; + }, [onClose]); + const handleAction = useCallback( (action: string) => { const ref = node.domainRef; @@ -62,6 +160,7 @@ function NodePopover({ case 'openDetail': if (ref.kind === 'task') events?.onOpenTaskDetail?.(ref.taskId, ref.teamName); else if (ref.kind === 'member') events?.onOpenMemberProfile?.(ref.memberName, ref.teamName); + else if (ref.kind === 'lead') events?.onOpenMemberProfile?.('team-lead', ref.teamName); break; case 'openUrl': if (node.processUrl) window.open(node.processUrl, '_blank'); @@ -72,93 +171,368 @@ function NodePopover({ [node, events, onClose], ); + const isMemberLike = node.kind === 'member' || node.kind === 'lead'; const color = node.kind === 'task' ? getTaskStatusColor(node.taskStatus) - : getStateColor(node.state); + : node.color ?? getStateColor(node.state); + const stateColor = getStateColor(node.state); + + if (isMemberLike) { + return ; + } + if (node.kind === 'task') { + return ; + } + return ; +} + +// ─── Member / Lead Popover ────────────────────────────────────────────────── + +import { forwardRef } from 'react'; + +const MemberPopover = forwardRef< + HTMLDivElement, + { node: GraphNode; color: string; stateColor: string; onAction: (a: string) => void } +>(function MemberPopover({ node, color, stateColor, onAction }, ref) { + const presenceText = getPresenceLabel(node.state); + const dotAnim = getStatusDotClass(node.state); return (
- {/* Header */} -
-
- - {node.label} - -
+ {/* Colored top accent */} +
- {/* Info */} - {node.sublabel && ( -
- {node.sublabel} +
+ {/* Header: avatar + name + status */} +
+ {node.avatarUrl ? ( +
+ {node.label} + +
+ ) : ( +
+
+ {node.label.charAt(0).toUpperCase()} +
+ +
+ )} + +
+
+ + {displayName(node.label)} + +
+ {node.role && ( +
+ {node.role} +
+ )} +
- )} - {node.role && ( -
- {node.role} + + {/* Status badge */} +
+ + {node.kind === 'lead' && ( + + )} + {node.spawnStatus && node.spawnStatus !== 'online' && ( + + )}
- )} - {/* Status badges */} -
- - {node.reviewState && node.reviewState !== 'none' && ( - + {/* Context usage bar (lead only) */} + {node.contextUsage != null && node.contextUsage > 0 && ( +
+
+ Context + {Math.round(node.contextUsage * 100)}% +
+
+
0.8 ? COLORS.error : color, + }} + /> +
+
)} + + {/* Sublabel (current activity) */} + {node.sublabel && ( +
+ {node.sublabel} +
+ )} + + {/* Action buttons */} +
+ } label="Message" onClick={() => onAction('sendMessage')} color={color} /> + } label="Profile" onClick={() => onAction('openDetail')} color={color} /> +
+
+ ); +}); - {/* Actions */} -
- {(node.kind === 'member' || node.kind === 'lead') && ( - handleAction('sendMessage')} /> +// ─── Task Popover ─────────────────────────────────────────────────────────── + +const TaskPopover = forwardRef< + HTMLDivElement, + { node: GraphNode; color: string; stateColor: string; onAction: (a: string) => void } +>(function TaskPopover({ node, color, stateColor, onAction }, ref) { + const taskStatusLabel = node.taskStatus ? formatLabel(node.taskStatus) : 'pending'; + + return ( +
+ {/* Colored top accent */} +
+ +
+ {/* Header: display ID + label */} +
+ {node.displayId && ( + + {node.displayId} + + )} + + {node.label} + +
+ + {/* Subject / description */} + {node.sublabel && ( +
+ {node.sublabel} +
)} - {(node.kind === 'task' || node.kind === 'member') && ( - handleAction('openDetail')} /> + + {/* Status badges */} +
+ + {node.state !== 'idle' && ( + + )} + {node.reviewState && node.reviewState !== 'none' && ( + + )} + {node.needsClarification && ( + + )} +
+ + {/* Action */} +
+ } label="Open task" onClick={() => onAction('openDetail')} color={color} /> +
+
+
+ ); +}); + +// ─── Process Popover ──────────────────────────────────────────────────────── + +const ProcessPopover = forwardRef< + HTMLDivElement, + { node: GraphNode; color: string; onAction: (a: string) => void } +>(function ProcessPopover({ node, color, onAction }, ref) { + return ( +
+
+ +
+
+
+ + {node.label} + +
+ + {node.sublabel && ( +
+ {node.sublabel} +
)} - {node.kind === 'process' && node.processUrl && ( - handleAction('openUrl')} /> + +
+ +
+ + {node.processUrl && ( +
+ } label="Open URL" onClick={() => onAction('openUrl')} color={color} /> +
)}
); -} +}); // ─── UI Primitives ────────────────────────────────────────────────────────── function StatusBadge({ label, color }: { label: string; color: string }): React.JSX.Element { return ( {label} ); } -function ActionButton({ label, onClick }: { label: string; onClick: () => void }): React.JSX.Element { +function ActionButton({ + icon, + label, + onClick, + color, +}: { + icon: React.ReactNode; + label: string; + onClick: () => void; + color: string; +}): React.JSX.Element { return ( );