diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 9a31800a..ed8db002 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -266,24 +266,49 @@ function drawLaunchStage( ctx.save(); switch (visualState) { case 'waiting': { - const ringR = r + 7 + Math.sin(time * 3.2) * 1.2; - const pulseAlpha = 0.2 + 0.14 * (0.5 + 0.5 * Math.sin(time * 3.2)); + const ringR = r + 8 + Math.sin(time * 3.2) * 1.4; + const pulseAlpha = 0.28 + 0.18 * (0.5 + 0.5 * Math.sin(time * 3.2)); + const dotOrbit = r + 11; ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha); - ctx.lineWidth = 2.2; + ctx.lineWidth = 2.5; + ctx.setLineDash([4, 5]); ctx.stroke(); + ctx.setLineDash([]); + for (let index = 0; index < 3; index += 1) { + const angle = time * 1.2 + (Math.PI * 2 * index) / 3; + ctx.beginPath(); + ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72); + ctx.fill(); + } break; } case 'spawning': { const ringR = r + 7; - const rotation = time * 2.4; + const rotation = time * 2.7; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15); ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8); - ctx.lineWidth = 2.2; + ctx.lineWidth = 2.8; ctx.lineCap = 'round'; ctx.stroke(); + + ctx.beginPath(); + ctx.arc(x, y, ringR + 4, rotation + Math.PI, rotation + Math.PI + Math.PI * 0.4); + ctx.strokeStyle = hexWithAlpha('#fbbf24', 0.65); + ctx.lineWidth = 1.8; + ctx.lineCap = 'round'; + ctx.stroke(); + + const glow = ctx.createRadialGradient(x, y, r * 0.5, x, y, ringR + 12); + glow.addColorStop(0, hexWithAlpha('#f59e0b', 0.18)); + glow.addColorStop(1, hexWithAlpha('#f59e0b', 0)); + ctx.beginPath(); + ctx.arc(x, y, ringR + 12, 0, Math.PI * 2); + ctx.fillStyle = glow; + ctx.fill(); break; } case 'runtime_pending': { @@ -291,27 +316,37 @@ function drawLaunchStage( ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48); - ctx.lineWidth = 1.75; + ctx.lineWidth = 1.9; + ctx.setLineDash([5, 4]); ctx.stroke(); + ctx.setLineDash([]); - const orbit = time * 1.6; - const dotR = 2.2; - const dotX = x + Math.cos(orbit) * ringR; - const dotY = y + Math.sin(orbit) * ringR; - ctx.beginPath(); - ctx.arc(dotX, dotY, dotR, 0, Math.PI * 2); - ctx.fillStyle = hexWithAlpha('#67e8f9', 0.9); - ctx.fill(); + const orbit = time * 1.8; + for (let index = 0; index < 2; index += 1) { + const angle = orbit + Math.PI * index; + const dotX = x + Math.cos(angle) * ringR; + const dotY = y + Math.sin(angle) * ringR; + ctx.beginPath(); + ctx.arc(dotX, dotY, 2.3, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha(index === 0 ? '#67e8f9' : '#38bdf8', 0.92); + ctx.fill(); + } break; } case 'settling': { const ringR = r + 6; - const arc = 0.65 + 0.08 * Math.sin(time * 2.2); + const arc = 0.72 + 0.08 * Math.sin(time * 2.2); const rotation = time * 1.25; + ctx.beginPath(); + ctx.arc(x, y, ringR, 0, Math.PI * 2); + ctx.strokeStyle = hexWithAlpha('#22c55e', 0.18); + ctx.lineWidth = 1.4; + ctx.stroke(); + ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc); ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62); - ctx.lineWidth = 2; + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -321,9 +356,14 @@ function drawLaunchStage( ctx.beginPath(); ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15); ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72); - ctx.lineWidth = 2.2; + ctx.lineWidth = 2.4; ctx.lineCap = 'round'; ctx.stroke(); + + ctx.beginPath(); + ctx.arc(x + ringR * 0.52, y - ringR * 0.5, 2.2, 0, Math.PI * 2); + ctx.fillStyle = hexWithAlpha('#f87171', 0.92); + ctx.fill(); break; } } diff --git a/packages/agent-graph/src/ui/GraphControls.tsx b/packages/agent-graph/src/ui/GraphControls.tsx index a3bc581d..801b4a8b 100644 --- a/packages/agent-graph/src/ui/GraphControls.tsx +++ b/packages/agent-graph/src/ui/GraphControls.tsx @@ -49,6 +49,7 @@ export interface GraphControlsProps { teamColor?: string; isAlive?: boolean; topToolbarContent?: React.ReactNode; + interactionLocked?: boolean; } const TOPBAR_BUTTON_SIZE = 25; @@ -69,6 +70,7 @@ export function GraphControls({ isSidebarVisible = true, teamColor, topToolbarContent, + interactionLocked = false, }: GraphControlsProps): React.JSX.Element { const [isSettingsOpen, setIsSettingsOpen] = useState(false); const settingsRef = useRef(null); @@ -104,6 +106,9 @@ export function GraphControls({ }, [isSettingsOpen]); const nameColor = teamColor ?? '#aaeeff'; + const chromeInteractivityClass = interactionLocked + ? 'pointer-events-none select-none' + : 'pointer-events-auto'; return ( <> @@ -111,7 +116,7 @@ export function GraphControls({
{onToggleSidebar ? (
{topToolbarContent ? ( -
+
{topToolbarContent}
) : null} @@ -175,7 +180,7 @@ export function GraphControls({
-
+
(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); + const [interactionLocked, setInteractionLocked] = useState(false); const [filters, setFilters] = useState({ showTasks: config?.showTasks ?? true, showProcesses: config?.showProcesses ?? true, @@ -136,6 +137,7 @@ export function GraphView({ color?: string | null; } | null>(null); const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null); + const activePrimaryInteractionRef = useRef(false); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -280,6 +282,15 @@ export function GraphView({ selectionLockRef.current = null; }, []); + const setInteractionGuards = useCallback( + (active: boolean) => { + activePrimaryInteractionRef.current = active; + setInteractionLocked(active); + setInteractionSelectionDisabled(active); + }, + [setInteractionSelectionDisabled] + ); + const animate = useCallback(() => { if (!runningRef.current) return; @@ -413,6 +424,7 @@ export function GraphView({ dragPreviewRef.current = null; isPanningRef.current = false; edgeMouseDownRef.current = null; + setInteractionGuards(false); }, [interaction, isSurfaceActive, simulation]); const handleWheel = useCallback( @@ -432,11 +444,11 @@ export function GraphView({ if (e.button !== 0) return; // only left click e.preventDefault(); dragPreviewRef.current = null; - setInteractionSelectionDisabled(true); + setInteractionGuards(true); const canvas = canvasHandle.current?.getCanvas(); if (!canvas) { - setInteractionSelectionDisabled(false); + setInteractionGuards(false); return; } const rect = canvas.getBoundingClientRect(); @@ -482,13 +494,14 @@ export function GraphView({ getVisibleNodes, interaction, markUserInteracted, + setInteractionGuards, simulation.stateRef, ] ); const processActivePointerMove = useCallback( - (clientX: number, clientY: number, buttons: number) => { - if ((buttons & 1) === 0) { + (clientX: number, clientY: number) => { + if (!activePrimaryInteractionRef.current) { dragPreviewRef.current = null; return false; } @@ -545,7 +558,7 @@ export function GraphView({ if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; - setInteractionSelectionDisabled(false); + setInteractionGuards(false); dragPreviewRef.current = null; setSelectedNodeId(null); setSelectedEdgeId(null); @@ -556,7 +569,7 @@ export function GraphView({ const clickedId = interaction.handleMouseUp(); if (wasDragging && draggedNodeId) { - setInteractionSelectionDisabled(false); + setInteractionGuards(false); const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) { const nearest = simulation.resolveNearestOwnerSlot( @@ -585,7 +598,7 @@ export function GraphView({ return; } - setInteractionSelectionDisabled(false); + setInteractionGuards(false); if (clickedId) { setSelectedNodeId(clickedId); setSelectedEdgeId(null); @@ -624,12 +637,12 @@ export function GraphView({ } dragPreviewRef.current = null; }, - [camera, events, interaction, onOwnerSlotDrop, setInteractionSelectionDisabled, simulation] + [camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation] ); const handleMouseMove = useCallback( (e: React.MouseEvent) => { - if (processActivePointerMove(e.clientX, e.clientY, e.buttons)) { + if (processActivePointerMove(e.clientX, e.clientY)) { return; } @@ -678,11 +691,8 @@ export function GraphView({ useEffect(() => { const handleWindowMouseMove = (event: MouseEvent): void => { - if ((event.buttons & 1) === 0) { - setInteractionSelectionDisabled(false); - return; - } if ( + !activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.dragNodeId.current && !interaction.isDragging.current && @@ -691,30 +701,47 @@ export function GraphView({ return; } event.preventDefault(); - processActivePointerMove(event.clientX, event.clientY, event.buttons); + processActivePointerMove(event.clientX, event.clientY); }; const handleWindowMouseUp = (event: MouseEvent): void => { if ( + !activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.dragNodeId.current && !interaction.isDragging.current && !edgeMouseDownRef.current ) { - setInteractionSelectionDisabled(false); + setInteractionGuards(false); return; } completePointerInteraction(event.clientX, event.clientY); }; + const clearInteraction = (): void => { + if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) { + return; + } + interaction.handleMouseUp(); + camera.handlePanEnd(); + isPanningRef.current = false; + edgeMouseDownRef.current = null; + dragPreviewRef.current = null; + setInteractionGuards(false); + }; + window.addEventListener('mousemove', handleWindowMouseMove); window.addEventListener('mouseup', handleWindowMouseUp); + window.addEventListener('blur', clearInteraction); + window.addEventListener('dragstart', clearInteraction); return () => { window.removeEventListener('mousemove', handleWindowMouseMove); window.removeEventListener('mouseup', handleWindowMouseUp); - setInteractionSelectionDisabled(false); + window.removeEventListener('blur', clearInteraction); + window.removeEventListener('dragstart', clearInteraction); + setInteractionGuards(false); }; - }, [completePointerInteraction, interaction, processActivePointerMove, setInteractionSelectionDisabled]); + }, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { @@ -911,6 +938,7 @@ export function GraphView({ teamColor={data.teamColor} isAlive={data.isAlive} topToolbarContent={renderTopToolbarContent?.()} + interactionLocked={interactionLocked} /> {renderHud ? ( diff --git a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx index 06dc290a..34574126 100644 --- a/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx +++ b/src/features/agent-graph/renderer/ui/GraphProvisioningHud.tsx @@ -34,7 +34,7 @@ const HUD_STEPPER_STYLE: CSSProperties = { }; function shouldRenderLaunchHud(presentation: TeamProvisioningPresentation | null): boolean { - return presentation != null; + return presentation != null && (presentation.isActive || presentation.isFailed); } export interface GraphProvisioningHudProps { @@ -80,8 +80,10 @@ export const GraphProvisioningHud = ({ <>