Files
Atomizer/docs/archive/review/RALPH_LOOP_CANVAS_V3.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

41 KiB

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

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

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

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

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

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

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

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:

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:

// 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

cd atomizer-dashboard/frontend
npm run build

T9.2 - Test Backend

cd atomizer-dashboard/backend
python -c "from api.routes.studies import router; print('Studies API OK')"

T9.3 - Git Commit

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.