From c4a3cff91a73a89fccd8ccdb30078efe7bc50d43 Mon Sep 17 00:00:00 2001 From: Anto01 Date: Tue, 20 Jan 2026 11:53:26 -0500 Subject: [PATCH] feat(canvas): Studio Enhancement Phase 1 & 2 - v2.0 architecture and file structure Phase 1 - Foundation: - Add NodeConfigPanelV2 using useSpecStore for AtomizerSpec v2.0 mode - Deprecate AtomizerCanvas and useCanvasStore with migration docs - Add VITE_USE_LEGACY_CANVAS env var for emergency fallback - Enhance NodePalette with collapse support, filtering, exports - Add drag-drop support to SpecRenderer with default node data - Setup test infrastructure (Vitest + Playwright configs) - Add useSpecStore unit tests (15 tests) Phase 2 - File Structure & Model: - Create FileStructurePanel with tree view of study files - Add ModelNodeV2 with collapsible file dependencies - Add tabbed left sidebar (Components/Files tabs) - Add GET /api/files/structure/{study_id} backend endpoint - Auto-expand 1_setup folders in file tree - Show model file introspection with solver type and expressions Technical: - All TypeScript checks pass - All 15 unit tests pass - Production build successful --- .../backend/api/routes/files.py | 246 ++++-- .../frontend/playwright.config.ts | 69 ++ .../src/components/canvas/AtomizerCanvas.tsx | 255 +++--- .../src/components/canvas/SpecRenderer.tsx | 521 ++++++++++++ .../components/canvas/nodes/ModelNodeV2.tsx | 260 ++++++ .../src/components/canvas/nodes/index.ts | 8 +- .../components/canvas/palette/NodePalette.tsx | 268 ++++++- .../canvas/panels/FileStructurePanel.tsx | 310 ++++++++ .../canvas/panels/NodeConfigPanelV2.tsx | 684 ++++++++++++++++ .../frontend/src/hooks/useCanvasStore.ts | 16 + .../frontend/src/hooks/useSpecStore.test.ts | 209 +++++ .../frontend/src/hooks/useSpecStore.ts | 742 ++++++++++++++++++ .../frontend/src/pages/CanvasView.tsx | 408 +++++++++- atomizer-dashboard/frontend/src/test/setup.ts | 137 ++++ .../frontend/src/test/utils.tsx | 142 ++++ atomizer-dashboard/frontend/vitest.config.ts | 31 + 16 files changed, 4067 insertions(+), 239 deletions(-) create mode 100644 atomizer-dashboard/frontend/playwright.config.ts create mode 100644 atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx create mode 100644 atomizer-dashboard/frontend/src/components/canvas/nodes/ModelNodeV2.tsx create mode 100644 atomizer-dashboard/frontend/src/components/canvas/panels/FileStructurePanel.tsx create mode 100644 atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx create mode 100644 atomizer-dashboard/frontend/src/hooks/useSpecStore.test.ts create mode 100644 atomizer-dashboard/frontend/src/hooks/useSpecStore.ts create mode 100644 atomizer-dashboard/frontend/src/test/setup.ts create mode 100644 atomizer-dashboard/frontend/src/test/utils.tsx create mode 100644 atomizer-dashboard/frontend/vitest.config.ts diff --git a/atomizer-dashboard/backend/api/routes/files.py b/atomizer-dashboard/backend/api/routes/files.py index 7675ce13..a1ed1dbd 100644 --- a/atomizer-dashboard/backend/api/routes/files.py +++ b/atomizer-dashboard/backend/api/routes/files.py @@ -19,23 +19,26 @@ router = APIRouter() class ImportRequest(BaseModel): """Request to import a file from a Windows path""" + source_path: str study_name: str copy_related: bool = True + # Path to studies root (go up 5 levels from this file) _file_path = os.path.abspath(__file__) -ATOMIZER_ROOT = Path(os.path.normpath(os.path.dirname(os.path.dirname(os.path.dirname( - os.path.dirname(os.path.dirname(_file_path)) -))))) +ATOMIZER_ROOT = Path( + os.path.normpath( + os.path.dirname( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(_file_path)))) + ) + ) +) STUDIES_ROOT = ATOMIZER_ROOT / "studies" @router.get("/list") -async def list_files( - path: str = "", - types: str = ".sim,.prt,.fem,.afem" -): +async def list_files(path: str = "", types: str = ".sim,.prt,.fem,.afem"): """ List files in a directory, filtered by type. @@ -46,7 +49,7 @@ async def list_files( Returns: List of files and directories with their paths """ - allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()] + allowed_types = [t.strip().lower() for t in types.split(",") if t.strip()] base_path = STUDIES_ROOT / path if path else STUDIES_ROOT @@ -58,26 +61,30 @@ async def list_files( try: for entry in sorted(base_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())): # Skip hidden files and directories - if entry.name.startswith('.'): + if entry.name.startswith("."): continue if entry.is_dir(): # Include directories - files.append({ - "name": entry.name, - "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), - "isDirectory": True, - }) + files.append( + { + "name": entry.name, + "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), + "isDirectory": True, + } + ) else: # Include files matching type filter suffix = entry.suffix.lower() if suffix in allowed_types: - files.append({ - "name": entry.name, - "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), - "isDirectory": False, - "size": entry.stat().st_size, - }) + files.append( + { + "name": entry.name, + "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), + "isDirectory": False, + "size": entry.stat().st_size, + } + ) except PermissionError: return {"files": [], "path": path, "error": "Permission denied"} except Exception as e: @@ -87,11 +94,7 @@ async def list_files( @router.get("/search") -async def search_files( - query: str, - types: str = ".sim,.prt,.fem,.afem", - max_results: int = 50 -): +async def search_files(query: str, types: str = ".sim,.prt,.fem,.afem", max_results: int = 50): """ Search for files by name pattern. @@ -103,7 +106,7 @@ async def search_files( Returns: List of matching files with their paths """ - allowed_types = [t.strip().lower() for t in types.split(',') if t.strip()] + allowed_types = [t.strip().lower() for t in types.split(",") if t.strip()] query_lower = query.lower() files = [] @@ -118,19 +121,21 @@ async def search_files( if len(files) >= max_results: return - if entry.name.startswith('.'): + if entry.name.startswith("."): continue if entry.is_dir(): search_recursive(entry, depth + 1) elif entry.suffix.lower() in allowed_types: if query_lower in entry.name.lower(): - files.append({ - "name": entry.name, - "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), - "isDirectory": False, - "size": entry.stat().st_size, - }) + files.append( + { + "name": entry.name, + "path": str(entry.relative_to(STUDIES_ROOT)).replace("\\", "/"), + "isDirectory": False, + "size": entry.stat().st_size, + } + ) except (PermissionError, OSError): pass @@ -190,18 +195,18 @@ def find_related_nx_files(source_path: Path) -> List[Path]: # Extract base name by removing _sim1, _fem1, _i suffixes base_name = stem - base_name = re.sub(r'_sim\d*$', '', base_name) - base_name = re.sub(r'_fem\d*$', '', base_name) - base_name = re.sub(r'_i$', '', base_name) + base_name = re.sub(r"_sim\d*$", "", base_name) + base_name = re.sub(r"_fem\d*$", "", base_name) + base_name = re.sub(r"_i$", "", base_name) # Define patterns to search for patterns = [ - f"{base_name}.prt", # Main geometry - f"{base_name}_i.prt", # Idealized part - f"{base_name}_fem*.fem", # FEM files - f"{base_name}_fem*_i.prt", # Idealized FEM parts - f"{base_name}_sim*.sim", # Simulation files - f"{base_name}.afem", # Assembled FEM + f"{base_name}.prt", # Main geometry + f"{base_name}_i.prt", # Idealized part + f"{base_name}_fem*.fem", # FEM files + f"{base_name}_fem*_i.prt", # Idealized FEM parts + f"{base_name}_sim*.sim", # Simulation files + f"{base_name}.afem", # Assembled FEM ] # Search for matching files @@ -244,7 +249,7 @@ async def validate_external_path(path: str): } # Check if it's a valid NX file type - valid_extensions = ['.prt', '.sim', '.fem', '.afem'] + valid_extensions = [".prt", ".sim", ".fem", ".afem"] if source_path.suffix.lower() not in valid_extensions: return { "valid": False, @@ -297,7 +302,9 @@ async def import_from_path(request: ImportRequest): source_path = Path(request.source_path) if not source_path.exists(): - raise HTTPException(status_code=404, detail=f"Source file not found: {request.source_path}") + raise HTTPException( + status_code=404, detail=f"Source file not found: {request.source_path}" + ) # Create study folder structure study_dir = STUDIES_ROOT / request.study_name @@ -316,22 +323,26 @@ async def import_from_path(request: ImportRequest): # Skip if already exists (avoid overwrite) if dest_file.exists(): - imported.append({ - "name": src_file.name, - "status": "skipped", - "reason": "Already exists", - "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), - }) + imported.append( + { + "name": src_file.name, + "status": "skipped", + "reason": "Already exists", + "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), + } + ) continue # Copy file shutil.copy2(src_file, dest_file) - imported.append({ - "name": src_file.name, - "status": "imported", - "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), - "size": dest_file.stat().st_size, - }) + imported.append( + { + "name": src_file.name, + "status": "imported", + "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), + "size": dest_file.stat().st_size, + } + ) return { "success": True, @@ -371,27 +382,31 @@ async def upload_files( for file in files: # Validate file type suffix = Path(file.filename).suffix.lower() - if suffix not in ['.prt', '.sim', '.fem', '.afem']: - uploaded.append({ - "name": file.filename, - "status": "rejected", - "reason": f"Invalid file type: {suffix}", - }) + if suffix not in [".prt", ".sim", ".fem", ".afem"]: + uploaded.append( + { + "name": file.filename, + "status": "rejected", + "reason": f"Invalid file type: {suffix}", + } + ) continue dest_file = model_dir / file.filename # Save file content = await file.read() - with open(dest_file, 'wb') as f: + with open(dest_file, "wb") as f: f.write(content) - uploaded.append({ - "name": file.filename, - "status": "uploaded", - "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), - "size": len(content), - }) + uploaded.append( + { + "name": file.filename, + "status": "uploaded", + "path": str(dest_file.relative_to(STUDIES_ROOT)).replace("\\", "/"), + "size": len(content), + } + ) return { "success": True, @@ -402,3 +417,96 @@ async def upload_files( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/structure/{study_id:path}") +async def get_study_structure(study_id: str): + """ + Get the file structure tree for a study. + + Args: + study_id: Study ID (can include path separators like M1_Mirror/m1_mirror_flatback) + + Returns: + Hierarchical file tree with type information + """ + # Resolve study path + study_path = STUDIES_ROOT / study_id + + if not study_path.exists(): + raise HTTPException(status_code=404, detail=f"Study not found: {study_id}") + + if not study_path.is_dir(): + raise HTTPException(status_code=400, detail=f"Not a directory: {study_id}") + + # File extensions to highlight as model files + model_extensions = {".prt", ".sim", ".fem", ".afem"} + result_extensions = {".op2", ".f06", ".dat", ".bdf", ".csv", ".json"} + + def build_tree(directory: Path, depth: int = 0) -> List[dict]: + """Recursively build file tree.""" + if depth > 5: # Limit depth to prevent infinite recursion + return [] + + entries = [] + + try: + items = sorted(directory.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())) + + for item in items: + # Skip hidden files/dirs and __pycache__ + if item.name.startswith(".") or item.name == "__pycache__": + continue + + # Skip very large directories (e.g., trial folders with many iterations) + if item.is_dir() and item.name.startswith("trial_"): + # Just count trials, don't recurse into each + entries.append( + { + "name": item.name, + "path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"), + "type": "directory", + "children": [], # Empty children for trial folders + } + ) + continue + + if item.is_dir(): + children = build_tree(item, depth + 1) + entries.append( + { + "name": item.name, + "path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"), + "type": "directory", + "children": children, + } + ) + else: + ext = item.suffix.lower() + entries.append( + { + "name": item.name, + "path": str(item.relative_to(STUDIES_ROOT)).replace("\\", "/"), + "type": "file", + "extension": ext, + "size": item.stat().st_size, + "isModelFile": ext in model_extensions, + "isResultFile": ext in result_extensions, + } + ) + + except PermissionError: + pass + except Exception as e: + print(f"Error reading directory {directory}: {e}") + + return entries + + # Build the tree starting from study root + files = build_tree(study_path) + + return { + "study_id": study_id, + "path": str(study_path), + "files": files, + } diff --git a/atomizer-dashboard/frontend/playwright.config.ts b/atomizer-dashboard/frontend/playwright.config.ts new file mode 100644 index 00000000..bfbcda3b --- /dev/null +++ b/atomizer-dashboard/frontend/playwright.config.ts @@ -0,0 +1,69 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright E2E Test Configuration + * + * Run with: npm run test:e2e + * UI mode: npm run test:e2e:ui + */ +export default defineConfig({ + testDir: './tests/e2e', + + // Run tests in parallel + fullyParallel: true, + + // Fail CI if test.only is left in code + forbidOnly: !!process.env.CI, + + // Retry on CI only + retries: process.env.CI ? 2 : 0, + + // Parallel workers + workers: process.env.CI ? 1 : undefined, + + // Reporter configuration + reporter: [ + ['html', { outputFolder: 'playwright-report' }], + ['list'], + ], + + // Global settings + use: { + // Base URL for navigation + baseURL: 'http://localhost:3003', + + // Collect trace on first retry + trace: 'on-first-retry', + + // Screenshot on failure + screenshot: 'only-on-failure', + + // Video on failure + video: 'on-first-retry', + }, + + // Browser projects + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + // Uncomment to test on more browsers + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + ], + + // Start dev server before tests + webServer: { + command: 'npm run dev', + url: 'http://localhost:3003', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/atomizer-dashboard/frontend/src/components/canvas/AtomizerCanvas.tsx b/atomizer-dashboard/frontend/src/components/canvas/AtomizerCanvas.tsx index 843ecd2a..1a139ee7 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/AtomizerCanvas.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/AtomizerCanvas.tsx @@ -1,3 +1,19 @@ +/** + * @deprecated This component is deprecated as of January 2026. + * Use SpecRenderer instead, which works with AtomizerSpec v2.0. + * + * Migration guide: + * - Replace with + * - Use useSpecStore instead of useCanvasStore for state management + * - Spec mode uses atomizer_spec.json instead of optimization_config.json + * + * This component is kept for emergency fallback only. Enable legacy mode + * by setting VITE_USE_LEGACY_CANVAS=true in your environment. + * + * @see SpecRenderer for the new implementation + * @see useSpecStore for the new state management + */ + import { useCallback, useRef, useState, useEffect, DragEvent } from 'react'; import ReactFlow, { Background, @@ -8,7 +24,6 @@ import ReactFlow, { Edge, } from 'reactflow'; import 'reactflow/dist/style.css'; -import { MessageCircle, Plug, X, AlertCircle, RefreshCw } from 'lucide-react'; import { nodeTypes } from './nodes'; import { NodePalette } from './palette/NodePalette'; @@ -16,15 +31,21 @@ import { NodeConfigPanel } from './panels/NodeConfigPanel'; import { ValidationPanel } from './panels/ValidationPanel'; import { ExecuteDialog } from './panels/ExecuteDialog'; import { useCanvasStore } from '../../hooks/useCanvasStore'; -import { useCanvasChat } from '../../hooks/useCanvasChat'; import { NodeType } from '../../lib/canvas/schema'; -import { ChatPanel } from './panels/ChatPanel'; -function CanvasFlow() { +interface CanvasFlowProps { + initialStudyId?: string; + initialStudyPath?: string; + onStudyChange?: (studyId: string) => void; +} + +function CanvasFlow({ initialStudyId, initialStudyPath, onStudyChange }: CanvasFlowProps) { const reactFlowWrapper = useRef(null); const reactFlowInstance = useRef(null); const [showExecuteDialog, setShowExecuteDialog] = useState(false); - const [showChat, setShowChat] = useState(false); + const [studyId, setStudyId] = useState(initialStudyId || null); + const [studyPath, setStudyPath] = useState(initialStudyPath || null); + const [isExecuting, setIsExecuting] = useState(false); const { nodes, @@ -41,32 +62,38 @@ function CanvasFlow() { validation, validate, toIntent, + loadFromConfig, } = useCanvasStore(); - const [chatError, setChatError] = useState(null); + const [isLoadingStudy, setIsLoadingStudy] = useState(false); + const [loadError, setLoadError] = useState(null); - const { - messages, - isThinking, - isExecuting, - isConnected, - executeIntent, - validateIntent, - analyzeIntent, - sendMessage, - } = useCanvasChat({ - onError: (error) => { - console.error('Canvas chat error:', error); - setChatError(error); - }, - }); + // Load a study config into the canvas + const handleLoadStudy = async () => { + if (!studyId) return; - const handleReconnect = useCallback(() => { - setChatError(null); - // Force refresh chat connection by toggling panel - setShowChat(false); - setTimeout(() => setShowChat(true), 100); - }, []); + setIsLoadingStudy(true); + setLoadError(null); + 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); + setStudyPath(data.path); + + // Notify parent of study change (for URL updates) + if (onStudyChange) { + onStudyChange(studyId); + } + } catch (error) { + console.error('Failed to load study:', error); + setLoadError(error instanceof Error ? error.message : 'Failed to load study'); + } finally { + setIsLoadingStudy(false); + } + }; const onDragOver = useCallback((event: DragEvent) => { event.preventDefault(); @@ -80,7 +107,6 @@ function CanvasFlow() { const type = event.dataTransfer.getData('application/reactflow') as NodeType; if (!type || !reactFlowInstance.current) return; - // screenToFlowPosition expects screen coordinates directly const position = reactFlowInstance.current.screenToFlowPosition({ x: event.clientX, y: event.clientY, @@ -114,7 +140,6 @@ function CanvasFlow() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Delete' || event.key === 'Backspace') { - // Don't delete if focus is on an input const target = event.target as HTMLElement; if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { return; @@ -128,22 +153,7 @@ function CanvasFlow() { }, [deleteSelected]); const handleValidate = () => { - const result = validate(); - if (result.valid) { - // Also send to Claude for intelligent feedback - const intent = toIntent(); - validateIntent(intent); - setShowChat(true); - } - }; - - const handleAnalyze = () => { - const result = validate(); - if (result.valid) { - const intent = toIntent(); - analyzeIntent(intent); - setShowChat(true); - } + validate(); }; const handleExecuteClick = () => { @@ -153,12 +163,43 @@ function CanvasFlow() { } }; - const handleExecute = async (studyName: string, autoRun: boolean, _mode: 'create' | 'update', _existingStudyId?: string) => { - const intent = toIntent(); - // For now, both modes use the same executeIntent - backend will handle the mode distinction - await executeIntent(intent, studyName, autoRun); - setShowExecuteDialog(false); - setShowChat(true); + const handleExecute = async (studyName: string, autoRun: boolean, mode: 'create' | 'update', existingStudyId?: string) => { + setIsExecuting(true); + try { + const intent = toIntent(); + + // Call API to create/update study from intent + const endpoint = mode === 'update' && existingStudyId + ? `/api/optimization/studies/${encodeURIComponent(existingStudyId)}/update-from-intent` + : '/api/optimization/studies/create-from-intent'; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + study_name: studyName, + intent, + auto_run: autoRun, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || `Failed to ${mode} study`); + } + + const result = await response.json(); + setStudyId(studyName); + setStudyPath(result.path); + + console.log(`Study ${mode}d:`, result); + } catch (error) { + console.error(`Failed to ${mode} study:`, error); + setLoadError(error instanceof Error ? error.message : `Failed to ${mode} study`); + } finally { + setIsExecuting(false); + setShowExecuteDialog(false); + } }; return ( @@ -168,6 +209,37 @@ function CanvasFlow() { {/* Center: Canvas */}
+ {/* Study Context Bar */} +
+ setStudyId(e.target.value || null)} + placeholder="Study ID (e.g., M1_Mirror/m1_mirror_flatback)" + className="flex-1 max-w-md px-3 py-2 bg-dark-800/90 backdrop-blur border border-dark-600 text-white placeholder-dark-500 rounded-lg text-sm focus:border-primary-500 focus:outline-none" + /> + + {studyPath && ( + + {studyPath.split(/[/\\]/).slice(-2).join('/')} + + )} +
+ + {/* Error Banner */} + {loadError && ( +
+ {loadError} + +
+ )} + ({ @@ -203,44 +275,22 @@ function CanvasFlow() { {/* Action Buttons */}
- -
@@ -250,43 +300,8 @@ function CanvasFlow() { )}
- {/* Right: Config Panel or Chat */} - {showChat ? ( -
-
-

Claude Assistant

- -
- {chatError ? ( -
- -

Connection Error

-

{chatError}

- -
- ) : ( - - )} -
- ) : selectedNode ? ( - - ) : null} + {/* Right: Config Panel */} + {selectedNode && } {/* Execute Dialog */} void; +} + +export function AtomizerCanvas({ studyId, studyPath, onStudyChange }: AtomizerCanvasProps = {}) { return ( - + ); } diff --git a/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx new file mode 100644 index 00000000..0c3cfa34 --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/SpecRenderer.tsx @@ -0,0 +1,521 @@ +/** + * SpecRenderer - ReactFlow canvas that renders from AtomizerSpec v2.0 + * + * This component replaces the legacy canvas approach with a spec-driven architecture: + * - Reads from useSpecStore instead of useCanvasStore + * - Converts spec to ReactFlow nodes/edges using spec converters + * - All changes flow through the spec store and sync with backend + * - Supports WebSocket real-time updates + * + * P2.7-P2.10: SpecRenderer component with node/edge/selection handling + */ + +import { useCallback, useRef, useEffect, useMemo, DragEvent } from 'react'; +import ReactFlow, { + Background, + Controls, + MiniMap, + ReactFlowProvider, + ReactFlowInstance, + Edge, + Node, + NodeChange, + EdgeChange, + Connection, +} from 'reactflow'; +import 'reactflow/dist/style.css'; + +import { nodeTypes } from './nodes'; +import { specToNodes, specToEdges } from '../../lib/spec'; +import { + useSpecStore, + useSpec, + useSpecLoading, + useSpecError, + useSelectedNodeId, + useSelectedEdgeId, +} from '../../hooks/useSpecStore'; +import { useSpecWebSocket } from '../../hooks/useSpecWebSocket'; +import { ConnectionStatusIndicator } from './ConnectionStatusIndicator'; +import { CanvasNodeData } from '../../lib/canvas/schema'; + +// ============================================================================ +// Drag-Drop Helpers +// ============================================================================ + +/** Addable node types via drag-drop */ +const ADDABLE_NODE_TYPES = ['designVar', 'extractor', 'objective', 'constraint'] as const; +type AddableNodeType = typeof ADDABLE_NODE_TYPES[number]; + +function isAddableNodeType(type: string): type is AddableNodeType { + return ADDABLE_NODE_TYPES.includes(type as AddableNodeType); +} + +/** Maps canvas NodeType to spec API type */ +function mapNodeTypeToSpecType(type: AddableNodeType): 'designVar' | 'extractor' | 'objective' | 'constraint' { + return type; +} + +/** Creates default data for a new node of the given type */ +function getDefaultNodeData(type: AddableNodeType, position: { x: number; y: number }): Record { + const timestamp = Date.now(); + + switch (type) { + case 'designVar': + return { + name: `variable_${timestamp}`, + expression_name: `expr_${timestamp}`, + type: 'continuous', + bounds: { min: 0, max: 1 }, + baseline: 0.5, + enabled: true, + canvas_position: position, + }; + case 'extractor': + return { + name: `extractor_${timestamp}`, + type: 'custom', + enabled: true, + canvas_position: position, + }; + case 'objective': + return { + name: `objective_${timestamp}`, + direction: 'minimize', + weight: 1.0, + source_extractor_id: null, + source_output: null, + canvas_position: position, + }; + case 'constraint': + return { + name: `constraint_${timestamp}`, + type: 'upper', + limit: 1.0, + source_extractor_id: null, + source_output: null, + enabled: true, + canvas_position: position, + }; + } +} + +// ============================================================================ +// Component Props +// ============================================================================ + +interface SpecRendererProps { + /** + * Optional study ID to load on mount. + * If not provided, assumes spec is already loaded in the store. + */ + studyId?: string; + + /** + * Callback when study changes (for URL updates) + */ + onStudyChange?: (studyId: string) => void; + + /** + * Show loading overlay while spec is loading + */ + showLoadingOverlay?: boolean; + + /** + * Enable/disable editing (drag, connect, delete) + */ + editable?: boolean; + + /** + * Enable real-time WebSocket sync (default: true) + */ + enableWebSocket?: boolean; + + /** + * Show connection status indicator (default: true when WebSocket enabled) + */ + showConnectionStatus?: boolean; +} + +function SpecRendererInner({ + studyId, + onStudyChange, + showLoadingOverlay = true, + editable = true, + enableWebSocket = true, + showConnectionStatus = true, +}: SpecRendererProps) { + const reactFlowWrapper = useRef(null); + const reactFlowInstance = useRef(null); + + // Spec store state and actions + const spec = useSpec(); + const isLoading = useSpecLoading(); + const error = useSpecError(); + const selectedNodeId = useSelectedNodeId(); + const selectedEdgeId = useSelectedEdgeId(); + + const { + loadSpec, + selectNode, + selectEdge, + clearSelection, + updateNodePosition, + addNode, + addEdge, + removeEdge, + removeNode, + setError, + } = useSpecStore(); + + // WebSocket for real-time sync + const storeStudyId = useSpecStore((s) => s.studyId); + const wsStudyId = enableWebSocket ? storeStudyId : null; + const { status: wsStatus } = useSpecWebSocket(wsStudyId); + + // Load spec on mount if studyId provided + useEffect(() => { + if (studyId) { + loadSpec(studyId).then(() => { + if (onStudyChange) { + onStudyChange(studyId); + } + }); + } + }, [studyId, loadSpec, onStudyChange]); + + // Convert spec to ReactFlow nodes + const nodes = useMemo(() => { + return specToNodes(spec); + }, [spec]); + + // Convert spec to ReactFlow edges with selection styling + const edges = useMemo(() => { + const baseEdges = specToEdges(spec); + return baseEdges.map((edge) => ({ + ...edge, + style: { + stroke: edge.id === selectedEdgeId ? '#60a5fa' : '#6b7280', + strokeWidth: edge.id === selectedEdgeId ? 3 : 2, + }, + animated: edge.id === selectedEdgeId, + })); + }, [spec, selectedEdgeId]); + + // Track node positions for change handling + const nodesRef = useRef[]>(nodes); + useEffect(() => { + nodesRef.current = nodes; + }, [nodes]); + + // Handle node position changes + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + if (!editable) return; + + // Handle position changes + for (const change of changes) { + if (change.type === 'position' && change.position && change.dragging === false) { + // Dragging ended - update spec + updateNodePosition(change.id, { + x: change.position.x, + y: change.position.y, + }); + } + } + }, + [editable, updateNodePosition] + ); + + // Handle edge changes (deletion) + const onEdgesChange = useCallback( + (changes: EdgeChange[]) => { + if (!editable) return; + + for (const change of changes) { + if (change.type === 'remove') { + // Find the edge being removed + const edge = edges.find((e) => e.id === change.id); + if (edge) { + removeEdge(edge.source, edge.target).catch((err) => { + console.error('Failed to remove edge:', err); + setError(err.message); + }); + } + } + } + }, + [editable, edges, removeEdge, setError] + ); + + // Handle new connections + const onConnect = useCallback( + (connection: Connection) => { + if (!editable) return; + if (!connection.source || !connection.target) return; + + addEdge(connection.source, connection.target).catch((err) => { + console.error('Failed to add edge:', err); + setError(err.message); + }); + }, + [editable, addEdge, setError] + ); + + // Handle node clicks for selection + const onNodeClick = useCallback( + (_: React.MouseEvent, node: { id: string }) => { + selectNode(node.id); + }, + [selectNode] + ); + + // Handle edge clicks for selection + const onEdgeClick = useCallback( + (_: React.MouseEvent, edge: Edge) => { + selectEdge(edge.id); + }, + [selectEdge] + ); + + // Handle pane clicks to clear selection + const onPaneClick = useCallback(() => { + clearSelection(); + }, [clearSelection]); + + // Keyboard handler for Delete/Backspace + useEffect(() => { + if (!editable) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Delete' || event.key === 'Backspace') { + const target = event.target as HTMLElement; + if ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ) { + return; + } + + // Delete selected edge first + if (selectedEdgeId) { + const edge = edges.find((e) => e.id === selectedEdgeId); + if (edge) { + removeEdge(edge.source, edge.target).catch((err) => { + console.error('Failed to delete edge:', err); + setError(err.message); + }); + } + return; + } + + // Delete selected node + if (selectedNodeId) { + // Don't allow deleting synthetic nodes (model, solver, optimization) + if (['model', 'solver', 'optimization', 'surrogate'].includes(selectedNodeId)) { + return; + } + + removeNode(selectedNodeId).catch((err) => { + console.error('Failed to delete node:', err); + setError(err.message); + }); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [editable, selectedNodeId, selectedEdgeId, edges, removeNode, removeEdge, setError]); + + // ========================================================================= + // Drag-Drop Handlers + // ========================================================================= + + const onDragOver = useCallback( + (event: DragEvent) => { + if (!editable) return; + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, + [editable] + ); + + const onDrop = useCallback( + async (event: DragEvent) => { + if (!editable || !reactFlowInstance.current) return; + event.preventDefault(); + + const type = event.dataTransfer.getData('application/reactflow'); + if (!type || !isAddableNodeType(type)) { + console.warn('Invalid or non-addable node type dropped:', type); + return; + } + + // Convert screen position to flow position + const position = reactFlowInstance.current.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, + }); + + // Create default data for the node + const nodeData = getDefaultNodeData(type, position); + const specType = mapNodeTypeToSpecType(type); + + try { + const nodeId = await addNode(specType, nodeData); + // Select the newly created node + selectNode(nodeId); + } catch (err) { + console.error('Failed to add node:', err); + setError(err instanceof Error ? err.message : 'Failed to add node'); + } + }, + [editable, addNode, selectNode, setError] + ); + + // Loading state + if (showLoadingOverlay && isLoading && !spec) { + return ( +
+
+
+

Loading spec...

+
+
+ ); + } + + // Error state + if (error && !spec) { + return ( +
+
+
+ + + +
+

