feat: add dual-mode tree editor with Code Mode, variables, and markdown sync

Implements the full dual-mode tree editor (Plan Phases 1-5):

Backend:
- JSONB↔Markdown bidirectional serializer/parser with mistune
- Markdown validator with line/column error reporting
- 3 API endpoints: export-markdown, import-markdown, validate-markdown
- Variable extraction/resolution service ([USER_INPUT], [VAR], [SAVE_AS])
- Session variables JSONB column (migration 028)
- 39 tree markdown tests + variable service tests (403 total passing)

Frontend:
- Monaco-based Code Mode with custom Monarch tokenizer and dark theme
- Autocomplete for @node_id refs, type values, variable names
- Debounced validation (800ms) with inline Monaco error markers
- Syntax help panel (absolute overlay, toggleable)
- Starter template for new trees with valid cross-references
- Bidirectional metadata sync (name/description/category/tags frontmatter)
- Synchronous tree→markdown serializer (fixes async race condition)
- Pre-save validation blocks save on broken refs or missing tree name
- Mode-aware undo/redo: Monaco native in Code Mode, throttled zundo in Flow Mode
- Variable prompt modal and frontend resolver for session navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-10 09:45:26 -05:00
parent 2bd47004e7
commit eac6e184ec
32 changed files with 3369 additions and 52 deletions

View File

@@ -1,7 +1,31 @@
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 } from '@/types'
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType, TreeMarkdownValidationError, TreeMarkdownValidation } from '@/types'
import { treeStructureToMarkdownPreview } from '@/lib/treeMarkdownSync'
// 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 {
@@ -14,6 +38,71 @@ export interface ValidationError {
// 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()
@@ -114,6 +203,14 @@ interface TreeEditorState {
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
@@ -159,6 +256,13 @@ interface TreeEditorState {
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 - State
setLoading: (loading: boolean) => void
setSaving: (saving: boolean) => void
@@ -188,6 +292,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
isLoading: false,
isSaving: false,
validationErrors: [],
editorMode: 'form',
markdownSource: null,
markdownValidationErrors: [],
isMarkdownValid: true,
isValidating: false,
lastValidTreeFromMarkdown: null,
lastSavedAt: null,
draftSavedAt: null,
hasDraft: false,
@@ -216,6 +326,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
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
@@ -292,6 +408,12 @@ export const useTreeEditorStore = create<TreeEditorState>()(
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
@@ -773,6 +895,96 @@ export const useTreeEditorStore = create<TreeEditorState>()(
}
},
// 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
})
}
},
setLoading: (loading: boolean) => {
set((state) => { state.isLoading = loading })
},
@@ -839,7 +1051,15 @@ export const useTreeEditorStore = create<TreeEditorState>()(
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),
}
)
)

View File

@@ -4,6 +4,7 @@ import { persist } from 'zustand/middleware'
type ExportFormat = 'markdown' | 'text' | 'html' | 'psa'
type TreeLibraryView = 'grid' | 'list' | 'table'
type TreeSortBy = 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
type EditorMode = 'form' | 'code'
interface UserPreferencesState {
defaultExportFormat: ExportFormat
@@ -12,6 +13,8 @@ interface UserPreferencesState {
setTreeLibraryView: (view: TreeLibraryView) => void
treeLibrarySortBy: TreeSortBy
setTreeLibrarySortBy: (sortBy: TreeSortBy) => void
preferredEditorMode: EditorMode
setPreferredEditorMode: (mode: EditorMode) => void
}
export const useUserPreferencesStore = create<UserPreferencesState>()(
@@ -23,6 +26,8 @@ export const useUserPreferencesStore = create<UserPreferencesState>()(
setTreeLibraryView: (view) => set({ treeLibraryView: view }),
treeLibrarySortBy: 'usage_count',
setTreeLibrarySortBy: (sortBy) => set({ treeLibrarySortBy: sortBy }),
preferredEditorMode: 'form',
setPreferredEditorMode: (mode) => set({ preferredEditorMode: mode }),
}),
{
name: 'user-preferences-storage',