Files
Atomizer/docs/plans/RALPH_LOOP_CANVAS_V3.md
Anto01 ac5e9b4054 docs: Comprehensive documentation update for Dashboard V3 and Canvas
## Documentation Updates
- DASHBOARD.md: Updated to V3.0 with Canvas V3 features, file browser, introspection
- DASHBOARD_IMPLEMENTATION_STATUS.md: Marked Canvas V3 features as COMPLETE
- CANVAS.md: New comprehensive guide for Canvas Builder V3 with all features
- CLAUDE.md: Added dashboard quick reference and Canvas V3 features

## Canvas V3 Features Documented
- File Browser: Browse studies directory for model files
- Model Introspection: Auto-discover expressions, solver type, dependencies
- One-Click Add: Add expressions as design variables instantly
- Claude Bug Fixes: WebSocket reconnection, SQL errors resolved
- Health Check: /api/health endpoint for monitoring

## Backend Services
- NX introspection service with expression discovery
- File browser API with type filtering
- Claude session management improvements
- Context builder enhancements

## Frontend Components
- FileBrowser: Modal for file selection with search
- IntrospectionPanel: View discovered model information
- ExpressionSelector: Dropdown for design variable configuration
- Improved chat hooks with reconnection logic

## Plan Documents
- Added RALPH_LOOP_CANVAS_V2/V3 implementation records
- Added ATOMIZER_DASHBOARD_V2_MASTER_PLAN
- Added investigation and sync documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 20:48:58 -05:00

1252 lines
41 KiB
Markdown

