Files
resolutionflow/docs/plans/2026-02-18-flow-editor-react-flow-impl.md
chihlasm 6efbdda688 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 <noreply@anthropic.com>
2026-02-18 20:50:00 -05:00

50 KiB

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:

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

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

// 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

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

// 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<Exclude<NodeType, 'answer'>, {
  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<NodeType, 'answer'>
  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 */}
      <Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />

      <div
        className={cn(
          'w-[280px] rounded-xl border border-border bg-card shadow-sm cursor-pointer transition-all',
          config.borderClass,
          selected && 'ring-1 ring-primary shadow-md'
        )}
      >
        {/* Header */}
        <div className="flex items-center gap-2 px-3 py-2.5">
          {/* Type badge */}
          <span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
            <Icon className="h-3 w-3" />
          </span>

          {/* Title */}
          <span className="flex-1 truncate text-sm font-heading font-medium text-foreground">
            {title}
          </span>

          {/* Badges */}
          {isNew && (
            <span className="rounded-full bg-yellow-500/20 px-1.5 py-0.5 text-[10px] font-label text-yellow-400">
              New
            </span>
          )}
          {hasValidationErrors && (
            <AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
          )}
        </div>

        {/* Decision options preview */}
        {node.type === 'decision' && optionCount > 0 && (
          <div className="border-t border-border px-3 py-1.5">
            <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
              <span className="font-label">{optionCount} option{optionCount !== 1 ? 's' : ''}</span>
            </div>
            <div className="mt-1 space-y-0.5">
              {node.options!.slice(0, 3).map((opt, i) => (
                <div key={opt.id} className="truncate text-xs text-muted-foreground">
                  <span className="font-label text-foreground/60">{String.fromCharCode(65 + i)}</span>{' '}
                  {opt.label || '(empty)'}
                </div>
              ))}
              {optionCount > 3 && (
                <div className="text-xs text-muted-foreground">+{optionCount - 3} more</div>
              )}
            </div>
          </div>
        )}

        {/* Description preview for action/solution */}
        {(node.type === 'action' || node.type === 'solution') && node.description && (
          <div className="border-t border-border px-3 py-1.5">
            <div className="line-clamp-2 text-xs text-muted-foreground">{node.description}</div>
          </div>
        )}

        {/* Collapse chevron */}
        {hasChildren && (
          <div className="flex justify-center border-t border-border py-1">
            <button
              type="button"
              onClick={(e) => {
                e.stopPropagation()
                onToggleCollapse(node.id)
              }}
              className="flex items-center gap-1 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
            >
              {isCollapsed ? (
                <>
                  <ChevronRight className="h-3 w-3" />
                  <span className="font-label">Expand</span>
                </>
              ) : (
                <>
                  <ChevronDown className="h-3 w-3" />
                  <span className="font-label">Collapse</span>
                </>
              )}
            </button>
          </div>
        )}
      </div>

      {/* Source handle at bottom */}
      <Handle type="source" position={Position.Bottom} className="!bg-border !w-2 !h-2 !border-0" />
    </>
  )
}

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

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

// 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 (
    <>
      <Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />

      <div
        className={cn(
          'w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50 transition-all',
          !picking && 'cursor-pointer hover:border-primary/40 hover:bg-accent/30',
          selected && 'ring-1 ring-primary'
        )}
        onClick={() => !picking && setPicking(true)}
      >
        <div className="px-3 pt-2.5 pb-1 text-sm font-heading font-medium text-foreground text-center">
          {label}
        </div>

        {!picking ? (
          <div className="pb-2.5 text-center text-[10px] text-muted-foreground font-label">
            + Choose Type
          </div>
        ) : (
          <div className="flex items-center justify-center gap-1.5 pb-2.5 px-2">
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'decision') }}
              className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20"
            >
              <HelpCircle className="h-2.5 w-2.5" /> Decision
            </button>
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'action') }}
              className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20"
            >
              <Zap className="h-2.5 w-2.5" /> Action
            </button>
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'solution') }}
              className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20"
            >
              <CheckCircle className="h-2.5 w-2.5" /> Solution
            </button>
          </div>
        )}
      </div>

      <Handle type="source" position={Position.Bottom} className="!bg-border !w-2 !h-2 !border-0" />
    </>
  )
}

