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 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-18 03:13:01 -05:00
parent 239be5017f
commit 8d8f557951
3 changed files with 83 additions and 14 deletions

View File

@@ -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<HTMLDivElement>(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 (
<div
ref={cardRef}
className={cn(
'relative min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50',
'transition-all duration-150',

View File

@@ -163,6 +163,7 @@ export function TreeCanvas() {
// ── Local canvas state ──
const [expandedNodeId, setExpandedNodeId] = useState<string | null>(null)
const [newNodeIds, setNewNodeIds] = useState<Set<string>>(new Set())
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
const [pendingAddKey, setPendingAddKey] = useState<string | null>(null)
const [pendingLinks, setPendingLinks] = useState<Map<string, PendingLink>>(
new Map()
@@ -204,26 +205,26 @@ export function TreeCanvas() {
(nodeId: string, updates: Partial<TreeStructure>) => {
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() {
</div>
)}
{/* Collapsed subtree pill */}
{hasChildren && !isExpanded && isSubtreeCollapsed && (
<div className="mt-3 flex flex-col items-center">
<div className="h-4 w-px bg-border" />
<button
type="button"
onClick={() => handleToggleSubtreeCollapse(node.id)}
className="rounded-full border border-dashed border-border bg-card px-3 py-1 text-[10px] text-muted-foreground font-label hover:border-primary/40 hover:text-foreground transition-colors"
>
{orderedChildren.length} node{orderedChildren.length !== 1 ? 's' : ''} hidden click to expand
</button>
</div>
)}
{/* Connector + Children */}
{hasChildren && !isExpanded && (
{hasChildren && !isExpanded && !isSubtreeCollapsed && (
<div className="mt-3 flex flex-col items-center w-full">
{/* Trunk line from card down */}
<div className="h-4 w-px bg-border" />
@@ -622,8 +652,10 @@ export function TreeCanvas() {
[
expandedNodeId,
newNodeIds,
collapsedNodeIds,
dragOverTarget,
handleToggleExpand,
handleToggleSubtreeCollapse,
handleSave,
handleCancelNew,
handleDelete,

View File

@@ -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<TreeStructure>) => 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({
</span>
)}
{/* Subtree collapse toggle — only in compact mode when node has children */}
{!isExpanded && hasChildren && onToggleSubtreeCollapse && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleSubtreeCollapse() }}
title={isSubtreeCollapsed ? 'Expand subtree' : 'Collapse subtree'}
className="rounded p-0.5 text-muted-foreground/50 hover:bg-accent hover:text-foreground shrink-0"
>
{isSubtreeCollapsed
? <ChevronsUpDown className="h-3.5 w-3.5" />
: <ChevronsDownUp className="h-3.5 w-3.5" />
}
</button>
)}
{/* Expand/collapse chevron */}
{!isExpanded ? (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />