import { useEffect, useState, useCallback, useRef } from 'react' import { useParams, useNavigate, useBlocker } from 'react-router-dom' import { useStore } from 'zustand' import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings, Download, Sparkles } from 'lucide-react' import { analytics } from '@/lib/analytics' import { Button } from '@/components/ui/Button' import { getMonacoEditor } from '@/components/tree-editor/code-mode' import { treesApi } from '@/api/trees' import { treeMarkdownApi } from '@/api/treeMarkdown' import type { TreeCreate, TreeUpdate, TreeStatus, TreeStructure, AIFixProposal } from '@/types' import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore' import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout' import { ValidationSummary } from '@/components/tree-editor/ValidationSummary' import { AIFixReviewModal } from '@/components/tree-editor/AIFixReviewModal' import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts' import { usePermissions } from '@/hooks/usePermissions' import { Spinner } from '@/components/common/Spinner' import { cn, safeGetItem } from '@/lib/utils' import { toast } from '@/lib/toast' import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel' import { ExportFlowModal } from '@/components/library/ExportFlowModal' import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel' import { ContextMenu } from '@/components/common/ContextMenu' import { useEditorAI } from '@/hooks/useEditorAI' import { findNodeInTree } from '@/store/treeEditorStore' /** 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() const isEditMode = !!id const { canCreateTrees, canEditTree } = usePermissions() const { name, description, isDirty, isLoading, isSaving, validationErrors, editorMode, treeStructure, initNewTree, loadTree, loadDraft, discardDraft, reset, validate, getTreeForSave, markSaved, setLoading, setSaving, selectNode, updateNode, deleteNode, setEditorMode, getAllNodeIds, replaceTreeStructure, } = useTreeEditorStore() // Access undo/redo from temporal store const { undo, redo, pastStates, futureStates } = useStore(useTreeEditorTemporal) const [showDraftPrompt, setShowDraftPrompt] = useState(false) const [treeStatus, setTreeStatus] = useState('draft') const [showAnalytics, setShowAnalytics] = useState(false) const [isMetadataOpen, setIsMetadataOpen] = useState(false) const [editingNodeId, setEditingNodeId] = useState(null) const [isFixing, setIsFixing] = useState(false) const [fixProposals, setFixProposals] = useState(null) const [showExportModal, setShowExportModal] = useState(false) const [importMetadata, setImportMetadata] = useState | null>(null) // Mobile detection const [isMobile, setIsMobile] = useState(false) useEffect(() => { const check = () => setIsMobile(window.innerWidth < 768) check() window.addEventListener('resize', check) return () => window.removeEventListener('resize', check) }, []) // AI Assist panel const handleFlowUpdate = useCallback((workingTree: Record) => { // For troubleshooting flows, working_tree is the tree structure directly if (workingTree.type && workingTree.id) { replaceTreeStructure(workingTree as unknown as TreeStructure) } }, [replaceTreeStructure]) const editorAI = useEditorAI({ flowType: 'troubleshooting', treeId: id, getFlowContext: useCallback(() => { if (!treeStructure) return null return { name, description, tree_structure: treeStructure as unknown as Record, } }, [treeStructure, name, description]), onFlowUpdate: handleFlowUpdate, }) const previousEditingNodeRef = useRef(null) const handleAIPanelClose = useCallback(() => { editorAI.closePanel() if (previousEditingNodeRef.current) { setEditingNodeId(previousEditingNodeRef.current) previousEditingNodeRef.current = null } }, [editorAI]) // Calculate if there are blocking errors const hasBlockingErrors = validationErrors.some(e => e.severity === 'error') // Block navigation if there are unsaved changes const blocker = useBlocker( ({ currentLocation, nextLocation }) => isDirty && currentLocation.pathname !== nextLocation.pathname ) const handleUndo = useCallback(() => { if (editorMode === 'code') { // In Code Mode, use Monaco's native undo (word-level, like VS Code) const editor = getMonacoEditor() if (editor) { editor.trigger('toolbar', 'undo', null) editor.focus() return } } if (pastStates.length > 0) { undo() toast.info('Undone') } }, [editorMode, pastStates.length, undo]) const handleRedo = useCallback(() => { if (editorMode === 'code') { // In Code Mode, use Monaco's native redo (word-level, like VS Code) const editor = getMonacoEditor() if (editor) { editor.trigger('toolbar', 'redo', null) editor.focus() return } } if (futureStates.length > 0) { redo() toast.info('Redone') } }, [editorMode, futureStates.length, redo]) // Keyboard shortcuts for undo/redo/save useKeyboardShortcuts([ { key: 'z', ctrl: true, handler: handleUndo }, { key: 'z', ctrl: true, shift: true, handler: handleRedo }, { key: 's', ctrl: true, handler: () => { handleSave() } }, { key: 'm', ctrl: true, shift: true, handler: () => { setEditorMode(editorMode === 'form' ? 'code' : 'form') } } ]) // Permission guard: redirect viewers away from editor useEffect(() => { if (!canCreateTrees) { toast.error("You don't have permission to edit flows") navigate('/trees') } }, [canCreateTrees, navigate]) // Initialize or load tree useEffect(() => { if (!canCreateTrees) return const initialize = async () => { if (isEditMode) { setLoading(true) try { const tree = await treesApi.get(id) if (!canEditTree({ author_id: tree.author_id, account_id: tree.account_id })) { toast.error("You don't have permission to edit this flow") navigate('/trees') return } loadTree(tree) setTreeStatus(tree.status) // Load status from existing tree if (tree.import_metadata) setImportMetadata(tree.import_metadata) } catch (err) { console.error('Failed to load tree:', err) toast.error('Failed to load flow') navigate('/trees') } } else { initNewTree() setTreeStatus('draft') // New trees start as draft // Check for draft after initializing const draftExists = safeGetItem('tree-editor-draft') !== null if (draftExists) { setShowDraftPrompt(true) } } } initialize() return () => { reset() } }, [id, isEditMode, canCreateTrees]) // eslint-disable-line react-hooks/exhaustive-deps -- initialization is keyed to route/editability state // Handle unsaved changes warning useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { if (isDirty) { e.preventDefault() e.returnValue = '' } } window.addEventListener('beforeunload', handleBeforeUnload) return () => window.removeEventListener('beforeunload', handleBeforeUnload) }, [isDirty]) const handleRestoreDraft = () => { loadDraft() setShowDraftPrompt(false) } const handleDiscardDraft = () => { discardDraft() setShowDraftPrompt(false) } const handleManualValidate = () => { validate() } const handleSelectNode = (nodeId: string) => { handleNodeSelect(nodeId) } const handleFixWithAI = async () => { const store = useTreeEditorStore.getState() if (!store.treeStructure) return const fixableErrors = store.validationErrors .filter(e => e.severity === 'error' && e.nodeId) .map(e => ({ node_id: e.nodeId!, message: e.message })) if (fixableErrors.length === 0) return setIsFixing(true) try { const result = await treesApi.fixTree({ tree_structure: store.treeStructure as unknown as Record, tree_name: store.name, tree_type: 'troubleshooting', validation_errors: fixableErrors, }) if (result.fixes.length > 0) { setFixProposals(result.fixes) } else { toast.info('AI could not generate fixes for these errors') } } catch { toast.error('Failed to generate AI fixes. Please try again.') } finally { setIsFixing(false) } } const handleApplyFix = (fix: AIFixProposal) => { updateNode(fix.target_node_id, fix.fixed_node as Partial) } const handleApplyAllFixes = () => { if (!fixProposals) return for (const fix of fixProposals) { handleApplyFix(fix) } setFixProposals(null) setTimeout(() => { validate() }, 100) } const handleCloseFixModal = () => { setFixProposals(null) validate() } const handleNodeSelect = useCallback((nodeId: string | null) => { if (nodeId) { setIsMetadataOpen(false) // close metadata when opening node editor } setEditingNodeId(nodeId) selectNode(nodeId) }, [selectNode]) const handleSelectAnswerType = useCallback((nodeId: string, type: 'decision' | 'action' | 'solution') => { updateNode(nodeId, { type }) // Keep the panel open on the same node — it will now show the form for the new type setEditingNodeId(nodeId) selectNode(nodeId) }, [updateNode, selectNode]) const handleSaveDraft = useCallback(async () => { if (isSaving) return setSaving(true) try { // In Code Mode, run fresh validation on current markdown before saving if (editorMode === 'code') { const { markdownSource, setMarkdownValidationResult } = useTreeEditorStore.getState() if (markdownSource) { const result = await treeMarkdownApi.validateMarkdown(markdownSource) setMarkdownValidationResult(result) // applies tree_structure + metadata to store if (!result.valid) { const errorCount = result.errors.filter(e => e.severity === 'error').length toast.error(`Cannot save: ${errorCount} markdown error${errorCount !== 1 ? 's' : ''} — fix them in the editor first`) setSaving(false) return } } } // Check tree name is set (metadata may come from Code Mode markdown) const currentState = useTreeEditorStore.getState() if (!currentState.name.trim()) { toast.error('Tree name is required. Add a "name:" field in the metadata block or switch to Flow Mode.') setSaving(false) return } const treeData = { ...getTreeForSave(), status: 'draft' as TreeStatus } if (isEditMode) { await treesApi.update(id!, treeData as TreeUpdate) setTreeStatus('draft') markSaved() toast.success('Draft saved successfully') } else { const newTree = await treesApi.create(treeData as TreeCreate) analytics.flowCreated({ flow_type: 'troubleshooting', method: 'manual' }) setTreeStatus('draft') markSaved() toast.success('Draft created successfully') navigate(`/trees/${newTree.id}/edit`, { replace: true }) } } catch (err: unknown) { console.error('Failed to save draft:', err) if (err && typeof err === 'object' && 'response' in err) { const axiosErr = err as { response?: { status?: number; data?: { detail?: string | { message?: string; errors?: string[] } } } } if (axiosErr.response?.status === 422) { const detail = axiosErr.response.data?.detail if (typeof detail === 'object' && detail?.errors) { toast.error(`Validation failed: ${detail.errors.join(', ')}`) } else if (typeof detail === 'string') { toast.error(`Validation failed: ${detail}`) } else { toast.error('Tree has validation errors. Fix them before saving.') } return } } toast.error('Failed to save draft. Please try again.') } finally { setSaving(false) } }, [isSaving, isEditMode, id, editorMode, getTreeForSave, markSaved, navigate, setSaving]) const handlePublish = useCallback(async () => { if (isSaving) return setSaving(true) try { // In Code Mode, run fresh validation on current markdown before publishing if (editorMode === 'code') { const { markdownSource, setMarkdownValidationResult } = useTreeEditorStore.getState() if (markdownSource) { const result = await treeMarkdownApi.validateMarkdown(markdownSource) setMarkdownValidationResult(result) if (!result.valid) { const errorCount = result.errors.filter(e => e.severity === 'error').length toast.error(`Cannot publish: ${errorCount} markdown error${errorCount !== 1 ? 's' : ''} — fix them in the editor first`) setSaving(false) return } } } // Check tree name is set const currentState = useTreeEditorStore.getState() if (!currentState.name.trim()) { toast.error('Tree name is required. Add a "name:" field in the metadata block or switch to Flow Mode.') setSaving(false) 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') if (hasErrors) { toast.error('Please fix validation errors before publishing') setSaving(false) return } const treeData = { ...getTreeForSave(), status: 'published' as TreeStatus } if (isEditMode) { await treesApi.update(id!, treeData as TreeUpdate) setTreeStatus('published') markSaved() toast.success('Tree published successfully') } else { const newTree = await treesApi.create(treeData as TreeCreate) analytics.flowCreated({ flow_type: 'troubleshooting', method: 'manual' }) setTreeStatus('published') markSaved() toast.success('Tree published successfully') navigate(`/trees/${newTree.id}/edit`, { replace: true }) } } catch (err: unknown) { console.error('Failed to publish tree:', err) if (err && typeof err === 'object' && 'response' in err) { const axiosErr = err as { response?: { status?: number; data?: { detail?: string | { message?: string; errors?: Array } } } } if (axiosErr.response?.status === 422) { const detail = axiosErr.response.data?.detail if (typeof detail === 'object' && detail?.errors) { const messages = detail.errors.map(e => typeof e === 'string' ? e : e.message || 'Unknown error') toast.error(`Cannot publish: ${messages.join(', ')}`) } else if (typeof detail === 'string') { toast.error(`Cannot publish: ${detail}`) } else { toast.error('Tree has validation errors. Fix them before publishing.') } return } } toast.error('Failed to publish tree. Please try again.') } finally { setSaving(false) } }, [isSaving, isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate, setSaving]) // Keep handleSave for backward compatibility (Ctrl+S shortcut) const handleSave = useCallback(async () => { // If tree is already published or has no errors, publish; otherwise save as draft if (treeStatus === 'published' || !hasBlockingErrors) { await handlePublish() } else { await handleSaveDraft() } }, [treeStatus, hasBlockingErrors, handlePublish, handleSaveDraft]) // Handle blocker const handleBlockerProceed = () => { if (blocker.state === 'blocked') { blocker.proceed() } } const handleBlockerReset = () => { if (blocker.state === 'blocked') { blocker.reset() } } if (isLoading) { return (
) } // Mobile gate: show read-only message if (isMobile) { return (

Desktop Required

The tree editor requires a larger screen for the best experience. Please open this page on a desktop or tablet in landscape mode.

) } return (
{/* Main content column */}
{/* Draft Restore Prompt */} {showDraftPrompt && (

Restore Draft?

You have an unsaved draft from a previous session. Would you like to restore it?

)} {/* Unsaved Changes Dialog */} {blocker.state === 'blocked' && (

Unsaved Changes

You have unsaved changes. Are you sure you want to leave?

)} {/* Toolbar */}

{isEditMode ? 'Edit Tree' : 'Create New Tree'} {name && - {name}}

{treeStatus === 'draft' && ( Draft )} {isDirty && ( Unsaved )}
{/* Mode Toggle */}
{/* Undo/Redo */}
{/* Metadata panel toggle — Flow mode only */} {editorMode === 'form' && ( )} {/* Analytics toggle (only for existing trees) */} {isEditMode && ( )} {/* Validate */} {isEditMode && ( )} {/* AI Assist toggle */} {/* Save Draft */} {/* Publish */}
{/* Validation Summary */} {validationErrors.length > 0 && (
)} {/* Import provenance */} {importMetadata && (
Imported{importMetadata.original_author_name ? ` from ${importMetadata.original_author_name}` : ''} {importMetadata.imported_at ? ` on ${new Date(importMetadata.imported_at).toLocaleDateString()}` : ''}
)} {/* Main Editor */}
setIsMetadataOpen(false)} editingNodeId={editorAI.isOpen ? null : editingNodeId} onNodeSelect={handleNodeSelect} onSelectAnswerType={handleSelectAnswerType} onNodeDelete={deleteNode} onNodeContextMenu={editorAI.openContextMenu} />
{/* Flow Analytics Panel (collapsible) */} {showAnalytics && id && (
)} {/* AI Fix Review Modal */} {fixProposals && ( )} {/* Export Modal */} {showExportModal && id && ( setShowExportModal(false)} /> )} {/* AI Context Menu */} {editorAI.contextMenu && ( , onClick: () => { if (editingNodeId) { previousEditingNodeRef.current = editingNodeId setEditingNodeId(null) } editorAI.triggerAction( editorAI.contextMenu!.nodeId, 'generate_branch', `Generate a branch from this node` ) }, }, { id: 'explain', label: 'Explain Node', icon: , onClick: () => { if (editingNodeId) { previousEditingNodeRef.current = editingNodeId setEditingNodeId(null) } editorAI.triggerAction( editorAI.contextMenu!.nodeId, 'quick_action', `Explain what this node does` ) }, }, { id: 'sep1', label: '', onClick: () => {}, separator: true, }, { id: 'delete', label: 'Delete Node', variant: 'danger' as const, onClick: () => deleteNode(editorAI.contextMenu!.nodeId), }, ]} onClose={editorAI.closeContextMenu} /> )}
{/* end main content column */}
) } export default TreeEditorPage