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>
This commit is contained in:
@@ -31,9 +31,10 @@ interface FlowCanvasProps {
|
|||||||
selectedNodeId: string | null
|
selectedNodeId: string | null
|
||||||
onNodeSelect: (nodeId: string | null) => void
|
onNodeSelect: (nodeId: string | null) => void
|
||||||
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||||
|
onNodeContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: FlowCanvasProps) {
|
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeContextMenu }: FlowCanvasProps) {
|
||||||
const { fitView, setCenter } = useReactFlow()
|
const { fitView, setCenter } = useReactFlow()
|
||||||
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout()
|
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout()
|
||||||
const [minimapVisible, setMinimapVisible] = useState(true)
|
const [minimapVisible, setMinimapVisible] = useState(true)
|
||||||
@@ -46,7 +47,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: F
|
|||||||
return {
|
return {
|
||||||
...n,
|
...n,
|
||||||
selected: n.id === selectedNodeId,
|
selected: n.id === selectedNodeId,
|
||||||
data: { ...data, onToggleCollapse: toggleCollapse },
|
data: { ...data, onToggleCollapse: toggleCollapse, onContextMenu: onNodeContextMenu },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (n.type === 'answerStub') {
|
if (n.type === 'answerStub') {
|
||||||
@@ -59,7 +60,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: F
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
})
|
})
|
||||||
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType])
|
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType, onNodeContextMenu])
|
||||||
|
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks)
|
const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks)
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges)
|
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges)
|
||||||
|
|||||||
@@ -41,10 +41,14 @@ export interface FlowCanvasNodeData {
|
|||||||
hasValidationErrors: boolean
|
hasValidationErrors: boolean
|
||||||
isNew: boolean
|
isNew: boolean
|
||||||
onToggleCollapse: (nodeId: string) => void
|
onToggleCollapse: (nodeId: string) => void
|
||||||
|
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||||
|
onAcceptSuggestion?: (nodeId: string) => void
|
||||||
|
onDismissSuggestion?: (nodeId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
||||||
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse } = data as unknown as FlowCanvasNodeData
|
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse, onContextMenu, onAcceptSuggestion, onDismissSuggestion } = data as unknown as FlowCanvasNodeData
|
||||||
|
const isGhost = !!(node as unknown as Record<string, unknown>)._suggestion
|
||||||
const nodeType = node.type as Exclude<NodeType, 'answer'>
|
const nodeType = node.type as Exclude<NodeType, 'answer'>
|
||||||
const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision
|
const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
@@ -61,10 +65,12 @@ function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
|||||||
<Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />
|
<Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
onContextMenu={(e) => onContextMenu?.(e, node.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-[280px] rounded-xl border border-border bg-card shadow-sm cursor-pointer transition-all',
|
'w-[280px] rounded-xl border border-border bg-card shadow-sm cursor-pointer transition-all',
|
||||||
config.borderClass,
|
config.borderClass,
|
||||||
selected && 'ring-1 ring-primary shadow-md'
|
selected && 'ring-1 ring-primary shadow-md',
|
||||||
|
isGhost && 'border-dashed !border-primary/40 opacity-60'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -142,6 +148,30 @@ function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Ghost node accept/dismiss overlay */}
|
||||||
|
{isGhost && (
|
||||||
|
<div className="mt-2 flex gap-2 border-t border-border/50 pt-2 px-3 pb-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onAcceptSuggestion?.(node.id)
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-md bg-emerald-500/20 px-2 py-1 text-xs text-emerald-400 hover:bg-emerald-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onDismissSuggestion?.(node.id)
|
||||||
|
}}
|
||||||
|
className="flex-1 rounded-md bg-rose-500/20 px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/30 transition-colors"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Source handle at bottom */}
|
{/* Source handle at bottom */}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface TreeEditorLayoutProps {
|
|||||||
editingNodeId: string | null
|
editingNodeId: string | null
|
||||||
onNodeSelect: (nodeId: string | null) => void
|
onNodeSelect: (nodeId: string | null) => void
|
||||||
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||||
|
onNodeContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreeEditorLayout({
|
export function TreeEditorLayout({
|
||||||
@@ -28,6 +29,7 @@ export function TreeEditorLayout({
|
|||||||
editingNodeId,
|
editingNodeId,
|
||||||
onNodeSelect,
|
onNodeSelect,
|
||||||
onSelectAnswerType,
|
onSelectAnswerType,
|
||||||
|
onNodeContextMenu,
|
||||||
}: TreeEditorLayoutProps) {
|
}: TreeEditorLayoutProps) {
|
||||||
const editorMode = useTreeEditorStore(s => s.editorMode)
|
const editorMode = useTreeEditorStore(s => s.editorMode)
|
||||||
|
|
||||||
@@ -70,6 +72,7 @@ export function TreeEditorLayout({
|
|||||||
selectedNodeId={editingNodeId}
|
selectedNodeId={editingNodeId}
|
||||||
onNodeSelect={onNodeSelect}
|
onNodeSelect={onNodeSelect}
|
||||||
onSelectAnswerType={onSelectAnswerType}
|
onSelectAnswerType={onSelectAnswerType}
|
||||||
|
onNodeContextMenu={onNodeContextMenu}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
||||||
import { useStore } from 'zustand'
|
import { useStore } from 'zustand'
|
||||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings, Download } from 'lucide-react'
|
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 { getMonacoEditor } from '@/components/tree-editor/code-mode'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { treeMarkdownApi } from '@/api/treeMarkdown'
|
import { treeMarkdownApi } from '@/api/treeMarkdown'
|
||||||
@@ -17,6 +17,10 @@ import { cn, safeGetItem } from '@/lib/utils'
|
|||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
|
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
|
||||||
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
|
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' */
|
/** Recursively check if any node in the tree has type 'answer' */
|
||||||
function hasAnswerNodes(node: TreeStructure): boolean {
|
function hasAnswerNodes(node: TreeStructure): boolean {
|
||||||
@@ -37,6 +41,7 @@ export function TreeEditorPage() {
|
|||||||
isSaving,
|
isSaving,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
editorMode,
|
editorMode,
|
||||||
|
treeStructure,
|
||||||
initNewTree,
|
initNewTree,
|
||||||
loadTree,
|
loadTree,
|
||||||
loadDraft,
|
loadDraft,
|
||||||
@@ -49,7 +54,9 @@ export function TreeEditorPage() {
|
|||||||
setSaving,
|
setSaving,
|
||||||
selectNode,
|
selectNode,
|
||||||
updateNode,
|
updateNode,
|
||||||
|
deleteNode,
|
||||||
setEditorMode,
|
setEditorMode,
|
||||||
|
getAllNodeIds,
|
||||||
} = useTreeEditorStore()
|
} = useTreeEditorStore()
|
||||||
|
|
||||||
// Access undo/redo from temporal store
|
// Access undo/redo from temporal store
|
||||||
@@ -74,6 +81,22 @@ export function TreeEditorPage() {
|
|||||||
return () => window.removeEventListener('resize', check)
|
return () => window.removeEventListener('resize', check)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// AI Assist panel
|
||||||
|
const editorAI = useEditorAI({
|
||||||
|
flowType: 'troubleshooting',
|
||||||
|
treeId: id,
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
// Calculate if there are blocking errors
|
||||||
const hasBlockingErrors = validationErrors.some(e => e.severity === 'error')
|
const hasBlockingErrors = validationErrors.some(e => e.severity === 'error')
|
||||||
|
|
||||||
@@ -705,6 +728,31 @@ export function TreeEditorPage() {
|
|||||||
</button>
|
</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
|
<button
|
||||||
onClick={handleManualValidate}
|
onClick={handleManualValidate}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
@@ -771,15 +819,37 @@ export function TreeEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Editor */}
|
{/* Main Editor + AI Panel */}
|
||||||
<TreeEditorLayout
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
isMobile={isMobile}
|
<div className="flex-1 overflow-hidden">
|
||||||
isMetadataOpen={isMetadataOpen}
|
<TreeEditorLayout
|
||||||
onCloseMetadata={() => setIsMetadataOpen(false)}
|
isMobile={isMobile}
|
||||||
editingNodeId={editingNodeId}
|
isMetadataOpen={isMetadataOpen}
|
||||||
onNodeSelect={handleNodeSelect}
|
onCloseMetadata={() => setIsMetadataOpen(false)}
|
||||||
onSelectAnswerType={handleSelectAnswerType}
|
editingNodeId={editorAI.isOpen ? null : editingNodeId}
|
||||||
/>
|
onNodeSelect={handleNodeSelect}
|
||||||
|
onSelectAnswerType={handleSelectAnswerType}
|
||||||
|
onNodeContextMenu={editorAI.openContextMenu}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
{/* Flow Analytics Panel (collapsible) */}
|
{/* Flow Analytics Panel (collapsible) */}
|
||||||
{showAnalytics && id && (
|
{showAnalytics && id && (
|
||||||
@@ -806,6 +876,60 @@ export function TreeEditorPage() {
|
|||||||
onClose={() => setShowExportModal(false)}
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user