export const FlowCanvasAnswerNode = memo(FlowCanvasAnswerNodeComponent)

Step 2: Verify build

Run: cd frontend && npm run build Expected: Clean build.

Step 3: Commit

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

// 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<string>
  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<Set<string>>(new Set())
  const [measuredHeights, setMeasuredHeights] = useState<Map<string, number>>(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<FlowCanvasAnswerNodeData>,
          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<FlowCanvasNodeData>,
          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<string>()

    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

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

// 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<Exclude<NodeType, 'answer'>, { 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<TreeStructure | null>(null)
  const [isDirty, setIsDirty] = useState(false)
  const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
  const panelRef = useRef<HTMLDivElement>(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<TreeStructure>) => {
    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 (
      <div ref={panelRef} className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
        <div className="flex items-center justify-between border-b border-border px-4 py-3">
          <span className="text-sm font-heading font-medium text-foreground">
            {node.title || 'Answer Placeholder'}
          </span>
          <button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
            <X className="h-4 w-4" />
          </button>
        </div>
        <div className="flex flex-1 flex-col items-center justify-center gap-4 px-4">
          <p className="text-sm text-muted-foreground text-center">Choose a type for this node:</p>
          <div className="flex gap-2">
            {(['decision', 'action', 'solution'] as const).map(type => {
              const cfg = TYPE_CONFIG[type]
              const TypeIcon = cfg.icon
              return (
                <button
                  key={type}
                  type="button"
                  onClick={() => onSelectType?.(nodeId, type)}
                  className={cn(
                    'flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-label border transition-colors',
                    type === 'decision' && 'border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20',
                    type === 'action' && 'border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20',
                    type === 'solution' && 'border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20',
                  )}
                >
                  <TypeIcon className="h-4 w-4" /> {cfg.label}
                </button>
              )
            })}
          </div>
        </div>
      </div>
    )
  }

  const config = TYPE_CONFIG[node.type as Exclude<NodeType, 'answer'>] ?? 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 (
    <div ref={panelRef} className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
      {/* Header */}
      <div className="flex items-center gap-2 border-b border-border px-4 py-3">
        <span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
          <TypeIcon className="h-3 w-3" />
        </span>
        <span className="flex-1 truncate text-sm font-heading font-medium text-foreground">{title}</span>
        <button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
          <X className="h-4 w-4" />
        </button>
      </div>

      {/* Body — scrollable form area */}
      <div className="flex-1 overflow-y-auto px-4 py-3">
        {draft.type === 'decision' && <NodeFormDecision node={draft} onUpdate={handleDraftUpdate} />}
        {draft.type === 'action' && <NodeFormAction node={draft} onUpdate={handleDraftUpdate} />}
        {draft.type === 'solution' && <NodeFormResolution node={draft} onUpdate={handleDraftUpdate} />}
      </div>

      {/* Footer */}
      <div className="flex items-center gap-2 border-t border-border px-4 py-3">
        <button
          onClick={handleSave}
          disabled={!isDirty}
          className={cn(
            'flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 transition-opacity',
            isDirty ? 'bg-gradient-brand hover:opacity-90' : 'bg-gradient-brand opacity-50 cursor-not-allowed'
          )}
        >
          <Save className="h-3.5 w-3.5" /> Save
        </button>
        <button
          onClick={handleClose}
          className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
        >
          Cancel
        </button>
        <div className="flex-1" />
        {!isRoot && (
          <>
            <button
              onClick={handleDuplicate}
              className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
              title="Duplicate"
            >
              <Copy className="h-4 w-4" />
            </button>
            {showDeleteConfirm ? (
              <div className="flex items-center gap-1">
                <button
                  onClick={handleDelete}
                  className="rounded-md bg-red-500/20 px-2 py-1 text-xs text-red-400 hover:bg-red-500/30"
                >
                  Confirm
                </button>
                <button
                  onClick={() => setShowDeleteConfirm(false)}
                  className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
                >
                  Cancel
                </button>
              </div>
            ) : (
              <button
                onClick={() => setShowDeleteConfirm(true)}
                className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-red-400"
                title="Delete"
              >
                <Trash2 className="h-4 w-4" />
              </button>
            )}
          </>
        )}
      </div>
    </div>
  )
}