Failed to load spec

+

{error}

+ {studyId && ( + + )} +
+
+ ); + } + + // Empty state + if (!spec) { + return ( +
+
+

No spec loaded

+

Load a study to see its optimization configuration

+
+
+ ); + } + + return ( +
+ {/* Status indicators (overlay) */} +
+ {/* WebSocket connection status */} + {enableWebSocket && showConnectionStatus && ( +
+ +
+ )} + + {/* Loading indicator */} + {isLoading && ( +
+
+ Syncing... +
+ )} +
+ + {/* Error banner (overlay) */} + {error && ( +
+ {error} + +
+ )} + + { + reactFlowInstance.current = instance; + }} + onDragOver={onDragOver} + onDrop={onDrop} + onNodeClick={onNodeClick} + onEdgeClick={onEdgeClick} + onPaneClick={onPaneClick} + nodeTypes={nodeTypes} + fitView + deleteKeyCode={null} // We handle delete ourselves + nodesDraggable={editable} + nodesConnectable={editable} + elementsSelectable={true} + className="bg-dark-900" + > + + + + + + {/* Study name badge */} +
+ {spec.meta.study_name} +
+
+ ); +} + +/** + * SpecRenderer with ReactFlowProvider wrapper. + * + * Usage: + * ```tsx + * // Load spec on mount + * + * + * // Use with already-loaded spec + * const { loadSpec } = useSpecStore(); + * await loadSpec('M1_Mirror/m1_mirror_flatback'); + * + * ``` + */ +export function SpecRenderer(props: SpecRendererProps) { + return ( + + + + ); +} + +export default SpecRenderer; diff --git a/atomizer-dashboard/frontend/src/components/canvas/nodes/ModelNodeV2.tsx b/atomizer-dashboard/frontend/src/components/canvas/nodes/ModelNodeV2.tsx new file mode 100644 index 00000000..9eec231d --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/nodes/ModelNodeV2.tsx @@ -0,0 +1,260 @@ +/** + * ModelNodeV2 - Enhanced model node with collapsible file dependencies + * + * Features: + * - Shows main model file (.sim) + * - Collapsible section showing related files (.prt, .fem, _i.prt) + * - Hover to reveal file path + * - Click to introspect model + * - Shows solver type badge + */ + +import { memo, useState, useCallback, useEffect } from 'react'; +import { NodeProps, Handle, Position } from 'reactflow'; +import { + Box, + ChevronDown, + ChevronRight, + FileBox, + FileCode, + Cpu, + RefreshCw, + AlertCircle, + CheckCircle, +} from 'lucide-react'; +import { ModelNodeData } from '../../../lib/canvas/schema'; + +interface DependentFile { + name: string; + path: string; + type: 'prt' | 'fem' | 'sim' | 'idealized' | 'other'; + exists: boolean; +} + +interface IntrospectionResult { + expressions: Array<{ + name: string; + value: number | string; + units?: string; + formula?: string; + }>; + solver_type?: string; + dependent_files?: string[]; +} + +function ModelNodeV2Component(props: NodeProps) { + const { data, selected } = props; + const [isExpanded, setIsExpanded] = useState(false); + const [dependencies, setDependencies] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [introspection, setIntrospection] = useState(null); + const [error, setError] = useState(null); + + // Extract filename from path + const fileName = data.filePath ? data.filePath.split(/[/\\]/).pop() : 'No file selected'; + + // Load dependencies when expanded + const loadDependencies = useCallback(async () => { + if (!data.filePath) return; + + setIsLoading(true); + setError(null); + + try { + // Call introspection API to get dependent files + const response = await fetch( + `/api/nx/introspect`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ file_path: data.filePath }), + } + ); + + if (!response.ok) { + throw new Error('Failed to introspect model'); + } + + const result = await response.json(); + setIntrospection(result); + + // Parse dependent files + const deps: DependentFile[] = []; + + if (result.dependent_files) { + for (const filePath of result.dependent_files) { + const name = filePath.split(/[/\\]/).pop() || filePath; + const ext = name.split('.').pop()?.toLowerCase(); + + let type: DependentFile['type'] = 'other'; + if (name.includes('_i.prt')) { + type = 'idealized'; + } else if (ext === 'prt') { + type = 'prt'; + } else if (ext === 'fem' || ext === 'afem') { + type = 'fem'; + } else if (ext === 'sim') { + type = 'sim'; + } + + deps.push({ + name, + path: filePath, + type, + exists: true, // Assume exists from introspection + }); + } + } + + setDependencies(deps); + } catch (err) { + console.error('Failed to load model dependencies:', err); + setError('Failed to introspect'); + } finally { + setIsLoading(false); + } + }, [data.filePath]); + + // Load on first expand + useEffect(() => { + if (isExpanded && dependencies.length === 0 && !isLoading && data.filePath) { + loadDependencies(); + } + }, [isExpanded, dependencies.length, isLoading, data.filePath, loadDependencies]); + + // Get icon for file type + const getFileIcon = (type: DependentFile['type']) => { + switch (type) { + case 'prt': + return ; + case 'fem': + return ; + case 'sim': + return ; + case 'idealized': + return ; + default: + return ; + } + }; + + return ( +
+ {/* Input handle */} + + + {/* Main content */} +
+
+
+ +
+
+
+ {data.label || 'Model'} +
+
+ {!data.configured && ( +
+ )} +
+ + {/* File info */} +
+ {fileName} +
+ + {/* Solver badge */} + {introspection?.solver_type && ( +
+ + {introspection.solver_type} +
+ )} +
+ + {/* Dependencies section (collapsible) */} + {data.filePath && ( +
+ + + {isExpanded && ( +
+ {error ? ( +
+ + {error} +
+ ) : dependencies.length === 0 && !isLoading ? ( +
+ No dependencies found +
+ ) : ( + dependencies.map((dep) => ( +
+ {getFileIcon(dep.type)} + {dep.name} + {dep.exists ? ( + + ) : ( + + )} +
+ )) + )} + + {/* Expressions count */} + {introspection?.expressions && introspection.expressions.length > 0 && ( +
+
+ {introspection.expressions.length} expressions found +
+
+ )} +
+ )} +
+ )} + + {/* Output handle */} + +
+ ); +} + +export const ModelNodeV2 = memo(ModelNodeV2Component); diff --git a/atomizer-dashboard/frontend/src/components/canvas/nodes/index.ts b/atomizer-dashboard/frontend/src/components/canvas/nodes/index.ts index b7520cd2..c279c8f5 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/nodes/index.ts +++ b/atomizer-dashboard/frontend/src/components/canvas/nodes/index.ts @@ -1,4 +1,5 @@ import { ModelNode } from './ModelNode'; +import { ModelNodeV2 } from './ModelNodeV2'; import { SolverNode } from './SolverNode'; import { DesignVarNode } from './DesignVarNode'; import { ExtractorNode } from './ExtractorNode'; @@ -9,6 +10,7 @@ import { SurrogateNode } from './SurrogateNode'; export { ModelNode, + ModelNodeV2, SolverNode, DesignVarNode, ExtractorNode, @@ -18,8 +20,12 @@ export { SurrogateNode, }; +// Use ModelNodeV2 by default for enhanced dependency display +// Set USE_LEGACY_MODEL_NODE=true to use the original +const useEnhancedModelNode = !import.meta.env.VITE_USE_LEGACY_MODEL_NODE; + export const nodeTypes = { - model: ModelNode, + model: useEnhancedModelNode ? ModelNodeV2 : ModelNode, solver: SolverNode, designVar: DesignVarNode, extractor: ExtractorNode, diff --git a/atomizer-dashboard/frontend/src/components/canvas/palette/NodePalette.tsx b/atomizer-dashboard/frontend/src/components/canvas/palette/NodePalette.tsx index cb79fa5f..d2d2a24d 100644 --- a/atomizer-dashboard/frontend/src/components/canvas/palette/NodePalette.tsx +++ b/atomizer-dashboard/frontend/src/components/canvas/palette/NodePalette.tsx @@ -1,5 +1,15 @@ +/** + * NodePalette - Draggable component library for canvas + * + * Features: + * - Draggable node items for canvas drop + * - Collapsible mode (icons only) + * - Filterable by node type + * - Works with both AtomizerCanvas and SpecRenderer + */ + import { DragEvent } from 'react'; -import { NodeType } from '../../../lib/canvas/schema'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; import { Box, Cpu, @@ -9,63 +19,237 @@ import { ShieldAlert, BrainCircuit, Rocket, + LucideIcon, } from 'lucide-react'; +import { NodeType } from '../../../lib/canvas/schema'; -interface PaletteItem { +// ============================================================================ +// Types +// ============================================================================ + +export interface PaletteItem { type: NodeType; label: string; - icon: React.ReactNode; + icon: LucideIcon; description: string; color: string; + /** Whether this can be added via drag-drop (synthetic nodes cannot) */ + canAdd: boolean; } -const PALETTE_ITEMS: PaletteItem[] = [ - { type: 'model', label: 'Model', icon: , description: 'NX model file (.prt, .sim)', color: 'text-blue-400' }, - { type: 'solver', label: 'Solver', icon: , description: 'Nastran solution type', color: 'text-violet-400' }, - { type: 'designVar', label: 'Design Variable', icon: , description: 'Parameter to optimize', color: 'text-emerald-400' }, - { type: 'extractor', label: 'Extractor', icon: , description: 'Physics result extraction', color: 'text-cyan-400' }, - { type: 'objective', label: 'Objective', icon: , description: 'Optimization goal', color: 'text-rose-400' }, - { type: 'constraint', label: 'Constraint', icon: , description: 'Design constraint', color: 'text-amber-400' }, - { type: 'algorithm', label: 'Algorithm', icon: , description: 'Optimization method', color: 'text-indigo-400' }, - { type: 'surrogate', label: 'Surrogate', icon: , description: 'Neural acceleration', color: 'text-pink-400' }, +export interface NodePaletteProps { + /** Whether palette is collapsed (icon-only mode) */ + collapsed?: boolean; + /** Callback when collapse state changes */ + onToggleCollapse?: () => void; + /** Custom className for container */ + className?: string; + /** Filter which node types to show */ + visibleTypes?: NodeType[]; + /** Show toggle button */ + showToggle?: boolean; +} + +// ============================================================================ +// Constants +// ============================================================================ + +export const PALETTE_ITEMS: PaletteItem[] = [ + { + type: 'model', + label: 'Model', + icon: Box, + description: 'NX model file (.prt, .sim)', + color: 'text-blue-400', + canAdd: false, // Synthetic - derived from spec + }, + { + type: 'solver', + label: 'Solver', + icon: Cpu, + description: 'Nastran solution type', + color: 'text-violet-400', + canAdd: false, // Synthetic - derived from model + }, + { + type: 'designVar', + label: 'Design Variable', + icon: SlidersHorizontal, + description: 'Parameter to optimize', + color: 'text-emerald-400', + canAdd: true, + }, + { + type: 'extractor', + label: 'Extractor', + icon: FlaskConical, + description: 'Physics result extraction', + color: 'text-cyan-400', + canAdd: true, + }, + { + type: 'objective', + label: 'Objective', + icon: Target, + description: 'Optimization goal', + color: 'text-rose-400', + canAdd: true, + }, + { + type: 'constraint', + label: 'Constraint', + icon: ShieldAlert, + description: 'Design constraint', + color: 'text-amber-400', + canAdd: true, + }, + { + type: 'algorithm', + label: 'Algorithm', + icon: BrainCircuit, + description: 'Optimization method', + color: 'text-indigo-400', + canAdd: false, // Synthetic - derived from spec.optimization + }, + { + type: 'surrogate', + label: 'Surrogate', + icon: Rocket, + description: 'Neural acceleration', + color: 'text-pink-400', + canAdd: false, // Synthetic - derived from spec.optimization.surrogate + }, ]; -export function NodePalette() { - const onDragStart = (event: DragEvent, nodeType: NodeType) => { - event.dataTransfer.setData('application/reactflow', nodeType); +/** Items that can be added via drag-drop */ +export const ADDABLE_ITEMS = PALETTE_ITEMS.filter(item => item.canAdd); + +// ============================================================================ +// Component +// ============================================================================ + +export function NodePalette({ + collapsed = false, + onToggleCollapse, + className = '', + visibleTypes, + showToggle = true, +}: NodePaletteProps) { + // Filter items if visibleTypes is provided + const items = visibleTypes + ? PALETTE_ITEMS.filter(item => visibleTypes.includes(item.type)) + : PALETTE_ITEMS; + + const onDragStart = (event: DragEvent, item: PaletteItem) => { + if (!item.canAdd) { + event.preventDefault(); + return; + } + event.dataTransfer.setData('application/reactflow', item.type); event.dataTransfer.effectAllowed = 'move'; }; - return ( -
-
-

- Components -

-

- Drag to canvas -

-
-
- {PALETTE_ITEMS.map((item) => ( -
onDragStart(e, item.type)} - className="flex items-center gap-3 px-3 py-3 bg-dark-800/50 rounded-lg border border-dark-700/50 - cursor-grab hover:border-primary-500/50 hover:bg-dark-800 - active:cursor-grabbing transition-all group" + // Collapsed mode - icons only + if (collapsed) { + return ( +
+ {/* Toggle Button */} + {showToggle && onToggleCollapse && ( + + )} + + {/* Collapsed Items */} +
+ {items.map((item) => { + const Icon = item.icon; + const isDraggable = item.canAdd; + + return ( +
onDragStart(e, item)} + className={`p-3 mx-2 my-1 rounded-lg transition-all flex items-center justify-center + ${isDraggable + ? 'cursor-grab hover:bg-dark-800 active:cursor-grabbing' + : 'cursor-default opacity-50' + }`} + title={`${item.label}${!isDraggable ? ' (auto-created)' : ''}`} + > + +
+ ); + })} +
+
+ ); + } + + // Expanded mode - full display + return ( +
+ {/* Header */} +
+
+

