diff --git a/frontend/src/components/tree-editor/useTreeLayout.ts b/frontend/src/components/tree-editor/useTreeLayout.ts index c600c70e..eda895de 100644 --- a/frontend/src/components/tree-editor/useTreeLayout.ts +++ b/frontend/src/components/tree-editor/useTreeLayout.ts @@ -29,6 +29,46 @@ function estimateNodeHeight(node: TreeStructure): number { return height } +/** Collect all node IDs in the tree. */ +function collectAllIds(root: TreeStructure): Set { + const ids = new Set() + function walk(node: TreeStructure) { + ids.add(node.id) + node.children?.forEach(walk) + } + walk(root) + return ids +} + +/** Find all cross-reference edges (next_node_id pointing outside children). */ +function collectCrossRefEdges(root: TreeStructure): Array<{ source: string; target: string; label?: string }> { + const refs: Array<{ source: string; target: string; label?: string }> = [] + const allIds = collectAllIds(root) + + function walk(node: TreeStructure) { + const childIds = new Set(node.children?.map(c => c.id) ?? []) + + // Decision options pointing outside children + if (node.type === 'decision' && node.options) { + for (const opt of node.options) { + if (opt.next_node_id && !childIds.has(opt.next_node_id) && allIds.has(opt.next_node_id)) { + refs.push({ source: node.id, target: opt.next_node_id, label: opt.label }) + } + } + } + + // Action next_node_id pointing to non-child (always a cross-ref since actions use next_node_id not children) + if (node.type === 'action' && node.next_node_id && allIds.has(node.next_node_id) && !childIds.has(node.next_node_id)) { + refs.push({ source: node.id, target: node.next_node_id, label: 'loops back' }) + } + + node.children?.forEach(walk) + } + + walk(root) + return refs +} + interface UseTreeLayoutResult { nodes: Node[] edges: Edge[] @@ -128,6 +168,40 @@ export function useTreeLayout(): UseTreeLayoutResult { walk(treeStructure, null) + // Add cross-reference edges (dashed, purple) + if (treeStructure) { + const crossRefs = collectCrossRefEdges(treeStructure) + for (const ref of crossRefs) { + // Only add if both source and target nodes are visible (not collapsed away) + const sourceVisible = nodes.some(n => n.id === ref.source) + const targetVisible = nodes.some(n => n.id === ref.target) + if (sourceVisible && targetVisible) { + edges.push({ + id: `xref-${ref.source}->${ref.target}`, + source: ref.source, + target: ref.target, + type: 'smoothstep', + animated: true, + label: ref.label ? truncateLabel(ref.label) : undefined, + labelStyle: { fill: 'hsl(var(--primary))', fontSize: 10, fontWeight: 500 }, + labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.95 }, + labelBgPadding: [4, 2] as [number, number], + style: { + stroke: 'hsl(var(--primary))', + strokeWidth: 2, + strokeDasharray: '6 3', + }, + markerEnd: { + type: 'arrowclosed' as const, + color: 'hsl(var(--primary))', + width: 16, + height: 16, + }, + }) + } + } + } + return { rawNodes: nodes, rawEdges: edges } }, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights])