From 5c660ff270b8f7795ac58f2015d7a0137287ba0d Mon Sep 17 00:00:00 2001 From: Antoine Date: Fri, 5 Dec 2025 12:56:34 -0500 Subject: [PATCH] feat: Add session management and global Claude terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 - Accurate study status detection: - Add is_optimization_running() to check for active processes - Add get_accurate_study_status() with proper status logic - Status now: not_started, running, paused, completed - Add "paused" status styling (orange) to Home page Phase 2 - Global Claude terminal: - Create ClaudeTerminalContext for app-level state - Create GlobalClaudeTerminal floating component - Terminal persists across page navigation - Shows green indicator when connected - Remove inline terminal from Dashboard 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../backend/api/routes/optimization.py | 157 ++++++++++++++++-- atomizer-dashboard/frontend/src/App.tsx | 31 ++-- .../src/components/ClaudeTerminal.tsx | 20 ++- .../src/components/GlobalClaudeTerminal.tsx | 50 ++++++ .../src/context/ClaudeTerminalContext.tsx | 42 +++++ .../frontend/src/pages/Dashboard.tsx | 37 ++--- .../frontend/src/pages/Home.tsx | 9 + atomizer-dashboard/frontend/vite.config.ts | 2 +- 8 files changed, 292 insertions(+), 56 deletions(-) create mode 100644 atomizer-dashboard/frontend/src/components/GlobalClaudeTerminal.tsx create mode 100644 atomizer-dashboard/frontend/src/context/ClaudeTerminalContext.tsx diff --git a/atomizer-dashboard/backend/api/routes/optimization.py b/atomizer-dashboard/backend/api/routes/optimization.py index 69c37923..11188766 100644 --- a/atomizer-dashboard/backend/api/routes/optimization.py +++ b/atomizer-dashboard/backend/api/routes/optimization.py @@ -34,6 +34,63 @@ def get_results_dir(study_dir: Path) -> Path: return results_dir +def is_optimization_running(study_id: str) -> bool: + """Check if an optimization process is currently running for a study. + + Looks for Python processes running run_optimization.py with the study_id in the command line. + """ + study_dir = STUDIES_DIR / study_id + + for proc in psutil.process_iter(['pid', 'name', 'cmdline', 'cwd']): + try: + cmdline = proc.info.get('cmdline') or [] + cmdline_str = ' '.join(cmdline) if cmdline else '' + + # Check if this is a Python process running run_optimization.py for this study + if 'python' in cmdline_str.lower() and 'run_optimization' in cmdline_str: + if study_id in cmdline_str or str(study_dir) in cmdline_str: + return True + except (psutil.NoSuchProcess, psutil.AccessDenied): + continue + + return False + + +def get_accurate_study_status(study_id: str, trial_count: int, total_trials: int, has_db: bool) -> str: + """Determine accurate study status based on multiple factors. + + Status can be: + - not_started: No database or 0 trials + - running: Active process found + - paused: Has trials but no active process and not completed + - completed: Reached trial target + - failed: Has error indicators (future enhancement) + + Args: + study_id: The study identifier + trial_count: Number of completed trials + total_trials: Target number of trials from config + has_db: Whether the study database exists + + Returns: + Status string: "not_started", "running", "paused", or "completed" + """ + # No database or no trials = not started + if not has_db or trial_count == 0: + return "not_started" + + # Check if we've reached the target + if trial_count >= total_trials: + return "completed" + + # Check if process is actively running + if is_optimization_running(study_id): + return "running" + + # Has trials but not running and not complete = paused + return "paused" + + @router.get("/studies") async def list_studies(): """List all available optimization studies""" @@ -66,12 +123,13 @@ async def list_studies(): study_db = results_dir / "study.db" history_file = results_dir / "optimization_history_incremental.json" - status = "not_started" trial_count = 0 best_value = None + has_db = False # Protocol 10: Read from Optuna SQLite database if study_db.exists(): + has_db = True try: # Use timeout to avoid blocking on locked databases conn = sqlite3.connect(str(study_db), timeout=2.0) @@ -97,19 +155,12 @@ async def list_studies(): conn.close() - # Determine status - total_trials = config.get('optimization_settings', {}).get('n_trials', 50) - if trial_count >= total_trials: - status = "completed" - else: - status = "running" # Simplified - would need process check - except Exception as e: print(f"Warning: Failed to read Optuna database for {study_dir.name}: {e}") - status = "error" # Legacy: Read from JSON history elif history_file.exists(): + has_db = True with open(history_file) as f: history = json.load(f) trial_count = len(history) @@ -118,19 +169,15 @@ async def list_studies(): best_trial = min(history, key=lambda x: x['objective']) best_value = best_trial['objective'] - # Determine status - total_trials = config.get('trials', {}).get('n_trials', 50) - if trial_count >= total_trials: - status = "completed" - else: - status = "running" # Simplified - would need process check - # Get total trials from config (supports both formats) total_trials = ( config.get('optimization_settings', {}).get('n_trials') or config.get('trials', {}).get('n_trials', 50) ) + # Get accurate status using process detection + status = get_accurate_study_status(study_dir.name, trial_count, total_trials, has_db) + # Get creation date from directory or config modification time created_at = None try: @@ -240,7 +287,7 @@ async def get_study_status(study_id: str): conn.close() total_trials = config.get('optimization_settings', {}).get('n_trials', 50) - status = "completed" if trial_count >= total_trials else "running" + status = get_accurate_study_status(study_id, trial_count, total_trials, True) return { "study_id": study_id, @@ -639,6 +686,82 @@ async def get_pareto_front(study_id: str): except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to get Pareto front: {str(e)}") + +@router.get("/studies/{study_id}/nn-pareto-front") +async def get_nn_pareto_front(study_id: str): + """Get NN surrogate Pareto front from nn_pareto_front.json""" + try: + study_dir = STUDIES_DIR / study_id + results_dir = get_results_dir(study_dir) + nn_pareto_file = results_dir / "nn_pareto_front.json" + + if not nn_pareto_file.exists(): + return {"has_nn_results": False, "pareto_front": []} + + with open(nn_pareto_file) as f: + nn_pareto = json.load(f) + + # Transform to match Trial interface format + transformed = [] + for trial in nn_pareto: + transformed.append({ + "trial_number": trial.get("trial_number"), + "values": [trial.get("mass"), trial.get("frequency")], + "params": trial.get("params", {}), + "user_attrs": { + "source": "NN", + "feasible": trial.get("feasible", False), + "predicted_stress": trial.get("predicted_stress"), + "predicted_displacement": trial.get("predicted_displacement"), + "mass": trial.get("mass"), + "frequency": trial.get("frequency") + }, + "constraint_satisfied": trial.get("feasible", False), + "source": "NN" + }) + + return { + "has_nn_results": True, + "pareto_front": transformed, + "count": len(transformed) + } + + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Study {study_id} not found") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get NN Pareto front: {str(e)}") + + +@router.get("/studies/{study_id}/nn-state") +async def get_nn_optimization_state(study_id: str): + """Get NN optimization state/summary from nn_optimization_state.json""" + try: + study_dir = STUDIES_DIR / study_id + results_dir = get_results_dir(study_dir) + nn_state_file = results_dir / "nn_optimization_state.json" + + if not nn_state_file.exists(): + return {"has_nn_state": False} + + with open(nn_state_file) as f: + state = json.load(f) + + return { + "has_nn_state": True, + "total_fea_count": state.get("total_fea_count", 0), + "total_nn_count": state.get("total_nn_count", 0), + "pareto_front_size": state.get("pareto_front_size", 0), + "best_mass": state.get("best_mass"), + "best_frequency": state.get("best_frequency"), + "timestamp": state.get("timestamp") + } + + except FileNotFoundError: + raise HTTPException(status_code=404, detail=f"Study {study_id} not found") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to get NN state: {str(e)}") + + @router.post("/studies") async def create_study( config: str = Form(...), diff --git a/atomizer-dashboard/frontend/src/App.tsx b/atomizer-dashboard/frontend/src/App.tsx index dbb3735f..11d29888 100644 --- a/atomizer-dashboard/frontend/src/App.tsx +++ b/atomizer-dashboard/frontend/src/App.tsx @@ -1,7 +1,9 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { StudyProvider } from './context/StudyContext'; +import { ClaudeTerminalProvider } from './context/ClaudeTerminalContext'; import { MainLayout } from './components/layout/MainLayout'; +import { GlobalClaudeTerminal } from './components/GlobalClaudeTerminal'; import Home from './pages/Home'; import Dashboard from './pages/Dashboard'; import Results from './pages/Results'; @@ -19,19 +21,24 @@ function App() { return ( - - - {/* Home page - no sidebar layout */} - } /> + + + + {/* Home page - no sidebar layout */} + } /> - {/* Study pages - with sidebar layout */} - }> - } /> - } /> - } /> - - - + {/* Study pages - with sidebar layout */} + }> + } /> + } /> + } /> + + + + {/* Global Claude Terminal - persists across navigation */} + + + ); diff --git a/atomizer-dashboard/frontend/src/components/ClaudeTerminal.tsx b/atomizer-dashboard/frontend/src/components/ClaudeTerminal.tsx index ef0c027d..e2321e34 100644 --- a/atomizer-dashboard/frontend/src/components/ClaudeTerminal.tsx +++ b/atomizer-dashboard/frontend/src/components/ClaudeTerminal.tsx @@ -13,6 +13,7 @@ import { FolderOpen } from 'lucide-react'; import { useStudy } from '../context/StudyContext'; +import { useClaudeTerminal } from '../context/ClaudeTerminalContext'; interface ClaudeTerminalProps { isExpanded?: boolean; @@ -26,17 +27,24 @@ export const ClaudeTerminal: React.FC = ({ onClose }) => { const { selectedStudy } = useStudy(); + const { setIsConnected: setGlobalConnected } = useClaudeTerminal(); const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const wsRef = useRef(null); - const [isConnected, setIsConnected] = useState(false); + const [isConnected, setIsConnectedLocal] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [_error, setError] = useState(null); const [cliAvailable, setCliAvailable] = useState(null); const [contextSet, setContextSet] = useState(false); const [settingContext, setSettingContext] = useState(false); + // Sync local connection state to global context + const setIsConnected = useCallback((connected: boolean) => { + setIsConnectedLocal(connected); + setGlobalConnected(connected); + }, [setGlobalConnected]); + // Check CLI availability useEffect(() => { fetch('/api/terminal/status') @@ -251,9 +259,13 @@ export const ClaudeTerminal: React.FC = ({ if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !selectedStudy?.id) return; setSettingContext(true); - // Send context message - Claude should use CLAUDE.md and .claude/skills/ for guidance - const contextMessage = `Context: Working on study "${selectedStudy.id}" at studies/${selectedStudy.id}/. ` + - `Read .claude/skills/ for task protocols. Use atomizer conda env. Acknowledge briefly.`; + // Send context message with POS bootstrap instructions and study context + const contextMessage = + `You are helping with Atomizer optimization. ` + + `First read: .claude/skills/00_BOOTSTRAP.md for task routing. ` + + `Then follow the Protocol Execution Framework. ` + + `Study context: Working on "${selectedStudy.id}" at studies/${selectedStudy.id}/. ` + + `Use atomizer conda env. Acknowledge briefly.`; wsRef.current.send(JSON.stringify({ type: 'input', data: contextMessage + '\n' })); // Mark as done after Claude has had time to process diff --git a/atomizer-dashboard/frontend/src/components/GlobalClaudeTerminal.tsx b/atomizer-dashboard/frontend/src/components/GlobalClaudeTerminal.tsx new file mode 100644 index 00000000..b4718d84 --- /dev/null +++ b/atomizer-dashboard/frontend/src/components/GlobalClaudeTerminal.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { useClaudeTerminal } from '../context/ClaudeTerminalContext'; +import { ClaudeTerminal } from './ClaudeTerminal'; +import { Terminal } from 'lucide-react'; + +/** + * GlobalClaudeTerminal - A floating terminal that persists across page navigation + * + * This component renders at the App level and maintains the Claude Code session + * even when the user navigates between pages. It can be minimized to a floating + * button or expanded to a side panel. + */ +export const GlobalClaudeTerminal: React.FC = () => { + const { isOpen, setIsOpen, isExpanded, setIsExpanded, isConnected } = useClaudeTerminal(); + + // Floating button when terminal is closed + if (!isOpen) { + return ( + + ); + } + + // Terminal panel + return ( +
+ setIsExpanded(!isExpanded)} + onClose={() => setIsOpen(false)} + /> +
+ ); +}; diff --git a/atomizer-dashboard/frontend/src/context/ClaudeTerminalContext.tsx b/atomizer-dashboard/frontend/src/context/ClaudeTerminalContext.tsx new file mode 100644 index 00000000..82dcb0a2 --- /dev/null +++ b/atomizer-dashboard/frontend/src/context/ClaudeTerminalContext.tsx @@ -0,0 +1,42 @@ +import React, { createContext, useContext, useState, ReactNode } from 'react'; + +interface ClaudeTerminalContextType { + // Terminal visibility state + isOpen: boolean; + setIsOpen: (open: boolean) => void; + isExpanded: boolean; + setIsExpanded: (expanded: boolean) => void; + + // Connection state (updated by the terminal component) + isConnected: boolean; + setIsConnected: (connected: boolean) => void; +} + +const ClaudeTerminalContext = createContext(undefined); + +export const ClaudeTerminalProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const [isOpen, setIsOpen] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + const [isConnected, setIsConnected] = useState(false); + + return ( + + {children} + + ); +}; + +export const useClaudeTerminal = () => { + const context = useContext(ClaudeTerminalContext); + if (context === undefined) { + throw new Error('useClaudeTerminal must be used within a ClaudeTerminalProvider'); + } + return context; +}; diff --git a/atomizer-dashboard/frontend/src/pages/Dashboard.tsx b/atomizer-dashboard/frontend/src/pages/Dashboard.tsx index 4090b5e3..9097294b 100644 --- a/atomizer-dashboard/frontend/src/pages/Dashboard.tsx +++ b/atomizer-dashboard/frontend/src/pages/Dashboard.tsx @@ -4,9 +4,9 @@ import { Terminal } from 'lucide-react'; import { useOptimizationWebSocket } from '../hooks/useWebSocket'; import { apiClient } from '../api/client'; import { useStudy } from '../context/StudyContext'; +import { useClaudeTerminal } from '../context/ClaudeTerminalContext'; import { Card } from '../components/common/Card'; import { ControlPanel } from '../components/dashboard/ControlPanel'; -import { ClaudeTerminal } from '../components/ClaudeTerminal'; import { ParetoPlot } from '../components/ParetoPlot'; import { ParallelCoordinatesPlot } from '../components/ParallelCoordinatesPlot'; import { ParameterImportanceChart } from '../components/ParameterImportanceChart'; @@ -64,9 +64,8 @@ export default function Dashboard() { // Chart library toggle: 'recharts' (faster) or 'plotly' (more interactive but slower) const [chartLibrary, setChartLibrary] = useState<'plotly' | 'recharts'>('recharts'); - // Claude chat panel state - const [chatOpen, setChatOpen] = useState(false); - const [chatExpanded, setChatExpanded] = useState(false); + // Claude terminal from global context + const { isOpen: claudeTerminalOpen, setIsOpen: setClaudeTerminalOpen, isConnected: claudeConnected } = useClaudeTerminal(); const showAlert = (type: 'success' | 'warning', message: string) => { const id = alertIdCounter; @@ -353,16 +352,21 @@ export default function Dashboard() {
{/* Claude Code Terminal Toggle Button */} {selectedStudyId && ( @@ -417,10 +421,10 @@ export default function Dashboard() {
- {/* Main Layout: Charts + Claude Terminal */} -
+ {/* Main Layout: Charts (Claude Terminal is now global/floating) */} +
{/* Main Content - Charts stacked vertically */} -
+
{/* Study Name Header + Metrics in one row */}
@@ -790,17 +794,6 @@ export default function Dashboard() { />
- - {/* Claude Code Terminal - Right Sidebar (wider for better visibility) */} - {chatOpen && ( - - )}
); diff --git a/atomizer-dashboard/frontend/src/pages/Home.tsx b/atomizer-dashboard/frontend/src/pages/Home.tsx index e6b5c891..f5504752 100644 --- a/atomizer-dashboard/frontend/src/pages/Home.tsx +++ b/atomizer-dashboard/frontend/src/pages/Home.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { FolderOpen, Play, + Pause, CheckCircle, Clock, AlertCircle, @@ -64,6 +65,8 @@ const Home: React.FC = () => { switch (status) { case 'running': return ; + case 'paused': + return ; case 'completed': return ; case 'not_started': @@ -81,6 +84,12 @@ const Home: React.FC = () => { card: 'border-green-500/30 hover:border-green-500/50', glow: 'shadow-green-500/10' }; + case 'paused': + return { + badge: 'bg-orange-500/20 text-orange-400 border-orange-500/30', + card: 'border-orange-500/30 hover:border-orange-500/50', + glow: 'shadow-orange-500/10' + }; case 'completed': return { badge: 'bg-blue-500/20 text-blue-400 border-blue-500/30', diff --git a/atomizer-dashboard/frontend/vite.config.ts b/atomizer-dashboard/frontend/vite.config.ts index 7af253fd..248ad643 100644 --- a/atomizer-dashboard/frontend/vite.config.ts +++ b/atomizer-dashboard/frontend/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ strictPort: false, // Allow fallback to next available port proxy: { '/api': { - target: 'http://127.0.0.1:8001', // Use 127.0.0.1 instead of localhost + target: 'http://127.0.0.1:8000', // Use 127.0.0.1 instead of localhost changeOrigin: true, secure: false, ws: true,