Files
Atomizer/docs/archive/review/RALPH_LOOP_CANVAS_V4.md
Antoine 8d9d55356c docs: Archive stale docs and create Atomizer-HQ agent documentation
Archive Management:
- Moved RALPH_LOOP, CANVAS, and dashboard implementation plans to archive/review/ for CEO review
- Moved completed restructuring plan and protocol v1 to archive/historical/
- Moved old session summaries to archive/review/

New HQ Documentation (docs/hq/):
- README.md: Overview of Atomizer-HQ multi-agent optimization team
- PROJECT_STRUCTURE.md: Standard KB-integrated project layout with Hydrotech reference
- KB_CONVENTIONS.md: Knowledge Base accumulation principles with generation tracking
- AGENT_WORKFLOWS.md: Project lifecycle phases and agent handoffs (OP_09 integration)
- STUDY_CONVENTIONS.md: Technical study execution standards and atomizer_spec.json format

Index Update:
- Reorganized docs/00_INDEX.md with HQ docs prominent
- Updated structure to reflect new agent-focused organization
- Maintained core documentation access for engineers

No files deleted, only moved to appropriate archive locations.
2026-02-09 02:48:35 +00:00

60 KiB

Ralph Loop: Canvas V4 - Model Introspection & Claude Integration

Purpose: Fix critical Canvas bugs and implement NX model introspection with Claude control Execution: Autonomous, all phases sequential, no stopping Estimated Duration: 12 work units Priority: HIGH - Core functionality broken


Launch Command

cd C:\Users\antoi\Atomizer
claude --dangerously-skip-permissions

Paste everything below the line.


You are executing a multi-phase autonomous development session to fix critical Canvas Builder issues and implement NX model introspection.

Critical Issues to Fix

Issue Impact Priority
Claude bot SQL error on Validate Blocking - can't validate P0
Execute with Claude broken Blocking - can't create studies P0
Expressions not connected to Model UX - wrong data flow P1
No file browser for .sim UX - can't find files P1
No model introspection Feature - manual config only P2

Key Learnings to Implement

  1. Data Flow: Expressions → Model (not Model → Expressions)
  2. Model has multiple inputs: One per design variable
  3. Introspection discovers: Expressions, solver type, dependent files (.fem, .prt)
  4. Auto-filter: Selecting .sim shows only related elements

Environment

Working Directory: C:/Users/antoi/Atomizer
Frontend: atomizer-dashboard/frontend/
Backend: atomizer-dashboard/backend/
Python: C:/Users/antoi/anaconda3/envs/atomizer/python.exe
Git: Push to origin AND github

Execution Rules

  1. TodoWrite - Track every task, mark complete immediately
  2. Sequential phases - Complete each phase fully before next
  3. Test after each phase - Run npm run build to verify
  4. Read before edit - ALWAYS read files before modifying
  5. No guessing - If unsure, read more code first
  6. Commit per phase - Git commit after each major phase

PHASE 1: Fix Claude Bot SQL Error (CRITICAL)

T1.1 - Investigate SQL Error

First, find the source of the SQL error:

Read: atomizer-dashboard/backend/api/services/conversation_store.py
Read: atomizer-dashboard/backend/api/services/session_manager.py
Read: atomizer-dashboard/backend/api/routes/terminal.py

Common SQL issues:

  • Table not created before use
  • Column mismatch
  • Connection not initialized
  • Missing migrations

Look for:

  • CREATE TABLE IF NOT EXISTS - ensure tables exist
  • Try/catch around all DB operations
  • Connection initialization on startup

T1.2 - Fix Database Initialization

File: atomizer-dashboard/backend/api/services/conversation_store.py

Ensure robust initialization:

import sqlite3
from pathlib import Path
from contextlib import contextmanager
import logging

logger = logging.getLogger(__name__)

