Files
resolutionflow/frontend/src/store/treeEditorStore.ts
chihlasm 0dc6123c0c feat: flow export/import + procedural Flow Assist (#96)
* feat: add flow export/import backend (migration, endpoints, schemas)

Add .rfflow file export/import support:
- Migration 050: import_metadata JSONB column on trees
- GET /trees/{id}/export?format=json|xml endpoint
- POST /trees/import endpoint (creates draft, resolves categories/tags)
- FlowExportEnvelope, FlowImportRequest/Response schemas
- import_metadata field on TreeResponse

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add flow export/import frontend + backend tests

Frontend:
- ExportFlowModal with JSON/XML format selection + download
- ImportFlowModal with drag-drop file picker + preview step
- rfflowParser for client-side JSON/XML .rfflow parsing
- Export buttons on editor toolbar and library action menus
- Import button on library page next to Create New
- Provenance display for imported flows in editor
- flowTransfer API client + types

Backend:
- Fix regex->pattern deprecation in export endpoint
- 12 integration tests covering export, import, round-trip,
  access control, tag/category creation, version validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove XML export, JSON-only for .rfflow files

- Remove XML builder, format query param, and XML tests
- Simplify ExportFlowModal (no format picker)
- Simplify rfflowParser (JSON-only)
- Remove format field from schemas and types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: match Flow Assist chat input to AI Assistant styling + strengthen one-question prompt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add procedural flow support to AI chat builder (Flow Assist)

- Add procedural-specific system prompts (schema, interview protocol, response format)
- Dispatch prompts by flow_type: procedural/maintenance use flat steps schema, troubleshooting uses decision tree schema
- Parse [STEPS_UPDATE] and [INTAKE_FORM] markers in AI responses
- Add validate_generated_procedural_steps() validator
- Handle intake form extraction in AI chat import endpoint
- Add StaticStepsPreview component for procedural flow preview
- Update store and page to render correct preview by flow type

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add flow type selection to Flow Assist entry points

- CreateFlowDropdown now shows "Build with Flow Assist" under each flow type
- Library page "Flow Assist" button respects current type filter
- Clean up unused AIFlowBuilderModal references

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update CLAUDE.md with AI chat builder and intake form learnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: refine assistant chat prompt for concise answers and focused questions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: switch AI provider to Claude Sonnet 4.6 + add shift+enter hint to chat inputs

- Default AI_PROVIDER changed from gemini to anthropic
- AI_MODEL and AI_MODEL_ANTHROPIC updated to claude-sonnet-4-6
- Added "Shift + Enter for a new line" hint below all chat textareas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update CLAUDE.md with AI provider and chat input learnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add editor-embedded Flow Assist design document

Design for replacing the standalone /ai/chat page with context-aware
AI side panels embedded in each editor (Troubleshooting + Procedural).
Covers ghost node suggestion system, output-based thresholds,
config-driven model routing, knowledge integration, and per-flow
chat persistence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add editor-embedded Flow Assist implementation plan

25-task plan across 9 phases covering backend foundation, frontend
infrastructure, tree/procedural editor integration, AI-assisted create,
old code removal, action-type dispatch, suggestion audit trail, and
build verification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use actual root node ID in orphan validation check

AI-generated trees use descriptive IDs (e.g., "verify-account-exists")
instead of "root", causing the root node to be falsely flagged as orphaned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add config-driven AI model tier routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: extend AI chat session with tree_id and archived_at

Add tree_id FK (CASCADE) for editor-embedded sessions and archived_at
timestamp column to ai_chat_sessions table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI suggestion audit trail table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add action_type and focal_node_id to AI chat message API

- Add VALID_ACTION_TYPES literal and action_type/focal_node_id fields to
  AIChatMessageRequest schema
- Add tree_id field to AIChatStartRequest schema for editor-embedded sessions
- Update send_message() signature with action_type and focal_node_id params
- Update start_chat_session() signature with tree_id param
- Pass new params through endpoints to service functions
- All new params have defaults so existing behavior is unchanged

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: route AI model selection through action-type config

Update get_ai_provider() to accept an optional model override parameter
(applied only to AnthropicProvider; Gemini always uses its own model).
Thread action_type-based model resolution through send_message() and
generate_final_tree() in the AI chat service.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add TypeScript types for editor-embedded AI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add shared ContextMenu component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add useEditorAI hook and editorAI API client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add EditorAIPanel component with Chat and Suggestions tabs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: integrate AI panel, context menu, and ghost nodes in tree editor

- Add AI Assist panel toggle button to tree editor toolbar
- Wire EditorAIPanel alongside TreeEditorLayout with single-panel rule
- Thread onNodeContextMenu through TreeEditorLayout → FlowCanvas → FlowCanvasNode
- Add right-click context menu with Generate Branch, Explain Node, Delete actions
- Add ghost node detection (_suggestion flag) with dashed border + opacity styling
- Add Accept/Dismiss overlay buttons on ghost nodes for future suggestion handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: integrate AI panel, context menu, and ghost steps in procedural editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI prompt dialog and wire into CreateFlowDropdown

Replace navigation to /ai/chat with an inline AIPromptDialog modal
that collects a single prompt, generates a flow via the editor AI API,
imports it, and navigates to the editor with the AI panel open.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add glassmorphism to AI prompt dialog + maintenance Flow Assist button

- Use .glass-card-static on AIPromptDialog card for consistent design system
- Add "Build with Flow Assist" button to maintenance section in CreateFlowDropdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove standalone Flow Assist page and old AI chat components

Remove the old /ai/chat page, AI wizard modal, and all associated
components/stores/types now replaced by the editor-embedded AI panel.

Deleted:
- AIChatBuilderPage, ai-chat/ components, aiChatStore, aiChat API, ai-chat types
- AIFlowBuilderModal, ai-builder/ components, aiFlowBuilderStore

Cleaned up:
- Router (removed /ai/chat route)
- Sidebar (removed Flow Assist nav item)
- MyTreesPage (removed AI builder modal and button)
- TreeLibraryPage (removed Flow Assist button)
- API and type barrel exports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add delta response parsing and action-type prompt dispatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add AI suggestion audit trail endpoints

Create/list/resolve endpoints for tracking AI-applied changes to flows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add APScheduler task to auto-archive stale AI chat sessions

Archives AI chat sessions with no activity for 30 days, runs daily at 3 AM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update project status for editor-embedded Flow Assist

- Add Editor-Embedded Flow Assist to CURRENT-STATE.md in-progress items
- Update CLAUDE.md: fix stale lessons (#41, #46), add new patterns (#47 editor AI architecture, #48 orphan validation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use correct model alias in AI_MODEL_TIERS standard tier

The dated model ID `claude-sonnet-4-6-20250514` was causing 502 errors.
Use the alias `claude-sonnet-4-6` which matches AI_MODEL_ANTHROPIC.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: send live flow context to AI Assist for full editor awareness

The AI panel now sends the current tree structure (troubleshooting) or
steps + intake form (procedural/maintenance) with each message. This
gives the AI full visibility into node details, questions, descriptions,
options, and intake form fields — not just the node ID.

- Backend: add flow_context param to schema, endpoint, and service
- Frontend: add getFlowContext callback to useEditorAI hook
- TreeEditorPage: passes treeStructure as flow context
- ProceduralEditorPage: passes steps + intakeForm as flow context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: include flow name and description in AI Assist context

Both editors now send name and description alongside the flow structure,
so the AI can reference what the flow is about when responding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: increase AI timeout to 120s and limit retries to 1

The 45s timeout was too short for generation tasks with full flow
context in the system prompt. The Anthropic SDK's default 2 retries
caused requests to hang for ~136s before failing. Now: 120s timeout
with max 1 retry = faster failure if it does timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: wire AI-generated flow structures into editor stores

The useEditorAI hook was ignoring result.working_tree from AI responses,
so generated steps/trees never appeared in the editor. Now:
- useEditorAI calls onFlowUpdate when working_tree is present in response
- ProceduralEditorPage handles steps + intake form updates via replaceSteps
- TreeEditorPage handles tree structure updates via replaceTreeStructure
- Both stores have new bulk-replace methods for AI integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add lessons learned for full-stack integration, Anthropic retries, model tiers

#49 Always verify frontend consumes backend response fields
#50 Anthropic SDK max_retries=1 to avoid 3× timeout
#51 AI model tier routing via settings.get_model_for_action()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: move AI Assist panel to full-height side layout in both editors

The AI panel was nested inside the content area, only spanning the
step list / canvas section. Now it sits at the outermost flex level,
spanning the full page height alongside all content (toolbar,
collapsible sections, steps/canvas). This prevents the panel from
overlapping content and lets the editor area properly shrink.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Assist panel as fixed right drawer (matching Copilot/Scratchpad)

Convert EditorAIPanel from in-flow flex child to fixed right-side drawer
overlay, same pattern as CopilotPanel and ScratchpadSidebar. The panel
is fixed at right:0 spanning full viewport height, and editor pages add
pr-[380px] padding when open so content shifts left without overlap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Assist panel sits below topbar with slide-in animation

- Panel now uses top:56px to sit below the app shell topbar instead of
  covering it (matches the main-content grid cell area)
- Added slideInRight CSS animation for smooth drawer entrance
- Editor pages use dynamic paddingRight via PANEL_WIDTH constant
- ChatTab upgraded: markdown rendering, CopilotPanel-style message
  bubbles, auto-focus input, Shift+Enter hint
- All borders use --glass-border for consistent glassmorphism

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Assist panel as in-flow flex sibling (not fixed/overlay)

Replace fixed positioning with in-flow flex layout. The outermost div
is now a horizontal flex row: content column (flex-1 min-w-0) + panel
(w-[380px] shrink-0). When the panel opens, the content column
automatically shrinks — no padding hacks or z-index stacking needed.
This guarantees the content shifts left and stays fully visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: AI Copilot panel as in-flow flex sibling in session navigation pages

Changed CopilotPanel from fixed overlay to flex layout sibling so it
pushes main content instead of covering it during active sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: remove duplicate CLAUDE.md lessons #47-48

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 15:51:37 -05:00

1114 lines
34 KiB
TypeScript

import { create } from 'zustand'
import { temporal } from 'zundo'
import { shallow } from 'zustand/shallow'
import { immer } from 'zustand/middleware/immer'
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType, TreeMarkdownValidationError, TreeMarkdownValidation } from '@/types'
import { treeStructureToMarkdownPreview } from '@/lib/treeMarkdownSync'
import { safeGetItem, safeSetItem, safeRemoveItem } from '@/lib/utils'
// Throttle helper: captures first call immediately, then throttles subsequent calls
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function throttle<T extends (...args: any[]) => void>(fn: T, ms: number): T {
let lastCall = 0
let timeout: ReturnType<typeof setTimeout> | null = null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return ((...args: any[]) => {
const now = Date.now()
if (now - lastCall >= ms) {
lastCall = now
fn(...args)
} else {
// Schedule a trailing call to capture the final state
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
lastCall = Date.now()
fn(...args)
}, ms - (now - lastCall))
}
}) as T
}
// Validation error interface
export interface ValidationError {
nodeId?: string
field?: string
message: string
severity: 'error' | 'warning'
}
// Draft storage key
const DRAFT_STORAGE_KEY = 'tree-editor-draft'
// Check if a tree is effectively empty (just initialized, no real content)
const isEmptyTree = (tree: TreeStructure | null): boolean => {
if (!tree) return true
if (tree.question && tree.question.trim()) return false
if (tree.children && tree.children.length > 0) return false
return true
}
// Starter template for new trees in Code Mode — all @refs are valid within the template
const CODE_MODE_STARTER_TEMPLATE = `---
name: My New Tree
description: A troubleshooting decision tree
---
---
id: root
type: decision
---
# What type of issue is the user experiencing?
> Select the category that best matches the reported problem
- [A] Option A \u2192 @option_a_action
- [B] Option B \u2192 @option_b_action
---
id: option_a_action
type: action
parent: root
---
## Investigate Option A
Describe the investigation steps here.
\`\`\`commands
example-command --flag
\`\`\`
**Expected:** Describe expected results here
\u2192 @resolution
---
id: option_b_action
type: action
parent: root
---
## Investigate Option B
Describe the investigation steps here.
\u2192 @resolution
---
id: resolution
type: solution
parent: root
---
## Resolution
1. Document findings
2. Apply the fix
3. Verify the issue is resolved
`
// Helper to generate unique IDs
const generateId = () => crypto.randomUUID()
// Helper to find a node in the tree structure (exported for drag validation)
export const findNodeInTree = (
nodeId: string,
structure: TreeStructure | null
): TreeStructure | null => {
if (!structure) return null
if (structure.id === nodeId) return structure
if (structure.children) {
for (const child of structure.children) {
const found = findNodeInTree(nodeId, child)
if (found) return found
}
}
return null
}
/** Collect all nodes in the tree as a flat list with depth info. */
export function collectAllNodesFlat(
root: TreeStructure | null
): Array<{ id: string; label: string; type: string; depth: number }> {
if (!root) return []
const result: Array<{ id: string; label: string; type: string; depth: number }> = []
function walk(node: TreeStructure, depth: number) {
const label = node.type === 'decision'
? (node.question || 'Untitled Decision')
: (node.title || `Untitled ${node.type}`)
result.push({ id: node.id, label, type: node.type, depth })
node.children?.forEach(child => walk(child, depth + 1))
}
walk(root, 0)
return result
}
// Helper to find parent of a node
const findParentNode = (
nodeId: string,
structure: TreeStructure | null,
parent: TreeStructure | null = null
): TreeStructure | null => {
if (!structure) return null
if (structure.id === nodeId) return parent
if (structure.children) {
for (const child of structure.children) {
const found = findParentNode(nodeId, child, structure)
if (found) return found
}
}
return null
}
// Helper to get all node IDs
const getAllNodeIds = (structure: TreeStructure | null): string[] => {
if (!structure) return []
const ids = [structure.id]
if (structure.children) {
for (const child of structure.children) {
ids.push(...getAllNodeIds(child))
}
}
return ids
}
// Helper to deep clone a node
const deepCloneNode = (node: TreeStructure): TreeStructure => {
const clone: TreeStructure = { ...node, id: generateId() }
// Update title/question to indicate it's a copy
if (clone.question) {
clone.question = `${clone.question} (Copy)`
} else if (clone.title) {
clone.title = `${clone.title} (Copy)`
}
// Clone options with new IDs
if (clone.options) {
clone.options = clone.options.map(opt => ({
...opt,
id: generateId(),
next_node_id: '' // Clear references - user must reassign
}))
}
// Clear next_node_id - user must reassign
if (clone.next_node_id) {
clone.next_node_id = ''
}
// Clone children recursively
if (clone.children) {
clone.children = clone.children.map(child => deepCloneNode(child))
}
return clone
}
interface TreeEditorState {
// Tree data
treeId: string | null // null for new tree
name: string
description: string
category: string
categoryId: string | null
tags: string[]
isPublic: boolean
treeStructure: TreeStructure | null
originalTree: Tree | null // For comparison in edit mode
// UI state
selectedNodeId: string | null
isDirty: boolean
isLoading: boolean
isSaving: boolean
validationErrors: ValidationError[]
// Code Mode state
editorMode: 'form' | 'code'
markdownSource: string | null
markdownValidationErrors: TreeMarkdownValidationError[]
isMarkdownValid: boolean
isValidating: boolean
lastValidTreeFromMarkdown: TreeStructure | null
// Auto-save state
lastSavedAt: Date | null
draftSavedAt: Date | null
hasDraft: boolean
// Actions - Initialization
initNewTree: () => void
loadTree: (tree: Tree) => void
loadDraft: () => boolean
discardDraft: () => void
reset: () => void
// Actions - Metadata
setName: (name: string) => void
setDescription: (description: string) => void
setCategory: (category: string) => void
setCategoryId: (categoryId: string | null) => void
setTags: (tags: string[]) => void
addTag: (tag: string) => void
removeTag: (tag: string) => void
setIsPublic: (isPublic: boolean) => void
// Actions - Node CRUD
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => string
updateNode: (nodeId: string, updates: Partial<TreeStructure>) => void
deleteNode: (nodeId: string) => void
duplicateNode: (nodeId: string) => string | null
// Actions - Node ordering
reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => void
moveNode: (nodeId: string, targetParentId: string, targetIndex: number) => void
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => void
// Actions - Selection
selectNode: (nodeId: string | null) => void
// Actions - Validation
validate: () => ValidationError[]
clearValidation: () => void
// Actions - Save/Draft
autoSaveDraft: () => void
markSaved: () => void
getTreeForSave: () => TreeCreate | TreeUpdate
// Actions - Code Mode
setEditorMode: (mode: 'form' | 'code') => void
setMarkdownSource: (markdown: string) => void
setMarkdownValidationResult: (result: TreeMarkdownValidation) => void
syncMarkdownToTree: () => void
syncTreeToMarkdown: () => void
// Actions - AI Integration
replaceTreeStructure: (structure: TreeStructure) => void
// Actions - State
setLoading: (loading: boolean) => void
setSaving: (saving: boolean) => void
// Helpers
findNode: (nodeId: string) => TreeStructure | null
getAllNodeIds: () => string[]
getAvailableTargetNodes: (excludeNodeId?: string) => Array<{ id: string; label: string; type: NodeType }>
}
// Create store with immer and temporal (undo/redo) middleware
export const useTreeEditorStore = create<TreeEditorState>()(
temporal(
immer((set, get) => ({
// Initial state
treeId: null,
name: '',
description: '',
category: '',
categoryId: null,
tags: [],
isPublic: false,
treeStructure: null,
originalTree: null,
selectedNodeId: null,
isDirty: false,
isLoading: false,
isSaving: false,
validationErrors: [],
editorMode: 'form',
markdownSource: null,
markdownValidationErrors: [],
isMarkdownValid: true,
isValidating: false,
lastValidTreeFromMarkdown: null,
lastSavedAt: null,
draftSavedAt: null,
hasDraft: false,
// Check for existing draft on init
initNewTree: () => {
const hasDraft = safeGetItem(DRAFT_STORAGE_KEY) !== null
set((state) => {
state.treeId = null
state.name = ''
state.description = ''
state.category = ''
state.categoryId = null
state.tags = []
state.isPublic = false
state.treeStructure = {
id: 'root',
type: 'decision',
question: '',
options: [],
children: []
}
state.originalTree = null
state.selectedNodeId = 'root'
state.isDirty = false
state.isLoading = false
state.isSaving = false
state.validationErrors = []
state.editorMode = 'form'
state.markdownSource = null
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
state.lastValidTreeFromMarkdown = null
state.lastSavedAt = null
state.draftSavedAt = null
state.hasDraft = hasDraft
})
},
loadTree: (tree: Tree) => {
set((state) => {
state.treeId = tree.id
state.name = tree.name
state.description = tree.description || ''
state.category = tree.category || ''
state.categoryId = tree.category_id || null
state.tags = tree.tags || []
state.isPublic = tree.is_public || false
state.treeStructure = tree.tree_structure
state.originalTree = tree
state.selectedNodeId = tree.tree_structure?.id || null
state.isDirty = false
state.isLoading = false
state.validationErrors = []
state.lastSavedAt = new Date()
state.draftSavedAt = null
state.hasDraft = false
})
},
loadDraft: () => {
const draftJson = safeGetItem(DRAFT_STORAGE_KEY)
if (!draftJson) return false
try {
const draft = JSON.parse(draftJson)
set((state) => {
state.treeId = draft.treeId || null
state.name = draft.name || ''
state.description = draft.description || ''
state.category = draft.category || ''
state.categoryId = draft.categoryId || null
state.tags = draft.tags || []
state.isPublic = draft.isPublic || false
state.treeStructure = draft.treeStructure || null
state.isDirty = true
state.draftSavedAt = draft.savedAt ? new Date(draft.savedAt) : null
state.hasDraft = false
})
return true
} catch {
safeRemoveItem(DRAFT_STORAGE_KEY)
return false
}
},
discardDraft: () => {
safeRemoveItem(DRAFT_STORAGE_KEY)
set((state) => {
state.hasDraft = false
})
},
reset: () => {
set((state) => {
state.treeId = null
state.name = ''
state.description = ''
state.category = ''
state.categoryId = null
state.tags = []
state.isPublic = false
state.treeStructure = null
state.originalTree = null
state.selectedNodeId = null
state.isDirty = false
state.isLoading = false
state.isSaving = false
state.validationErrors = []
state.editorMode = 'form'
state.markdownSource = null
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
state.lastValidTreeFromMarkdown = null
state.lastSavedAt = null
state.draftSavedAt = null
state.hasDraft = false
})
},
// Metadata actions
setName: (name: string) => {
set((state) => {
state.name = name
state.isDirty = true
})
get().autoSaveDraft()
},
setDescription: (description: string) => {
set((state) => {
state.description = description
state.isDirty = true
})
get().autoSaveDraft()
},
setCategory: (category: string) => {
set((state) => {
state.category = category
state.isDirty = true
})
get().autoSaveDraft()
},
setCategoryId: (categoryId: string | null) => {
set((state) => {
state.categoryId = categoryId
state.isDirty = true
})
get().autoSaveDraft()
},
setTags: (tags: string[]) => {
set((state) => {
state.tags = tags
state.isDirty = true
})
get().autoSaveDraft()
},
addTag: (tag: string) => {
set((state) => {
if (!state.tags.includes(tag)) {
state.tags.push(tag)
state.isDirty = true
}
})
get().autoSaveDraft()
},
removeTag: (tag: string) => {
set((state) => {
state.tags = state.tags.filter(t => t !== tag)
state.isDirty = true
})
get().autoSaveDraft()
},
setIsPublic: (isPublic: boolean) => {
set((state) => {
state.isPublic = isPublic
state.isDirty = true
})
get().autoSaveDraft()
},
// Node CRUD
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => {
const newId = generateId()
const newNode: TreeStructure = {
id: newId,
type,
...(type === 'decision' && {
question: '',
options: [],
children: []
}),
...(type === 'action' && {
title: '',
description: ''
}),
...(type === 'solution' && {
title: '',
description: ''
})
}
set((state) => {
if (!parentId) {
// Adding as root
state.treeStructure = newNode
} else {
// Find parent and add to children
const parent = findNodeInTree(parentId, state.treeStructure)
if (parent) {
if (!parent.children) {
parent.children = []
}
if (insertIndex !== undefined && insertIndex >= 0) {
parent.children.splice(insertIndex, 0, newNode)
} else {
parent.children.push(newNode)
}
}
}
state.selectedNodeId = newId
state.isDirty = true
})
get().autoSaveDraft()
return newId
},
updateNode: (nodeId: string, updates: Partial<TreeStructure>) => {
set((state) => {
const node = findNodeInTree(nodeId, state.treeStructure)
if (node) {
Object.assign(node, updates)
state.isDirty = true
}
})
get().autoSaveDraft()
},
deleteNode: (nodeId: string) => {
if (nodeId === 'root') {
// Don't allow deleting root, just clear it
set((state) => {
if (state.treeStructure) {
state.treeStructure.question = ''
state.treeStructure.options = []
state.treeStructure.children = []
}
state.isDirty = true
})
get().autoSaveDraft()
return
}
set((state) => {
const parent = findParentNode(nodeId, state.treeStructure)
if (parent && parent.children) {
const index = parent.children.findIndex(c => c.id === nodeId)
if (index !== -1) {
parent.children.splice(index, 1)
}
}
// Clear selection if deleted node was selected
if (state.selectedNodeId === nodeId) {
state.selectedNodeId = parent?.id || 'root'
}
state.isDirty = true
})
get().autoSaveDraft()
},
duplicateNode: (nodeId: string) => {
const state = get()
const node = findNodeInTree(nodeId, state.treeStructure)
if (!node) return null
const clonedNode = deepCloneNode(node)
// Find parent and add cloned node as sibling
const parent = findParentNode(nodeId, state.treeStructure)
set((s) => {
if (parent && parent.children) {
const index = parent.children.findIndex(c => c.id === nodeId)
parent.children.splice(index + 1, 0, clonedNode)
} else if (nodeId === 'root') {
// Can't duplicate root - just select it
return
}
s.selectedNodeId = clonedNode.id
s.isDirty = true
})
get().autoSaveDraft()
return clonedNode.id
},
// Reordering
reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => {
set((state) => {
const parent = findNodeInTree(parentId, state.treeStructure)
if (parent && parent.children && parent.children.length > 0) {
const [moved] = parent.children.splice(fromIndex, 1)
parent.children.splice(toIndex, 0, moved)
state.isDirty = true
}
})
get().autoSaveDraft()
},
moveNode: (nodeId: string, targetParentId: string, targetIndex: number) => {
set((state) => {
// Find and remove from current parent
const currentParent = findParentNode(nodeId, state.treeStructure)
if (!currentParent?.children) return
const sourceIndex = currentParent.children.findIndex(c => c.id === nodeId)
if (sourceIndex === -1) return
const [movedNode] = currentParent.children.splice(sourceIndex, 1)
// Find target parent and insert
const targetParent = findNodeInTree(targetParentId, state.treeStructure)
if (!targetParent) return
if (!targetParent.children) {
targetParent.children = []
}
// Adjust index if moving within same parent and source was before target
let adjustedIndex = targetIndex
if (currentParent.id === targetParent.id && sourceIndex < targetIndex) {
adjustedIndex = targetIndex - 1
}
targetParent.children.splice(adjustedIndex, 0, movedNode)
state.isDirty = true
})
get().autoSaveDraft()
},
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => {
set((state) => {
const node = findNodeInTree(nodeId, state.treeStructure)
if (node && node.options && node.options.length > 0) {
const [moved] = node.options.splice(fromIndex, 1)
node.options.splice(toIndex, 0, moved)
state.isDirty = true
}
})
get().autoSaveDraft()
},
// Selection
selectNode: (nodeId: string | null) => {
set((state) => {
state.selectedNodeId = nodeId
})
},
// Validation
validate: () => {
const state = get()
const errors: ValidationError[] = []
// Check tree name
if (!state.name.trim()) {
errors.push({ message: 'Tree name is required', severity: 'error' })
}
// Check tree structure exists
if (!state.treeStructure) {
errors.push({ message: 'Tree must have at least one node', severity: 'error' })
set((s) => { s.validationErrors = errors })
return errors
}
const allNodeIds = getAllNodeIds(state.treeStructure)
const referencedIds = new Set<string>()
let hasSolution = false
// Traverse and validate all nodes
const validateNode = (node: TreeStructure) => {
// Check type-specific required fields
if (node.type === 'decision') {
if (!node.question?.trim()) {
errors.push({
nodeId: node.id,
field: 'question',
message: `Decision node "${node.id}" requires a question`,
severity: 'error'
})
}
if (!node.options || node.options.length === 0) {
errors.push({
nodeId: node.id,
field: 'options',
message: `Decision node "${node.id}" requires at least one option`,
severity: 'error'
})
}
// Decision nodes with children must have at least 2 branches
if (node.children && node.children.length > 0 && node.children.length < 2) {
errors.push({
nodeId: node.id,
field: 'children',
message: `Decision node "${node.id}" must have at least 2 branches`,
severity: 'error'
})
}
if (node.options && node.options.length > 0) {
// Validate options
node.options.forEach((opt, i) => {
if (!opt.label?.trim()) {
errors.push({
nodeId: node.id,
field: `options[${i}].label`,
message: `Option ${i + 1} in "${node.id}" requires a label`,
severity: 'error'
})
}
if (opt.next_node_id) {
referencedIds.add(opt.next_node_id)
if (!allNodeIds.includes(opt.next_node_id)) {
errors.push({
nodeId: node.id,
field: `options[${i}].next_node_id`,
message: `Option "${opt.label}" references non-existent node "${opt.next_node_id}"`,
severity: 'error'
})
}
}
})
}
}
if (node.type === 'action') {
if (!node.title?.trim()) {
errors.push({
nodeId: node.id,
field: 'title',
message: `Action node "${node.id}" requires a title`,
severity: 'error'
})
}
if (node.next_node_id) {
referencedIds.add(node.next_node_id)
if (!allNodeIds.includes(node.next_node_id)) {
errors.push({
nodeId: node.id,
field: 'next_node_id',
message: `Action "${node.title}" references non-existent node "${node.next_node_id}"`,
severity: 'error'
})
}
}
}
if (node.type === 'solution') {
hasSolution = true
if (!node.title?.trim()) {
errors.push({
nodeId: node.id,
field: 'title',
message: `Solution node "${node.id}" requires a title`,
severity: 'error'
})
}
}
// Validate children
if (node.children) {
node.children.forEach(child => validateNode(child))
}
}
validateNode(state.treeStructure)
// Check for circular references in next_node_id chains
const detectCircularRefs = (startId: string, visited: Set<string> = new Set()): boolean => {
if (visited.has(startId)) return true
visited.add(startId)
const node = findNodeInTree(startId, state.treeStructure)
if (!node) return false
// Check options
if (node.options) {
for (const opt of node.options) {
if (opt.next_node_id && detectCircularRefs(opt.next_node_id, new Set(visited))) {
errors.push({
nodeId: node.id,
message: `This path loops back to an earlier node via "${opt.label}"`,
severity: 'warning'
})
return true
}
}
}
// Check next_node_id
if (node.next_node_id && detectCircularRefs(node.next_node_id, new Set(visited))) {
errors.push({
nodeId: node.id,
message: `This node loops back to an earlier node ("${node.title || node.id}")`,
severity: 'warning'
})
return true
}
return false
}
// Run from root
detectCircularRefs('root')
// Check for at least one solution
if (!hasSolution) {
errors.push({
message: 'Tree must have at least one solution (terminal) node',
severity: 'error'
})
}
// Check for orphaned nodes (not root and not referenced)
allNodeIds.forEach(id => {
if (id !== state.treeStructure?.id && !referencedIds.has(id)) {
// Check if it's a direct child of another node (via children array)
let isChildOfAny = false
const checkIfChild = (node: TreeStructure) => {
if (node.children?.some(c => c.id === id)) {
isChildOfAny = true
}
node.children?.forEach(checkIfChild)
}
checkIfChild(state.treeStructure!)
if (!isChildOfAny) {
errors.push({
nodeId: id,
message: `Node "${id}" is orphaned (not reachable from root)`,
severity: 'warning'
})
}
}
})
set((s) => { s.validationErrors = errors })
return errors
},
clearValidation: () => {
set((state) => { state.validationErrors = [] })
},
// Auto-save draft (debounced externally, called after each change)
autoSaveDraft: () => {
const state = get()
const draft = {
treeId: state.treeId,
name: state.name,
description: state.description,
category: state.category,
categoryId: state.categoryId,
tags: state.tags,
isPublic: state.isPublic,
treeStructure: state.treeStructure,
savedAt: new Date().toISOString()
}
safeSetItem(DRAFT_STORAGE_KEY, JSON.stringify(draft))
set((s) => { s.draftSavedAt = new Date() })
},
markSaved: () => {
safeRemoveItem(DRAFT_STORAGE_KEY)
set((state) => {
state.isDirty = false
state.lastSavedAt = new Date()
state.draftSavedAt = null
state.hasDraft = false
})
},
getTreeForSave: (): TreeCreate | TreeUpdate => {
const state = get()
return {
name: state.name,
description: state.description || undefined,
category: state.category || undefined,
category_id: state.categoryId || undefined,
tags: state.tags.length > 0 ? state.tags : undefined,
is_public: state.isPublic,
tree_structure: state.treeStructure!
}
},
// Code Mode actions
setEditorMode: (mode: 'form' | 'code') => {
const current = get()
if (mode === current.editorMode) return
if (mode === 'code') {
// Form → Code: generate markdown from tree structure (synchronous)
const { treeStructure, name, description, category, tags } = current
if (isEmptyTree(treeStructure) && !name.trim()) {
// New empty tree: use starter template
set((state) => {
state.markdownSource = CODE_MODE_STARTER_TEMPLATE
state.markdownValidationErrors = []
state.isMarkdownValid = false // needs validation
state.isValidating = false
state.editorMode = mode
})
} else if (treeStructure) {
const metadata = { name, description, category, tags }
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
set((state) => {
state.markdownSource = md
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
state.editorMode = mode
})
} else {
set((state) => { state.editorMode = mode })
}
} else {
// Code → Form: apply last valid parse to tree structure
get().syncMarkdownToTree()
set((state) => { state.editorMode = mode })
}
},
setMarkdownSource: (markdown: string) => {
set((state) => {
state.markdownSource = markdown
state.isDirty = true
state.isValidating = true
})
},
setMarkdownValidationResult: (result: TreeMarkdownValidation) => {
set((state) => {
state.markdownValidationErrors = result.errors
state.isMarkdownValid = result.valid
state.isValidating = false
if (result.valid && result.tree_structure) {
state.lastValidTreeFromMarkdown = result.tree_structure as TreeStructure
// Live sync: apply valid parsed tree to treeStructure immediately
state.treeStructure = result.tree_structure as TreeStructure
}
// Apply metadata from parsed markdown (bidirectional sync)
if (result.metadata) {
if (result.metadata.name !== undefined) state.name = result.metadata.name
if (result.metadata.description !== undefined) state.description = result.metadata.description
if (result.metadata.category !== undefined) state.category = result.metadata.category
if (result.metadata.tags !== undefined) state.tags = result.metadata.tags
}
})
},
syncMarkdownToTree: () => {
const { lastValidTreeFromMarkdown, isMarkdownValid } = get()
if (isMarkdownValid && lastValidTreeFromMarkdown) {
set((state) => {
state.treeStructure = lastValidTreeFromMarkdown
state.markdownSource = null
state.isDirty = true
})
}
},
syncTreeToMarkdown: () => {
const { treeStructure, name, description, category, tags } = get()
if (treeStructure) {
const metadata = { name, description, category, tags }
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
set((state) => {
state.markdownSource = md
state.markdownValidationErrors = []
state.isMarkdownValid = true
state.isValidating = false
})
}
},
// AI Integration
replaceTreeStructure: (structure: TreeStructure) => {
set((state) => {
state.treeStructure = structure
state.selectedNodeId = structure.id
state.isDirty = true
})
},
setLoading: (loading: boolean) => {
set((state) => { state.isLoading = loading })
},
setSaving: (saving: boolean) => {
set((state) => { state.isSaving = saving })
},
// Helpers
findNode: (nodeId: string) => {
return findNodeInTree(nodeId, get().treeStructure)
},
getAllNodeIds: () => {
return getAllNodeIds(get().treeStructure)
},
getAvailableTargetNodes: (excludeNodeId?: string) => {
const state = get()
const allIds = getAllNodeIds(state.treeStructure)
return allIds
.filter(id => id !== excludeNodeId)
.map(id => {
const node = findNodeInTree(id, state.treeStructure)
let type: NodeType = 'decision'
let title = ''
if (node) {
type = node.type
if (node.question) title = node.question.slice(0, 50)
else if (node.title) title = node.title.slice(0, 50)
}
// Use short ID format, but 'root' stays as-is
const shortId = id === 'root' ? 'root' : id.slice(0, 8) + '...'
// Format: "Title (shortId)" or just "(shortId)" if no title
// For root without a question, show "Root Question (root)"
let label: string
if (id === 'root') {
label = title ? `${title} (root)` : 'Root Question (root)'
} else if (title) {
label = `${title} (${shortId})`
} else {
// No title yet - show placeholder with ID
const typeName = type === 'decision' ? 'Untitled Question' : `Untitled ${type}`
label = `${typeName} (${shortId})`
}
return { id, label, type }
})
}
})),
{
// Zundo options for undo/redo
limit: 50, // Keep last 50 states
partialize: (state) => ({
// Only track these fields in history
name: state.name,
description: state.description,
category: state.category,
categoryId: state.categoryId,
tags: state.tags,
isPublic: state.isPublic,
treeStructure: state.treeStructure
}),
// Skip no-op entries where partialized fields haven't changed
equality: (pastState, currentState) => shallow(pastState, currentState),
// Throttle history captures: collapse rapid changes (typing, validation) into ~one entry per 3s
// This makes Flow Mode undo revert meaningful chunks (whole field edits) instead of single characters
handleSet: (handleSet) =>
throttle<typeof handleSet>((state) => {
handleSet(state)
}, 3000),
}
)
)
// Export temporal store for undo/redo access
// Use with: useStore(useTreeEditorStore.temporal, selector)
export const useTreeEditorTemporal = useTreeEditorStore.temporal
export default useTreeEditorStore