From af9e44d63309ae93887af6f48ba256c0f52b4a78 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 20:41:34 -0500 Subject: [PATCH 01/19] 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 --- ...026-02-18-flow-editor-react-flow-design.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/plans/2026-02-18-flow-editor-react-flow-design.md diff --git a/docs/plans/2026-02-18-flow-editor-react-flow-design.md b/docs/plans/2026-02-18-flow-editor-react-flow-design.md new file mode 100644 index 00000000..efa3df29 --- /dev/null +++ b/docs/plans/2026-02-18-flow-editor-react-flow-design.md @@ -0,0 +1,235 @@ +# Flow Editor — React Flow Migration Design + +> **Date:** 2026-02-18 +> **Scope:** Replace hand-built CSS flexbox canvas with @xyflow/react for zoom, pan, auto-layout, and improved collapse UX + +## Overview + +The current flow editor canvas (`TreeCanvas.tsx`) uses pure CSS flexbox to position nodes. This works for small trees but breaks down with large flows — nodes overlap, there's no zoom/pan, and collapsing subtrees is hard to discover. This design replaces the canvas with React Flow (`@xyflow/react`), adds dagre-based auto-layout, and moves editing to a right-side panel. + +## Problems Solved + +1. **No zoom/pan** — users can only scroll; can't zoom out for a bird's-eye view or zoom into a section +2. **Node overlap** — wide trees with many branches cause flexbox lanes to overlap +3. **Collapse is hidden** — the subtree collapse toggle is a small icon in the node header, easy to miss +4. **Inline editing bloats cards** — expanded cards are huge, disrupting the visual tree layout + +## Architecture + +### Source of Truth + +The Zustand store's `treeStructure` (recursive nested object) remains the single source of truth. No store changes required. The canvas maintains a **derived** flat representation (`Node[]` and `Edge[]`) computed from the tree structure. + +### Data Flow + +``` +treeStructure (Zustand) → useTreeLayout hook → { nodes, edges } → ReactFlow → renders + ↓ + user clicks node → NodeEditorPanel opens + user saves edits → updateNode(id, data) → store updates → re-derive +``` + +### New Dependencies + +- `@xyflow/react` — canvas framework (MIT, 20k+ GitHub stars) +- `@dagrejs/dagre` — directed graph layout algorithm +- `@types/dagre` — TypeScript types + +## Interactions + +### Zoom + +Ctrl/Cmd + scroll wheel to zoom. Zoom range: 25%–200%. Plain scroll pans vertically (natural page scrolling feel). + +### Pan + +Click and drag on empty canvas space. Plain scroll pans vertically. Middle-click drag also pans. + +### Node Selection + +Single-click on a node body selects it and opens the side panel editor. + +### Subtree Collapse + +Single-click on a visible chevron icon at the bottom edge of any node that has children. Always visible (not behind hover). When collapsed: +- Children and their edges are removed from the React Flow graph entirely +- A pill below the node shows "N nodes hidden" +- Clicking the pill or chevron again expands + +### Zoom Controls + +Small floating toolbar in bottom-left corner: zoom in (+), zoom out (−), fit-to-view. Uses React Flow's built-in `` component. + +### Minimap + +Bottom-right corner. Collapsible via a toggle button — user can minimize or close it. Pannable and zoomable (click on minimap to jump to that area). Uses React Flow's built-in `` component. Node colors in minimap match type accent colors (blue/yellow/green). + +### Fit View + +Auto-fits on initial load and when clicking the fit button. Applies padding so nodes aren't pressed against viewport edges. + +## Custom Node Types + +Four React Flow custom node types, all **compact** (no inline editing): + +| Type | Accent | Icon | Content | +|------|--------|------|---------| +| `decision` | Blue left border | `HelpCircle` | Question text (1-2 lines), "N options" badge, option labels | +| `action` | Yellow left border | `Zap` | Title, description preview (truncated) | +| `solution` | Green left border | `CheckCircle` | Title, description preview (truncated) | +| `answer` | Dashed border, muted | — | Label + "Choose Type" prompt | + +### Card Specs + +- **Width:** 280px (fixed — gives dagre consistent widths) +- **Height:** Variable based on content (~80–120px estimated) +- **Selected state:** `ring-1 ring-primary` +- **Validation errors:** Red dot badge on nodes with errors +- **Collapse chevron:** Visible at bottom of node when it has children + +### Edges + +Smoothstep edges (React Flow built-in) — route around nodes with rounded corners. Color: `border-border`. + +**Edge labels:** Show the option text leading to each child. **Truncated to 35 characters + ellipsis** for long option text (e.g., "User reports intermittent VPN di…"). Full text visible on hover tooltip and in the side panel when the parent decision is selected. + +## Side Panel Editor + +When a node is selected, a right-side editor panel opens. The canvas container **resizes** (shrinks by panel width) rather than the panel overlaying the canvas — this prevents covering the selected node. React Flow handles container resize natively. + +### Panel Specs + +- **Width:** 400px +- **Position:** Right side, part of the layout (not floating) +- **Open triggers:** Single-click a node +- **Close triggers:** X button, Escape key, clicking empty canvas +- **Auto-center:** When panel opens, auto-pan so the selected node stays centered in the remaining canvas area (via React Flow's `setCenter` / `fitBounds`) + +### Panel Structure + +- **Header:** Node type icon + badge, node title (or "New Decision"), close button +- **Body:** Renders existing form components — `NodeFormDecision`, `NodeFormAction`, `NodeFormResolution`. For `answer` nodes: type picker buttons (Decision/Action/Solution) +- **Footer:** Save (`bg-gradient-brand`), Cancel, Delete (with confirmation), Duplicate + +### Draft Model + +Same local-draft-then-commit pattern as current inline editor: +- Panel opens with a clone of the node data +- Edits modify the draft only +- Save writes to Zustand store → triggers re-derive of React Flow nodes/edges +- Cancel discards draft and closes panel +- Switching nodes while editing prompts save/discard + +### Panel Coexistence + +Only one right panel open at a time. Opening node editor closes metadata panel and vice versa. + +## Layout Engine (Dagre) + +### Configuration + +- Direction: `rankdir: 'TB'` (top-to-bottom) +- Node width: 280px +- Node height: estimated heuristic (~80px base + content) +- Rank separation (vertical gap): ~100px +- Node separation (horizontal gap): ~40px + +### Height Measurement Correction + +Dagre needs node heights before rendering, but content varies. Strategy: +1. **First pass:** Use heuristic height estimates based on node type and content length +2. **After first paint:** Measure actual rendered heights via refs +3. **If any height differs by >10px from estimate:** Re-run dagre with actual heights (single correction pass, no infinite loops) + +This avoids visible layout jumps in most cases while catching edge cases like decision nodes with 8+ options. + +### When Re-layout Runs + +| Trigger | Re-layout? | +|---------|-----------| +| Node added/deleted | Yes | +| Node moved (reparented) | Yes | +| Options added/removed on a decision (structural change) | Yes | +| Content-only edits (title, description text) | No | +| Collapse/expand toggle | Yes (different nodes visible) | +| Panel open/close | No (React Flow handles container resize) | + +After re-layout, `fitView` is called with padding. + +## File Changes + +### New Files + +| File | Purpose | +|------|---------| +| `components/tree-editor/FlowCanvas.tsx` | React Flow canvas — replaces `TreeCanvas.tsx` | +| `components/tree-editor/FlowCanvasNode.tsx` | Custom compact node component (decision/action/solution) | +| `components/tree-editor/FlowCanvasAnswerNode.tsx` | Custom node for answer stubs | +| `components/tree-editor/NodeEditorPanel.tsx` | Right-side editor panel — replaces inline card editing | +| `components/tree-editor/useTreeLayout.ts` | Hook: treeStructure → nodes/edges + dagre + measure-correct | +| `lib/dagreLayout.ts` | Pure function: positioned nodes from dagre | + +### Modified Files + +| File | Changes | +|------|---------| +| `TreeEditorLayout.tsx` | Flow mode renders `FlowCanvas` + `NodeEditorPanel` instead of `TreeCanvas` | +| `TreeEditorPage.tsx` | Panel state: node editor vs metadata, single-panel-at-a-time | + +### Unchanged + +- `treeEditorStore.ts` — no store changes needed +- `NodeFormDecision.tsx`, `NodeFormAction.tsx`, `NodeFormResolution.tsx` — reused inside panel +- `MetadataSidePanel.tsx` — already works, gets single-panel-at-a-time rule +- Code mode — completely untouched + +### Removed from Active Flow Mode Path + +- `TreeCanvas.tsx` → replaced by `FlowCanvas.tsx` +- `TreeCanvasNode.tsx` → replaced by `FlowCanvasNode.tsx` +- `AnswerStubCard.tsx` → logic moves to `FlowCanvasAnswerNode.tsx` + +Old components stay in the repo but are no longer imported in Flow mode. + +## React Flow Configuration + +```tsx + + + + + +``` + +## Not Included (YAGNI) + +- No drag-to-reparent nodes on canvas +- No visual edge reconnection (dragging edges) +- No multi-select nodes +- No undo/redo on canvas position changes (undo/redo stays on tree data only) +- No manual node drag repositioning (dagre controls layout) +- No light mode (dark-first design system) -- 2.49.1 From 6efbdda688b546593afbb171ee7223a7900eab0f Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 20:50:00 -0500 Subject: [PATCH 02/19] docs: add React Flow migration implementation plan 12 tasks across 8 phases covering dagre layout, custom nodes, side panel editor, and full canvas integration. Co-Authored-By: Claude Opus 4.6 --- .../2026-02-18-flow-editor-react-flow-impl.md | 1474 +++++++++++++++++ 1 file changed, 1474 insertions(+) create mode 100644 docs/plans/2026-02-18-flow-editor-react-flow-impl.md diff --git a/docs/plans/2026-02-18-flow-editor-react-flow-impl.md b/docs/plans/2026-02-18-flow-editor-react-flow-impl.md new file mode 100644 index 00000000..28fddc66 --- /dev/null +++ b/docs/plans/2026-02-18-flow-editor-react-flow-impl.md @@ -0,0 +1,1474 @@ +# Flow Editor React Flow Migration — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the hand-built CSS flexbox tree canvas with @xyflow/react for zoom/pan, dagre auto-layout, collapsible minimap, and side-panel node editing. + +**Architecture:** The Zustand store (`treeEditorStore`) remains the single source of truth — no store changes. A `useTreeLayout` hook derives flat `Node[]`/`Edge[]` arrays from the recursive `treeStructure` and positions them with dagre. Custom React Flow node components render compact cards. A right-side `NodeEditorPanel` reuses existing form components (`NodeFormDecision`, `NodeFormAction`, `NodeFormResolution`) for editing. + +**Tech Stack:** @xyflow/react, @dagrejs/dagre, React 19, TypeScript, Tailwind CSS, Zustand + +**Design Doc:** `docs/plans/2026-02-18-flow-editor-react-flow-design.md` + +--- + +## Phase 1: Dependencies + Dagre Layout Utility + +### Task 1: Install dependencies + +**Step 1: Install packages** + +Run: +```bash +cd frontend && npm install @xyflow/react @dagrejs/dagre +``` + +`@xyflow/react` includes its own types. `@dagrejs/dagre` includes types via `@types/dagre` bundled. + +**Step 2: Verify build still passes** + +Run: `cd frontend && npm run build` +Expected: Clean build, no errors. + +**Step 3: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json +git commit -m "chore: install @xyflow/react and @dagrejs/dagre" +``` + +--- + +### Task 2: Create dagre layout utility + +**Files:** +- Create: `frontend/src/lib/dagreLayout.ts` + +This is a pure function with no React dependencies — it takes nodes and edges and returns positioned nodes. + +**Step 1: Create the layout utility** + +```typescript +// frontend/src/lib/dagreLayout.ts +import dagre from '@dagrejs/dagre' +import type { Node, Edge } from '@xyflow/react' + +const NODE_WIDTH = 280 +const DEFAULT_NODE_HEIGHT = 100 + +interface LayoutOptions { + direction?: 'TB' | 'LR' + rankSep?: number + nodeSep?: number +} + +export function getLayoutedElements( + nodes: Node[], + edges: Edge[], + options: LayoutOptions = {} +): Node[] { + const { direction = 'TB', rankSep = 100, nodeSep = 40 } = options + + const g = new dagre.graphlib.Graph() + g.setDefaultEdgeLabel(() => ({})) + g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep }) + + nodes.forEach((node) => { + const height = node.measured?.height ?? node.data?.estimatedHeight ?? DEFAULT_NODE_HEIGHT + g.setNode(node.id, { width: NODE_WIDTH, height }) + }) + + edges.forEach((edge) => { + g.setEdge(edge.source, edge.target) + }) + + dagre.layout(g) + + return nodes.map((node) => { + const dagreNode = g.node(node.id) + // dagre gives center positions — convert to top-left for React Flow + const x = dagreNode.x - NODE_WIDTH / 2 + const y = dagreNode.y - (dagreNode.height ?? DEFAULT_NODE_HEIGHT) / 2 + return { ...node, position: { x, y } } + }) +} + +export { NODE_WIDTH, DEFAULT_NODE_HEIGHT } +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/lib/dagreLayout.ts +git commit -m "feat: add dagre layout utility for React Flow node positioning" +``` + +--- + +## Phase 2: Custom Node Components + +### Task 3: Create FlowCanvasNode (compact card for decision/action/solution) + +**Files:** +- Create: `frontend/src/components/tree-editor/FlowCanvasNode.tsx` + +**Context:** This is a React Flow custom node component. It receives props via `data` from React Flow's node system. It renders a compact, non-editable card. Clicking the card body selects the node (handled by React Flow's `onNodeClick`). The collapse chevron at the bottom is a separate click target. + +**Step 1: Create the component** + +```tsx +// frontend/src/components/tree-editor/FlowCanvasNode.tsx +import { memo } from 'react' +import { Handle, Position, type NodeProps } from '@xyflow/react' +import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { TreeStructure, NodeType } from '@/types' + +const NODE_TYPE_CONFIG: Record, { + icon: typeof HelpCircle + label: string + borderClass: string + badgeClass: string + minimapColor: string +}> = { + decision: { + icon: HelpCircle, + label: 'Decision', + borderClass: 'border-l-4 border-l-blue-500', + badgeClass: 'bg-blue-500/20 text-blue-400', + minimapColor: '#3b82f6', + }, + action: { + icon: Zap, + label: 'Action', + borderClass: 'border-l-4 border-l-yellow-500', + badgeClass: 'bg-yellow-500/20 text-yellow-400', + minimapColor: '#eab308', + }, + solution: { + icon: CheckCircle, + label: 'Solution', + borderClass: 'border-l-4 border-l-green-500', + badgeClass: 'bg-green-500/20 text-green-400', + minimapColor: '#22c55e', + }, +} + +export interface FlowCanvasNodeData { + node: TreeStructure + hasChildren: boolean + isCollapsed: boolean + hasValidationErrors: boolean + isNew: boolean + onToggleCollapse: (nodeId: string) => void +} + +function FlowCanvasNodeComponent({ data, selected }: NodeProps) { + const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse } = data as FlowCanvasNodeData + const nodeType = node.type as Exclude + const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision + const Icon = config.icon + + const title = node.type === 'decision' + ? (node.question || 'Untitled Decision') + : (node.title || `Untitled ${config.label}`) + + const optionCount = node.options?.length ?? 0 + + return ( + <> + {/* Target handle at top */} + + +
+ {/* Header */} +
+ {/* Type badge */} + + + + + {/* Title */} + + {title} + + + {/* Badges */} + {isNew && ( + + New + + )} + {hasValidationErrors && ( + + )} +
+ + {/* Decision options preview */} + {node.type === 'decision' && optionCount > 0 && ( +
+
+ {optionCount} option{optionCount !== 1 ? 's' : ''} +
+
+ {node.options!.slice(0, 3).map((opt, i) => ( +
+ {String.fromCharCode(65 + i)}{' '} + {opt.label || '(empty)'} +
+ ))} + {optionCount > 3 && ( +
+{optionCount - 3} more
+ )} +
+
+ )} + + {/* Description preview for action/solution */} + {(node.type === 'action' || node.type === 'solution') && node.description && ( +
+
{node.description}
+
+ )} + + {/* Collapse chevron */} + {hasChildren && ( +
+ +
+ )} +
+ + {/* Source handle at bottom */} + + + ) +} + +export const FlowCanvasNode = memo(FlowCanvasNodeComponent) +export { NODE_TYPE_CONFIG } +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/FlowCanvasNode.tsx +git commit -m "feat: add FlowCanvasNode compact card for React Flow canvas" +``` + +--- + +### Task 4: Create FlowCanvasAnswerNode (answer stub) + +**Files:** +- Create: `frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx` + +**Context:** This is the React Flow custom node for `type === 'answer'` nodes. It shows the stub label and a "Choose Type" prompt. Clicking opens a type picker inline. Selecting a type calls `onSelectType` which updates the node in the store and opens the editor panel. + +**Step 1: Create the component** + +```tsx +// frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx +import { memo, useState } from 'react' +import { Handle, Position, type NodeProps } from '@xyflow/react' +import { HelpCircle, Zap, CheckCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { TreeStructure } from '@/types' + +export interface FlowCanvasAnswerNodeData { + node: TreeStructure + onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void +} + +function FlowCanvasAnswerNodeComponent({ data, selected }: NodeProps) { + const { node, onSelectType } = data as FlowCanvasAnswerNodeData + const [picking, setPicking] = useState(false) + const label = node.title || 'Answer' + + return ( + <> + + +
!picking && setPicking(true)} + > +
+ {label} +
+ + {!picking ? ( +
+ + Choose Type +
+ ) : ( +
+ + + +
+ )} +
+ + + + ) +} + +export const FlowCanvasAnswerNode = memo(FlowCanvasAnswerNodeComponent) +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx +git commit -m "feat: add FlowCanvasAnswerNode stub card for React Flow canvas" +``` + +--- + +## Phase 3: Tree-to-ReactFlow Conversion Hook + +### Task 5: Create useTreeLayout hook + +**Files:** +- Create: `frontend/src/components/tree-editor/useTreeLayout.ts` + +**Context:** This hook converts the Zustand store's recursive `treeStructure` into flat React Flow `Node[]` and `Edge[]` arrays. It runs dagre to compute positions. It tracks collapsed node IDs and skips children of collapsed nodes. + +The hook also handles the height-measurement correction: after first render, it checks if any node's actual rendered height differs from the dagre estimate by >10px and re-runs layout if so. + +**Step 1: Create the hook** + +```typescript +// frontend/src/components/tree-editor/useTreeLayout.ts +import { useMemo, useCallback, useState, useRef, useEffect } from 'react' +import { useReactFlow, type Node, type Edge } from '@xyflow/react' +import type { TreeStructure } from '@/types' +import { getLayoutedElements, NODE_WIDTH, DEFAULT_NODE_HEIGHT } from '@/lib/dagreLayout' +import type { FlowCanvasNodeData } from './FlowCanvasNode' +import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode' +import { useTreeEditorStore } from '@/store/treeEditorStore' + +const MAX_EDGE_LABEL_LENGTH = 35 + +function truncateLabel(label: string): string { + if (label.length <= MAX_EDGE_LABEL_LENGTH) return label + return label.slice(0, MAX_EDGE_LABEL_LENGTH).trimEnd() + '…' +} + +function estimateNodeHeight(node: TreeStructure): number { + let height = 52 // header baseline + if (node.type === 'decision' && node.options) { + height += 24 // options header line + height += Math.min(node.options.length, 3) * 18 // option rows (max 3 shown) + if (node.options.length > 3) height += 18 // "+N more" row + } + if ((node.type === 'action' || node.type === 'solution') && node.description) { + height += 36 // description preview (2 lines) + } + if (node.type === 'answer') { + return 70 // fixed height for answer stubs + } + return height +} + +interface UseTreeLayoutResult { + nodes: Node[] + edges: Edge[] + collapsedNodeIds: Set + toggleCollapse: (nodeId: string) => void + onNodesMeasured: (measuredNodes: Node[]) => void +} + +export function useTreeLayout(): UseTreeLayoutResult { + const treeStructure = useTreeEditorStore(s => s.treeStructure) + const validationErrors = useTreeEditorStore(s => s.validationErrors) + const [collapsedNodeIds, setCollapsedNodeIds] = useState>(new Set()) + const [measuredHeights, setMeasuredHeights] = useState>(new Map()) + const correctionDone = useRef(false) + + const toggleCollapse = useCallback((nodeId: string) => { + setCollapsedNodeIds(prev => { + const next = new Set(prev) + if (next.has(nodeId)) next.delete(nodeId) + else next.add(nodeId) + return next + }) + }, []) + + // Convert tree structure to flat nodes and edges + const { rawNodes, rawEdges } = useMemo(() => { + const nodes: Node[] = [] + const edges: Edge[] = [] + + if (!treeStructure) return { rawNodes: nodes, rawEdges: edges } + + function walk(node: TreeStructure, _parentId: string | null) { + const isCollapsed = collapsedNodeIds.has(node.id) + const hasChildren = (node.children?.length ?? 0) > 0 + const hasErrors = validationErrors.some(e => e.nodeId === node.id && e.severity === 'error') + + const estimatedHeight = measuredHeights.get(node.id) ?? estimateNodeHeight(node) + + if (node.type === 'answer') { + nodes.push({ + id: node.id, + type: 'answerStub', + position: { x: 0, y: 0 }, // dagre will set this + data: { + node, + onSelectType: () => {}, // placeholder — set by FlowCanvas + } satisfies Partial, + style: { width: NODE_WIDTH }, + measured: { width: NODE_WIDTH, height: estimatedHeight }, + }) + } else { + nodes.push({ + id: node.id, + type: 'flowNode', + position: { x: 0, y: 0 }, + data: { + node, + hasChildren, + isCollapsed, + hasValidationErrors: hasErrors, + isNew: false, + onToggleCollapse: () => {}, // placeholder — set by FlowCanvas + } satisfies Partial, + style: { width: NODE_WIDTH }, + measured: { width: NODE_WIDTH, height: estimatedHeight }, + }) + } + + // Skip children if collapsed + if (isCollapsed) return + + // Create edges and recurse into children + if (node.children) { + // For decision nodes: order children by option link, then unlinked + const orderedChildren = orderChildren(node) + + for (const { child, optionLabel } of orderedChildren) { + const edgeLabel = optionLabel ? truncateLabel(optionLabel) : undefined + + edges.push({ + id: `${node.id}->${child.id}`, + source: node.id, + target: child.id, + type: 'smoothstep', + label: edgeLabel, + labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 11 }, + labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.9 }, + labelBgPadding: [4, 2] as [number, number], + style: { stroke: 'hsl(var(--border))' }, + }) + + walk(child, node.id) + } + } + } + + walk(treeStructure, null) + + return { rawNodes: nodes, rawEdges: edges } + }, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights]) + + // Run dagre layout + const { nodes, edges } = useMemo(() => { + if (rawNodes.length === 0) return { nodes: rawNodes, edges: rawEdges } + const layouted = getLayoutedElements(rawNodes, rawEdges) + return { nodes: layouted, edges: rawEdges } + }, [rawNodes, rawEdges]) + + // Height measurement correction callback + const onNodesMeasured = useCallback((measuredNodes: Node[]) => { + if (correctionDone.current) return + + let needsCorrection = false + const newHeights = new Map(measuredHeights) + + for (const mNode of measuredNodes) { + const actual = mNode.measured?.height + if (!actual) continue + const estimated = measuredHeights.get(mNode.id) ?? estimateNodeHeight( + (mNode.data as FlowCanvasNodeData)?.node ?? (mNode.data as FlowCanvasAnswerNodeData)?.node + ) + if (Math.abs(actual - estimated) > 10) { + newHeights.set(mNode.id, actual) + needsCorrection = true + } + } + + if (needsCorrection) { + correctionDone.current = true + setMeasuredHeights(newHeights) + } + }, [measuredHeights]) + + // Reset correction flag when tree structure changes + useEffect(() => { + correctionDone.current = false + }, [treeStructure, collapsedNodeIds]) + + return { nodes, edges, collapsedNodeIds, toggleCollapse, onNodesMeasured } +} + +// Helper: order children by decision option links +function orderChildren(node: TreeStructure): Array<{ child: TreeStructure; optionLabel?: string }> { + if (!node.children || node.children.length === 0) return [] + + if (node.type === 'decision' && node.options) { + const linked: Array<{ child: TreeStructure; optionLabel: string }> = [] + const linkedIds = new Set() + + for (const opt of node.options) { + if (opt.next_node_id) { + const child = node.children.find(c => c.id === opt.next_node_id) + if (child) { + linked.push({ child, optionLabel: opt.label }) + linkedIds.add(child.id) + } + } + } + + const unlinked = node.children + .filter(c => !linkedIds.has(c.id)) + .map(child => ({ child, optionLabel: undefined })) + + return [...linked, ...unlinked] + } + + return node.children.map(child => ({ child, optionLabel: undefined })) +} +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/useTreeLayout.ts +git commit -m "feat: add useTreeLayout hook for tree-to-ReactFlow conversion with dagre" +``` + +--- + +## Phase 4: Node Editor Panel + +### Task 6: Create NodeEditorPanel + +**Files:** +- Create: `frontend/src/components/tree-editor/NodeEditorPanel.tsx` + +**Context:** This is the right-side editing panel that opens when a node is selected. It replaces the old inline-in-card editing. It reuses the existing form components. It follows the local-draft-then-commit pattern from the current `TreeCanvasNode`. + +The panel takes up 400px of real layout space (the React Flow container shrinks). It uses the existing form components exactly as `TreeCanvasNode.tsx` uses them: `node={draft}` + `onUpdate={handleDraftUpdate}`. + +Important: Import form components from their direct paths, not from the barrel export: +- `import { NodeFormDecision } from './NodeFormDecision'` +- `import { NodeFormAction } from './NodeFormAction'` +- `import { NodeFormResolution } from './NodeFormResolution'` + +**Step 1: Create the component** + +```tsx +// frontend/src/components/tree-editor/NodeEditorPanel.tsx +import { useState, useCallback, useEffect, useRef } from 'react' +import { HelpCircle, Zap, CheckCircle, X, Trash2, Copy, Save } from 'lucide-react' +import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore' +import { NodeFormDecision } from './NodeFormDecision' +import { NodeFormAction } from './NodeFormAction' +import { NodeFormResolution } from './NodeFormResolution' +import { cn } from '@/lib/utils' +import type { TreeStructure, NodeType } from '@/types' + +interface NodeEditorPanelProps { + nodeId: string + onClose: () => void + onSelectType?: (nodeId: string, type: 'decision' | 'action' | 'solution') => void +} + +const TYPE_CONFIG: Record, { icon: typeof HelpCircle; label: string; badgeClass: string }> = { + decision: { icon: HelpCircle, label: 'Decision', badgeClass: 'bg-blue-500/20 text-blue-400' }, + action: { icon: Zap, label: 'Action', badgeClass: 'bg-yellow-500/20 text-yellow-400' }, + solution: { icon: CheckCircle, label: 'Solution', badgeClass: 'bg-green-500/20 text-green-400' }, +} + +function cloneWithoutChildren(node: TreeStructure): TreeStructure { + const { children, ...rest } = node + return structuredClone(rest) as TreeStructure +} + +export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPanelProps) { + const treeStructure = useTreeEditorStore(s => s.treeStructure) + const updateNode = useTreeEditorStore(s => s.updateNode) + const deleteNode = useTreeEditorStore(s => s.deleteNode) + const duplicateNode = useTreeEditorStore(s => s.duplicateNode) + const addNode = useTreeEditorStore(s => s.addNode) + const selectNode = useTreeEditorStore(s => s.selectNode) + + const node = treeStructure ? findNodeInTree(nodeId, treeStructure) : null + const [draft, setDraft] = useState(null) + const [isDirty, setIsDirty] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const panelRef = useRef(null) + + // Initialize/reset draft when nodeId changes + useEffect(() => { + if (node) { + setDraft(cloneWithoutChildren(node)) + setIsDirty(false) + setShowDeleteConfirm(false) + } + }, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Escape to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isDirty]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleDraftUpdate = useCallback((updates: Partial) => { + setDraft(prev => prev ? { ...prev, ...updates } : prev) + setIsDirty(true) + }, []) + + const handleSave = useCallback(() => { + if (!draft || !node) return + const { children, ...draftWithoutChildren } = draft + updateNode(nodeId, draftWithoutChildren) + + // Auto-create answer stubs for new decision options without next_node_id + if (draft.options) { + const options = draft.options.filter(o => o.label.trim()) + const stubsCreated: Array<{ optId: string; stubId: string }> = [] + + options.forEach(opt => { + if (!opt.next_node_id) { + const stubId = addNode(nodeId, 'answer') + updateNode(stubId, { title: opt.label }) + stubsCreated.push({ optId: opt.id, stubId }) + } + }) + + if (stubsCreated.length > 0) { + const updatedOptions = options.map(o => { + const stub = stubsCreated.find(s => s.optId === o.id) + return stub ? { ...o, next_node_id: stub.stubId } : o + }) + updateNode(nodeId, { options: updatedOptions }) + } + } + + setIsDirty(false) + }, [draft, node, nodeId, updateNode, addNode]) + + const handleClose = useCallback(() => { + if (isDirty) { + // Simple confirm — could be replaced with a nicer modal later + if (!window.confirm('You have unsaved changes. Discard them?')) return + } + onClose() + }, [isDirty, onClose]) + + const handleDelete = useCallback(() => { + if (!treeStructure) return + // Clear inbound references before deleting + clearInboundReferences(nodeId, treeStructure, updateNode) + deleteNode(nodeId) + onClose() + }, [nodeId, treeStructure, updateNode, deleteNode, onClose]) + + const handleDuplicate = useCallback(() => { + const newId = duplicateNode(nodeId) + if (newId) { + selectNode(newId) + } + }, [nodeId, duplicateNode, selectNode]) + + if (!node || !draft) return null + + // Answer stub: show type picker instead of form + if (node.type === 'answer') { + return ( +
+
+ + {node.title || 'Answer Placeholder'} + + +
+
+

