import { useState, useEffect, useCallback } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal, Undo2, Redo2 } from 'lucide-react'; import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas'; import { SpecRenderer } from '../components/canvas/SpecRenderer'; import { NodePalette } from '../components/canvas/palette/NodePalette'; import { FileStructurePanel } from '../components/canvas/panels/FileStructurePanel'; import { TemplateSelector } from '../components/canvas/panels/TemplateSelector'; import { ConfigImporter } from '../components/canvas/panels/ConfigImporter'; import { NodeConfigPanel } from '../components/canvas/panels/NodeConfigPanel'; import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2'; import { ChatPanel } from '../components/canvas/panels/ChatPanel'; import { useCanvasStore } from '../hooks/useCanvasStore'; import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore'; import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo'; import { useStudy } from '../context/StudyContext'; import { useChat } from '../hooks/useChat'; import { CanvasTemplate } from '../lib/canvas/templates'; export function CanvasView() { const [showTemplates, setShowTemplates] = useState(false); const [showImporter, setShowImporter] = useState(false); const [showChat, setShowChat] = useState(true); const [chatPowerMode, setChatPowerMode] = useState(false); const [notification, setNotification] = useState(null); const [isSaving, setIsSaving] = useState(false); const [isLoading, setIsLoading] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [paletteCollapsed, setPaletteCollapsed] = useState(false); const [leftSidebarTab, setLeftSidebarTab] = useState<'components' | 'files'>('components'); const navigate = useNavigate(); const [searchParams] = useSearchParams(); // Spec mode is the default (AtomizerSpec v2.0) // Legacy mode can be enabled via: // 1. VITE_USE_LEGACY_CANVAS=true environment variable // 2. ?mode=legacy query param (for emergency fallback) const legacyEnvEnabled = import.meta.env.VITE_USE_LEGACY_CANVAS === 'true'; const legacyQueryParam = searchParams.get('mode') === 'legacy'; const useSpecMode = !legacyEnvEnabled && !legacyQueryParam; // Get study ID from URL params (supports nested paths like M1_Mirror/study_name) const { '*': urlStudyId } = useParams<{ '*': string }>(); // Legacy canvas store (for backwards compatibility) const { nodes, edges, clear, loadFromConfig, toIntent } = useCanvasStore(); // New spec store (AtomizerSpec v2.0) const spec = useSpec(); const specLoading = useSpecLoading(); const specIsDirty = useSpecIsDirty(); const selectedNodeId = useSelectedNodeId(); const { loadSpec, saveSpec, reloadSpec } = useSpecStore(); const { setSelectedStudy, studies } = useStudy(); const { clearSpec, setSpecFromWebSocket } = useSpecStore(); // Undo/Redo for spec mode const undoRedo = useSpecUndoRedo(); const { undo, redo, canUndo, canRedo, historyLength } = undoRedo; // Enable keyboard shortcuts for undo/redo (Ctrl+Z, Ctrl+Y) useUndoRedoKeyboard(undoRedo); // Active study ID comes ONLY from URL - don't auto-load from context // This ensures /canvas shows empty canvas, /canvas/{id} shows the study const activeStudyId = urlStudyId; // Chat hook for assistant panel const { messages, isThinking, isConnected, sendMessage, notifyCanvasEdit } = useChat({ studyId: activeStudyId, mode: chatPowerMode ? 'power' : 'user', useWebSocket: true, onCanvasModification: chatPowerMode ? (modification) => { // Handle canvas modifications from Claude in power mode (legacy) console.log('Canvas modification from Claude:', modification); showNotification(`Claude: ${modification.action} ${modification.nodeType || modification.nodeId || ''}`); // The actual modification is handled by the MCP tools on the backend // which update atomizer_spec.json, then the canvas reloads via WebSocket reloadSpec(); } : undefined, onSpecUpdated: useSpecMode ? (newSpec) => { // Direct spec update from Claude via WebSocket - no HTTP reload needed console.log('Spec updated from Claude via WebSocket:', newSpec.meta?.study_name); setSpecFromWebSocket(newSpec, activeStudyId); showNotification('Canvas synced with Claude'); } : undefined, }); // Load or clear spec based on URL study ID useEffect(() => { if (urlStudyId) { if (useSpecMode) { // Try to load spec first, fall back to legacy config loadSpec(urlStudyId).catch(() => { // If spec doesn't exist, try legacy config loadStudyConfig(urlStudyId); }); } else { loadStudyConfig(urlStudyId); } } else { // No study ID in URL - clear spec for empty canvas (new study creation) if (useSpecMode) { clearSpec(); } else { clear(); } } }, [urlStudyId, useSpecMode]); // Notify Claude when user edits the spec (bi-directional sync) // This sends the updated spec to Claude so it knows what the user changed useEffect(() => { if (useSpecMode && spec && specIsDirty && chatPowerMode) { // User made changes - notify Claude via WebSocket notifyCanvasEdit(spec); } }, [spec, specIsDirty, useSpecMode, chatPowerMode, notifyCanvasEdit]); // Track unsaved changes (legacy mode only) useEffect(() => { if (!useSpecMode && activeStudyId && nodes.length > 0) { setHasUnsavedChanges(true); } }, [nodes, edges, useSpecMode]); const loadStudyConfig = async (studyId: string) => { setIsLoading(true); try { const response = await fetch(`/api/optimization/studies/${encodeURIComponent(studyId)}/config`); if (!response.ok) { throw new Error(`Failed to load study: ${response.status}`); } const data = await response.json(); loadFromConfig(data.config); setHasUnsavedChanges(false); // Also select the study in context const study = studies.find(s => s.id === studyId); if (study) { setSelectedStudy(study); } showNotification(`Loaded: ${studyId}`); } catch (error) { console.error('Failed to load study config:', error); showNotification('Failed to load study config'); } finally { setIsLoading(false); } }; const saveToConfig = async () => { if (!activeStudyId) { showNotification('No study selected to save to'); return; } setIsSaving(true); try { if (useSpecMode && spec) { // Save spec using new API await saveSpec(spec); showNotification('Saved to atomizer_spec.json'); } else { // Legacy save const intent = toIntent(); const response = await fetch(`/api/optimization/studies/${encodeURIComponent(activeStudyId)}/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ intent }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.detail || 'Failed to save'); } setHasUnsavedChanges(false); showNotification('Saved to optimization_config.json'); } } catch (error) { console.error('Failed to save:', error); showNotification('Failed to save: ' + (error instanceof Error ? error.message : 'Unknown error')); } finally { setIsSaving(false); } }; const handleTemplateSelect = (template: CanvasTemplate) => { setHasUnsavedChanges(true); showNotification(`Loaded template: ${template.name}`); }; const handleImport = (source: string) => { setHasUnsavedChanges(true); showNotification(`Imported from ${source}`); }; const handleClear = () => { if (useSpecMode) { // In spec mode, clearing is not typically needed since changes sync automatically showNotification('Use Reload to reset to saved state'); return; } if (nodes.length === 0 || window.confirm('Clear all nodes from the canvas?')) { clear(); setHasUnsavedChanges(true); showNotification('Canvas cleared'); } }; const handleReload = () => { if (activeStudyId) { const hasChanges = useSpecMode ? specIsDirty : hasUnsavedChanges; if (hasChanges && !window.confirm('Reload will discard unsaved changes. Continue?')) { return; } if (useSpecMode) { reloadSpec(); showNotification('Reloaded from atomizer_spec.json'); } else { loadStudyConfig(activeStudyId); } } }; const showNotification = (message: string) => { setNotification(message); setTimeout(() => setNotification(null), 3000); }; // Navigate to canvas with study ID const navigateToStudy = useCallback((studyId: string) => { navigate(`/canvas/${studyId}`); }, [navigate]); return (
{/* Minimal Header */}
{/* Home button */} {/* Breadcrumb */}
Canvas Builder {activeStudyId && ( <> {activeStudyId} {hasUnsavedChanges && ( )} )}
{/* Stats */} {useSpecMode && spec ? ( {spec.design_variables.length} vars • {spec.extractors.length} ext • {spec.objectives.length} obj ) : ( {nodes.length} node{nodes.length !== 1 ? 's' : ''} • {edges.length} edge{edges.length !== 1 ? 's' : ''} )} {/* Mode indicator */} {useSpecMode && ( v2.0 )} {(isLoading || specLoading) && ( )}
{/* Action Buttons */}
{/* Save Button - always show in spec mode with study, grayed when no changes */} {useSpecMode && spec && ( )} {/* Legacy Save Button */} {!useSpecMode && activeStudyId && ( )} {/* Reload Button */} {(useSpecMode ? spec : activeStudyId) && ( )} {/* Undo/Redo Buttons (spec mode only) */} {useSpecMode && activeStudyId && ( <>
)} {/* Divider */}
{/* Chat Toggle */}
{/* Main Canvas */}
{/* Left Sidebar with tabs (spec mode only - AtomizerCanvas has its own) */} {useSpecMode && (
{/* Tab buttons (only show when expanded) */} {!paletteCollapsed && (
)} {/* Tab content */}
{leftSidebarTab === 'components' || paletteCollapsed ? ( setPaletteCollapsed(!paletteCollapsed)} showToggle={true} /> ) : ( { // TODO: Update model path in spec showNotification(`Selected: ${path.split(/[/\\]/).pop()}`); }} /> )}
)} {/* Canvas area - must have explicit height for ReactFlow */}
{useSpecMode ? ( ) : ( )}
{/* Config Panel - use V2 for spec mode, legacy for AtomizerCanvas */} {/* Shows INSTEAD of chat when a node is selected */} {selectedNodeId ? ( useSpecMode ? ( useSpecStore.getState().clearSelection()} /> ) : (
) ) : showChat ? (
{/* Chat Header */}
Assistant {isConnected && ( )}
{/* Power Mode Toggle */}
{/* Chat Content */}
) : null}
{/* Template Selector Modal */} setShowTemplates(false)} onSelect={handleTemplateSelect} /> {/* Config Importer Modal */} setShowImporter(false)} onImport={handleImport} /> {/* Notification Toast */} {notification && (
{notification}
)}
); } export default CanvasView;