+ Components +

+

+ Drag to canvas +

+
+ {showToggle && onToggleCollapse && ( + + )} +
+ + {/* Items */} +
+ {items.map((item) => { + const Icon = item.icon; + const isDraggable = item.canAdd; + + return ( +
onDragStart(e, item)} + className={`flex items-center gap-3 px-3 py-3 rounded-lg border transition-all group + ${isDraggable + ? 'bg-dark-800/50 border-dark-700/50 cursor-grab hover:border-primary-500/50 hover:bg-dark-800 active:cursor-grabbing' + : 'bg-dark-900/30 border-dark-800/30 cursor-default' + }`} + title={!isDraggable ? 'Auto-created from study configuration' : undefined} + > +
+ +
+
+
+ {item.label} +
+
+ {isDraggable ? item.description : 'Auto-created'} +
+
-
-
{item.label}
-
{item.description}
-
-
- ))} + ); + })}
); } + +export default NodePalette; diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/FileStructurePanel.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/FileStructurePanel.tsx new file mode 100644 index 00000000..903993a6 --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/FileStructurePanel.tsx @@ -0,0 +1,310 @@ +/** + * FileStructurePanel - Shows study file structure in the canvas sidebar + * + * Features: + * - Tree view of study directory + * - Highlights model files (.prt, .fem, .sim) + * - Shows file dependencies + * - One-click to set as model source + * - Refresh button to reload + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + Folder, + FolderOpen, + FileBox, + ChevronRight, + RefreshCw, + Box, + Cpu, + FileCode, + AlertCircle, + CheckCircle, + Plus, +} from 'lucide-react'; + +interface FileNode { + name: string; + path: string; + type: 'file' | 'directory'; + extension?: string; + size?: number; + children?: FileNode[]; + isModelFile?: boolean; + isSelected?: boolean; +} + +interface FileStructurePanelProps { + studyId: string | null; + onModelSelect?: (filePath: string, fileType: string) => void; + selectedModelPath?: string; + className?: string; +} + +// File type to icon mapping +const FILE_ICONS: Record = { + '.prt': { icon: Box, color: 'text-blue-400' }, + '.sim': { icon: Cpu, color: 'text-violet-400' }, + '.fem': { icon: FileCode, color: 'text-emerald-400' }, + '.afem': { icon: FileCode, color: 'text-emerald-400' }, + '.dat': { icon: FileBox, color: 'text-amber-400' }, + '.bdf': { icon: FileBox, color: 'text-amber-400' }, + '.op2': { icon: FileBox, color: 'text-rose-400' }, + '.f06': { icon: FileBox, color: 'text-dark-400' }, +}; + +const MODEL_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem']; + +export function FileStructurePanel({ + studyId, + onModelSelect, + selectedModelPath, + className = '', +}: FileStructurePanelProps) { + const [files, setFiles] = useState([]); + const [expandedPaths, setExpandedPaths] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Load study file structure + const loadFileStructure = useCallback(async () => { + if (!studyId) { + setFiles([]); + return; + } + + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`/api/files/structure/${encodeURIComponent(studyId)}`); + + if (!response.ok) { + if (response.status === 404) { + setError('Study not found'); + } else { + throw new Error(`Failed to load: ${response.status}`); + } + setFiles([]); + return; + } + + const data = await response.json(); + + // Process the file tree to mark model files + const processNode = (node: FileNode): FileNode => { + if (node.type === 'directory' && node.children) { + return { + ...node, + children: node.children.map(processNode), + }; + } + + const ext = '.' + node.name.split('.').pop()?.toLowerCase(); + return { + ...node, + extension: ext, + isModelFile: MODEL_EXTENSIONS.includes(ext), + isSelected: node.path === selectedModelPath, + }; + }; + + const processedFiles = (data.files || []).map(processNode); + setFiles(processedFiles); + + // Auto-expand 1_setup and root directories + const toExpand = new Set(); + processedFiles.forEach((node: FileNode) => { + if (node.type === 'directory') { + toExpand.add(node.path); + if (node.name === '1_setup' && node.children) { + node.children.forEach((child: FileNode) => { + if (child.type === 'directory') { + toExpand.add(child.path); + } + }); + } + } + }); + setExpandedPaths(toExpand); + } catch (err) { + console.error('Failed to load file structure:', err); + setError('Failed to load files'); + } finally { + setIsLoading(false); + } + }, [studyId, selectedModelPath]); + + useEffect(() => { + loadFileStructure(); + }, [loadFileStructure]); + + // Toggle directory expansion + const toggleExpand = (path: string) => { + setExpandedPaths((prev) => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + // Handle file selection + const handleFileClick = (node: FileNode) => { + if (node.type === 'directory') { + toggleExpand(node.path); + } else if (node.isModelFile && onModelSelect) { + onModelSelect(node.path, node.extension || ''); + } + }; + + // Render a file/folder node + const renderNode = (node: FileNode, depth: number = 0) => { + const isExpanded = expandedPaths.has(node.path); + const isDirectory = node.type === 'directory'; + const fileInfo = node.extension ? FILE_ICONS[node.extension] : null; + const Icon = isDirectory + ? isExpanded + ? FolderOpen + : Folder + : fileInfo?.icon || FileBox; + const iconColor = isDirectory + ? 'text-amber-400' + : fileInfo?.color || 'text-dark-400'; + + const isSelected = node.path === selectedModelPath; + + return ( +
+ + + {/* Children */} + {isDirectory && isExpanded && node.children && ( +
+ {node.children.map((child) => renderNode(child, depth + 1))} +
+ )} +
+ ); + }; + + // No study selected state + if (!studyId) { + return ( +
+
+ +