# Ralph Loop: Canvas V3 - Complete Fix & UX Enhancement
**Purpose:** Fix all Canvas bugs AND implement proper study management workflow
**Execution:** Autonomous, all phases sequential, no stopping
**Estimated Duration:** 15 work units
---
## Launch Command
```powershell
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 and enhance the Atomizer Canvas Builder with proper study management.
## Problem Summary
### Bugs to Fix
1. **Atomizer Assistant broken** - Error when opening chat panel
2. **Cannot delete connections** - No way to remove edges
3. **Drag & drop wrong position** - Nodes appear at wrong location
4. **Auto-connect missing** - Loading study doesn't create edges
5. **Missing elements on load** - Extractors, constraints not loaded
6. **Algorithm not pre-selected** - Algorithm node missing from load
7. **Interface too small** - Canvas not using full screen
8. **Contrast issues** - White on blue hard to read
### Features to Add
1. **Study context awareness** - Know which study is active
2. **New Study workflow** - Create studies from scratch
3. **Process dialog** - Choose update vs. create new study
4. **Home page study browser** - List all studies, quick actions
5. **Context-aware Canvas header** - Show current study name
## 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. **No questions** - Make reasonable decisions based on codebase
5. **Commit per phase** - Git commit after each major phase
6. **Read before edit** - Always read files before modifying
---
# PHASE 1: Critical Bug Fixes (Same as before)
## T1.1 - Fix Atomizer Assistant Error
Read and fix:
```
Read: atomizer-dashboard/frontend/src/components/canvas/panels/ChatPanel.tsx
Read: atomizer-dashboard/frontend/src/hooks/useCanvasChat.ts
```
Add error handling with reconnect button. Wrap in try/catch.
## T1.2 - Enable Connection Deletion
In `AtomizerCanvas.tsx`:
- Add `onEdgeClick` handler to select edge
- Add keyboard listener for Delete/Backspace
- Add `selectedEdge` state
In `useCanvasStore.ts`:
- Add `deleteEdge(edgeId)` action
## T1.3 - Fix Drag & Drop Positioning
Fix `onDrop` in AtomizerCanvas.tsx to use `screenToFlowPosition` correctly with screen coordinates.
---
# PHASE 2: Complete Data Loading Fix
## T2.1 - Rewrite loadFromConfig
Complete rewrite of `useCanvasStore.ts` `loadFromConfig` to:
1. Create ALL node types (model, solver, dvars, extractors, objectives, constraints, algorithm, surrogate)
2. Create ALL edges (model→solver, solver→extractors, extractors→objectives, extractors→constraints, objectives→algorithm)
3. Proper column layout (model/dvar: 50, solver: 250, extractor: 450, obj/con: 650, algo: 850)
4. Parse all config fields including constraints and optimization.sampler
---
# PHASE 3: UI/UX Polish
## T3.1 - Full-Screen Responsive Canvas
Update `CanvasView.tsx` with minimal header and flex-1 canvas area.
## T3.2 - Fix Contrast
Ensure all nodes use `bg-dark-850` with white/light text. No white on light blue.
## T3.3 - Increase Font Sizes
Update BaseNode, NodePalette, NodeConfigPanel for larger, readable fonts.
---
# PHASE 4: Study Context System (NEW)
## T4.1 - Create Study Context
**File:** `atomizer-dashboard/frontend/src/contexts/StudyContext.tsx`
```tsx
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
interface Study {
path: string;
name: string;
category: string;
status: 'not_started' | 'running' | 'paused' | 'completed';
trialCount: number;
maxTrials: number;
bestValue?: number;
hasConfig: boolean;
}
interface StudyContextState {
currentStudy: Study | null;
isCreatingNew: boolean;
pendingStudyName: string | null;
pendingCategory: string | null;
studies: Study[];
categories: string[];
isLoading: boolean;
selectStudy: (studyPath: string) => Promise<void>;
startNewStudy: (name: string, category: string) => void;
confirmNewStudy: () => Promise<Study>;
cancelNewStudy: () => void;
closeStudy: () => void;
refreshStudies: () => Promise<void>;
}
const StudyContext = createContext<StudyContextState | null>(null);
export function StudyProvider({ children }: { children: React.ReactNode }) {
const [currentStudy, setCurrentStudy] = useState<Study | null>(null);
const [isCreatingNew, setIsCreatingNew] = useState(false);
const [pendingStudyName, setPendingStudyName] = useState<string | null>(null);
const [pendingCategory, setPendingCategory] = useState<string | null>(null);
const [studies, setStudies] = useState<Study[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const refreshStudies = useCallback(async () => {
setIsLoading(true);
try {
const res = await fetch('/api/studies');
if (res.ok) {
const data = await res.json();
setStudies(data.studies || []);
setCategories(data.categories || []);
}
} catch (err) {
console.error('Failed to fetch studies:', err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
refreshStudies();
}, [refreshStudies]);
const selectStudy = useCallback(async (studyPath: string) => {
try {
const res = await fetch(`/api/studies/${encodeURIComponent(studyPath)}`);
if (res.ok) {
const study = await res.json();
setCurrentStudy(study);
setIsCreatingNew(false);
setPendingStudyName(null);
setPendingCategory(null);
}
} catch (err) {
console.error('Failed to select study:', err);
}
}, []);
const startNewStudy = useCallback((name: string, category: string) => {
setIsCreatingNew(true);
setPendingStudyName(name);
setPendingCategory(category);
setCurrentStudy(null);
}, []);
const confirmNewStudy = useCallback(async (): Promise<Study> => {
if (!pendingStudyName || !pendingCategory) {
throw new Error('No pending study to confirm');
}
const res = await fetch('/api/studies', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: pendingStudyName,
category: pendingCategory,
}),
});
if (!res.ok) {
throw new Error('Failed to create study');
}
const newStudy = await res.json();
setCurrentStudy(newStudy);
setIsCreatingNew(false);
setPendingStudyName(null);
setPendingCategory(null);
await refreshStudies();
return newStudy;
}, [pendingStudyName, pendingCategory, refreshStudies]);
const cancelNewStudy = useCallback(() => {
setIsCreatingNew(false);
setPendingStudyName(null);
setPendingCategory(null);
}, []);
const closeStudy = useCallback(() => {
setCurrentStudy(null);
setIsCreatingNew(false);
setPendingStudyName(null);
setPendingCategory(null);
}, []);
return (
<StudyContext.Provider
value={{
currentStudy,
isCreatingNew,
pendingStudyName,
pendingCategory,
studies,
categories,
isLoading,
selectStudy,
startNewStudy,
confirmNewStudy,
cancelNewStudy,
closeStudy,
refreshStudies,
}}
>
{children}
</StudyContext.Provider>
);
}
export function useStudyContext() {
const ctx = useContext(StudyContext);
if (!ctx) throw new Error('useStudyContext must be used within StudyProvider');
return ctx;
}
```
## T4.2 - Update App.tsx to Include Provider
```tsx
import { StudyProvider } from './contexts/StudyContext';
function App() {
return (
<StudyProvider>
{/* ... rest of app */}
</StudyProvider>
);
}
```
---
# PHASE 5: Home Page Study Browser (NEW)
## T5.1 - Create Study Browser Component
**File:** `atomizer-dashboard/frontend/src/components/StudyBrowser.tsx`
```tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStudyContext } from '../contexts/StudyContext';
import {
FolderOpen,
Play,
Pause,
CheckCircle,
Circle,
Plus,
Search,
LayoutDashboard,
FileBarChart,
Workflow
} from 'lucide-react';
export function StudyBrowser() {
const navigate = useNavigate();
const { studies, categories, isLoading, selectStudy, refreshStudies } = useStudyContext();
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [showNewStudyDialog, setShowNewStudyDialog] = useState(false);
const filteredStudies = studies.filter((study) => {
const matchesSearch = study.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = !selectedCategory || study.category === selectedCategory;
return matchesSearch && matchesCategory;
});
const getStatusIcon = (status: string) => {
switch (status) {
case 'running':
return <Play size={14} className="text-green-400 fill-green-400" />;
case 'paused':
return <Pause size={14} className="text-amber-400" />;
case 'completed':
return <CheckCircle size={14} className="text-blue-400" />;
default:
return <Circle size={14} className="text-dark-500" />;
}
};
const handleStudyAction = async (study: any, action: 'dashboard' | 'canvas' | 'results') => {
await selectStudy(study.path);
navigate(`/${action}`);
};
return (
<div className="h-full flex flex-col bg-dark-900 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-white">Studies</h1>
<button
onClick={() => setShowNewStudyDialog(true)}
className="flex items-center gap-2 px-4 py-2 bg-primary-500 hover:bg-primary-600
text-white rounded-lg font-medium transition-colors"
>
<Plus size={18} />
New Study
</button>
</div>
{/* Search & Filter */}
<div className="flex items-center gap-4 mb-6">
<div className="relative flex-1 max-w-md">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500" />
<input
type="text"
placeholder="Search studies..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 bg-dark-800 border border-dark-700 rounded-lg
text-white placeholder-dark-500 focus:outline-none focus:border-primary-500"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedCategory(null)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
${!selectedCategory ? 'bg-primary-500/20 text-primary-400' : 'text-dark-400 hover:text-white'}`}
>
All
</button>
{categories.map((cat) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors
${selectedCategory === cat ? 'bg-primary-500/20 text-primary-400' : 'text-dark-400 hover:text-white'}`}
>
{cat}
</button>
))}
</div>
</div>
{/* Study List */}
<div className="flex-1 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32 text-dark-500">
Loading studies...
</div>
) : filteredStudies.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-dark-500">
<FolderOpen size={32} className="mb-2" />
<p>No studies found</p>
</div>
) : (
<div className="grid gap-3">
{filteredStudies.map((study) => (
<div
key={study.path}
className="bg-dark-800 border border-dark-700 rounded-xl p-4
hover:border-dark-600 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{getStatusIcon(study.status)}
<div>
<h3 className="font-semibold text-white">{study.name}</h3>
<p className="text-sm text-dark-400">
{study.category} {study.trialCount}/{study.maxTrials} trials
{study.bestValue !== undefined && ` • Best: ${study.bestValue.toFixed(2)}`}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => handleStudyAction(study, 'dashboard')}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg"
title="Dashboard"
>
<LayoutDashboard size={18} />
</button>
<button
onClick={() => handleStudyAction(study, 'canvas')}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg"
title="Canvas"
>
<Workflow size={18} />
</button>
<button
onClick={() => handleStudyAction(study, 'results')}
className="p-2 text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg"
title="Results"
>
<FileBarChart size={18} />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* New Study Dialog */}
{showNewStudyDialog && (
<NewStudyDialog onClose={() => setShowNewStudyDialog(false)} />
)}
</div>
);
}
```
## T5.2 - Create New Study Dialog
**File:** `atomizer-dashboard/frontend/src/components/NewStudyDialog.tsx`
```tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStudyContext } from '../contexts/StudyContext';
import { X, LayoutTemplate, Copy, FileBox } from 'lucide-react';
interface Props {
onClose: () => void;
}
export function NewStudyDialog({ onClose }: Props) {
const navigate = useNavigate();
const { categories, studies, startNewStudy } = useStudyContext();
const [name, setName] = useState('');
const [category, setCategory] = useState(categories[0] || 'General');
const [newCategory, setNewCategory] = useState('');
const [startWith, setStartWith] = useState<'blank' | 'template' | 'copy'>('blank');
const [selectedTemplate, setSelectedTemplate] = useState('mass_minimization');
const [copyFrom, setCopyFrom] = useState('');
const handleCreate = () => {
const finalCategory = newCategory || category;
startNewStudy(name, finalCategory);
// Navigate to canvas with the starting mode
navigate('/canvas', {
state: {
newStudy: true,
startWith,
template: startWith === 'template' ? selectedTemplate : null,
copyFrom: startWith === 'copy' ? copyFrom : null,
},
});
onClose();
};
const isValid = name.trim().length > 0 && (category || newCategory);
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-lg shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
<h2 className="text-lg font-semibold text-white">Create New Study</h2>
<button onClick={onClose} className="p-1 text-dark-400 hover:text-white">
<X size={20} />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 space-y-4">
{/* Study Name */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-1.5">
Study Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value.replace(/[^a-zA-Z0-9_-]/g, '_'))}
placeholder="my_new_study"
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-white placeholder-dark-500 focus:outline-none focus:border-primary-500"
/>
<p className="text-xs text-dark-500 mt-1">Use snake_case (letters, numbers, underscores)</p>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-1.5">
Category
</label>
<div className="flex gap-2">
<select
value={category}
onChange={(e) => {
setCategory(e.target.value);
setNewCategory('');
}}
className="flex-1 px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-white focus:outline-none focus:border-primary-500"
>
{categories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
<option value="">+ New Category</option>
</select>
{category === '' && (
<input
type="text"
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
placeholder="New category name"
className="flex-1 px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg
text-white placeholder-dark-500 focus:outline-none focus:border-primary-500"
/>
)}
</div>
</div>
{/* Start With */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Start With
</label>
<div className="space-y-2">
{/* Blank */}
<label className="flex items-center gap-3 p-3 bg-dark-800 border border-dark-600
rounded-lg cursor-pointer hover:border-dark-500">
<input
type="radio"
name="startWith"
value="blank"
checked={startWith === 'blank'}
onChange={() => setStartWith('blank')}
className="text-primary-500"
/>
<FileBox size={20} className="text-dark-400" />
<div>
<p className="font-medium text-white">Blank Canvas</p>
<p className="text-xs text-dark-500">Start from scratch</p>
</div>
</label>
{/* Template */}
<label className="flex items-center gap-3 p-3 bg-dark-800 border border-dark-600
rounded-lg cursor-pointer hover:border-dark-500">
<input
type="radio"
name="startWith"
value="template"
checked={startWith === 'template'}
onChange={() => setStartWith('template')}
className="text-primary-500"
/>
<LayoutTemplate size={20} className="text-dark-400" />
<div className="flex-1">
<p className="font-medium text-white">From Template</p>
{startWith === 'template' && (
<select
value={selectedTemplate}
onChange={(e) => setSelectedTemplate(e.target.value)}
className="mt-1 w-full px-2 py-1 bg-dark-700 border border-dark-600 rounded
text-sm text-white"
onClick={(e) => e.stopPropagation()}
>
<option value="mass_minimization">Mass Minimization</option>
<option value="multi_objective">Multi-Objective</option>
<option value="frequency_target">Frequency Target</option>
<option value="turbo_mode">Turbo Mode</option>
<option value="mirror_wfe">Mirror WFE</option>
</select>
)}
</div>
</label>
{/* Copy From */}
<label className="flex items-center gap-3 p-3 bg-dark-800 border border-dark-600
rounded-lg cursor-pointer hover:border-dark-500">
<input
type="radio"
name="startWith"
value="copy"
checked={startWith === 'copy'}
onChange={() => setStartWith('copy')}
className="text-primary-500"
/>
<Copy size={20} className="text-dark-400" />
<div className="flex-1">
<p className="font-medium text-white">Copy from Existing</p>
{startWith === 'copy' && (
<select
value={copyFrom}
onChange={(e) => setCopyFrom(e.target.value)}
className="mt-1 w-full px-2 py-1 bg-dark-700 border border-dark-600 rounded
text-sm text-white"
onClick={(e) => e.stopPropagation()}
>
<option value="">Select a study...</option>
{studies.filter(s => s.hasConfig).map((s) => (
<option key={s.path} value={s.path}>{s.name}</option>
))}
</select>
)}
</div>
</label>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-dark-700">
<button
onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={!isValid}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:bg-dark-700
disabled:text-dark-500 text-white rounded-lg font-medium transition-colors"
>
Create & Open Canvas
</button>
</div>
</div>
</div>
);
}
```
---
# PHASE 6: Process Dialog with Update/Create Choice (NEW)
## T6.1 - Create Process Dialog Component
**File:** `atomizer-dashboard/frontend/src/components/canvas/panels/ProcessDialog.tsx`
```tsx
import { useState } from 'react';
import { useStudyContext } from '../../../contexts/StudyContext';
import { X, AlertTriangle, FolderPlus, RefreshCw, Loader2 } from 'lucide-react';
interface Props {
isOpen: boolean;
onClose: () => void;
onProcess: (options: ProcessOptions) => void;
isProcessing: boolean;
}
export interface ProcessOptions {
mode: 'update' | 'create';
studyName?: string;
category?: string;
copyModelFiles?: boolean;
openAfter?: boolean;
runAfter?: boolean;
}
export function ProcessDialog({ isOpen, onClose, onProcess, isProcessing }: Props) {
const { currentStudy, isCreatingNew, pendingStudyName, pendingCategory, categories } = useStudyContext();
const [mode, setMode] = useState<'update' | 'create'>(isCreatingNew ? 'create' : 'update');
const [newStudyName, setNewStudyName] = useState('');
const [newCategory, setNewCategory] = useState(currentStudy?.category || categories[0] || 'General');
const [copyModelFiles, setCopyModelFiles] = useState(true);
const [openAfter, setOpenAfter] = useState(true);
const [runAfter, setRunAfter] = useState(false);
if (!isOpen) return null;
// If creating new study, no choice - only create mode
const canUpdate = !!currentStudy && !isCreatingNew;
const handleProcess = () => {
onProcess({
mode,
studyName: mode === 'create' ? newStudyName : undefined,
category: mode === 'create' ? newCategory : undefined,
copyModelFiles: mode === 'create' && canUpdate ? copyModelFiles : false,
openAfter,
runAfter,
});
};
const displayStudyName = isCreatingNew ? pendingStudyName : currentStudy?.name;
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-lg shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-dark-700">
<h2 className="text-lg font-semibold text-white">Generate Optimization</h2>
<button onClick={onClose} className="p-1 text-dark-400 hover:text-white">
<X size={20} />
</button>
</div>
{/* Body */}
<div className="px-6 py-4 space-y-4">
{/* What Claude generates */}
<div className="p-3 bg-dark-800 rounded-lg">
<p className="text-sm text-dark-300 mb-2">Claude will generate:</p>
<ul className="text-sm text-dark-400 space-y-1">
<li> optimization_config.json</li>
<li> run_optimization.py (using Atomizer protocols)</li>
</ul>
</div>
{/* Mode Selection */}
{canUpdate ? (
<div className="space-y-3">
{/* Update existing */}
<label className={`flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors
${mode === 'update' ? 'bg-dark-800 border-primary-500' : 'border-dark-600 hover:border-dark-500'}`}>
<input
type="radio"
name="mode"
value="update"
checked={mode === 'update'}
onChange={() => setMode('update')}
className="mt-1"
/>
<RefreshCw size={20} className="text-dark-400 mt-0.5" />
<div>
<p className="font-medium text-white">Update current study</p>
<p className="text-sm text-dark-400">Modify: {currentStudy.name}</p>
<div className="flex items-center gap-1 mt-1 text-xs text-amber-400">
<AlertTriangle size={12} />
<span>Will overwrite existing configuration</span>
</div>
</div>
</label>
{/* Create new */}
<label className={`flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-colors
${mode === 'create' ? 'bg-dark-800 border-primary-500' : 'border-dark-600 hover:border-dark-500'}`}>
<input
type="radio"
name="mode"
value="create"
checked={mode === 'create'}
onChange={() => setMode('create')}
className="mt-1"
/>
<FolderPlus size={20} className="text-dark-400 mt-0.5" />
<div className="flex-1">
<p className="font-medium text-white">Create new study</p>
{mode === 'create' && (
<div className="mt-2 space-y-2">
<input
type="text"
value={newStudyName}
onChange={(e) => setNewStudyName(e.target.value.replace(/[^a-zA-Z0-9_-]/g, '_'))}
placeholder={`${currentStudy?.name}_v2`}
className="w-full px-2 py-1 bg-dark-700 border border-dark-600 rounded
text-sm text-white placeholder-dark-500"
/>
<select
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
className="w-full px-2 py-1 bg-dark-700 border border-dark-600 rounded
text-sm text-white"
>
{categories.map((cat) => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
<label className="flex items-center gap-2 text-sm text-dark-300">
<input
type="checkbox"
checked={copyModelFiles}
onChange={(e) => setCopyModelFiles(e.target.checked)}
/>
Copy model files from {currentStudy.name}
</label>
</div>
)}
</div>
</label>
</div>
) : (
/* Creating new study - no choice */
<div className="p-3 bg-dark-800 border border-dark-600 rounded-lg">
<div className="flex items-center gap-2">
<FolderPlus size={20} className="text-primary-400" />
<div>
<p className="font-medium text-white">Creating: {displayStudyName}</p>
<p className="text-sm text-dark-400">
{isCreatingNew && pendingCategory ? `Category: ${pendingCategory}` : ''}
</p>
</div>
</div>
</div>
)}
{/* After creation options */}
<div className="pt-2 border-t border-dark-700 space-y-2">
<p className="text-sm font-medium text-dark-300">After generation:</p>
<label className="flex items-center gap-2 text-sm text-dark-300">
<input
type="checkbox"
checked={openAfter}
onChange={(e) => setOpenAfter(e.target.checked)}
/>
Open study automatically
</label>
<label className="flex items-center gap-2 text-sm text-dark-300">
<input
type="checkbox"
checked={runAfter}
onChange={(e) => setRunAfter(e.target.checked)}
/>
Start optimization immediately
</label>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-dark-700">
<button
onClick={onClose}
disabled={isProcessing}
className="px-4 py-2 text-dark-300 hover:text-white transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleProcess}
disabled={isProcessing || (mode === 'create' && !newStudyName && canUpdate)}
className="flex items-center gap-2 px-4 py-2 bg-primary-500 hover:bg-primary-600
disabled:bg-dark-700 disabled:text-dark-500 text-white rounded-lg
font-medium transition-colors"
>
{isProcessing ? (
<>
<Loader2 size={16} className="animate-spin" />
Processing...
</>
) : (
'Generate with Claude'
)}
</button>
</div>
</div>
</div>
);
}
```
---
# PHASE 7: Backend API for Studies (NEW)
## T7.1 - Create Studies API Routes
**File:** `atomizer-dashboard/backend/api/routes/studies.py`
```python
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from pathlib import Path
from typing import Optional, List
import json
import shutil
router = APIRouter(prefix="/api/studies", tags=["studies"])
STUDIES_ROOT = Path(__file__).parent.parent.parent.parent.parent / "studies"
class CreateStudyRequest(BaseModel):
name: str
category: str
class GenerateRequest(BaseModel):
intent: dict
overwrite: bool = False
class Study(BaseModel):
path: str
name: str
category: str
status: str
trialCount: int
maxTrials: int
bestValue: Optional[float] = None
hasConfig: bool
@router.get("")
async def list_studies():
"""List all studies with metadata."""
studies = []
categories = set()
if not STUDIES_ROOT.exists():
return {"studies": [], "categories": []}
for category_dir in STUDIES_ROOT.iterdir():
if not category_dir.is_dir() or category_dir.name.startswith('.'):
continue
categories.add(category_dir.name)
for study_dir in category_dir.iterdir():
if not study_dir.is_dir() or study_dir.name.startswith('.'):
continue
# Check for config
config_path = study_dir / "optimization_config.json"
has_config = config_path.exists()
# Get trial count from results
status = "not_started"
trial_count = 0
max_trials = 100
best_value = None
db_path = study_dir / "3_results" / "study.db"
if db_path.exists():
try:
import sqlite3
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Get trial count
cursor.execute("SELECT COUNT(*) FROM trials WHERE state = 'COMPLETE'")
trial_count = cursor.fetchone()[0]
# Get best value
cursor.execute("""
SELECT MIN(value) FROM trial_values
JOIN trials ON trial_values.trial_id = trials.trial_id
WHERE trials.state = 'COMPLETE'
""")
result = cursor.fetchone()
if result and result[0] is not None:
best_value = result[0]
conn.close()
# Determine status
if trial_count > 0:
if trial_count >= max_trials:
status = "completed"
else:
# Check if running (simplified - could check PID file)
status = "paused" # Default to paused if has trials
except Exception:
pass
if has_config:
try:
with open(config_path) as f:
config = json.load(f)
max_trials = config.get("optimization", {}).get("n_trials", 100)
except Exception:
pass
studies.append(Study(
path=f"{category_dir.name}/{study_dir.name}",
name=study_dir.name,
category=category_dir.name,
status=status,
trialCount=trial_count,
maxTrials=max_trials,
bestValue=best_value,
hasConfig=has_config,
))
return {
"studies": [s.dict() for s in sorted(studies, key=lambda x: x.name)],
"categories": sorted(list(categories)),
}
@router.get("/{study_path:path}")
async def get_study(study_path: str):
"""Get single study details."""
study_dir = STUDIES_ROOT / study_path
if not study_dir.exists():
raise HTTPException(status_code=404, detail="Study not found")
parts = study_path.split("/")
if len(parts) != 2:
raise HTTPException(status_code=400, detail="Invalid study path")
category, name = parts
config_path = study_dir / "optimization_config.json"
has_config = config_path.exists()
max_trials = 100
if has_config:
try:
with open(config_path) as f:
config = json.load(f)
max_trials = config.get("optimization", {}).get("n_trials", 100)
except Exception:
pass
return Study(
path=study_path,
name=name,
category=category,
status="not_started",
trialCount=0,
maxTrials=max_trials,
bestValue=None,
hasConfig=has_config,
).dict()
@router.post("")
async def create_study(request: CreateStudyRequest):
"""Create new study folder structure."""
study_dir = STUDIES_ROOT / request.category / request.name
if study_dir.exists():
raise HTTPException(status_code=400, detail="Study already exists")
# Create folder structure
(study_dir / "1_config").mkdir(parents=True, exist_ok=True)
(study_dir / "2_iterations").mkdir(parents=True, exist_ok=True)
(study_dir / "3_results").mkdir(parents=True, exist_ok=True)
return Study(
path=f"{request.category}/{request.name}",
name=request.name,
category=request.category,
status="not_started",
trialCount=0,
maxTrials=100,
bestValue=None,
hasConfig=False,
).dict()
@router.post("/{study_path:path}/copy-models")
async def copy_model_files(study_path: str, source_study: str):
"""Copy model files from another study."""
target_dir = STUDIES_ROOT / study_path
source_dir = STUDIES_ROOT / source_study
if not target_dir.exists():
raise HTTPException(status_code=404, detail="Target study not found")
if not source_dir.exists():
raise HTTPException(status_code=404, detail="Source study not found")
copied = []
for ext in [".prt", ".sim", ".fem"]:
for f in source_dir.glob(f"*{ext}"):
target = target_dir / f.name
shutil.copy2(f, target)
copied.append(f.name)
return {"copied": copied}
@router.get("/{study_path:path}/config")
async def get_study_config(study_path: str):
"""Get optimization_config.json for a study."""
config_path = STUDIES_ROOT / study_path / "optimization_config.json"
if not config_path.exists():
raise HTTPException(status_code=404, detail="Config not found")
with open(config_path) as f:
return json.load(f)
```
## T7.2 - Register Routes in Main
**File:** `atomizer-dashboard/backend/api/main.py`
Add:
```python
from api.routes import studies
app.include_router(studies.router)
```
---
# PHASE 8: Update Canvas Header (Context-Aware)
## T8.1 - Update CanvasView Header
Update `CanvasView.tsx` to show current study context:
```tsx
// In header
<header className="flex-shrink-0 h-10 bg-dark-850 border-b border-dark-700 px-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-sm font-semibold text-white">Canvas Builder</h1>
{currentStudy && !isCreatingNew && (
<span className="px-2 py-0.5 bg-dark-700 rounded text-xs text-dark-300">
Editing: {currentStudy.name}
</span>
)}
{isCreatingNew && pendingStudyName && (
<span className="px-2 py-0.5 bg-primary-500/20 rounded text-xs text-primary-400">
Creating: {pendingStudyName}
</span>
)}
<span className="text-xs text-dark-500 tabular-nums">
{nodes.length} node{nodes.length !== 1 ? 's' : ''}
</span>
</div>
{/* ... rest of header */}
</header>
```
---
# PHASE 9: Build and Commit
## T9.1 - Build Frontend
```bash
cd atomizer-dashboard/frontend
npm run build
```
## T9.2 - Test Backend
```bash
cd atomizer-dashboard/backend
python -c "from api.routes.studies import router; print('Studies API OK')"
```
## T9.3 - Git Commit
```bash
git add .
git commit -m "feat: Canvas V3 - Bug fixes and study management workflow
## Bug Fixes (Phase 1-3)
- Fix Atomizer Assistant error with proper error handling + reconnect
- Enable edge selection and deletion (Delete/Backspace key)
- Fix drag & drop positioning
- Complete loadFromConfig rewrite with ALL nodes and edges
- Full-screen responsive canvas layout
- Fix contrast issues and increase font sizes
## Study Management (Phase 4-8)
- Add StudyContext for global study state management
- Create StudyBrowser component for Home page
- Add NewStudyDialog for creating studies from scratch
- Add ProcessDialog with update/create choice
- Add backend /api/studies endpoints (list, create, copy-models)
- Context-aware Canvas header showing current study
## User Flows
- Open existing study → edit in Canvas → update or create new
- Create new study from blank/template/copy → design → generate
- Home page shows all studies with quick actions
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
git push origin main && git push github main
```
---
# ACCEPTANCE CRITERIA
## Bug Fixes
- [ ] Chat panel opens without error
- [ ] Edges can be selected and deleted
- [ ] Drag & drop positions correctly
- [ ] Loading study creates ALL nodes and edges
- [ ] Canvas fills screen
- [ ] All text readable (good contrast)
## Study Management
- [ ] Home page lists all studies
- [ ] "New Study" button opens dialog
- [ ] Can create study from blank/template/copy
- [ ] Canvas shows "Editing: X" or "Creating: X"
- [ ] Process dialog offers update vs. create choice
- [ ] Backend /api/studies endpoints work
## Build
- [ ] `npm run build` passes
---
# BEGIN EXECUTION
Execute all 9 phases sequentially. Use TodoWrite for every task. Complete fully before moving to next phase. Do not stop.
**GO.**