import { useState, useMemo } from 'react' import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, Copy, Check, Play, Users } from 'lucide-react' import type { TreeStructure, NodeType } from '@/types' import { cn } from '@/lib/utils' import type { SharedLinksMap } from './TreePreviewPanel' type FindNodeFn = (nodeId: string) => TreeStructure | null /** * Recursively check if a node's subtree contains any solution nodes * Also follows next_node_id references using the findNode function * @param visited - Set to track visited nodes and prevent infinite loops */ function hasSolutionInSubtree( node: TreeStructure, findNode: FindNodeFn, visited: Set = new Set() ): boolean { // Prevent infinite loops from circular references if (visited.has(node.id)) return false visited.add(node.id) // This node is a solution if (node.type === 'solution') { return true } // Check children array if (node.children && node.children.length > 0) { if (node.children.some(child => hasSolutionInSubtree(child, findNode, visited))) { return true } } // Check next_node_id reference (for action nodes) if (node.next_node_id) { const nextNode = findNode(node.next_node_id) if (nextNode && hasSolutionInSubtree(nextNode, findNode, visited)) { return true } } return false } interface TreePreviewNodeProps { node: TreeStructure selectedNodeId: string | null onSelect: (nodeId: string) => void depth: number /** Optional label showing which option led to this node */ fromOption?: string /** Callback when hovering over a node reference */ onHoverNodeId?: (nodeId: string | null) => void /** Currently hovered node ID */ hoveredNodeId?: string | null /** Function to look up any node by ID (for following next_node_id references) */ findNode: FindNodeFn /** Map of targetNodeId -> sources that link to it (for showing shared connections) */ sharedLinksMap: SharedLinksMap } export function TreePreviewNode({ node, selectedNodeId, onSelect, depth, fromOption, onHoverNodeId, hoveredNodeId, findNode, sharedLinksMap }: TreePreviewNodeProps) { const [isCollapsed, setIsCollapsed] = useState(false) const [copiedId, setCopiedId] = useState(false) const isSelected = selectedNodeId === node.id const isHovered = hoveredNodeId === node.id const isRootNode = node.id === 'root' // Check if this node (or its children/next_node_id) leads to a solution const leadsTosolution = useMemo(() => { // Don't show indicator on solution nodes themselves if (node.type === 'solution') return false return hasSolutionInSubtree(node, findNode) }, [node, findNode]) const nodeTypeColors: Record = { decision: 'border-blue-500/50 bg-blue-500/10', action: 'border-yellow-500/50 bg-yellow-500/10', solution: 'border-green-500/50 bg-green-500/10', answer: 'border-dashed border-border bg-muted/50' } const nodeTypeSelectedColors: Record = { decision: 'border-blue-500 bg-blue-500/20 ring-2 ring-blue-500/50 shadow-lg shadow-blue-500/20', action: 'border-yellow-500 bg-yellow-500/20 ring-2 ring-yellow-500/50 shadow-lg shadow-yellow-500/20', solution: 'border-green-500 bg-green-500/20 ring-2 ring-green-500/50 shadow-lg shadow-green-500/20', answer: 'border-border bg-muted/50' } const nodeTypeHoveredColors: Record = { decision: 'border-blue-400 bg-blue-500/15 ring-1 ring-blue-400/50', action: 'border-yellow-400 bg-yellow-500/15 ring-1 ring-yellow-400/50', solution: 'border-green-400 bg-green-500/15 ring-1 ring-green-400/50', answer: 'border-border bg-muted/50' } const nodeTypeIcons: Record = { decision: , action: , solution: , answer: } const getNodeLabel = () => { if (node.type === 'decision') return node.question || 'Untitled Question' return node.title || `Untitled ${node.type}` } const hasChildren = node.children && node.children.length > 0 const handleCopyId = (e: React.MouseEvent) => { e.stopPropagation() navigator.clipboard.writeText(node.id) setCopiedId(true) setTimeout(() => setCopiedId(false), 2000) } const toggleCollapse = (e: React.MouseEvent) => { e.stopPropagation() setIsCollapsed(!isCollapsed) } // Find which option label leads to each child node const getOptionLabelForChild = (childId: string): string | undefined => { if (node.type === 'decision' && node.options) { const option = node.options.find(opt => opt.next_node_id === childId) return option?.label } return undefined } // Check if a specific option/next_node_id leads to a solution const nodeLeadsToSolution = (nextNodeId: string | undefined): boolean => { if (!nextNodeId) return false // First try to find in children const childNode = node.children?.find(child => child.id === nextNodeId) if (childNode) { return hasSolutionInSubtree(childNode, findNode) } // Otherwise look up using findNode (for shared/external node references) const targetNode = findNode(nextNodeId) if (!targetNode) return false return hasSolutionInSubtree(targetNode, findNode) } return (
{/* From option label */} {fromOption && (
{fromOption}
)} {/* Node card */}
onSelect(node.id)} className={cn( 'relative cursor-pointer rounded-lg border-2 p-3 transition-all', isRootNode ? isSelected ? 'border-blue-500 bg-blue-500/20 ring-2 ring-blue-500/50 shadow-lg shadow-blue-500/20' : isHovered ? 'border-blue-400 bg-blue-500/15 ring-1 ring-blue-400/50' : 'border-blue-500/50 bg-blue-500/10' : isSelected ? nodeTypeSelectedColors[node.type] : isHovered ? nodeTypeHoveredColors[node.type] : nodeTypeColors[node.type], 'hover:shadow-md', isRootNode ? 'min-w-[260px] max-w-[360px]' : 'min-w-[220px] max-w-[320px]' )} > {/* Solution path indicator - shows when this branch leads to a solution */} {leadsTosolution && (
)} {/* Root node START header */} {isRootNode && (
Starting Question
)}
{/* Collapse toggle for nodes with children */} {hasChildren ? ( ) : (
// Spacer for alignment )} {!isRootNode && nodeTypeIcons[node.type]} {isRootNode && }

{getNodeLabel()}

{/* Node ID with copy button */}
{node.id === 'root' ? 'root' : node.id.slice(0, 8) + '...'}
{/* Show options for decision nodes */} {node.type === 'decision' && node.options && node.options.length > 0 && (

Options:

{node.options.map((opt, i) => { const leadsToSolution = nodeLeadsToSolution(opt.next_node_id) return (
opt.next_node_id && onHoverNodeId?.(opt.next_node_id)} onMouseLeave={() => onHoverNodeId?.(null)} > {i + 1} {opt.label || 'Untitled'} {leadsToSolution && ( )} {opt.next_node_id ? ( ) : ( (no link) )}
) })}
)} {/* Show next_node_id for action nodes */} {node.type === 'action' && node.next_node_id && (() => { const nextNode = findNode(node.next_node_id!) const nextNodeLeadsToSolution = nodeLeadsToSolution(node.next_node_id) const nextNodeLabel = nextNode ? (nextNode.type === 'decision' ? nextNode.question : nextNode.title) || 'Untitled' : node.next_node_id!.slice(0, 8) + '...' // Check if this target is shared by multiple sources const sourcesLinkingToTarget = sharedLinksMap.get(node.next_node_id!) || [] const otherSources = sourcesLinkingToTarget.filter(s => s.id !== node.id) const isSharedTarget = otherSources.length > 0 // Build tooltip for shared connection const sharedTooltip = isSharedTarget ? `Shared endpoint - also connected from: ${otherSources.map(s => s.label).join(', ')}` : undefined return (
onHoverNodeId?.(node.next_node_id!)} onMouseLeave={() => onHoverNodeId?.(null)} >
Next: {isSharedTarget && ( )} {nextNodeLabel.slice(0, 30)}{nextNodeLabel.length > 30 ? '...' : ''} {(nextNodeLeadsToSolution || nextNode?.type === 'solution') && ( )}
{/* Show shared sources count */} {isSharedTarget && (
Shared by {sourcesLinkingToTarget.length} nodes
)}
) })()}
{/* Children - show as branches */} {hasChildren && !isCollapsed && (
{node.children!.map((child) => { const optionLabel = getOptionLabelForChild(child.id) return (
{/* Horizontal connector line */}
) })}
)} {/* Show collapsed indicator */} {hasChildren && isCollapsed && (
{node.children!.length} child node{node.children!.length !== 1 ? 's' : ''} hidden
)}
) } export default TreePreviewNode