6-phase, 16-task plan covering: canvas card scroll + fullscreen modal, InfoTip component + tooltip replacements in all 3 NodeForm components, answer stub type system (types → AnswerStubCard → TreeCanvas wiring → NodeList guard), backend draft/publish validation, markdown serializer compatibility, and session navigation defensive guard. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
38 KiB
Flow Editor UX Fixes Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Fix three UX problems in the flow editor — unreachable card content, noisy hint text, and forced child-type selection while naming answer options.
Architecture: Five phases in order: scrollability + fullscreen modal, reusable InfoTip component + tooltip replacements, answer stub type system (frontend types → new component → canvas wiring → NodeList guard), backend draft/publish validation, then markdown serializer and runtime navigation guard. Each phase builds on the previous and must produce a clean npm run build before the next begins.
Tech Stack: React 19, TypeScript, Tailwind CSS, Zustand (treeEditorStore), FastAPI (tree_validation.py), frontend/src/lib/treeMarkdownSync.ts
Working directory: /home/michaelchihlas/dev/patherly (main branch — this plan targets the main codebase, not the worktree, since the canvas code was already merged or will be)
Note on worktree vs main: If the
feature/tree-editor-canvasbranch has not yet been merged to main, run all frontend tasks in.worktrees/tree-editor-canvas/frontend/and all backend tasks in.worktrees/tree-editor-canvas/backend/. If it has been merged, use the repo root. Check withgit branch --show-currentat the start.
Phase 1: Scrollability + Fullscreen Editor
Task 1.1: Fix canvas inline card scroll (TreeCanvasNode)
Files:
- Modify:
frontend/src/components/tree-editor/TreeCanvasNode.tsx
Step 1: Make the card header sticky when expanded
Open the file. Find the card header <div> (around line 165) — it's the one with class flex items-center gap-2 px-3 py-2.5. It currently has a cn() call like this:
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'
)}
Add a sticky class when expanded:
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 && 'sticky top-0 z-10 bg-card rounded-t-xl'
)}
Step 2: Make the expanded editing area scrollable
Find the expanded content <div> (around line 324) — it's the one that appears under {isExpanded && (:
<div className="border-t border-border px-3 pb-3 pt-3">
Change it to:
<div className="border-t border-border px-3 pb-3 pt-3 max-h-[70vh] overflow-y-auto">
Step 3: Build
cd frontend && npm run build 2>&1 | tail -10
Expected: ✓ built in Xs with zero errors.
Step 4: Commit
git add frontend/src/components/tree-editor/TreeCanvasNode.tsx
git commit -m "fix: make canvas card expanded area scrollable with sticky header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 1.2: Add fullscreen toggle to Modal component
Files:
- Modify:
frontend/src/components/common/Modal.tsx - Modify:
frontend/src/components/tree-editor/NodeEditorModal.tsx
Step 1: Update Modal.tsx
The current Modal.tsx is ~103 lines. The ModalProps interface (lines 5–13) and the component signature (line 15) need a new allowFullScreen optional prop.
Replace the entire Modal.tsx content with the following (it's short enough to replace in full to be safe):
import { useState, useEffect, useCallback, type ReactNode } from 'react'
import { X, Maximize2, Minimize2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: ReactNode
/** 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', 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) => {
if (e.key === 'Escape') {
onClose()
}
},
[onClose]
)
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [isOpen, handleKeyDown])
if (!isOpen) return null
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-full sm:max-w-lg',
xl: 'max-w-full sm:max-w-4xl',
}
return (
<div
className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal Content */}
<div
className={cn(
'relative flex w-full flex-col border border-border bg-card shadow-lg',
'animate-scale-in transition-all duration-200',
isFullScreen
? 'fixed inset-4 max-w-none w-auto h-auto rounded-2xl'
: cn(
'max-h-[100vh] rounded-t-2xl sm:max-h-[85vh] sm:rounded-2xl',
sizeClasses[size]
)
)}
>
{/* Header - Fixed at top */}
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4">
<h2 id="modal-title" className="text-lg font-semibold text-foreground">
{title}
</h2>
<div className="flex items-center gap-1">
{allowFullScreen && (
<button
type="button"
onClick={toggleFullScreen}
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
title={isFullScreen ? 'Exit full screen' : 'Full screen'}
>
{isFullScreen
? <Minimize2 className="h-4 w-4" />
: <Maximize2 className="h-4 w-4" />
}
</button>
)}
<button
onClick={onClose}
className={cn(
'rounded-md p-1.5 text-muted-foreground transition-colors sm:p-1',
'hover:bg-accent hover:text-foreground',
'focus:outline-none focus:ring-2 focus:ring-primary/20'
)}
aria-label="Close modal"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Body - Scrollable */}
<div className="flex-1 overflow-y-auto px-4 py-4 sm:px-6">
{children}
</div>
{/* Footer - Fixed at bottom */}
{footer && (
<div className="flex-shrink-0 border-t border-border px-4 py-3 sm:px-6 sm:py-4">
{footer}
</div>
)}
</div>
</div>
)
}
export default Modal
Step 2: Pass allowFullScreen to NodeEditorModal
In frontend/src/components/tree-editor/NodeEditorModal.tsx, find line 86:
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent}>
Change to:
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent} allowFullScreen={true}>
Step 3: Build
cd frontend && npm run build 2>&1 | tail -10
Expected: Clean build, zero errors.
Step 4: Commit
git add frontend/src/components/common/Modal.tsx frontend/src/components/tree-editor/NodeEditorModal.tsx
git commit -m "feat: add fullscreen toggle to Modal, enable in NodeEditorModal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Phase 2: Info-On-Demand Tooltips
Task 2.1: Create the InfoTip component
Files:
- Create:
frontend/src/components/common/InfoTip.tsx
Step 1: Create the file
interface InfoTipProps {
text: string
}
export function InfoTip({ text }: InfoTipProps) {
return (
<span
className="inline-flex items-center justify-center h-3.5 w-3.5 rounded-full border border-muted-foreground/40 text-[9px] text-muted-foreground cursor-help shrink-0"
title={text}
>
i
</span>
)
}
Step 2: Build
cd frontend && npm run build 2>&1 | tail -10
Expected: Clean build.
Step 3: Commit
git add frontend/src/components/common/InfoTip.tsx
git commit -m "feat: add reusable InfoTip component for field-level help
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 2.2: Replace hint text in NodeFormDecision
Files:
- Modify:
frontend/src/components/tree-editor/NodeFormDecision.tsx
Step 1: Add the InfoTip import
After the existing imports at the top of the file, add:
import { InfoTip } from '@/components/common/InfoTip'
Step 2: Remove the root node question hint paragraph
Around line 89–93 there is:
{isRootNode && (
<p className="mt-0.5 text-xs text-muted-foreground">
What's the main question to diagnose the issue?
</p>
)}
Delete this entire block. The input placeholder "e.g., What type of issue are you experiencing?" already conveys the intent.
Step 3: Replace the options hint paragraphs with an InfoTip on the label
Around lines 133–144, the Options section label and hints look like:
<label className="block text-sm font-medium text-foreground">
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-red-400">*</span>
</label>
{isRootNode ? (
<p className="mt-0.5 text-xs text-muted-foreground">
Add as many options as needed (A, B, C, D...). Each option leads to a completely different troubleshooting path.
</p>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
Each option can branch to a different next step.
</p>
)}
Replace with:
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-red-400">*</span>
<InfoTip text={isRootNode
? "Add as many options as needed (A, B, C, D...). Each option leads to a different troubleshooting path."
: "Each option can branch to a different next step."} />
</label>
Step 4: Build
cd frontend && npm run build 2>&1 | tail -10
Expected: Clean build.
Step 5: Commit
git add frontend/src/components/tree-editor/NodeFormDecision.tsx
git commit -m "fix: replace hint paragraphs with InfoTip tooltips in NodeFormDecision
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 2.3: Replace hint text in NodeFormAction
Files:
- Modify:
frontend/src/components/tree-editor/NodeFormAction.tsx
Step 1: Add the InfoTip import
Add at the top after existing imports:
import { InfoTip } from '@/components/common/InfoTip'
Step 2: Replace the Description hint paragraph
Around lines 91–93:
<p className="mb-1 text-xs text-muted-foreground">
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
</p>
And the Description label above it (around line 77–79):
<label className="block text-sm font-medium text-foreground">
Description
</label>
Replace both with (combine label + infotip, remove paragraph):
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
Description
<InfoTip text="Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`" />
</label>
Step 3: Replace the Commands hint paragraph
Around lines 124–126:
<p className="mb-2 text-xs text-muted-foreground">
PowerShell or CLI commands to execute
</p>
And the Commands label above it:
<label className="block text-sm font-medium text-foreground">
Commands
</label>
Replace with:
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
Commands
<InfoTip text="PowerShell or CLI commands to execute" />
</label>
Step 4: Build
cd frontend && npm run build 2>&1 | tail -10
Expected: Clean build.
Step 5: Commit
git add frontend/src/components/tree-editor/NodeFormAction.tsx
git commit -m "fix: replace hint paragraphs with InfoTip tooltips in NodeFormAction
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 2.4: Replace hint text in NodeFormResolution
Files:
- Modify:
frontend/src/components/tree-editor/NodeFormResolution.tsx
Step 1: Add the InfoTip import
import { InfoTip } from '@/components/common/InfoTip'
Step 2: Replace the Description hint paragraph
Around lines 86–88 (same markdown hint as NodeFormAction). Replace:
<label className="block text-sm font-medium text-foreground">
Description
</label>
<p className="mb-1 text-xs text-muted-foreground">
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
</p>
With:
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
Description
<InfoTip text="Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`" />
</label>
Step 3: Replace the Resolution Steps hint paragraph
Around lines 118–120:
<label className="block text-sm font-medium text-foreground">
Resolution Steps
</label>
<p className="mb-2 text-xs text-muted-foreground">
Step-by-step instructions for resolving the issue
</p>
Replace with:
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
Resolution Steps
<InfoTip text="Step-by-step instructions for resolving the issue" />
</label>
Step 4: Build
cd frontend && npm run build 2>&1 | tail -10
Expected: Clean build.
Step 5: Commit
git add frontend/src/components/tree-editor/NodeFormResolution.tsx
git commit -m "fix: replace hint paragraphs with InfoTip tooltips in NodeFormResolution
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Phase 3: Answer Stub Placeholder System
Task 3.1: Add 'answer' to the NodeType union
Files:
- Modify:
frontend/src/types/tree.ts:4
Step 1: Edit the NodeType line
Find line 4:
export type NodeType = 'decision' | 'action' | 'solution'
Change to:
export type NodeType = 'decision' | 'action' | 'solution' | 'answer'
Step 2: Run build — note the expected error
cd frontend && npm run build 2>&1 | grep "error TS" | head -10
Expected: You will see TypeScript errors in TreeCanvasNode.tsx (and possibly NodeList.tsx) because their Record<NodeType, ...> maps don't include 'answer'. This is expected and will be fixed in Tasks 3.3 and 3.6.
Step 3: Commit the type change now (before fixing downstream errors)
git add frontend/src/types/tree.ts
git commit -m "feat: add 'answer' to NodeType union for branch placeholder stubs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 3.2: Create the AnswerStubCard component
Files:
- Create:
frontend/src/components/tree-editor/AnswerStubCard.tsx
Step 1: Create the file
import { useState } from 'react'
import { HelpCircle, Zap, CheckCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { TreeStructure } from '@/types'
interface AnswerStubCardProps {
node: TreeStructure // type === 'answer'
fromOption?: string
onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
}
export function AnswerStubCard({ node, fromOption, onSelectType }: AnswerStubCardProps) {
const [picking, setPicking] = useState(false)
const label = fromOption || node.title || 'Answer'
return (
<div
className={cn(
'min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50',
'transition-all duration-150',
!picking && 'cursor-pointer hover:border-primary/40 hover:bg-accent/30'
)}
onClick={() => !picking && setPicking(true)}
>
{/* Label */}
<div className="px-3 pt-2.5 pb-1 text-sm font-heading font-medium text-foreground text-center">
{label}
</div>
{/* Prompt / type picker */}
{!picking ? (
<div className="pb-2.5 text-center text-[10px] text-muted-foreground font-label">
+ Choose Type
</div>
) : (
<div className="flex items-center justify-center gap-1.5 pb-2.5 px-2">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'decision') }}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label',
'border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20'
)}
>
<HelpCircle className="h-2.5 w-2.5" /> Decision
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'action') }}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label',
'border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20'
)}
>
<Zap className="h-2.5 w-2.5" /> Action
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'solution') }}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label',
'border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20'
)}
>
<CheckCircle className="h-2.5 w-2.5" /> Solution
</button>
</div>
)}
</div>
)
}
export default AnswerStubCard
Step 2: Build to confirm no errors in this new file
cd frontend && npm run build 2>&1 | grep "AnswerStubCard"
Expected: No errors mentioning AnswerStubCard (the earlier TreeCanvasNode.tsx errors from Task 3.1 are still present, that's fine).
Step 3: Commit
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 <noreply@anthropic.com>"
Task 3.3: Guard TreeCanvasNode against 'answer' type
Files:
- Modify:
frontend/src/components/tree-editor/TreeCanvasNode.tsx
Step 1: Fix the NODE_TYPE_CONFIG lookup
Find (around line 135):
const config = NODE_TYPE_CONFIG[node.type]
const TypeIcon = config.icon
Replace with:
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' (rendered by AnswerStubCard)
const TypeIcon = config.icon
Step 2: Build — confirm the TS error from Task 3.1 is now gone
cd frontend && npm run build 2>&1 | grep "TreeCanvasNode"
Expected: No errors mentioning TreeCanvasNode.
Step 3: Confirm full clean build (NodeList errors may still exist)
cd frontend && npm run build 2>&1 | grep "error TS" | head -5
Note: NodeList errors will be fixed in Task 3.6. Only TreeCanvasNode should be clean now.
Step 4: Commit
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 <noreply@anthropic.com>"
Task 3.4: Redesign NodeFormDecision — label-only options (remove NodePicker)
Files:
- Modify:
frontend/src/components/tree-editor/NodeFormDecision.tsx
The old form had a NodePicker per option that forced users to pick a child node type during the same editing session as writing the question. The new form is label-only — stubs are created automatically on save.
Step 1: Remove the NodePicker import
Find and delete:
import { NodePicker } from './NodePicker'
Step 2: Replace the DynamicArrayField renderItem
Find the existing renderItem prop inside <DynamicArrayField>. The current version renders a box with a letter badge + label input + NodePicker. Replace the entire renderItem callback with:
renderItem={(option, index) => {
const optionLabelError = validationErrors.find(
e => e.nodeId === node.id && e.field === `options[${index}].label`
)
const letter = indexToLetter(index)
return (
<div className="flex items-center gap-2">
<span className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold',
isRootNode ? 'bg-blue-500/20 text-blue-400' : 'bg-accent text-muted-foreground'
)}>
{letter}
</span>
<div className="flex-1">
<input
type="text"
value={option.label}
onChange={(e) => handleUpdateOption(index, { label: e.target.value })}
placeholder={isRootNode
? `Branch ${letter}: e.g., "Network Issues"...`
: `Option ${letter} label`}
className={cn(
'block w-full 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 && (
<p className="mt-1 text-xs text-red-400">{optionLabelError.message}</p>
)}
</div>
</div>
)
}}
Step 3: Remove the optionNextError validation lookup (it referenced options[N].next_node_id, no longer needed since there's no NodePicker)
Inside the old renderItem, there was:
const optionNextError = validationErrors.find(
e => e.nodeId === node.id && e.field === `options[${index}].next_node_id`
)
This is now gone (it was inside the old renderItem you just replaced). Verify there are no remaining references.
Step 4: Build
cd frontend && npm run build 2>&1 | grep -E "NodeFormDecision|NodePicker" | head -10
Expected: No errors. If you see "Cannot find module './NodePicker'" that means the import wasn't fully removed — double-check Step 1.
Step 5: Commit
git add frontend/src/components/tree-editor/NodeFormDecision.tsx
git commit -m "feat: redesign NodeFormDecision to label-only options, remove 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 <noreply@anthropic.com>"
Task 3.5: Wire up auto-creation and AnswerStubCard rendering in TreeCanvas
Files:
- Modify:
frontend/src/components/tree-editor/TreeCanvas.tsx
Step 1: Add the AnswerStubCard import
After the existing TreeCanvasNode import, add:
import { AnswerStubCard } from './AnswerStubCard'
Step 2: Add handleSelectAnswerType callback
After the handleDuplicate callback (around line 278), add:
// ── 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 auto-create stubs for unlinked options
Find handleSave (around line 202). It currently starts:
const handleSave = useCallback(
(nodeId: string, updates: Partial<TreeStructure>) => {
updateNode(nodeId, updates)
// Resolve pending link for new nodes
const link = pendingLinks.get(nodeId)
After updateNode(nodeId, updates) and before the pending link resolution, insert:
// For decision nodes: create answer stubs for any option without a next_node_id
if (updates.options) {
const options = updates.options
const stubsToCreate: Array<{ opt: typeof options[number]; stubId: string }> = []
options.forEach((opt) => {
if (!opt.next_node_id && opt.label.trim()) {
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 })
}
}
Why this shape: We build the list of stubs first, then do a single
updateNodewith the fully updated options array, to avoid multiple sequential calls stomping on each other.
Step 4: Add handleSelectAnswerType to the renderNode dependency array
Find the useCallback dependency array at the end of renderNode (around line 580–594). Add handleSelectAnswerType to the array.
Step 5: Render AnswerStubCard for answer-type nodes in renderNode
In renderNode, find where <TreeCanvasNode> is rendered (it's the card component, around line 468). Wrap it with a conditional:
Replace:
{/* The node card itself */}
<TreeCanvasNode
node={node}
...
/>
With:
{/* The node card — answer stubs get their own component */}
{node.type === 'answer' ? (
<AnswerStubCard
node={node}
fromOption={optionLabel}
onSelectType={handleSelectAnswerType}
/>
) : (
<TreeCanvasNode
node={node}
depth={0}
fromOption={optionLabel}
isExpanded={isExpanded}
isNew={isNew}
onToggleExpand={() => 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
cd frontend && npm run build 2>&1 | tail -15
Expected: Clean build, zero errors (NodeList may still have errors — check next task).
Step 7: Commit
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 <noreply@anthropic.com>"
Task 3.6: Guard NodeList against 'answer' type
Files:
- Modify:
frontend/src/components/tree-editor/NodeList.tsx
The nodeTypeIcons and nodeTypeColors objects (lines 91–101) use Record<NodeType, ...> which now requires an 'answer' entry.
Step 1: Add 'answer' entries to both records
Find:
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
decision: <HelpCircle className="h-4 w-4" />,
action: <Zap className="h-4 w-4" />,
solution: <CheckCircle className="h-4 w-4" />
}
const nodeTypeColors: Record<NodeType, string> = {
decision: 'bg-blue-500/20 text-blue-400',
action: 'bg-yellow-500/20 text-yellow-400',
solution: 'bg-green-500/20 text-green-400'
}
Replace with:
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
decision: <HelpCircle className="h-4 w-4" />,
action: <Zap className="h-4 w-4" />,
solution: <CheckCircle className="h-4 w-4" />,
answer: <HelpCircle className="h-4 w-4 opacity-50" />
}
const nodeTypeColors: Record<NodeType, string> = {
decision: 'bg-blue-500/20 text-blue-400',
action: 'bg-yellow-500/20 text-yellow-400',
solution: 'bg-green-500/20 text-green-400',
answer: 'bg-muted text-muted-foreground border border-dashed border-border'
}
Step 2: Build — confirm full clean build
cd frontend && npm run build 2>&1 | tail -10
Expected: ✓ built in Xs — zero TypeScript errors.
Step 3: Commit
git add frontend/src/components/tree-editor/NodeList.tsx
git commit -m "fix: add answer type to NodeList icon and color maps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Phase 4: Backend + Frontend Validation
Task 4.1: Backend — allow 'answer' in drafts, block on publish
Files:
- Modify:
backend/app/core/tree_validation.py
Step 1: Add the 'answer' elif in _validate_node
Find the _validate_node function. Inside it, find the else branch at the end (around lines 92–96):
else:
errors.append({
"field": f"{path}.type",
"message": f"Unknown node type: {node_type}"
})
Insert a new elif before the else:
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 the _has_answer_nodes helper
After the _validate_children function (ends around line 115), add a new function:
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 3: Add publish-time check in validate_tree_structure
Find validate_tree_structure. After the _validate_children call and before return len(errors) == 0, errors (around line 53–56):
# 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:
# 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 4: Run backend tests
cd backend
pytest --override-ini="addopts=" -q 2>&1 | tail -15
Expected: All existing tests pass. No new failures.
Step 5: Commit
git add backend/app/core/tree_validation.py
git commit -m "feat: allow 'answer' type in tree drafts, block on publish
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 4.2: Frontend publish guard
Files:
- Modify:
frontend/src/pages/TreeEditorPage.tsx
Step 1: Add utility function before the component
Find the component declaration (export function TreeEditorPage or similar). Immediately before it, add:
/** 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)
}
Ensure TreeStructure is imported from @/types — check the existing imports at the top of the file (it should already be there).
Step 2: Add the guard in handlePublish
Find handlePublish (around line 269). It starts with a code-mode markdown validation check. After the tree name check (around line 293, after if (!currentState.name.trim()) {...}) and before const errors = validate(), insert:
// 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
cd frontend && npm run build 2>&1 | tail -10
Expected: Clean build.
Step 4: Commit
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 <noreply@anthropic.com>"
Phase 5: Markdown Serializer + Runtime Guard
Task 5.1: Handle 'answer' in the markdown serializer
Files:
- Modify:
frontend/src/lib/treeMarkdownSync.ts
Step 1: Locate the serializeNode function
In treeMarkdownSync.ts, find serializeNode. It has a chain of if (node.type === 'decision') ... else if (node.type === 'action') ... else if (node.type === 'solution'). After the final else if (around line 75–81), add an else if for 'answer':
} else if (node.type === 'answer') {
// Answer placeholder — render as a clearly marked stub
body.push(`## [ANSWER PLACEHOLDER] ${node.title || 'Untitled'}`, '')
body.push('> This is an unresolved answer stub. Convert it to a Decision, Action, or Solution before publishing.')
}
Step 2: Build
cd frontend && npm run build 2>&1 | tail -10
Expected: Clean build.
Step 3: Commit
git add frontend/src/lib/treeMarkdownSync.ts
git commit -m "feat: serialize 'answer' stub nodes in markdown output
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 5.2: Add runtime defensive guard in TreeNavigationPage
Files:
- Modify:
frontend/src/pages/TreeNavigationPage.tsx
Step 1: Find the "Current Node" rendering block
Around line 758–760 there is a comment {/* Current Node */} followed by a <div>. Inside this <div>, node type is dispatched via conditionals:
{currentNode && currentNode.type === 'decision' && (
...
)}
Before any of these existing conditionals (before the decision block), add a guard for 'answer' nodes:
{currentNode && currentNode.type === 'answer' && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-6 text-center">
<p className="text-sm font-medium text-yellow-400">
This tree contains an unresolved placeholder node. Please contact the tree author to complete it before use.
</p>
</div>
)}
Step 2: Build
cd frontend && npm run build 2>&1 | tail -10
Expected: Clean build.
Step 3: Commit
git add frontend/src/pages/TreeNavigationPage.tsx
git commit -m "fix: add defensive guard for answer nodes in session navigation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Phase 6: Final Verification
Task 6.1: Full build and backend test suite
Step 1: Frontend build
cd frontend && npm run build 2>&1 | tail -5
Expected: ✓ built in Xs — zero errors.
Step 2: Backend tests
cd backend && pytest --override-ini="addopts=" -q 2>&1 | tail -10
Expected: All existing tests pass.
Task 6.2: Manual test checklist
Confirm all of the following in the browser:
- Canvas scroll — Open a decision node in the canvas editor → resize browser to a short viewport → form content scrolls → sticky header (save/cancel) stays visible at top
- Modal scroll — Open a node via the modal editor (
NodeEditorModal) → content scrolls, header and footer are fixed - Fullscreen toggle — Click the expand icon in the modal header → modal fills viewport with margin → click again → returns to normal size smoothly → refresh → preference is remembered
- Other modals unaffected — Open any other modal (step library, share session, etc.) → no fullscreen button appears
- InfoTip tooltips — Hover over
ⓘbadges on NodeFormDecision / NodeFormAction / NodeFormResolution labels → tooltip text appears → no always-visible hint paragraphs remain - Answer stubs — creation — Create or edit a decision node → type a question → type answer labels ("Server", "Desktop") → save → two dashed stub cards appear below the decision
- Answer stubs — conversion — Click a dashed stub → three type buttons appear (Decision / Action / Solution) → click one → stub converts to a real node card in expanded editing mode
- Draft save with stubs — Save draft with unresolved stubs → no backend error
- Publish blocked — Leave an unresolved stub → click Publish → toast: "Resolve all answer placeholders before publishing."
- Publish succeeds after resolution — Convert all stubs → Publish → succeeds
Summary of All Files Changed
New Files
| File | Description |
|---|---|
frontend/src/components/common/InfoTip.tsx |
Reusable info tooltip badge |
frontend/src/components/tree-editor/AnswerStubCard.tsx |
Dashed stub card with inline type picker |
Modified Files
| File | Changes |
|---|---|
frontend/src/components/tree-editor/TreeCanvasNode.tsx |
Sticky header + scrollable area + answer type guard |
frontend/src/components/common/Modal.tsx |
allowFullScreen prop + toggle button + localStorage |
frontend/src/components/tree-editor/NodeEditorModal.tsx |
Pass allowFullScreen={true} |
frontend/src/components/common/InfoTip.tsx |
(new) |
frontend/src/components/tree-editor/NodeFormDecision.tsx |
InfoTip tooltips + label-only options |
frontend/src/components/tree-editor/NodeFormAction.tsx |
InfoTip tooltips |
frontend/src/components/tree-editor/NodeFormResolution.tsx |
InfoTip tooltips |
frontend/src/types/tree.ts |
Add 'answer' to NodeType union |
frontend/src/components/tree-editor/TreeCanvas.tsx |
Auto-create stubs + AnswerStubCard rendering |
frontend/src/components/tree-editor/NodeList.tsx |
Add answer type to icon/color maps |
frontend/src/pages/TreeEditorPage.tsx |
Publish guard |
frontend/src/pages/TreeNavigationPage.tsx |
Runtime guard for answer nodes |
frontend/src/lib/treeMarkdownSync.ts |
Serialize answer nodes |
backend/app/core/tree_validation.py |
Allow answer in drafts, block on publish |