From 6efbdda688b546593afbb171ee7223a7900eab0f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 20:50:00 -0500 Subject: [PATCH] docs: add React Flow migration implementation plan 12 tasks across 8 phases covering dagre layout, custom nodes, side panel editor, and full canvas integration. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-18-flow-editor-react-flow-impl.md | 1474 +++++++++++++++++ 1 file changed, 1474 insertions(+) create mode 100644 docs/plans/2026-02-18-flow-editor-react-flow-impl.md diff --git a/docs/plans/2026-02-18-flow-editor-react-flow-impl.md b/docs/plans/2026-02-18-flow-editor-react-flow-impl.md new file mode 100644 index 00000000..28fddc66 --- /dev/null +++ b/docs/plans/2026-02-18-flow-editor-react-flow-impl.md @@ -0,0 +1,1474 @@ +# Flow Editor React Flow Migration — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the hand-built CSS flexbox tree canvas with @xyflow/react for zoom/pan, dagre auto-layout, collapsible minimap, and side-panel node editing. + +**Architecture:** The Zustand store (`treeEditorStore`) remains the single source of truth — no store changes. A `useTreeLayout` hook derives flat `Node[]`/`Edge[]` arrays from the recursive `treeStructure` and positions them with dagre. Custom React Flow node components render compact cards. A right-side `NodeEditorPanel` reuses existing form components (`NodeFormDecision`, `NodeFormAction`, `NodeFormResolution`) for editing. + +**Tech Stack:** @xyflow/react, @dagrejs/dagre, React 19, TypeScript, Tailwind CSS, Zustand + +**Design Doc:** `docs/plans/2026-02-18-flow-editor-react-flow-design.md` + +--- + +## Phase 1: Dependencies + Dagre Layout Utility + +### Task 1: Install dependencies + +**Step 1: Install packages** + +Run: +```bash +cd frontend && npm install @xyflow/react @dagrejs/dagre +``` + +`@xyflow/react` includes its own types. `@dagrejs/dagre` includes types via `@types/dagre` bundled. + +**Step 2: Verify build still passes** + +Run: `cd frontend && npm run build` +Expected: Clean build, no errors. + +**Step 3: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json +git commit -m "chore: install @xyflow/react and @dagrejs/dagre" +``` + +--- + +### Task 2: Create dagre layout utility + +**Files:** +- Create: `frontend/src/lib/dagreLayout.ts` + +This is a pure function with no React dependencies — it takes nodes and edges and returns positioned nodes. + +**Step 1: Create the layout utility** + +```typescript +// frontend/src/lib/dagreLayout.ts +import dagre from '@dagrejs/dagre' +import type { Node, Edge } from '@xyflow/react' + +const NODE_WIDTH = 280 +const DEFAULT_NODE_HEIGHT = 100 + +interface LayoutOptions { + direction?: 'TB' | 'LR' + rankSep?: number + nodeSep?: number +} + +export function getLayoutedElements( + nodes: Node[], + edges: Edge[], + options: LayoutOptions = {} +): Node[] { + const { direction = 'TB', rankSep = 100, nodeSep = 40 } = options + + const g = new dagre.graphlib.Graph() + g.setDefaultEdgeLabel(() => ({})) + g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep }) + + nodes.forEach((node) => { + const height = node.measured?.height ?? node.data?.estimatedHeight ?? DEFAULT_NODE_HEIGHT + g.setNode(node.id, { width: NODE_WIDTH, height }) + }) + + edges.forEach((edge) => { + g.setEdge(edge.source, edge.target) + }) + + dagre.layout(g) + + return nodes.map((node) => { + const dagreNode = g.node(node.id) + // dagre gives center positions — convert to top-left for React Flow + const x = dagreNode.x - NODE_WIDTH / 2 + const y = dagreNode.y - (dagreNode.height ?? DEFAULT_NODE_HEIGHT) / 2 + return { ...node, position: { x, y } } + }) +} + +export { NODE_WIDTH, DEFAULT_NODE_HEIGHT } +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/lib/dagreLayout.ts +git commit -m "feat: add dagre layout utility for React Flow node positioning" +``` + +--- + +## Phase 2: Custom Node Components + +### Task 3: Create FlowCanvasNode (compact card for decision/action/solution) + +**Files:** +- Create: `frontend/src/components/tree-editor/FlowCanvasNode.tsx` + +**Context:** This is a React Flow custom node component. It receives props via `data` from React Flow's node system. It renders a compact, non-editable card. Clicking the card body selects the node (handled by React Flow's `onNodeClick`). The collapse chevron at the bottom is a separate click target. + +**Step 1: Create the component** + +```tsx +// frontend/src/components/tree-editor/FlowCanvasNode.tsx +import { memo } from 'react' +import { Handle, Position, type NodeProps } from '@xyflow/react' +import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { TreeStructure, NodeType } from '@/types' + +const NODE_TYPE_CONFIG: Record, { + icon: typeof HelpCircle + label: string + borderClass: string + badgeClass: string + minimapColor: string +}> = { + decision: { + icon: HelpCircle, + label: 'Decision', + borderClass: 'border-l-4 border-l-blue-500', + badgeClass: 'bg-blue-500/20 text-blue-400', + minimapColor: '#3b82f6', + }, + action: { + icon: Zap, + label: 'Action', + borderClass: 'border-l-4 border-l-yellow-500', + badgeClass: 'bg-yellow-500/20 text-yellow-400', + minimapColor: '#eab308', + }, + solution: { + icon: CheckCircle, + label: 'Solution', + borderClass: 'border-l-4 border-l-green-500', + badgeClass: 'bg-green-500/20 text-green-400', + minimapColor: '#22c55e', + }, +} + +export interface FlowCanvasNodeData { + node: TreeStructure + hasChildren: boolean + isCollapsed: boolean + hasValidationErrors: boolean + isNew: boolean + onToggleCollapse: (nodeId: string) => void +} + +function FlowCanvasNodeComponent({ data, selected }: NodeProps) { + const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse } = data as FlowCanvasNodeData + const nodeType = node.type as Exclude + const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision + const Icon = config.icon + + const title = node.type === 'decision' + ? (node.question || 'Untitled Decision') + : (node.title || `Untitled ${config.label}`) + + const optionCount = node.options?.length ?? 0 + + return ( + <> + {/* Target handle at top */} + + +
+ {/* Header */} +
+ {/* Type badge */} + + + + + {/* Title */} + + {title} + + + {/* Badges */} + {isNew && ( + + New + + )} + {hasValidationErrors && ( + + )} +
+ + {/* Decision options preview */} + {node.type === 'decision' && optionCount > 0 && ( +
+
+ {optionCount} option{optionCount !== 1 ? 's' : ''} +
+
+ {node.options!.slice(0, 3).map((opt, i) => ( +
+ {String.fromCharCode(65 + i)}{' '} + {opt.label || '(empty)'} +
+ ))} + {optionCount > 3 && ( +
+{optionCount - 3} more
+ )} +
+
+ )} + + {/* Description preview for action/solution */} + {(node.type === 'action' || node.type === 'solution') && node.description && ( +
+
{node.description}
+
+ )} + + {/* Collapse chevron */} + {hasChildren && ( +
+ +
+ )} +
+ + {/* Source handle at bottom */} + + + ) +} + +export const FlowCanvasNode = memo(FlowCanvasNodeComponent) +export { NODE_TYPE_CONFIG } +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/FlowCanvasNode.tsx +git commit -m "feat: add FlowCanvasNode compact card for React Flow canvas" +``` + +--- + +### Task 4: Create FlowCanvasAnswerNode (answer stub) + +**Files:** +- Create: `frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx` + +**Context:** This is the React Flow custom node for `type === 'answer'` nodes. It shows the stub label and a "Choose Type" prompt. Clicking opens a type picker inline. Selecting a type calls `onSelectType` which updates the node in the store and opens the editor panel. + +**Step 1: Create the component** + +```tsx +// frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx +import { memo, useState } from 'react' +import { Handle, Position, type NodeProps } from '@xyflow/react' +import { HelpCircle, Zap, CheckCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { TreeStructure } from '@/types' + +export interface FlowCanvasAnswerNodeData { + node: TreeStructure + onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void +} + +function FlowCanvasAnswerNodeComponent({ data, selected }: NodeProps) { + const { node, onSelectType } = data as FlowCanvasAnswerNodeData + const [picking, setPicking] = useState(false) + const label = node.title || 'Answer' + + return ( + <> + + +
!picking && setPicking(true)} + > +
+ {label} +
+ + {!picking ? ( +
+ + Choose Type +
+ ) : ( +
+ + + +
+ )} +
+ + + + ) +} + +export const FlowCanvasAnswerNode = memo(FlowCanvasAnswerNodeComponent) +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx +git commit -m "feat: add FlowCanvasAnswerNode stub card for React Flow canvas" +``` + +--- + +## Phase 3: Tree-to-ReactFlow Conversion Hook + +### Task 5: Create useTreeLayout hook + +**Files:** +- Create: `frontend/src/components/tree-editor/useTreeLayout.ts` + +**Context:** This hook converts the Zustand store's recursive `treeStructure` into flat React Flow `Node[]` and `Edge[]` arrays. It runs dagre to compute positions. It tracks collapsed node IDs and skips children of collapsed nodes. + +The hook also handles the height-measurement correction: after first render, it checks if any node's actual rendered height differs from the dagre estimate by >10px and re-runs layout if so. + +**Step 1: Create the hook** + +```typescript +// frontend/src/components/tree-editor/useTreeLayout.ts +import { useMemo, useCallback, useState, useRef, useEffect } from 'react' +import { useReactFlow, type Node, type Edge } from '@xyflow/react' +import type { TreeStructure } from '@/types' +import { getLayoutedElements, NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '@/lib/dagreLayout' +import type { FlowCanvasNodeData } from './FlowCanvasNode' +import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode' +import { useTreeEditorStore } from '@/store/treeEditorStore' + +const MAX_EDGE_LABEL_LENGTH = 35 + +function truncateLabel(label: string): string { + if (label.length <= MAX_EDGE_LABEL_LENGTH) return label + return label.slice(0, MAX_EDGE_LABEL_LENGTH).trimEnd() + '…' +} + +function estimateNodeHeight(node: TreeStructure): number { + let height = 52 // header baseline + if (node.type === 'decision' && node.options) { + height += 24 // options header line + height += Math.min(node.options.length, 3) * 18 // option rows (max 3 shown) + if (node.options.length > 3) height += 18 // "+N more" row + } + if ((node.type === 'action' || node.type === 'solution') && node.description) { + height += 36 // description preview (2 lines) + } + if (node.type === 'answer') { + return 70 // fixed height for answer stubs + } + return height +} + +interface UseTreeLayoutResult { + nodes: Node[] + edges: Edge[] + collapsedNodeIds: Set + toggleCollapse: (nodeId: string) => void + onNodesMeasured: (measuredNodes: Node[]) => void +} + +export function useTreeLayout(): UseTreeLayoutResult { + const treeStructure = useTreeEditorStore(s => s.treeStructure) + const validationErrors = useTreeEditorStore(s => s.validationErrors) + const [collapsedNodeIds, setCollapsedNodeIds] = useState>(new Set()) + const [measuredHeights, setMeasuredHeights] = useState>(new Map()) + const correctionDone = useRef(false) + + const toggleCollapse = useCallback((nodeId: string) => { + setCollapsedNodeIds(prev => { + const next = new Set(prev) + if (next.has(nodeId)) next.delete(nodeId) + else next.add(nodeId) + return next + }) + }, []) + + // Convert tree structure to flat nodes and edges + const { rawNodes, rawEdges } = useMemo(() => { + const nodes: Node[] = [] + const edges: Edge[] = [] + + if (!treeStructure) return { rawNodes: nodes, rawEdges: edges } + + function walk(node: TreeStructure, _parentId: string | null) { + const isCollapsed = collapsedNodeIds.has(node.id) + const hasChildren = (node.children?.length ?? 0) > 0 + const hasErrors = validationErrors.some(e => e.nodeId === node.id && e.severity === 'error') + + const estimatedHeight = measuredHeights.get(node.id) ?? estimateNodeHeight(node) + + if (node.type === 'answer') { + nodes.push({ + id: node.id, + type: 'answerStub', + position: { x: 0, y: 0 }, // dagre will set this + data: { + node, + onSelectType: () => {}, // placeholder — set by FlowCanvas + } satisfies Partial, + style: { width: NODE_WIDTH }, + measured: { width: NODE_WIDTH, height: estimatedHeight }, + }) + } else { + nodes.push({ + id: node.id, + type: 'flowNode', + position: { x: 0, y: 0 }, + data: { + node, + hasChildren, + isCollapsed, + hasValidationErrors: hasErrors, + isNew: false, + onToggleCollapse: () => {}, // placeholder — set by FlowCanvas + } satisfies Partial, + style: { width: NODE_WIDTH }, + measured: { width: NODE_WIDTH, height: estimatedHeight }, + }) + } + + // Skip children if collapsed + if (isCollapsed) return + + // Create edges and recurse into children + if (node.children) { + // For decision nodes: order children by option link, then unlinked + const orderedChildren = orderChildren(node) + + for (const { child, optionLabel } of orderedChildren) { + const edgeLabel = optionLabel ? truncateLabel(optionLabel) : undefined + + edges.push({ + id: `${node.id}->${child.id}`, + source: node.id, + target: child.id, + type: 'smoothstep', + label: edgeLabel, + labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 11 }, + labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.9 }, + labelBgPadding: [4, 2] as [number, number], + style: { stroke: 'hsl(var(--border))' }, + }) + + walk(child, node.id) + } + } + } + + walk(treeStructure, null) + + return { rawNodes: nodes, rawEdges: edges } + }, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights]) + + // Run dagre layout + const { nodes, edges } = useMemo(() => { + if (rawNodes.length === 0) return { nodes: rawNodes, edges: rawEdges } + const layouted = getLayoutedElements(rawNodes, rawEdges) + return { nodes: layouted, edges: rawEdges } + }, [rawNodes, rawEdges]) + + // Height measurement correction callback + const onNodesMeasured = useCallback((measuredNodes: Node[]) => { + if (correctionDone.current) return + + let needsCorrection = false + const newHeights = new Map(measuredHeights) + + for (const mNode of measuredNodes) { + const actual = mNode.measured?.height + if (!actual) continue + const estimated = measuredHeights.get(mNode.id) ?? estimateNodeHeight( + (mNode.data as FlowCanvasNodeData)?.node ?? (mNode.data as FlowCanvasAnswerNodeData)?.node + ) + if (Math.abs(actual - estimated) > 10) { + newHeights.set(mNode.id, actual) + needsCorrection = true + } + } + + if (needsCorrection) { + correctionDone.current = true + setMeasuredHeights(newHeights) + } + }, [measuredHeights]) + + // Reset correction flag when tree structure changes + useEffect(() => { + correctionDone.current = false + }, [treeStructure, collapsedNodeIds]) + + return { nodes, edges, collapsedNodeIds, toggleCollapse, onNodesMeasured } +} + +// Helper: order children by decision option links +function orderChildren(node: TreeStructure): Array<{ child: TreeStructure; optionLabel?: string }> { + if (!node.children || node.children.length === 0) return [] + + if (node.type === 'decision' && node.options) { + const linked: Array<{ child: TreeStructure; optionLabel: string }> = [] + const linkedIds = new Set() + + for (const opt of node.options) { + if (opt.next_node_id) { + const child = node.children.find(c => c.id === opt.next_node_id) + if (child) { + linked.push({ child, optionLabel: opt.label }) + linkedIds.add(child.id) + } + } + } + + const unlinked = node.children + .filter(c => !linkedIds.has(c.id)) + .map(child => ({ child, optionLabel: undefined })) + + return [...linked, ...unlinked] + } + + return node.children.map(child => ({ child, optionLabel: undefined })) +} +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/useTreeLayout.ts +git commit -m "feat: add useTreeLayout hook for tree-to-ReactFlow conversion with dagre" +``` + +--- + +## Phase 4: Node Editor Panel + +### Task 6: Create NodeEditorPanel + +**Files:** +- Create: `frontend/src/components/tree-editor/NodeEditorPanel.tsx` + +**Context:** This is the right-side editing panel that opens when a node is selected. It replaces the old inline-in-card editing. It reuses the existing form components. It follows the local-draft-then-commit pattern from the current `TreeCanvasNode`. + +The panel takes up 400px of real layout space (the React Flow container shrinks). It uses the existing form components exactly as `TreeCanvasNode.tsx` uses them: `node={draft}` + `onUpdate={handleDraftUpdate}`. + +Important: Import form components from their direct paths, not from the barrel export: +- `import { NodeFormDecision } from './NodeFormDecision'` +- `import { NodeFormAction } from './NodeFormAction'` +- `import { NodeFormResolution } from './NodeFormResolution'` + +**Step 1: Create the component** + +```tsx +// frontend/src/components/tree-editor/NodeEditorPanel.tsx +import { useState, useCallback, useEffect, useRef } from 'react' +import { HelpCircle, Zap, CheckCircle, X, Trash2, Copy, Save } from 'lucide-react' +import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore' +import { NodeFormDecision } from './NodeFormDecision' +import { NodeFormAction } from './NodeFormAction' +import { NodeFormResolution } from './NodeFormResolution' +import { cn } from '@/lib/utils' +import type { TreeStructure, NodeType } from '@/types' + +interface NodeEditorPanelProps { + nodeId: string + onClose: () => void + onSelectType?: (nodeId: string, type: 'decision' | 'action' | 'solution') => void +} + +const TYPE_CONFIG: Record, { icon: typeof HelpCircle; label: string; badgeClass: string }> = { + decision: { icon: HelpCircle, label: 'Decision', badgeClass: 'bg-blue-500/20 text-blue-400' }, + action: { icon: Zap, label: 'Action', badgeClass: 'bg-yellow-500/20 text-yellow-400' }, + solution: { icon: CheckCircle, label: 'Solution', badgeClass: 'bg-green-500/20 text-green-400' }, +} + +function cloneWithoutChildren(node: TreeStructure): TreeStructure { + const { children, ...rest } = node + return structuredClone(rest) as TreeStructure +} + +export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPanelProps) { + const treeStructure = useTreeEditorStore(s => s.treeStructure) + const updateNode = useTreeEditorStore(s => s.updateNode) + const deleteNode = useTreeEditorStore(s => s.deleteNode) + const duplicateNode = useTreeEditorStore(s => s.duplicateNode) + const addNode = useTreeEditorStore(s => s.addNode) + const selectNode = useTreeEditorStore(s => s.selectNode) + + const node = treeStructure ? findNodeInTree(nodeId, treeStructure) : null + const [draft, setDraft] = useState(null) + const [isDirty, setIsDirty] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const panelRef = useRef(null) + + // Initialize/reset draft when nodeId changes + useEffect(() => { + if (node) { + setDraft(cloneWithoutChildren(node)) + setIsDirty(false) + setShowDeleteConfirm(false) + } + }, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Escape to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isDirty]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleDraftUpdate = useCallback((updates: Partial) => { + setDraft(prev => prev ? { ...prev, ...updates } : prev) + setIsDirty(true) + }, []) + + const handleSave = useCallback(() => { + if (!draft || !node) return + const { children, ...draftWithoutChildren } = draft + updateNode(nodeId, draftWithoutChildren) + + // Auto-create answer stubs for new decision options without next_node_id + if (draft.options) { + const options = draft.options.filter(o => o.label.trim()) + const stubsCreated: Array<{ optId: string; stubId: string }> = [] + + options.forEach(opt => { + if (!opt.next_node_id) { + const stubId = addNode(nodeId, 'answer') + updateNode(stubId, { title: opt.label }) + stubsCreated.push({ optId: opt.id, stubId }) + } + }) + + if (stubsCreated.length > 0) { + const updatedOptions = options.map(o => { + const stub = stubsCreated.find(s => s.optId === o.id) + return stub ? { ...o, next_node_id: stub.stubId } : o + }) + updateNode(nodeId, { options: updatedOptions }) + } + } + + setIsDirty(false) + }, [draft, node, nodeId, updateNode, addNode]) + + const handleClose = useCallback(() => { + if (isDirty) { + // Simple confirm — could be replaced with a nicer modal later + if (!window.confirm('You have unsaved changes. Discard them?')) return + } + onClose() + }, [isDirty, onClose]) + + const handleDelete = useCallback(() => { + if (!treeStructure) return + // Clear inbound references before deleting + clearInboundReferences(nodeId, treeStructure, updateNode) + deleteNode(nodeId) + onClose() + }, [nodeId, treeStructure, updateNode, deleteNode, onClose]) + + const handleDuplicate = useCallback(() => { + const newId = duplicateNode(nodeId) + if (newId) { + selectNode(newId) + } + }, [nodeId, duplicateNode, selectNode]) + + if (!node || !draft) return null + + // Answer stub: show type picker instead of form + if (node.type === 'answer') { + return ( +
+
+ + {node.title || 'Answer Placeholder'} + + +
+
+

Choose a type for this node:

+
+ {(['decision', 'action', 'solution'] as const).map(type => { + const cfg = TYPE_CONFIG[type] + const TypeIcon = cfg.icon + return ( + + ) + })} +
+
+
+ ) + } + + const config = TYPE_CONFIG[node.type as Exclude] ?? TYPE_CONFIG.decision + const TypeIcon = config.icon + const title = node.type === 'decision' ? (node.question || 'Untitled Decision') : (node.title || `Untitled ${config.label}`) + const isRoot = treeStructure?.id === nodeId + + return ( +
+ {/* Header */} +
+ + + + {title} + +
+ + {/* Body — scrollable form area */} +
+ {draft.type === 'decision' && } + {draft.type === 'action' && } + {draft.type === 'solution' && } +
+ + {/* Footer */} +
+ + +
+ {!isRoot && ( + <> + + {showDeleteConfirm ? ( +
+ + +
+ ) : ( + + )} + + )} +
+
+ ) +} + +// Same helper used in TreeCanvas.tsx — clear all next_node_id references to a node before deleting +function clearInboundReferences( + nodeId: string, + treeStructure: TreeStructure, + updateNode: (id: string, updates: Partial) => void +) { + function walk(node: TreeStructure) { + if (node.type === 'decision' && node.options) { + const needsUpdate = node.options.some(o => o.next_node_id === nodeId) + if (needsUpdate) { + updateNode(node.id, { + options: node.options.map(o => o.next_node_id === nodeId ? { ...o, next_node_id: '' } : o), + }) + } + } + if (node.type === 'action' && node.next_node_id === nodeId) { + updateNode(node.id, { next_node_id: '' }) + } + node.children?.forEach(walk) + } + walk(treeStructure) +} +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/NodeEditorPanel.tsx +git commit -m "feat: add NodeEditorPanel side panel for React Flow canvas editing" +``` + +--- + +## Phase 5: Main FlowCanvas Component + +### Task 7: Create FlowCanvas + +**Files:** +- Create: `frontend/src/components/tree-editor/FlowCanvas.tsx` + +**Context:** This is the main React Flow canvas component that replaces `TreeCanvas.tsx`. It orchestrates everything: the React Flow instance, node types, the `useTreeLayout` hook, minimap toggle, add-node buttons (via a floating toolbar or context), and the node click → panel selection flow. + +Important: Import the React Flow CSS at the top: `import '@xyflow/react/dist/style.css'` + +The component receives `selectedNodeId` and `onNodeSelect` props from its parent (`TreeEditorLayout`) so the panel state lives above. + +**Step 1: Create the component** + +```tsx +// frontend/src/components/tree-editor/FlowCanvas.tsx +import { useCallback, useMemo, useState, useEffect } from 'react' +import { + ReactFlow, + Background, + Controls, + MiniMap, + BackgroundVariant, + useReactFlow, + useNodesState, + useEdgesState, + ReactFlowProvider, + type OnNodesChange, + type OnEdgesChange, + type NodeMouseHandler, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' + +import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode' +import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode' +import { useTreeLayout } from './useTreeLayout' +import { useTreeEditorStore } from '@/store/treeEditorStore' +import { cn } from '@/lib/utils' +import { Map as MapIcon, MapOff } from 'lucide-react' +import type { FlowCanvasNodeData } from './FlowCanvasNode' +import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode' + +const nodeTypes = { + flowNode: FlowCanvasNode, + answerStub: FlowCanvasAnswerNode, +} + +interface FlowCanvasProps { + selectedNodeId: string | null + onNodeSelect: (nodeId: string | null) => void + onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void +} + +function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: FlowCanvasProps) { + const { fitView, setCenter } = useReactFlow() + const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout() + const [minimapVisible, setMinimapVisible] = useState(true) + + // Inject callbacks into node data (because useTreeLayout creates placeholder functions) + const nodesWithCallbacks = useMemo(() => { + return layoutNodes.map(n => { + if (n.type === 'flowNode') { + const data = n.data as FlowCanvasNodeData + return { + ...n, + selected: n.id === selectedNodeId, + data: { ...data, onToggleCollapse: toggleCollapse }, + } + } + if (n.type === 'answerStub') { + const data = n.data as FlowCanvasAnswerNodeData + return { + ...n, + selected: n.id === selectedNodeId, + data: { ...data, onSelectType: onSelectAnswerType }, + } + } + return n + }) + }, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType]) + + const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks) + const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges) + + // Sync layout changes into React Flow state + useEffect(() => { + setNodes(nodesWithCallbacks) + setEdges(layoutEdges) + }, [nodesWithCallbacks, layoutEdges, setNodes, setEdges]) + + // Fit view after layout changes + useEffect(() => { + // Small delay to let React Flow process the node updates + const timer = setTimeout(() => { + fitView({ padding: 0.1, duration: 200 }) + }, 50) + return () => clearTimeout(timer) + }, [layoutNodes.length, collapsedNodeIds.size]) // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-center on selected node when panel opens + useEffect(() => { + if (!selectedNodeId) return + const node = nodes.find(n => n.id === selectedNodeId) + if (node) { + const x = node.position.x + 140 // center of 280px node + const y = node.position.y + 50 + setCenter(x, y, { duration: 300, zoom: 1 }) + } + }, [selectedNodeId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Height measurement correction + useEffect(() => { + if (nodes.length > 0 && nodes.some(n => n.measured?.height)) { + onNodesMeasured(nodes) + } + }, [nodes]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleNodeClick: NodeMouseHandler = useCallback((_event, node) => { + onNodeSelect(node.id) + }, [onNodeSelect]) + + const handlePaneClick = useCallback(() => { + onNodeSelect(null) + }, [onNodeSelect]) + + const getNodeColor = useCallback((node: { type?: string }) => { + if (node.type === 'answerStub') return 'hsl(var(--muted-foreground))' + // Use NODE_TYPE_CONFIG minimap colors + const config = NODE_TYPE_CONFIG[(node.type === 'flowNode' ? 'decision' : 'decision') as keyof typeof NODE_TYPE_CONFIG] + return config?.minimapColor ?? '#6b7280' + }, []) + + // Custom minimap node color based on actual tree node type + const minimapNodeColor = useCallback((rfNode: { data?: unknown }) => { + const data = rfNode.data as FlowCanvasNodeData | FlowCanvasAnswerNodeData | undefined + if (!data || !('node' in data)) return '#6b7280' + const treeNode = data.node + if (treeNode.type === 'answer') return '#6b7280' + const config = NODE_TYPE_CONFIG[treeNode.type as keyof typeof NODE_TYPE_CONFIG] + return config?.minimapColor ?? '#6b7280' + }, []) + + return ( +
+ + + + {minimapVisible && ( + + )} + + + {/* Minimap toggle button */} + +
+ ) +} + +// Wrap in ReactFlowProvider (required by useReactFlow hook) +export function FlowCanvas(props: FlowCanvasProps) { + return ( + + + + ) +} +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/FlowCanvas.tsx +git commit -m "feat: add FlowCanvas main React Flow component with zoom/pan/minimap" +``` + +--- + +## Phase 6: Integration — Wire Into Editor Layout + +### Task 8: Update TreeEditorLayout to use FlowCanvas + NodeEditorPanel + +**Files:** +- Modify: `frontend/src/components/tree-editor/TreeEditorLayout.tsx` + +**Context:** Currently Flow mode renders `` full-width with `` as an overlay. We need to change it to render `` that shrinks when `` is open. Both the node editor panel and metadata panel take up real layout space on the right. + +The panel state (which node is being edited) needs to come from the parent `TreeEditorPage.tsx` via props. + +**Step 1: Read the current file to understand exact structure** + +Read: `frontend/src/components/tree-editor/TreeEditorLayout.tsx` + +**Step 2: Update the component** + +Add new props and update the Flow mode rendering. The key change is the Flow mode now renders: +```tsx +
+
+ +
+ {editingNodeId && ( + onNodeSelect(null)} + onSelectType={onSelectAnswerType} + /> + )} + {isMetadataOpen && !editingNodeId && ( + + )} +
+``` + +New props needed on `TreeEditorLayoutProps`: +```typescript +interface TreeEditorLayoutProps { + isMobile?: boolean + isMetadataOpen?: boolean + onCloseMetadata?: () => void + editingNodeId: string | null // NEW + onNodeSelect: (nodeId: string | null) => void // NEW + onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void // NEW +} +``` + +**Important:** `MetadataSidePanel` currently uses `position: fixed` overlay. For the new layout, when the node editor is open, metadata panel should be hidden (single-panel-at-a-time rule from the design doc). When neither is open, metadata can still use its existing overlay behavior OR be changed to use the same flex layout pattern. Start with the simpler approach: just hide metadata when node editor is open. + +**Step 3: Verify build** + +Run: `cd frontend && npm run build` +Expected: May have type errors in `TreeEditorPage.tsx` because it doesn't pass the new props yet. That's fine — we fix it in Task 9. + +**Step 4: Commit** + +```bash +git add frontend/src/components/tree-editor/TreeEditorLayout.tsx +git commit -m "feat: wire FlowCanvas and NodeEditorPanel into TreeEditorLayout" +``` + +--- + +### Task 9: Update TreeEditorPage to manage panel state + +**Files:** +- Modify: `frontend/src/pages/TreeEditorPage.tsx` + +**Context:** `TreeEditorPage` already manages `isMetadataOpen`. Now it also needs to manage `editingNodeId` and enforce the single-panel-at-a-time rule. + +**Step 1: Read the current file** + +Read: `frontend/src/pages/TreeEditorPage.tsx` + +**Step 2: Add state and handlers** + +Add these state variables and handlers: +```typescript +const [editingNodeId, setEditingNodeId] = useState(null) + +const handleNodeSelect = useCallback((nodeId: string | null) => { + if (nodeId) { + setIsMetadataOpen(false) // close metadata when opening node editor + } + setEditingNodeId(nodeId) + selectNode(nodeId) +}, [selectNode]) + +const handleSelectAnswerType = useCallback((nodeId: string, type: 'decision' | 'action' | 'solution') => { + updateNode(nodeId, { type }) + // Keep the panel open on the same node — it will now show the form for the new type + setEditingNodeId(nodeId) + selectNode(nodeId) +}, [updateNode, selectNode]) +``` + +Update the metadata toggle to close the node editor: +```typescript +const handleMetadataToggle = () => { + if (!isMetadataOpen) { + setEditingNodeId(null) // close node editor when opening metadata + } + setIsMetadataOpen(!isMetadataOpen) +} +``` + +Pass the new props to `TreeEditorLayout`: +```tsx + setIsMetadataOpen(false)} + editingNodeId={editingNodeId} + onNodeSelect={handleNodeSelect} + onSelectAnswerType={handleSelectAnswerType} +/> +``` + +Also: close the node editor when switching to Code mode: +```typescript +// In the mode switch handler: +setEditingNodeId(null) +``` + +**Step 3: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build with zero errors. + +**Step 4: Commit** + +```bash +git add frontend/src/pages/TreeEditorPage.tsx +git commit -m "feat: add panel state management for node editor in TreeEditorPage" +``` + +--- + +## Phase 7: Styling + Polish + +### Task 10: Override React Flow default styles for dark theme + +**Files:** +- Modify: `frontend/src/index.css` (or create `frontend/src/components/tree-editor/flow-canvas.css`) + +**Context:** React Flow ships with its own light-mode styles. We need to override them to match the dark-first design system. The overrides target React Flow's CSS class names. + +**Step 1: Add style overrides** + +Add to `index.css` (after the existing Tailwind layers) or create a dedicated CSS file imported by `FlowCanvas.tsx`: + +```css +/* React Flow dark theme overrides */ +.react-flow__background { + background-color: hsl(var(--background)); +} + +.react-flow__controls { + background-color: hsl(var(--card)); + border-color: hsl(var(--border)); + border-radius: 0.75rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); +} + +.react-flow__controls-button { + background-color: hsl(var(--card)); + border-color: hsl(var(--border)); + fill: hsl(var(--muted-foreground)); +} + +.react-flow__controls-button:hover { + background-color: hsl(var(--accent)); +} + +.react-flow__minimap { + background-color: hsl(var(--card)); + border-color: hsl(var(--border)); + border-radius: 0.75rem; +} + +.react-flow__edge-path { + stroke: hsl(var(--border)); +} + +.react-flow__edge-text { + fill: hsl(var(--muted-foreground)); +} + +.react-flow__edge-textbg { + fill: hsl(var(--card)); +} + +/* Hide default React Flow attribution */ +.react-flow__attribution { + display: none; +} + +/* Handle styles */ +.react-flow__handle { + background-color: hsl(var(--border)); +} +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/index.css +git commit -m "style: add React Flow dark theme overrides for canvas" +``` + +--- + +### Task 11: Update tree-editor barrel export + +**Files:** +- Modify: `frontend/src/components/tree-editor/index.ts` + +**Step 1: Add new exports** + +Add these lines to the existing exports (don't remove any existing exports): +```typescript +export { FlowCanvas } from './FlowCanvas' +export { FlowCanvasNode } from './FlowCanvasNode' +export { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode' +export { NodeEditorPanel } from './NodeEditorPanel' +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/index.ts +git commit -m "chore: export new React Flow canvas components from barrel" +``` + +--- + +## Phase 8: Final Verification + +### Task 12: Full build verification + manual test + +**Step 1: Run frontend build** + +Run: `cd frontend && npm run build` +Expected: Clean build with zero TypeScript errors. + +**Step 2: Run backend tests (ensure nothing broken)** + +Run: `cd backend && source venv/bin/activate && pytest --override-ini="addopts=" -q` +Expected: All tests pass (backend is unchanged, but sanity check). + +**Step 3: Manual test checklist** + +- [ ] Open any troubleshooting tree in Flow mode → see React Flow canvas with nodes +- [ ] Ctrl+scroll zooms in/out +- [ ] Click and drag on empty space pans the canvas +- [ ] Plain scroll pans vertically +- [ ] Click a node → right panel opens with the correct form, canvas shrinks +- [ ] Edit a field in the panel → Save → node card updates on canvas +- [ ] Cancel discards changes +- [ ] Delete a node → node removed, edges cleaned up +- [ ] Click collapse chevron on a node with children → subtree disappears, "N nodes hidden" pill shows +- [ ] Click expand → subtree reappears +- [ ] Minimap visible in bottom-right, shows node overview +- [ ] Click minimap toggle → minimap hides, click again → shows +- [ ] Zoom controls in bottom-left work (zoom in, zoom out, fit view) +- [ ] Click "Metadata" toolbar button → metadata panel opens, node editor closes +- [ ] Switch to Code mode → canvas replaced with Monaco, node editor closes +- [ ] Switch back to Flow mode → React Flow canvas renders +- [ ] Answer stub nodes show dashed card → click → type picker → pick type → panel opens with correct form +- [ ] Create a new decision with options → save → answer stubs appear for each option +- [ ] Publish guard still works (can't publish with answer stubs) +- [ ] Large tree (Password Reset / Account Lockout) → no overlap, can zoom to see full tree + +**Step 4: Commit any fixes from manual testing** + +If any issues found during manual testing, fix and commit individually. + +**Step 5: Final commit** + +```bash +git add -A +git commit -m "feat: complete React Flow migration for flow editor canvas" +``` + +--- + +## Summary of Files Changed + +### New Files (6) +| File | Purpose | +|------|---------| +| `frontend/src/lib/dagreLayout.ts` | Pure dagre layout utility | +| `frontend/src/components/tree-editor/FlowCanvas.tsx` | Main React Flow canvas component | +| `frontend/src/components/tree-editor/FlowCanvasNode.tsx` | Custom compact node (decision/action/solution) | +| `frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx` | Custom answer stub node | +| `frontend/src/components/tree-editor/NodeEditorPanel.tsx` | Right-side editor panel | +| `frontend/src/components/tree-editor/useTreeLayout.ts` | Tree→ReactFlow conversion hook with dagre | + +### Modified Files (4) +| File | Changes | +|------|---------| +| `frontend/src/components/tree-editor/TreeEditorLayout.tsx` | Flow mode uses FlowCanvas + NodeEditorPanel | +| `frontend/src/pages/TreeEditorPage.tsx` | Panel state management, single-panel-at-a-time | +| `frontend/src/components/tree-editor/index.ts` | New component exports | +| `frontend/src/index.css` | React Flow dark theme overrides | + +### New Dependencies (2) +| Package | Purpose | +|---------|---------| +| `@xyflow/react` | React Flow canvas framework | +| `@dagrejs/dagre` | Directed graph layout algorithm | + +### No Changes Required +- `treeEditorStore.ts` — no store changes +- `NodeFormDecision.tsx`, `NodeFormAction.tsx`, `NodeFormResolution.tsx` — reused as-is +- `MetadataSidePanel.tsx` — works as-is with single-panel-at-a-time rule +- Code mode — untouched +- All backend files — untouched