feat: canvas UX fixes — scroll, fullscreen, InfoTip tooltips, answer stub system #80

Merged
chihlasm merged 28 commits from feature/tree-editor-canvas into main 2026-02-18 17:52:08 +00:00
24 changed files with 2853 additions and 146 deletions

View File

@@ -53,6 +53,13 @@ def validate_tree_structure(tree_structure: dict[str, Any]) -> tuple[bool, list[
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
@@ -89,6 +96,10 @@ def _validate_node(node: dict[str, Any], path: str, errors: list[dict[str, str]]
"message": "Solution nodes must have a non-empty solution"
})
elif node_type == "answer":
# Answer nodes are draft-only placeholders — no structural validation needed
pass
else:
errors.append({
"field": f"{path}.type",
@@ -115,6 +126,16 @@ def _validate_children(children: list[dict[str, Any]], path: str, errors: list[d
_validate_children(child["children"], f"{child_path}.children", errors)
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
# --- Procedural Tree Validation ---
VALID_STEP_TYPES = {"procedure_step", "procedure_end", "section_header"}

View File

@@ -0,0 +1,258 @@
# Canvas UX Fixes — Design Document
**Date:** 2026-02-18
**Branch:** `feature/tree-editor-canvas`
**Status:** Approved, pending implementation
---
## Context
The new TreeCanvas editor (Phase 14) was tested and three UX problems were identified:
1. **Scroll**: Expanded card forms have no height limit — long forms are cut off and unreachable
2. **Busy forms**: Inline hint text (`<p className="text-xs">`) inside NodeForm components creates visual clutter
3. **Answer stubs**: When building a decision node, users must immediately pick a child node type — there's no way to sketch out answer options first and decide types later
All three fixes apply exclusively to the canvas editor. No session, navigation, backend session-saving, or procedural flow code is affected.
---
## Fix 1: Card Scroll
### Problem
`TreeCanvasNode.tsx` renders the expanded editing area as an unbounded `<div>`. On decision nodes with many options, or on any node when the browser viewport is short, the card overflows off-screen. There is no scrollbar — content is unreachable. Tab cycling doesn't scroll the canvas to bring hidden fields into view.
### Design
Apply `max-h-[70vh] overflow-y-auto` to the expanded editing `<div>` inside `TreeCanvasNode.tsx`.
Make the save/cancel header row sticky (`sticky top-0 z-10 bg-card`) so the action buttons are always visible when the user scrolls the form content.
**Files changed:**
- `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
- Add `max-h-[70vh] overflow-y-auto` to the expanded area `<div>` (currently `border-t border-border px-3 pb-3 pt-3`)
- Add `sticky top-0 z-10 bg-card` to the card header `<div>` 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 `<p className="mb-1 text-xs text-muted-foreground">` 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 `<p>` 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
<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>
// After
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
Description
<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"
title="Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`"
>
i
</span>
</label>
```
**Files changed:**
- `frontend/src/components/tree-editor/NodeFormDecision.tsx` — remove help_text hint `<p>`, replace with `ⓘ` on `Help Text` label
- `frontend/src/components/tree-editor/NodeFormAction.tsx` — remove description markdown hint `<p>` and commands hint `<p>`, add `ⓘ` on those labels
- `frontend/src/components/tree-editor/NodeFormResolution.tsx` — remove description markdown hint `<p>` and steps hint `<p>`, 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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
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>
)
}

View File

@@ -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' }:
<div
className={cn(
'relative flex w-full flex-col border border-border bg-card shadow-lg',
'max-h-[100vh] rounded-t-2xl sm:max-h-[85vh] sm:rounded-2xl',
'animate-scale-in',
sizeClasses[size]
'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 */}
@@ -71,17 +94,32 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }:
<h2 id="modal-title" className="text-lg font-semibold text-foreground">
{title}
</h2>
<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'
<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>
)}
aria-label="Close modal"
>
<X className="h-5 w-5" />
</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 */}

View File

@@ -21,13 +21,15 @@ interface ContinuationModalProps {
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
decision: <HelpCircle className="h-4 w-4 text-blue-500" />,
action: <Zap className="h-4 w-4 text-amber-500" />,
solution: <CheckCircle className="h-4 w-4 text-green-500" />
solution: <CheckCircle className="h-4 w-4 text-green-500" />,
answer: <HelpCircle className="h-4 w-4 opacity-40" />
}
const nodeTypeLabels: Record<NodeType, string> = {
decision: 'Decision',
action: 'Action',
solution: 'Solution'
solution: 'Solution',
answer: 'Answer'
}
export function ContinuationModal({

View File

@@ -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<HTMLDivElement>(null)
const label = fromOption || node.title || 'Answer'
// Collapse picker when clicking outside the card
useEffect(() => {
if (!picking) return
const handleOutsideClick = (e: MouseEvent) => {
if (cardRef.current && !cardRef.current.contains(e.target as Node)) {
setPicking(false)
}
}
document.addEventListener('mousedown', handleOutsideClick)
return () => document.removeEventListener('mousedown', handleOutsideClick)
}, [picking])
return (
<div
ref={cardRef}
className={cn(
'relative min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50',
'transition-all duration-150',
!picking && !confirming && 'cursor-pointer hover:border-primary/40 hover:bg-accent/30'
)}
onClick={() => !picking && !confirming && setPicking(true)}
>
{/* Delete button — top-right corner */}
{!picking && !confirming && (
<button
type="button"
onClick={(e) => { 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"
>
<Trash2 className="h-3 w-3" />
</button>
)}
{/* Label */}
<div className="px-3 pt-2.5 pb-1 text-sm font-heading font-medium text-foreground text-center">
{label}
</div>
{/* Confirm delete */}
{confirming ? (
<div className="px-2 pb-2.5 text-center space-y-1.5">
<p className="text-[10px] text-muted-foreground">Delete this stub?</p>
<div className="flex items-center justify-center gap-1.5">
<button
type="button"
onClick={(e) => { 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
</button>
<button
type="button"
onClick={(e) => { 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
</button>
</div>
</div>
) : !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

View File

@@ -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 */}
<div
className="fixed inset-0 z-40 bg-background/40 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Side panel — slides in from right */}
<div
className="fixed right-0 top-0 z-50 flex h-full w-80 flex-col border-l border-border bg-card shadow-xl"
role="dialog"
aria-label="Flow metadata"
>
{/* Panel header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold text-foreground font-heading">
Flow Details
</h2>
<button
type="button"
onClick={onClose}
className="rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Close metadata panel"
>
<X className="h-4 w-4" />
</button>
</div>
{/* Scrollable metadata form */}
<div className="flex-1 overflow-y-auto px-4 py-4">
<TreeMetadataForm />
</div>
</div>
</>
)
}
export default MetadataSidePanel

View File

@@ -83,7 +83,7 @@ export function NodeEditorModal({ node, onClose, isNewNode = false }: NodeEditor
)
return (
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent}>
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent} allowFullScreen={true}>
{/* Node ID display */}
<div className="mb-4 text-xs text-muted-foreground">
Node ID: <code className="rounded bg-accent px-1 py-0.5">{node.id}</code>

View File

@@ -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 */}
<div>
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-foreground">
<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>
{node.description && (
<button
@@ -88,9 +90,6 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
</button>
)}
</div>
<p className="mb-1 text-xs text-muted-foreground">
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
</p>
{showPreview && node.description ? (
<div className="mt-1 rounded-md border border-border bg-accent/50 p-3 text-sm">
<MarkdownContent content={node.description} />
@@ -118,12 +117,10 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
{/* Commands */}
<div>
<label className="block text-sm font-medium text-foreground">
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
Commands
<InfoTip text="PowerShell or CLI commands to execute" />
</label>
<p className="mb-2 text-xs text-muted-foreground">
PowerShell or CLI commands to execute
</p>
<DynamicArrayField
items={node.commands || []}
onAdd={handleAddCommand}

View File

@@ -1,9 +1,10 @@
import { useRef, useEffect } from 'react'
import { Play } from 'lucide-react'
import { DynamicArrayField } from './DynamicArrayField'
import { NodePicker } from './NodePicker'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import type { TreeStructure, TreeOption } from '@/types'
import { cn } from '@/lib/utils'
import { InfoTip } from '@/components/common/InfoTip'
interface NodeFormDecisionProps {
node: TreeStructure
@@ -16,8 +17,11 @@ const indexToLetter = (index: number): string => {
}
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<Map<number, HTMLInputElement>>(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) {
<label className="block text-sm font-medium text-foreground">
{isRootNode ? 'Starting Question' : 'Question'} <span className="text-red-400">*</span>
</label>
{isRootNode && (
<p className="mt-0.5 text-xs text-muted-foreground">
What's the main question to diagnose the issue?
</p>
)}
<input
type="text"
value={node.question || ''}
@@ -125,18 +144,12 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
{/* Options */}
<div>
<label className="block text-sm font-medium text-foreground">
<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>
{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>
)}
{optionsError && (
<p className="mt-1 text-xs text-red-400">{optionsError.message}</p>
)}
@@ -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 (
<div className="rounded-md border border-border bg-accent/50 p-3">
<div className="mb-2 flex items-center gap-2">
{/* Letter badge */}
<span className={cn(
'flex h-6 w-6 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 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
ref={(el) => {
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'
)}
/>
</div>
{optionLabelError && (
<p className="mb-2 text-xs text-red-400">{optionLabelError.message}</p>
)}
<div className="pl-8">
<NodePicker
value={option.next_node_id}
onChange={(nodeId) => 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 && (
<p className="mt-1 text-xs text-red-400">{optionLabelError.message}</p>
)}
</div>
</div>
)

View File

@@ -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 */}
<div>
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-foreground">
<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>
{node.description && (
<button
@@ -83,9 +85,6 @@ export function NodeFormResolution({ node, onUpdate }: NodeFormResolutionProps)
</button>
)}
</div>
<p className="mb-1 text-xs text-muted-foreground">
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
</p>
{showPreview && node.description ? (
<div className="mt-1 rounded-md border border-border bg-accent/50 p-3 text-sm">
<MarkdownContent content={node.description} />
@@ -112,12 +111,10 @@ Document what was done and the outcome.
{/* Resolution Steps */}
<div>
<label className="block text-sm font-medium text-foreground">
<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>
<p className="mb-2 text-xs text-muted-foreground">
Step-by-step instructions for resolving the issue
</p>
<DynamicArrayField
items={node.resolution_steps || []}
onAdd={handleAddStep}

View File

@@ -91,13 +91,15 @@ function NodeListItem({
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" />
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'
solution: 'bg-green-500/20 text-green-400',
answer: 'bg-muted text-muted-foreground border border-dashed border-border'
}
const getNodeLabel = () => {

View File

@@ -13,14 +13,16 @@ const CREATE_SOLUTION = `${CREATE_PREFIX}solution__`
const NODE_TYPE_SYMBOLS: Record<NodeType, string> = {
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<NodeType, string> = {
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({
>
<option value="">{placeholder}</option>
{/* Create new options */}
<optgroup label="Create New Node">
<option value={CREATE_DECISION}>+ New Decision (question)</option>
<option value={CREATE_ACTION}>+ New Action (task)</option>
<option value={CREATE_SOLUTION}>+ New Solution (endpoint)</option>
</optgroup>
{/* Create new options — hidden when allowCreate=false (e.g. canvas inline editing) */}
{allowCreate && (
<optgroup label="Create New Node">
<option value={CREATE_DECISION}>+ New Decision (question)</option>
<option value={CREATE_ACTION}>+ New Action (task)</option>
<option value={CREATE_SOLUTION}>+ New Solution (endpoint)</option>
</optgroup>
)}
{/* Existing nodes grouped by type */}
{groupedNodes.decisions.length > 0 && (

View File

@@ -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<TreeStructure>) => 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 (
<div className="flex items-center gap-2 rounded-xl border border-dashed border-primary/40 bg-card px-3 py-2 shadow-sm">
<span className="text-xs text-muted-foreground shrink-0">Add:</span>
<button
type="button"
onClick={() => 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'
)}
>
<HelpCircle className="h-3 w-3" />
Decision
</button>
<button
type="button"
onClick={() => 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'
)}
>
<Zap className="h-3 w-3" />
Action
</button>
<button
type="button"
onClick={() => 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'
)}
>
<CheckCircle className="h-3 w-3" />
Solution
</button>
<button
type="button"
onClick={onCancel}
className="ml-1 rounded p-0.5 text-muted-foreground hover:bg-accent"
>
<X className="h-3 w-3" />
</button>
</div>
)
}
// ─── Add-node trigger button ──────────────────────────────────────────────────
interface AddNodeButtonProps {
label?: string
onClick: () => void
}
function AddNodeButton({ label = 'Add node', onClick }: AddNodeButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-label',
'border border-dashed border-border text-muted-foreground',
'hover:border-primary/40 hover:text-foreground hover:bg-accent/50',
'transition-all duration-150'
)}
>
<Plus className="h-3 w-3" />
{label}
</button>
)
}
// ─── 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<string | null>(null)
const [newNodeIds, setNewNodeIds] = useState<Set<string>>(new Set())
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
const [pendingAddKey, setPendingAddKey] = useState<string | null>(null)
const [pendingLinks, setPendingLinks] = useState<Map<string, PendingLink>>(
new Map()
)
const [dragState, setDragState] = useState<DragState | null>(null)
const [dragOverTarget, setDragOverTarget] = useState<{
parentId: string | null
index: number
} | null>(null)
// Node ref map for scroll-into-view
const nodeRefs = useRef<Map<string, HTMLDivElement>>(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<TreeStructure>) => {
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<string>()
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 (
<div
key={node.id}
className="flex flex-col items-center"
ref={(el) => {
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 && (
<div className="mb-1 h-1 w-full rounded-full bg-primary" />
)}
{/* Option label tag (above card, shown when this is a branch from a decision) */}
{optionLabel && (
<div className="mb-1 rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground font-label">
{optionLabel}
</div>
)}
{/* The node card — answer stubs get their own component */}
{node.type === 'answer' ? (
<AnswerStubCard
node={node}
fromOption={optionLabel}
onSelectType={handleSelectAnswerType}
onDelete={handleDelete}
/>
) : (
<TreeCanvasNode
node={node}
depth={0}
fromOption={optionLabel}
isExpanded={isExpanded}
isNew={isNew}
hasChildren={nodeChildren.length > 0}
isSubtreeCollapsed={isSubtreeCollapsed}
onToggleExpand={() => handleToggleExpand(node.id)}
onToggleSubtreeCollapse={() => handleToggleSubtreeCollapse(node.id)}
onSave={handleSave}
onCancelNew={handleCancelNew}
onDelete={handleDelete}
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 && (
<div className="mt-3 flex flex-col items-center gap-2">
{unlinkedOptions.map((opt) => {
const key = addKey(node.id, opt.id)
return (
<div key={opt.id} className="flex flex-col items-center gap-1">
<div className="h-4 w-px bg-border" />
<span className="text-[10px] text-muted-foreground font-label">
{opt.label || '(unlabeled option)'}
</span>
{pendingAddKey === key ? (
<AddNodePicker
onSelect={(type) =>
handleAddNodeSelect(type, node.id, opt.id)
}
onCancel={() => setPendingAddKey(null)}
/>
) : (
<AddNodeButton
label="Add child"
onClick={() => setPendingAddKey(key)}
/>
)}
</div>
)
})}
</div>
)}
{/* Single add button for action nodes without children */}
{!isExpanded && showSingleAddButton && (
<div className="mt-3 flex flex-col items-center gap-1">
<div className="h-4 w-px bg-border" />
{pendingAddKey === node.id ? (
<AddNodePicker
onSelect={(type) => handleAddNodeSelect(type, node.id)}
onCancel={() => setPendingAddKey(null)}
/>
) : (
<AddNodeButton
label="Add next node"
onClick={() => setPendingAddKey(node.id)}
/>
)}
</div>
)}
{/* Collapsed subtree pill */}
{hasChildren && !isExpanded && isSubtreeCollapsed && (
<div className="mt-3 flex flex-col items-center">
<div className="h-4 w-px bg-border" />
<button
type="button"
onClick={() => handleToggleSubtreeCollapse(node.id)}
className="rounded-full border border-dashed border-border bg-card px-3 py-1 text-[10px] text-muted-foreground font-label hover:border-primary/40 hover:text-foreground transition-colors"
>
{orderedChildren.length} node{orderedChildren.length !== 1 ? 's' : ''} hidden click to expand
</button>
</div>
)}
{/* Connector + Children */}
{hasChildren && !isExpanded && !isSubtreeCollapsed && (
<div className="mt-3 flex flex-col items-center w-full">
{/* Trunk line from card down */}
<div className="h-4 w-px bg-border" />
{orderedChildren.length === 1 ? (
// Single child: straight vertical
<div className="flex flex-col items-center">
{renderNode(
orderedChildren[0].child,
node.id,
orderedChildren[0].childIndex,
orderedChildren[0].optionLabel
)}
</div>
) : (
// 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.
<div className="flex items-start justify-center gap-6 w-full relative"
style={{ maxWidth: `${orderedChildren.length * 360}px` }}
>
{/* Horizontal fork line — absolutely positioned, aligned to child centers.
Spans from center of first lane to center of last lane. */}
<div
className="absolute top-0 h-px bg-border pointer-events-none"
style={{
left: `calc(${100 / (orderedChildren.length * 2)}%)`,
right: `calc(${100 / (orderedChildren.length * 2)}%)`,
}}
/>
{orderedChildren.map(({ child, optionLabel: ol, childIndex }) => (
<div
key={child.id}
className="flex flex-col items-center min-w-[260px]"
>
{/* Vertical stub into child lane */}
<div className="h-4 w-px bg-border" />
{renderNode(child, node.id, childIndex, ol)}
</div>
))}
</div>
)}
</div>
)}
</div>
)
},
[
expandedNodeId,
newNodeIds,
collapsedNodeIds,
dragOverTarget,
handleToggleExpand,
handleToggleSubtreeCollapse,
handleSave,
handleCancelNew,
handleDelete,
handleDuplicate,
handleSelectAnswerType,
handleDragStart,
handleDragOver,
handleDrop,
pendingAddKey,
handleAddNodeSelect,
]
)
// ── Empty state ──
if (!treeStructure) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mb-2 text-muted-foreground text-sm">
No tree structure. Start by saving a tree name.
</div>
</div>
</div>
)
}
return (
<div
className="relative h-full w-full overflow-auto"
onDragEnd={handleDragEnd}
style={{
// Subtle dot grid background
backgroundImage:
'radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)',
backgroundSize: '24px 24px',
}}
>
<div className="flex min-h-full min-w-full items-start justify-center p-8 pb-24">
<div className="flex flex-col items-center">
{/* START badge above root */}
<div className="mb-2 rounded-full border border-border bg-card px-3 py-1 text-xs font-label text-muted-foreground">
START
</div>
<div className="mb-1 h-4 w-px bg-border" />
{renderNode(treeStructure, null, 0)}
</div>
</div>
</div>
)
}
export default TreeCanvas

View File

@@ -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<TreeStructure>) => 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<TreeStructure>(() =>
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<TreeStructure>) => {
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 (
<div
className={cn(
'relative rounded-xl border border-border bg-card shadow-sm transition-all duration-150',
config.borderClass,
isExpanded && 'ring-1 ring-primary shadow-md',
isSelected && !isExpanded && 'ring-1 ring-primary/50',
hasError && 'ring-1 ring-destructive',
hasWarning && !hasError && 'ring-1 ring-yellow-500/70',
isNew && 'ring-1 ring-yellow-400/60',
'min-w-[240px] max-w-[340px]'
)}
onDragOver={onDragOver}
onDrop={onDrop}
>
{/* Card Header */}
<div
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'
)}
onClick={!isExpanded ? handleCardClick : undefined}
>
{/* Drag handle (hide for root) */}
{!isRoot && (
<span
className="cursor-grab shrink-0"
draggable
onDragStart={(e) => {
e.stopPropagation()
onDragStart(e, node.id)
}}
>
<GripVertical className="h-4 w-4 text-muted-foreground/50" />
</span>
)}
{/* Node type badge */}
{isRoot ? (
<span className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold bg-blue-500/30 text-blue-400 font-label shrink-0">
<Play className="h-3 w-3" />
START
</span>
) : (
<span
className={cn(
'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-label shrink-0',
config.badgeClass
)}
>
<TypeIcon className="h-3 w-3" />
{config.label}
</span>
)}
{/* From-option label */}
{fromOption && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground truncate max-w-[80px]">
{fromOption}
</span>
)}
{/* Title text (compact mode) */}
{!isExpanded && (
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">
{getTitle()}
</span>
)}
{/* Options count badge */}
{!isExpanded && getOptionsSummary() && (
<span className="text-[10px] text-muted-foreground shrink-0 font-label">
{getOptionsSummary()}
</span>
)}
{/* Validation badges (compact mode) */}
{!isExpanded && hasError && (
<span
className="flex items-center gap-0.5 rounded bg-destructive/20 px-1.5 py-0.5 text-[10px] text-destructive shrink-0"
title={nodeErrors.map((e) => e.message).join('\n')}
>
<AlertCircle className="h-2.5 w-2.5" />
{nodeErrors.length}
</span>
)}
{!isExpanded && !hasError && hasWarning && (
<span
className="flex items-center gap-0.5 rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 shrink-0"
title={nodeWarnings.map((e) => e.message).join('\n')}
>
<AlertTriangle className="h-2.5 w-2.5" />
{nodeWarnings.length}
</span>
)}
{/* Unsaved badge */}
{!isExpanded && isNew && (
<span className="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 font-label shrink-0">
Unsaved
</span>
)}
{/* Subtree collapse toggle — only in compact mode when node has children */}
{!isExpanded && hasChildren && onToggleSubtreeCollapse && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleSubtreeCollapse() }}
title={isSubtreeCollapsed ? 'Expand subtree' : 'Collapse subtree'}
className="rounded p-0.5 text-muted-foreground/50 hover:bg-accent hover:text-foreground shrink-0"
>
{isSubtreeCollapsed
? <ChevronsUpDown className="h-3.5 w-3.5" />
: <ChevronsDownUp className="h-3.5 w-3.5" />
}
</button>
)}
{/* Expand/collapse chevron */}
{!isExpanded ? (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
{/* Editing action buttons (expanded state) */}
{isExpanded && (
<div className="ml-auto flex items-center gap-1 shrink-0">
{/* New badge */}
{isNew && (
<span className="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 font-label">
Unsaved
</span>
)}
{/* Duplicate (hide for root) */}
{!isRoot && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDuplicate(node.id)
}}
title="Duplicate node"
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Copy className="h-3.5 w-3.5" />
</button>
)}
{/* Delete (hide for root) */}
{!isRoot && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete(node.id)
}}
title="Delete node"
className="rounded p-1 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
{/* Cancel */}
<button
type="button"
onClick={handleCancel}
title={isNew ? 'Cancel (deletes this node)' : 'Cancel changes'}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
{/* Save */}
<button
type="button"
onClick={handleSave}
title="Save changes"
className="rounded p-1 bg-gradient-brand text-white hover:opacity-90"
>
<Check className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{/* Expanded editing area */}
{isExpanded && (
<div className="border-t border-border px-3 pb-3 pt-3 max-h-[70vh] overflow-y-auto">
{/* Validation errors */}
{(hasError || hasWarning) && (
<div className="mb-3 space-y-1">
{nodeErrors.map((error, i) => (
<div
key={i}
className="rounded-md bg-red-400/10 px-3 py-2 text-xs text-red-400"
>
{error.message}
</div>
))}
{!hasError &&
nodeWarnings.map((warning, i) => (
<div
key={i}
className="rounded-md bg-yellow-400/10 px-3 py-2 text-xs text-yellow-400"
>
{warning.message}
</div>
))}
</div>
)}
{/* Type-specific form — uses draft, not live node */}
{draft.type === 'decision' && (
<NodeFormDecision node={draft} onUpdate={handleDraftUpdate} />
)}
{draft.type === 'action' && (
<NodeFormAction node={draft} onUpdate={handleDraftUpdate} />
)}
{draft.type === 'solution' && (
<NodeFormResolution node={draft} onUpdate={handleDraftUpdate} />
)}
</div>
)}
</div>
)
}
export default TreeCanvasNode

View File

@@ -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 */}
<div className={cn(
'flex flex-col overflow-hidden border-border',
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
@@ -50,24 +56,16 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
</>
) : (
<>
{/* Flow Mode: Form editor (60%) + Preview (40%) */}
<div className={cn(
'flex flex-col overflow-y-auto border-border',
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
)}>
<div className="space-y-4 p-4">
<TreeMetadataForm />
<NodeList />
</div>
{/* Flow Mode: Full-width visual canvas */}
<div className="flex-1 overflow-hidden">
<TreeCanvas />
</div>
{/* Right Panel - Preview */}
<div className={cn(
'flex-1 overflow-hidden bg-accent/50',
isMobile ? 'hidden' : 'block'
)}>
<TreePreviewPanel />
</div>
{/* Metadata side panel — overlays the canvas from the right */}
<MetadataSidePanel
isOpen={isMetadataOpen}
onClose={onCloseMetadata}
/>
</>
)}
</div>

View File

@@ -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
/>
<button
type="button"
onClick={() => {
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
</button>

View File

@@ -87,25 +87,29 @@ export function TreePreviewNode({
const nodeTypeColors: Record<NodeType, string> = {
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<NodeType, string> = {
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<NodeType, string> = {
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<NodeType, React.ReactNode> = {
decision: <HelpCircle className="h-4 w-4 text-blue-500" />,
action: <Zap className="h-4 w-4 text-yellow-500" />,
solution: <CheckCircle className="h-4 w-4 text-green-500" />
solution: <CheckCircle className="h-4 w-4 text-green-500" />,
answer: <HelpCircle className="h-4 w-4 opacity-40" />
}
const getNodeLabel = () => {

View File

@@ -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'))

View File

@@ -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<TreeStatus>('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() {
<button
type="button"
onClick={() => 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() {
<div className="h-5 w-px bg-border" />
<button
type="button"
onClick={() => 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() {
<div className="mx-2 h-6 w-px bg-border" />
{/* Metadata panel toggle — Flow mode only */}
{editorMode === 'form' && (
<button
type="button"
onClick={() => 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'
)}
>
<Settings className="h-4 w-4" />
Metadata
</button>
)}
{/* Analytics toggle (only for existing trees) */}
{isEditMode && (
<button
@@ -612,7 +648,11 @@ export function TreeEditorPage() {
)}
{/* Main Editor */}
<TreeEditorLayout isMobile={isMobile} />
<TreeEditorLayout
isMobile={isMobile}
isMetadataOpen={isMetadataOpen}
onCloseMetadata={() => setIsMetadataOpen(false)}
/>
{/* Flow Analytics Panel (collapsible) */}
{showAnalytics && id && (

View File

@@ -326,35 +326,40 @@ export function TreeLibraryPage() {
{/* View Controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4">
<div className="flex rounded-lg border border-border p-0.5">
{(['all', 'troubleshooting', 'procedural', 'maintenance'] as const).map((t) => (
{/* Type filter tabs — includes Drafts as a first-class filter */}
<div className="flex rounded-lg border border-border p-0.5">
{(['all', 'troubleshooting', 'procedural', 'maintenance', 'drafts'] as const).map((t) => {
const isActive = t === 'drafts' ? showDrafts && typeFilter === 'all' : !showDrafts && typeFilter === t
return (
<button
key={t}
onClick={() => 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'}
</button>
))}
</div>
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showDrafts}
onChange={(e) => 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"
/>
<span className="text-sm text-muted-foreground">Show my drafts</span>
</label>
)
})}
</div>
{/* Right controls: sort + view toggle */}
<div className="flex items-center gap-2">
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
<ViewToggle view={treeLibraryView} onChange={setTreeLibraryView} />
</div>
<ViewToggle view={treeLibraryView} onChange={setTreeLibraryView} />
</div>
</div>

View File

@@ -757,6 +757,15 @@ export function TreeNavigationPage() {
{/* Current Node */}
<div className="bg-card border border-border rounded-xl p-6 shadow-sm">
{/* Answer placeholder guard */}
{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>
)}
{/* Decision Node */}
{currentNode && currentNode.type === 'decision' && (
<>

View File

@@ -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