No study selected

+

+ Load a study to see its files +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ + Files +
+ +
+ + {/* Content */} +
+ {isLoading && files.length === 0 ? ( +
+ + Loading... +
+ ) : error ? ( +
+ + {error} +
+ ) : files.length === 0 ? ( +
+

No files found

+

+ Add model files to 1_setup/ +

+
+ ) : ( +
+ {files.map((node) => renderNode(node))} +
+ )} +
+ + {/* Footer hint */} +
+ Click a model file to select it +
+
+ ); +} + +export default FileStructurePanel; diff --git a/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx new file mode 100644 index 00000000..d5757c74 --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanelV2.tsx @@ -0,0 +1,684 @@ +/** + * NodeConfigPanelV2 - Configuration panel for AtomizerSpec v2.0 nodes + * + * This component uses useSpecStore instead of the legacy useCanvasStore. + * It renders type-specific configuration forms based on the selected node. + */ + +import { useState, useMemo, useCallback } from 'react'; +import { Microscope, Trash2, X, AlertCircle } from 'lucide-react'; +import { + useSpecStore, + useSpec, + useSelectedNodeId, + useSelectedNode, +} from '../../../hooks/useSpecStore'; +import { FileBrowser } from './FileBrowser'; +import { IntrospectionPanel } from './IntrospectionPanel'; +import { + DesignVariable, + Extractor, + Objective, + Constraint, +} from '../../../types/atomizer-spec'; + +// Common input class for dark theme +const inputClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white placeholder-dark-400 rounded-lg focus:border-primary-500 focus:outline-none transition-colors"; +const selectClass = "w-full px-3 py-2 bg-dark-800 border border-dark-600 text-white rounded-lg focus:border-primary-500 focus:outline-none transition-colors"; +const labelClass = "block text-sm font-medium text-dark-300 mb-1"; + +interface NodeConfigPanelV2Props { + /** Called when panel should close */ + onClose?: () => void; +} + +export function NodeConfigPanelV2({ onClose }: NodeConfigPanelV2Props) { + const spec = useSpec(); + const selectedNodeId = useSelectedNodeId(); + const selectedNode = useSelectedNode(); + const { updateNode, removeNode, clearSelection } = useSpecStore(); + + const [showFileBrowser, setShowFileBrowser] = useState(false); + const [showIntrospection, setShowIntrospection] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + + // Determine node type from ID prefix or from the node itself + const nodeType = useMemo(() => { + if (!selectedNodeId) return null; + + // Synthetic nodes have fixed IDs + if (selectedNodeId === 'model') return 'model'; + if (selectedNodeId === 'solver') return 'solver'; + if (selectedNodeId === 'algorithm') return 'algorithm'; + if (selectedNodeId === 'surrogate') return 'surrogate'; + + // Real nodes have prefixed IDs + const prefix = selectedNodeId.split('_')[0]; + switch (prefix) { + case 'dv': return 'designVar'; + case 'ext': return 'extractor'; + case 'obj': return 'objective'; + case 'con': return 'constraint'; + default: return null; + } + }, [selectedNodeId]); + + // Get label for display + const nodeLabel = useMemo(() => { + if (!selectedNodeId || !spec) return 'Node'; + + switch (nodeType) { + case 'model': return spec.meta.study_name || 'Model'; + case 'solver': return spec.model.sim?.solution_type || 'Solver'; + case 'algorithm': return spec.optimization.algorithm?.type || 'Algorithm'; + case 'surrogate': return 'Neural Surrogate'; + default: + if (selectedNode) { + return (selectedNode as any).name || selectedNodeId; + } + return selectedNodeId; + } + }, [selectedNodeId, selectedNode, nodeType, spec]); + + // Handle field changes + const handleChange = useCallback(async (field: string, value: unknown) => { + if (!selectedNodeId || !selectedNode) return; + + setIsUpdating(true); + setError(null); + + try { + await updateNode(selectedNodeId, { [field]: value }); + } catch (err) { + console.error('Failed to update node:', err); + setError(err instanceof Error ? err.message : 'Update failed'); + } finally { + setIsUpdating(false); + } + }, [selectedNodeId, selectedNode, updateNode]); + + // Handle delete + const handleDelete = useCallback(async () => { + if (!selectedNodeId) return; + + // Synthetic nodes can't be deleted + if (['model', 'solver', 'algorithm', 'surrogate'].includes(selectedNodeId)) { + setError('This node cannot be deleted'); + return; + } + + setIsUpdating(true); + setError(null); + + try { + await removeNode(selectedNodeId); + clearSelection(); + onClose?.(); + } catch (err) { + console.error('Failed to delete node:', err); + setError(err instanceof Error ? err.message : 'Delete failed'); + } finally { + setIsUpdating(false); + } + }, [selectedNodeId, removeNode, clearSelection, onClose]); + + // Don't render if no node selected + if (!selectedNodeId || !spec) { + return null; + } + + // Check if this is a synthetic node (model, solver, algorithm, surrogate) + const isSyntheticNode = ['model', 'solver', 'algorithm', 'surrogate'].includes(selectedNodeId); + + return ( +
+ {/* Header */} +
+