// 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<TreeStructure>) => 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

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

// 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 (
    <div className="relative h-full w-full">
      <ReactFlow
        nodes={nodes}
        edges={edges}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        nodeTypes={nodeTypes}
        onNodeClick={handleNodeClick}
        onPaneClick={handlePaneClick}
        fitView
        minZoom={0.25}
        maxZoom={2}
        zoomOnScroll={false}
        zoomOnPinch={true}
        panOnScroll={true}
        panOnScrollMode="vertical"
        selectionOnDrag={false}
        nodesDraggable={false}
        nodesConnectable={false}
        proOptions={{ hideAttribution: true }}
        className="bg-background"
      >
        <Background variant={BackgroundVariant.Dots} gap={24} size={1} color="hsl(var(--border))" />
        <Controls showInteractive={false} className="!bg-card !border-border !shadow-lg" />
        {minimapVisible && (
          <MiniMap
            pannable
            zoomable
            nodeColor={minimapNodeColor}
            className="!bg-card !border-border"
            nodeStrokeWidth={2}
          />
        )}
      </ReactFlow>

      {/* Minimap toggle button */}
      <button
        onClick={() => setMinimapVisible(v => !v)}
        className={cn(
          'absolute bottom-2 right-2 z-10 rounded-lg border border-border bg-card p-2 text-muted-foreground shadow-lg hover:bg-accent hover:text-foreground transition-colors',
          minimapVisible && 'bottom-[170px]'
        )}
        title={minimapVisible ? 'Hide minimap' : 'Show minimap'}
      >
        {minimapVisible ? <MapOff className="h-4 w-4" /> : <MapIcon className="h-4 w-4" />}
      </button>
    </div>
  )
}

// Wrap in ReactFlowProvider (required by useReactFlow hook)
export function FlowCanvas(props: FlowCanvasProps) {
  return (
    <ReactFlowProvider>
      <FlowCanvasInner {...props} />
    </ReactFlowProvider>
  )
}

Step 2: Verify build

Run: cd frontend && npm run build Expected: Clean build.

Step 3: Commit

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 <TreeCanvas /> full-width with <MetadataSidePanel> as an overlay. We need to change it to render <FlowCanvas> that shrinks when <NodeEditorPanel> 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:

<div className="flex flex-1 overflow-hidden">
  <div className="flex-1 overflow-hidden">
    <FlowCanvas
      selectedNodeId={editingNodeId}
      onNodeSelect={onNodeSelect}
      onSelectAnswerType={onSelectAnswerType}
    />
  </div>
  {editingNodeId && (
    <NodeEditorPanel
      nodeId={editingNodeId}
      onClose={() => onNodeSelect(null)}
      onSelectType={onSelectAnswerType}
    />
  )}
  {isMetadataOpen && !editingNodeId && (
    <MetadataSidePanel isOpen={true} onClose={onCloseMetadata} />
  )}
</div>

New props needed on TreeEditorLayoutProps:

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

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:

const [editingNodeId, setEditingNodeId] = useState<string | null>(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:

const handleMetadataToggle = () => {
  if (!isMetadataOpen) {
    setEditingNodeId(null)  // close node editor when opening metadata
  }
  setIsMetadataOpen(!isMetadataOpen)
}

Pass the new props to TreeEditorLayout:

<TreeEditorLayout
  isMobile={isMobile}
  isMetadataOpen={isMetadataOpen}
  onCloseMetadata={() => setIsMetadataOpen(false)}
  editingNodeId={editingNodeId}
  onNodeSelect={handleNodeSelect}
  onSelectAnswerType={handleSelectAnswerType}
/>

Also: close the node editor when switching to Code mode:

// 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

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:

/* 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

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):

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

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

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