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

@@ -0,0 +1,186 @@
import { ChevronDown, AlertCircle, CheckCircle2, Loader2, Plus, HelpCircle } from 'lucide-react'
import { useState, useRef, useEffect } from 'react'
import { cn } from '@/lib/utils'
import type { TreeMarkdownValidationError } from '@/types'
interface CodeModeToolbarProps {
validationErrors: TreeMarkdownValidationError[]
isValidating: boolean
isValid: boolean
onInsertTemplate: (template: string) => void
onToggleSyntaxHelp: () => void
syntaxHelpOpen: boolean
}
const NODE_TEMPLATES = {
decision: [
'',
'---',
'id: new_decision',
'type: decision',
'parent: root',
'---',
'# What is the question?',
'',
'> Help text for the engineer',
'',
'- [A] Option A → @target_id',
'- [B] Option B → @target_id',
'',
].join('\n'),
action: [
'',
'---',
'id: new_action',
'type: action',
'parent: root',
'---',
'## Action Title',
'',
'Description of what to do.',
'',
'```commands',
'command here',
'```',
'',
'**Expected:** Expected outcome',
'',
'→ @next_node_id',
'',
].join('\n'),
solution: [
'',
'---',
'id: new_solution',
'type: solution',
'parent: root',
'---',
'## Solution Title',
'',
'Description of the resolution.',
'',
'1. Step 1',
'2. Step 2',
'',
].join('\n'),
}
export function CodeModeToolbar({
validationErrors,
isValidating,
isValid,
onInsertTemplate,
onToggleSyntaxHelp,
syntaxHelpOpen,
}: CodeModeToolbarProps) {
const [insertOpen, setInsertOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const errorCount = validationErrors.filter(e => e.severity === 'error').length
const warningCount = validationErrors.filter(e => e.severity === 'warning').length
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setInsertOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
return (
<div className="flex items-center justify-between border-b border-white/[0.06] bg-black/50 px-3 py-1.5">
<div className="flex items-center gap-2">
{/* Insert Node dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setInsertOpen(!insertOpen)}
className={cn(
'flex items-center gap-1 rounded-md border border-white/10 px-2.5 py-1 text-xs font-medium text-white/60',
'hover:bg-white/10 hover:text-white'
)}
>
<Plus className="h-3 w-3" />
Insert Node
<ChevronDown className="h-3 w-3" />
</button>
{insertOpen && (
<div className="absolute left-0 top-full z-50 mt-1 w-44 rounded-lg border border-white/10 bg-[#111] py-1 shadow-xl">
<button
onClick={() => { onInsertTemplate(NODE_TEMPLATES.decision); setInsertOpen(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
>
<span className="h-2 w-2 rounded-full bg-blue-400" />
Decision
</button>
<button
onClick={() => { onInsertTemplate(NODE_TEMPLATES.action); setInsertOpen(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
>
<span className="h-2 w-2 rounded-full bg-amber-400" />
Action
</button>
<button
onClick={() => { onInsertTemplate(NODE_TEMPLATES.solution); setInsertOpen(false) }}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs text-white/70 hover:bg-white/10"
>
<span className="h-2 w-2 rounded-full bg-emerald-400" />
Solution
</button>
</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
{/* Validation status */}
<div className="flex items-center gap-1.5 text-xs">
{isValidating ? (
<>
<Loader2 className="h-3 w-3 animate-spin text-white/40" />
<span className="text-white/40">Validating...</span>
</>
) : isValid ? (
<>
<CheckCircle2 className="h-3 w-3 text-emerald-400" />
<span className="text-emerald-400/70">Synced</span>
{warningCount > 0 && (
<span className="text-yellow-400/70">
({warningCount} warning{warningCount !== 1 ? 's' : ''})
</span>
)}
</>
) : (
<>
<AlertCircle className="h-3 w-3 text-red-400" />
<span className="text-red-400/70">
{errorCount} error{errorCount !== 1 ? 's' : ''}
</span>
{warningCount > 0 && (
<span className="text-yellow-400/70">
, {warningCount} warning{warningCount !== 1 ? 's' : ''}
</span>
)}
</>
)}
</div>
{/* Syntax Help toggle */}
<button
onClick={onToggleSyntaxHelp}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-xs',
syntaxHelpOpen
? 'bg-white/10 text-white'
: 'text-white/40 hover:bg-white/[0.06] hover:text-white/60'
)}
>
<HelpCircle className="h-3 w-3" />
Syntax
</button>
</div>
</div>
)
}