* 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>
957 lines
33 KiB
TypeScript
957 lines
33 KiB
TypeScript
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 { 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<TreeStatus>('draft')
|
|
const [showAnalytics, setShowAnalytics] = useState(false)
|
|
const [isMetadataOpen, setIsMetadataOpen] = useState(false)
|
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
|
const [isFixing, setIsFixing] = useState(false)
|
|
const [fixProposals, setFixProposals] = useState<AIFixProposal[] | null>(null)
|
|
const [showExportModal, setShowExportModal] = useState(false)
|
|
const [importMetadata, setImportMetadata] = useState<Record<string, string | null> | 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<string, unknown>) => {
|
|
// 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<string, unknown>,
|
|
}
|
|
}, [treeStructure, name, description]),
|
|
onFlowUpdate: handleFlowUpdate,
|
|
})
|
|
|
|
const previousEditingNodeRef = useRef<string | null>(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])
|
|
|
|
// 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) => {
|
|
selectNode(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<string, unknown>,
|
|
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<TreeStructure>)
|
|
}
|
|
|
|
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 () => {
|
|
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)
|
|
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)
|
|
}
|
|
}, [isEditMode, id, editorMode, getTreeForSave, markSaved, navigate])
|
|
|
|
const handlePublish = useCallback(async () => {
|
|
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)
|
|
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<string | { message?: string }> } } } }
|
|
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)
|
|
}
|
|
}, [isEditMode, id, editorMode, validate, getTreeForSave, markSaved, navigate])
|
|
|
|
// 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 (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Spinner />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// Mobile gate: show read-only message
|
|
if (isMobile) {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center px-6 text-center">
|
|
<Monitor className="mb-4 h-12 w-12 text-muted-foreground" />
|
|
<h2 className="mb-2 text-xl font-heading font-semibold text-foreground">Desktop Required</h2>
|
|
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
|
|
The tree editor requires a larger screen for the best experience. Please open this page on a desktop or tablet in landscape mode.
|
|
</p>
|
|
<button
|
|
onClick={() => navigate('/trees')}
|
|
className={cn(
|
|
'rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90'
|
|
)}
|
|
>
|
|
Back to Library
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full overflow-hidden">
|
|
{/* Main content column */}
|
|
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
|
|
{/* Draft Restore Prompt */}
|
|
{showDraftPrompt && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
|
<div className="bg-card border border-border rounded-xl w-full max-w-md p-6 shadow-lg">
|
|
<h2 className="mb-2 text-lg font-heading font-semibold text-foreground">Restore Draft?</h2>
|
|
<p className="mb-4 text-sm text-muted-foreground">
|
|
You have an unsaved draft from a previous session. Would you like to restore it?
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleRestoreDraft}
|
|
className={cn(
|
|
'flex-1 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90'
|
|
)}
|
|
>
|
|
Restore Draft
|
|
</button>
|
|
<button
|
|
onClick={handleDiscardDraft}
|
|
className={cn(
|
|
'flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
Start Fresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Unsaved Changes Dialog */}
|
|
{blocker.state === 'blocked' && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
|
<div className="bg-card border border-border rounded-xl w-full max-w-md p-6 shadow-lg">
|
|
<h2 className="mb-2 text-lg font-heading font-semibold text-foreground">Unsaved Changes</h2>
|
|
<p className="mb-4 text-sm text-muted-foreground">
|
|
You have unsaved changes. Are you sure you want to leave?
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleBlockerReset}
|
|
className={cn(
|
|
'flex-1 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90'
|
|
)}
|
|
>
|
|
Stay
|
|
</button>
|
|
<button
|
|
onClick={handleBlockerProceed}
|
|
className={cn(
|
|
'flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium text-red-400',
|
|
'hover:bg-accent'
|
|
)}
|
|
>
|
|
Leave Without Saving
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => navigate('/trees')}
|
|
className="text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
← Back to Library
|
|
</button>
|
|
<h1 className="text-lg font-heading font-semibold text-foreground">
|
|
{isEditMode ? 'Edit Tree' : 'Create New Tree'}
|
|
{name && <span className="ml-2 text-muted-foreground">- {name}</span>}
|
|
</h1>
|
|
<div className="flex items-center gap-2">
|
|
{treeStatus === 'draft' && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-yellow-900/30 px-2 py-0.5 text-xs font-medium text-yellow-400 border border-yellow-500/20">
|
|
<FileText className="h-3 w-3" />
|
|
Draft
|
|
</span>
|
|
)}
|
|
{isDirty && (
|
|
<span className="rounded-full bg-yellow-500/20 px-2 py-0.5 text-xs text-yellow-400">
|
|
Unsaved
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* Mode Toggle */}
|
|
<div className="flex items-center rounded-md border border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditorMode('form')}
|
|
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'
|
|
? 'bg-accent text-foreground'
|
|
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
|
)}
|
|
>
|
|
<LayoutList className="h-3.5 w-3.5" />
|
|
Flow
|
|
</button>
|
|
<div className="h-5 w-px bg-border" />
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setEditorMode('code')
|
|
setIsMetadataOpen(false) // Auto-close metadata panel on Code mode
|
|
setEditingNodeId(null) // Close node editor 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',
|
|
editorMode === 'code'
|
|
? 'bg-accent text-foreground'
|
|
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
|
|
)}
|
|
>
|
|
<Code2 className="h-3.5 w-3.5" />
|
|
Code
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mx-1 h-6 w-px bg-border" />
|
|
|
|
{/* Undo/Redo */}
|
|
<div className="flex items-center rounded-md border border-border">
|
|
<button
|
|
type="button"
|
|
onClick={handleUndo}
|
|
disabled={pastStates.length === 0}
|
|
title={pastStates.length > 0 ? `Undo (Ctrl+Z) - ${pastStates.length} step${pastStates.length !== 1 ? 's' : ''} available` : 'Nothing to undo'}
|
|
className={cn(
|
|
'rounded-l-md p-2 transition-colors',
|
|
pastStates.length > 0
|
|
? 'text-foreground hover:bg-accent/50 active:bg-accent'
|
|
: 'text-muted-foreground/50 cursor-not-allowed'
|
|
)}
|
|
>
|
|
<Undo2 className="h-4 w-4" />
|
|
</button>
|
|
<div className="h-6 w-px bg-border" />
|
|
<button
|
|
type="button"
|
|
onClick={handleRedo}
|
|
disabled={futureStates.length === 0}
|
|
title={futureStates.length > 0 ? `Redo (Ctrl+Shift+Z) - ${futureStates.length} step${futureStates.length !== 1 ? 's' : ''} available` : 'Nothing to redo'}
|
|
className={cn(
|
|
'rounded-r-md p-2 transition-colors',
|
|
futureStates.length > 0
|
|
? 'text-foreground hover:bg-accent/50 active:bg-accent'
|
|
: 'text-muted-foreground/50 cursor-not-allowed'
|
|
)}
|
|
>
|
|
<Redo2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="mx-2 h-6 w-px bg-border" />
|
|
|
|
{/* Metadata panel toggle — Flow mode only */}
|
|
{editorMode === 'form' && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (!isMetadataOpen) {
|
|
setEditingNodeId(null) // close node editor when opening metadata
|
|
}
|
|
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
|
|
onClick={() => setShowAnalytics(!showAnalytics)}
|
|
title="Toggle flow analytics panel"
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
|
|
showAnalytics
|
|
? 'bg-accent text-foreground'
|
|
: 'bg-card text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
<BarChart3 className="h-4 w-4" />
|
|
Analytics
|
|
</button>
|
|
)}
|
|
|
|
{/* Validate */}
|
|
{isEditMode && (
|
|
<button
|
|
onClick={() => setShowExportModal(true)}
|
|
title="Export flow as .rfflow file"
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm font-medium text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
<Download className="h-4 w-4" />
|
|
Export
|
|
</button>
|
|
)}
|
|
|
|
{/* AI Assist toggle */}
|
|
<button
|
|
onClick={() => {
|
|
if (editorAI.isOpen) {
|
|
handleAIPanelClose()
|
|
} else {
|
|
if (editingNodeId) {
|
|
previousEditingNodeRef.current = editingNodeId
|
|
setEditingNodeId(null)
|
|
}
|
|
editorAI.openPanel()
|
|
}
|
|
}}
|
|
title="Toggle AI Assist panel"
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
|
|
editorAI.isOpen
|
|
? 'bg-primary/10 text-primary border-primary/30'
|
|
: 'bg-card text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
)}
|
|
>
|
|
<Sparkles className="h-4 w-4" />
|
|
AI Assist
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleManualValidate}
|
|
disabled={isSaving}
|
|
title="Validate tree structure (checks for errors and warnings)"
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm font-medium text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground disabled:opacity-50'
|
|
)}
|
|
>
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
Validate
|
|
</button>
|
|
|
|
{/* Save Draft */}
|
|
<button
|
|
onClick={handleSaveDraft}
|
|
disabled={isSaving || !isDirty}
|
|
title="Save as draft (Ctrl+S when draft or has errors)"
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm font-medium text-muted-foreground',
|
|
'hover:bg-accent hover:text-foreground disabled:opacity-50 disabled:cursor-not-allowed'
|
|
)}
|
|
>
|
|
<Save className="h-4 w-4" />
|
|
Save Draft
|
|
</button>
|
|
|
|
{/* Publish */}
|
|
<button
|
|
onClick={handlePublish}
|
|
disabled={isSaving || hasBlockingErrors}
|
|
title={hasBlockingErrors ? 'Fix validation errors before publishing (Ctrl+S when no errors)' : 'Publish tree (Ctrl+S when no errors)'}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
|
'hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed'
|
|
)}
|
|
>
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
{isSaving ? 'Publishing...' : 'Publish'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Validation Summary */}
|
|
{validationErrors.length > 0 && (
|
|
<div className="px-4 py-3">
|
|
<ValidationSummary
|
|
errors={validationErrors}
|
|
onSelectNode={handleSelectNode}
|
|
onFixWithAI={handleFixWithAI}
|
|
isFixing={isFixing}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import provenance */}
|
|
{importMetadata && (
|
|
<div className="mx-4 mb-2 flex items-center gap-2 text-xs font-label text-muted-foreground">
|
|
<FileText className="h-3 w-3" />
|
|
<span>
|
|
Imported{importMetadata.original_author_name ? ` from ${importMetadata.original_author_name}` : ''}
|
|
{importMetadata.imported_at ? ` on ${new Date(importMetadata.imported_at).toLocaleDateString()}` : ''}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Editor */}
|
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
<TreeEditorLayout
|
|
isMobile={isMobile}
|
|
isMetadataOpen={isMetadataOpen}
|
|
onCloseMetadata={() => setIsMetadataOpen(false)}
|
|
editingNodeId={editorAI.isOpen ? null : editingNodeId}
|
|
onNodeSelect={handleNodeSelect}
|
|
onSelectAnswerType={handleSelectAnswerType}
|
|
onNodeContextMenu={editorAI.openContextMenu}
|
|
/>
|
|
</div>
|
|
|
|
{/* Flow Analytics Panel (collapsible) */}
|
|
{showAnalytics && id && (
|
|
<div className="border-t border-border p-6 overflow-y-auto max-h-[50vh]">
|
|
<FlowAnalyticsPanel treeId={id} />
|
|
</div>
|
|
)}
|
|
|
|
{/* AI Fix Review Modal */}
|
|
{fixProposals && (
|
|
<AIFixReviewModal
|
|
fixes={fixProposals}
|
|
onApply={handleApplyFix}
|
|
onApplyAll={handleApplyAllFixes}
|
|
onClose={handleCloseFixModal}
|
|
/>
|
|
)}
|
|
|
|
{/* Export Modal */}
|
|
{showExportModal && id && (
|
|
<ExportFlowModal
|
|
treeId={id}
|
|
treeName={name || 'flow'}
|
|
onClose={() => setShowExportModal(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* AI Context Menu */}
|
|
{editorAI.contextMenu && (
|
|
<ContextMenu
|
|
position={editorAI.contextMenu.position}
|
|
items={[
|
|
{
|
|
id: 'generate-branch',
|
|
label: 'Generate Branch',
|
|
icon: <Sparkles className="h-4 w-4" />,
|
|
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: <Sparkles className="h-4 w-4" />,
|
|
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}
|
|
/>
|
|
)}
|
|
</div>{/* end main content column */}
|
|
|
|
<EditorAIPanel
|
|
isOpen={editorAI.isOpen}
|
|
onClose={handleAIPanelClose}
|
|
focalNode={editorAI.focalNodeId && treeStructure
|
|
? findNodeInTree(editorAI.focalNodeId, treeStructure)
|
|
: null}
|
|
flowName={name}
|
|
flowType="troubleshooting"
|
|
nodeCount={treeStructure ? getAllNodeIds().length : 0}
|
|
messages={editorAI.messages}
|
|
input={editorAI.input}
|
|
onInputChange={editorAI.setInput}
|
|
onSend={editorAI.sendMessage}
|
|
isLoading={editorAI.isLoading}
|
|
suggestions={editorAI.suggestions}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default TreeEditorPage
|