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>
673 lines
23 KiB
TypeScript
673 lines
23 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|