+ Configure {nodeLabel} +

+
+ {!isSyntheticNode && ( + + )} + {onClose && ( + + )} +
+
+ + {/* Error display */} + {error && ( +
+ +

{error}

+ +
+ )} + + {/* Content */} +
+
+ {/* Loading indicator */} + {isUpdating && ( +
Updating...
+ )} + + {/* Model node (synthetic) */} + {nodeType === 'model' && spec.model && ( + + )} + + {/* Solver node (synthetic) */} + {nodeType === 'solver' && ( + + )} + + {/* Algorithm node (synthetic) */} + {nodeType === 'algorithm' && ( + + )} + + {/* Surrogate node (synthetic) */} + {nodeType === 'surrogate' && ( + + )} + + {/* Design Variable */} + {nodeType === 'designVar' && selectedNode && ( + + )} + + {/* Extractor */} + {nodeType === 'extractor' && selectedNode && ( + + )} + + {/* Objective */} + {nodeType === 'objective' && selectedNode && ( + + )} + + {/* Constraint */} + {nodeType === 'constraint' && selectedNode && ( + + )} +
+
+ + {/* File Browser Modal */} + setShowFileBrowser(false)} + onSelect={() => { + // This would update the model path - but model is synthetic + setShowFileBrowser(false); + }} + fileTypes={['.sim', '.prt', '.fem', '.afem']} + /> + + {/* Introspection Panel */} + {showIntrospection && spec.model.sim?.path && ( +
+ setShowIntrospection(false)} + /> +
+ )} +
+ ); +} + +// ============================================================================ +// Type-specific configuration components +// ============================================================================ + +interface SpecConfigProps { + spec: NonNullable>; +} + +function ModelNodeConfig({ spec }: SpecConfigProps) { + const [showIntrospection, setShowIntrospection] = useState(false); + + return ( + <> +
+ + +

Read-only. Set in study configuration.

