Move completed plan docs to docs/plans/archive/. Add survey migration 046 and reference HTML/plan files. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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 createfrontend/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 changesNodeFormDecision.tsx,NodeFormAction.tsx,NodeFormResolution.tsx— reused as-isMetadataSidePanel.tsx— works as-is with single-panel-at-a-time rule- Code mode — untouched
- All backend files — untouched