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
This commit is contained in:
2026-01-20 11:53:26 -05:00
parent ea437d360e
commit c4a3cff91a
16 changed files with 4067 additions and 239 deletions

View File

@@ -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,
}

View File

@@ -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,
},
});

View File

@@ -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 <AtomizerCanvas studyId="..." /> with <SpecRenderer studyId="..." />
* - 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<HTMLDivElement>(null);
const reactFlowInstance = useRef<ReactFlowInstance | null>(null);
const [showExecuteDialog, setShowExecuteDialog] = useState(false);
const [showChat, setShowChat] = useState(false);
const [studyId, setStudyId] = useState<string | null>(initialStudyId || null);
const [studyPath, setStudyPath] = useState<string | null>(initialStudyPath || null);
const [isExecuting, setIsExecuting] = useState(false);
const {
nodes,
@@ -41,32 +62,38 @@ function CanvasFlow() {
validation,
validate,
toIntent,
loadFromConfig,
} = useCanvasStore();
const [chatError, setChatError] = useState<string | null>(null);
const [isLoadingStudy, setIsLoadingStudy] = useState(false);
const [loadError, setLoadError] = useState<string | null>(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 */}
<div className="flex-1 relative" ref={reactFlowWrapper}>
{/* Study Context Bar */}
<div className="absolute top-4 left-4 right-4 z-10 flex items-center gap-2">
<input
type="text"
value={studyId || ''}
onChange={(e) => 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"
/>
<button
onClick={handleLoadStudy}
disabled={!studyId || isLoadingStudy}
className="px-4 py-2 bg-primary-600 text-white rounded-lg text-sm hover:bg-primary-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isLoadingStudy ? 'Loading...' : 'Load Study'}
</button>
{studyPath && (
<span className="text-xs text-dark-400 truncate max-w-xs" title={studyPath}>
{studyPath.split(/[/\\]/).slice(-2).join('/')}
</span>
)}
</div>
{/* Error Banner */}
{loadError && (
<div className="absolute top-16 left-4 right-4 z-10 px-4 py-2 bg-red-900/90 backdrop-blur border border-red-700 text-red-200 rounded-lg text-sm flex justify-between items-center">
<span>{loadError}</span>
<button onClick={() => setLoadError(null)} className="text-red-400 hover:text-red-200">×</button>
</div>
)}
<ReactFlow
nodes={nodes}
edges={edges.map(e => ({
@@ -203,44 +275,22 @@ function CanvasFlow() {
{/* Action Buttons */}
<div className="absolute bottom-4 right-4 flex gap-2 z-10">
<button
onClick={() => setShowChat(!showChat)}
className={`px-3 py-2 rounded-lg transition-colors ${
showChat
? 'bg-primary-600/20 text-primary-400 border border-primary-500/50'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700 border border-dark-600'
}`}
title="Toggle Chat"
>
{isConnected ? <MessageCircle size={18} /> : <Plug size={18} />}
</button>
<button
onClick={handleValidate}
className="px-4 py-2 bg-dark-700 text-white rounded-lg hover:bg-dark-600 border border-dark-600 transition-colors"
>
Validate
</button>
<button
onClick={handleAnalyze}
disabled={!validation.valid}
className={`px-4 py-2 rounded-lg transition-colors border ${
validation.valid
? 'bg-purple-600 text-white hover:bg-purple-500 border-purple-500'
: 'bg-dark-800 text-dark-500 cursor-not-allowed border-dark-700'
}`}
>
Analyze
</button>
<button
onClick={handleExecuteClick}
disabled={!validation.valid}
disabled={!validation.valid || isExecuting}
className={`px-4 py-2 rounded-lg transition-colors border ${
validation.valid
validation.valid && !isExecuting
? 'bg-primary-600 text-white hover:bg-primary-500 border-primary-500'
: 'bg-dark-800 text-dark-500 cursor-not-allowed border-dark-700'
}`}
>
Execute with Claude
{isExecuting ? 'Creating...' : 'Create Study'}
</button>
</div>
@@ -250,43 +300,8 @@ function CanvasFlow() {
)}
</div>
{/* Right: Config Panel or Chat */}
{showChat ? (
<div className="w-96 border-l border-dark-700 flex flex-col bg-dark-850">
<div className="p-3 border-b border-dark-700 flex justify-between items-center">
<h3 className="font-semibold text-white">Claude Assistant</h3>
<button
onClick={() => setShowChat(false)}
className="text-dark-400 hover:text-white transition-colors"
>
<X size={18} />
</button>
</div>
{chatError ? (
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
<AlertCircle size={32} className="text-red-400 mb-3" />
<p className="text-white font-medium mb-1">Connection Error</p>
<p className="text-sm text-dark-400 mb-4">{chatError}</p>
<button
onClick={handleReconnect}
className="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-500 text-white rounded-lg transition-colors"
>
<RefreshCw size={16} />
Reconnect
</button>
</div>
) : (
<ChatPanel
messages={messages}
isThinking={isThinking || isExecuting}
onSendMessage={sendMessage}
isConnected={isConnected}
/>
)}
</div>
) : selectedNode ? (
<NodeConfigPanel nodeId={selectedNode} />
) : null}
{/* Right: Config Panel */}
{selectedNode && <NodeConfigPanel nodeId={selectedNode} />}
{/* Execute Dialog */}
<ExecuteDialog
@@ -299,10 +314,20 @@ function CanvasFlow() {
);
}
export function AtomizerCanvas() {
interface AtomizerCanvasProps {
studyId?: string;
studyPath?: string;
onStudyChange?: (studyId: string) => void;
}
export function AtomizerCanvas({ studyId, studyPath, onStudyChange }: AtomizerCanvasProps = {}) {
return (
<ReactFlowProvider>
<CanvasFlow />
<CanvasFlow
initialStudyId={studyId}
initialStudyPath={studyPath}
onStudyChange={onStudyChange}
/>
</ReactFlowProvider>
);
}

View File

@@ -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<string, unknown> {
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<HTMLDivElement>(null);
const reactFlowInstance = useRef<ReactFlowInstance | null>(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<Node<CanvasNodeData>[]>(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 (
<div className="flex-1 flex items-center justify-center bg-dark-900">
<div className="text-center">
<div className="animate-spin w-8 h-8 border-2 border-primary-500 border-t-transparent rounded-full mx-auto mb-4" />
<p className="text-dark-400">Loading spec...</p>
</div>
</div>
);
}
// Error state
if (error && !spec) {
return (
<div className="flex-1 flex items-center justify-center bg-dark-900">
<div className="text-center max-w-md">
<div className="w-12 h-12 rounded-full bg-red-900/50 flex items-center justify-center mx-auto mb-4">
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className="text-lg font-medium text-white mb-2">Failed to load spec</h3>
<p className="text-dark-400 mb-4">{error}</p>
{studyId && (
<button
onClick={() => loadSpec(studyId)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-500 transition-colors"
>
Retry
</button>
)}
</div>
</div>
);
}
// Empty state
if (!spec) {
return (
<div className="flex-1 flex items-center justify-center bg-dark-900">
<div className="text-center">
<p className="text-dark-400">No spec loaded</p>
<p className="text-dark-500 text-sm mt-2">Load a study to see its optimization configuration</p>
</div>
</div>
);
}
return (
<div className="w-full h-full relative" ref={reactFlowWrapper}>
{/* Status indicators (overlay) */}
<div className="absolute top-4 right-4 z-20 flex items-center gap-2">
{/* WebSocket connection status */}
{enableWebSocket && showConnectionStatus && (
<div className="px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<ConnectionStatusIndicator status={wsStatus} />
</div>
)}
{/* Loading indicator */}
{isLoading && (
<div className="flex items-center gap-2 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<div className="animate-spin w-4 h-4 border-2 border-primary-500 border-t-transparent rounded-full" />
<span className="text-xs text-dark-300">Syncing...</span>
</div>
)}
</div>
{/* Error banner (overlay) */}
{error && (
<div className="absolute top-4 left-4 right-20 z-20 px-4 py-2 bg-red-900/90 backdrop-blur border border-red-700 text-red-200 rounded-lg text-sm flex justify-between items-center">
<span>{error}</span>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-200 ml-2"
>
×
</button>
</div>
)}
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onInit={(instance) => {
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"
>
<Background color="#374151" gap={20} />
<Controls className="!bg-dark-800 !border-dark-600 !rounded-lg [&>button]:!bg-dark-700 [&>button]:!border-dark-600 [&>button]:!fill-dark-300 [&>button:hover]:!bg-dark-600" />
<MiniMap
className="!bg-dark-800 !border-dark-600 !rounded-lg"
nodeColor="#4B5563"
maskColor="rgba(0, 0, 0, 0.5)"
/>
</ReactFlow>
{/* Study name badge */}
<div className="absolute bottom-4 left-4 z-10 px-3 py-1.5 bg-dark-800/90 backdrop-blur rounded-lg border border-dark-600">
<span className="text-sm text-dark-300">{spec.meta.study_name}</span>
</div>
</div>
);
}
/**
* SpecRenderer with ReactFlowProvider wrapper.
*
* Usage:
* ```tsx
* // Load spec on mount
* <SpecRenderer studyId="M1_Mirror/m1_mirror_flatback" />
*
* // Use with already-loaded spec
* const { loadSpec } = useSpecStore();
* await loadSpec('M1_Mirror/m1_mirror_flatback');
* <SpecRenderer />
* ```
*/
export function SpecRenderer(props: SpecRendererProps) {
return (
<ReactFlowProvider>
<SpecRendererInner {...props} />
</ReactFlowProvider>
);
}
export default SpecRenderer;

View File

@@ -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<ModelNodeData>) {
const { data, selected } = props;
const [isExpanded, setIsExpanded] = useState(false);
const [dependencies, setDependencies] = useState<DependentFile[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [introspection, setIntrospection] = useState<IntrospectionResult | null>(null);
const [error, setError] = useState<string | null>(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 <Box size={12} className="text-blue-400" />;
case 'fem':
return <FileCode size={12} className="text-emerald-400" />;
case 'sim':
return <Cpu size={12} className="text-violet-400" />;
case 'idealized':
return <Box size={12} className="text-cyan-400" />;
default:
return <FileBox size={12} className="text-dark-400" />;
}
};
return (
<div
className={`
relative rounded-xl border min-w-[200px] max-w-[280px]
bg-dark-800 shadow-xl transition-all duration-150 overflow-hidden
${selected ? 'border-primary-400 ring-2 ring-primary-400/30 shadow-primary-500/20' : 'border-dark-600'}
${!data.configured ? 'border-dashed border-dark-500' : ''}
`}
>
{/* Input handle */}
<Handle
type="target"
position={Position.Left}
className="!w-3 !h-3 !bg-dark-400 !border-2 !border-dark-600 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
/>
{/* Main content */}
<div className="px-4 py-3">
<div className="flex items-center gap-2.5">
<div className="text-blue-400 flex-shrink-0">
<Box size={16} />
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-white text-sm truncate">
{data.label || 'Model'}
</div>
</div>
{!data.configured && (
<div className="w-2 h-2 rounded-full bg-amber-400 flex-shrink-0 animate-pulse" />
)}
</div>
{/* File info */}
<div className="mt-2 text-xs text-dark-300 truncate" title={data.filePath}>
{fileName}
</div>
{/* Solver badge */}
{introspection?.solver_type && (
<div className="mt-2 inline-flex items-center gap-1 px-2 py-0.5 rounded bg-violet-500/20 text-violet-400 text-xs">
<Cpu size={10} />
{introspection.solver_type}
</div>
)}
</div>
{/* Dependencies section (collapsible) */}
{data.filePath && (
<div className="border-t border-dark-700">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-2 flex items-center gap-2 text-xs text-dark-400 hover:text-white hover:bg-dark-700/50 transition-colors"
>
{isExpanded ? (
<ChevronDown size={12} />
) : (
<ChevronRight size={12} />
)}
<span>Dependencies</span>
{dependencies.length > 0 && (
<span className="ml-auto text-dark-500">{dependencies.length}</span>
)}
{isLoading && (
<RefreshCw size={12} className="ml-auto animate-spin text-primary-400" />
)}
</button>
{isExpanded && (
<div className="px-3 pb-3 space-y-1">
{error ? (
<div className="flex items-center gap-1 text-xs text-red-400">
<AlertCircle size={12} />
{error}
</div>
) : dependencies.length === 0 && !isLoading ? (
<div className="text-xs text-dark-500 py-1">
No dependencies found
</div>
) : (
dependencies.map((dep) => (
<div
key={dep.path}
className="flex items-center gap-2 px-2 py-1 rounded bg-dark-900/50 text-xs"
title={dep.path}
>
{getFileIcon(dep.type)}
<span className="flex-1 truncate text-dark-300">{dep.name}</span>
{dep.exists ? (
<CheckCircle size={10} className="text-emerald-400 flex-shrink-0" />
) : (
<AlertCircle size={10} className="text-amber-400 flex-shrink-0" />
)}
</div>
))
)}
{/* Expressions count */}
{introspection?.expressions && introspection.expressions.length > 0 && (
<div className="mt-2 pt-2 border-t border-dark-700">
<div className="text-xs text-dark-400">
{introspection.expressions.length} expressions found
</div>
</div>
)}
</div>
)}
</div>
)}
{/* Output handle */}
<Handle
type="source"
position={Position.Right}
className="!w-3 !h-3 !bg-dark-400 !border-2 !border-dark-600 hover:!bg-primary-400 hover:!border-primary-500 transition-colors"
/>
</div>
);
}
export const ModelNodeV2 = memo(ModelNodeV2Component);

View File

@@ -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,

View File

@@ -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: <Box size={18} />, description: 'NX model file (.prt, .sim)', color: 'text-blue-400' },
{ type: 'solver', label: 'Solver', icon: <Cpu size={18} />, description: 'Nastran solution type', color: 'text-violet-400' },
{ type: 'designVar', label: 'Design Variable', icon: <SlidersHorizontal size={18} />, description: 'Parameter to optimize', color: 'text-emerald-400' },
{ type: 'extractor', label: 'Extractor', icon: <FlaskConical size={18} />, description: 'Physics result extraction', color: 'text-cyan-400' },
{ type: 'objective', label: 'Objective', icon: <Target size={18} />, description: 'Optimization goal', color: 'text-rose-400' },
{ type: 'constraint', label: 'Constraint', icon: <ShieldAlert size={18} />, description: 'Design constraint', color: 'text-amber-400' },
{ type: 'algorithm', label: 'Algorithm', icon: <BrainCircuit size={18} />, description: 'Optimization method', color: 'text-indigo-400' },
{ type: 'surrogate', label: 'Surrogate', icon: <Rocket size={18} />, 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 (
<div className="w-60 bg-dark-850 border-r border-dark-700 flex flex-col">
<div className="p-4 border-b border-dark-700">
<h3 className="text-sm font-semibold text-dark-300 uppercase tracking-wider">
Components
</h3>
<p className="text-xs text-dark-400 mt-1">
Drag to canvas
</p>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{PALETTE_ITEMS.map((item) => (
<div
key={item.type}
draggable
onDragStart={(e) => 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 (
<div className={`w-14 bg-dark-850 border-r border-dark-700 flex flex-col ${className}`}>
{/* Toggle Button */}
{showToggle && onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="p-4 border-b border-dark-700 hover:bg-dark-800 transition-colors flex items-center justify-center"
title="Expand palette"
>
<div className={`${item.color} opacity-90 group-hover:opacity-100 transition-opacity`}>
{item.icon}
<ChevronRight size={18} className="text-dark-400" />
</button>
)}
{/* Collapsed Items */}
<div className="flex-1 overflow-y-auto py-2">
{items.map((item) => {
const Icon = item.icon;
const isDraggable = item.canAdd;
return (
<div
key={item.type}
draggable={isDraggable}
onDragStart={(e) => 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)' : ''}`}
>
<Icon size={18} className={item.color} />
</div>
);
})}
</div>
</div>
);
}
// Expanded mode - full display
return (
<div className={`w-60 bg-dark-850 border-r border-dark-700 flex flex-col ${className}`}>
{/* Header */}
<div className="p-4 border-b border-dark-700 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-dark-300 uppercase tracking-wider">
Components
</h3>
<p className="text-xs text-dark-400 mt-1">
Drag to canvas
</p>
</div>
{showToggle && onToggleCollapse && (
<button
onClick={onToggleCollapse}
className="p-1.5 rounded hover:bg-dark-800 transition-colors"
title="Collapse palette"
>
<ChevronLeft size={16} className="text-dark-400" />
</button>
)}
</div>
{/* Items */}
<div className="flex-1 overflow-y-auto p-3 space-y-2">
{items.map((item) => {
const Icon = item.icon;
const isDraggable = item.canAdd;
return (
<div
key={item.type}
draggable={isDraggable}
onDragStart={(e) => 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}
>
<div className={`${item.color} ${isDraggable ? 'opacity-90 group-hover:opacity-100' : 'opacity-50'} transition-opacity`}>
<Icon size={18} />
</div>
<div className="flex-1 min-w-0">
<div className={`font-semibold text-sm leading-tight ${isDraggable ? 'text-white' : 'text-dark-400'}`}>
{item.label}
</div>
<div className="text-xs text-dark-400 truncate">
{isDraggable ? item.description : 'Auto-created'}
</div>
</div>
</div>
<div className="flex-1 min-w-0">
<div className="font-semibold text-white text-sm leading-tight">{item.label}</div>
<div className="text-xs text-dark-400 truncate">{item.description}</div>
</div>
</div>
))}
);
})}
</div>
</div>
);
}
export default NodePalette;

View File

@@ -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<string, { icon: typeof FileBox; color: string }> = {
'.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<FileNode[]>([]);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string>();
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 (
<div key={node.path}>
<button
onClick={() => handleFileClick(node)}
className={`
w-full flex items-center gap-2 px-2 py-1.5 text-left text-sm rounded-md
transition-colors group
${isSelected ? 'bg-primary-500/20 text-primary-400' : 'hover:bg-dark-700/50'}
${node.isModelFile ? 'cursor-pointer' : isDirectory ? 'cursor-pointer' : 'cursor-default opacity-60'}
`}
style={{ paddingLeft: `${depth * 16 + 8}px` }}
>
{/* Expand/collapse chevron for directories */}
{isDirectory ? (
<ChevronRight
size={14}
className={`text-dark-500 transition-transform flex-shrink-0 ${
isExpanded ? 'rotate-90' : ''
}`}
/>
) : (
<span className="w-3.5 flex-shrink-0" />
)}
{/* Icon */}
<Icon size={16} className={`${iconColor} flex-shrink-0`} />
{/* Name */}
<span
className={`flex-1 truncate ${
isSelected ? 'text-primary-400 font-medium' : 'text-dark-200'
}`}
>
{node.name}
</span>
{/* Model file indicator */}
{node.isModelFile && !isSelected && (
<span title="Set as model">
<Plus
size={14}
className="text-dark-500 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
/>
</span>
)}
{/* Selected indicator */}
{isSelected && (
<CheckCircle size={14} className="text-primary-400 flex-shrink-0" />
)}
</button>
{/* Children */}
{isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child) => renderNode(child, depth + 1))}
</div>
)}
</div>
);
};
// No study selected state
if (!studyId) {
return (
<div className={`p-4 ${className}`}>
<div className="text-center text-dark-400 text-sm">
<Folder size={32} className="mx-auto mb-2 text-dark-500" />
<p>No study selected</p>
<p className="text-xs text-dark-500 mt-1">
Load a study to see its files
</p>
</div>
</div>
);
}
return (
<div className={`flex flex-col h-full ${className}`}>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-dark-700">
<div className="flex items-center gap-2">
<Folder size={16} className="text-amber-400" />
<span className="text-sm font-medium text-white">Files</span>
</div>
<button
onClick={loadFileStructure}
disabled={isLoading}
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
title="Refresh"
>
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-2">
{isLoading && files.length === 0 ? (
<div className="flex items-center justify-center h-20 text-dark-400 text-sm">
<RefreshCw size={16} className="animate-spin mr-2" />
Loading...
</div>
) : error ? (
<div className="flex items-center justify-center h-20 text-red-400 text-sm gap-2">
<AlertCircle size={16} />
{error}
</div>
) : files.length === 0 ? (
<div className="text-center text-dark-400 text-sm py-4">
<p>No files found</p>
<p className="text-xs text-dark-500 mt-1">
Add model files to 1_setup/
</p>
</div>
) : (
<div className="space-y-0.5">
{files.map((node) => renderNode(node))}
</div>
)}
</div>
{/* Footer hint */}
<div className="px-3 py-2 border-t border-dark-700 text-xs text-dark-500">
Click a model file to select it
</div>
</div>
);
}
export default FileStructurePanel;

View File

@@ -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<string | null>(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 (
<div className="w-80 bg-dark-850 border-l border-dark-700 flex flex-col h-full">
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-dark-700">
<h3 className="font-semibold text-white truncate flex-1">
Configure {nodeLabel}
</h3>
<div className="flex items-center gap-2">
{!isSyntheticNode && (
<button
onClick={handleDelete}
disabled={isUpdating}
className="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded transition-colors"
title="Delete node"
>
<Trash2 size={16} />
</button>
)}
{onClose && (
<button
onClick={onClose}
className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded transition-colors"
title="Close panel"
>
<X size={16} />
</button>
)}
</div>
</div>
{/* Error display */}
{error && (
<div className="mx-4 mt-4 p-3 bg-red-500/10 border border-red-500/30 rounded-lg flex items-start gap-2">
<AlertCircle size={16} className="text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-400 flex-1">{error}</p>
<button onClick={() => setError(null)} className="text-red-400 hover:text-red-300">
<X size={14} />
</button>
</div>
)}
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{/* Loading indicator */}
{isUpdating && (
<div className="text-xs text-primary-400 animate-pulse">Updating...</div>
)}
{/* Model node (synthetic) */}
{nodeType === 'model' && spec.model && (
<ModelNodeConfig spec={spec} />
)}
{/* Solver node (synthetic) */}
{nodeType === 'solver' && (
<SolverNodeConfig spec={spec} />
)}
{/* Algorithm node (synthetic) */}
{nodeType === 'algorithm' && (
<AlgorithmNodeConfig spec={spec} />
)}
{/* Surrogate node (synthetic) */}
{nodeType === 'surrogate' && (
<SurrogateNodeConfig spec={spec} />
)}
{/* Design Variable */}
{nodeType === 'designVar' && selectedNode && (
<DesignVarNodeConfig
node={selectedNode as DesignVariable}
onChange={handleChange}
/>
)}
{/* Extractor */}
{nodeType === 'extractor' && selectedNode && (
<ExtractorNodeConfig
node={selectedNode as Extractor}
onChange={handleChange}
/>
)}
{/* Objective */}
{nodeType === 'objective' && selectedNode && (
<ObjectiveNodeConfig
node={selectedNode as Objective}
onChange={handleChange}
/>
)}
{/* Constraint */}
{nodeType === 'constraint' && selectedNode && (
<ConstraintNodeConfig
node={selectedNode as Constraint}
onChange={handleChange}
/>
)}
</div>
</div>
{/* File Browser Modal */}
<FileBrowser
isOpen={showFileBrowser}
onClose={() => 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 && (
<div className="fixed top-20 right-96 z-40">
<IntrospectionPanel
filePath={spec.model.sim.path}
onClose={() => setShowIntrospection(false)}
/>
</div>
)}
</div>
);
}
// ============================================================================
// Type-specific configuration components
// ============================================================================
interface SpecConfigProps {
spec: NonNullable<ReturnType<typeof useSpec>>;
}
function ModelNodeConfig({ spec }: SpecConfigProps) {
const [showIntrospection, setShowIntrospection] = useState(false);
return (
<>
<div>
<label className={labelClass}>Model File</label>
<input
type="text"
value={spec.model.sim?.path || ''}
readOnly
className={`${inputClass} font-mono text-sm bg-dark-900 cursor-not-allowed`}
title="Model path is read-only. Change via study configuration."
/>
<p className="text-xs text-dark-500 mt-1">Read-only. Set in study configuration.</p>
</div>
<div>
<label className={labelClass}>Solver Type</label>
<input
type="text"
value={spec.model.sim?.solution_type || 'Not detected'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
{spec.model.sim?.path && (
<button
onClick={() => setShowIntrospection(true)}
className="w-full flex items-center justify-center gap-2 px-3 py-2.5 bg-primary-500/20
hover:bg-primary-500/30 border border-primary-500/30 rounded-lg
text-primary-400 text-sm font-medium transition-colors"
>
<Microscope size={16} />
Introspect Model
</button>
)}
{showIntrospection && spec.model.sim?.path && (
<div className="fixed top-20 right-96 z-40">
<IntrospectionPanel
filePath={spec.model.sim.path}
onClose={() => setShowIntrospection(false)}
/>
</div>
)}
</>
);
}
function SolverNodeConfig({ spec }: SpecConfigProps) {
return (
<div>
<label className={labelClass}>Solution Type</label>
<input
type="text"
value={spec.model.sim?.solution_type || 'Not configured'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
title="Solver type is determined by the model file."
/>
<p className="text-xs text-dark-500 mt-1">Detected from model file.</p>
</div>
);
}
function AlgorithmNodeConfig({ spec }: SpecConfigProps) {
const algo = spec.optimization.algorithm;
return (
<>
<div>
<label className={labelClass}>Method</label>
<input
type="text"
value={algo?.type || 'TPE'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
<p className="text-xs text-dark-500 mt-1">Edit in optimization settings.</p>
</div>
<div>
<label className={labelClass}>Max Trials</label>
<input
type="number"
value={spec.optimization.budget?.max_trials || 100}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
</>
);
}
function SurrogateNodeConfig({ spec }: SpecConfigProps) {
const surrogate = spec.optimization.surrogate;
return (
<>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="surrogate-enabled"
checked={surrogate?.enabled || false}
readOnly
className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 cursor-not-allowed"
/>
<label htmlFor="surrogate-enabled" className="text-sm font-medium text-dark-300">
Neural Surrogate {surrogate?.enabled ? 'Enabled' : 'Disabled'}
</label>
</div>
{surrogate?.enabled && (
<>
<div>
<label className={labelClass}>Model Type</label>
<input
type="text"
value={surrogate.type || 'MLP'}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
<div>
<label className={labelClass}>Min Training Samples</label>
<input
type="number"
value={surrogate.config?.min_training_samples || 20}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
</div>
</>
)}
<p className="text-xs text-dark-500">Edit in optimization settings.</p>
</>
);
}
// ============================================================================
// Editable node configs
// ============================================================================
interface DesignVarNodeConfigProps {
node: DesignVariable;
onChange: (field: string, value: unknown) => void;
}
function DesignVarNodeConfig({ node, onChange }: DesignVarNodeConfigProps) {
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Expression Name</label>
<input
type="text"
value={node.expression_name}
onChange={(e) => onChange('expression_name', e.target.value)}
placeholder="NX expression name"
className={`${inputClass} font-mono text-sm`}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Min</label>
<input
type="number"
value={node.bounds.min}
onChange={(e) => onChange('bounds', { ...node.bounds, min: parseFloat(e.target.value) })}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Max</label>
<input
type="number"
value={node.bounds.max}
onChange={(e) => onChange('bounds', { ...node.bounds, max: parseFloat(e.target.value) })}
className={inputClass}
/>
</div>
</div>
{node.baseline !== undefined && (
<div>
<label className={labelClass}>Baseline</label>
<input
type="number"
value={node.baseline}
onChange={(e) => onChange('baseline', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
)}
<div>
<label className={labelClass}>Units</label>
<input
type="text"
value={node.units || ''}
onChange={(e) => onChange('units', e.target.value)}
placeholder="mm"
className={inputClass}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id={`${node.id}-enabled`}
checked={node.enabled !== false}
onChange={(e) => onChange('enabled', e.target.checked)}
className="w-4 h-4 rounded bg-dark-800 border-dark-600 text-primary-500 focus:ring-primary-500"
/>
<label htmlFor={`${node.id}-enabled`} className="text-sm text-dark-300">
Enabled
</label>
</div>
</>
);
}
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 (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Extractor Type</label>
<select
value={node.type}
onChange={(e) => onChange('type', e.target.value)}
className={selectClass}
>
<option value="">Select...</option>
{extractorOptions.map(opt => (
<option key={opt.id} value={opt.type}>
{opt.id} - {opt.name}
</option>
))}
<option value="custom">Custom Function</option>
</select>
</div>
{node.type === 'custom_function' && node.function && (
<div>
<label className={labelClass}>Custom Function</label>
<input
type="text"
value={node.function.name || ''}
readOnly
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
<p className="text-xs text-dark-500 mt-1">Edit custom code in dedicated editor.</p>
</div>
)}
<div>
<label className={labelClass}>Outputs</label>
<input
type="text"
value={node.outputs?.map(o => o.name).join(', ') || ''}
readOnly
placeholder="value, unit"
className={`${inputClass} bg-dark-900 cursor-not-allowed`}
/>
<p className="text-xs text-dark-500 mt-1">Outputs are defined by extractor type.</p>
</div>
</>
);
}
interface ObjectiveNodeConfigProps {
node: Objective;
onChange: (field: string, value: unknown) => void;
}
function ObjectiveNodeConfig({ node, onChange }: ObjectiveNodeConfigProps) {
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Direction</label>
<select
value={node.direction}
onChange={(e) => onChange('direction', e.target.value)}
className={selectClass}
>
<option value="minimize">Minimize</option>
<option value="maximize">Maximize</option>
</select>
</div>
<div>
<label className={labelClass}>Weight</label>
<input
type="number"
step="0.1"
min="0"
value={node.weight ?? 1}
onChange={(e) => onChange('weight', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
{node.target !== undefined && (
<div>
<label className={labelClass}>Target Value</label>
<input
type="number"
value={node.target}
onChange={(e) => onChange('target', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
)}
</>
);
}
interface ConstraintNodeConfigProps {
node: Constraint;
onChange: (field: string, value: unknown) => void;
}
function ConstraintNodeConfig({ node, onChange }: ConstraintNodeConfigProps) {
return (
<>
<div>
<label className={labelClass}>Name</label>
<input
type="text"
value={node.name}
onChange={(e) => onChange('name', e.target.value)}
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className={labelClass}>Type</label>
<select
value={node.type}
onChange={(e) => onChange('type', e.target.value)}
className={selectClass}
>
<option value="less_than">&lt; Less than</option>
<option value="less_equal">&lt;= Less or equal</option>
<option value="greater_than">&gt; Greater than</option>
<option value="greater_equal">&gt;= Greater or equal</option>
<option value="equal">= Equal</option>
</select>
</div>
<div>
<label className={labelClass}>Threshold</label>
<input
type="number"
value={node.threshold}
onChange={(e) => onChange('threshold', parseFloat(e.target.value))}
className={inputClass}
/>
</div>
</div>
</>
);
}
export default NodeConfigPanelV2;

View File

@@ -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';

View File

@@ -0,0 +1,209 @@
/**
* useSpecStore Unit Tests
*
* Tests for the AtomizerSpec v2.0 state management store.
*/
/// <reference types="vitest/globals" />
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);
});
});
});

View File

@@ -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<void>;
reloadSpec: () => Promise<void>;
clearSpec: () => void;
// WebSocket integration - set spec directly without API call
setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => void;
// Full spec operations
saveSpec: (spec: AtomizerSpec) => Promise<void>;
replaceSpec: (spec: AtomizerSpec) => Promise<void>;
// Patch operations
patchSpec: (path: string, value: unknown) => Promise<void>;
patchSpecOptimistic: (path: string, value: unknown) => void;
// Node operations
addNode: (
type: 'designVar' | 'extractor' | 'objective' | 'constraint',
data: Record<string, unknown>
) => Promise<string>;
updateNode: (nodeId: string, updates: Record<string, unknown>) => Promise<void>;
removeNode: (nodeId: string) => Promise<void>;
updateNodePosition: (nodeId: string, position: CanvasPosition) => Promise<void>;
// Edge operations
addEdge: (source: string, target: string) => Promise<void>;
removeEdge: (source: string, target: string) => Promise<void>;
// Custom function
addCustomFunction: (
name: string,
code: string,
outputs: string[],
description?: string
) => Promise<string>;
// Validation
validateSpec: () => Promise<SpecValidationReport>;
// 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<string, unknown>
): 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<string, unknown>
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<SpecValidationReport> {
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<string, unknown> = newSpec as unknown as Record<string, unknown>;
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<string, unknown>;
} else {
current = current[part] as Record<string, unknown>;
}
}
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<SpecStore>()(
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);
});

