From 8d8f5579517049342b0a55aa46d88e64fd617277 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Wed, 18 Feb 2026 03:13:01 -0500 Subject: [PATCH] fix+feat: blank options, stub card dismiss, collapsible subtrees - TreeCanvas: strip blank-label options on save so they don't generate stubs; also filter them from the unlinked-option add-button list - AnswerStubCard: collapse type-picker when clicking outside the card - TreeCanvasNode: add subtree collapse toggle button (ChevronsDownUp icon) visible in compact mode when the node has children - TreeCanvas: track collapsedNodeIds; hide subtree behind a clickable "N nodes hidden" pill when collapsed Co-Authored-By: Claude Sonnet 4.6 --- .../components/tree-editor/AnswerStubCard.tsx | 16 ++++- .../src/components/tree-editor/TreeCanvas.tsx | 58 ++++++++++++++----- .../components/tree-editor/TreeCanvasNode.tsx | 23 ++++++++ 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/tree-editor/AnswerStubCard.tsx b/frontend/src/components/tree-editor/AnswerStubCard.tsx index 1a630ad6..277e8aea 100644 --- a/frontend/src/components/tree-editor/AnswerStubCard.tsx +++ b/frontend/src/components/tree-editor/AnswerStubCard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useState, useRef, useEffect } from 'react' import { HelpCircle, Zap, CheckCircle, Trash2 } from 'lucide-react' import { cn } from '@/lib/utils' import type { TreeStructure } from '@/types' @@ -13,10 +13,24 @@ interface AnswerStubCardProps { export function AnswerStubCard({ node, fromOption, onSelectType, onDelete }: AnswerStubCardProps) { const [picking, setPicking] = useState(false) const [confirming, setConfirming] = useState(false) + const cardRef = useRef(null) const label = fromOption || node.title || 'Answer' + // Collapse picker when clicking outside the card + useEffect(() => { + if (!picking) return + const handleOutsideClick = (e: MouseEvent) => { + if (cardRef.current && !cardRef.current.contains(e.target as Node)) { + setPicking(false) + } + } + document.addEventListener('mousedown', handleOutsideClick) + return () => document.removeEventListener('mousedown', handleOutsideClick) + }, [picking]) + return (
(null) const [newNodeIds, setNewNodeIds] = useState>(new Set()) + const [collapsedNodeIds, setCollapsedNodeIds] = useState>(new Set()) const [pendingAddKey, setPendingAddKey] = useState(null) const [pendingLinks, setPendingLinks] = useState>( new Map() @@ -204,26 +205,26 @@ export function TreeCanvas() { (nodeId: string, updates: Partial) => { updateNode(nodeId, updates) - // For decision nodes: create answer stubs for any option without a next_node_id + // For decision nodes: strip blank options, then create answer stubs for any + // labelled option that doesn't yet have a linked child if (updates.options) { - const options = updates.options + const options = updates.options.filter((o) => o.label.trim()) const stubsToCreate: Array<{ opt: typeof options[number]; stubId: string }> = [] options.forEach((opt) => { - if (!opt.next_node_id && opt.label.trim()) { + if (!opt.next_node_id) { const stubId = addNode(nodeId, 'answer') updateNode(stubId, { title: opt.label }) stubsToCreate.push({ opt, stubId }) } }) - if (stubsToCreate.length > 0) { - const updatedOptions = options.map((o) => { - const stub = stubsToCreate.find((s) => s.opt.id === o.id) - return stub ? { ...o, next_node_id: stub.stubId } : o - }) - updateNode(nodeId, { options: updatedOptions }) - } + // Write back: filtered options + any newly assigned next_node_ids + const updatedOptions = options.map((o) => { + const stub = stubsToCreate.find((s) => s.opt.id === o.id) + return stub ? { ...o, next_node_id: stub.stubId } : o + }) + updateNode(nodeId, { options: updatedOptions }) } // Resolve pending link for new nodes @@ -301,6 +302,16 @@ export function TreeCanvas() { [duplicateNode] ) + // ── Subtree collapse toggle ── + const handleToggleSubtreeCollapse = useCallback((nodeId: string) => { + setCollapsedNodeIds((prev) => { + const next = new Set(prev) + if (next.has(nodeId)) next.delete(nodeId) + else next.add(nodeId) + return next + }) + }, []) + // ── Convert answer stub to a real node type ── const handleSelectAnswerType = useCallback( (nodeId: string, type: 'decision' | 'action' | 'solution') => { @@ -416,6 +427,7 @@ export function TreeCanvas() { ): React.ReactNode => { const isExpanded = expandedNodeId === node.id const isNew = newNodeIds.has(node.id) + const isSubtreeCollapsed = collapsedNodeIds.has(node.id) const nodeChildren = node.children || [] // For decision nodes, order children by option link order @@ -467,8 +479,9 @@ export function TreeCanvas() { node.type === 'decision' && node.options ? node.options.filter( (opt) => - !opt.next_node_id || - !nodeChildren.find((c) => c.id === opt.next_node_id) + opt.label.trim() && + (!opt.next_node_id || + !nodeChildren.find((c) => c.id === opt.next_node_id)) ) : [] @@ -512,7 +525,10 @@ export function TreeCanvas() { fromOption={optionLabel} isExpanded={isExpanded} isNew={isNew} + hasChildren={nodeChildren.length > 0} + isSubtreeCollapsed={isSubtreeCollapsed} onToggleExpand={() => handleToggleExpand(node.id)} + onToggleSubtreeCollapse={() => handleToggleSubtreeCollapse(node.id)} onSave={handleSave} onCancelNew={handleCancelNew} onDelete={handleDelete} @@ -571,8 +587,22 @@ export function TreeCanvas() {
)} + {/* Collapsed subtree pill */} + {hasChildren && !isExpanded && isSubtreeCollapsed && ( +
+
+ +
+ )} + {/* Connector + Children */} - {hasChildren && !isExpanded && ( + {hasChildren && !isExpanded && !isSubtreeCollapsed && (
{/* Trunk line from card down */}
@@ -622,8 +652,10 @@ export function TreeCanvas() { [ expandedNodeId, newNodeIds, + collapsedNodeIds, dragOverTarget, handleToggleExpand, + handleToggleSubtreeCollapse, handleSave, handleCancelNew, handleDelete, diff --git a/frontend/src/components/tree-editor/TreeCanvasNode.tsx b/frontend/src/components/tree-editor/TreeCanvasNode.tsx index 3a3cb005..692ba10f 100644 --- a/frontend/src/components/tree-editor/TreeCanvasNode.tsx +++ b/frontend/src/components/tree-editor/TreeCanvasNode.tsx @@ -13,6 +13,8 @@ import { AlertTriangle, ChevronDown, ChevronRight, + ChevronsDownUp, + ChevronsUpDown, } from 'lucide-react' import { useTreeEditorStore } from '@/store/treeEditorStore' import { NodeFormDecision } from './NodeFormDecision' @@ -27,7 +29,10 @@ interface TreeCanvasNodeProps { fromOption?: string isExpanded: boolean isNew: boolean + hasChildren?: boolean + isSubtreeCollapsed?: boolean onToggleExpand: () => void + onToggleSubtreeCollapse?: () => void onSave: (nodeId: string, updates: Partial) => void onCancelNew: (nodeId: string) => void onDelete: (nodeId: string) => void @@ -70,7 +75,10 @@ export function TreeCanvasNode({ fromOption, isExpanded, isNew, + hasChildren = false, + isSubtreeCollapsed = false, onToggleExpand, + onToggleSubtreeCollapse, onSave, onCancelNew, onDelete, @@ -262,6 +270,21 @@ export function TreeCanvasNode({ )} + {/* Subtree collapse toggle — only in compact mode when node has children */} + {!isExpanded && hasChildren && onToggleSubtreeCollapse && ( + + )} + {/* Expand/collapse chevron */} {!isExpanded ? (