"use client"; import { useCallback, useMemo, useRef } from "react"; import { Handle, Position, type NodeProps, type Node } from "@xyflow/react"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { showToast } from "@/components/Toaster"; import { Tooltip } from "@/components/Tooltip"; import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens"; import { useShallow } from "zustand/react/shallow"; /** Stable selector: returns children, grandchild flag, and descendant count for a node */ function useHierarchyInfo(parentId: string) { const childIds = useCanvasStore( useCallback((s) => s.nodes.filter((n) => n.data.parentId === parentId).map((n) => n.id).join(","), [parentId]) ); const children = useCanvasStore( useShallow((s) => s.nodes.filter((n) => n.data.parentId === parentId)) ); const hasGrandchildren = useCanvasStore( useCallback((s) => { const ids = childIds.split(",").filter(Boolean); return ids.length > 0 && ids.some((cid) => s.nodes.some((n) => n.data.parentId === cid)); }, [childIds]) ); const descendantCount = useCanvasStore( useCallback((s) => countDescendants(parentId, s.nodes), [parentId]) ); return { children, hasGrandchildren, descendantCount }; } /** Eject/extract arrow icon — visually distinct from delete ✕ */ function EjectIcon() { return ( ); } export function WorkspaceNode({ id, data }: NodeProps>) { const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline; const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" }; const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); const selectNode = useCanvasStore((s) => s.selectNode); const openContextMenu = useCanvasStore((s) => s.openContextMenu); const nestNode = useCanvasStore((s) => s.nestNode); const isDragTarget = useCanvasStore((s) => s.dragOverNodeId === id); const isSelected = selectedNodeId === id; const isOnline = data.status === "online"; // Get children + hierarchy info (single stable selector avoids redundant re-renders) const { children, hasGrandchildren, descendantCount } = useHierarchyInfo(id); const hasChildren = children.length > 0; const skills = getSkillNames(data.agentCard); const handleExtract = useCallback( (childId: string) => nestNode(childId, null), [nestNode] ); return (
{ e.stopPropagation(); selectNode(isSelected ? null : id); }} onDoubleClick={(e) => { e.stopPropagation(); if (hasChildren) { window.dispatchEvent(new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: id } })); } }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); openContextMenu({ x: e.clientX, y: e.clientY, nodeId: id, nodeData: data }); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectNode(isSelected ? null : id); } else if (e.key === "ContextMenu") { e.preventDefault(); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); openContextMenu({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, nodeId: id, nodeData: data, }); } }} className={` group relative rounded-xl ${hasGrandchildren ? "min-w-[720px] max-w-[960px]" : hasChildren ? "min-w-[320px] max-w-[450px]" : "min-w-[210px] max-w-[280px]"} cursor-pointer overflow-hidden transition-all duration-200 ease-out ${isDragTarget ? "bg-emerald-950/40 border-2 border-emerald-400/60 ring-2 ring-emerald-400/20 scale-[1.03]" : isSelected ? "bg-zinc-900/95 border border-blue-500/70 ring-1 ring-blue-500/30 shadow-lg shadow-blue-500/10" : "bg-zinc-900/90 border border-zinc-700/80 hover:border-zinc-500/60 shadow-lg shadow-black/30 hover:shadow-xl hover:shadow-black/40" } backdrop-blur-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950 `} > {/* Status gradient bar at top */}
{/* Header row */}
{data.name}
{hasChildren && ( {descendantCount} sub )} {tierCfg.label}
{/* Runtime badge — prefers workspace.runtime (DB column) over agent_card.runtime (agent-reported). Phase 30 remote agents (runtime='external') get a distinct purple "REMOTE" pill. We treat empty-string DB values as "missing" so an unbackfilled row falls through to the agent-card value rather than rendering a blank pill. */} {(() => { const dbRuntime = typeof data.runtime === "string" && data.runtime !== "" ? data.runtime : null; const cardRuntime = data.agentCard && typeof (data.agentCard as Record).runtime === "string" ? (data.agentCard as Record).runtime : null; const runtime = dbRuntime ?? cardRuntime; if (!runtime) return null; return (
{runtime === "external" ? ( ★ REMOTE ) : ( {runtime} )}
); })()} {/* Role */} {data.role && (
{data.role}
)} {/* Skills */} {skills.length > 0 && (
{skills.slice(0, 4).map((skill) => ( {skill} ))} {skills.length > 4 && ( +{skills.length - 4} )}
)} {/* Embedded children — rendered INSIDE the parent node */} {hasChildren && ( )} {/* Current task */} {data.currentTask && (
{data.currentTask}
)} {/* Needs restart banner */} {data.needsRestart && !data.currentTask && ( )} {/* Bottom row: status / active tasks */}
{data.status !== "online" ? (
{statusCfg.label}
) :
} {data.activeTasks > 0 && (
{data.activeTasks} task{data.activeTasks > 1 ? "s" : ""}
)}
{/* Degraded error preview */} {data.status === "degraded" && data.lastSampleError && (
{data.lastSampleError}
)}
); } const MAX_NESTING_DEPTH = 3; /** Count all descendants (children + grandchildren + ...) */ function countDescendants(nodeId: string, allNodes: Node[], visited = new Set()): number { if (visited.has(nodeId)) return 0; visited.add(nodeId); const directChildren = allNodes.filter((n) => n.data.parentId === nodeId); let count = directChildren.length; for (const child of directChildren) { count += countDescendants(child.id, allNodes, visited); } return count; } /** Subscribes to allNodes only when children exist — isolates re-renders from parent */ function EmbeddedTeam({ members, depth, onSelect, onExtract }: { members: Node[]; depth: number; onSelect: (id: string) => void; onExtract: (id: string) => void; }) { const allNodes = useCanvasStore((s) => s.nodes); // Use grid layout at depth 0 when there are multiple members (departments side-by-side) const useGrid = depth === 0 && members.length >= 2; return (
Team Members
{members.map((child) => ( ))}
); } /** Recursive mini-card — mirrors parent card layout at smaller scale */ function TeamMemberChip({ node, allNodes, depth, onSelect, onExtract, }: { node: Node; allNodes: Node[]; depth: number; onSelect: (id: string) => void; onExtract: (id: string) => void; }) { const { data } = node; const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline; const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-zinc-500 bg-zinc-800" }; const isOnline = data.status === "online"; const skills = getSkillNames(data.agentCard); const subChildren = useMemo( () => allNodes.filter((n) => n.data.parentId === node.id), [allNodes, node.id] ); const hasSubChildren = subChildren.length > 0; const descendantCount = useMemo( () => hasSubChildren ? countDescendants(node.id, allNodes) : 0, [allNodes, node.id, hasSubChildren] ); return (
{ e.stopPropagation(); onSelect(node.id); }} onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); useCanvasStore.getState().openContextMenu({ x: e.clientX, y: e.clientY, nodeId: node.id, nodeData: data }); }} > {/* Status gradient bar */}
{/* Header: name + badges + extract */}
{data.name}
{hasSubChildren && ( {descendantCount} )} {tierCfg.label}
{/* Role */} {data.role && (
{data.role}
)} {/* Skills */} {skills.length > 0 && (
{skills.slice(0, 3).map((skill) => ( {skill} ))} {skills.length > 3 && ( +{skills.length - 3} )}
)} {/* Status + active tasks row */}
{data.status !== "online" ? ( {statusCfg.label} ) :
} {data.activeTasks > 0 && (
{data.activeTasks}
)}
{/* Current task banner for sub-agents */} {data.currentTask && (
{data.currentTask}
)} {/* Recursive sub-children rendered inside this card */} {hasSubChildren && depth < MAX_NESTING_DEPTH && (
Team
= 2 ? "grid grid-cols-2 gap-1" : "space-y-1"}> {subChildren.map((sub) => ( ))}
)}
); } function getSkillNames(agentCard: Record | null): string[] { if (!agentCard) return []; const skills = agentCard.skills; if (!Array.isArray(skills)) return []; return skills.map((s: Record) => String(s.name || s.id || "") ).filter(Boolean); }