Files
resolutionflow/docs/plans/2026-02-18-flow-editor-react-flow-impl.md
chihlasm 50d9ff59d0 feat: React Flow migration for flow editor canvas (#82)
* docs: add React Flow migration design for flow editor canvas

Replaces hand-built CSS flexbox canvas with @xyflow/react for zoom/pan,
dagre auto-layout, collapsible minimap, and side-panel editing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

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

* chore: install @xyflow/react and @dagrejs/dagre

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add dagre layout utility for React Flow node positioning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add FlowCanvasNode compact card for React Flow canvas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add FlowCanvasAnswerNode stub card for React Flow canvas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add useTreeLayout hook for tree-to-ReactFlow conversion with dagre

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add NodeEditorPanel side panel for React Flow canvas editing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add FlowCanvas main React Flow component with zoom/pan/minimap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: wire FlowCanvas and NodeEditorPanel into TreeEditorLayout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add panel state management for node editor in TreeEditorPage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: add React Flow dark theme overrides for canvas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: export new React Flow canvas components from barrel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: enable scrolling in node editor panel sidebar

Add min-h-0 to flex containers in the ancestor chain so overflow-y-auto
actually triggers instead of content overflowing off-screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: constrain tree editor page height to prevent panel overflow

Add overflow-hidden to TreeEditorPage root and NodeEditorPanel container
so the flex height chain is properly constrained by the CSS Grid cell,
preventing the node editor sidebar from growing beyond the viewport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve lint errors in NodeEditorPanel and useTreeLayout

- Fix unused 'children' destructuring with _children prefix
- Move handleClose declaration above the useEffect that references it
- Use handleClose as proper dependency instead of eslint-disable
- Fix unused _parentId parameter type in useTreeLayout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use viewport-based height for node editor panel

Replace h-full with calc(100vh - 105px) to bypass the CSS height chain
that fails to constrain the panel across browsers. The 105px accounts
for the topbar (56px) and editor toolbar (49px).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix canvas controls visibility and enhance dot grid background

- Add !important to all React Flow dark theme overrides to ensure they
  win over library default styles (fixes white controls rectangle)
- Add SVG fill inheritance for control button icons
- Use slightly lighter canvas background (bg-accent/30) so dot grid
  is more visible
- Increase dot size and use muted-foreground color for better contrast

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: collapse sidebar categories with show more/less toggle

Show only the first 4 categories by default with a "N more" button
to expand the full list. Reduces sidebar clutter when many categories
exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 00:43:34 -05:00

1475 lines
50 KiB
Markdown

# Flow Editor React Flow Migration — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Replace the hand-built CSS flexbox tree canvas with @xyflow/react for zoom/pan, dagre auto-layout, collapsible minimap, and side-panel node editing.
**Architecture:** The Zustand store (`treeEditorStore`) remains the single source of truth — no store changes. A `useTreeLayout` hook derives flat `Node[]`/`Edge[]` arrays from the recursive `treeStructure` and positions them with dagre. Custom React Flow node components render compact cards. A right-side `NodeEditorPanel` reuses existing form components (`NodeFormDecision`, `NodeFormAction`, `NodeFormResolution`) for editing.
**Tech Stack:** @xyflow/react, @dagrejs/dagre, React 19, TypeScript, Tailwind CSS, Zustand
**Design Doc:** `docs/plans/2026-02-18-flow-editor-react-flow-design.md`
---
## Phase 1: Dependencies + Dagre Layout Utility
### Task 1: Install dependencies
**Step 1: Install packages**
Run:
```bash
cd frontend && npm install @xyflow/react @dagrejs/dagre
```
`@xyflow/react` includes its own types. `@dagrejs/dagre` includes types via `@types/dagre` bundled.
**Step 2: Verify build still passes**
Run: `cd frontend && npm run build`
Expected: Clean build, no errors.
**Step 3: Commit**
```bash
git add frontend/package.json frontend/package-lock.json
git commit -m "chore: install @xyflow/react and @dagrejs/dagre"
```
---
### Task 2: Create dagre layout utility
**Files:**
- Create: `frontend/src/lib/dagreLayout.ts`
This is a pure function with no React dependencies — it takes nodes and edges and returns positioned nodes.
**Step 1: Create the layout utility**
```typescript
// frontend/src/lib/dagreLayout.ts
import dagre from '@dagrejs/dagre'
import type { Node, Edge } from '@xyflow/react'
const NODE_WIDTH = 280
const DEFAULT_NODE_HEIGHT = 100
interface LayoutOptions {
direction?: 'TB' | 'LR'
rankSep?: number
nodeSep?: number
}
export function getLayoutedElements(
nodes: Node[],
edges: Edge[],
options: LayoutOptions = {}
): Node[] {
const { direction = 'TB', rankSep = 100, nodeSep = 40 } = options
const g = new dagre.graphlib.Graph()
g.setDefaultEdgeLabel(() => ({}))
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep })
nodes.forEach((node) => {
const height = node.measured?.height ?? node.data?.estimatedHeight ?? DEFAULT_NODE_HEIGHT
g.setNode(node.id, { width: NODE_WIDTH, height })
})
edges.forEach((edge) => {
g.setEdge(edge.source, edge.target)
})
dagre.layout(g)
return nodes.map((node) => {
const dagreNode = g.node(node.id)
// dagre gives center positions — convert to top-left for React Flow
const x = dagreNode.x - NODE_WIDTH / 2
const y = dagreNode.y - (dagreNode.height ?? DEFAULT_NODE_HEIGHT) / 2
return { ...node, position: { x, y } }
})
}
export { NODE_WIDTH, DEFAULT_NODE_HEIGHT }
```
**Step 2: Verify build**
Run: `cd frontend && npm run build`
Expected: Clean build.
**Step 3: Commit**
```bash
git add frontend/src/lib/dagreLayout.ts
git commit -m "feat: add dagre layout utility for React Flow node positioning"
```
---
## Phase 2: Custom Node Components
### Task 3: Create FlowCanvasNode (compact card for decision/action/solution)
**Files:**
- Create: `frontend/src/components/tree-editor/FlowCanvasNode.tsx`
**Context:** This is a React Flow custom node component. It receives props via `data` from React Flow's node system. It renders a compact, non-editable card. Clicking the card body selects the node (handled by React Flow's `onNodeClick`). The collapse chevron at the bottom is a separate click target.
**Step 1: Create the component**
```tsx
// frontend/src/components/tree-editor/FlowCanvasNode.tsx
import { memo } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TreeStructure, NodeType } from '@/types'
const NODE_TYPE_CONFIG: Record<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**
```bash
git add frontend/src/components/tree-editor/FlowCanvasNode.tsx
git commit -m "feat: add FlowCanvasNode compact card for React Flow canvas"
```
---
### Task 4: Create FlowCanvasAnswerNode (answer stub)
**Files:**
- Create: `frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx`
**Context:** This is the React Flow custom node for `type === 'answer'` nodes. It shows the stub label and a "Choose Type" prompt. Clicking opens a type picker inline. Selecting a type calls `onSelectType` which updates the node in the store and opens the editor panel.
**Step 1: Create the component**
```tsx
// frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx
import { memo, useState } from 'react'
import { Handle, Position, type NodeProps } from '@xyflow/react'
import { HelpCircle, Zap, CheckCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TreeStructure } from '@/types'
export interface FlowCanvasAnswerNodeData {
node: TreeStructure
onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}
function FlowCanvasAnswerNodeComponent({ data, selected }: NodeProps) {
const { node, onSelectType } = data as FlowCanvasAnswerNodeData
const [picking, setPicking] = useState(false)
const label = node.title || 'Answer'
return (
<>
<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**
```bash
git add frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx
git commit -m "feat: add FlowCanvasAnswerNode stub card for React Flow canvas"
```
---
## Phase 3: Tree-to-ReactFlow Conversion Hook
### Task 5: Create useTreeLayout hook
**Files:**
- Create: `frontend/src/components/tree-editor/useTreeLayout.ts`
**Context:** This hook converts the Zustand store's recursive `treeStructure` into flat React Flow `Node[]` and `Edge[]` arrays. It runs dagre to compute positions. It tracks collapsed node IDs and skips children of collapsed nodes.
The hook also handles the height-measurement correction: after first render, it checks if any node's actual rendered height differs from the dagre estimate by >10px and re-runs layout if so.
**Step 1: Create the hook**
```typescript
// frontend/src/components/tree-editor/useTreeLayout.ts
import { useMemo, useCallback, useState, useRef, useEffect } from 'react'
import { useReactFlow, type Node, type Edge } from '@xyflow/react'
import type { TreeStructure } from '@/types'
import { getLayoutedElements, NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '@/lib/dagreLayout'
import type { FlowCanvasNodeData } from './FlowCanvasNode'
import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode'
import { useTreeEditorStore } from '@/store/treeEditorStore'
const MAX_EDGE_LABEL_LENGTH = 35
function truncateLabel(label: string): string {
if (label.length <= MAX_EDGE_LABEL_LENGTH) return label
return label.slice(0, MAX_EDGE_LABEL_LENGTH).trimEnd() + '…'
}
function estimateNodeHeight(node: TreeStructure): number {
let height = 52 // header baseline
if (node.type === 'decision' && node.options) {
height += 24 // options header line
height += Math.min(node.options.length, 3) * 18 // option rows (max 3 shown)
if (node.options.length > 3) height += 18 // "+N more" row
}
if ((node.type === 'action' || node.type === 'solution') && node.description) {
height += 36 // description preview (2 lines)
}
if (node.type === 'answer') {
return 70 // fixed height for answer stubs
}
return height
}
interface UseTreeLayoutResult {
nodes: Node[]
edges: Edge[]
collapsedNodeIds: Set<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**
```bash
git add frontend/src/components/tree-editor/useTreeLayout.ts
git commit -m "feat: add useTreeLayout hook for tree-to-ReactFlow conversion with dagre"
```
---
## Phase 4: Node Editor Panel
### Task 6: Create NodeEditorPanel
**Files:**
- Create: `frontend/src/components/tree-editor/NodeEditorPanel.tsx`
**Context:** This is the right-side editing panel that opens when a node is selected. It replaces the old inline-in-card editing. It reuses the existing form components. It follows the local-draft-then-commit pattern from the current `TreeCanvasNode`.
The panel takes up 400px of real layout space (the React Flow container shrinks). It uses the existing form components exactly as `TreeCanvasNode.tsx` uses them: `node={draft}` + `onUpdate={handleDraftUpdate}`.
Important: Import form components from their direct paths, not from the barrel export:
- `import { NodeFormDecision } from './NodeFormDecision'`
- `import { NodeFormAction } from './NodeFormAction'`
- `import { NodeFormResolution } from './NodeFormResolution'`
**Step 1: Create the component**
```tsx
// frontend/src/components/tree-editor/NodeEditorPanel.tsx
import { useState, useCallback, useEffect, useRef } from 'react'
import { HelpCircle, Zap, CheckCircle, X, Trash2, Copy, Save } from 'lucide-react'
import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore'
import { NodeFormDecision } from './NodeFormDecision'
import { NodeFormAction } from './NodeFormAction'
import { NodeFormResolution } from './NodeFormResolution'
import { cn } from '@/lib/utils'
import type { TreeStructure, NodeType } from '@/types'
interface NodeEditorPanelProps {
nodeId: string
onClose: () => void
onSelectType?: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}
const TYPE_CONFIG: Record<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**
```bash
git add frontend/src/components/tree-editor/NodeEditorPanel.tsx
git commit -m "feat: add NodeEditorPanel side panel for React Flow canvas editing"
```
---
## Phase 5: Main FlowCanvas Component
### Task 7: Create FlowCanvas
**Files:**
- Create: `frontend/src/components/tree-editor/FlowCanvas.tsx`
**Context:** This is the main React Flow canvas component that replaces `TreeCanvas.tsx`. It orchestrates everything: the React Flow instance, node types, the `useTreeLayout` hook, minimap toggle, add-node buttons (via a floating toolbar or context), and the node click → panel selection flow.
Important: Import the React Flow CSS at the top: `import '@xyflow/react/dist/style.css'`
The component receives `selectedNodeId` and `onNodeSelect` props from its parent (`TreeEditorLayout`) so the panel state lives above.
**Step 1: Create the component**
```tsx
// frontend/src/components/tree-editor/FlowCanvas.tsx
import { useCallback, useMemo, useState, useEffect } from 'react'
import {
ReactFlow,
Background,
Controls,
MiniMap,
BackgroundVariant,
useReactFlow,
useNodesState,
useEdgesState,
ReactFlowProvider,
type OnNodesChange,
type OnEdgesChange,
type NodeMouseHandler,
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode'
import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode'
import { useTreeLayout } from './useTreeLayout'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { cn } from '@/lib/utils'
import { Map as MapIcon, MapOff } from 'lucide-react'
import type { FlowCanvasNodeData } from './FlowCanvasNode'
import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode'
const nodeTypes = {
flowNode: FlowCanvasNode,
answerStub: FlowCanvasAnswerNode,
}
interface FlowCanvasProps {
selectedNodeId: string | null
onNodeSelect: (nodeId: string | null) => void
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: FlowCanvasProps) {
const { fitView, setCenter } = useReactFlow()
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout()
const [minimapVisible, setMinimapVisible] = useState(true)
// Inject callbacks into node data (because useTreeLayout creates placeholder functions)
const nodesWithCallbacks = useMemo(() => {
return layoutNodes.map(n => {
if (n.type === 'flowNode') {
const data = n.data as FlowCanvasNodeData
return {
...n,
selected: n.id === selectedNodeId,
data: { ...data, onToggleCollapse: toggleCollapse },
}
}
if (n.type === 'answerStub') {
const data = n.data as FlowCanvasAnswerNodeData
return {
...n,
selected: n.id === selectedNodeId,
data: { ...data, onSelectType: onSelectAnswerType },
}
}
return n
})
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType])
const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks)
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges)
// Sync layout changes into React Flow state
useEffect(() => {
setNodes(nodesWithCallbacks)
setEdges(layoutEdges)
}, [nodesWithCallbacks, layoutEdges, setNodes, setEdges])
// Fit view after layout changes
useEffect(() => {
// Small delay to let React Flow process the node updates
const timer = setTimeout(() => {
fitView({ padding: 0.1, duration: 200 })
}, 50)
return () => clearTimeout(timer)
}, [layoutNodes.length, collapsedNodeIds.size]) // eslint-disable-line react-hooks/exhaustive-deps
// Auto-center on selected node when panel opens
useEffect(() => {
if (!selectedNodeId) return
const node = nodes.find(n => n.id === selectedNodeId)
if (node) {
const x = node.position.x + 140 // center of 280px node
const y = node.position.y + 50
setCenter(x, y, { duration: 300, zoom: 1 })
}
}, [selectedNodeId]) // eslint-disable-line react-hooks/exhaustive-deps
// Height measurement correction
useEffect(() => {
if (nodes.length > 0 && nodes.some(n => n.measured?.height)) {
onNodesMeasured(nodes)
}
}, [nodes]) // eslint-disable-line react-hooks/exhaustive-deps
const handleNodeClick: NodeMouseHandler = useCallback((_event, node) => {
onNodeSelect(node.id)
}, [onNodeSelect])
const handlePaneClick = useCallback(() => {
onNodeSelect(null)
}, [onNodeSelect])
const getNodeColor = useCallback((node: { type?: string }) => {
if (node.type === 'answerStub') return 'hsl(var(--muted-foreground))'
// Use NODE_TYPE_CONFIG minimap colors
const config = NODE_TYPE_CONFIG[(node.type === 'flowNode' ? 'decision' : 'decision') as keyof typeof NODE_TYPE_CONFIG]
return config?.minimapColor ?? '#6b7280'
}, [])
// Custom minimap node color based on actual tree node type
const minimapNodeColor = useCallback((rfNode: { data?: unknown }) => {
const data = rfNode.data as FlowCanvasNodeData | FlowCanvasAnswerNodeData | undefined
if (!data || !('node' in data)) return '#6b7280'
const treeNode = data.node
if (treeNode.type === 'answer') return '#6b7280'
const config = NODE_TYPE_CONFIG[treeNode.type as keyof typeof NODE_TYPE_CONFIG]
return config?.minimapColor ?? '#6b7280'
}, [])
return (
<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**
```bash
git add frontend/src/components/tree-editor/FlowCanvas.tsx
git commit -m "feat: add FlowCanvas main React Flow component with zoom/pan/minimap"
```
---
## Phase 6: Integration — Wire Into Editor Layout
### Task 8: Update TreeEditorLayout to use FlowCanvas + NodeEditorPanel
**Files:**
- Modify: `frontend/src/components/tree-editor/TreeEditorLayout.tsx`
**Context:** Currently Flow mode renders `<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:
```tsx
<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`:
```typescript
interface TreeEditorLayoutProps {
isMobile?: boolean
isMetadataOpen?: boolean
onCloseMetadata?: () => void
editingNodeId: string | null // NEW
onNodeSelect: (nodeId: string | null) => void // NEW
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void // NEW
}
```
**Important:** `MetadataSidePanel` currently uses `position: fixed` overlay. For the new layout, when the node editor is open, metadata panel should be hidden (single-panel-at-a-time rule from the design doc). When neither is open, metadata can still use its existing overlay behavior OR be changed to use the same flex layout pattern. Start with the simpler approach: just hide metadata when node editor is open.
**Step 3: Verify build**
Run: `cd frontend && npm run build`
Expected: May have type errors in `TreeEditorPage.tsx` because it doesn't pass the new props yet. That's fine — we fix it in Task 9.
**Step 4: Commit**
```bash
git add frontend/src/components/tree-editor/TreeEditorLayout.tsx
git commit -m "feat: wire FlowCanvas and NodeEditorPanel into TreeEditorLayout"
```
---
### Task 9: Update TreeEditorPage to manage panel state
**Files:**
- Modify: `frontend/src/pages/TreeEditorPage.tsx`
**Context:** `TreeEditorPage` already manages `isMetadataOpen`. Now it also needs to manage `editingNodeId` and enforce the single-panel-at-a-time rule.
**Step 1: Read the current file**
Read: `frontend/src/pages/TreeEditorPage.tsx`
**Step 2: Add state and handlers**
Add these state variables and handlers:
```typescript
const [editingNodeId, setEditingNodeId] = useState<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:
```typescript
const handleMetadataToggle = () => {
if (!isMetadataOpen) {
setEditingNodeId(null) // close node editor when opening metadata
}
setIsMetadataOpen(!isMetadataOpen)
}
```
Pass the new props to `TreeEditorLayout`:
```tsx
<TreeEditorLayout
isMobile={isMobile}
isMetadataOpen={isMetadataOpen}
onCloseMetadata={() => setIsMetadataOpen(false)}
editingNodeId={editingNodeId}
onNodeSelect={handleNodeSelect}
onSelectAnswerType={handleSelectAnswerType}
/>
```
Also: close the node editor when switching to Code mode:
```typescript
// In the mode switch handler:
setEditingNodeId(null)
```
**Step 3: Verify build**
Run: `cd frontend && npm run build`
Expected: Clean build with zero errors.
**Step 4: Commit**
```bash
git add frontend/src/pages/TreeEditorPage.tsx
git commit -m "feat: add panel state management for node editor in TreeEditorPage"
```
---
## Phase 7: Styling + Polish
### Task 10: Override React Flow default styles for dark theme
**Files:**
- Modify: `frontend/src/index.css` (or create `frontend/src/components/tree-editor/flow-canvas.css`)
**Context:** React Flow ships with its own light-mode styles. We need to override them to match the dark-first design system. The overrides target React Flow's CSS class names.
**Step 1: Add style overrides**
Add to `index.css` (after the existing Tailwind layers) or create a dedicated CSS file imported by `FlowCanvas.tsx`:
```css
/* React Flow dark theme overrides */
.react-flow__background {
background-color: hsl(var(--background));
}
.react-flow__controls {
background-color: hsl(var(--card));
border-color: hsl(var(--border));
border-radius: 0.75rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
}
.react-flow__controls-button {
background-color: hsl(var(--card));
border-color: hsl(var(--border));
fill: hsl(var(--muted-foreground));
}
.react-flow__controls-button:hover {
background-color: hsl(var(--accent));
}
.react-flow__minimap {
background-color: hsl(var(--card));
border-color: hsl(var(--border));
border-radius: 0.75rem;
}
.react-flow__edge-path {
stroke: hsl(var(--border));
}
.react-flow__edge-text {
fill: hsl(var(--muted-foreground));
}
.react-flow__edge-textbg {
fill: hsl(var(--card));
}
/* Hide default React Flow attribution */
.react-flow__attribution {
display: none;
}
/* Handle styles */
.react-flow__handle {
background-color: hsl(var(--border));
}
```
**Step 2: Verify build**
Run: `cd frontend && npm run build`
Expected: Clean build.
**Step 3: Commit**
```bash
git add frontend/src/index.css
git commit -m "style: add React Flow dark theme overrides for canvas"
```
---
### Task 11: Update tree-editor barrel export
**Files:**
- Modify: `frontend/src/components/tree-editor/index.ts`
**Step 1: Add new exports**
Add these lines to the existing exports (don't remove any existing exports):
```typescript
export { FlowCanvas } from './FlowCanvas'
export { FlowCanvasNode } from './FlowCanvasNode'
export { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode'
export { NodeEditorPanel } from './NodeEditorPanel'
```
**Step 2: Verify build**
Run: `cd frontend && npm run build`
Expected: Clean build.
**Step 3: Commit**
```bash
git add frontend/src/components/tree-editor/index.ts
git commit -m "chore: export new React Flow canvas components from barrel"
```
---
## Phase 8: Final Verification
### Task 12: Full build verification + manual test
**Step 1: Run frontend build**
Run: `cd frontend && npm run build`
Expected: Clean build with zero TypeScript errors.
**Step 2: Run backend tests (ensure nothing broken)**
Run: `cd backend && source venv/bin/activate && pytest --override-ini="addopts=" -q`
Expected: All tests pass (backend is unchanged, but sanity check).
**Step 3: Manual test checklist**
- [ ] Open any troubleshooting tree in Flow mode → see React Flow canvas with nodes
- [ ] Ctrl+scroll zooms in/out
- [ ] Click and drag on empty space pans the canvas
- [ ] Plain scroll pans vertically
- [ ] Click a node → right panel opens with the correct form, canvas shrinks
- [ ] Edit a field in the panel → Save → node card updates on canvas
- [ ] Cancel discards changes
- [ ] Delete a node → node removed, edges cleaned up
- [ ] Click collapse chevron on a node with children → subtree disappears, "N nodes hidden" pill shows
- [ ] Click expand → subtree reappears
- [ ] Minimap visible in bottom-right, shows node overview
- [ ] Click minimap toggle → minimap hides, click again → shows
- [ ] Zoom controls in bottom-left work (zoom in, zoom out, fit view)
- [ ] Click "Metadata" toolbar button → metadata panel opens, node editor closes
- [ ] Switch to Code mode → canvas replaced with Monaco, node editor closes
- [ ] Switch back to Flow mode → React Flow canvas renders
- [ ] Answer stub nodes show dashed card → click → type picker → pick type → panel opens with correct form
- [ ] Create a new decision with options → save → answer stubs appear for each option
- [ ] Publish guard still works (can't publish with answer stubs)
- [ ] Large tree (Password Reset / Account Lockout) → no overlap, can zoom to see full tree
**Step 4: Commit any fixes from manual testing**
If any issues found during manual testing, fix and commit individually.
**Step 5: Final commit**
```bash
git add -A
git commit -m "feat: complete React Flow migration for flow editor canvas"
```
---
## Summary of Files Changed
### New Files (6)
| File | Purpose |
|------|---------|
| `frontend/src/lib/dagreLayout.ts` | Pure dagre layout utility |
| `frontend/src/components/tree-editor/FlowCanvas.tsx` | Main React Flow canvas component |
| `frontend/src/components/tree-editor/FlowCanvasNode.tsx` | Custom compact node (decision/action/solution) |
| `frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx` | Custom answer stub node |
| `frontend/src/components/tree-editor/NodeEditorPanel.tsx` | Right-side editor panel |
| `frontend/src/components/tree-editor/useTreeLayout.ts` | Tree→ReactFlow conversion hook with dagre |
### Modified Files (4)
| File | Changes |
|------|---------|
| `frontend/src/components/tree-editor/TreeEditorLayout.tsx` | Flow mode uses FlowCanvas + NodeEditorPanel |
| `frontend/src/pages/TreeEditorPage.tsx` | Panel state management, single-panel-at-a-time |
| `frontend/src/components/tree-editor/index.ts` | New component exports |
| `frontend/src/index.css` | React Flow dark theme overrides |
### New Dependencies (2)
| Package | Purpose |
|---------|---------|
| `@xyflow/react` | React Flow canvas framework |
| `@dagrejs/dagre` | Directed graph layout algorithm |
### No Changes Required
- `treeEditorStore.ts` — no store changes
- `NodeFormDecision.tsx`, `NodeFormAction.tsx`, `NodeFormResolution.tsx` — reused as-is
- `MetadataSidePanel.tsx` — works as-is with single-panel-at-a-time rule
- Code mode — untouched
- All backend files — untouched