Choose a type for this node:

+
+ {(['decision', 'action', 'solution'] as const).map(type => { + const cfg = TYPE_CONFIG[type] + const TypeIcon = cfg.icon + return ( + + ) + })} +
+
+
+ ) + } + + const config = TYPE_CONFIG[node.type as Exclude] ?? TYPE_CONFIG.decision + const TypeIcon = config.icon + const title = node.type === 'decision' ? (node.question || 'Untitled Decision') : (node.title || `Untitled ${config.label}`) + const isRoot = treeStructure?.id === nodeId + + return ( +
+ {/* Header */} +
+ + + + {title} + +
+ + {/* Body — scrollable form area */} +
+ {draft.type === 'decision' && } + {draft.type === 'action' && } + {draft.type === 'solution' && } +
+ + {/* Footer */} +
+ + +
+ {!isRoot && ( + <> + + {showDeleteConfirm ? ( +
+ + +
+ ) : ( + + )} + + )} +
+
+ ) +} + +// Same helper used in TreeCanvas.tsx — clear all next_node_id references to a node before deleting +function clearInboundReferences( + nodeId: string, + treeStructure: TreeStructure, + updateNode: (id: string, updates: Partial) => void +) { + function walk(node: TreeStructure) { + if (node.type === 'decision' && node.options) { + const needsUpdate = node.options.some(o => o.next_node_id === nodeId) + if (needsUpdate) { + updateNode(node.id, { + options: node.options.map(o => o.next_node_id === nodeId ? { ...o, next_node_id: '' } : o), + }) + } + } + if (node.type === 'action' && node.next_node_id === nodeId) { + updateNode(node.id, { next_node_id: '' }) + } + node.children?.forEach(walk) + } + walk(treeStructure) +} +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/NodeEditorPanel.tsx +git commit -m "feat: add NodeEditorPanel side panel for React Flow canvas editing" +``` + +--- + +## Phase 5: Main FlowCanvas Component + +### Task 7: Create FlowCanvas + +**Files:** +- Create: `frontend/src/components/tree-editor/FlowCanvas.tsx` + +**Context:** This is the main React Flow canvas component that replaces `TreeCanvas.tsx`. It orchestrates everything: the React Flow instance, node types, the `useTreeLayout` hook, minimap toggle, add-node buttons (via a floating toolbar or context), and the node click → panel selection flow. + +Important: Import the React Flow CSS at the top: `import '@xyflow/react/dist/style.css'` + +The component receives `selectedNodeId` and `onNodeSelect` props from its parent (`TreeEditorLayout`) so the panel state lives above. + +**Step 1: Create the component** + +```tsx +// frontend/src/components/tree-editor/FlowCanvas.tsx +import { useCallback, useMemo, useState, useEffect } from 'react' +import { + ReactFlow, + Background, + Controls, + MiniMap, + BackgroundVariant, + useReactFlow, + useNodesState, + useEdgesState, + ReactFlowProvider, + type OnNodesChange, + type OnEdgesChange, + type NodeMouseHandler, +} from '@xyflow/react' +import '@xyflow/react/dist/style.css' + +import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode' +import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode' +import { useTreeLayout } from './useTreeLayout' +import { useTreeEditorStore } from '@/store/treeEditorStore' +import { cn } from '@/lib/utils' +import { Map as MapIcon, MapOff } from 'lucide-react' +import type { FlowCanvasNodeData } from './FlowCanvasNode' +import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode' + +const nodeTypes = { + flowNode: FlowCanvasNode, + answerStub: FlowCanvasAnswerNode, +} + +interface FlowCanvasProps { + selectedNodeId: string | null + onNodeSelect: (nodeId: string | null) => void + onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void +} + +function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: FlowCanvasProps) { + const { fitView, setCenter } = useReactFlow() + const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout() + const [minimapVisible, setMinimapVisible] = useState(true) + + // Inject callbacks into node data (because useTreeLayout creates placeholder functions) + const nodesWithCallbacks = useMemo(() => { + return layoutNodes.map(n => { + if (n.type === 'flowNode') { + const data = n.data as FlowCanvasNodeData + return { + ...n, + selected: n.id === selectedNodeId, + data: { ...data, onToggleCollapse: toggleCollapse }, + } + } + if (n.type === 'answerStub') { + const data = n.data as FlowCanvasAnswerNodeData + return { + ...n, + selected: n.id === selectedNodeId, + data: { ...data, onSelectType: onSelectAnswerType }, + } + } + return n + }) + }, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType]) + + const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks) + const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges) + + // Sync layout changes into React Flow state + useEffect(() => { + setNodes(nodesWithCallbacks) + setEdges(layoutEdges) + }, [nodesWithCallbacks, layoutEdges, setNodes, setEdges]) + + // Fit view after layout changes + useEffect(() => { + // Small delay to let React Flow process the node updates + const timer = setTimeout(() => { + fitView({ padding: 0.1, duration: 200 }) + }, 50) + return () => clearTimeout(timer) + }, [layoutNodes.length, collapsedNodeIds.size]) // eslint-disable-line react-hooks/exhaustive-deps + + // Auto-center on selected node when panel opens + useEffect(() => { + if (!selectedNodeId) return + const node = nodes.find(n => n.id === selectedNodeId) + if (node) { + const x = node.position.x + 140 // center of 280px node + const y = node.position.y + 50 + setCenter(x, y, { duration: 300, zoom: 1 }) + } + }, [selectedNodeId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Height measurement correction + useEffect(() => { + if (nodes.length > 0 && nodes.some(n => n.measured?.height)) { + onNodesMeasured(nodes) + } + }, [nodes]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleNodeClick: NodeMouseHandler = useCallback((_event, node) => { + onNodeSelect(node.id) + }, [onNodeSelect]) + + const handlePaneClick = useCallback(() => { + onNodeSelect(null) + }, [onNodeSelect]) + + const getNodeColor = useCallback((node: { type?: string }) => { + if (node.type === 'answerStub') return 'hsl(var(--muted-foreground))' + // Use NODE_TYPE_CONFIG minimap colors + const config = NODE_TYPE_CONFIG[(node.type === 'flowNode' ? 'decision' : 'decision') as keyof typeof NODE_TYPE_CONFIG] + return config?.minimapColor ?? '#6b7280' + }, []) + + // Custom minimap node color based on actual tree node type + const minimapNodeColor = useCallback((rfNode: { data?: unknown }) => { + const data = rfNode.data as FlowCanvasNodeData | FlowCanvasAnswerNodeData | undefined + if (!data || !('node' in data)) return '#6b7280' + const treeNode = data.node + if (treeNode.type === 'answer') return '#6b7280' + const config = NODE_TYPE_CONFIG[treeNode.type as keyof typeof NODE_TYPE_CONFIG] + return config?.minimapColor ?? '#6b7280' + }, []) + + return ( +
+ + + + {minimapVisible && ( + + )} + + + {/* Minimap toggle button */} + +
+ ) +} + +// Wrap in ReactFlowProvider (required by useReactFlow hook) +export function FlowCanvas(props: FlowCanvasProps) { + return ( + + + + ) +} +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/FlowCanvas.tsx +git commit -m "feat: add FlowCanvas main React Flow component with zoom/pan/minimap" +``` + +--- + +## Phase 6: Integration — Wire Into Editor Layout + +### Task 8: Update TreeEditorLayout to use FlowCanvas + NodeEditorPanel + +**Files:** +- Modify: `frontend/src/components/tree-editor/TreeEditorLayout.tsx` + +**Context:** Currently Flow mode renders `` full-width with `` as an overlay. We need to change it to render `` that shrinks when `` is open. Both the node editor panel and metadata panel take up real layout space on the right. + +The panel state (which node is being edited) needs to come from the parent `TreeEditorPage.tsx` via props. + +**Step 1: Read the current file to understand exact structure** + +Read: `frontend/src/components/tree-editor/TreeEditorLayout.tsx` + +**Step 2: Update the component** + +Add new props and update the Flow mode rendering. The key change is the Flow mode now renders: +```tsx +
+
+ +
+ {editingNodeId && ( + onNodeSelect(null)} + onSelectType={onSelectAnswerType} + /> + )} + {isMetadataOpen && !editingNodeId && ( + + )} +
+``` + +New props needed on `TreeEditorLayoutProps`: +```typescript +interface TreeEditorLayoutProps { + isMobile?: boolean + isMetadataOpen?: boolean + onCloseMetadata?: () => void + editingNodeId: string | null // NEW + onNodeSelect: (nodeId: string | null) => void // NEW + onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void // NEW +} +``` + +**Important:** `MetadataSidePanel` currently uses `position: fixed` overlay. For the new layout, when the node editor is open, metadata panel should be hidden (single-panel-at-a-time rule from the design doc). When neither is open, metadata can still use its existing overlay behavior OR be changed to use the same flex layout pattern. Start with the simpler approach: just hide metadata when node editor is open. + +**Step 3: Verify build** + +Run: `cd frontend && npm run build` +Expected: May have type errors in `TreeEditorPage.tsx` because it doesn't pass the new props yet. That's fine — we fix it in Task 9. + +**Step 4: Commit** + +```bash +git add frontend/src/components/tree-editor/TreeEditorLayout.tsx +git commit -m "feat: wire FlowCanvas and NodeEditorPanel into TreeEditorLayout" +``` + +--- + +### Task 9: Update TreeEditorPage to manage panel state + +**Files:** +- Modify: `frontend/src/pages/TreeEditorPage.tsx` + +**Context:** `TreeEditorPage` already manages `isMetadataOpen`. Now it also needs to manage `editingNodeId` and enforce the single-panel-at-a-time rule. + +**Step 1: Read the current file** + +Read: `frontend/src/pages/TreeEditorPage.tsx` + +**Step 2: Add state and handlers** + +Add these state variables and handlers: +```typescript +const [editingNodeId, setEditingNodeId] = useState(null) + +const handleNodeSelect = useCallback((nodeId: string | null) => { + if (nodeId) { + setIsMetadataOpen(false) // close metadata when opening node editor + } + setEditingNodeId(nodeId) + selectNode(nodeId) +}, [selectNode]) + +const handleSelectAnswerType = useCallback((nodeId: string, type: 'decision' | 'action' | 'solution') => { + updateNode(nodeId, { type }) + // Keep the panel open on the same node — it will now show the form for the new type + setEditingNodeId(nodeId) + selectNode(nodeId) +}, [updateNode, selectNode]) +``` + +Update the metadata toggle to close the node editor: +```typescript +const handleMetadataToggle = () => { + if (!isMetadataOpen) { + setEditingNodeId(null) // close node editor when opening metadata + } + setIsMetadataOpen(!isMetadataOpen) +} +``` + +Pass the new props to `TreeEditorLayout`: +```tsx + setIsMetadataOpen(false)} + editingNodeId={editingNodeId} + onNodeSelect={handleNodeSelect} + onSelectAnswerType={handleSelectAnswerType} +/> +``` + +Also: close the node editor when switching to Code mode: +```typescript +// In the mode switch handler: +setEditingNodeId(null) +``` + +**Step 3: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build with zero errors. + +**Step 4: Commit** + +```bash +git add frontend/src/pages/TreeEditorPage.tsx +git commit -m "feat: add panel state management for node editor in TreeEditorPage" +``` + +--- + +## Phase 7: Styling + Polish + +### Task 10: Override React Flow default styles for dark theme + +**Files:** +- Modify: `frontend/src/index.css` (or create `frontend/src/components/tree-editor/flow-canvas.css`) + +**Context:** React Flow ships with its own light-mode styles. We need to override them to match the dark-first design system. The overrides target React Flow's CSS class names. + +**Step 1: Add style overrides** + +Add to `index.css` (after the existing Tailwind layers) or create a dedicated CSS file imported by `FlowCanvas.tsx`: + +```css +/* React Flow dark theme overrides */ +.react-flow__background { + background-color: hsl(var(--background)); +} + +.react-flow__controls { + background-color: hsl(var(--card)); + border-color: hsl(var(--border)); + border-radius: 0.75rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3); +} + +.react-flow__controls-button { + background-color: hsl(var(--card)); + border-color: hsl(var(--border)); + fill: hsl(var(--muted-foreground)); +} + +.react-flow__controls-button:hover { + background-color: hsl(var(--accent)); +} + +.react-flow__minimap { + background-color: hsl(var(--card)); + border-color: hsl(var(--border)); + border-radius: 0.75rem; +} + +.react-flow__edge-path { + stroke: hsl(var(--border)); +} + +.react-flow__edge-text { + fill: hsl(var(--muted-foreground)); +} + +.react-flow__edge-textbg { + fill: hsl(var(--card)); +} + +/* Hide default React Flow attribution */ +.react-flow__attribution { + display: none; +} + +/* Handle styles */ +.react-flow__handle { + background-color: hsl(var(--border)); +} +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/index.css +git commit -m "style: add React Flow dark theme overrides for canvas" +``` + +--- + +### Task 11: Update tree-editor barrel export + +**Files:** +- Modify: `frontend/src/components/tree-editor/index.ts` + +**Step 1: Add new exports** + +Add these lines to the existing exports (don't remove any existing exports): +```typescript +export { FlowCanvas } from './FlowCanvas' +export { FlowCanvasNode } from './FlowCanvasNode' +export { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode' +export { NodeEditorPanel } from './NodeEditorPanel' +``` + +**Step 2: Verify build** + +Run: `cd frontend && npm run build` +Expected: Clean build. + +**Step 3: Commit** + +```bash +git add frontend/src/components/tree-editor/index.ts +git commit -m "chore: export new React Flow canvas components from barrel" +``` + +--- + +## Phase 8: Final Verification + +### Task 12: Full build verification + manual test + +**Step 1: Run frontend build** + +Run: `cd frontend && npm run build` +Expected: Clean build with zero TypeScript errors. + +**Step 2: Run backend tests (ensure nothing broken)** + +Run: `cd backend && source venv/bin/activate && pytest --override-ini="addopts=" -q` +Expected: All tests pass (backend is unchanged, but sanity check). + +**Step 3: Manual test checklist** + +- [ ] Open any troubleshooting tree in Flow mode → see React Flow canvas with nodes +- [ ] Ctrl+scroll zooms in/out +- [ ] Click and drag on empty space pans the canvas +- [ ] Plain scroll pans vertically +- [ ] Click a node → right panel opens with the correct form, canvas shrinks +- [ ] Edit a field in the panel → Save → node card updates on canvas +- [ ] Cancel discards changes +- [ ] Delete a node → node removed, edges cleaned up +- [ ] Click collapse chevron on a node with children → subtree disappears, "N nodes hidden" pill shows +- [ ] Click expand → subtree reappears +- [ ] Minimap visible in bottom-right, shows node overview +- [ ] Click minimap toggle → minimap hides, click again → shows +- [ ] Zoom controls in bottom-left work (zoom in, zoom out, fit view) +- [ ] Click "Metadata" toolbar button → metadata panel opens, node editor closes +- [ ] Switch to Code mode → canvas replaced with Monaco, node editor closes +- [ ] Switch back to Flow mode → React Flow canvas renders +- [ ] Answer stub nodes show dashed card → click → type picker → pick type → panel opens with correct form +- [ ] Create a new decision with options → save → answer stubs appear for each option +- [ ] Publish guard still works (can't publish with answer stubs) +- [ ] Large tree (Password Reset / Account Lockout) → no overlap, can zoom to see full tree + +**Step 4: Commit any fixes from manual testing** + +If any issues found during manual testing, fix and commit individually. + +**Step 5: Final commit** + +```bash +git add -A +git commit -m "feat: complete React Flow migration for flow editor canvas" +``` + +--- + +## Summary of Files Changed + +### New Files (6) +| File | Purpose | +|------|---------| +| `frontend/src/lib/dagreLayout.ts` | Pure dagre layout utility | +| `frontend/src/components/tree-editor/FlowCanvas.tsx` | Main React Flow canvas component | +| `frontend/src/components/tree-editor/FlowCanvasNode.tsx` | Custom compact node (decision/action/solution) | +| `frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx` | Custom answer stub node | +| `frontend/src/components/tree-editor/NodeEditorPanel.tsx` | Right-side editor panel | +| `frontend/src/components/tree-editor/useTreeLayout.ts` | Tree→ReactFlow conversion hook with dagre | + +### Modified Files (4) +| File | Changes | +|------|---------| +| `frontend/src/components/tree-editor/TreeEditorLayout.tsx` | Flow mode uses FlowCanvas + NodeEditorPanel | +| `frontend/src/pages/TreeEditorPage.tsx` | Panel state management, single-panel-at-a-time | +| `frontend/src/components/tree-editor/index.ts` | New component exports | +| `frontend/src/index.css` | React Flow dark theme overrides | + +### New Dependencies (2) +| Package | Purpose | +|---------|---------| +| `@xyflow/react` | React Flow canvas framework | +| `@dagrejs/dagre` | Directed graph layout algorithm | + +### No Changes Required +- `treeEditorStore.ts` — no store changes +- `NodeFormDecision.tsx`, `NodeFormAction.tsx`, `NodeFormResolution.tsx` — reused as-is +- `MetadataSidePanel.tsx` — works as-is with single-panel-at-a-time rule +- Code mode — untouched +- All backend files — untouched -- 2.49.1 From 2d73bb861f44df3d1e19a8a65edfdb4674da2a30 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 20:59:31 -0500 Subject: [PATCH 03/19] chore: install @xyflow/react and @dagrejs/dagre Co-Authored-By: Claude Opus 4.6 --- frontend/package-lock.json | 183 +++++++++++++++++++++++++++++++++++++ frontend/package.json | 2 + 2 files changed, 185 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 000c894f..0a3ac2e9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,12 +8,14 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "@dagrejs/dagre": "^2.0.4", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@stripe/stripe-js": "^8.7.0", "@types/lodash": "^4.17.23", + "@xyflow/react": "^12.10.0", "axios": "^1.13.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -563,6 +565,21 @@ "node": ">=20.19.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz", + "integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==", + "license": "MIT", + "dependencies": { + "@dagrejs/graphlib": "3.0.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz", + "integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==", + "license": "MIT" + }, "node_modules/@date-fns/tz": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", @@ -1972,6 +1989,15 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -2002,6 +2028,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -2023,6 +2055,25 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2546,6 +2597,66 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", + "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.74", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.74", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", + "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3043,6 +3154,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -3248,6 +3365,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -3303,6 +3442,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -3348,6 +3496,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b038492c..98ac3d0b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,12 +13,14 @@ "analyze": "vite-bundle-visualizer" }, "dependencies": { + "@dagrejs/dagre": "^2.0.4", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", "@stripe/stripe-js": "^8.7.0", "@types/lodash": "^4.17.23", + "@xyflow/react": "^12.10.0", "axios": "^1.13.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", -- 2.49.1 From c53acfc62dc082ead8a01fdf187da0e6c4404851 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 21:00:35 -0500 Subject: [PATCH 04/19] feat: add dagre layout utility for React Flow node positioning Co-Authored-By: Claude Opus 4.6 --- frontend/src/lib/dagreLayout.ts | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 frontend/src/lib/dagreLayout.ts diff --git a/frontend/src/lib/dagreLayout.ts b/frontend/src/lib/dagreLayout.ts new file mode 100644 index 00000000..228882c7 --- /dev/null +++ b/frontend/src/lib/dagreLayout.ts @@ -0,0 +1,44 @@ +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 } -- 2.49.1 From 821939744adc978602548127b3c91ea7699e424a Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 21:02:00 -0500 Subject: [PATCH 05/19] feat: add FlowCanvasNode compact card for React Flow canvas Co-Authored-By: Claude Opus 4.6 --- .../components/tree-editor/FlowCanvasNode.tsx | 154 ++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 frontend/src/components/tree-editor/FlowCanvasNode.tsx diff --git a/frontend/src/components/tree-editor/FlowCanvasNode.tsx b/frontend/src/components/tree-editor/FlowCanvasNode.tsx new file mode 100644 index 00000000..096591dd --- /dev/null +++ b/frontend/src/components/tree-editor/FlowCanvasNode.tsx @@ -0,0 +1,154 @@ +import { memo } from 'react' +import { Handle, Position, type NodeProps } from '@xyflow/react' +import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { TreeStructure, NodeType } from '@/types' + +const NODE_TYPE_CONFIG: Record, { + icon: typeof HelpCircle + label: string + borderClass: string + badgeClass: string + minimapColor: string +}> = { + decision: { + icon: HelpCircle, + label: 'Decision', + borderClass: 'border-l-4 border-l-blue-500', + badgeClass: 'bg-blue-500/20 text-blue-400', + minimapColor: '#3b82f6', + }, + action: { + icon: Zap, + label: 'Action', + borderClass: 'border-l-4 border-l-yellow-500', + badgeClass: 'bg-yellow-500/20 text-yellow-400', + minimapColor: '#eab308', + }, + solution: { + icon: CheckCircle, + label: 'Solution', + borderClass: 'border-l-4 border-l-green-500', + badgeClass: 'bg-green-500/20 text-green-400', + minimapColor: '#22c55e', + }, +} + +export interface FlowCanvasNodeData { + node: TreeStructure + hasChildren: boolean + isCollapsed: boolean + hasValidationErrors: boolean + isNew: boolean + onToggleCollapse: (nodeId: string) => void +} + +function FlowCanvasNodeComponent({ data, selected }: NodeProps) { + const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse } = data as unknown as FlowCanvasNodeData + const nodeType = node.type as Exclude + const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision + const Icon = config.icon + + const title = node.type === 'decision' + ? (node.question || 'Untitled Decision') + : (node.title || `Untitled ${config.label}`) + + const optionCount = node.options?.length ?? 0 + + return ( + <> + {/* Target handle at top */} + + +
+ {/* Header */} +
+ {/* Type badge */} + + + + + {/* Title */} + + {title} + + + {/* Badges */} + {isNew && ( + + New + + )} + {hasValidationErrors && ( + + )} +
+ + {/* Decision options preview */} + {node.type === 'decision' && optionCount > 0 && ( +
+
+ {optionCount} option{optionCount !== 1 ? 's' : ''} +
+
+ {node.options!.slice(0, 3).map((opt, i) => ( +
+ {String.fromCharCode(65 + i)}{' '} + {opt.label || '(empty)'} +
+ ))} + {optionCount > 3 && ( +
+{optionCount - 3} more
+ )} +
+
+ )} + + {/* Description preview for action/solution */} + {(node.type === 'action' || node.type === 'solution') && node.description && ( +
+
{node.description}
+
+ )} + + {/* Collapse chevron */} + {hasChildren && ( +
+ +
+ )} +
+ + {/* Source handle at bottom */} + + + ) +} + +export const FlowCanvasNode = memo(FlowCanvasNodeComponent) +export { NODE_TYPE_CONFIG } -- 2.49.1 From e94171fb18cefae934e9a6347c528eed3c2d3b47 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 21:08:28 -0500 Subject: [PATCH 06/19] feat: add FlowCanvasAnswerNode stub card for React Flow canvas Co-Authored-By: Claude Opus 4.6 --- .../tree-editor/FlowCanvasAnswerNode.tsx | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx diff --git a/frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx b/frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx new file mode 100644 index 00000000..2fd84eee --- /dev/null +++ b/frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx @@ -0,0 +1,69 @@ +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 unknown as FlowCanvasAnswerNodeData + const [picking, setPicking] = useState(false) + const label = node.title || 'Answer' + + return ( + <> + + +
!picking && setPicking(true)} + > +
+ {label} +
+ + {!picking ? ( +
+ + Choose Type +
+ ) : ( +
+ + + +
+ )} +
+ + + + ) +} + +export const FlowCanvasAnswerNode = memo(FlowCanvasAnswerNodeComponent) -- 2.49.1 From c85c6368f32dd7ced54b915078a89f0dfb61add3 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 21:10:05 -0500 Subject: [PATCH 07/19] feat: add useTreeLayout hook for tree-to-ReactFlow conversion with dagre Co-Authored-By: Claude Opus 4.6 --- .../components/tree-editor/useTreeLayout.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 frontend/src/components/tree-editor/useTreeLayout.ts diff --git a/frontend/src/components/tree-editor/useTreeLayout.ts b/frontend/src/components/tree-editor/useTreeLayout.ts new file mode 100644 index 00000000..3c8aef2c --- /dev/null +++ b/frontend/src/components/tree-editor/useTreeLayout.ts @@ -0,0 +1,199 @@ +import { useMemo, useCallback, useState, useRef, useEffect } from 'react' +import type { Node, Edge } from '@xyflow/react' +import type { TreeStructure } from '@/types' +import { getLayoutedElements, NODE_WIDTH } from '@/lib/dagreLayout' +import type { FlowCanvasNodeData } from './FlowCanvasNode' +import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode' +import { useTreeEditorStore } from '@/store/treeEditorStore' + +const MAX_EDGE_LABEL_LENGTH = 35 + +function truncateLabel(label: string): string { + if (label.length <= MAX_EDGE_LABEL_LENGTH) return label + return label.slice(0, MAX_EDGE_LABEL_LENGTH).trimEnd() + '…' +} + +function estimateNodeHeight(node: TreeStructure): number { + let height = 52 // header baseline + if (node.type === 'decision' && node.options) { + height += 24 // options header line + height += Math.min(node.options.length, 3) * 18 // option rows (max 3 shown) + if (node.options.length > 3) height += 18 // "+N more" row + } + if ((node.type === 'action' || node.type === 'solution') && node.description) { + height += 36 // description preview (2 lines) + } + if (node.type === 'answer') { + return 70 // fixed height for answer stubs + } + return height +} + +interface UseTreeLayoutResult { + nodes: Node[] + edges: Edge[] + collapsedNodeIds: Set + toggleCollapse: (nodeId: string) => void + onNodesMeasured: (measuredNodes: Node[]) => void +} + +export function useTreeLayout(): UseTreeLayoutResult { + const treeStructure = useTreeEditorStore(s => s.treeStructure) + const validationErrors = useTreeEditorStore(s => s.validationErrors) + const [collapsedNodeIds, setCollapsedNodeIds] = useState>(new Set()) + const [measuredHeights, setMeasuredHeights] = useState>(new Map()) + const correctionDone = useRef(false) + + const toggleCollapse = useCallback((nodeId: string) => { + setCollapsedNodeIds(prev => { + const next = new Set(prev) + if (next.has(nodeId)) next.delete(nodeId) + else next.add(nodeId) + return next + }) + }, []) + + // Convert tree structure to flat nodes and edges + const { rawNodes, rawEdges } = useMemo(() => { + const nodes: Node[] = [] + const edges: Edge[] = [] + + if (!treeStructure) return { rawNodes: nodes, rawEdges: edges } + + function walk(node: TreeStructure, _parentId: string | null) { + const isCollapsed = collapsedNodeIds.has(node.id) + const hasChildren = (node.children?.length ?? 0) > 0 + const hasErrors = validationErrors.some(e => e.nodeId === node.id && e.severity === 'error') + + const estimatedHeight = measuredHeights.get(node.id) ?? estimateNodeHeight(node) + + if (node.type === 'answer') { + nodes.push({ + id: node.id, + type: 'answerStub', + position: { x: 0, y: 0 }, // dagre will set this + data: { + node, + onSelectType: () => {}, // placeholder — set by FlowCanvas + } satisfies 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 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 unknown as FlowCanvasNodeData)?.node ?? (mNode.data as unknown as FlowCanvasAnswerNodeData)?.node + ) + if (Math.abs(actual - estimated) > 10) { + newHeights.set(mNode.id, actual) + needsCorrection = true + } + } + + if (needsCorrection) { + correctionDone.current = true + setMeasuredHeights(newHeights) + } + }, [measuredHeights]) + + // Reset correction flag when tree structure changes + useEffect(() => { + correctionDone.current = false + }, [treeStructure, collapsedNodeIds]) + + return { nodes, edges, collapsedNodeIds, toggleCollapse, onNodesMeasured } +} + +// Helper: order children by decision option links +function orderChildren(node: TreeStructure): Array<{ child: TreeStructure; optionLabel?: string }> { + if (!node.children || node.children.length === 0) return [] + + if (node.type === 'decision' && node.options) { + const linked: Array<{ child: TreeStructure; optionLabel: string }> = [] + const linkedIds = new Set() + + for (const opt of node.options) { + if (opt.next_node_id) { + const child = node.children.find(c => c.id === opt.next_node_id) + if (child) { + linked.push({ child, optionLabel: opt.label }) + linkedIds.add(child.id) + } + } + } + + const unlinked = node.children + .filter(c => !linkedIds.has(c.id)) + .map(child => ({ child, optionLabel: undefined })) + + return [...linked, ...unlinked] + } + + return node.children.map(child => ({ child, optionLabel: undefined })) +} -- 2.49.1 From df44ba28571404daed6af48d6db975c1cf1a9024 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 21:11:37 -0500 Subject: [PATCH 08/19] feat: add NodeEditorPanel side panel for React Flow canvas editing Co-Authored-By: Claude Opus 4.6 --- .../tree-editor/NodeEditorPanel.tsx | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 frontend/src/components/tree-editor/NodeEditorPanel.tsx diff --git a/frontend/src/components/tree-editor/NodeEditorPanel.tsx b/frontend/src/components/tree-editor/NodeEditorPanel.tsx new file mode 100644 index 00000000..64797a02 --- /dev/null +++ b/frontend/src/components/tree-editor/NodeEditorPanel.tsx @@ -0,0 +1,264 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { HelpCircle, Zap, CheckCircle, X, Trash2, Copy, Save } from 'lucide-react' +import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore' +import { NodeFormDecision } from './NodeFormDecision' +import { NodeFormAction } from './NodeFormAction' +import { NodeFormResolution } from './NodeFormResolution' +import { cn } from '@/lib/utils' +import type { TreeStructure, NodeType } from '@/types' + +interface NodeEditorPanelProps { + nodeId: string + onClose: () => void + onSelectType?: (nodeId: string, type: 'decision' | 'action' | 'solution') => void +} + +const TYPE_CONFIG: Record, { icon: typeof HelpCircle; label: string; badgeClass: string }> = { + decision: { icon: HelpCircle, label: 'Decision', badgeClass: 'bg-blue-500/20 text-blue-400' }, + action: { icon: Zap, label: 'Action', badgeClass: 'bg-yellow-500/20 text-yellow-400' }, + solution: { icon: CheckCircle, label: 'Solution', badgeClass: 'bg-green-500/20 text-green-400' }, +} + +function cloneWithoutChildren(node: TreeStructure): TreeStructure { + const { children, ...rest } = node + return structuredClone(rest) as TreeStructure +} + +export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPanelProps) { + const treeStructure = useTreeEditorStore(s => s.treeStructure) + const updateNode = useTreeEditorStore(s => s.updateNode) + const deleteNode = useTreeEditorStore(s => s.deleteNode) + const duplicateNode = useTreeEditorStore(s => s.duplicateNode) + const addNode = useTreeEditorStore(s => s.addNode) + const selectNode = useTreeEditorStore(s => s.selectNode) + + const node = treeStructure ? findNodeInTree(nodeId, treeStructure) : null + const [draft, setDraft] = useState(null) + const [isDirty, setIsDirty] = useState(false) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const panelRef = useRef(null) + + // Initialize/reset draft when nodeId changes + useEffect(() => { + if (node) { + setDraft(cloneWithoutChildren(node)) + setIsDirty(false) + setShowDeleteConfirm(false) + } + }, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps + + // Escape to close + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose() + } + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isDirty]) // eslint-disable-line react-hooks/exhaustive-deps + + const handleDraftUpdate = useCallback((updates: Partial) => { + setDraft(prev => prev ? { ...prev, ...updates } : prev) + setIsDirty(true) + }, []) + + const handleSave = useCallback(() => { + if (!draft || !node) return + const { children, ...draftWithoutChildren } = draft + updateNode(nodeId, draftWithoutChildren) + + // Auto-create answer stubs for new decision options without next_node_id + if (draft.options) { + const options = draft.options.filter(o => o.label.trim()) + const stubsCreated: Array<{ optId: string; stubId: string }> = [] + + options.forEach(opt => { + if (!opt.next_node_id) { + const stubId = addNode(nodeId, 'answer') + updateNode(stubId, { title: opt.label }) + stubsCreated.push({ optId: opt.id, stubId }) + } + }) + + if (stubsCreated.length > 0) { + const updatedOptions = options.map(o => { + const stub = stubsCreated.find(s => s.optId === o.id) + return stub ? { ...o, next_node_id: stub.stubId } : o + }) + updateNode(nodeId, { options: updatedOptions }) + } + } + + setIsDirty(false) + }, [draft, node, nodeId, updateNode, addNode]) + + const handleClose = useCallback(() => { + if (isDirty) { + if (!window.confirm('You have unsaved changes. Discard them?')) return + } + onClose() + }, [isDirty, onClose]) + + const handleDelete = useCallback(() => { + if (!treeStructure) return + clearInboundReferences(nodeId, treeStructure, updateNode) + deleteNode(nodeId) + onClose() + }, [nodeId, treeStructure, updateNode, deleteNode, onClose]) + + const handleDuplicate = useCallback(() => { + const newId = duplicateNode(nodeId) + if (newId) { + selectNode(newId) + } + }, [nodeId, duplicateNode, selectNode]) + + if (!node || !draft) return null + + // Answer stub: show type picker instead of form + if (node.type === 'answer') { + return ( +
+
+ + {node.title || 'Answer Placeholder'} + + +
+
+

Choose a type for this node:

+
+ {(['decision', 'action', 'solution'] as const).map(type => { + const cfg = TYPE_CONFIG[type] + const TypeIcon = cfg.icon + return ( + + ) + })} +
+
+
+ ) + } + + const config = TYPE_CONFIG[node.type as Exclude] ?? TYPE_CONFIG.decision + const TypeIcon = config.icon + const title = node.type === 'decision' ? (node.question || 'Untitled Decision') : (node.title || `Untitled ${config.label}`) + const isRoot = treeStructure?.id === nodeId + + return ( +
+ {/* Header */} +
+ + + + {title} + +
+ + {/* Body — scrollable form area */} +
+ {draft.type === 'decision' && } + {draft.type === 'action' && } + {draft.type === 'solution' && } +
+ + {/* Footer */} +
+ + +
+ {!isRoot && ( + <> + + {showDeleteConfirm ? ( +
+ + +
+ ) : ( + + )} + + )} +
+
+ ) +} + +// Clear all next_node_id references to a node before deleting +function clearInboundReferences( + nodeId: string, + treeStructure: TreeStructure, + updateNode: (id: string, updates: Partial) => void +) { + function walk(node: TreeStructure) { + if (node.type === 'decision' && node.options) { + const needsUpdate = node.options.some(o => o.next_node_id === nodeId) + if (needsUpdate) { + updateNode(node.id, { + options: node.options.map(o => o.next_node_id === nodeId ? { ...o, next_node_id: '' } : o), + }) + } + } + if (node.type === 'action' && node.next_node_id === nodeId) { + updateNode(node.id, { next_node_id: '' }) + } + node.children?.forEach(walk) + } + walk(treeStructure) +} -- 2.49.1 From 586c06be485d9fd48f9030c89c8275e71afb8830 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 21:19:22 -0500 Subject: [PATCH 09/19] feat: add FlowCanvas main React Flow component with zoom/pan/minimap Co-Authored-By: Claude Opus 4.6 --- .../src/components/tree-editor/FlowCanvas.tsx | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 frontend/src/components/tree-editor/FlowCanvas.tsx diff --git a/frontend/src/components/tree-editor/FlowCanvas.tsx b/frontend/src/components/tree-editor/FlowCanvas.tsx new file mode 100644 index 00000000..30e89afc --- /dev/null +++ b/frontend/src/components/tree-editor/FlowCanvas.tsx @@ -0,0 +1,176 @@ +import { useCallback, useMemo, useState, useEffect } from 'react' +import { + ReactFlow, + Background, + Controls, + MiniMap, + BackgroundVariant, + useReactFlow, + useNodesState, + useEdgesState, + ReactFlowProvider, + PanOnScrollMode, + 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 { cn } from '@/lib/utils' +import { Map as MapIcon, MapPinOff } 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 unknown as FlowCanvasNodeData + return { + ...n, + selected: n.id === selectedNodeId, + data: { ...data, onToggleCollapse: toggleCollapse }, + } + } + if (n.type === 'answerStub') { + const data = n.data as unknown 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]) + + // Custom minimap node color based on actual tree node type + const minimapNodeColor = useCallback((rfNode: { data?: unknown }) => { + const data = rfNode.data as (FlowCanvasNodeData & FlowCanvasAnswerNodeData) | undefined + if (!data || !('node' in data)) return '#6b7280' + const treeNode = data.node + if (treeNode.type === 'answer') return '#6b7280' + const config = NODE_TYPE_CONFIG[treeNode.type as keyof typeof NODE_TYPE_CONFIG] + return config?.minimapColor ?? '#6b7280' + }, []) + + return ( +
+ + + + {minimapVisible && ( + + )} + + + {/* Minimap toggle button */} + +
+ ) +} + +// Wrap in ReactFlowProvider (required by useReactFlow hook) +export function FlowCanvas(props: FlowCanvasProps) { + return ( + + + + ) +} -- 2.49.1 From cf4849a7c45ebdd73db9dba00a7d054570673d04 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 21:20:05 -0500 Subject: [PATCH 10/19] feat: wire FlowCanvas and NodeEditorPanel into TreeEditorLayout Co-Authored-By: Claude Opus 4.6 --- .../tree-editor/TreeEditorLayout.tsx | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/tree-editor/TreeEditorLayout.tsx b/frontend/src/components/tree-editor/TreeEditorLayout.tsx index 7b87ef56..c622ff71 100644 --- a/frontend/src/components/tree-editor/TreeEditorLayout.tsx +++ b/frontend/src/components/tree-editor/TreeEditorLayout.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from 'react' import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel' -import { TreeCanvas } from './TreeCanvas' +import { FlowCanvas } from './FlowCanvas' +import { NodeEditorPanel } from './NodeEditorPanel' import { MetadataSidePanel } from './MetadataSidePanel' import { useTreeEditorStore } from '@/store/treeEditorStore' import { cn } from '@/lib/utils' @@ -14,12 +15,18 @@ interface TreeEditorLayoutProps { isMobile?: boolean isMetadataOpen?: boolean onCloseMetadata?: () => void + editingNodeId: string | null + onNodeSelect: (nodeId: string | null) => void + onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void } export function TreeEditorLayout({ isMobile = false, isMetadataOpen = false, onCloseMetadata = () => {}, + editingNodeId, + onNodeSelect, + onSelectAnswerType, }: TreeEditorLayoutProps) { const editorMode = useTreeEditorStore(s => s.editorMode) @@ -56,16 +63,31 @@ export function TreeEditorLayout({ ) : ( <> - {/* Flow Mode: Full-width visual canvas */} + {/* Flow Mode: React Flow canvas + side panels */}
- +
- {/* Metadata side panel — overlays the canvas from the right */} - + {/* Node editor panel — takes real layout space */} + {editingNodeId && ( + onNodeSelect(null)} + onSelectType={onSelectAnswerType} + /> + )} + + {/* Metadata side panel — only show when node editor is closed */} + {!editingNodeId && ( + + )} )}
-- 2.49.1 From 4cfeb2eefec6ac0d88e364daa076cff80d2e9d85 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 21:21:29 -0500 Subject: [PATCH 11/19] feat: add panel state management for node editor in TreeEditorPage Co-Authored-By: Claude Opus 4.6 --- frontend/src/pages/TreeEditorPage.tsx | 28 ++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx index d21fd4f1..731d29c9 100644 --- a/frontend/src/pages/TreeEditorPage.tsx +++ b/frontend/src/pages/TreeEditorPage.tsx @@ -45,6 +45,7 @@ export function TreeEditorPage() { setLoading, setSaving, selectNode, + updateNode, setEditorMode, } = useTreeEditorStore() @@ -55,6 +56,7 @@ export function TreeEditorPage() { const [treeStatus, setTreeStatus] = useState('draft') const [showAnalytics, setShowAnalytics] = useState(false) const [isMetadataOpen, setIsMetadataOpen] = useState(false) + const [editingNodeId, setEditingNodeId] = useState(null) // Mobile detection const [isMobile, setIsMobile] = useState(false) @@ -211,6 +213,21 @@ export function TreeEditorPage() { selectNode(nodeId) } + 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]) + const handleSaveDraft = useCallback(async () => { setSaving(true) try { @@ -507,6 +524,7 @@ export function TreeEditorPage() { onClick={() => { setEditorMode('code') setIsMetadataOpen(false) // Auto-close metadata panel on Code mode + setEditingNodeId(null) // Close node editor on Code mode }} title="Code Mode — markdown editing (Ctrl+Shift+M)" className={cn( @@ -562,7 +580,12 @@ export function TreeEditorPage() { {editorMode === 'form' && ( ))} + {hasMore && ( + + )}
) -- 2.49.1