class ConversationStore:
    def __init__(self, db_path: str = "sessions.db"):
        self.db_path = Path(db_path)
        self._init_db()

    def _init_db(self):
        """Initialize database with all required tables."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Sessions table
                cursor.execute('''
                    CREATE TABLE IF NOT EXISTS sessions (
                        id TEXT PRIMARY KEY,
                        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                        updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                        study_path TEXT,
                        context TEXT
                    )
                ''')

                # Messages table
                cursor.execute('''
                    CREATE TABLE IF NOT EXISTS messages (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        session_id TEXT NOT NULL,
                        role TEXT NOT NULL,
                        content TEXT NOT NULL,
                        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                        FOREIGN KEY (session_id) REFERENCES sessions(id)
                    )
                ''')

                conn.commit()
                logger.info(f"Database initialized: {self.db_path}")
        except Exception as e:
            logger.error(f"Failed to initialize database: {e}")
            raise

    @contextmanager
    def _get_connection(self):
        """Get a database connection with proper error handling."""
        conn = None
        try:
            conn = sqlite3.connect(str(self.db_path))
            conn.row_factory = sqlite3.Row
            yield conn
        except sqlite3.Error as e:
            logger.error(f"Database error: {e}")
            if conn:
                conn.rollback()
            raise
        finally:
            if conn:
                conn.close()

    def get_or_create_session(self, session_id: str, study_path: str = None):
        """Get existing session or create new one."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()

                # Check if exists
                cursor.execute('SELECT * FROM sessions WHERE id = ?', (session_id,))
                row = cursor.fetchone()

                if row:
                    return dict(row)

                # Create new
                cursor.execute(
                    'INSERT INTO sessions (id, study_path) VALUES (?, ?)',
                    (session_id, study_path)
                )
                conn.commit()

                return {
                    'id': session_id,
                    'study_path': study_path,
                    'created_at': None
                }
        except Exception as e:
            logger.error(f"Failed to get/create session: {e}")
            return {'id': session_id, 'study_path': study_path}

    def add_message(self, session_id: str, role: str, content: str):
        """Add a message to session history."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute(
                    'INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)',
                    (session_id, role, content)
                )
                conn.commit()
        except Exception as e:
            logger.error(f"Failed to add message: {e}")
            # Don't raise - message logging is not critical

    def get_messages(self, session_id: str, limit: int = 50):
        """Get recent messages for a session."""
        try:
            with self._get_connection() as conn:
                cursor = conn.cursor()
                cursor.execute(
                    '''SELECT role, content, created_at
                       FROM messages
                       WHERE session_id = ?
                       ORDER BY created_at DESC
                       LIMIT ?''',
                    (session_id, limit)
                )
                rows = cursor.fetchall()
                return [dict(row) for row in reversed(rows)]
        except Exception as e:
            logger.error(f"Failed to get messages: {e}")
            return []

T1.3 - Fix Session Manager

File: atomizer-dashboard/backend/api/services/session_manager.py

Add error handling:

class SessionManager:
    def __init__(self):
        self.store = ConversationStore()
        self.active_sessions = {}

    async def get_session(self, session_id: str):
        """Get or create a session with error handling."""
        try:
            if session_id not in self.active_sessions:
                session_data = self.store.get_or_create_session(session_id)
                self.active_sessions[session_id] = session_data
            return self.active_sessions.get(session_id)
        except Exception as e:
            logger.error(f"Session error: {e}")
            # Return minimal session on error
            return {'id': session_id, 'study_path': None}

T1.4 - Add Health Check Endpoint

File: atomizer-dashboard/backend/api/main.py

@app.get("/api/health")
async def health_check():
    """Health check with database status."""
    try:
        from api.services.conversation_store import ConversationStore
        store = ConversationStore()
        store.get_or_create_session("health_check")
        return {"status": "healthy", "database": "connected"}
    except Exception as e:
        return {"status": "unhealthy", "database": str(e)}

PHASE 2: Fix Execute with Claude (CRITICAL)

T2.1 - Investigate Chat Integration

Read the chat components:

Read: atomizer-dashboard/frontend/src/hooks/useCanvasChat.ts
Read: atomizer-dashboard/frontend/src/hooks/useChat.ts
Read: atomizer-dashboard/frontend/src/components/canvas/panels/ChatPanel.tsx
Read: atomizer-dashboard/backend/api/routes/terminal.py

T2.2 - Fix WebSocket Chat Hook

File: atomizer-dashboard/frontend/src/hooks/useChat.ts

Ensure proper connection handling:

import { useState, useCallback, useRef, useEffect } from 'react';

interface ChatMessage {
  role: 'user' | 'assistant' | 'system';
  content: string;
  timestamp?: Date;
}

interface UseChatOptions {
  endpoint?: string;
  sessionId?: string;
  onError?: (error: string) => void;
}

export function useChat(options: UseChatOptions = {}) {
  const {
    endpoint = '/api/chat',
    sessionId = 'default',
    onError,
  } = options;

  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [isConnected, setIsConnected] = useState(false);
  const [isThinking, setIsThinking] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const wsRef = useRef<WebSocket | null>(null);
  const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
  const reconnectAttempts = useRef(0);
  const maxReconnectAttempts = 5;

  const connect = useCallback(() => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      return;
    }

    try {
      const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
      const wsUrl = `${protocol}//${window.location.host}${endpoint}?session_id=${sessionId}`;

      console.log('Connecting to WebSocket:', wsUrl);
      wsRef.current = new WebSocket(wsUrl);

      wsRef.current.onopen = () => {
        console.log('WebSocket connected');
        setIsConnected(true);
        setError(null);
        reconnectAttempts.current = 0;
      };

      wsRef.current.onmessage = (event) => {
        try {
          const data = JSON.parse(event.data);

          if (data.type === 'message') {
            setMessages((prev) => [...prev, {
              role: data.role || 'assistant',
              content: data.content,
              timestamp: new Date(),
            }]);
            setIsThinking(false);
          } else if (data.type === 'thinking') {
            setIsThinking(true);
          } else if (data.type === 'error') {
            setError(data.message);
            setIsThinking(false);
            onError?.(data.message);
          } else if (data.type === 'done') {
            setIsThinking(false);
          }
        } catch (e) {
          console.error('Failed to parse message:', e);
        }
      };

      wsRef.current.onclose = (event) => {
        console.log('WebSocket closed:', event.code, event.reason);
        setIsConnected(false);

        // Attempt reconnect
        if (reconnectAttempts.current < maxReconnectAttempts) {
          reconnectAttempts.current++;
          const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 30000);
          console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts.current})`);

          reconnectTimeoutRef.current = setTimeout(() => {
            connect();
          }, delay);
        } else {
          setError('Connection lost. Please refresh the page.');
        }
      };

      wsRef.current.onerror = (event) => {
        console.error('WebSocket error:', event);
        setError('Connection error');
      };
    } catch (e) {
      console.error('Failed to create WebSocket:', e);
      setError('Failed to connect');
    }
  }, [endpoint, sessionId, onError]);

  const disconnect = useCallback(() => {
    if (reconnectTimeoutRef.current) {
      clearTimeout(reconnectTimeoutRef.current);
    }
    if (wsRef.current) {
      wsRef.current.close();
      wsRef.current = null;
    }
    setIsConnected(false);
  }, []);

  const sendMessage = useCallback(async (content: string) => {
    if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
      setError('Not connected');
      return;
    }

    // Add user message immediately
    setMessages((prev) => [...prev, {
      role: 'user',
      content,
      timestamp: new Date(),
    }]);

    setIsThinking(true);
    setError(null);

    try {
      wsRef.current.send(JSON.stringify({
        type: 'message',
        content,
        session_id: sessionId,
      }));
    } catch (e) {
      console.error('Failed to send message:', e);
      setError('Failed to send message');
      setIsThinking(false);
    }
  }, [sessionId]);

  const clearMessages = useCallback(() => {
    setMessages([]);
  }, []);

  const reconnect = useCallback(() => {
    disconnect();
    reconnectAttempts.current = 0;
    setTimeout(connect, 100);
  }, [connect, disconnect]);

  // Auto-connect on mount
  useEffect(() => {
    connect();
    return () => disconnect();
  }, [connect, disconnect]);

  return {
    messages,
    isConnected,
    isThinking,
    error,
    sendMessage,
    clearMessages,
    reconnect,
    connect,
    disconnect,
  };
}

T2.3 - Fix Canvas Chat Hook

File: atomizer-dashboard/frontend/src/hooks/useCanvasChat.ts

import { useCallback } from 'react';
import { useChat } from './useChat';
import { useCanvasStore } from './useCanvasStore';
import { toOptimizationIntent } from '../lib/canvas/intent';

export function useCanvasChat() {
  const { nodes, edges, validation } = useCanvasStore();

  const chat = useChat({
    endpoint: '/api/chat/canvas',
    sessionId: `canvas_${Date.now()}`,
    onError: (error) => {
      console.error('Canvas chat error:', error);
    },
  });

  const validateWithClaude = useCallback(async () => {
    try {
      const intent = toOptimizationIntent(nodes, edges);
      const message = `Please validate this Canvas workflow configuration:

\`\`\`json
${JSON.stringify(intent, null, 2)}
\`\`\`

Check for:
1. Missing required nodes (Model, Solver, at least one Objective, Algorithm)
2. Invalid connections
3. Configuration issues
4. Suggest improvements`;

      await chat.sendMessage(message);
    } catch (e) {
      console.error('Validation error:', e);
    }
  }, [nodes, edges, chat]);

  const processWithClaude = useCallback(async (studyName?: string, options?: {
    overwrite?: boolean;
    copyModels?: boolean;
  }) => {
    try {
      const intent = toOptimizationIntent(nodes, edges);
      const message = `Create an optimization study from this Canvas workflow:

Study Name: ${studyName || 'new_study'}
Options: ${JSON.stringify(options || {})}

\`\`\`json
${JSON.stringify(intent, null, 2)}
\`\`\`

Please:
1. Validate the configuration
2. Generate optimization_config.json
3. Generate run_optimization.py using Atomizer protocols
4. Create the study folder structure`;

      await chat.sendMessage(message);
    } catch (e) {
      console.error('Process error:', e);
    }
  }, [nodes, edges, chat]);

  const askClaude = useCallback(async (question: string) => {
    try {
      const intent = toOptimizationIntent(nodes, edges);
      const message = `Context - Current Canvas workflow:
\`\`\`json
${JSON.stringify(intent, null, 2)}
\`\`\`

Question: ${question}`;

      await chat.sendMessage(message);
    } catch (e) {
      console.error('Ask error:', e);
    }
  }, [nodes, edges, chat]);

  return {
    ...chat,
    validateWithClaude,
    processWithClaude,
    askClaude,
  };
}

T2.4 - Fix Backend Chat WebSocket

File: atomizer-dashboard/backend/api/routes/terminal.py

from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from api.services.session_manager import SessionManager
from api.services.claude_agent import ClaudeAgent
import json
import logging
import asyncio

router = APIRouter()
logger = logging.getLogger(__name__)

session_manager = SessionManager()

@router.websocket("/api/chat")
@router.websocket("/api/chat/canvas")
async def chat_websocket(websocket: WebSocket):
    """WebSocket endpoint for Claude chat."""
    await websocket.accept()

    session_id = websocket.query_params.get('session_id', 'default')
    logger.info(f"Chat WebSocket connected: {session_id}")

    try:
        # Initialize session
        session = await session_manager.get_session(session_id)

        # Send connection confirmation
        await websocket.send_json({
            "type": "connected",
            "session_id": session_id,
        })

        while True:
            try:
                # Receive message
                data = await websocket.receive_json()

                if data.get('type') == 'message':
                    content = data.get('content', '')

                    # Send thinking indicator
                    await websocket.send_json({"type": "thinking"})

                    try:
                        # Process with Claude
                        agent = ClaudeAgent()
                        response = await agent.process_message(
                            session_id=session_id,
                            message=content,
                            context=session.get('context')
                        )

                        # Send response
                        await websocket.send_json({
                            "type": "message",
                            "role": "assistant",
                            "content": response,
                        })

                    except Exception as e:
                        logger.error(f"Claude error: {e}")
                        await websocket.send_json({
                            "type": "error",
                            "message": str(e),
                        })

                    # Send done
                    await websocket.send_json({"type": "done"})

            except json.JSONDecodeError as e:
                logger.error(f"Invalid JSON: {e}")
                await websocket.send_json({
                    "type": "error",
                    "message": "Invalid message format",
                })

    except WebSocketDisconnect:
        logger.info(f"Chat WebSocket disconnected: {session_id}")
    except Exception as e:
        logger.error(f"WebSocket error: {e}")
        try:
            await websocket.send_json({
                "type": "error",
                "message": str(e),
            })
        except:
            pass

PHASE 3: Fix Node Connection Flow (Expressions → Model)

T3.1 - Update Node Schema for Inputs

File: atomizer-dashboard/frontend/src/lib/canvas/schema.ts

Add input/output handle definitions:

// Node handle positions
export interface NodeHandles {
  inputs: HandleConfig[];
  outputs: HandleConfig[];
}

export interface HandleConfig {
  id: string;
  type: 'source' | 'target';
  position: 'top' | 'bottom' | 'left' | 'right';
  label?: string;
}

// Define handles for each node type
export const NODE_HANDLES: Record<NodeType, NodeHandles> = {
  model: {
    inputs: [
      { id: 'params', type: 'target', position: 'left', label: 'Design Vars' },
    ],
    outputs: [
      { id: 'sim', type: 'source', position: 'right', label: 'Simulation' },
    ],
  },
  solver: {
    inputs: [
      { id: 'model', type: 'target', position: 'left', label: 'Model' },
    ],
    outputs: [
      { id: 'results', type: 'source', position: 'right', label: 'Results' },
    ],
  },
  designVar: {
    inputs: [],
    outputs: [
      { id: 'value', type: 'source', position: 'right', label: 'Value' },
    ],
  },
  extractor: {
    inputs: [
      { id: 'results', type: 'target', position: 'left', label: 'Results' },
    ],
    outputs: [
      { id: 'value', type: 'source', position: 'right', label: 'Value' },
    ],
  },
  objective: {
    inputs: [
      { id: 'value', type: 'target', position: 'left', label: 'Value' },
    ],
    outputs: [
      { id: 'objective', type: 'source', position: 'right', label: 'To Algo' },
    ],
  },
  constraint: {
    inputs: [
      { id: 'value', type: 'target', position: 'left', label: 'Value' },
    ],
    outputs: [],
  },
  algorithm: {
    inputs: [
      { id: 'objectives', type: 'target', position: 'left', label: 'Objectives' },
    ],
    outputs: [],
  },
  surrogate: {
    inputs: [
      { id: 'algo', type: 'target', position: 'left', label: 'Algorithm' },
    ],
    outputs: [],
  },
};

T3.2 - Update BaseNode with Multiple Handles

File: atomizer-dashboard/frontend/src/components/canvas/nodes/BaseNode.tsx

import { Handle, Position } from 'reactflow';
import { NODE_HANDLES } from '../../../lib/canvas/schema';

interface BaseNodeProps {
  type: NodeType;
  data: CanvasNodeData;
  selected: boolean;
  children?: React.ReactNode;
}

export function BaseNode({ type, data, selected, children }: BaseNodeProps) {
  const handles = NODE_HANDLES[type];
  const icon = getIconForType(type);
  const iconColor = getColorForType(type);

  return (
    <div
      className={`
        relative px-3 py-2.5 rounded-lg border min-w-[180px] max-w-[220px]
        bg-dark-850 shadow-lg transition-all duration-150
        ${selected ? 'border-primary-400 ring-2 ring-primary-400/30' : 'border-dark-600'}
        ${!data.configured ? 'border-dashed border-dark-500' : ''}
      `}
    >
      {/* Input handles (left side) */}
      {handles.inputs.map((handle, idx) => (
        <Handle
          key={`in-${handle.id}`}
          type="target"
          position={Position.Left}
          id={handle.id}
          style={{ top: `${30 + idx * 20}%` }}
          className="!w-3 !h-3 !bg-dark-600 !border-2 !border-dark-400 hover:!bg-primary-500 hover:!border-primary-400"
        />
      ))}

      {/* Content */}
      <div className="flex items-center gap-2.5">
        <div className={`${iconColor} flex-shrink-0`}>
          {icon}
        </div>
        <div className="flex-1 min-w-0">
          <div className="font-medium text-white text-sm leading-tight truncate">
            {data.label}
          </div>
        </div>
      </div>

      {children && (
        <div className="mt-2 text-xs text-dark-300 truncate">
          {children}
        </div>
      )}

      {/* Output handles (right side) */}
      {handles.outputs.map((handle, idx) => (
        <Handle
          key={`out-${handle.id}`}
          type="source"
          position={Position.Right}
          id={handle.id}
          style={{ top: `${30 + idx * 20}%` }}
          className="!w-3 !h-3 !bg-dark-600 !border-2 !border-dark-400 hover:!bg-primary-500 hover:!border-primary-400"
        />
      ))}
    </div>
  );
}

T3.3 - Update Validation for New Flow

File: atomizer-dashboard/frontend/src/lib/canvas/validation.ts

Update validation to check new connection flow:

export function validateCanvas(nodes: Node[], edges: Edge[]): ValidationResult {
  const errors: string[] = [];
  const warnings: string[] = [];

  // Get nodes by type
  const modelNodes = nodes.filter(n => n.data.type === 'model');
  const solverNodes = nodes.filter(n => n.data.type === 'solver');
  const dvarNodes = nodes.filter(n => n.data.type === 'designVar');
  const extractorNodes = nodes.filter(n => n.data.type === 'extractor');
  const objectiveNodes = nodes.filter(n => n.data.type === 'objective');
  const algoNodes = nodes.filter(n => n.data.type === 'algorithm');

  // Required nodes
  if (modelNodes.length === 0) errors.push('Missing Model node');
  if (solverNodes.length === 0) errors.push('Missing Solver node');
  if (objectiveNodes.length === 0) errors.push('Missing Objective node');
  if (algoNodes.length === 0) errors.push('Missing Algorithm node');

  // Check connections
  // Design Variables should connect TO Model
  dvarNodes.forEach(dvar => {
    const hasConnection = edges.some(e =>
      e.source === dvar.id &&
      modelNodes.some(m => m.id === e.target)
    );
    if (!hasConnection) {
      warnings.push(`Design variable "${dvar.data.label}" not connected to Model`);
    }
  });

  // Model should connect to Solver
  modelNodes.forEach(model => {
    const hasConnection = edges.some(e =>
      e.source === model.id &&
      solverNodes.some(s => s.id === e.target)
    );
    if (!hasConnection) {
      errors.push(`Model "${model.data.label}" not connected to Solver`);
    }
  });

  // Solver should connect to Extractors
  solverNodes.forEach(solver => {
    const connectedExtractors = edges.filter(e =>
      e.source === solver.id &&
      extractorNodes.some(ex => ex.id === e.target)
    );
    if (connectedExtractors.length === 0) {
      warnings.push('Solver not connected to any Extractor');
    }
  });

  // Extractors should connect to Objectives
  objectiveNodes.forEach(obj => {
    const hasConnection = edges.some(e =>
      extractorNodes.some(ex => ex.id === e.source) &&
      e.target === obj.id
    );
    if (!hasConnection) {
      errors.push(`Objective "${obj.data.label}" not connected to any Extractor`);
    }
  });

  // Objectives should connect to Algorithm
  const algoNode = algoNodes[0];
  if (algoNode) {
    objectiveNodes.forEach(obj => {
      const hasConnection = edges.some(e =>
        e.source === obj.id && e.target === algoNode.id
      );
      if (!hasConnection) {
        warnings.push(`Objective "${obj.data.label}" not connected to Algorithm`);
      }
    });
  }

  // Check configuration
  nodes.forEach(node => {
    if (!node.data.configured) {
      warnings.push(`"${node.data.label}" is not fully configured`);
    }
  });

  return {
    valid: errors.length === 0,
    errors,
    warnings,
  };
}

PHASE 4: Add File Browser for Model Selection

T4.1 - Create File Browser Component

File: atomizer-dashboard/frontend/src/components/canvas/panels/FileBrowser.tsx

import { useState, useEffect } from 'react';
import { X, Folder, FileBox, ChevronRight, ChevronDown, Search, RefreshCw } from 'lucide-react';

interface FileBrowserProps {
  isOpen: boolean;
  onClose: () => void;
  onSelect: (filePath: string) => void;
  fileTypes?: string[];  // e.g., ['.sim', '.prt', '.fem', '.afem']
  initialPath?: string;
}

interface FileEntry {
  name: string;
  path: string;
  isDirectory: boolean;
  children?: FileEntry[];
}

export function FileBrowser({ isOpen, onClose, onSelect, fileTypes = ['.sim', '.prt', '.fem', '.afem'], initialPath }: FileBrowserProps) {
  const [currentPath, setCurrentPath] = useState(initialPath || '');
  const [files, setFiles] = useState<FileEntry[]>([]);
  const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
  const [searchTerm, setSearchTerm] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const loadDirectory = async (path: string) => {
    setIsLoading(true);
    setError(null);
    try {
      const res = await fetch(`/api/files/list?path=${encodeURIComponent(path)}&types=${fileTypes.join(',')}`);
      if (!res.ok) throw new Error('Failed to load directory');
      const data = await res.json();
      setFiles(data.files || []);
    } catch (e) {
      setError('Failed to load files');
      console.error(e);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    if (isOpen) {
      loadDirectory(currentPath);
    }
  }, [isOpen, currentPath]);

  const toggleExpand = (path: string) => {
    setExpandedPaths(prev => {
      const next = new Set(prev);
      if (next.has(path)) {
        next.delete(path);
      } else {
        next.add(path);
      }
      return next;
    });
  };

  const handleSelect = (file: FileEntry) => {
    if (file.isDirectory) {
      toggleExpand(file.path);
    } else {
      onSelect(file.path);
      onClose();
    }
  };

  const filteredFiles = files.filter(f =>
    f.name.toLowerCase().includes(searchTerm.toLowerCase())
  );

  if (!isOpen) return null;

  return (
    <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
      <div className="bg-dark-850 border border-dark-700 rounded-xl w-full max-w-2xl max-h-[80vh] flex flex-col shadow-2xl">
        {/* Header */}
        <div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
          <h3 className="font-semibold text-white">Select Model File</h3>
          <button onClick={onClose} className="p-1 text-dark-400 hover:text-white">
            <X size={20} />
          </button>
        </div>

        {/* Search */}
        <div className="px-4 py-3 border-b border-dark-700">
          <div className="relative">
            <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500" />
            <input
              type="text"
              placeholder="Search files..."
              value={searchTerm}
              onChange={(e) => setSearchTerm(e.target.value)}
              className="w-full pl-9 pr-4 py-2 bg-dark-800 border border-dark-600 rounded-lg
                         text-white placeholder-dark-500 text-sm focus:outline-none focus:border-primary-500"
            />
          </div>
          <div className="flex items-center gap-2 mt-2 text-xs text-dark-500">
            <span>Looking for:</span>
            {fileTypes.map(t => (
              <span key={t} className="px-1.5 py-0.5 bg-dark-700 rounded">{t}</span>
            ))}
          </div>
        </div>

        {/* Path breadcrumb */}
        <div className="px-4 py-2 text-sm text-dark-400 flex items-center gap-1 border-b border-dark-700">
          <button onClick={() => setCurrentPath('')} className="hover:text-white">
            studies
          </button>
          {currentPath.split('/').filter(Boolean).map((part, i, arr) => (
            <span key={i} className="flex items-center gap-1">
              <ChevronRight size={14} />
              <button
                onClick={() => setCurrentPath(arr.slice(0, i + 1).join('/'))}
                className="hover:text-white"
              >
                {part}
              </button>
            </span>
          ))}
        </div>

        {/* File list */}
        <div className="flex-1 overflow-auto p-2">
          {isLoading ? (
            <div className="flex items-center justify-center h-32 text-dark-500">
              <RefreshCw size={20} className="animate-spin mr-2" />
              Loading...
            </div>
          ) : error ? (
            <div className="flex items-center justify-center h-32 text-red-400">
              {error}
            </div>
          ) : filteredFiles.length === 0 ? (
            <div className="flex items-center justify-center h-32 text-dark-500">
              No matching files found
            </div>
          ) : (
            <div className="space-y-1">
              {filteredFiles.map((file) => (
                <button
                  key={file.path}
                  onClick={() => handleSelect(file)}
                  className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left
                    hover:bg-dark-700 transition-colors
                    ${file.isDirectory ? 'text-dark-300' : 'text-white'}`}
                >
                  {file.isDirectory ? (
                    <>
                      {expandedPaths.has(file.path) ? (
                        <ChevronDown size={16} className="text-dark-500" />
                      ) : (
                        <ChevronRight size={16} className="text-dark-500" />
                      )}
                      <Folder size={16} className="text-amber-400" />
                    </>
                  ) : (
                    <>
                      <span className="w-4" />
                      <FileBox size={16} className="text-primary-400" />
                    </>
                  )}
                  <span className="flex-1 truncate">{file.name}</span>
                  {!file.isDirectory && (
                    <span className="text-xs text-dark-500">
                      {file.name.split('.').pop()?.toUpperCase()}
                    </span>
                  )}
                </button>
              ))}
            </div>
          )}
        </div>

        {/* Footer */}
        <div className="px-4 py-3 border-t border-dark-700 flex justify-end">
          <button
            onClick={onClose}
            className="px-4 py-2 text-dark-300 hover:text-white transition-colors"
          >
            Cancel
          </button>
        </div>
      </div>
    </div>
  );
}

T4.2 - Add Backend File List Endpoint

File: atomizer-dashboard/backend/api/routes/files.py

from fastapi import APIRouter, Query
from pathlib import Path
from typing import List
import os

router = APIRouter(prefix="/api/files", tags=["files"])

STUDIES_ROOT = Path(__file__).parent.parent.parent.parent.parent / "studies"


@router.get("/list")
async def list_files(
    path: str = "",
    types: str = ".sim,.prt,.fem,.afem"
):
    """List files in a directory, filtered by type."""
    allowed_types = [t.strip().lower() for t in types.split(',')]

    base_path = STUDIES_ROOT / path if path else STUDIES_ROOT

    if not base_path.exists():
        return {"files": [], "path": path}

    files = []

    try:
        for entry in sorted(base_path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
            if entry.name.startswith('.'):
                continue

            if entry.is_dir():
                # Include directories
                files.append({
                    "name": entry.name,
                    "path": str(entry.relative_to(STUDIES_ROOT)),
                    "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)),
                        "isDirectory": False,
                    })
    except Exception as e:
        return {"files": [], "path": path, "error": str(e)}

    return {"files": files, "path": path}

T4.3 - Register Files Router

File: atomizer-dashboard/backend/api/main.py

Add:

from api.routes import files

app.include_router(files.router)

PHASE 5: Implement Model Introspection

T5.1 - Create NX Introspection Service

File: atomizer-dashboard/backend/api/services/nx_introspection.py

from pathlib import Path
from typing import Dict, List, Any, Optional
import logging
import re

logger = logging.getLogger(__name__)

STUDIES_ROOT = Path(__file__).parent.parent.parent.parent.parent / "studies"


class NXIntrospector:
    """Introspect NX model files to discover expressions, dependencies, and solver info."""

    def __init__(self, file_path: str):
        self.file_path = STUDIES_ROOT / file_path
        self.file_type = self.file_path.suffix.lower()

    def introspect(self) -> Dict[str, Any]:
        """Full introspection of the model file."""
        result = {
            "file_path": str(self.file_path),
            "file_type": self.file_type,
            "expressions": [],
            "solver_type": None,
            "dependent_files": [],
            "extractors_available": [],
            "warnings": [],
        }

        if not self.file_path.exists():
            result["warnings"].append(f"File not found: {self.file_path}")
            return result

        try:
            if self.file_type == '.sim':
                result.update(self._introspect_sim())
            elif self.file_type == '.prt':
                result.update(self._introspect_prt())
            elif self.file_type in ['.fem', '.afem']:
                result.update(self._introspect_fem())
        except Exception as e:
            logger.error(f"Introspection error: {e}")
            result["warnings"].append(str(e))

        # Suggest extractors based on solver type
        result["extractors_available"] = self._suggest_extractors(result.get("solver_type"))

        return result

    def _introspect_sim(self) -> Dict[str, Any]:
        """Introspect .sim file."""
        result = {
            "solver_type": None,
            "dependent_files": [],
            "expressions": [],
        }

        parent_dir = self.file_path.parent
        base_name = self.file_path.stem

        # Find related files
        for ext in ['.prt', '.fem', '.afem']:
            # Look for exact match or _fem suffix variations
            patterns = [
                parent_dir / f"{base_name}{ext}",
                parent_dir / f"{base_name.replace('_sim1', '')}{ext}",
                parent_dir / f"{base_name.replace('_sim1', '_fem1')}{ext}",
            ]
            for pattern in patterns:
                if pattern.exists():
                    result["dependent_files"].append({
                        "path": str(pattern.relative_to(STUDIES_ROOT)),
                        "type": ext[1:],  # Remove dot
                    })

        # Find idealized part (*_i.prt)
        for f in parent_dir.glob("*_i.prt"):
            result["dependent_files"].append({
                "path": str(f.relative_to(STUDIES_ROOT)),
                "type": "idealized_prt",
            })

        # Try to determine solver type (would need NX API for accurate detection)
        # For now, infer from file contents or naming
        result["solver_type"] = self._detect_solver_type()

        # Get expressions from associated .prt
        prt_files = [d for d in result["dependent_files"] if d["type"] in ["prt", "idealized_prt"]]
        for prt in prt_files:
            prt_path = STUDIES_ROOT / prt["path"]
            if prt_path.exists():
                # In real implementation, would use NX API
                # For now, try to detect from common expression patterns
                result["expressions"].extend(self._discover_expressions_from_file(prt_path))

        return result

    def _introspect_prt(self) -> Dict[str, Any]:
        """Introspect .prt file."""
        result = {
            "expressions": [],
            "dependent_files": [],
        }

        # Look for associated .sim and .fem files
        parent_dir = self.file_path.parent
        base_name = self.file_path.stem

        for ext in ['.sim', '.fem', '.afem']:
            patterns = [
                parent_dir / f"{base_name}{ext}",
                parent_dir / f"{base_name}_sim1{ext}",
                parent_dir / f"{base_name}_fem1{ext}",
            ]
            for pattern in patterns:
                if pattern.exists():
                    result["dependent_files"].append({
                        "path": str(pattern.relative_to(STUDIES_ROOT)),
                        "type": ext[1:],
                    })

        result["expressions"] = self._discover_expressions_from_file(self.file_path)

        return result

    def _introspect_fem(self) -> Dict[str, Any]:
        """Introspect .fem or .afem file."""
        return {
            "expressions": [],
            "dependent_files": [],
        }

    def _detect_solver_type(self) -> Optional[str]:
        """Detect solver type from file name or contents."""
        name_lower = self.file_path.name.lower()

        # Infer from naming conventions
        if 'modal' in name_lower or 'freq' in name_lower:
            return 'SOL103'  # Modal analysis
        elif 'static' in name_lower or 'stress' in name_lower:
            return 'SOL101'  # Static analysis
        elif 'thermal' in name_lower or 'heat' in name_lower:
            return 'SOL153'  # Thermal
        elif 'dynamic' in name_lower:
            return 'SOL111'  # Frequency response

        # Default to static
        return 'SOL101'

    def _discover_expressions_from_file(self, file_path: Path) -> List[Dict[str, Any]]:
        """Discover expressions from a part file."""
        # In real implementation, this would use NX Open API
        # For now, return common expression patterns for demo
        common_expressions = [
            {"name": "thickness", "value": 10.0, "unit": "mm", "type": "dimension"},
            {"name": "length", "value": 100.0, "unit": "mm", "type": "dimension"},
            {"name": "width", "value": 50.0, "unit": "mm", "type": "dimension"},
            {"name": "height", "value": 25.0, "unit": "mm", "type": "dimension"},
            {"name": "wall_thickness", "value": 2.0, "unit": "mm", "type": "dimension"},
            {"name": "rib_height", "value": 5.0, "unit": "mm", "type": "dimension"},
            {"name": "fillet_radius", "value": 3.0, "unit": "mm", "type": "dimension"},
            {"name": "hole_diameter", "value": 8.0, "unit": "mm", "type": "dimension"},
        ]

        # Filter to likely candidates based on file name
        return common_expressions

    def _suggest_extractors(self, solver_type: Optional[str]) -> List[Dict[str, Any]]:
        """Suggest extractors based on solver type."""
        extractors = [
            {"id": "E4", "name": "Mass (BDF)", "always": True},
            {"id": "E5", "name": "Mass (Expression)", "always": True},
        ]

        if solver_type == 'SOL101':
            extractors.extend([
                {"id": "E1", "name": "Displacement", "always": False},
                {"id": "E3", "name": "Stress", "always": False},
            ])
        elif solver_type == 'SOL103':
            extractors.extend([
                {"id": "E2", "name": "Frequency", "always": False},
            ])

        # Always include Zernike for mirrors
        extractors.extend([
            {"id": "E8", "name": "Zernike Coefficients", "always": False},
            {"id": "E9", "name": "Zernike RMS", "always": False},
            {"id": "E10", "name": "Zernike WFE", "always": False},
        ])

        return extractors

T5.2 - Create Introspection API Endpoint

File: atomizer-dashboard/backend/api/routes/nx.py

from fastapi import APIRouter, HTTPException
from api.services.nx_introspection import NXIntrospector

router = APIRouter(prefix="/api/nx", tags=["nx"])


@router.post("/introspect")
async def introspect_model(file_path: str):
    """Introspect an NX model file to discover expressions, solver type, and dependencies."""
    try:
        introspector = NXIntrospector(file_path)
        result = introspector.introspect()
        return result
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@router.get("/expressions")
async def get_expressions(file_path: str):
    """Get expressions from an NX model."""
    try:
        introspector = NXIntrospector(file_path)
        result = introspector.introspect()
        return {
            "expressions": result.get("expressions", []),
            "file_path": file_path,
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

T5.3 - Register NX Router

File: atomizer-dashboard/backend/api/main.py

Add:

from api.routes import nx

app.include_router(nx.router)

PHASE 6: Add Introspection Panel to Model Config

T6.1 - Create Introspection Panel Component

File: atomizer-dashboard/frontend/src/components/canvas/panels/IntrospectionPanel.tsx

import { useState, useEffect } from 'react';
import { X, Search, RefreshCw, Plus, ChevronDown, ChevronRight, FileBox, Cpu, FlaskConical, SlidersHorizontal } from 'lucide-react';
import { useCanvasStore } from '../../../hooks/useCanvasStore';

interface IntrospectionPanelProps {
  filePath: string;
  onClose: () => void;
}

interface IntrospectionResult {
  file_path: string;
  file_type: string;
  expressions: Array<{
    name: string;
    value: number;
    unit: string;
    type: string;
  }>;
  solver_type: string | null;
  dependent_files: Array<{
    path: string;
    type: string;
  }>;
  extractors_available: Array<{
    id: string;
    name: string;
    always: boolean;
  }>;
  warnings: string[];
}

export function IntrospectionPanel({ filePath, onClose }: IntrospectionPanelProps) {
  const [result, setResult] = useState<IntrospectionResult | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['expressions', 'extractors']));
  const [searchTerm, setSearchTerm] = useState('');

  const { addNode, nodes, edges } = useCanvasStore();

  const runIntrospection = async () => {
    setIsLoading(true);
    setError(null);
    try {
      const res = await fetch('/api/nx/introspect', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ file_path: filePath }),
      });
      if (!res.ok) throw new Error('Introspection failed');
      const data = await res.json();
      setResult(data);
    } catch (e) {
      setError('Failed to introspect model');
      console.error(e);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    if (filePath) {
      runIntrospection();
    }
  }, [filePath]);

  const toggleSection = (section: string) => {
    setExpandedSections(prev => {
      const next = new Set(prev);
      if (next.has(section)) next.delete(section);
      else next.add(section);
      return next;
    });
  };

  const addExpressionAsDesignVar = (expr: IntrospectionResult['expressions'][0]) => {
    // Find a good position (left of model node)
    const modelNode = nodes.find(n => n.data.type === 'model');
    const existingDvars = nodes.filter(n => n.data.type === 'designVar');

    const position = {
      x: (modelNode?.position.x || 300) - 250,
      y: (modelNode?.position.y || 100) + existingDvars.length * 100,
    };

    addNode('designVar', position, {
      label: expr.name,
      expressionName: expr.name,
      minValue: expr.value * 0.5,
      maxValue: expr.value * 1.5,
      unit: expr.unit,
      configured: true,
    });
  };

  const addExtractor = (extractor: IntrospectionResult['extractors_available'][0]) => {
    // Find a good position (right of solver node)
    const solverNode = nodes.find(n => n.data.type === 'solver');
    const existingExtractors = nodes.filter(n => n.data.type === 'extractor');

    const position = {
      x: (solverNode?.position.x || 400) + 200,
      y: (solverNode?.position.y || 100) + existingExtractors.length * 100,
    };

    addNode('extractor', position, {
      label: extractor.name,
      extractorId: extractor.id,
      extractorName: extractor.name,
      configured: true,
    });
  };

  const filteredExpressions = result?.expressions.filter(e =>
    e.name.toLowerCase().includes(searchTerm.toLowerCase())
  ) || [];

  return (
    <div className="bg-dark-850 border border-dark-700 rounded-xl w-80 max-h-[70vh] flex flex-col shadow-xl">
      {/* Header */}
      <div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
        <div className="flex items-center gap-2">
          <Search size={16} className="text-primary-400" />
          <span className="font-medium text-white text-sm">Model Introspection</span>
        </div>
        <div className="flex items-center gap-1">
          <button
            onClick={runIntrospection}
            disabled={isLoading}
            className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded"
          >
            <RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
          </button>
          <button onClick={onClose} className="p-1.5 text-dark-400 hover:text-white hover:bg-dark-700 rounded">
            <X size={14} />
          </button>
        </div>
      </div>

      {/* Search */}
      <div className="px-4 py-2 border-b border-dark-700">
        <input
          type="text"
          placeholder="Filter expressions..."
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          className="w-full px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
                     text-sm text-white placeholder-dark-500 focus:outline-none focus:border-primary-500"
        />
      </div>

      {/* Content */}
      <div className="flex-1 overflow-auto">
        {isLoading ? (
          <div className="flex items-center justify-center h-32 text-dark-500">
            <RefreshCw size={20} className="animate-spin mr-2" />
            Analyzing model...
          </div>
        ) : error ? (
          <div className="p-4 text-red-400 text-sm">{error}</div>
        ) : result ? (
          <div className="p-2 space-y-2">
            {/* Solver Type */}
            {result.solver_type && (
              <div className="p-2 bg-dark-800 rounded-lg">
                <div className="flex items-center gap-2 text-sm">
                  <Cpu size={14} className="text-violet-400" />
                  <span className="text-dark-300">Solver:</span>
                  <span className="text-white font-medium">{result.solver_type}</span>
                </div>
              </div>
            )}

            {/* Expressions Section */}
            <div className="border border-dark-700 rounded-lg overflow-hidden">
              <button
                onClick={() => toggleSection('expressions')}
                className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750"
              >
                <div className="flex items-center gap-2">
                  <SlidersHorizontal size={14} className="text-emerald-400" />
                  <span className="text-sm font-medium text-white">
                    Expressions ({filteredExpressions.length})
                  </span>
                </div>
                {expandedSections.has('expressions') ? (
                  <ChevronDown size={14} className="text-dark-400" />
                ) : (
                  <ChevronRight size={14} className="text-dark-400" />
                )}
              </button>

              {expandedSections.has('expressions') && (
                <div className="p-2 space-y-1">
                  {filteredExpressions.map((expr) => (
                    <div
                      key={expr.name}
                      className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group"
                    >
                      <div>
                        <p className="text-sm text-white">{expr.name}</p>
                        <p className="text-xs text-dark-500">
                          {expr.value} {expr.unit}
                        </p>
                      </div>
                      <button
                        onClick={() => addExpressionAsDesignVar(expr)}
                        className="p-1.5 text-dark-500 hover:text-primary-400 hover:bg-dark-700 rounded
                                   opacity-0 group-hover:opacity-100 transition-opacity"
                        title="Add as Design Variable"
                      >
                        <Plus size={14} />
                      </button>
                    </div>
                  ))}
                </div>
              )}
            </div>

            {/* Extractors Section */}
            <div className="border border-dark-700 rounded-lg overflow-hidden">
              <button
                onClick={() => toggleSection('extractors')}
                className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750"
              >
                <div className="flex items-center gap-2">
                  <FlaskConical size={14} className="text-cyan-400" />
                  <span className="text-sm font-medium text-white">
                    Available Extractors ({result.extractors_available.length})
                  </span>
                </div>
                {expandedSections.has('extractors') ? (
                  <ChevronDown size={14} className="text-dark-400" />
                ) : (
                  <ChevronRight size={14} className="text-dark-400" />
                )}
              </button>

              {expandedSections.has('extractors') && (
                <div className="p-2 space-y-1">
                  {result.extractors_available.map((ext) => (
                    <div
                      key={ext.id}
                      className="flex items-center justify-between p-2 bg-dark-850 rounded hover:bg-dark-750 group"
                    >
                      <div>
                        <p className="text-sm text-white">{ext.name}</p>
                        <p className="text-xs text-dark-500">{ext.id}</p>
                      </div>
                      <button
                        onClick={() => addExtractor(ext)}
                        className="p-1.5 text-dark-500 hover:text-primary-400 hover:bg-dark-700 rounded
                                   opacity-0 group-hover:opacity-100 transition-opacity"
                        title="Add Extractor"
                      >
                        <Plus size={14} />
                      </button>
                    </div>
                  ))}
                </div>
              )}
            </div>

            {/* Dependent Files */}
            {result.dependent_files.length > 0 && (
              <div className="border border-dark-700 rounded-lg overflow-hidden">
                <button
                  onClick={() => toggleSection('files')}
                  className="w-full flex items-center justify-between px-3 py-2 bg-dark-800 hover:bg-dark-750"
                >
                  <div className="flex items-center gap-2">
                    <FileBox size={14} className="text-amber-400" />
                    <span className="text-sm font-medium text-white">
                      Dependent Files ({result.dependent_files.length})
                    </span>
                  </div>
                  {expandedSections.has('files') ? (
                    <ChevronDown size={14} className="text-dark-400" />
                  ) : (
                    <ChevronRight size={14} className="text-dark-400" />
                  )}
                </button>

                {expandedSections.has('files') && (
                  <div className="p-2 space-y-1">
                    {result.dependent_files.map((file) => (
                      <div
                        key={file.path}
                        className="flex items-center gap-2 p-2 bg-dark-850 rounded"
                      >
                        <FileBox size={14} className="text-dark-400" />
                        <div className="flex-1 min-w-0">
                          <p className="text-sm text-white truncate">{file.path.split('/').pop()}</p>
                          <p className="text-xs text-dark-500">{file.type}</p>
                        </div>
                      </div>
                    ))}
                  </div>
                )}
              </div>
            )}

            {/* Warnings */}
            {result.warnings.length > 0 && (
              <div className="p-2 bg-amber-500/10 border border-amber-500/30 rounded-lg">
                <p className="text-xs text-amber-400 font-medium mb-1">Warnings</p>
                {result.warnings.map((w, i) => (
                  <p key={i} className="text-xs text-amber-300">{w}</p>
                ))}
              </div>
            )}
          </div>
        ) : (
          <div className="flex items-center justify-center h-32 text-dark-500 text-sm">
            Select a model to introspect
          </div>
        )}
      </div>
    </div>
  );
}

PHASE 7: Integrate Into NodeConfigPanel

T7.1 - Update Model Node Config

File: atomizer-dashboard/frontend/src/components/canvas/panels/NodeConfigPanel.tsx

Add file browser and introspection button to Model node configuration:

// Add to imports
import { FileBrowser } from './FileBrowser';
import { IntrospectionPanel } from './IntrospectionPanel';
import { FolderSearch, Microscope } from 'lucide-react';

// In the component, add state
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [showIntrospection, setShowIntrospection] = useState(false);

// In the Model node config section, add:
{selectedNode.data.type === 'model' && (
  <div className="space-y-3">
    {/* File Path with Browse Button */}
    <div>
      <label className="block text-xs font-medium text-dark-400 mb-1">
        Model File
      </label>
      <div className="flex gap-2">
        <input
          type="text"
          value={selectedNode.data.filePath || ''}
          onChange={(e) => updateData('filePath', e.target.value)}
          placeholder="path/to/model.sim"
          className="flex-1 px-3 py-1.5 bg-dark-800 border border-dark-600 rounded-lg
                     text-sm text-white placeholder-dark-500"
        />
        <button
          onClick={() => setShowFileBrowser(true)}
          className="px-2 py-1.5 bg-dark-700 hover:bg-dark-600 rounded-lg text-dark-300 hover:text-white"
          title="Browse files"
        >
          <FolderSearch size={16} />
        </button>
      </div>
    </div>

    {/* Introspect Button */}
    {selectedNode.data.filePath && (
      <button
        onClick={() => setShowIntrospection(true)}
        className="w-full flex items-center justify-center gap-2 px-3 py-2 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>
    )}

    {/* File Type Display */}
    {selectedNode.data.fileType && (
      <div className="text-xs text-dark-500">
        Type: {selectedNode.data.fileType.toUpperCase()}
      </div>
    )}
  </div>
)}

// Add dialogs at the end of the component
{showFileBrowser && (
  <FileBrowser
    isOpen={showFileBrowser}
    onClose={() => setShowFileBrowser(false)}
    onSelect={(path) => {
      updateData('filePath', path);
      updateData('fileType', path.split('.').pop());
      setShowFileBrowser(false);
    }}
    fileTypes={['.sim', '.prt', '.fem', '.afem']}
  />
)}

{showIntrospection && selectedNode.data.filePath && (
  <div className="fixed top-20 right-4 z-40">
    <IntrospectionPanel
      filePath={selectedNode.data.filePath}
      onClose={() => setShowIntrospection(false)}
    />
  </div>
)}

PHASE 8: Build, Test, and Commit

T8.1 - Build Frontend

cd atomizer-dashboard/frontend
npm run build

T8.2 - Test Backend

cd atomizer-dashboard/backend
python -c "
from api.services.conversation_store import ConversationStore
from api.services.nx_introspection import NXIntrospector

# Test DB
store = ConversationStore()
session = store.get_or_create_session('test')
print('DB OK:', session)

# Test introspection
# introspector = NXIntrospector('test.sim')
print('Backend modules OK')
"

T8.3 - Git Commit

git add .
git commit -m "feat: Canvas V4 - Model introspection and Claude integration fixes

## Critical Bug Fixes
- Fix Claude bot SQL error with robust database initialization
- Fix WebSocket chat connection with proper error handling and reconnect
- Add health check endpoint for database status

## Model Introspection
- Add NX model introspection service (expressions, solver type, dependencies)
- Create Introspection Panel with collapsible sections
- Add File Browser component for .sim/.prt/.fem/.afem selection
- One-click add expressions as Design Variables
- One-click add suggested Extractors

## Connection Flow Fix
- Update node handles: expressions flow INTO model (not out)
- Update validation for new data flow direction
- Multiple input handles on Model node for design variables

## Backend Additions
- /api/files/list - Directory browser with type filtering
- /api/nx/introspect - Model introspection endpoint
- /api/nx/expressions - Expression discovery endpoint

## Frontend Improvements
- FileBrowser: File picker with search and type filtering
- IntrospectionPanel: Shows expressions, extractors, dependencies
- NodeConfigPanel: Integrated file browser and introspection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"

git push origin main && git push github main

ACCEPTANCE CRITERIA

Critical Fixes

  • No SQL error when clicking Validate
  • Chat panel connects and responds
  • WebSocket reconnects on disconnect

File Browser

  • Browse button opens file picker
  • Can navigate directories
  • Filters to .sim/.prt/.fem/.afem
  • Selecting file populates path

Model Introspection

  • "Introspect Model" button works
  • Shows discovered expressions
  • Shows available extractors
  • Shows dependent files
  • Can add expression as Design Variable
  • Can add suggested Extractor

Connection Flow

  • Design Variables connect TO Model (not from)
  • Validation checks correct flow direction

Build

  • npm run build passes
  • Backend imports work

BEGIN EXECUTION

Execute all 8 phases sequentially. Use TodoWrite for every task. Complete fully before moving to next phase. Do not stop.

GO.