` (currently `border-t border-border px-3 pb-3 pt-3`)
+ - Add `sticky top-0 z-10 bg-card` to the card header `
` containing the save/cancel row when in expanded state
+
+**No other files affected.**
+
+---
+
+## Fix 2: Info Tooltips
+
+### Problem
+
+`NodeFormDecision.tsx`, `NodeFormAction.tsx`, and `NodeFormResolution.tsx` each contain `
` hint paragraphs below field labels. These add vertical height and visual noise inside a card that's already compact.
+
+Examples of the current hint text:
+- "Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, \`code\`"
+- "PowerShell or CLI commands to execute"
+- "Step-by-step instructions for resolving the issue"
+
+### Design
+
+Replace each hint `
` with a small `ⓘ` icon placed inline next to the field label. The icon shows a tooltip on hover containing the same text.
+
+**Tooltip implementation:**
+
+Use `title=""` on the icon element for a native browser tooltip. No third-party tooltip library needed — keeps the implementation minimal and consistent with the existing codebase pattern (the validation badge already uses `title={nodeErrors.map(...).join('\n')}`).
+
+```tsx
+// Before
+
+ Description
+
+
+ Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
+
+
+// After
+
+ Description
+
+ i
+
+
+```
+
+**Files changed:**
+- `frontend/src/components/tree-editor/NodeFormDecision.tsx` — remove help_text hint `
`, replace with `ⓘ` on `Help Text` label
+- `frontend/src/components/tree-editor/NodeFormAction.tsx` — remove description markdown hint `
` and commands hint `
`, add `ⓘ` on those labels
+- `frontend/src/components/tree-editor/NodeFormResolution.tsx` — remove description markdown hint `
` and steps hint `
`, add `ⓘ` on those labels
+
+---
+
+## Fix 3: Answer Stubs (New `answer` Node Type)
+
+### Problem
+
+Decision nodes require the user to pick child node types at the same time they're creating the decision. This is backwards — you naturally know the answer options before you know what each one should do. The NodePicker in NodeFormDecision forces a concrete type selection (decision / action / solution) or leaves the option disconnected (`next_node_id: null`).
+
+Users want to type answer labels first, see those answers appear as placeholder cards in the canvas, and then click each placeholder to assign a type and fill in details.
+
+### Design
+
+Introduce `'answer'` as a new internal NodeType that represents a typed-but-unresolved branch placeholder. Answer nodes are:
+- Created when a user types an answer label in the decision node form
+- Shown in the canvas as a dashed-border stub card with the answer label
+- Clickable to open a type picker (decision / action / solution) and convert to a real node
+- **Not publishable** — blocked by backend and frontend validation on publish
+
+Answer nodes persist to draft saves so users don't lose their sketch when they navigate away.
+
+### Data Model
+
+**`frontend/src/types/tree.ts`**
+
+```typescript
+// Before
+export type NodeType = 'decision' | 'action' | 'solution'
+
+// After
+export type NodeType = 'decision' | 'action' | 'solution' | 'answer'
+```
+
+`TreeStructure` interface: no new fields needed. Answer nodes use:
+- `id`: auto-generated UUID (same as other nodes)
+- `type`: `'answer'`
+- `title`: the answer label text (e.g. "Server", "Desktop")
+- No other fields required
+
+### NodeFormDecision Redesign
+
+Replace the current options UI (NodePicker per option → picks existing or creates new) with a two-zone layout:
+
+**Zone 1 — Answer Labels**
+A simple list of text inputs, one per answer option. Each input edits `options[i].label`. Add/remove/reorder via `DynamicArrayField` (already available).
+
+No `next_node_id` selection here. When the user saves, for each option that has a label but no `next_node_id`, a new answer-type stub node is created and linked automatically.
+
+```
+Options (answer labels):
+[ Server ] [×]
+[ Desktop ] [×]
+[ + Add Answer ]
+```
+
+**Zone 2 — (removed) NodePicker per option**
+The per-option NodePicker is removed entirely. The canvas becomes the way to traverse to a child and set its type.
+
+### TreeCanvas Changes
+
+**Rendering answer nodes:**
+
+When a node has `type === 'answer'`, render an `AnswerStubCard` instead of a full `TreeCanvasNode`. The stub card:
+- Dashed border: `border-2 border-dashed border-border`
+- Colored left accent: none (neutral/muted)
+- Shows the answer label (`node.title`) centered
+- Shows a "+ Choose Type" label below the title
+- On click: opens an inline type picker (3 buttons: Decision / Action / Solution)
+- On type selection: calls `updateNode(node.id, { type: selectedType })` and immediately expands the node for editing
+
+**New component:** `frontend/src/components/tree-editor/AnswerStubCard.tsx`
+
+Props:
+```typescript
+interface AnswerStubCardProps {
+ node: TreeStructure // type === 'answer'
+ fromOption?: string // the answer label (same as node.title)
+ onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
+}
+```
+
+### Stub Creation Logic (TreeCanvas)
+
+When a decision node is saved (`onSave`), the canvas compares options before/after:
+
+For each option in the saved node:
+- If `option.next_node_id` is null/undefined → create a new answer stub node with `title = option.label` and link `option.next_node_id` to its ID.
+- If `option.next_node_id` already points to a node → leave it.
+
+This creation logic lives in `TreeCanvas.tsx`'s `handleNodeSave()` function, which already handles pending link resolution.
+
+### Backend Validation
+
+**`backend/app/core/tree_validation.py`**
+
+`_validate_node()` currently rejects unknown node types:
+```python
+if node_type not in ('decision', 'action', 'solution'):
+ errors.append(...)
+```
+
+Changes:
+1. Allow `'answer'` type through without structural validation (answer nodes have no required fields beyond `id` and `type`).
+2. Add a publish-time check in `can_publish_tree()` (or in `validate_tree_structure()` before publish): if any node has `type == 'answer'`, reject with a clear message: `"Answer placeholders must be resolved to a node type before publishing."`
+3. The draft save endpoint (`PUT /trees/:id`) does not call `can_publish_tree()`, so draft saves continue to work with answer nodes present.
+
+### Frontend Publish Guard
+
+In `TreeEditorPage.tsx`, before calling the publish API, add a check:
+```typescript
+const hasAnswerNodes = findAllAnswerNodes(tree.tree_structure).length > 0
+if (hasAnswerNodes) {
+ // Show toast or inline error: "Resolve all answer placeholders before publishing."
+ return
+}
+```
+
+`findAllAnswerNodes` is a simple recursive traversal (can be a small utility function in `TreeCanvas.tsx` or a new file `lib/treeUtils.ts`).
+
+### Visual Design (AnswerStubCard)
+
+```
+┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
+ Server
+ [ ? Decision ] [ ⚡ Action ] [ ✓ Solution ]
+└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
+```
+
+- Card: `min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50`
+- Title: `text-sm font-heading font-medium text-foreground text-center py-2 px-3`
+- Type picker row (default state — not yet clicked): `text-xs text-muted-foreground text-center pb-2 cursor-pointer hover:text-foreground`
+ - Clicking the card reveals three compact buttons for type selection
+- Type picker (expanded): three small buttons side-by-side in the card footer
+
+---
+
+## Files Changed Summary
+
+| File | Change |
+|------|--------|
+| `frontend/src/components/tree-editor/TreeCanvasNode.tsx` | Fix 1: max-h + overflow-y + sticky header |
+| `frontend/src/components/tree-editor/NodeFormDecision.tsx` | Fix 2: ⓘ tooltip on help_text; Fix 3: replace NodePicker with answer label list |
+| `frontend/src/components/tree-editor/NodeFormAction.tsx` | Fix 2: ⓘ tooltips on description + commands fields |
+| `frontend/src/components/tree-editor/NodeFormResolution.tsx` | Fix 2: ⓘ tooltips on description + steps fields |
+| `frontend/src/components/tree-editor/TreeCanvas.tsx` | Fix 3: stub creation in handleNodeSave; AnswerStubCard rendering |
+| `frontend/src/components/tree-editor/AnswerStubCard.tsx` | Fix 3: NEW — dashed stub card with inline type picker |
+| `frontend/src/types/tree.ts` | Fix 3: add `'answer'` to NodeType union |
+| `backend/app/core/tree_validation.py` | Fix 3: allow 'answer' in draft; block on publish |
+| `frontend/src/pages/TreeEditorPage.tsx` | Fix 3: frontend publish guard |
+
+---
+
+## Non-Goals
+
+- No changes to session navigation, procedural flows, or maintenance flows
+- No changes to the Code mode editor
+- No changes to `treeEditorStore.ts` store actions (addNode, updateNode, deleteNode are used as-is)
+- No third-party tooltip library
+- No new backend endpoints
+
+---
+
+## Verification
+
+1. Open a troubleshooting tree in the canvas editor
+2. Click a decision node → card expands, form is scrollable with sticky save/cancel header
+3. Field labels show `ⓘ` icons; hovering reveals the hint text
+4. Type answer labels in the Options section; click ✓ to save
+5. Answer stub cards appear as dashed cards below the decision node
+6. Click a stub card → type picker appears; select "Decision" → card converts and expands for editing
+7. Draft save works with answer nodes present (no backend error)
+8. Attempt to publish with unresolved answer nodes → blocked with a clear error message
+9. `npm run build` passes with no TypeScript errors
diff --git a/docs/plans/2026-02-18-canvas-ux-fixes-impl.md b/docs/plans/2026-02-18-canvas-ux-fixes-impl.md
new file mode 100644
index 00000000..a070a5bd
--- /dev/null
+++ b/docs/plans/2026-02-18-canvas-ux-fixes-impl.md
@@ -0,0 +1,1003 @@
+# Canvas 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 TreeCanvas editor: card scroll, noisy hint text, and forced child-type selection when building decision nodes.
+
+**Architecture:** Three independent fixes applied to the canvas editor components only. Fix 1 is a pure CSS change. Fix 2 replaces `
` hint text with native `title` tooltips on ⓘ badges. Fix 3 introduces a new `'answer'` NodeType — a branch placeholder that the user converts to a real type by clicking it.
+
+**Tech Stack:** React 19, TypeScript, Tailwind CSS, Zustand (existing `treeEditorStore`), FastAPI backend (`tree_validation.py`)
+
+**Working directory:** `/home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas`
+
+---
+
+## Fix 1: Card Scroll
+
+### Task 1: Make expanded card area scrollable with sticky header
+
+**Files:**
+- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx:164-321`
+
+**Step 1: Open the file and locate the card header div (expanded state)**
+
+The card header is the `
` at line 165. When expanded it shows the action buttons (save/cancel/etc). We need this row to be sticky.
+
+Find this block (around line 165):
+```tsx
+
+```
+
+Change it to:
+```tsx
+
+```
+
+**Step 2: Make the expanded editing area scrollable**
+
+Find the expanded editing area div (around line 324):
+```tsx
+{isExpanded && (
+
+```
+
+Change it to:
+```tsx
+{isExpanded && (
+
+```
+
+**Step 3: Build and verify no TypeScript errors**
+
+```bash
+cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas/frontend
+npm run build 2>&1 | tail -20
+```
+
+Expected: Build exits with code 0, no errors mentioning TreeCanvasNode.
+
+**Step 4: Commit**
+
+```bash
+cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas
+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
"
+```
+
+---
+
+## Fix 2: Info Tooltips
+
+### Task 2: Replace hint text in NodeFormDecision
+
+**Files:**
+- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
+
+The ⓘ badge pattern to use throughout Fix 2:
+```tsx
+
+ i
+
+```
+
+**Step 1: Find the root node hint paragraph inside the Question field**
+
+Around line 89–93:
+```tsx
+{isRootNode && (
+
+ What's the main question to diagnose the issue?
+
+)}
+```
+
+Remove this `` block entirely. The input's placeholder already conveys the intent.
+
+**Step 2: Find the options hint paragraphs**
+
+Around lines 136–143:
+```tsx
+{isRootNode ? (
+
+ Add as many options as needed (A, B, C, D...). Each option leads to a completely different troubleshooting path.
+
+) : (
+
+ Each option can branch to a different next step.
+
+)}
+```
+
+Replace both `` tags with a ⓘ tooltip on the Options label. Change the label section (around line 133) from:
+```tsx
+
+ {isRootNode ? 'Answer Options (Branches)' : 'Options'} *
+
+{isRootNode ? (
+
+ Add as many options as needed (A, B, C, D...). Each option leads to a completely different troubleshooting path.
+
+) : (
+
+ Each option can branch to a different next step.
+
+)}
+```
+
+To:
+```tsx
+
+ {isRootNode ? 'Answer Options (Branches)' : 'Options'} *
+
+ i
+
+
+```
+
+**Step 3: Build to check for TS errors**
+
+```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/components/tree-editor/NodeFormDecision.tsx
+git commit -m "fix: replace hint paragraphs with info tooltips in NodeFormDecision
+
+Co-Authored-By: Claude Sonnet 4.6 "
+```
+
+---
+
+### Task 3: Replace hint text in NodeFormAction
+
+**Files:**
+- Modify: `frontend/src/components/tree-editor/NodeFormAction.tsx`
+
+**Step 1: Find the description hint paragraph**
+
+Around lines 91–93:
+```tsx
+
+ Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
+
+```
+
+Change the Description label + remove the hint:
+```tsx
+// Before
+
+ Description
+
+
+ Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
+
+
+// After
+
+ Description
+
+ i
+
+
+```
+
+**Step 2: Find the commands hint paragraph**
+
+Around lines 124–126:
+```tsx
+
+ PowerShell or CLI commands to execute
+
+```
+
+Change the Commands label + remove the hint:
+```tsx
+// Before
+
+ Commands
+
+
+ PowerShell or CLI commands to execute
+
+
+// After
+
+ Commands
+
+ i
+
+
+```
+
+**Step 3: Build to check for TS errors**
+
+```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/components/tree-editor/NodeFormAction.tsx
+git commit -m "fix: replace hint paragraphs with info tooltips in NodeFormAction
+
+Co-Authored-By: Claude Sonnet 4.6 "
+```
+
+---
+
+### Task 4: Replace hint text in NodeFormResolution
+
+**Files:**
+- Modify: `frontend/src/components/tree-editor/NodeFormResolution.tsx`
+
+**Step 1: Find the description hint paragraph**
+
+Around lines 86–88:
+```tsx
+
+ Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
+
+```
+
+Change the Description label + remove the hint:
+```tsx
+// Before
+
+ Description
+
+
+ Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
+
+
+// After
+
+ Description
+
+ i
+
+
+```
+
+**Step 2: Find the resolution steps hint paragraph**
+
+Around lines 118–120:
+```tsx
+
+ Step-by-step instructions for resolving the issue
+
+```
+
+Change the Resolution Steps label + remove the hint:
+```tsx
+// Before
+
+ Resolution Steps
+
+
+ Step-by-step instructions for resolving the issue
+
+
+// After
+
+ Resolution Steps
+
+ i
+
+
+```
+
+**Step 3: Build to check for TS errors**
+
+```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/components/tree-editor/NodeFormResolution.tsx
+git commit -m "fix: replace hint paragraphs with info tooltips in NodeFormResolution
+
+Co-Authored-By: Claude Sonnet 4.6 "
+```
+
+---
+
+## Fix 3: Answer Stubs
+
+### Task 5: Add `'answer'` to the NodeType union
+
+**Files:**
+- Modify: `frontend/src/types/tree.ts:4`
+
+**Step 1: Add `'answer'` to NodeType**
+
+Current line 4:
+```typescript
+export type NodeType = 'decision' | 'action' | 'solution'
+```
+
+Change to:
+```typescript
+export type NodeType = 'decision' | 'action' | 'solution' | 'answer'
+```
+
+**Step 2: Check for TS exhaustiveness errors**
+
+```bash
+cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas/frontend
+npm run build 2>&1 | grep -E "error TS|Type.*answer"
+```
+
+The build will likely show an error in `TreeCanvasNode.tsx` because `NODE_TYPE_CONFIG` only has keys for `decision`, `action`, `solution` — and `config = NODE_TYPE_CONFIG[node.type]` will fail when `node.type === 'answer'`. We fix that in Task 7. For now note the exact error and proceed.
+
+**Step 3: Commit the type change**
+
+```bash
+cd /home/michaelchihlas/dev/patherly/.worktrees/tree-editor-canvas
+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 "
+```
+
+---
+
+### Task 6: Create the AnswerStubCard component
+
+**Files:**
+- Create: `frontend/src/components/tree-editor/AnswerStubCard.tsx`
+
+**Step 1: Create the file with the following content**
+
+```tsx
+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 (
+ !picking && setPicking(true)}
+ >
+ {/* Label */}
+
+ {label}
+
+
+ {/* Prompt / type picker */}
+ {!picking ? (
+
+ + Choose Type
+
+ ) : (
+
+ {
+ 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'
+ )}
+ >
+
+ Decision
+
+
+ {
+ 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'
+ )}
+ >
+
+ Action
+
+
+ {
+ 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'
+ )}
+ >
+
+ Solution
+
+
+ )}
+
+ )
+}
+
+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.
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
+
+ )
+}
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' }:
{title}
-
+ {allowFullScreen && (
+
+ {isFullScreen
+ ?
+ :
+ }
+
)}
- aria-label="Close modal"
- >
-
-
+
+
+
+
{/* Body - Scrollable */}
diff --git a/frontend/src/components/session/ContinuationModal.tsx b/frontend/src/components/session/ContinuationModal.tsx
index b6b4a8f0..3e594890 100644
--- a/frontend/src/components/session/ContinuationModal.tsx
+++ b/frontend/src/components/session/ContinuationModal.tsx
@@ -21,13 +21,15 @@ interface ContinuationModalProps {
const nodeTypeIcons: Record = {
decision: ,
action: ,
- solution:
+ solution: ,
+ answer:
}
const nodeTypeLabels: Record = {
decision: 'Decision',
action: 'Action',
- solution: 'Solution'
+ solution: 'Solution',
+ answer: 'Answer'
}
export function ContinuationModal({
diff --git a/frontend/src/components/tree-editor/AnswerStubCard.tsx b/frontend/src/components/tree-editor/AnswerStubCard.tsx
new file mode 100644
index 00000000..277e8aea
--- /dev/null
+++ b/frontend/src/components/tree-editor/AnswerStubCard.tsx
@@ -0,0 +1,121 @@
+import { useState, useRef, useEffect } from 'react'
+import { HelpCircle, Zap, CheckCircle, Trash2 } 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
+ onDelete: (nodeId: string) => void
+}
+
+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 (
+ !picking && !confirming && setPicking(true)}
+ >
+ {/* Delete button — top-right corner */}
+ {!picking && !confirming && (
+
{ e.stopPropagation(); setConfirming(true) }}
+ className="absolute top-1.5 right-1.5 rounded p-0.5 text-muted-foreground/40 hover:bg-red-500/10 hover:text-red-400 transition-colors"
+ title="Delete stub"
+ >
+
+
+ )}
+
+ {/* Label */}
+
+ {label}
+
+
+ {/* Confirm delete */}
+ {confirming ? (
+
+
Delete this stub?
+
+ { e.stopPropagation(); onDelete(node.id) }}
+ className="rounded-md px-2 py-1 text-[10px] font-label border border-red-500/30 bg-red-500/10 text-red-400 hover:bg-red-500/20"
+ >
+ Delete
+
+ { e.stopPropagation(); setConfirming(false) }}
+ className="rounded-md px-2 py-1 text-[10px] font-label border border-border text-muted-foreground hover:bg-accent"
+ >
+ Cancel
+
+
+
+ ) : !picking ? (
+
+ + Choose Type
+
+ ) : (
+
+ { 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'
+ )}
+ >
+ Decision
+
+ { 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'
+ )}
+ >
+ Action
+
+ { 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'
+ )}
+ >
+ Solution
+
+
+ )}
+
+ )
+}
+
+export default AnswerStubCard
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 */}
+
+
+ {/* Side panel — slides in from right */}
+
+ {/* Panel header */}
+
+
+ Flow Details
+
+
+
+
+
+
+ {/* Scrollable metadata form */}
+
+
+
+
+ >
+ )
+}
+
+export default MetadataSidePanel
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}
diff --git a/frontend/src/components/tree-editor/NodeFormAction.tsx b/frontend/src/components/tree-editor/NodeFormAction.tsx
index 1d37d80f..90fcd1cf 100644
--- a/frontend/src/components/tree-editor/NodeFormAction.tsx
+++ b/frontend/src/components/tree-editor/NodeFormAction.tsx
@@ -3,6 +3,7 @@ import { DynamicArrayField } from './DynamicArrayField'
import { NodePicker } from './NodePicker'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
+import { InfoTip } from '@/components/common/InfoTip'
import type { TreeStructure } from '@/types'
import { cn } from '@/lib/utils'
@@ -75,8 +76,9 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
{/* Description */}
-
+
Description
+
{node.description && (
)}
-
- Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
-
{showPreview && node.description ? (
@@ -118,12 +117,10 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
{/* Commands */}
-
+
Commands
+
-
- PowerShell or CLI commands to execute
-
{
}
export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
- const { reorderOptions, validationErrors } = useTreeEditorStore()
+ const { validationErrors } = useTreeEditorStore()
const isRootNode = node.id === 'root'
+ // Track input elements by index so we can focus the newly added one
+ const inputRefs = useRef>(new Map())
+ const shouldFocusLast = useRef(false)
const questionError = validationErrors.find(
e => e.nodeId === node.id && e.field === 'question'
@@ -27,6 +31,15 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
e => e.nodeId === node.id && e.field === 'options'
)
+ // After options array grows (due to keyboard-triggered add), focus the last input
+ useEffect(() => {
+ if (shouldFocusLast.current) {
+ shouldFocusLast.current = false
+ const lastIndex = (node.options?.length ?? 1) - 1
+ inputRefs.current.get(lastIndex)?.focus()
+ }
+ }, [node.options?.length])
+
const handleAddOption = () => {
const newOption: TreeOption = {
id: crypto.randomUUID(),
@@ -38,6 +51,12 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
})
}
+ // Add a new option and focus it (used by keyboard shortcut)
+ const handleAddOptionAndFocus = () => {
+ shouldFocusLast.current = true
+ handleAddOption()
+ }
+
const handleRemoveOption = (index: number) => {
const newOptions = [...(node.options || [])]
newOptions.splice(index, 1)
@@ -51,7 +70,12 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
}
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
- reorderOptions(node.id, fromIndex, toIndex)
+ // Mutate local draft via onUpdate (backward-compatible: modal path relays to store,
+ // canvas path updates local draft without writing to store early)
+ const newOptions = [...(node.options || [])]
+ const [moved] = newOptions.splice(fromIndex, 1)
+ newOptions.splice(toIndex, 0, moved)
+ onUpdate({ options: newOptions })
}
return (
@@ -81,11 +105,6 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
{isRootNode ? 'Starting Question' : 'Question'} *
- {isRootNode && (
-
- What's the main question to diagnose the issue?
-
- )}
-
+
{isRootNode ? 'Answer Options (Branches)' : 'Options'} *
+
- {isRootNode ? (
-
- Add as many options as needed (A, B, C, D...). Each option leads to a completely different troubleshooting path.
-
- ) : (
-
- Each option can branch to a different next step.
-
- )}
{optionsError && (
{optionsError.message}
)}
@@ -152,52 +165,45 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
const optionLabelError = validationErrors.find(
e => e.nodeId === node.id && e.field === `options[${index}].label`
)
- const optionNextError = validationErrors.find(
- e => e.nodeId === node.id && e.field === `options[${index}].next_node_id`
- )
const letter = indexToLetter(index)
+ const isLastOption = index === (node.options?.length ?? 1) - 1
return (
-
-
- {/* Letter badge */}
-
- {letter}
-
+
+
+ {letter}
+
+
{
+ if (el) inputRefs.current.set(index, el)
+ else inputRefs.current.delete(index)
+ }}
type="text"
value={option.label}
onChange={(e) => handleUpdateOption(index, { label: e.target.value })}
placeholder={isRootNode
- ? `Branch ${letter}: e.g., "Network Issues", "Application Errors"...`
+ ? `Branch ${letter}: e.g., "Network Issues"...`
: `Option ${letter} label`}
+ onKeyDown={(e) => {
+ if ((e.key === 'Tab' || e.key === 'Enter') && isLastOption && option.label.trim()) {
+ e.preventDefault()
+ handleAddOptionAndFocus()
+ }
+ }}
className={cn(
- 'block flex-1 rounded-md border px-3 py-2 text-sm',
+ '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 && (
-
{optionLabelError.message}
- )}
-
-
handleUpdateOption(index, { next_node_id: nodeId })}
- parentNodeId={node.id}
- excludeNodeId={node.id}
- placeholder={isRootNode
- ? `What happens when user selects "${option.label || `Branch ${letter}`}"?`
- : "Select or create next node..."}
- error={optionNextError?.message}
- />
+ {optionLabelError && (
+ {optionLabelError.message}
+ )}
)
diff --git a/frontend/src/components/tree-editor/NodeFormResolution.tsx b/frontend/src/components/tree-editor/NodeFormResolution.tsx
index e24e7df9..2e1503e1 100644
--- a/frontend/src/components/tree-editor/NodeFormResolution.tsx
+++ b/frontend/src/components/tree-editor/NodeFormResolution.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { DynamicArrayField } from './DynamicArrayField'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
+import { InfoTip } from '@/components/common/InfoTip'
import type { TreeStructure } from '@/types'
import { cn } from '@/lib/utils'
@@ -70,8 +71,9 @@ export function NodeFormResolution({ node, onUpdate }: NodeFormResolutionProps)
{/* Description */}
-
+
Description
+
{node.description && (
)}
-
- Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
-
{showPreview && node.description ? (
@@ -112,12 +111,10 @@ Document what was done and the outcome.
{/* Resolution Steps */}
-
+
Resolution Steps
+
-
- Step-by-step instructions for resolving the issue
-
= {
decision: ,
action: ,
- solution:
+ solution: ,
+ answer:
}
const nodeTypeColors: Record = {
decision: 'bg-blue-500/20 text-blue-400',
action: 'bg-yellow-500/20 text-yellow-400',
- solution: 'bg-green-500/20 text-green-400'
+ solution: 'bg-green-500/20 text-green-400',
+ answer: 'bg-muted text-muted-foreground border border-dashed border-border'
}
const getNodeLabel = () => {
diff --git a/frontend/src/components/tree-editor/NodePicker.tsx b/frontend/src/components/tree-editor/NodePicker.tsx
index 0ea8bd7a..7710120c 100644
--- a/frontend/src/components/tree-editor/NodePicker.tsx
+++ b/frontend/src/components/tree-editor/NodePicker.tsx
@@ -13,14 +13,16 @@ const CREATE_SOLUTION = `${CREATE_PREFIX}solution__`
const NODE_TYPE_SYMBOLS: Record = {
decision: '\u24D8', // Information/question symbol
action: '\u26A1', // Lightning bolt for action
- solution: '\u2713' // Checkmark for solution
+ solution: '\u2713', // Checkmark for solution
+ answer: '\u25CC' // Dashed circle for placeholder
}
// Node type labels for UI
const NODE_TYPE_LABELS: Record = {
decision: 'Decision',
action: 'Action',
- solution: 'Solution'
+ solution: 'Solution',
+ answer: 'Answer'
}
interface NodePickerProps {
@@ -35,6 +37,9 @@ interface NodePickerProps {
error?: string
/** Callback when a new node is created (receives the new node ID) */
onNodeCreated?: (nodeId: string) => void
+ /** Whether to show the "Create New Node" options. Default: true.
+ * Set to false in inline canvas editing to prevent premature store writes. */
+ allowCreate?: boolean
}
export function NodePicker({
@@ -46,7 +51,8 @@ export function NodePicker({
className,
label,
error,
- onNodeCreated
+ onNodeCreated,
+ allowCreate = true
}: NodePickerProps) {
const { getAvailableTargetNodes, addNode, updateNode } = useTreeEditorStore()
const availableNodes = getAvailableTargetNodes(excludeNodeId)
@@ -201,12 +207,14 @@ export function NodePicker({
>
{placeholder}
- {/* Create new options */}
-
- + New Decision (question)
- + New Action (task)
- + New Solution (endpoint)
-
+ {/* Create new options — hidden when allowCreate=false (e.g. canvas inline editing) */}
+ {allowCreate && (
+
+ + New Decision (question)
+ + New Action (task)
+ + New Solution (endpoint)
+
+ )}
{/* Existing nodes grouped by type */}
{groupedNodes.decisions.length > 0 && (
diff --git a/frontend/src/components/tree-editor/TreeCanvas.tsx b/frontend/src/components/tree-editor/TreeCanvas.tsx
new file mode 100644
index 00000000..a89cfd25
--- /dev/null
+++ b/frontend/src/components/tree-editor/TreeCanvas.tsx
@@ -0,0 +1,713 @@
+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 { AnswerStubCard } from './AnswerStubCard'
+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:
+
+ onSelect('decision')}
+ className={cn(
+ 'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-label',
+ 'border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20'
+ )}
+ >
+
+ Decision
+
+
+ onSelect('action')}
+ className={cn(
+ 'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-label',
+ 'border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20'
+ )}
+ >
+
+ Action
+
+
+ onSelect('solution')}
+ className={cn(
+ 'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-label',
+ 'border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20'
+ )}
+ >
+
+ Solution
+
+
+
+
+
+
+ )
+}
+
+// ─── Add-node trigger button ──────────────────────────────────────────────────
+
+interface AddNodeButtonProps {
+ label?: string
+ onClick: () => void
+}
+
+function AddNodeButton({ label = 'Add node', onClick }: AddNodeButtonProps) {
+ return (
+
+
+ {label}
+
+ )
+}
+
+// ─── 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 [collapsedNodeIds, setCollapsedNodeIds] = 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)
+
+ // 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.filter((o) => o.label.trim())
+ const stubsToCreate: Array<{ opt: typeof options[number]; stubId: string }> = []
+
+ options.forEach((opt) => {
+ if (!opt.next_node_id) {
+ const stubId = addNode(nodeId, 'answer')
+ updateNode(stubId, { title: opt.label })
+ stubsToCreate.push({ opt, stubId })
+ }
+ })
+
+ // 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
+ 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]
+ )
+
+ // ── 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') => {
+ updateNode(nodeId, { type })
+ setExpandedNodeId(nodeId)
+ selectNode(nodeId)
+ },
+ [updateNode, selectNode]
+ )
+
+ // ── 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 isSubtreeCollapsed = collapsedNodeIds.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.label.trim() &&
+ (!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 — answer stubs get their own component */}
+ {node.type === 'answer' ? (
+
+ ) : (
+
0}
+ isSubtreeCollapsed={isSubtreeCollapsed}
+ onToggleExpand={() => handleToggleExpand(node.id)}
+ onToggleSubtreeCollapse={() => handleToggleSubtreeCollapse(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)}
+ />
+ )}
+
+ )}
+
+ {/* Collapsed subtree pill */}
+ {hasChildren && !isExpanded && isSubtreeCollapsed && (
+
+
+
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
+
+
+ )}
+
+ {/* Connector + Children */}
+ {hasChildren && !isExpanded && !isSubtreeCollapsed && (
+
+ {/* 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
+ // The fork line and child lanes share the same flex container so the
+ // line is sized by the actual rendered children, not a hardcoded estimate.
+
+ {/* Horizontal fork line — absolutely positioned, aligned to child centers.
+ Spans from center of first lane to center of last lane. */}
+
+
+ {orderedChildren.map(({ child, optionLabel: ol, childIndex }) => (
+
+ {/* Vertical stub into child lane */}
+
+ {renderNode(child, node.id, childIndex, ol)}
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+ },
+ [
+ expandedNodeId,
+ newNodeIds,
+ collapsedNodeIds,
+ dragOverTarget,
+ handleToggleExpand,
+ handleToggleSubtreeCollapse,
+ handleSave,
+ handleCancelNew,
+ handleDelete,
+ handleDuplicate,
+ handleSelectAnswerType,
+ 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
diff --git a/frontend/src/components/tree-editor/TreeCanvasNode.tsx b/frontend/src/components/tree-editor/TreeCanvasNode.tsx
new file mode 100644
index 00000000..692ba10f
--- /dev/null
+++ b/frontend/src/components/tree-editor/TreeCanvasNode.tsx
@@ -0,0 +1,400 @@
+import { useState, useCallback, useEffect } from 'react'
+import {
+ HelpCircle,
+ Zap,
+ CheckCircle,
+ Play,
+ Check,
+ X,
+ Copy,
+ Trash2,
+ GripVertical,
+ AlertCircle,
+ AlertTriangle,
+ ChevronDown,
+ ChevronRight,
+ ChevronsDownUp,
+ ChevronsUpDown,
+} 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
+ hasChildren?: boolean
+ isSubtreeCollapsed?: boolean
+ onToggleExpand: () => void
+ onToggleSubtreeCollapse?: () => 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,
+ hasChildren = false,
+ isSubtreeCollapsed = false,
+ onToggleExpand,
+ onToggleSubtreeCollapse,
+ 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 ID changes (e.g. navigating between nodes)
+ const [lastNodeId, setLastNodeId] = useState(node.id)
+ if (node.id !== lastNodeId) {
+ setDraft(cloneNodeWithoutChildren(node))
+ setLastNodeId(node.id)
+ }
+
+ // Re-sync draft from store whenever the card is opened, so stale next_node_id
+ // values (written back after stub creation) don't cause duplicate stubs on re-save
+ useEffect(() => {
+ if (isExpanded) {
+ setDraft(cloneNodeWithoutChildren(node))
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [isExpanded])
+
+ 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 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
+
+ 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
+
+ )}
+
+ {/* Subtree collapse toggle — only in compact mode when node has children */}
+ {!isExpanded && hasChildren && onToggleSubtreeCollapse && (
+
{ 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
+ ?
+ :
+ }
+
+ )}
+
+ {/* Expand/collapse chevron */}
+ {!isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {/* Editing action buttons (expanded state) */}
+ {isExpanded && (
+
+ {/* New badge */}
+ {isNew && (
+
+ Unsaved
+
+ )}
+
+ {/* Duplicate (hide for root) */}
+ {!isRoot && (
+ {
+ e.stopPropagation()
+ onDuplicate(node.id)
+ }}
+ title="Duplicate node"
+ className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
+ >
+
+
+ )}
+
+ {/* Delete (hide for root) */}
+ {!isRoot && (
+ {
+ e.stopPropagation()
+ onDelete(node.id)
+ }}
+ title="Delete node"
+ className="rounded p-1 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
+ >
+
+
+ )}
+
+ {/* 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
diff --git a/frontend/src/components/tree-editor/TreeEditorLayout.tsx b/frontend/src/components/tree-editor/TreeEditorLayout.tsx
index cb2f9510..7b87ef56 100644
--- a/frontend/src/components/tree-editor/TreeEditorLayout.tsx
+++ b/frontend/src/components/tree-editor/TreeEditorLayout.tsx
@@ -1,7 +1,7 @@
import { lazy, Suspense } from 'react'
-import { TreeMetadataForm } from './TreeMetadataForm'
-import { NodeList } from './NodeList'
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
+import { TreeCanvas } from './TreeCanvas'
+import { MetadataSidePanel } from './MetadataSidePanel'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { cn } from '@/lib/utils'
@@ -12,9 +12,15 @@ const CodeModeEditor = lazy(() =>
interface TreeEditorLayoutProps {
isMobile?: boolean
+ isMetadataOpen?: boolean
+ onCloseMetadata?: () => void
}
-export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
+export function TreeEditorLayout({
+ isMobile = false,
+ isMetadataOpen = false,
+ onCloseMetadata = () => {},
+}: TreeEditorLayoutProps) {
const editorMode = useTreeEditorStore(s => s.editorMode)
return (
@@ -26,7 +32,7 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
>
{editorMode === 'code' ? (
<>
- {/* Code Mode: Monaco editor (60%) + Preview (40%) */}
+ {/* Code Mode: Monaco editor (60%) + Preview (40%) — unchanged */}
) : (
<>
- {/* Flow Mode: Form editor (60%) + Preview (40%) */}
-
-
-
-
-
+ {/* Flow Mode: Full-width visual canvas */}
+
+
- {/* Right Panel - Preview */}
-
-
-
+ {/* Metadata side panel — overlays the canvas from the right */}
+
>
)}
diff --git a/frontend/src/components/tree-editor/TreeMetadataForm.tsx b/frontend/src/components/tree-editor/TreeMetadataForm.tsx
index 01e3a818..2bacbcb1 100644
--- a/frontend/src/components/tree-editor/TreeMetadataForm.tsx
+++ b/frontend/src/components/tree-editor/TreeMetadataForm.tsx
@@ -132,19 +132,20 @@ export function TreeMetadataForm() {
onChange={(e) => setCategory(e.target.value)}
placeholder="Enter new category"
className={cn(
- 'block flex-1 rounded-md border border-border px-3 py-2 text-sm',
+ 'block min-w-0 flex-1 rounded-md border border-border px-3 py-2 text-sm',
'bg-card text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
)}
autoFocus
/>
{
setCustomCategory(false)
setCategory('')
setCategoryId(null)
}}
- className="rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
+ className="shrink-0 rounded-md border border-border px-2.5 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
diff --git a/frontend/src/components/tree-preview/TreePreviewNode.tsx b/frontend/src/components/tree-preview/TreePreviewNode.tsx
index 05acb78f..0837426d 100644
--- a/frontend/src/components/tree-preview/TreePreviewNode.tsx
+++ b/frontend/src/components/tree-preview/TreePreviewNode.tsx
@@ -87,25 +87,29 @@ export function TreePreviewNode({
const nodeTypeColors: Record = {
decision: 'border-blue-500/50 bg-blue-500/10',
action: 'border-yellow-500/50 bg-yellow-500/10',
- solution: 'border-green-500/50 bg-green-500/10'
+ solution: 'border-green-500/50 bg-green-500/10',
+ answer: 'border-dashed border-border bg-muted/50'
}
const nodeTypeSelectedColors: Record = {
decision: 'border-blue-500 bg-blue-500/20 ring-2 ring-blue-500/50 shadow-lg shadow-blue-500/20',
action: 'border-yellow-500 bg-yellow-500/20 ring-2 ring-yellow-500/50 shadow-lg shadow-yellow-500/20',
- solution: 'border-green-500 bg-green-500/20 ring-2 ring-green-500/50 shadow-lg shadow-green-500/20'
+ solution: 'border-green-500 bg-green-500/20 ring-2 ring-green-500/50 shadow-lg shadow-green-500/20',
+ answer: 'border-border bg-muted/50'
}
const nodeTypeHoveredColors: Record = {
decision: 'border-blue-400 bg-blue-500/15 ring-1 ring-blue-400/50',
action: 'border-yellow-400 bg-yellow-500/15 ring-1 ring-yellow-400/50',
- solution: 'border-green-400 bg-green-500/15 ring-1 ring-green-400/50'
+ solution: 'border-green-400 bg-green-500/15 ring-1 ring-green-400/50',
+ answer: 'border-border bg-muted/50'
}
const nodeTypeIcons: Record = {
decision: ,
action: ,
- solution:
+ solution: ,
+ answer:
}
const getNodeLabel = () => {
diff --git a/frontend/src/lib/treeMarkdownSync.ts b/frontend/src/lib/treeMarkdownSync.ts
index a16bc515..9461bd9a 100644
--- a/frontend/src/lib/treeMarkdownSync.ts
+++ b/frontend/src/lib/treeMarkdownSync.ts
@@ -78,6 +78,10 @@ function serializeNode(
if (node.resolution_steps?.length) {
node.resolution_steps.forEach((step, i) => body.push(`${i + 1}. ${step}`))
}
+ } 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.')
}
blocks.push(fm.join('\n') + '\n' + body.join('\n'))
diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx
index 5a7f582b..d21fd4f1 100644
--- a/frontend/src/pages/TreeEditorPage.tsx
+++ b/frontend/src/pages/TreeEditorPage.tsx
@@ -1,11 +1,11 @@
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
import { useStore } from 'zustand'
-import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3 } from 'lucide-react'
+import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings } from 'lucide-react'
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
import { treesApi } from '@/api/trees'
import { treeMarkdownApi } from '@/api/treeMarkdown'
-import type { TreeCreate, TreeUpdate, TreeStatus } from '@/types'
+import type { TreeCreate, TreeUpdate, TreeStatus, TreeStructure } from '@/types'
import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore'
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
import { ValidationSummary } from '@/components/tree-editor/ValidationSummary'
@@ -15,6 +15,12 @@ import { cn, safeGetItem } from '@/lib/utils'
import { toast } from '@/lib/toast'
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
+/** 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)
+}
+
export function TreeEditorPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
@@ -48,6 +54,7 @@ export function TreeEditorPage() {
const [showDraftPrompt, setShowDraftPrompt] = useState(false)
const [treeStatus, setTreeStatus] = useState('draft')
const [showAnalytics, setShowAnalytics] = useState(false)
+ const [isMetadataOpen, setIsMetadataOpen] = useState(false)
// Mobile detection
const [isMobile, setIsMobile] = useState(false)
@@ -291,6 +298,14 @@ export function TreeEditorPage() {
return
}
+ // 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
+ }
+
// Validate tree structure
const errors = validate()
const hasErrors = errors.some(e => e.severity === 'error')
@@ -475,7 +490,7 @@ export function TreeEditorPage() {
setEditorMode('form')}
- title="Flow Mode — form-based editing"
+ title="Flow Mode — visual canvas editing"
className={cn(
'flex items-center gap-1.5 rounded-l-md px-3 py-1.5 text-xs font-medium transition-colors',
editorMode === 'form'
@@ -489,7 +504,10 @@ export function TreeEditorPage() {
setEditorMode('code')}
+ onClick={() => {
+ setEditorMode('code')
+ setIsMetadataOpen(false) // Auto-close metadata panel on Code mode
+ }}
title="Code Mode — markdown editing (Ctrl+Shift+M)"
className={cn(
'flex items-center gap-1.5 rounded-r-md px-3 py-1.5 text-xs font-medium transition-colors',
@@ -540,6 +558,24 @@ export function TreeEditorPage() {
+ {/* Metadata panel toggle — Flow mode only */}
+ {editorMode === 'form' && (
+ setIsMetadataOpen(!isMetadataOpen)}
+ title="Edit flow metadata (name, description, category, tags)"
+ className={cn(
+ 'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
+ isMetadataOpen
+ ? 'bg-accent text-foreground'
+ : 'bg-card text-muted-foreground hover:bg-accent hover:text-foreground'
+ )}
+ >
+
+ Metadata
+
+ )}
+
{/* Analytics toggle (only for existing trees) */}
{isEditMode && (
+ setIsMetadataOpen(false)}
+ />
{/* Flow Analytics Panel (collapsible) */}
{showAnalytics && id && (
diff --git a/frontend/src/pages/TreeLibraryPage.tsx b/frontend/src/pages/TreeLibraryPage.tsx
index ff73f9d9..3a2c1610 100644
--- a/frontend/src/pages/TreeLibraryPage.tsx
+++ b/frontend/src/pages/TreeLibraryPage.tsx
@@ -326,35 +326,40 @@ export function TreeLibraryPage() {
{/* View Controls */}
-
-
- {(['all', 'troubleshooting', 'procedural', 'maintenance'] as const).map((t) => (
+ {/* Type filter tabs — includes Drafts as a first-class filter */}
+
+ {(['all', 'troubleshooting', 'procedural', 'maintenance', 'drafts'] as const).map((t) => {
+ const isActive = t === 'drafts' ? showDrafts && typeFilter === 'all' : !showDrafts && typeFilter === t
+ return (
setTypeFilter(t)}
+ onClick={() => {
+ if (t === 'drafts') {
+ setShowDrafts(true)
+ setTypeFilter('all')
+ } else {
+ setShowDrafts(false)
+ setTypeFilter(t)
+ }
+ }}
className={cn(
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
- typeFilter === t
+ isActive
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
- {t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : 'Maintenance'}
+ {t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : t === 'maintenance' ? 'Maintenance' : 'Drafts'}
- ))}
-
-
-
- setShowDrafts(e.target.checked)}
- className="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-primary/20 focus:ring-offset-2"
- />
- Show my drafts
-
+ )
+ })}
+
+
+ {/* Right controls: sort + view toggle */}
+
+
+
-
diff --git a/frontend/src/pages/TreeNavigationPage.tsx b/frontend/src/pages/TreeNavigationPage.tsx
index e3183895..43418c02 100644
--- a/frontend/src/pages/TreeNavigationPage.tsx
+++ b/frontend/src/pages/TreeNavigationPage.tsx
@@ -757,6 +757,15 @@ export function TreeNavigationPage() {
{/* Current Node */}
+ {/* Answer placeholder guard */}
+ {currentNode && currentNode.type === 'answer' && (
+
+
+ This tree contains an unresolved placeholder node. Please contact the tree author to complete it before use.
+
+
+ )}
+
{/* Decision Node */}
{currentNode && currentNode.type === 'decision' && (
<>
diff --git a/frontend/src/types/tree.ts b/frontend/src/types/tree.ts
index 1dae8a5a..c9674902 100644
--- a/frontend/src/types/tree.ts
+++ b/frontend/src/types/tree.ts
@@ -1,7 +1,7 @@
import type { CategoryInfo } from './category'
// Tree node types
-export type NodeType = 'decision' | 'action' | 'solution'
+export type NodeType = 'decision' | 'action' | 'solution' | 'answer'
export interface TreeOption {
id: string