+
+ +
+ + +
+ + {spec.model.sim?.path && ( + + )} + + {showIntrospection && spec.model.sim?.path && ( +
+ setShowIntrospection(false)} + /> +
+ )} + + ); +} + +function SolverNodeConfig({ spec }: SpecConfigProps) { + return ( +
+ + +

Detected from model file.

+
+ ); +} + +function AlgorithmNodeConfig({ spec }: SpecConfigProps) { + const algo = spec.optimization.algorithm; + + return ( + <> +
+ + +

Edit in optimization settings.

+
+ +
+ + +
+ + ); +} + +function SurrogateNodeConfig({ spec }: SpecConfigProps) { + const surrogate = spec.optimization.surrogate; + + return ( + <> +
+ + +
+ + {surrogate?.enabled && ( + <> +
+ + +
+ +
+ + +
+ + )} + +

Edit in optimization settings.

+ + ); +} + +// ============================================================================ +// Editable node configs +// ============================================================================ + +interface DesignVarNodeConfigProps { + node: DesignVariable; + onChange: (field: string, value: unknown) => void; +} + +function DesignVarNodeConfig({ node, onChange }: DesignVarNodeConfigProps) { + return ( + <> +
+ + onChange('name', e.target.value)} + className={inputClass} + /> +
+ +
+ + onChange('expression_name', e.target.value)} + placeholder="NX expression name" + className={`${inputClass} font-mono text-sm`} + /> +
+ +
+
+ + onChange('bounds', { ...node.bounds, min: parseFloat(e.target.value) })} + className={inputClass} + /> +
+
+ + onChange('bounds', { ...node.bounds, max: parseFloat(e.target.value) })} + className={inputClass} + /> +
+
+ + {node.baseline !== undefined && ( +
+ + onChange('baseline', parseFloat(e.target.value))} + className={inputClass} + /> +
+ )} + +
+ + onChange('units', e.target.value)} + placeholder="mm" + className={inputClass} + /> +
+ +
+ onChange('enabled', e.target.checked)} + className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 focus:ring-primary-500" + /> + +
+ + ); +} + +interface ExtractorNodeConfigProps { + node: Extractor; + onChange: (field: string, value: unknown) => void; +} + +function ExtractorNodeConfig({ node, onChange }: ExtractorNodeConfigProps) { + const extractorOptions = [ + { id: 'E1', name: 'Displacement', type: 'displacement' }, + { id: 'E2', name: 'Frequency', type: 'frequency' }, + { id: 'E3', name: 'Solid Stress', type: 'solid_stress' }, + { id: 'E4', name: 'BDF Mass', type: 'mass_bdf' }, + { id: 'E5', name: 'CAD Mass', type: 'mass_expression' }, + { id: 'E8', name: 'Zernike (OP2)', type: 'zernike_op2' }, + { id: 'E9', name: 'Zernike (CSV)', type: 'zernike_csv' }, + { id: 'E10', name: 'Zernike (RMS)', type: 'zernike_rms' }, + ]; + + return ( + <> +
+ + onChange('name', e.target.value)} + className={inputClass} + /> +
+ +
+ + +
+ + {node.type === 'custom_function' && node.function && ( +
+ + +

Edit custom code in dedicated editor.

+
+ )} + +
+ + o.name).join(', ') || ''} + readOnly + placeholder="value, unit" + className={`${inputClass} bg-dark-900 cursor-not-allowed`} + /> +

Outputs are defined by extractor type.

