diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index 21a1d228..9a31800a 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -71,7 +71,7 @@ export function drawAgents( drawAvatar(ctx, x, y, r, node.label, color, node.kind === 'lead', node.avatarUrl); // Breathing animation + launch-stage effects - drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus, node.runtimeLabel); + drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus); drawLaunchStage(ctx, x, y, r, node.launchVisualState, time); } @@ -122,7 +122,17 @@ export function drawAgents( if (!simplify) { // Name + role label (single line: "jack · developer") const labelText = node.role ? `${node.label} · ${node.role}` : node.label; - drawLabel(ctx, x, y, r, labelText, color, node.runtimeLabel); + drawLabel( + ctx, + x, + y, + r, + labelText, + color, + node.runtimeLabel, + node.launchStatusLabel, + node.launchVisualState + ); } // TODO: Context ring disabled — LeadContextUsage.percent is unreliable @@ -257,11 +267,11 @@ function drawLaunchStage( switch (visualState) { case 'waiting': { const ringR = r + 7 + Math.sin(time * 3.2) * 1.2; - const pulseAlpha = 0.16 + 0.12 * (0.5 + 0.5 * Math.sin(time * 3.2)); + const pulseAlpha = 0.2 + 0.14 * (0.5 + 0.5 * Math.sin(time * 3.2)); ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); ctx.strokeStyle = hexWithAlpha('#d4d4d8', pulseAlpha); - ctx.lineWidth = 2; + ctx.lineWidth = 2.2; ctx.stroke(); break; } @@ -270,8 +280,8 @@ function drawLaunchStage( const rotation = time * 2.4; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.15); - ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.72); - ctx.lineWidth = 2; + ctx.strokeStyle = hexWithAlpha('#f59e0b', 0.8); + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -280,8 +290,8 @@ function drawLaunchStage( const ringR = r + 8; ctx.beginPath(); ctx.arc(x, y, ringR, 0, Math.PI * 2); - ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.4); - ctx.lineWidth = 1.5; + ctx.strokeStyle = hexWithAlpha('#38bdf8', 0.48); + ctx.lineWidth = 1.75; ctx.stroke(); const orbit = time * 1.6; @@ -300,8 +310,8 @@ function drawLaunchStage( const rotation = time * 1.25; ctx.beginPath(); ctx.arc(x, y, ringR, rotation, rotation + Math.PI * arc); - ctx.strokeStyle = hexWithAlpha('#22c55e', 0.55); - ctx.lineWidth = 1.75; + ctx.strokeStyle = hexWithAlpha('#22c55e', 0.62); + ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -310,8 +320,8 @@ function drawLaunchStage( const ringR = r + 7 + Math.sin(time * 4) * 0.8; ctx.beginPath(); ctx.arc(x, y, ringR, Math.PI * 0.2, Math.PI * 1.15); - ctx.strokeStyle = hexWithAlpha('#ef4444', 0.6); - ctx.lineWidth = 2; + ctx.strokeStyle = hexWithAlpha('#ef4444', 0.72); + ctx.lineWidth = 2.2; ctx.lineCap = 'round'; ctx.stroke(); break; @@ -467,12 +477,8 @@ function drawBreathing( r: number, state: string, time: number, - spawnStatus?: GraphNode['spawnStatus'], - runtimeLabel?: string + spawnStatus?: GraphNode['spawnStatus'] ): void { - const hasRuntimeLabel = Boolean(runtimeLabel?.trim()); - const serviceLabelY = y + r + AGENT_DRAW.labelYOffset + (hasRuntimeLabel ? 24 : 14); - // Spawning: bright animated double ring + radial glow if (spawnStatus === 'spawning') { const ringR = r + AGENT_DRAW.orbitParticleOffset; @@ -505,12 +511,6 @@ function drawBreathing( ctx.stroke(); ctx.setLineDash([]); ctx.restore(); - - // "connecting" label below name - ctx.font = '7px monospace'; - ctx.textAlign = 'center'; - ctx.fillStyle = hexWithAlpha(COLORS.holoBase, 0.5 + 0.3 * Math.sin(time * 2)); - ctx.fillText('connecting...', x, serviceLabelY); return; } @@ -532,12 +532,6 @@ function drawBreathing( ctx.strokeStyle = hexWithAlpha(COLORS.waiting, pulse); ctx.lineWidth = 1.5; ctx.stroke(); - - // "waiting" label - ctx.font = '7px monospace'; - ctx.textAlign = 'center'; - ctx.fillStyle = hexWithAlpha(COLORS.waiting, 0.4 + 0.2 * Math.sin(time * 1.5)); - ctx.fillText('waiting...', x, serviceLabelY); return; } @@ -649,7 +643,9 @@ function drawLabel( r: number, label: string, color: string, - runtimeLabel?: string + runtimeLabel?: string, + launchStatusLabel?: string, + launchVisualState?: GraphNode['launchVisualState'] ): void { const labelY = y + r + AGENT_DRAW.labelYOffset; ctx.font = '9px monospace'; @@ -659,16 +655,27 @@ function drawLabel( ctx.fillText(label, x, labelY); const trimmedRuntimeLabel = runtimeLabel?.trim(); - if (!trimmedRuntimeLabel) { + const trimmedLaunchStatusLabel = launchStatusLabel?.trim(); + if (!trimmedRuntimeLabel && !trimmedLaunchStatusLabel) { return; } - ctx.font = '8px monospace'; - ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72); - ctx.fillText(truncateRuntimeLabel(ctx, trimmedRuntimeLabel, r), x, labelY + 10); + let nextLineY = labelY + 10; + if (trimmedRuntimeLabel) { + ctx.font = '8px monospace'; + ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72); + ctx.fillText(truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY); + nextLineY += 10; + } + + if (trimmedLaunchStatusLabel) { + ctx.font = '7px monospace'; + ctx.fillStyle = getLaunchStatusColor(launchVisualState); + ctx.fillText(truncateSubLabel(ctx, trimmedLaunchStatusLabel, r), x, nextLineY); + } } -function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string { +function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string { const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2); if (ctx.measureText(label).width <= maxWidth) return label; @@ -679,6 +686,23 @@ function truncateRuntimeLabel(ctx: CanvasRenderingContext2D, label: string, r: n return `${out}…`; } +function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): string { + switch (visualState) { + case 'waiting': + return hexWithAlpha('#d4d4d8', 0.8); + case 'spawning': + return hexWithAlpha('#f59e0b', 0.9); + case 'runtime_pending': + return hexWithAlpha('#67e8f9', 0.9); + case 'settling': + return hexWithAlpha('#22c55e', 0.9); + case 'error': + return hexWithAlpha('#ef4444', 0.92); + default: + return hexWithAlpha(COLORS.holoBright, 0.75); + } +} + /** * Draw context usage ring around lead node. */ diff --git a/packages/agent-graph/src/layout/stableSlotGeometry.ts b/packages/agent-graph/src/layout/stableSlotGeometry.ts index a9e58b13..d1e5265f 100644 --- a/packages/agent-graph/src/layout/stableSlotGeometry.ts +++ b/packages/agent-graph/src/layout/stableSlotGeometry.ts @@ -9,7 +9,7 @@ export const STABLE_SLOT_GEOMETRY = { unassignedGap: 72, maxGeneratedRings: 12, ownerCollisionPadding: 28, - ownerBandHeight: 72, + ownerBandHeight: 96, ownerMinWidth: 200, processBandHeight: 32, processRailWidth: 220, diff --git a/packages/agent-graph/src/ports/types.ts b/packages/agent-graph/src/ports/types.ts index df378f75..af439ae4 100644 --- a/packages/agent-graph/src/ports/types.ts +++ b/packages/agent-graph/src/ports/types.ts @@ -84,6 +84,8 @@ export interface GraphNode { spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error'; /** Shared launch-stage visual derived by the host app */ launchVisualState?: GraphLaunchVisualState; + /** Shared launch-stage text shown beside the node during launch only */ + launchStatusLabel?: string; /** Context window usage ratio (0..1), available for lead only */ contextUsage?: number; /** Current task ID this member is working on */ diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index a842bbf9..bb3a43af 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -135,6 +135,7 @@ export function GraphView({ y: number; color?: string | null; } | null>(null); + const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); @@ -255,6 +256,30 @@ export function GraphView({ return { x: node.x, y: node.y }; }, []); + const setInteractionSelectionDisabled = useCallback((disabled: boolean) => { + if (typeof document === 'undefined') { + return; + } + const bodyStyle = document.body.style; + if (disabled) { + if (!selectionLockRef.current) { + selectionLockRef.current = { + userSelect: bodyStyle.userSelect, + webkitUserSelect: bodyStyle.webkitUserSelect, + }; + } + bodyStyle.userSelect = 'none'; + bodyStyle.webkitUserSelect = 'none'; + return; + } + if (!selectionLockRef.current) { + return; + } + bodyStyle.userSelect = selectionLockRef.current.userSelect; + bodyStyle.webkitUserSelect = selectionLockRef.current.webkitUserSelect; + selectionLockRef.current = null; + }, []); + const animate = useCallback(() => { if (!runningRef.current) return; @@ -405,10 +430,15 @@ export function GraphView({ const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return; // only left click + e.preventDefault(); dragPreviewRef.current = null; + setInteractionSelectionDisabled(true); const canvas = canvasHandle.current?.getCanvas(); - if (!canvas) return; + if (!canvas) { + setInteractionSelectionDisabled(false); + return; + } const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); const nodes = getVisibleNodes(simulation.stateRef.current.nodes); @@ -464,6 +494,9 @@ export function GraphView({ } if (isPanningRef.current) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); + } camera.handlePanMove(clientX, clientY); return true; } @@ -480,6 +513,9 @@ export function GraphView({ const draggedNodeId = interaction.dragNodeId.current; if (interaction.isDragging.current && draggedNodeId) { + if (typeof document !== 'undefined') { + document.getSelection()?.removeAllRanges(); + } const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); if (draggedNode?.kind === 'member') { const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y); @@ -509,6 +545,7 @@ export function GraphView({ if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; + setInteractionSelectionDisabled(false); dragPreviewRef.current = null; setSelectedNodeId(null); setSelectedEdgeId(null); @@ -519,6 +556,7 @@ export function GraphView({ const clickedId = interaction.handleMouseUp(); if (wasDragging && draggedNodeId) { + setInteractionSelectionDisabled(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( @@ -547,6 +585,7 @@ export function GraphView({ return; } + setInteractionSelectionDisabled(false); if (clickedId) { setSelectedNodeId(clickedId); setSelectedEdgeId(null); @@ -585,7 +624,7 @@ export function GraphView({ } dragPreviewRef.current = null; }, - [camera, events, interaction, onOwnerSlotDrop, simulation] + [camera, events, interaction, onOwnerSlotDrop, setInteractionSelectionDisabled, simulation] ); const handleMouseMove = useCallback( @@ -640,6 +679,7 @@ export function GraphView({ useEffect(() => { const handleWindowMouseMove = (event: MouseEvent): void => { if ((event.buttons & 1) === 0) { + setInteractionSelectionDisabled(false); return; } if ( @@ -650,6 +690,7 @@ export function GraphView({ ) { return; } + event.preventDefault(); processActivePointerMove(event.clientX, event.clientY, event.buttons); }; @@ -660,6 +701,7 @@ export function GraphView({ !interaction.isDragging.current && !edgeMouseDownRef.current ) { + setInteractionSelectionDisabled(false); return; } completePointerInteraction(event.clientX, event.clientY); @@ -670,8 +712,9 @@ export function GraphView({ return () => { window.removeEventListener('mousemove', handleWindowMouseMove); window.removeEventListener('mouseup', handleWindowMouseUp); + setInteractionSelectionDisabled(false); }; - }, [completePointerInteraction, interaction, processActivePointerMove]); + }, [completePointerInteraction, interaction, processActivePointerMove, setInteractionSelectionDisabled]); const handleDoubleClick = useCallback( (e: React.MouseEvent) => { @@ -819,7 +862,10 @@ export function GraphView({ // ─── Render ───────────────────────────────────────────────────────────── return ( -