View File

@@ -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<string | null>(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 (
<div className="h-screen flex flex-col bg-dark-900">
{/* Minimal Header */}
@@ -55,24 +249,75 @@ export function CanvasView() {
<div className="flex items-center gap-2">
<Layers size={18} className="text-primary-400" />
<span className="text-sm font-medium text-white">Canvas Builder</span>
{selectedStudy && (
{activeStudyId && (
<>
<ChevronRight size={14} className="text-dark-500" />
<span className="text-sm text-primary-400 font-medium">
{selectedStudy.name || selectedStudy.id}
{activeStudyId}
</span>
{hasUnsavedChanges && (
<span className="text-xs text-amber-400 ml-1" title="Unsaved changes"></span>
)}
</>
)}
</div>
{/* Stats */}
<span className="text-xs text-dark-500 tabular-nums ml-2">
{nodes.length} node{nodes.length !== 1 ? 's' : ''} {edges.length} edge{edges.length !== 1 ? 's' : ''}
</span>
{useSpecMode && spec ? (
<span className="text-xs text-dark-500 tabular-nums ml-2">
{spec.design_variables.length} vars {spec.extractors.length} ext {spec.objectives.length} obj
</span>
) : (
<span className="text-xs text-dark-500 tabular-nums ml-2">
{nodes.length} node{nodes.length !== 1 ? 's' : ''} {edges.length} edge{edges.length !== 1 ? 's' : ''}
</span>
)}
{/* Mode indicator */}
{useSpecMode && (
<span className="ml-2 px-1.5 py-0.5 text-xs bg-primary-900/50 text-primary-400 rounded border border-primary-800 flex items-center gap-1">
<Zap size={10} />
v2.0
</span>
)}
{(isLoading || specLoading) && (
<RefreshCw size={14} className="text-primary-400 animate-spin ml-2" />
)}
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{/* Save Button - only show when there's a study and changes */}
{activeStudyId && (
<button
onClick={saveToConfig}
disabled={isSaving || (useSpecMode ? !specIsDirty : !hasUnsavedChanges)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${
(useSpecMode ? specIsDirty : hasUnsavedChanges)
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
}`}
title={(useSpecMode ? specIsDirty : hasUnsavedChanges) ? `Save changes to ${useSpecMode ? 'atomizer_spec.json' : 'optimization_config.json'}` : 'No changes to save'}
>
<Save size={14} />
{isSaving ? 'Saving...' : 'Save'}
</button>
)}
{/* Reload Button */}
{activeStudyId && (
<button
onClick={handleReload}
disabled={isLoading || specLoading}
className="px-3 py-1.5 bg-dark-700 text-dark-200 hover:bg-dark-600 hover:text-white text-sm rounded-lg transition-colors flex items-center gap-1.5 border border-dark-600"
title={`Reload from ${useSpecMode ? 'atomizer_spec.json' : 'optimization_config.json'}`}
>
<RefreshCw size={14} className={(isLoading || specLoading) ? 'animate-spin' : ''} />
Reload
</button>
)}
<button
onClick={() => setShowTemplates(true)}
className="px-3 py-1.5 bg-primary-600 hover:bg-primary-500 text-white text-sm rounded-lg transition-colors flex items-center gap-1.5"
@@ -94,12 +339,151 @@ export function CanvasView() {
<Trash2 size={14} />
Clear
</button>
{/* Divider */}
<div className="w-px h-6 bg-dark-600" />
{/* Chat Toggle */}
<button
onClick={() => setShowChat(!showChat)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 border ${
showChat
? 'bg-primary-600 text-white border-primary-500'
: 'bg-dark-700 text-dark-200 hover:bg-dark-600 hover:text-white border-dark-600'
}`}
title={showChat ? 'Hide Assistant' : 'Show Assistant'}
>
<MessageSquare size={14} />
Assistant
</button>
</div>
</header>
{/* Main Canvas */}
<main className="flex-1 overflow-hidden">
<AtomizerCanvas />
<main className="flex-1 overflow-hidden flex">
{/* Left Sidebar with tabs (spec mode only - AtomizerCanvas has its own) */}
{useSpecMode && (
<div className={`${paletteCollapsed ? 'w-14' : 'w-60'} bg-dark-850 border-r border-dark-700 flex flex-col transition-all duration-200`}>
{/* Tab buttons (only show when expanded) */}
{!paletteCollapsed && (
<div className="flex border-b border-dark-700">
<button
onClick={() => setLeftSidebarTab('components')}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 text-xs font-medium transition-colors
${leftSidebarTab === 'components'
? 'text-primary-400 border-b-2 border-primary-400 -mb-px bg-dark-800/50'
: 'text-dark-400 hover:text-white'}`}
>
<SlidersHorizontal size={14} />
Components
</button>
<button
onClick={() => setLeftSidebarTab('files')}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 text-xs font-medium transition-colors
${leftSidebarTab === 'files'
? 'text-primary-400 border-b-2 border-primary-400 -mb-px bg-dark-800/50'
: 'text-dark-400 hover:text-white'}`}
>
<Folder size={14} />
Files
</button>
</div>
)}
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{leftSidebarTab === 'components' || paletteCollapsed ? (
<NodePalette
collapsed={paletteCollapsed}
onToggleCollapse={() => setPaletteCollapsed(!paletteCollapsed)}
showToggle={true}
/>
) : (
<FileStructurePanel
studyId={activeStudyId || null}
selectedModelPath={spec?.model?.sim?.path}
onModelSelect={(path, _type) => {
// TODO: Update model path in spec
showNotification(`Selected: ${path.split(/[/\\]/).pop()}`);
}}
/>
)}
</div>
</div>
)}
{/* Canvas area - must have explicit height for ReactFlow */}
<div className="flex-1 h-full">
{useSpecMode ? (
<SpecRenderer
studyId={activeStudyId}
onStudyChange={navigateToStudy}
enableWebSocket={true}
showConnectionStatus={true}
editable={true}
/>
) : (
<AtomizerCanvas
studyId={activeStudyId}
onStudyChange={navigateToStudy}
/>
)}
</div>
{/* Config Panel - use V2 for spec mode, legacy for AtomizerCanvas */}
{selectedNodeId && !showChat && (
useSpecMode ? (
<NodeConfigPanelV2 onClose={() => useSpecStore.getState().clearSelection()} />
) : (
<div className="w-80 border-l border-dark-700 bg-dark-850 overflow-y-auto">
<NodeConfigPanel nodeId={selectedNodeId} />
</div>
)
)}
{/* Chat/Assistant Panel */}
{showChat && (
<div className="w-96 border-l border-dark-700 bg-dark-850 flex flex-col">
{/* Chat Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
<MessageSquare size={16} className="text-primary-400" />
<span className="font-medium text-white">Assistant</span>
{isConnected && (
<span className="w-2 h-2 rounded-full bg-green-400" title="Connected" />
)}
</div>
<div className="flex items-center gap-2">
{/* Power Mode Toggle */}
<button
onClick={() => setChatPowerMode(!chatPowerMode)}
className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
chatPowerMode
? 'bg-amber-600 text-white'
: 'bg-dark-700 text-dark-400 hover:text-white'
}`}
title={chatPowerMode ? 'Power Mode: Claude can modify the canvas' : 'User Mode: Read-only assistant'}
>
<Zap size={12} />
{chatPowerMode ? 'Power' : 'User'}
</button>
<button
onClick={() => setShowChat(false)}
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* Chat Content */}
<ChatPanel
messages={messages}
isThinking={isThinking}
onSendMessage={sendMessage}
isConnected={isConnected}
/>
</div>
)}
</main>
{/* Template Selector Modal */}

View File

@@ -0,0 +1,137 @@
/**
* Vitest Test Setup
*
* This file runs before each test file to set up the testing environment.
*/
/// <reference types="vitest/globals" />
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();
});

View File

@@ -0,0 +1,142 @@
/**
* Test Utilities
*
* Provides custom render function with all necessary providers.
*/
/// <reference types="vitest/globals" />
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 (
<BrowserRouter>
<StudyProvider>
{children}
</StudyProvider>
</BrowserRouter>
);
}
/**
* Custom render function that wraps component with all providers
*/
const customRender = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) => 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> = {}): 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<string, any>) {
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));
}

View File

@@ -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',
},
},
});