+
+ + ); +} + +interface ObjectiveNodeConfigProps { + node: Objective; + onChange: (field: string, value: unknown) => void; +} + +function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) { + return ( + <> +
+ + onChange('name', e.target.value)} + className={inputClass} + /> +
+ +
+ + +
+ +
+ + onChange('weight', parseFloat(e.target.value))} + className={inputClass} + /> +
+ + {node.target !== undefined && ( +
+ + onChange('target', parseFloat(e.target.value))} + className={inputClass} + /> +
+ )} + + ); +} + +interface ConstraintNodeConfigProps { + node: Constraint; + onChange: (field: string, value: unknown) => void; +} + +function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) { + return ( + <> +
+ + onChange('name', e.target.value)} + className={inputClass} + /> +
+ +
+
+ + +
+
+ + onChange('threshold', parseFloat(e.target.value))} + className={inputClass} + /> +
+
+ + + ); +} + +export default NodeConfigPanelV2; diff --git a/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts b/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts index b182e92d..23acc648 100644 --- a/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts +++ b/atomizer-dashboard/frontend/src/hooks/useCanvasStore.ts @@ -1,3 +1,19 @@ +/** + * @deprecated This store is deprecated as of January 2026. + * Use useSpecStore instead, which works with AtomizerSpec v2.0. + * + * Migration guide: + * - Import useSpecStore from '../hooks/useSpecStore' instead + * - Use spec.design_variables, spec.extractors, etc. instead of nodes/edges + * - Use addNode(), updateNode(), removeNode() instead of canvas mutations + * - Spec changes sync automatically via WebSocket + * + * This store is kept for emergency fallback only with AtomizerCanvas. + * + * @see useSpecStore for the new state management + * @see AtomizerSpec v2.0 documentation + */ + import { create } from 'zustand'; import { Node, Edge, addEdge, applyNodeChanges, applyEdgeChanges, Connection, NodeChange, EdgeChange } from 'reactflow'; import { CanvasNodeData, NodeType } from '../lib/canvas/schema'; diff --git a/atomizer-dashboard/frontend/src/hooks/useSpecStore.test.ts b/atomizer-dashboard/frontend/src/hooks/useSpecStore.test.ts new file mode 100644 index 00000000..4e379a3f --- /dev/null +++ b/atomizer-dashboard/frontend/src/hooks/useSpecStore.test.ts @@ -0,0 +1,209 @@ +/** + * useSpecStore Unit Tests + * + * Tests for the AtomizerSpec v2.0 state management store. + */ + +/// + +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSpecStore } from './useSpecStore'; +import { createMockSpec, mockFetch } from '../test/utils'; + +// Type for global context +declare const global: typeof globalThis; + +describe('useSpecStore', () => { + beforeEach(() => { + // Reset the store state before each test + useSpecStore.setState({ + spec: null, + studyId: null, + hash: null, + isLoading: false, + error: null, + validation: null, + selectedNodeId: null, + selectedEdgeId: null, + isDirty: false, + pendingChanges: [], + }); + }); + + describe('initial state', () => { + it('should have null spec initially', () => { + const { spec } = useSpecStore.getState(); + expect(spec).toBeNull(); + }); + + it('should not be loading initially', () => { + const { isLoading } = useSpecStore.getState(); + expect(isLoading).toBe(false); + }); + + it('should have no selected node initially', () => { + const { selectedNodeId } = useSpecStore.getState(); + expect(selectedNodeId).toBeNull(); + }); + }); + + describe('selection', () => { + it('should select a node', () => { + const { selectNode } = useSpecStore.getState(); + selectNode('dv_001'); + + const { selectedNodeId, selectedEdgeId } = useSpecStore.getState(); + expect(selectedNodeId).toBe('dv_001'); + expect(selectedEdgeId).toBeNull(); + }); + + it('should select an edge', () => { + const { selectEdge } = useSpecStore.getState(); + selectEdge('edge_1'); + + const { selectedNodeId, selectedEdgeId } = useSpecStore.getState(); + expect(selectedEdgeId).toBe('edge_1'); + expect(selectedNodeId).toBeNull(); + }); + + it('should clear selection', () => { + const { selectNode, clearSelection } = useSpecStore.getState(); + selectNode('dv_001'); + clearSelection(); + + const { selectedNodeId, selectedEdgeId } = useSpecStore.getState(); + expect(selectedNodeId).toBeNull(); + expect(selectedEdgeId).toBeNull(); + }); + + it('should clear edge when selecting node', () => { + const { selectEdge, selectNode } = useSpecStore.getState(); + selectEdge('edge_1'); + selectNode('dv_001'); + + const { selectedNodeId, selectedEdgeId } = useSpecStore.getState(); + expect(selectedNodeId).toBe('dv_001'); + expect(selectedEdgeId).toBeNull(); + }); + }); + + describe('setSpecFromWebSocket', () => { + it('should set spec directly', () => { + const mockSpec = createMockSpec({ meta: { study_name: 'ws_test' } }); + const { setSpecFromWebSocket } = useSpecStore.getState(); + + setSpecFromWebSocket(mockSpec, 'test_study'); + + const { spec, studyId, isLoading, error } = useSpecStore.getState(); + expect(spec?.meta.study_name).toBe('ws_test'); + expect(studyId).toBe('test_study'); + expect(isLoading).toBe(false); + expect(error).toBeNull(); + }); + }); + + describe('loadSpec', () => { + it('should set loading state', async () => { + mockFetch({ + 'spec': createMockSpec(), + 'hash': { hash: 'abc123' }, + }); + + const { loadSpec } = useSpecStore.getState(); + const loadPromise = loadSpec('test_study'); + + // Should be loading immediately + expect(useSpecStore.getState().isLoading).toBe(true); + + await loadPromise; + + // Should no longer be loading + expect(useSpecStore.getState().isLoading).toBe(false); + }); + + it('should handle errors', async () => { + (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); + + const { loadSpec } = useSpecStore.getState(); + + await loadSpec('test_study'); + + const { error, isLoading } = useSpecStore.getState(); + expect(error).toContain('error'); + expect(isLoading).toBe(false); + }); + }); + + describe('getNodeById', () => { + beforeEach(() => { + const mockSpec = createMockSpec({ + design_variables: [ + { id: 'dv_001', name: 'thickness', expression_name: 't', type: 'continuous', bounds: { min: 1, max: 10 } }, + { id: 'dv_002', name: 'width', expression_name: 'w', type: 'continuous', bounds: { min: 5, max: 20 } }, + ], + extractors: [ + { id: 'ext_001', name: 'displacement', type: 'displacement', outputs: ['d'] }, + ], + objectives: [ + { id: 'obj_001', name: 'mass', type: 'minimize', weight: 1.0 }, + ], + }); + + useSpecStore.setState({ spec: mockSpec }); + }); + + it('should find design variable by id', () => { + const { getNodeById } = useSpecStore.getState(); + const node = getNodeById('dv_001'); + + expect(node).not.toBeNull(); + expect((node as any).name).toBe('thickness'); + }); + + it('should find extractor by id', () => { + const { getNodeById } = useSpecStore.getState(); + const node = getNodeById('ext_001'); + + expect(node).not.toBeNull(); + expect((node as any).name).toBe('displacement'); + }); + + it('should find objective by id', () => { + const { getNodeById } = useSpecStore.getState(); + const node = getNodeById('obj_001'); + + expect(node).not.toBeNull(); + expect((node as any).name).toBe('mass'); + }); + + it('should return null for unknown id', () => { + const { getNodeById } = useSpecStore.getState(); + const node = getNodeById('unknown_999'); + + expect(node).toBeNull(); + }); + }); + + describe('clearSpec', () => { + it('should reset all state', () => { + // Set up some state + useSpecStore.setState({ + spec: createMockSpec(), + studyId: 'test', + hash: 'abc', + selectedNodeId: 'dv_001', + isDirty: true, + }); + + const { clearSpec } = useSpecStore.getState(); + clearSpec(); + + const state = useSpecStore.getState(); + expect(state.spec).toBeNull(); + expect(state.studyId).toBeNull(); + expect(state.hash).toBeNull(); + expect(state.selectedNodeId).toBeNull(); + expect(state.isDirty).toBe(false); + }); + }); +}); diff --git a/atomizer-dashboard/frontend/src/hooks/useSpecStore.ts b/atomizer-dashboard/frontend/src/hooks/useSpecStore.ts new file mode 100644 index 00000000..1d150db8 --- /dev/null +++ b/atomizer-dashboard/frontend/src/hooks/useSpecStore.ts @@ -0,0 +1,742 @@ +/** + * useSpecStore - Zustand store for AtomizerSpec v2.0 + * + * Central state management for the unified configuration system. + * All spec modifications flow through this store and sync with backend. + * + * Features: + * - Load spec from backend API + * - Optimistic updates with rollback on error + * - Patch operations via JSONPath + * - Node CRUD operations + * - Hash-based conflict detection + */ + +import { create } from 'zustand'; +import { devtools, subscribeWithSelector } from 'zustand/middleware'; +import { + AtomizerSpec, + DesignVariable, + Extractor, + Objective, + Constraint, + CanvasPosition, + SpecValidationReport, + SpecModification, +} from '../types/atomizer-spec'; + +// API base URL +const API_BASE = '/api'; + +// ============================================================================ +// Types +// ============================================================================ + +interface SpecStoreState { + // Spec data + spec: AtomizerSpec | null; + studyId: string | null; + hash: string | null; + + // Loading state + isLoading: boolean; + error: string | null; + + // Validation + validation: SpecValidationReport | null; + + // Selection state (for canvas) + selectedNodeId: string | null; + selectedEdgeId: string | null; + + // Dirty tracking + isDirty: boolean; + pendingChanges: SpecModification[]; +} + +interface SpecStoreActions { + // Loading + loadSpec: (studyId: string) => Promise; + reloadSpec: () => Promise; + clearSpec: () => void; + + // WebSocket integration - set spec directly without API call + setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => void; + + // Full spec operations + saveSpec: (spec: AtomizerSpec) => Promise; + replaceSpec: (spec: AtomizerSpec) => Promise; + + // Patch operations + patchSpec: (path: string, value: unknown) => Promise; + patchSpecOptimistic: (path: string, value: unknown) => void; + + // Node operations + addNode: ( + type: 'designVar' | 'extractor' | 'objective' | 'constraint', + data: Record + ) => Promise; + updateNode: (nodeId: string, updates: Record) => Promise; + removeNode: (nodeId: string) => Promise; + updateNodePosition: (nodeId: string, position: CanvasPosition) => Promise; + + // Edge operations + addEdge: (source: string, target: string) => Promise; + removeEdge: (source: string, target: string) => Promise; + + // Custom function + addCustomFunction: ( + name: string, + code: string, + outputs: string[], + description?: string + ) => Promise; + + // Validation + validateSpec: () => Promise; + + // Selection + selectNode: (nodeId: string | null) => void; + selectEdge: (edgeId: string | null) => void; + clearSelection: () => void; + + // Utility + getNodeById: (nodeId: string) => DesignVariable | Extractor | Objective | Constraint | null; + setError: (error: string | null) => void; +} + +type SpecStore = SpecStoreState & SpecStoreActions; + +// ============================================================================ +// API Functions +// ============================================================================ + +async function fetchSpec(studyId: string): Promise<{ spec: AtomizerSpec; hash: string }> { + const response = await fetch(`${API_BASE}/studies/${studyId}/spec`); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Failed to load spec' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + const spec = await response.json(); + + // Get hash + const hashResponse = await fetch(`${API_BASE}/studies/${studyId}/spec/hash`); + const { hash } = await hashResponse.json(); + + return { spec, hash }; +} + +async function patchSpecApi( + studyId: string, + path: string, + value: unknown +): Promise<{ hash: string; modified: string }> { + const response = await fetch(`${API_BASE}/studies/${studyId}/spec`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, value, modified_by: 'canvas' }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Patch failed' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + return response.json(); +} + +async function addNodeApi( + studyId: string, + type: string, + data: Record +): Promise<{ node_id: string }> { + const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type, data, modified_by: 'canvas' }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Add node failed' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + return response.json(); +} + +async function updateNodeApi( + studyId: string, + nodeId: string, + updates: Record +): Promise { + const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ updates, modified_by: 'canvas' }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Update node failed' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } +} + +async function deleteNodeApi(studyId: string, nodeId: string): Promise { + const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}?modified_by=canvas`, { + method: 'DELETE', + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Delete node failed' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } +} + +async function addEdgeApi(studyId: string, source: string, target: string): Promise { + const response = await fetch( + `${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`, + { method: 'POST' } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Add edge failed' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } +} + +async function removeEdgeApi(studyId: string, source: string, target: string): Promise { + const response = await fetch( + `${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`, + { method: 'DELETE' } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Remove edge failed' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } +} + +async function addCustomFunctionApi( + studyId: string, + name: string, + code: string, + outputs: string[], + description?: string +): Promise<{ node_id: string }> { + const response = await fetch(`${API_BASE}/studies/${studyId}/spec/custom-functions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, code, outputs, description, modified_by: 'claude' }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Add custom function failed' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + return response.json(); +} + +async function validateSpecApi(studyId: string): Promise { + const response = await fetch(`${API_BASE}/studies/${studyId}/spec/validate`, { + method: 'POST', + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Validation failed' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + return response.json(); +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function applyPatchLocally(spec: AtomizerSpec, path: string, value: unknown): AtomizerSpec { + // Deep clone spec + const newSpec = JSON.parse(JSON.stringify(spec)) as AtomizerSpec; + + // Parse path and apply value + const parts = path.split(/\.|\[|\]/).filter(Boolean); + + let current: Record = newSpec as unknown as Record; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + const index = parseInt(part, 10); + if (!isNaN(index)) { + current = (current as unknown as unknown[])[index] as Record; + } else { + current = current[part] as Record; + } + } + + const finalKey = parts[parts.length - 1]; + const index = parseInt(finalKey, 10); + if (!isNaN(index)) { + (current as unknown as unknown[])[index] = value; + } else { + current[finalKey] = value; + } + + return newSpec; +} + +function findNodeById( + spec: AtomizerSpec, + nodeId: string +): DesignVariable | Extractor | Objective | Constraint | null { + // Check design variables + const dv = spec.design_variables.find((d) => d.id === nodeId); + if (dv) return dv; + + // Check extractors + const ext = spec.extractors.find((e) => e.id === nodeId); + if (ext) return ext; + + // Check objectives + const obj = spec.objectives.find((o) => o.id === nodeId); + if (obj) return obj; + + // Check constraints + const con = spec.constraints?.find((c) => c.id === nodeId); + if (con) return con; + + return null; +} + +// ============================================================================ +// Store +// ============================================================================ + +export const useSpecStore = create()( + devtools( + subscribeWithSelector((set, get) => ({ + // Initial state + spec: null, + studyId: null, + hash: null, + isLoading: false, + error: null, + validation: null, + selectedNodeId: null, + selectedEdgeId: null, + isDirty: false, + pendingChanges: [], + + // ===================================================================== + // Loading Actions + // ===================================================================== + + loadSpec: async (studyId: string) => { + set({ isLoading: true, error: null, studyId }); + + try { + const { spec, hash } = await fetchSpec(studyId); + set({ + spec, + hash, + isLoading: false, + isDirty: false, + pendingChanges: [], + }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to load spec', + }); + } + }, + + reloadSpec: async () => { + const { studyId } = get(); + if (!studyId) return; + + set({ isLoading: true, error: null }); + + try { + const { spec, hash } = await fetchSpec(studyId); + set({ + spec, + hash, + isLoading: false, + isDirty: false, + pendingChanges: [], + }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Failed to reload spec', + }); + } + }, + + clearSpec: () => { + set({ + spec: null, + studyId: null, + hash: null, + isLoading: false, + error: null, + validation: null, + selectedNodeId: null, + selectedEdgeId: null, + isDirty: false, + pendingChanges: [], + }); + }, + + // Set spec directly from WebSocket (no API call) + setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => { + const currentStudyId = studyId || get().studyId; + console.log('[useSpecStore] Setting spec from WebSocket:', spec.meta?.study_name); + set({ + spec, + studyId: currentStudyId, + isLoading: false, + isDirty: false, + error: null, + }); + }, + + // ===================================================================== + // Full Spec Operations + // ===================================================================== + + saveSpec: async (spec: AtomizerSpec) => { + const { studyId, hash } = get(); + if (!studyId) throw new Error('No study loaded'); + + set({ isLoading: true, error: null }); + + try { + const response = await fetch( + `${API_BASE}/studies/${studyId}/spec?modified_by=canvas${hash ? `&expected_hash=${hash}` : ''}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(spec), + } + ); + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Save failed' })); + throw new Error(error.detail || `HTTP ${response.status}`); + } + + const result = await response.json(); + set({ + spec, + hash: result.hash, + isLoading: false, + isDirty: false, + pendingChanges: [], + }); + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Save failed', + }); + throw error; + } + }, + + replaceSpec: async (spec: AtomizerSpec) => { + await get().saveSpec(spec); + }, + + // ===================================================================== + // Patch Operations + // ===================================================================== + + patchSpec: async (path: string, value: unknown) => { + const { studyId, spec } = get(); + if (!studyId || !spec) throw new Error('No study loaded'); + + // Optimistic update + const oldSpec = spec; + const newSpec = applyPatchLocally(spec, path, value); + set({ spec: newSpec, isDirty: true }); + + try { + const result = await patchSpecApi(studyId, path, value); + set({ hash: result.hash, isDirty: false }); + } catch (error) { + // Rollback on error + set({ spec: oldSpec, isDirty: false }); + const message = error instanceof Error ? error.message : 'Patch failed'; + set({ error: message }); + throw error; + } + }, + + patchSpecOptimistic: (path: string, value: unknown) => { + const { spec, studyId } = get(); + if (!spec) return; + + // Apply locally immediately + const newSpec = applyPatchLocally(spec, path, value); + set({ + spec: newSpec, + isDirty: true, + pendingChanges: [...get().pendingChanges, { operation: 'set', path, value }], + }); + + // Sync with backend (fire and forget, but handle errors) + if (studyId) { + patchSpecApi(studyId, path, value) + .then((result) => { + set({ hash: result.hash }); + // Remove from pending + set({ + pendingChanges: get().pendingChanges.filter( + (c) => !(c.path === path && c.value === value) + ), + }); + }) + .catch((error) => { + console.error('Patch sync failed:', error); + set({ error: error.message }); + }); + } + }, + + // ===================================================================== + // Node Operations + // ===================================================================== + + addNode: async (type, data) => { + const { studyId } = get(); + if (!studyId) throw new Error('No study loaded'); + + set({ isLoading: true, error: null }); + + try { + const result = await addNodeApi(studyId, type, data); + + // Reload spec to get new state + await get().reloadSpec(); + + return result.node_id; + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Add node failed', + }); + throw error; + } + }, + + updateNode: async (nodeId, updates) => { + const { studyId } = get(); + if (!studyId) throw new Error('No study loaded'); + + try { + await updateNodeApi(studyId, nodeId, updates); + await get().reloadSpec(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Update failed'; + set({ error: message }); + throw error; + } + }, + + removeNode: async (nodeId) => { + const { studyId } = get(); + if (!studyId) throw new Error('No study loaded'); + + set({ isLoading: true, error: null }); + + try { + await deleteNodeApi(studyId, nodeId); + await get().reloadSpec(); + + // Clear selection if deleted node was selected + if (get().selectedNodeId === nodeId) { + set({ selectedNodeId: null }); + } + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Delete failed', + }); + throw error; + } + }, + + updateNodePosition: async (nodeId, position) => { + const { studyId, spec } = get(); + if (!studyId || !spec) return; + + // Find the node type and index + let path: string | null = null; + + const dvIndex = spec.design_variables.findIndex((d) => d.id === nodeId); + if (dvIndex >= 0) { + path = `design_variables[${dvIndex}].canvas_position`; + } + + if (!path) { + const extIndex = spec.extractors.findIndex((e) => e.id === nodeId); + if (extIndex >= 0) { + path = `extractors[${extIndex}].canvas_position`; + } + } + + if (!path) { + const objIndex = spec.objectives.findIndex((o) => o.id === nodeId); + if (objIndex >= 0) { + path = `objectives[${objIndex}].canvas_position`; + } + } + + if (!path && spec.constraints) { + const conIndex = spec.constraints.findIndex((c) => c.id === nodeId); + if (conIndex >= 0) { + path = `constraints[${conIndex}].canvas_position`; + } + } + + if (path) { + // Use optimistic update for smooth dragging + get().patchSpecOptimistic(path, position); + } + }, + + // ===================================================================== + // Edge Operations + // ===================================================================== + + addEdge: async (source, target) => { + const { studyId } = get(); + if (!studyId) throw new Error('No study loaded'); + + try { + await addEdgeApi(studyId, source, target); + await get().reloadSpec(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Add edge failed'; + set({ error: message }); + throw error; + } + }, + + removeEdge: async (source, target) => { + const { studyId } = get(); + if (!studyId) throw new Error('No study loaded'); + + try { + await removeEdgeApi(studyId, source, target); + await get().reloadSpec(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Remove edge failed'; + set({ error: message }); + throw error; + } + }, + + // ===================================================================== + // Custom Function + // ===================================================================== + + addCustomFunction: async (name, code, outputs, description) => { + const { studyId } = get(); + if (!studyId) throw new Error('No study loaded'); + + set({ isLoading: true, error: null }); + + try { + const result = await addCustomFunctionApi(studyId, name, code, outputs, description); + await get().reloadSpec(); + return result.node_id; + } catch (error) { + set({ + isLoading: false, + error: error instanceof Error ? error.message : 'Add custom function failed', + }); + throw error; + } + }, + + // ===================================================================== + // Validation + // ===================================================================== + + validateSpec: async () => { + const { studyId } = get(); + if (!studyId) throw new Error('No study loaded'); + + try { + const validation = await validateSpecApi(studyId); + set({ validation }); + return validation; + } catch (error) { + const message = error instanceof Error ? error.message : 'Validation failed'; + set({ error: message }); + throw error; + } + }, + + // ===================================================================== + // Selection + // ===================================================================== + + selectNode: (nodeId) => { + set({ selectedNodeId: nodeId, selectedEdgeId: null }); + }, + + selectEdge: (edgeId) => { + set({ selectedEdgeId: edgeId, selectedNodeId: null }); + }, + + clearSelection: () => { + set({ selectedNodeId: null, selectedEdgeId: null }); + }, + + // ===================================================================== + // Utility + // ===================================================================== + + getNodeById: (nodeId) => { + const { spec } = get(); + if (!spec) return null; + return findNodeById(spec, nodeId); + }, + + setError: (error) => { + set({ error }); + }, + })), + { name: 'spec-store' } + ) +); + +// ============================================================================ +// Selector Hooks +// ============================================================================ + +export const useSpec = () => useSpecStore((state) => state.spec); +export const useSpecLoading = () => useSpecStore((state) => state.isLoading); +export const useSpecError = () => useSpecStore((state) => state.error); +export const useSpecValidation = () => useSpecStore((state) => state.validation); +export const useSelectedNodeId = () => useSpecStore((state) => state.selectedNodeId); +export const useSelectedEdgeId = () => useSpecStore((state) => state.selectedEdgeId); +export const useSpecHash = () => useSpecStore((state) => state.hash); +export const useSpecIsDirty = () => useSpecStore((state) => state.isDirty); + +// Computed selectors +export const useDesignVariables = () => + useSpecStore((state) => state.spec?.design_variables ?? []); +export const useExtractors = () => useSpecStore((state) => state.spec?.extractors ?? []); +export const useObjectives = () => useSpecStore((state) => state.spec?.objectives ?? []); +export const useConstraints = () => useSpecStore((state) => state.spec?.constraints ?? []); +export const useCanvasEdges = () => useSpecStore((state) => state.spec?.canvas?.edges ?? []); + +export const useSelectedNode = () => + useSpecStore((state) => { + if (!state.spec || !state.selectedNodeId) return null; + return findNodeById(state.spec, state.selectedNodeId); + }); diff --git a/atomizer-dashboard/frontend/src/pages/CanvasView.tsx b/atomizer-dashboard/frontend/src/pages/CanvasView.tsx index 289f099f..a10d1e5e 100644 --- a/atomizer-dashboard/frontend/src/pages/CanvasView.tsx +++ b/atomizer-dashboard/frontend/src/pages/CanvasView.tsx @@ -1,42 +1,236 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight } from 'lucide-react'; +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 } 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 { 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(); - const { nodes, edges, clear } = useCanvasStore(); - const { selectedStudy } = useStudy(); + // 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(); + + // 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 */} @@ -55,24 +249,75 @@ export function CanvasView() {
Canvas Builder - {selectedStudy && ( + {activeStudyId && ( <> - {selectedStudy.name || selectedStudy.id} + {activeStudyId} + {hasUnsavedChanges && ( + + )} )}
{/* Stats */} - - {nodes.length} node{nodes.length !== 1 ? 's' : ''} • {edges.length} edge{edges.length !== 1 ? 's' : ''} - + {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 - only show when there's a study and changes */} + {activeStudyId && ( + + )} + + {/* Reload Button */} + {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 */} + {selectedNodeId && !showChat && ( + useSpecMode ? ( + useSpecStore.getState().clearSelection()} /> + ) : ( +
+ +
+ ) + )} + + {/* Chat/Assistant Panel */} + {showChat && ( +
+ {/* Chat Header */} +
+
+ + Assistant + {isConnected && ( + + )} +
+
+ {/* Power Mode Toggle */} + + +
+
+ {/* Chat Content */} + +
+ )}
{/* Template Selector Modal */} diff --git a/atomizer-dashboard/frontend/src/test/setup.ts b/atomizer-dashboard/frontend/src/test/setup.ts new file mode 100644 index 00000000..ccd72fcf --- /dev/null +++ b/atomizer-dashboard/frontend/src/test/setup.ts @@ -0,0 +1,137 @@ +/** + * Vitest Test Setup + * + * This file runs before each test file to set up the testing environment. + */ + +/// + +import '@testing-library/jest-dom'; +import { vi, beforeAll, afterAll, afterEach } from 'vitest'; + +// Type for global context +declare const global: typeof globalThis; + +// ============================================================================ +// Mock Browser APIs +// ============================================================================ + +// Mock ResizeObserver (used by ReactFlow) +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +// Mock IntersectionObserver +global.IntersectionObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})); + +// Mock matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +// Mock scrollTo +Element.prototype.scrollTo = vi.fn(); +window.scrollTo = vi.fn(); + +// Mock fetch for API calls +global.fetch = vi.fn(); + +// ============================================================================ +// Mock localStorage +// ============================================================================ + +const localStorageMock = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: vi.fn(), +}; +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); + +// ============================================================================ +// Mock WebSocket +// ============================================================================ + +class MockWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSING = 2; + readonly CLOSED = 3; + + url: string; + readyState: number = MockWebSocket.CONNECTING; + onopen: ((event: Event) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(url: string) { + this.url = url; + // Simulate connection after a tick + setTimeout(() => { + this.readyState = MockWebSocket.OPEN; + this.onopen?.(new Event('open')); + }, 0); + } + + send = vi.fn(); + close = vi.fn(() => { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.(new CloseEvent('close')); + }); +} + +global.WebSocket = MockWebSocket as any; + +// ============================================================================ +// Console Suppression (optional) +// ============================================================================ + +// Suppress console.error for expected test warnings +const originalError = console.error; +beforeAll(() => { + console.error = (...args: any[]) => { + // Suppress React act() warnings + if (typeof args[0] === 'string' && args[0].includes('Warning: An update to')) { + return; + } + originalError.call(console, ...args); + }; +}); + +afterAll(() => { + console.error = originalError; +}); + +// ============================================================================ +// Cleanup +// ============================================================================ + +afterEach(() => { + vi.clearAllMocks(); + localStorageMock.getItem.mockReset(); + localStorageMock.setItem.mockReset(); +}); diff --git a/atomizer-dashboard/frontend/src/test/utils.tsx b/atomizer-dashboard/frontend/src/test/utils.tsx new file mode 100644 index 00000000..3092a5b1 --- /dev/null +++ b/atomizer-dashboard/frontend/src/test/utils.tsx @@ -0,0 +1,142 @@ +/** + * Test Utilities + * + * Provides custom render function with all necessary providers. + */ + +/// + +import { ReactElement, ReactNode } from 'react'; +import { render, RenderOptions } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import { StudyProvider } from '../context/StudyContext'; + +// Type for global context +declare const global: typeof globalThis; + +/** + * All providers needed for testing components + */ +function AllProviders({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} + +/** + * Custom render function that wraps component with all providers + */ +const customRender = ( + ui: ReactElement, + options?: Omit +) => render(ui, { wrapper: AllProviders, ...options }); + +// Re-export everything from RTL +export * from '@testing-library/react'; +export { userEvent } from '@testing-library/user-event'; + +// Override render with our custom one +export { customRender as render }; + +/** + * Create a mock AtomizerSpec for testing + */ +export function createMockSpec(overrides: Partial = {}): any { + return { + meta: { + version: '2.0', + study_name: 'test_study', + created_by: 'test', + created_at: new Date().toISOString(), + ...overrides.meta, + }, + model: { + sim: { + path: 'model.sim', + solver: 'nastran', + solution_type: 'SOL101', + }, + ...overrides.model, + }, + design_variables: overrides.design_variables ?? [ + { + id: 'dv_001', + name: 'thickness', + expression_name: 'wall_thickness', + type: 'continuous', + bounds: { min: 1, max: 10 }, + baseline: 5, + enabled: true, + }, + ], + extractors: overrides.extractors ?? [ + { + id: 'ext_001', + name: 'displacement', + type: 'displacement', + outputs: ['max_disp'], + enabled: true, + }, + ], + objectives: overrides.objectives ?? [ + { + id: 'obj_001', + name: 'minimize_mass', + type: 'minimize', + source: { extractor_id: 'ext_001', output: 'max_disp' }, + weight: 1.0, + enabled: true, + }, + ], + constraints: overrides.constraints ?? [], + optimization: { + algorithm: { type: 'TPE' }, + budget: { max_trials: 100 }, + ...overrides.optimization, + }, + canvas: { + edges: [], + layout_version: '2.0', + ...overrides.canvas, + }, + }; +} + +/** + * Create a mock API response + */ +export function mockFetch(responses: Record) { + return (global.fetch as any).mockImplementation((url: string, options?: RequestInit) => { + const method = options?.method || 'GET'; + const key = `${method} ${url}`; + + // Find matching response + for (const [pattern, response] of Object.entries(responses)) { + if (key.includes(pattern) || url.includes(pattern)) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(response), + text: () => Promise.resolve(JSON.stringify(response)), + }); + } + } + + // Default 404 + return Promise.resolve({ + ok: false, + status: 404, + json: () => Promise.resolve({ detail: 'Not found' }), + }); + }); +} + +/** + * Wait for async state updates + */ +export async function waitForStateUpdate() { + await new Promise(resolve => setTimeout(resolve, 0)); +} diff --git a/atomizer-dashboard/frontend/vitest.config.ts b/atomizer-dashboard/frontend/vitest.config.ts new file mode 100644 index 00000000..5e2a1a44 --- /dev/null +++ b/atomizer-dashboard/frontend/vitest.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.{test,spec}.{ts,tsx}'], + exclude: ['node_modules', 'dist', 'tests/e2e'], + coverage: { + provider: 'v8', + reporter: ['text', 'html', 'lcov'], + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/test/**', + 'src/**/*.d.ts', + 'src/vite-env.d.ts', + 'src/main.tsx', + ], + }, + // Mock CSS imports + css: false, + }, + resolve: { + alias: { + '@': '/src', + }, + }, +});