From ab96751d47ec8daf2cdf00d48ca5fdca60afafff Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Feb 2026 22:45:23 -0500 Subject: [PATCH 01/28] feat: Add TreeCanvasNode inline editor card component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces modal-based node editing with inline expand/collapse cards. Each card shows node type, title, and options in compact mode, then renders the full edit form inline on expand — no modal required. Local draft state with save/cancel prevents premature store writes. Co-Authored-By: Claude Sonnet 4.6 --- .../components/tree-editor/TreeCanvasNode.tsx | 365 ++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 frontend/src/components/tree-editor/TreeCanvasNode.tsx diff --git a/frontend/src/components/tree-editor/TreeCanvasNode.tsx b/frontend/src/components/tree-editor/TreeCanvasNode.tsx new file mode 100644 index 00000000..0dc99d6e --- /dev/null +++ b/frontend/src/components/tree-editor/TreeCanvasNode.tsx @@ -0,0 +1,365 @@ +import { useState, useCallback } from 'react' +import { + HelpCircle, + Zap, + CheckCircle, + Play, + Check, + X, + Copy, + Trash2, + GripVertical, + AlertCircle, + AlertTriangle, + ChevronDown, + ChevronRight, +} from 'lucide-react' +import { useTreeEditorStore } from '@/store/treeEditorStore' +import { NodeFormDecision } from './NodeFormDecision' +import { NodeFormAction } from './NodeFormAction' +import { NodeFormResolution } from './NodeFormResolution' +import type { TreeStructure } from '@/types' +import { cn } from '@/lib/utils' + +interface TreeCanvasNodeProps { + node: TreeStructure + depth: number + fromOption?: string + isExpanded: boolean + isNew: boolean + onToggleExpand: () => void + onSave: (nodeId: string, updates: Partial) => void + onCancelNew: (nodeId: string) => void + onDelete: (nodeId: string) => void + onDuplicate: (nodeId: string) => void + onDragStart: (e: React.DragEvent, nodeId: string) => void + onDragOver: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent) => void +} + +/** Clone a node without its children (for local draft state) */ +function cloneNodeWithoutChildren(node: TreeStructure): TreeStructure { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children, ...rest } = node + return structuredClone(rest) as TreeStructure +} + +const NODE_TYPE_CONFIG = { + decision: { + icon: HelpCircle, + label: 'Decision', + borderClass: 'border-l-4 border-l-blue-500', + badgeClass: 'bg-blue-500/20 text-blue-400', + }, + action: { + icon: Zap, + label: 'Action', + borderClass: 'border-l-4 border-l-yellow-500', + badgeClass: 'bg-yellow-500/20 text-yellow-400', + }, + solution: { + icon: CheckCircle, + label: 'Solution', + borderClass: 'border-l-4 border-l-green-500', + badgeClass: 'bg-green-500/20 text-green-400', + }, +} as const + +export function TreeCanvasNode({ + node, + fromOption, + isExpanded, + isNew, + onToggleExpand, + onSave, + onCancelNew, + onDelete, + onDuplicate, + onDragStart, + onDragOver, + onDrop, +}: TreeCanvasNodeProps) { + const { validationErrors, selectedNodeId, selectNode } = useTreeEditorStore() + const isRoot = node.id === 'root' + const isSelected = selectedNodeId === node.id + + const nodeErrors = validationErrors.filter( + (e) => e.nodeId === node.id && e.severity === 'error' + ) + const nodeWarnings = validationErrors.filter( + (e) => e.nodeId === node.id && e.severity === 'warning' + ) + const hasError = nodeErrors.length > 0 + const hasWarning = nodeWarnings.length > 0 + + // Local draft state for inline editing + const [draft, setDraft] = useState(() => + cloneNodeWithoutChildren(node) + ) + + // Reset draft if node changes while editing (e.g. store update from undo) + const [lastNodeId, setLastNodeId] = useState(node.id) + if (node.id !== lastNodeId) { + setDraft(cloneNodeWithoutChildren(node)) + setLastNodeId(node.id) + } + + const handleDraftUpdate = useCallback((updates: Partial) => { + setDraft((prev) => ({ ...prev, ...updates })) + }, []) + + const handleSave = (e: React.MouseEvent) => { + e.stopPropagation() + // Strip children from draft before passing to onSave + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children, ...draftWithoutChildren } = draft + onSave(node.id, draftWithoutChildren) + } + + const handleCancel = (e: React.MouseEvent) => { + e.stopPropagation() + if (isNew) { + onCancelNew(node.id) + } else { + // Discard draft changes and collapse + setDraft(cloneNodeWithoutChildren(node)) + onToggleExpand() + } + } + + const handleCardClick = () => { + selectNode(node.id) + onToggleExpand() + } + + const config = NODE_TYPE_CONFIG[node.type] + const TypeIcon = config.icon + + const getTitle = () => { + if (node.type === 'decision') return node.question || 'Untitled Question' + return node.title || `Untitled ${node.type}` + } + + const getOptionsSummary = () => { + if (node.type !== 'decision' || !node.options?.length) return null + const count = node.options.length + return `${count} option${count !== 1 ? 's' : ''}` + } + + return ( +
+ {/* Card Header */} +
+ {/* Drag handle (hide for root) */} + {!isRoot && ( + { + e.stopPropagation() + onDragStart(e, node.id) + }} + > + + + )} + + {/* Node type badge */} + {isRoot ? ( + + + START + + ) : ( + + + {config.label} + + )} + + {/* From-option label */} + {fromOption && ( + + {fromOption} + + )} + + {/* Title text (compact mode) */} + {!isExpanded && ( + + {getTitle()} + + )} + + {/* Options count badge */} + {!isExpanded && getOptionsSummary() && ( + + {getOptionsSummary()} + + )} + + {/* Validation badges (compact mode) */} + {!isExpanded && hasError && ( + e.message).join('\n')} + > + + {nodeErrors.length} + + )} + {!isExpanded && !hasError && hasWarning && ( + e.message).join('\n')} + > + + {nodeWarnings.length} + + )} + + {/* Unsaved badge */} + {!isExpanded && isNew && ( + + Unsaved + + )} + + {/* Expand/collapse chevron */} + {!isExpanded ? ( + + ) : ( + + )} + + {/* Editing action buttons (expanded state) */} + {isExpanded && ( +
+ {/* New badge */} + {isNew && ( + + Unsaved + + )} + + {/* Duplicate (hide for root) */} + {!isRoot && ( + + )} + + {/* Delete (hide for root) */} + {!isRoot && ( + + )} + + {/* Cancel */} + + + {/* Save */} + +
+ )} +
+ + {/* Expanded editing area */} + {isExpanded && ( +
+ {/* Validation errors */} + {(hasError || hasWarning) && ( +
+ {nodeErrors.map((error, i) => ( +
+ {error.message} +
+ ))} + {!hasError && + nodeWarnings.map((warning, i) => ( +
+ {warning.message} +
+ ))} +
+ )} + + {/* Type-specific form — uses draft, not live node */} + {draft.type === 'decision' && ( + + )} + {draft.type === 'action' && ( + + )} + {draft.type === 'solution' && ( + + )} +
+ )} +
+ ) +} + +export default TreeCanvasNode -- 2.49.1 From 9e59b04dcc60a598d3bcbf5c7e0d57e658bab5ff Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Feb 2026 22:45:28 -0500 Subject: [PATCH 02/28] feat: Add TreeCanvas layout with visual branching and orchestration Replaces NodeList + TreePreviewPanel with a single full-width canvas. Decision nodes branch horizontally; action/solution nodes flow vertically. Inline type picker adds nodes without modal interruption. Handles pending link resolution, inbound reference cleanup on delete, and selection sync. CSS dot-grid background + connector lines for structure clarity. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/tree-editor/TreeCanvas.tsx | 636 ++++++++++++++++++ 1 file changed, 636 insertions(+) create mode 100644 frontend/src/components/tree-editor/TreeCanvas.tsx diff --git a/frontend/src/components/tree-editor/TreeCanvas.tsx b/frontend/src/components/tree-editor/TreeCanvas.tsx new file mode 100644 index 00000000..f268db79 --- /dev/null +++ b/frontend/src/components/tree-editor/TreeCanvas.tsx @@ -0,0 +1,636 @@ +import { useState, useCallback, useRef, useEffect } from 'react' +import { HelpCircle, Zap, CheckCircle, Plus, X } from 'lucide-react' +import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore' +import { TreeCanvasNode } from './TreeCanvasNode' +import type { TreeStructure, NodeType } from '@/types' +import { cn } from '@/lib/utils' + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface PendingLink { + parentId: string + optionId?: string // For decision option linking +} + +interface DragState { + nodeId: string + parentId: string | null + index: number +} + +// ─── Reference cleanup helper ───────────────────────────────────────────────── + +/** + * Before deleting a node, clear all inbound references to it across the tree. + * This prevents stale next_node_id / option.next_node_id references. + */ +function clearInboundReferences( + nodeId: string, + treeStructure: TreeStructure, + updateNode: (id: string, updates: Partial) => void +) { + function walk(node: TreeStructure) { + // Clear decision option references + 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 + ), + }) + } + } + + // Clear action next_node_id references + if (node.type === 'action' && node.next_node_id === nodeId) { + updateNode(node.id, { next_node_id: '' }) + } + + // Recurse + node.children?.forEach(walk) + } + + walk(treeStructure) +} + +// ─── Add-node type picker ───────────────────────────────────────────────────── + +interface AddNodePickerProps { + onSelect: (type: NodeType) => void + onCancel: () => void +} + +function AddNodePicker({ onSelect, onCancel }: AddNodePickerProps) { + return ( +
+ Add: + + + + + + + + +
+ ) +} + +// ─── Add-node trigger button ────────────────────────────────────────────────── + +interface AddNodeButtonProps { + label?: string + onClick: () => void +} + +function AddNodeButton({ label = 'Add node', onClick }: AddNodeButtonProps) { + return ( + + ) +} + +// ─── Add-key builder ────────────────────────────────────────────────────────── + +/** Unique key for an add-target: "parentId" or "parentId:optionId" */ +function addKey(parentId: string, optionId?: string) { + return optionId ? `${parentId}:${optionId}` : parentId +} + +// ─── TreeCanvas ─────────────────────────────────────────────────────────────── + +export function TreeCanvas() { + const { + treeStructure, + addNode, + updateNode, + deleteNode, + duplicateNode, + reorderNodes, + selectNode, + selectedNodeId, + } = useTreeEditorStore() + + // ── Local canvas state ── + const [expandedNodeId, setExpandedNodeId] = useState(null) + const [newNodeIds, setNewNodeIds] = useState>(new Set()) + const [pendingAddKey, setPendingAddKey] = useState(null) + const [pendingLinks, setPendingLinks] = useState>( + new Map() + ) + const [dragState, setDragState] = useState(null) + const [dragOverTarget, setDragOverTarget] = useState<{ + parentId: string | null + index: number + } | null>(null) + + // Node ref map for scroll-into-view + const nodeRefs = useRef>(new Map()) + + // ── Selection sync ── + // When selectedNodeId changes externally (e.g. ValidationSummary click), + // auto-expand that card and scroll it into view. + useEffect(() => { + if (selectedNodeId && selectedNodeId !== expandedNodeId) { + setExpandedNodeId(selectedNodeId) + const el = nodeRefs.current.get(selectedNodeId) + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedNodeId]) + + // ── Card expand/collapse ── + const handleToggleExpand = useCallback( + (nodeId: string) => { + setExpandedNodeId((prev) => (prev === nodeId ? null : nodeId)) + selectNode(nodeId) + }, + [selectNode] + ) + + // ── Save inline edits ── + const handleSave = useCallback( + (nodeId: string, updates: Partial) => { + updateNode(nodeId, updates) + + // Resolve pending link for new nodes + const link = pendingLinks.get(nodeId) + if (link) { + const parentNode = treeStructure + ? findNodeInTree(link.parentId, treeStructure) + : null + + if (parentNode) { + if (link.optionId && parentNode.type === 'decision' && parentNode.options) { + // Link the decision option to this new child node + const updatedOptions = parentNode.options.map((o) => + o.id === link.optionId ? { ...o, next_node_id: nodeId } : o + ) + updateNode(link.parentId, { options: updatedOptions }) + } else if (parentNode.type === 'action') { + // Link the action's next node + updateNode(link.parentId, { next_node_id: nodeId }) + } + } + + setPendingLinks((prev) => { + const next = new Map(prev) + next.delete(nodeId) + return next + }) + } + + setNewNodeIds((prev) => { + const next = new Set(prev) + next.delete(nodeId) + return next + }) + setExpandedNodeId(null) + }, + [pendingLinks, treeStructure, updateNode] + ) + + // ── Cancel new node ── + const handleCancelNew = useCallback( + (nodeId: string) => { + deleteNode(nodeId) + setNewNodeIds((prev) => { + const next = new Set(prev) + next.delete(nodeId) + return next + }) + setPendingLinks((prev) => { + const next = new Map(prev) + next.delete(nodeId) + return next + }) + if (expandedNodeId === nodeId) setExpandedNodeId(null) + }, + [deleteNode, expandedNodeId] + ) + + // ── Delete node (with inbound reference cleanup) ── + const handleDelete = useCallback( + (nodeId: string) => { + if (!treeStructure) return + clearInboundReferences(nodeId, treeStructure, updateNode) + deleteNode(nodeId) + if (expandedNodeId === nodeId) setExpandedNodeId(null) + }, + [treeStructure, updateNode, deleteNode, expandedNodeId] + ) + + // ── Duplicate node ── + const handleDuplicate = useCallback( + (nodeId: string) => { + duplicateNode(nodeId) + }, + [duplicateNode] + ) + + // ── Add node flow ── + const handleAddNodeSelect = useCallback( + (type: NodeType, parentId: string, optionId?: string) => { + const newId = addNode(parentId, type) + setNewNodeIds((prev) => new Set([...prev, newId])) + setPendingLinks((prev) => { + const next = new Map(prev) + next.set(newId, { parentId, optionId }) + return next + }) + setExpandedNodeId(newId) + setPendingAddKey(null) + }, + [addNode] + ) + + // ── Drag & drop ── + const handleDragStart = useCallback( + (e: React.DragEvent, nodeId: string) => { + e.dataTransfer.effectAllowed = 'move' + // Find parent and index for this node + const findParentAndIndex = ( + searchNode: TreeStructure, + targetId: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _parentId: string | null + ): { parentId: string | null; index: number } | null => { + if (searchNode.children) { + for (let i = 0; i < searchNode.children.length; i++) { + if (searchNode.children[i].id === targetId) { + return { parentId: searchNode.id, index: i } + } + const found = findParentAndIndex( + searchNode.children[i], + targetId, + searchNode.id + ) + if (found) return found + } + } + return null + } + + if (!treeStructure) return + const location = findParentAndIndex(treeStructure, nodeId, null) + if (location) { + setDragState({ + nodeId, + parentId: location.parentId, + index: location.index, + }) + } + }, + [treeStructure] + ) + + const handleDragOver = useCallback( + (e: React.DragEvent, parentId: string | null, index: number) => { + e.preventDefault() + setDragOverTarget({ parentId, index }) + }, + [] + ) + + const handleDrop = useCallback( + (e: React.DragEvent, targetParentId: string | null, targetIndex: number) => { + e.preventDefault() + if (!dragState || !targetParentId) { + setDragState(null) + setDragOverTarget(null) + return + } + + const { parentId: sourceParentId, index: sourceIndex } = dragState + + if (sourceParentId === targetParentId) { + const adjustedIndex = + sourceIndex < targetIndex ? targetIndex - 1 : targetIndex + if (sourceIndex !== adjustedIndex) { + reorderNodes(sourceParentId!, sourceIndex, adjustedIndex) + } + } + // Cross-parent move intentionally not supported in canvas (complex to handle safely) + + setDragState(null) + setDragOverTarget(null) + }, + [dragState, reorderNodes] + ) + + const handleDragEnd = useCallback(() => { + setDragState(null) + setDragOverTarget(null) + }, []) + + // ── Recursive node renderer ── + const renderNode = useCallback( + ( + node: TreeStructure, + parentId: string | null, + index: number, + optionLabel?: string + ): React.ReactNode => { + const isExpanded = expandedNodeId === node.id + const isNew = newNodeIds.has(node.id) + const nodeChildren = node.children || [] + + // For decision nodes, order children by option link order + const orderedChildren: Array<{ + child: TreeStructure + optionLabel?: string + optionId?: string + childIndex: number + }> = [] + + if (node.type === 'decision' && node.options && nodeChildren.length > 0) { + // First: children linked by options (in option order) + const linkedChildIds = new Set() + node.options.forEach((opt) => { + const linked = nodeChildren.find((c) => c.id === opt.next_node_id) + if (linked) { + orderedChildren.push({ + child: linked, + optionLabel: opt.label || undefined, + optionId: opt.id, + childIndex: nodeChildren.indexOf(linked), + }) + linkedChildIds.add(linked.id) + } + }) + // Then: unlinked children + nodeChildren.forEach((child, idx) => { + if (!linkedChildIds.has(child.id)) { + orderedChildren.push({ + child, + childIndex: idx, + }) + } + }) + } else { + nodeChildren.forEach((child, idx) => { + orderedChildren.push({ child, childIndex: idx }) + }) + } + + // Determine if this node has any children to render + const hasChildren = orderedChildren.length > 0 + + // Determine "add" targets for this node + // For decision nodes: one add-button per option (not-yet-linked options) + // For action nodes: one add-button below + // For solution: none + const unlinkedOptions = + node.type === 'decision' && node.options + ? node.options.filter( + (opt) => + !opt.next_node_id || + !nodeChildren.find((c) => c.id === opt.next_node_id) + ) + : [] + + const showSingleAddButton = + node.type === 'action' && !hasChildren + + return ( +
{ + if (el) nodeRefs.current.set(node.id, el as HTMLDivElement) + else nodeRefs.current.delete(node.id) + }} + > + {/* Drop indicator above */} + {dragOverTarget?.parentId === parentId && + dragOverTarget.index === index && ( +
+ )} + + {/* Option label tag (above card, shown when this is a branch from a decision) */} + {optionLabel && ( +
+ {optionLabel} +
+ )} + + {/* The node card itself */} + handleToggleExpand(node.id)} + onSave={handleSave} + onCancelNew={handleCancelNew} + onDelete={handleDelete} + onDuplicate={handleDuplicate} + onDragStart={handleDragStart} + onDragOver={(e) => handleDragOver(e, parentId, index)} + onDrop={(e) => handleDrop(e, parentId, index)} + /> + + {/* Unlinked option add buttons (decision nodes with unlinked options) */} + {!isExpanded && unlinkedOptions.length > 0 && ( +
+ {unlinkedOptions.map((opt) => { + const key = addKey(node.id, opt.id) + return ( +
+
+ + {opt.label || '(unlabeled option)'} + + {pendingAddKey === key ? ( + + handleAddNodeSelect(type, node.id, opt.id) + } + onCancel={() => setPendingAddKey(null)} + /> + ) : ( + setPendingAddKey(key)} + /> + )} +
+ ) + })} +
+ )} + + {/* Single add button for action nodes without children */} + {!isExpanded && showSingleAddButton && ( +
+
+ {pendingAddKey === node.id ? ( + handleAddNodeSelect(type, node.id)} + onCancel={() => setPendingAddKey(null)} + /> + ) : ( + setPendingAddKey(node.id)} + /> + )} +
+ )} + + {/* Connector + Children */} + {hasChildren && !isExpanded && ( +
+ {/* Trunk line from card down */} +
+ + {orderedChildren.length === 1 ? ( + // Single child: straight vertical +
+ {renderNode( + orderedChildren[0].child, + node.id, + orderedChildren[0].childIndex, + orderedChildren[0].optionLabel + )} +
+ ) : ( + // Multiple children: horizontal branching +
+ {/* Horizontal fork line */} +
+ + {/* Child lanes */} +
+ {orderedChildren.map(({ child, optionLabel: ol, childIndex }) => ( +
+ {/* Vertical stub into child lane */} +
+ {renderNode(child, node.id, childIndex, ol)} +
+ ))} +
+
+ )} +
+ )} +
+ ) + }, + [ + expandedNodeId, + newNodeIds, + dragOverTarget, + handleToggleExpand, + handleSave, + handleCancelNew, + handleDelete, + handleDuplicate, + handleDragStart, + handleDragOver, + handleDrop, + pendingAddKey, + handleAddNodeSelect, + ] + ) + + // ── Empty state ── + if (!treeStructure) { + return ( +
+
+
+ No tree structure. Start by saving a tree name. +
+
+
+ ) + } + + return ( +
+
+
+ {/* START badge above root */} +
+ START +
+
+ + {renderNode(treeStructure, null, 0)} +
+
+
+ ) +} + +export default TreeCanvas -- 2.49.1 From 79bf051666392a3486446a4949258048402575c7 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Tue, 17 Feb 2026 22:45:35 -0500 Subject: [PATCH 03/28] refactor: Update forms for inline safety, add MetadataSidePanel, update layout - NodeFormDecision: option reorder via onUpdate (no premature store writes) - NodePicker: add allowCreate prop (default true) to hide Create New options during inline canvas editing, preventing side-effect node creation - MetadataSidePanel: 320px right slide-in overlay wrapping TreeMetadataForm, closes on backdrop click, close button, and Escape key - TreeEditorLayout: Flow mode now renders full-width TreeCanvas + MetadataSidePanel overlay; Code mode unchanged (Monaco + preview split) Co-Authored-By: Claude Sonnet 4.6 --- .../tree-editor/MetadataSidePanel.tsx | 66 +++++++++++++++++++ .../tree-editor/NodeFormDecision.tsx | 9 ++- .../src/components/tree-editor/NodePicker.tsx | 20 ++++-- .../tree-editor/TreeEditorLayout.tsx | 38 +++++------ 4 files changed, 104 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/tree-editor/MetadataSidePanel.tsx diff --git a/frontend/src/components/tree-editor/MetadataSidePanel.tsx b/frontend/src/components/tree-editor/MetadataSidePanel.tsx new file mode 100644 index 00000000..25ebafb3 --- /dev/null +++ b/frontend/src/components/tree-editor/MetadataSidePanel.tsx @@ -0,0 +1,66 @@ +import { useEffect } from 'react' +import { X } from 'lucide-react' +import { TreeMetadataForm } from './TreeMetadataForm' + +interface MetadataSidePanelProps { + isOpen: boolean + onClose: () => void +} + +export function MetadataSidePanel({ isOpen, onClose }: MetadataSidePanelProps) { + // Close on Escape key + useEffect(() => { + if (!isOpen) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [isOpen, onClose]) + + if (!isOpen) return null + + return ( + <> + {/* Backdrop — click to close */} + + ) +} + +export default AnswerStubCard +``` + +**Step 2: Build to check for TS errors in the new file only** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas/frontend +npm run build 2>&1 | grep "AnswerStubCard" +``` + +Expected: No errors mentioning AnswerStubCard. + +**Step 3: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas +git add frontend/src/components/tree-editor/AnswerStubCard.tsx +git commit -m "feat: add AnswerStubCard component for unresolved branch placeholders + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 7: Update TreeCanvasNode to handle `'answer'` type + +**Files:** +- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx` + +The `NODE_TYPE_CONFIG` object (line 47) only has entries for `decision`, `action`, `solution`. When `node.type === 'answer'`, calling `NODE_TYPE_CONFIG[node.type]` will cause a TypeScript error and runtime crash. + +The fix: guard `config` access so answer nodes get a safe fallback. However, answer nodes should **never** be rendered by `TreeCanvasNode` — `TreeCanvas` will render them as `AnswerStubCard` instead. We still need to fix the TypeScript error. + +**Step 1: Guard the config lookup** + +Find around line 135: +```tsx +const config = NODE_TYPE_CONFIG[node.type] +const TypeIcon = config.icon +``` + +Change to: +```tsx +const config = node.type in NODE_TYPE_CONFIG + ? NODE_TYPE_CONFIG[node.type as keyof typeof NODE_TYPE_CONFIG] + : NODE_TYPE_CONFIG.decision // fallback for 'answer' type (should be rendered by AnswerStubCard instead) +const TypeIcon = config.icon +``` + +**Step 2: Build to confirm the TS error from Task 5 is now resolved** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas/frontend +npm run build 2>&1 | tail -20 +``` + +Expected: Clean build (zero errors). + +**Step 3: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas +git add frontend/src/components/tree-editor/TreeCanvasNode.tsx +git commit -m "fix: guard NODE_TYPE_CONFIG lookup against 'answer' type + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 8: Redesign NodeFormDecision to use answer labels only (no NodePicker) + +**Files:** +- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx` + +This is the biggest change in the plan. We replace the per-option NodePicker with a simple label-only input. The `next_node_id` field on each option is **preserved** in the data model but no longer set via the form — it gets wired up automatically in TreeCanvas when the user saves (Task 9). + +**Step 1: Remove the NodePicker import** + +Current line 3: +```tsx +import { NodePicker } from './NodePicker' +``` + +Remove this line entirely. + +**Step 2: Simplify handleAddOption — set next_node_id to empty string (not required by user)** + +The current `handleAddOption` (line 30–39) is fine as-is — it creates options with `next_node_id: ''`. Leave it unchanged. + +**Step 3: Replace the options renderItem to show only the label input** + +Find the `DynamicArrayField` renderItem (lines 156–209). Replace the entire `renderItem` prop with a simpler version: + +```tsx +renderItem={(option, index) => { + const optionLabelError = validationErrors.find( + e => e.nodeId === node.id && e.field === `options[${index}].label` + ) + const letter = indexToLetter(index) + + return ( +
+ {/* Letter badge */} + + {letter} + + handleUpdateOption(index, { label: e.target.value })} + placeholder={isRootNode + ? `Branch ${letter}: e.g., "Network Issues"...` + : `Option ${letter} label`} + className={cn( + 'block flex-1 rounded-md border px-3 py-2 text-sm', + 'bg-background text-foreground placeholder:text-muted-foreground', + 'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary', + optionLabelError ? 'border-red-400' : 'border-border' + )} + /> + {optionLabelError && ( +

{optionLabelError.message}

+ )} +
+ ) +}} +``` + +Note: The surrounding `
` wrapper from the old renderItem should also be removed — the new renderItem renders a flat row. + +**Step 4: Remove the optionNextError validation lookup** (it's no longer displayed) + +Find and remove: +```tsx +const optionNextError = validationErrors.find( + e => e.nodeId === node.id && e.field === `options[${index}].next_node_id` +) +``` + +**Step 5: Build** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas/frontend +npm run build 2>&1 | tail -20 +``` + +Expected: Clean build. If there's an unused import warning for `NodePicker` even after removal, double-check Step 1. + +**Step 6: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas +git add frontend/src/components/tree-editor/NodeFormDecision.tsx +git commit -m "feat: redesign NodeFormDecision to use answer label list (no NodePicker) + +Users now type answer labels only. Stub nodes are created automatically +by TreeCanvas when the decision node is saved. + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 9: Wire up answer stub creation and AnswerStubCard rendering in TreeCanvas + +**Files:** +- Modify: `frontend/src/components/tree-editor/TreeCanvas.tsx` + +Two changes: (1) when a decision node is saved, create answer stubs for any option without a `next_node_id`; (2) render `AnswerStubCard` for nodes with `type === 'answer'`. + +**Step 1: Import AnswerStubCard and add handleSelectAnswerType** + +At the top of the file, add the import after the existing `TreeCanvasNode` import: +```tsx +import { AnswerStubCard } from './AnswerStubCard' +``` + +**Step 2: Add a `handleSelectAnswerType` callback to the TreeCanvas component** + +After the `handleDuplicate` callback (around line 278), add: + +```tsx +// ── Convert answer stub to a real node type ── +const handleSelectAnswerType = useCallback( + (nodeId: string, type: 'decision' | 'action' | 'solution') => { + updateNode(nodeId, { type }) + setExpandedNodeId(nodeId) + selectNode(nodeId) + }, + [updateNode, selectNode] +) +``` + +**Step 3: Update handleSave to create answer stubs for unlinked options** + +Find `handleSave` (around line 202). After the existing `updateNode(nodeId, updates)` call but before the pending link resolution, add answer stub creation logic: + +The current `handleSave` starts: +```tsx +const handleSave = useCallback( + (nodeId: string, updates: Partial) => { + updateNode(nodeId, updates) + + // Resolve pending link for new nodes + const link = pendingLinks.get(nodeId) +``` + +Change to: +```tsx +const handleSave = useCallback( + (nodeId: string, updates: Partial) => { + updateNode(nodeId, updates) + + // For decision nodes: create answer stubs for any option without a next_node_id + if (updates.type === 'decision' || updates.options) { + const options = updates.options || [] + options.forEach((opt) => { + if (!opt.next_node_id && opt.label.trim()) { + // Create a new answer stub node under this decision node + const stubId = addNode(nodeId, 'answer') + // Give it the label as its title so AnswerStubCard can display it + updateNode(stubId, { title: opt.label }) + // Link the option to the stub + const updatedOptions = options.map((o) => + o.id === opt.id ? { ...o, next_node_id: stubId } : o + ) + updateNode(nodeId, { options: updatedOptions }) + } + }) + } + + // Resolve pending link for new nodes + const link = pendingLinks.get(nodeId) +``` + +**Step 4: Add `handleSelectAnswerType` to the renderNode dependency array** + +Find the `useCallback` dependency array at the end of `renderNode` (around line 580). Add `handleSelectAnswerType` to it: + +```tsx +[ + expandedNodeId, + newNodeIds, + dragOverTarget, + handleToggleExpand, + handleSave, + handleCancelNew, + handleDelete, + handleDuplicate, + handleDragStart, + handleDragOver, + handleDrop, + pendingAddKey, + handleAddNodeSelect, + handleSelectAnswerType, // ← add this +] +``` + +**Step 5: Render AnswerStubCard for answer-type nodes inside renderNode** + +Find the section in `renderNode` where `` is rendered (around line 468). Add a conditional before it: + +```tsx +{/* The node card — answer stubs get their own component */} +{node.type === 'answer' ? ( + +) : ( + handleToggleExpand(node.id)} + onSave={handleSave} + onCancelNew={handleCancelNew} + onDelete={handleDelete} + onDuplicate={handleDuplicate} + onDragStart={handleDragStart} + onDragOver={(e) => handleDragOver(e, parentId, index)} + onDrop={(e) => handleDrop(e, parentId, index)} + /> +)} +``` + +**Step 6: Build** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas/frontend +npm run build 2>&1 | tail -30 +``` + +Expected: Clean build. + +**Step 7: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas +git add frontend/src/components/tree-editor/TreeCanvas.tsx +git commit -m "feat: auto-create answer stubs on decision save, render AnswerStubCard + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 10: Update backend to allow `'answer'` type in drafts and block on publish + +**Files:** +- Modify: `backend/app/core/tree_validation.py` + +**Step 1: Allow `'answer'` type in `_validate_node` without structural validation** + +Find the `else` branch at the end of `_validate_node` (around line 92–96): +```python +else: + errors.append({ + "field": f"{path}.type", + "message": f"Unknown node type: {node_type}" + }) +``` + +Change to: +```python +elif node_type == "answer": + # Answer nodes are draft-only placeholders — no structural validation needed + pass +else: + errors.append({ + "field": f"{path}.type", + "message": f"Unknown node type: {node_type}" + }) +``` + +**Step 2: Add publish-time answer node check in `validate_tree_structure`** + +After the root node is validated and before returning, add a recursive check for answer nodes. + +Find the end of `validate_tree_structure` (around line 53–56): +```python + # Validate all child nodes recursively + if "children" in tree_structure: + _validate_children(tree_structure["children"], "root.children", errors) + + return len(errors) == 0, errors +``` + +Change to: +```python + # Validate all child nodes recursively + if "children" in tree_structure: + _validate_children(tree_structure["children"], "root.children", errors) + + # Block publish if any answer placeholder nodes remain + if _has_answer_nodes(tree_structure): + errors.append({ + "field": "tree_structure", + "message": "Answer placeholders must be resolved to a node type before publishing." + }) + + return len(errors) == 0, errors +``` + +**Step 3: Add the `_has_answer_nodes` helper function** + +Add this function after `_validate_children` (around line 115): + +```python +def _has_answer_nodes(node: dict[str, Any]) -> bool: + """Recursively check if any node in the tree has type 'answer'.""" + if node.get("type") == "answer": + return True + for child in node.get("children", []): + if _has_answer_nodes(child): + return True + return False +``` + +**Step 4: Verify the backend tests still pass** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas/backend +source venv/bin/activate 2>/dev/null || true +pytest tests/ -k "tree_valid" --override-ini="addopts=" -q 2>&1 | tail -20 +``` + +If no tests exist specifically for tree_validation, run the full suite: +```bash +pytest --override-ini="addopts=" -q 2>&1 | tail -20 +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas +git add backend/app/core/tree_validation.py +git commit -m "feat: allow 'answer' type in tree drafts, block on publish + +Draft saves succeed with answer placeholder nodes. Publish is blocked +with a clear message if any answer nodes remain unresolved. + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +### Task 11: Add frontend publish guard for answer nodes + +**Files:** +- Modify: `frontend/src/pages/TreeEditorPage.tsx` + +**Step 1: Add a `hasAnswerNodes` utility** + +At the top of `TreeEditorPage.tsx`, after the imports, add a small utility function (before the component function): + +```typescript +/** Recursively check if any node in the tree has type 'answer' */ +function hasAnswerNodes(node: TreeStructure): boolean { + if (node.type === 'answer') return true + return (node.children || []).some(hasAnswerNodes) +} +``` + +You'll need to ensure `TreeStructure` is imported — it should already be imported via `@/types`. + +**Step 2: Add the guard in `handlePublish`** + +Find `handlePublish` (around line 269). After the name check (around line 293) and before `validate()`, add: + +```typescript +// Block publish if any answer placeholder nodes remain +const currentStructure = useTreeEditorStore.getState().treeStructure +if (currentStructure && hasAnswerNodes(currentStructure)) { + toast.error('Resolve all answer placeholders before publishing. Click each dashed stub card to assign a type.') + setSaving(false) + return +} +``` + +**Step 3: Build** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas/frontend +npm run build 2>&1 | tail -20 +``` + +Expected: Clean build. + +**Step 4: Commit** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas +git add frontend/src/pages/TreeEditorPage.tsx +git commit -m "feat: block publish if unresolved answer stub nodes exist + +Co-Authored-By: Claude Sonnet 4.6 " +``` + +--- + +## Final Verification + +### Task 12: Full build and manual test checklist + +**Step 1: Run the full frontend build** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas/frontend +npm run build 2>&1 | tail -10 +``` + +Expected: `✓ built in Xs` with zero errors. + +**Step 2: Run backend tests** + +```bash +cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas/backend +pytest --override-ini="addopts=" -q 2>&1 | tail -10 +``` + +Expected: All tests pass. + +**Step 3: Manual test checklist (confirm with developer)** + +1. Open a troubleshooting tree in the canvas editor +2. Click a decision node → card expands +3. Resize the browser to a short viewport — form should scroll, sticky header (save/cancel) stays visible +4. Hover over the `i` badge next to field labels — tooltip text appears +5. Type answer labels in the Options section (e.g. "Server", "Desktop") → click ✓ to save +6. Two dashed stub cards appear below the decision node labeled "Server" and "Desktop" +7. Click "Server" stub → three type buttons appear (Decision / Action / Solution) +8. Click "Decision" → stub converts to a full Decision card in expanded editing mode +9. Save draft → no backend error (answer nodes allowed in drafts) +10. Leave an unresolved stub and click Publish → blocked with: "Resolve all answer placeholders before publishing." +11. `npm run build` passes with no TypeScript errors + +**Step 4: Complete the development branch** + +Use `superpowers:finishing-a-development-branch` to present merge/PR options. -- 2.49.1 From d295ae5d887140ae6ef655a4a0c3bedd0852784a Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 01:14:53 -0500 Subject: [PATCH 07/28] fix: make canvas card expanded area scrollable with sticky header Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/tree-editor/TreeCanvasNode.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/tree-editor/TreeCanvasNode.tsx b/frontend/src/components/tree-editor/TreeCanvasNode.tsx index 0dc99d6e..22f029f7 100644 --- a/frontend/src/components/tree-editor/TreeCanvasNode.tsx +++ b/frontend/src/components/tree-editor/TreeCanvasNode.tsx @@ -166,7 +166,8 @@ export function TreeCanvasNode({ className={cn( 'flex items-center gap-2 px-3 py-2.5', !isExpanded && 'cursor-pointer hover:bg-accent/50 rounded-t-xl', - !isExpanded && 'rounded-xl' + !isExpanded && 'rounded-xl', + isExpanded && 'sticky top-0 z-10 bg-card rounded-t-xl' )} onClick={!isExpanded ? handleCardClick : undefined} > @@ -322,7 +323,7 @@ export function TreeCanvasNode({ {/* Expanded editing area */} {isExpanded && ( -
+
{/* Validation errors */} {(hasError || hasWarning) && (
-- 2.49.1 From bee7f4545996a8a6e57789e1f58e5f277c023b42 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 01:16:10 -0500 Subject: [PATCH 08/28] feat: add fullscreen toggle to Modal, enable in NodeEditorModal Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/common/Modal.tsx | 70 ++++++++++++++----- .../tree-editor/NodeEditorModal.tsx | 2 +- 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/common/Modal.tsx b/frontend/src/components/common/Modal.tsx index 73cf54ad..f798e4bb 100644 --- a/frontend/src/components/common/Modal.tsx +++ b/frontend/src/components/common/Modal.tsx @@ -1,5 +1,5 @@ -import { useEffect, useCallback, type ReactNode } from 'react' -import { X } from 'lucide-react' +import { useState, useEffect, useCallback, type ReactNode } from 'react' +import { X, Maximize2, Minimize2 } from 'lucide-react' import { cn } from '@/lib/utils' interface ModalProps { @@ -10,9 +10,28 @@ interface ModalProps { /** Optional footer content that stays fixed at bottom (doesn't scroll) */ footer?: ReactNode size?: 'sm' | 'md' | 'lg' | 'xl' + /** If true, a fullscreen toggle button appears in the modal header */ + allowFullScreen?: boolean } -export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }: ModalProps) { +export function Modal({ isOpen, onClose, title, children, footer, size = 'md', allowFullScreen = false }: ModalProps) { + const [isFullScreen, setIsFullScreen] = useState(() => { + if (!allowFullScreen) return false + try { + return localStorage.getItem('rf-editor-fullscreen') === 'true' + } catch { + return false + } + }) + + const toggleFullScreen = () => { + const next = !isFullScreen + setIsFullScreen(next) + try { + localStorage.setItem('rf-editor-fullscreen', String(next)) + } catch {} + } + // Close on Escape key const handleKeyDown = useCallback( (e: KeyboardEvent) => { @@ -61,9 +80,13 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }:
{/* Header - Fixed at top */} @@ -71,17 +94,32 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }: - )} - aria-label="Close modal" - > - - + +
{/* Body - Scrollable */} diff --git a/frontend/src/components/tree-editor/NodeEditorModal.tsx b/frontend/src/components/tree-editor/NodeEditorModal.tsx index 95f55ef5..96fbc31e 100644 --- a/frontend/src/components/tree-editor/NodeEditorModal.tsx +++ b/frontend/src/components/tree-editor/NodeEditorModal.tsx @@ -83,7 +83,7 @@ export function NodeEditorModal({ node, onClose, isNewNode = false }: NodeEditor ) return ( - + {/* Node ID display */}
Node ID: {node.id} -- 2.49.1 From 335db4d1580702a3d0757cc5a95815dbef1414d1 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 01:17:05 -0500 Subject: [PATCH 09/28] feat: add reusable InfoTip component for field-level help Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/common/InfoTip.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 frontend/src/components/common/InfoTip.tsx diff --git a/frontend/src/components/common/InfoTip.tsx b/frontend/src/components/common/InfoTip.tsx new file mode 100644 index 00000000..ccaf448e --- /dev/null +++ b/frontend/src/components/common/InfoTip.tsx @@ -0,0 +1,14 @@ +interface InfoTipProps { + text: string +} + +export function InfoTip({ text }: InfoTipProps) { + return ( + + i + + ) +} -- 2.49.1 From d7580de07006770f5f9c17a082e4cd6f215bf6f3 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 01:21:07 -0500 Subject: [PATCH 10/28] fix: replace hint paragraphs with InfoTip tooltips in NodeFormDecision Co-Authored-By: Claude Sonnet 4.6 --- .../tree-editor/NodeFormDecision.tsx | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/tree-editor/NodeFormDecision.tsx b/frontend/src/components/tree-editor/NodeFormDecision.tsx index d7bbd0fd..7cd0d353 100644 --- a/frontend/src/components/tree-editor/NodeFormDecision.tsx +++ b/frontend/src/components/tree-editor/NodeFormDecision.tsx @@ -4,6 +4,7 @@ import { NodePicker } from './NodePicker' import { useTreeEditorStore } from '@/store/treeEditorStore' import type { TreeStructure, TreeOption } from '@/types' import { cn } from '@/lib/utils' +import { InfoTip } from '@/components/common/InfoTip' interface NodeFormDecisionProps { node: TreeStructure @@ -86,11 +87,6 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) { - {isRootNode && ( -

- What's the main question to diagnose the issue? -

- )} -