feat: Add Studio UI, intake system, and extractor improvements
Dashboard: - Add Studio page with drag-drop model upload and Claude chat - Add intake system for study creation workflow - Improve session manager and context builder - Add intake API routes and frontend components Optimization Engine: - Add CLI module for command-line operations - Add intake module for study preprocessing - Add validation module with gate checks - Improve Zernike extractor documentation - Update spec models with better validation - Enhance solve_simulation robustness Documentation: - Add ATOMIZER_STUDIO.md planning doc - Add ATOMIZER_UX_SYSTEM.md for UX patterns - Update extractor library docs - Add study-readme-generator skill Tools: - Add test scripts for extraction validation - Add Zernike recentering test Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,12 +18,15 @@ import {
|
||||
FolderOpen,
|
||||
Maximize2,
|
||||
X,
|
||||
Layers
|
||||
Layers,
|
||||
Sparkles,
|
||||
Settings2
|
||||
} from 'lucide-react';
|
||||
import { useStudy } from '../context/StudyContext';
|
||||
import { Study } from '../types';
|
||||
import { apiClient } from '../api/client';
|
||||
import { MarkdownRenderer } from '../components/MarkdownRenderer';
|
||||
import { InboxSection } from '../components/intake';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
const { studies, setSelectedStudy, refreshStudies, isLoading } = useStudy();
|
||||
@@ -174,6 +177,18 @@ const Home: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/studio')}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all font-medium hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
color: '#000',
|
||||
boxShadow: '0 4px 15px rgba(245, 158, 11, 0.3)'
|
||||
}}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
New Study
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/canvas')}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all font-medium hover:-translate-y-0.5"
|
||||
@@ -250,6 +265,11 @@ const Home: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inbox Section - Study Creation Workflow */}
|
||||
<div className="mb-8">
|
||||
<InboxSection onStudyFinalized={refreshStudies} />
|
||||
</div>
|
||||
|
||||
{/* Two-column layout: Table + Preview */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Study Table */}
|
||||
@@ -407,6 +427,19 @@ const Home: React.FC = () => {
|
||||
<Layers className="w-4 h-4" />
|
||||
Canvas
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate(`/studio/${selectedPreview.id}`)}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-lg transition-all font-medium whitespace-nowrap hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: 'rgba(8, 15, 26, 0.85)',
|
||||
border: '1px solid rgba(245, 158, 11, 0.3)',
|
||||
color: '#f59e0b'
|
||||
}}
|
||||
title="Edit study configuration with AI assistant"
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
Studio
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSelectStudy(selectedPreview)}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-lg transition-all font-semibold whitespace-nowrap hover:-translate-y-0.5"
|
||||
|
||||
672
atomizer-dashboard/frontend/src/pages/Studio.tsx
Normal file
672
atomizer-dashboard/frontend/src/pages/Studio.tsx
Normal file
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* Atomizer Studio - Unified Study Creation Environment
|
||||
*
|
||||
* A drag-and-drop workspace for creating optimization studies with:
|
||||
* - File upload (models + context documents)
|
||||
* - Visual canvas configuration
|
||||
* - AI-powered assistance
|
||||
* - One-click build to final study
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Home,
|
||||
ChevronRight,
|
||||
Upload,
|
||||
FileText,
|
||||
Settings,
|
||||
Sparkles,
|
||||
Save,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
MessageSquare,
|
||||
Layers,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
GripVertical,
|
||||
} from 'lucide-react';
|
||||
import { intakeApi } from '../api/intake';
|
||||
import { SpecRenderer } from '../components/canvas/SpecRenderer';
|
||||
import { NodePalette } from '../components/canvas/palette/NodePalette';
|
||||
import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2';
|
||||
import { useSpecStore, useSpec, useSpecLoading } from '../hooks/useSpecStore';
|
||||
import { StudioDropZone } from '../components/studio/StudioDropZone';
|
||||
import { StudioParameterList } from '../components/studio/StudioParameterList';
|
||||
import { StudioContextFiles } from '../components/studio/StudioContextFiles';
|
||||
import { StudioChat } from '../components/studio/StudioChat';
|
||||
import { StudioBuildDialog } from '../components/studio/StudioBuildDialog';
|
||||
|
||||
interface DraftState {
|
||||
draftId: string | null;
|
||||
status: 'idle' | 'creating' | 'ready' | 'error';
|
||||
error: string | null;
|
||||
modelFiles: string[];
|
||||
contextFiles: string[];
|
||||
contextContent: string;
|
||||
introspectionAvailable: boolean;
|
||||
designVariableCount: number;
|
||||
objectiveCount: number;
|
||||
}
|
||||
|
||||
export default function Studio() {
|
||||
const navigate = useNavigate();
|
||||
const { draftId: urlDraftId } = useParams<{ draftId: string }>();
|
||||
|
||||
// Draft state
|
||||
const [draft, setDraft] = useState<DraftState>({
|
||||
draftId: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
modelFiles: [],
|
||||
contextFiles: [],
|
||||
contextContent: '',
|
||||
introspectionAvailable: false,
|
||||
designVariableCount: 0,
|
||||
objectiveCount: 0,
|
||||
});
|
||||
|
||||
// UI state
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState(320);
|
||||
const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false);
|
||||
const [showBuildDialog, setShowBuildDialog] = useState(false);
|
||||
const [isIntrospecting, setIsIntrospecting] = useState(false);
|
||||
const [notification, setNotification] = useState<{ type: 'success' | 'error' | 'info'; message: string } | null>(null);
|
||||
|
||||
// Resize state
|
||||
const isResizing = useRef(false);
|
||||
const minPanelWidth = 280;
|
||||
const maxPanelWidth = 500;
|
||||
|
||||
// Spec store for canvas
|
||||
const spec = useSpec();
|
||||
const specLoading = useSpecLoading();
|
||||
const { loadSpec, clearSpec } = useSpecStore();
|
||||
|
||||
// Handle panel resize
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isResizing.current = true;
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isResizing.current) return;
|
||||
const newWidth = Math.min(maxPanelWidth, Math.max(minPanelWidth, e.clientX));
|
||||
setLeftPanelWidth(newWidth);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
isResizing.current = false;
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize or load draft on mount
|
||||
useEffect(() => {
|
||||
if (urlDraftId) {
|
||||
loadDraft(urlDraftId);
|
||||
} else {
|
||||
createNewDraft();
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup: clear spec when leaving Studio
|
||||
clearSpec();
|
||||
};
|
||||
}, [urlDraftId]);
|
||||
|
||||
// Create a new draft
|
||||
const createNewDraft = async () => {
|
||||
setDraft(prev => ({ ...prev, status: 'creating', error: null }));
|
||||
|
||||
try {
|
||||
const response = await intakeApi.createDraft();
|
||||
|
||||
setDraft({
|
||||
draftId: response.draft_id,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
modelFiles: [],
|
||||
contextFiles: [],
|
||||
contextContent: '',
|
||||
introspectionAvailable: false,
|
||||
designVariableCount: 0,
|
||||
objectiveCount: 0,
|
||||
});
|
||||
|
||||
// Update URL without navigation
|
||||
window.history.replaceState(null, '', `/studio/${response.draft_id}`);
|
||||
|
||||
// Load the empty spec for this draft
|
||||
await loadSpec(response.draft_id);
|
||||
|
||||
showNotification('info', 'New studio session started. Drop your files to begin.');
|
||||
} catch (err) {
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Failed to create draft',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Load existing draft or study
|
||||
const loadDraft = async (studyId: string) => {
|
||||
setDraft(prev => ({ ...prev, status: 'creating', error: null }));
|
||||
|
||||
// Check if this is a draft (in _inbox) or an existing study
|
||||
const isDraft = studyId.startsWith('draft_');
|
||||
|
||||
if (isDraft) {
|
||||
// Load from intake API
|
||||
try {
|
||||
const response = await intakeApi.getStudioDraft(studyId);
|
||||
|
||||
// Also load context content if there are context files
|
||||
let contextContent = '';
|
||||
if (response.context_files.length > 0) {
|
||||
try {
|
||||
const contextResponse = await intakeApi.getContextContent(studyId);
|
||||
contextContent = contextResponse.content;
|
||||
} catch {
|
||||
// Ignore context loading errors
|
||||
}
|
||||
}
|
||||
|
||||
setDraft({
|
||||
draftId: response.draft_id,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
modelFiles: response.model_files,
|
||||
contextFiles: response.context_files,
|
||||
contextContent,
|
||||
introspectionAvailable: response.introspection_available,
|
||||
designVariableCount: response.design_variable_count,
|
||||
objectiveCount: response.objective_count,
|
||||
});
|
||||
|
||||
// Load the spec
|
||||
await loadSpec(studyId);
|
||||
|
||||
showNotification('info', `Resuming draft: ${studyId}`);
|
||||
} catch (err) {
|
||||
// Draft doesn't exist, create new one
|
||||
createNewDraft();
|
||||
}
|
||||
} else {
|
||||
// Load existing study directly via spec store
|
||||
try {
|
||||
await loadSpec(studyId);
|
||||
|
||||
// Get counts from loaded spec
|
||||
const loadedSpec = useSpecStore.getState().spec;
|
||||
|
||||
setDraft({
|
||||
draftId: studyId,
|
||||
status: 'ready',
|
||||
error: null,
|
||||
modelFiles: [], // Existing studies don't track files separately
|
||||
contextFiles: [],
|
||||
contextContent: '',
|
||||
introspectionAvailable: true, // Assume introspection was done
|
||||
designVariableCount: loadedSpec?.design_variables?.length || 0,
|
||||
objectiveCount: loadedSpec?.objectives?.length || 0,
|
||||
});
|
||||
|
||||
showNotification('info', `Editing study: ${studyId}`);
|
||||
} catch (err) {
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : 'Failed to load study',
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh draft data
|
||||
const refreshDraft = async () => {
|
||||
if (!draft.draftId) return;
|
||||
|
||||
const isDraft = draft.draftId.startsWith('draft_');
|
||||
|
||||
if (isDraft) {
|
||||
try {
|
||||
const response = await intakeApi.getStudioDraft(draft.draftId);
|
||||
|
||||
// Also refresh context content
|
||||
let contextContent = draft.contextContent;
|
||||
if (response.context_files.length > 0) {
|
||||
try {
|
||||
const contextResponse = await intakeApi.getContextContent(draft.draftId);
|
||||
contextContent = contextResponse.content;
|
||||
} catch {
|
||||
// Keep existing content
|
||||
}
|
||||
}
|
||||
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
modelFiles: response.model_files,
|
||||
contextFiles: response.context_files,
|
||||
contextContent,
|
||||
introspectionAvailable: response.introspection_available,
|
||||
designVariableCount: response.design_variable_count,
|
||||
objectiveCount: response.objective_count,
|
||||
}));
|
||||
|
||||
// Reload spec
|
||||
await loadSpec(draft.draftId);
|
||||
} catch (err) {
|
||||
showNotification('error', 'Failed to refresh draft');
|
||||
}
|
||||
} else {
|
||||
// For existing studies, just reload the spec
|
||||
try {
|
||||
await loadSpec(draft.draftId);
|
||||
|
||||
const loadedSpec = useSpecStore.getState().spec;
|
||||
setDraft(prev => ({
|
||||
...prev,
|
||||
designVariableCount: loadedSpec?.design_variables?.length || 0,
|
||||
objectiveCount: loadedSpec?.objectives?.length || 0,
|
||||
}));
|
||||
} catch (err) {
|
||||
showNotification('error', 'Failed to refresh study');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Run introspection
|
||||
const runIntrospection = async () => {
|
||||
if (!draft.draftId || draft.modelFiles.length === 0) {
|
||||
showNotification('error', 'Please upload model files first');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsIntrospecting(true);
|
||||
|
||||
try {
|
||||
const response = await intakeApi.introspect({ study_name: draft.draftId });
|
||||
|
||||
showNotification('success', `Found ${response.expressions_count} expressions (${response.candidates_count} candidates)`);
|
||||
|
||||
// Refresh draft state
|
||||
await refreshDraft();
|
||||
} catch (err) {
|
||||
showNotification('error', err instanceof Error ? err.message : 'Introspection failed');
|
||||
} finally {
|
||||
setIsIntrospecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file upload complete
|
||||
const handleUploadComplete = useCallback(() => {
|
||||
refreshDraft();
|
||||
showNotification('success', 'Files uploaded successfully');
|
||||
}, [draft.draftId]);
|
||||
|
||||
// Handle build complete
|
||||
const handleBuildComplete = (finalPath: string, finalName: string) => {
|
||||
setShowBuildDialog(false);
|
||||
showNotification('success', `Study "${finalName}" created successfully!`);
|
||||
|
||||
// Navigate to the new study
|
||||
setTimeout(() => {
|
||||
navigate(`/canvas/${finalPath.replace('studies/', '')}`);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
// Reset draft
|
||||
const resetDraft = async () => {
|
||||
if (!draft.draftId) return;
|
||||
|
||||
if (!confirm('Are you sure you want to reset? This will delete all uploaded files and configurations.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await intakeApi.deleteInboxStudy(draft.draftId);
|
||||
await createNewDraft();
|
||||
} catch (err) {
|
||||
showNotification('error', 'Failed to reset draft');
|
||||
}
|
||||
};
|
||||
|
||||
// Show notification
|
||||
const showNotification = (type: 'success' | 'error' | 'info', message: string) => {
|
||||
setNotification({ type, message });
|
||||
setTimeout(() => setNotification(null), 4000);
|
||||
};
|
||||
|
||||
// Can always save/build - even empty studies can be saved for later
|
||||
const canBuild = draft.draftId !== null;
|
||||
|
||||
// Loading state
|
||||
if (draft.status === 'creating') {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 text-primary-400 animate-spin mx-auto mb-4" />
|
||||
<p className="text-dark-300">Initializing Studio...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (draft.status === 'error') {
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 flex items-center justify-center">
|
||||
<div className="text-center max-w-md">
|
||||
<AlertCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-white mb-2">Failed to Initialize</h2>
|
||||
<p className="text-dark-400 mb-4">{draft.error}</p>
|
||||
<button
|
||||
onClick={createNewDraft}
|
||||
className="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-400 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="h-14 bg-dark-850 border-b border-dark-700 flex items-center justify-between px-4 flex-shrink-0">
|
||||
{/* Left: Navigation */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="p-2 hover:bg-dark-700 rounded-lg text-dark-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
</button>
|
||||
<ChevronRight className="w-4 h-4 text-dark-600" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-primary-400" />
|
||||
<span className="text-white font-medium">Atomizer Studio</span>
|
||||
</div>
|
||||
{draft.draftId && (
|
||||
<>
|
||||
<ChevronRight className="w-4 h-4 text-dark-600" />
|
||||
<span className="text-dark-400 text-sm font-mono">{draft.draftId}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={resetDraft}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm text-dark-400 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Reset
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBuildDialog(true)}
|
||||
disabled={!canBuild}
|
||||
className="flex items-center gap-2 px-4 py-1.5 text-sm font-medium bg-primary-500 text-white rounded-lg hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Save & Name Study
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Panel: Resources (Resizable) */}
|
||||
<div
|
||||
className="bg-dark-850 border-r border-dark-700 flex flex-col flex-shrink-0 relative"
|
||||
style={{ width: leftPanelWidth }}
|
||||
>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
||||
{/* Drop Zone */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<Upload className="w-4 h-4" />
|
||||
Model Files
|
||||
</h3>
|
||||
{draft.draftId && (
|
||||
<StudioDropZone
|
||||
draftId={draft.draftId}
|
||||
type="model"
|
||||
files={draft.modelFiles}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Introspection */}
|
||||
{draft.modelFiles.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-dark-300 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Parameters
|
||||
</h3>
|
||||
<button
|
||||
onClick={runIntrospection}
|
||||
disabled={isIntrospecting}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-primary-400 hover:bg-primary-400/10 rounded transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isIntrospecting ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
)}
|
||||
{isIntrospecting ? 'Scanning...' : 'Scan'}
|
||||
</button>
|
||||
</div>
|
||||
{draft.draftId && draft.introspectionAvailable && (
|
||||
<StudioParameterList
|
||||
draftId={draft.draftId}
|
||||
onParameterAdded={refreshDraft}
|
||||
/>
|
||||
)}
|
||||
{!draft.introspectionAvailable && (
|
||||
<p className="text-xs text-dark-500 italic">
|
||||
Click "Scan" to discover parameters from your model.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Context Files */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
Context Documents
|
||||
</h3>
|
||||
{draft.draftId && (
|
||||
<StudioContextFiles
|
||||
draftId={draft.draftId}
|
||||
files={draft.contextFiles}
|
||||
onUploadComplete={handleUploadComplete}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-dark-500 mt-2">
|
||||
Upload requirements, goals, or specs. The AI will read these.
|
||||
</p>
|
||||
|
||||
{/* Show context preview if loaded */}
|
||||
{draft.contextContent && (
|
||||
<div className="mt-3 p-2 bg-dark-700/50 rounded-lg border border-dark-600">
|
||||
<p className="text-xs text-amber-400 mb-1 font-medium">Context Loaded:</p>
|
||||
<p className="text-xs text-dark-400 line-clamp-3">
|
||||
{draft.contextContent.substring(0, 200)}...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Node Palette - EXPANDED, not collapsed */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
Components
|
||||
</h3>
|
||||
<NodePalette
|
||||
collapsed={false}
|
||||
showToggle={false}
|
||||
className="!w-full !border-0 !bg-transparent"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Resize Handle */}
|
||||
<div
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary-500/50 transition-colors group"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-4 h-8 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<GripVertical className="w-3 h-3 text-dark-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Canvas */}
|
||||
<div className="flex-1 relative bg-dark-900">
|
||||
{draft.draftId && (
|
||||
<SpecRenderer
|
||||
studyId={draft.draftId}
|
||||
editable={true}
|
||||
showLoadingOverlay={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!specLoading && (!spec || Object.keys(spec).length === 0) && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center max-w-md p-8">
|
||||
<div className="w-20 h-20 rounded-full bg-dark-800 flex items-center justify-center mx-auto mb-6">
|
||||
<Sparkles className="w-10 h-10 text-primary-400" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-white mb-3">
|
||||
Welcome to Atomizer Studio
|
||||
</h2>
|
||||
<p className="text-dark-400 mb-6">
|
||||
Drop your model files on the left, or drag components from the palette to start building your optimization study.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 text-sm text-dark-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
<span>Upload .sim, .prt, .fem files</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
<span>Add context documents (PDF, MD, TXT)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-400" />
|
||||
<span>Configure with AI assistance</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Panel: Assistant + Config - wider for better chat UX */}
|
||||
<div
|
||||
className={`bg-dark-850 border-l border-dark-700 flex flex-col transition-all duration-300 flex-shrink-0 ${
|
||||
rightPanelCollapsed ? 'w-12' : 'w-[480px]'
|
||||
}`}
|
||||
>
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
onClick={() => setRightPanelCollapsed(!rightPanelCollapsed)}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 z-10 p-1 bg-dark-700 border border-dark-600 rounded-l-lg hover:bg-dark-600 transition-colors"
|
||||
style={{ marginRight: rightPanelCollapsed ? '48px' : '480px' }}
|
||||
>
|
||||
{rightPanelCollapsed ? (
|
||||
<ChevronLeft className="w-4 h-4 text-dark-400" />
|
||||
) : (
|
||||
<ChevronRightIcon className="w-4 h-4 text-dark-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!rightPanelCollapsed && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Chat */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{draft.draftId && (
|
||||
<StudioChat
|
||||
draftId={draft.draftId}
|
||||
contextFiles={draft.contextFiles}
|
||||
contextContent={draft.contextContent}
|
||||
modelFiles={draft.modelFiles}
|
||||
onSpecUpdated={refreshDraft}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config Panel (when node selected) */}
|
||||
<NodeConfigPanelV2 />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightPanelCollapsed && (
|
||||
<div className="flex flex-col items-center py-4 gap-4">
|
||||
<MessageSquare className="w-5 h-5 text-dark-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Toast */}
|
||||
{notification && (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg z-50 animate-slide-up ${
|
||||
notification.type === 'success'
|
||||
? 'bg-green-500/10 border border-green-500/30 text-green-400'
|
||||
: notification.type === 'error'
|
||||
? 'bg-red-500/10 border border-red-500/30 text-red-400'
|
||||
: 'bg-primary-500/10 border border-primary-500/30 text-primary-400'
|
||||
}`}
|
||||
>
|
||||
{notification.type === 'success' && <CheckCircle className="w-5 h-5" />}
|
||||
{notification.type === 'error' && <AlertCircle className="w-5 h-5" />}
|
||||
{notification.type === 'info' && <Sparkles className="w-5 h-5" />}
|
||||
<span>{notification.message}</span>
|
||||
<button
|
||||
onClick={() => setNotification(null)}
|
||||
className="p-1 hover:bg-white/10 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Build Dialog */}
|
||||
{showBuildDialog && draft.draftId && (
|
||||
<StudioBuildDialog
|
||||
draftId={draft.draftId}
|
||||
onClose={() => setShowBuildDialog(false)}
|
||||
onBuildComplete={handleBuildComplete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user