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:
2026-01-27 12:02:30 -05:00
parent 3193831340
commit a26914bbe8
56 changed files with 14173 additions and 646 deletions

View File

@@ -9,6 +9,7 @@ import Analysis from './pages/Analysis';
import Insights from './pages/Insights';
import Results from './pages/Results';
import CanvasView from './pages/CanvasView';
import Studio from './pages/Studio';
const queryClient = new QueryClient({
defaultOptions: {
@@ -32,6 +33,10 @@ function App() {
<Route path="canvas" element={<CanvasView />} />
<Route path="canvas/*" element={<CanvasView />} />
{/* Studio - unified study creation environment */}
<Route path="studio" element={<Studio />} />
<Route path="studio/:draftId" element={<Studio />} />
{/* Study pages - with sidebar layout */}
<Route element={<MainLayout />}>
<Route path="setup" element={<Setup />} />

View File

@@ -0,0 +1,411 @@
/**
* Intake API Client
*
* API client methods for the study intake workflow.
*/
import {
CreateInboxRequest,
CreateInboxResponse,
IntrospectRequest,
IntrospectResponse,
ListInboxResponse,
ListTopicsResponse,
InboxStudyDetail,
GenerateReadmeResponse,
FinalizeRequest,
FinalizeResponse,
UploadFilesResponse,
} from '../types/intake';
const API_BASE = '/api';
/**
* Intake API client for study creation workflow.
*/
export const intakeApi = {
/**
* Create a new inbox study folder with initial spec.
*/
async createInbox(request: CreateInboxRequest): Promise<CreateInboxResponse> {
const response = await fetch(`${API_BASE}/intake/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create inbox study');
}
return response.json();
},
/**
* Run NX introspection on an inbox study.
*/
async introspect(request: IntrospectRequest): Promise<IntrospectResponse> {
const response = await fetch(`${API_BASE}/intake/introspect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Introspection failed');
}
return response.json();
},
/**
* List all studies in the inbox.
*/
async listInbox(): Promise<ListInboxResponse> {
const response = await fetch(`${API_BASE}/intake/list`);
if (!response.ok) {
throw new Error('Failed to fetch inbox studies');
}
return response.json();
},
/**
* List existing topic folders.
*/
async listTopics(): Promise<ListTopicsResponse> {
const response = await fetch(`${API_BASE}/intake/topics`);
if (!response.ok) {
throw new Error('Failed to fetch topics');
}
return response.json();
},
/**
* Get detailed information about an inbox study.
*/
async getInboxStudy(studyName: string): Promise<InboxStudyDetail> {
const response = await fetch(`${API_BASE}/intake/${encodeURIComponent(studyName)}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to fetch inbox study');
}
return response.json();
},
/**
* Delete an inbox study.
*/
async deleteInboxStudy(studyName: string): Promise<{ success: boolean; deleted: string }> {
const response = await fetch(`${API_BASE}/intake/${encodeURIComponent(studyName)}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to delete inbox study');
}
return response.json();
},
/**
* Generate README for an inbox study using Claude AI.
*/
async generateReadme(studyName: string): Promise<GenerateReadmeResponse> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/readme`,
{ method: 'POST' }
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'README generation failed');
}
return response.json();
},
/**
* Finalize an inbox study and move to studies directory.
*/
async finalize(studyName: string, request: FinalizeRequest): Promise<FinalizeResponse> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/finalize`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Finalization failed');
}
return response.json();
},
/**
* Upload model files to an inbox study.
*/
async uploadFiles(studyName: string, files: File[]): Promise<UploadFilesResponse> {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/upload`,
{
method: 'POST',
body: formData,
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'File upload failed');
}
return response.json();
},
/**
* Upload context files to an inbox study.
* Context files help Claude understand optimization goals.
*/
async uploadContextFiles(studyName: string, files: File[]): Promise<UploadFilesResponse> {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context`,
{
method: 'POST',
body: formData,
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Context file upload failed');
}
return response.json();
},
/**
* List context files for an inbox study.
*/
async listContextFiles(studyName: string): Promise<{
study_name: string;
context_files: Array<{ name: string; path: string; size: number; extension: string }>;
total: number;
}> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context`
);
if (!response.ok) {
throw new Error('Failed to list context files');
}
return response.json();
},
/**
* Delete a context file from an inbox study.
*/
async deleteContextFile(studyName: string, filename: string): Promise<{ success: boolean; deleted: string }> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context/${encodeURIComponent(filename)}`,
{ method: 'DELETE' }
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to delete context file');
}
return response.json();
},
/**
* Create design variables from selected expressions.
*/
async createDesignVariables(
studyName: string,
expressionNames: string[],
options?: { autoBounds?: boolean; boundFactor?: number }
): Promise<{
success: boolean;
study_name: string;
created: Array<{
id: string;
name: string;
expression_name: string;
bounds_min: number;
bounds_max: number;
baseline: number;
units: string | null;
}>;
total_created: number;
}> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/design-variables`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
expression_names: expressionNames,
auto_bounds: options?.autoBounds ?? true,
bound_factor: options?.boundFactor ?? 0.5,
}),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create design variables');
}
return response.json();
},
// ===========================================================================
// Studio Endpoints (Atomizer Studio - Unified Creation Environment)
// ===========================================================================
/**
* Create an anonymous draft study for Studio workflow.
* Returns a temporary draft_id that can be renamed during finalization.
*/
async createDraft(): Promise<{
success: boolean;
draft_id: string;
inbox_path: string;
spec_path: string;
status: string;
}> {
const response = await fetch(`${API_BASE}/intake/draft`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to create draft');
}
return response.json();
},
/**
* Get extracted text content from context files.
* Used for AI context injection.
*/
async getContextContent(studyName: string): Promise<{
success: boolean;
study_name: string;
content: string;
files_read: Array<{
name: string;
extension: string;
size: number;
status: string;
characters?: number;
error?: string;
}>;
total_characters: number;
}> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/context/content`
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to get context content');
}
return response.json();
},
/**
* Finalize a Studio draft with rename support.
* Enhanced version that supports renaming draft_xxx to proper names.
*/
async finalizeStudio(
studyName: string,
request: {
topic: string;
newName?: string;
runBaseline?: boolean;
}
): Promise<{
success: boolean;
original_name: string;
final_name: string;
final_path: string;
status: string;
baseline_success: boolean | null;
readme_generated: boolean;
}> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/finalize/studio`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
topic: request.topic,
new_name: request.newName,
run_baseline: request.runBaseline ?? false,
}),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Studio finalization failed');
}
return response.json();
},
/**
* Get complete draft information for Studio UI.
* Convenience endpoint that returns everything the Studio needs.
*/
async getStudioDraft(studyName: string): Promise<{
success: boolean;
draft_id: string;
spec: Record<string, unknown>;
model_files: string[];
context_files: string[];
introspection_available: boolean;
design_variable_count: number;
objective_count: number;
}> {
const response = await fetch(
`${API_BASE}/intake/${encodeURIComponent(studyName)}/studio`
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to get studio draft');
}
return response.json();
},
};
export default intakeApi;

View File

@@ -777,6 +777,8 @@ function SpecRendererInner({
onConnect={onConnect}
onInit={(instance) => {
reactFlowInstance.current = instance;
// Auto-fit view on init with padding
setTimeout(() => instance.fitView({ padding: 0.2, duration: 300 }), 100);
}}
onDragOver={onDragOver}
onDrop={onDrop}
@@ -785,6 +787,7 @@ function SpecRendererInner({
onPaneClick={onPaneClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2, includeHiddenNodes: false }}
deleteKeyCode={null} // We handle delete ourselves
nodesDraggable={editable}
nodesConnectable={editable}

View File

@@ -0,0 +1,292 @@
/**
* ContextFileUpload - Upload context files for study configuration
*
* Allows uploading markdown, text, PDF, and image files that help
* Claude understand optimization goals and generate better documentation.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Upload, FileText, X, Loader2, AlertCircle, CheckCircle, Trash2, BookOpen } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface ContextFileUploadProps {
studyName: string;
onUploadComplete: () => void;
}
interface ContextFile {
name: string;
path: string;
size: number;
extension: string;
}
interface FileStatus {
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
message?: string;
}
const VALID_EXTENSIONS = ['.md', '.txt', '.pdf', '.png', '.jpg', '.jpeg', '.json', '.csv'];
export const ContextFileUpload: React.FC<ContextFileUploadProps> = ({
studyName,
onUploadComplete,
}) => {
const [contextFiles, setContextFiles] = useState<ContextFile[]>([]);
const [pendingFiles, setPendingFiles] = useState<FileStatus[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Load existing context files
const loadContextFiles = useCallback(async () => {
try {
const response = await intakeApi.listContextFiles(studyName);
setContextFiles(response.context_files);
} catch (err) {
console.error('Failed to load context files:', err);
}
}, [studyName]);
useEffect(() => {
loadContextFiles();
}, [loadContextFiles]);
const validateFile = (file: File): { valid: boolean; reason?: string } => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
if (!VALID_EXTENSIONS.includes(ext)) {
return { valid: false, reason: `Invalid type: ${ext}` };
}
// Max 10MB per file
if (file.size > 10 * 1024 * 1024) {
return { valid: false, reason: 'File too large (max 10MB)' };
}
return { valid: true };
};
const addFiles = useCallback((newFiles: File[]) => {
const validFiles: FileStatus[] = [];
for (const file of newFiles) {
// Skip duplicates
if (pendingFiles.some(f => f.file.name === file.name)) {
continue;
}
if (contextFiles.some(f => f.name === file.name)) {
continue;
}
const validation = validateFile(file);
if (validation.valid) {
validFiles.push({ file, status: 'pending' });
} else {
validFiles.push({ file, status: 'error', message: validation.reason });
}
}
setPendingFiles(prev => [...prev, ...validFiles]);
}, [pendingFiles, contextFiles]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
addFiles(selectedFiles);
e.target.value = '';
}, [addFiles]);
const removeFile = (index: number) => {
setPendingFiles(prev => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
const filesToUpload = pendingFiles.filter(f => f.status === 'pending');
if (filesToUpload.length === 0) return;
setIsUploading(true);
setError(null);
try {
const response = await intakeApi.uploadContextFiles(
studyName,
filesToUpload.map(f => f.file)
);
// Update pending file statuses
const uploadResults = new Map(
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
);
setPendingFiles(prev => prev.map(f => {
if (f.status !== 'pending') return f;
const success = uploadResults.get(f.file.name);
return {
...f,
status: success ? 'success' : 'error',
message: success ? undefined : 'Upload failed',
};
}));
// Refresh and clear after a moment
setTimeout(() => {
setPendingFiles(prev => prev.filter(f => f.status !== 'success'));
loadContextFiles();
onUploadComplete();
}, 1500);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
} finally {
setIsUploading(false);
}
};
const handleDeleteFile = async (filename: string) => {
try {
await intakeApi.deleteContextFile(studyName, filename);
loadContextFiles();
} catch (err) {
setError(err instanceof Error ? err.message : 'Delete failed');
}
};
const pendingCount = pendingFiles.filter(f => f.status === 'pending').length;
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
};
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
Context Files
</h5>
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium
bg-purple-500/10 text-purple-400 hover:bg-purple-500/20
transition-colors"
>
<Upload className="w-3 h-3" />
Add Context
</button>
</div>
<p className="text-xs text-dark-500">
Add .md, .txt, or .pdf files describing your optimization goals. Claude will use these to generate documentation.
</p>
{/* Error Display */}
{error && (
<div className="p-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-xs flex items-center gap-2">
<AlertCircle className="w-3 h-3 flex-shrink-0" />
{error}
<button onClick={() => setError(null)} className="ml-auto hover:text-white">
<X className="w-3 h-3" />
</button>
</div>
)}
{/* Existing Context Files */}
{contextFiles.length > 0 && (
<div className="space-y-1">
{contextFiles.map((file) => (
<div
key={file.name}
className="flex items-center justify-between p-2 rounded-lg bg-purple-500/5 border border-purple-500/20"
>
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-purple-400" />
<span className="text-sm text-white">{file.name}</span>
<span className="text-xs text-dark-500">{formatSize(file.size)}</span>
</div>
<button
onClick={() => handleDeleteFile(file.name)}
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-red-400"
title="Delete file"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
{/* Pending Files */}
{pendingFiles.length > 0 && (
<div className="space-y-1">
{pendingFiles.map((f, i) => (
<div
key={i}
className={`flex items-center justify-between p-2 rounded-lg
${f.status === 'error' ? 'bg-red-500/10' :
f.status === 'success' ? 'bg-green-500/10' :
'bg-dark-700'}`}
>
<div className="flex items-center gap-2">
{f.status === 'pending' && <FileText className="w-4 h-4 text-dark-400" />}
{f.status === 'uploading' && <Loader2 className="w-4 h-4 text-purple-400 animate-spin" />}
{f.status === 'success' && <CheckCircle className="w-4 h-4 text-green-400" />}
{f.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
<span className={`text-sm ${f.status === 'error' ? 'text-red-400' :
f.status === 'success' ? 'text-green-400' :
'text-white'}`}>
{f.file.name}
</span>
{f.message && (
<span className="text-xs text-red-400">({f.message})</span>
)}
</div>
{f.status === 'pending' && (
<button
onClick={() => removeFile(i)}
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-white"
>
<X className="w-3 h-3" />
</button>
)}
</div>
))}
</div>
)}
{/* Upload Button */}
{pendingCount > 0 && (
<button
onClick={handleUpload}
disabled={isUploading}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg
bg-purple-500 text-white text-sm font-medium
hover:bg-purple-400 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
</>
)}
</button>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={VALID_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
};
export default ContextFileUpload;

View File

@@ -0,0 +1,227 @@
/**
* CreateStudyCard - Card for initiating new study creation
*
* Displays a prominent card on the Home page that allows users to
* create a new study through the intake workflow.
*/
import React, { useState } from 'react';
import { Plus, Loader2 } from 'lucide-react';
import { intakeApi } from '../../api/intake';
import { TopicInfo } from '../../types/intake';
interface CreateStudyCardProps {
topics: TopicInfo[];
onStudyCreated: (studyName: string) => void;
}
export const CreateStudyCard: React.FC<CreateStudyCardProps> = ({
topics,
onStudyCreated,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [studyName, setStudyName] = useState('');
const [description, setDescription] = useState('');
const [selectedTopic, setSelectedTopic] = useState('');
const [newTopic, setNewTopic] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCreate = async () => {
if (!studyName.trim()) {
setError('Study name is required');
return;
}
// Validate study name format
const nameRegex = /^[a-z0-9_]+$/;
if (!nameRegex.test(studyName)) {
setError('Study name must be lowercase with underscores only (e.g., my_study_name)');
return;
}
setIsCreating(true);
setError(null);
try {
const topic = newTopic.trim() || selectedTopic || undefined;
await intakeApi.createInbox({
study_name: studyName.trim(),
description: description.trim() || undefined,
topic,
});
// Reset form
setStudyName('');
setDescription('');
setSelectedTopic('');
setNewTopic('');
setIsExpanded(false);
onStudyCreated(studyName.trim());
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create study');
} finally {
setIsCreating(false);
}
};
if (!isExpanded) {
return (
<button
onClick={() => setIsExpanded(true)}
className="w-full glass rounded-xl p-6 border border-dashed border-primary-400/30
hover:border-primary-400/60 hover:bg-primary-400/5 transition-all
flex items-center justify-center gap-3 group"
>
<div className="w-12 h-12 rounded-xl bg-primary-400/10 flex items-center justify-center
group-hover:bg-primary-400/20 transition-colors">
<Plus className="w-6 h-6 text-primary-400" />
</div>
<div className="text-left">
<h3 className="text-lg font-semibold text-white">Create New Study</h3>
<p className="text-sm text-dark-400">Set up a new optimization study</p>
</div>
</button>
);
}
return (
<div className="glass-strong rounded-xl border border-primary-400/20 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-primary-400/10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-400/10 flex items-center justify-center">
<Plus className="w-5 h-5 text-primary-400" />
</div>
<h3 className="text-lg font-semibold text-white">Create New Study</h3>
</div>
<button
onClick={() => setIsExpanded(false)}
className="text-dark-400 hover:text-white transition-colors text-sm"
>
Cancel
</button>
</div>
{/* Form */}
<div className="p-6 space-y-4">
{/* Study Name */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Study Name <span className="text-red-400">*</span>
</label>
<input
type="text"
value={studyName}
onChange={(e) => setStudyName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'))}
placeholder="my_optimization_study"
className="w-full px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white placeholder-dark-500 focus:border-primary-400
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
/>
<p className="mt-1 text-xs text-dark-500">
Lowercase letters, numbers, and underscores only
</p>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of the optimization goal..."
rows={2}
className="w-full px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white placeholder-dark-500 focus:border-primary-400
focus:outline-none focus:ring-1 focus:ring-primary-400/50 resize-none"
/>
</div>
{/* Topic Selection */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Topic Folder
</label>
<div className="flex gap-2">
<select
value={selectedTopic}
onChange={(e) => {
setSelectedTopic(e.target.value);
setNewTopic('');
}}
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white focus:border-primary-400 focus:outline-none
focus:ring-1 focus:ring-primary-400/50"
>
<option value="">Select existing topic...</option>
{topics.map((topic) => (
<option key={topic.name} value={topic.name}>
{topic.name} ({topic.study_count} studies)
</option>
))}
</select>
<span className="text-dark-500 self-center">or</span>
<input
type="text"
value={newTopic}
onChange={(e) => {
setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'));
setSelectedTopic('');
}}
placeholder="New_Topic"
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white placeholder-dark-500 focus:border-primary-400
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
/>
</div>
</div>
{/* Error Message */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
onClick={() => setIsExpanded(false)}
className="px-4 py-2 rounded-lg border border-dark-600 text-dark-300
hover:border-dark-500 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={isCreating || !studyName.trim()}
className="px-6 py-2 rounded-lg font-medium transition-all disabled:opacity-50
flex items-center gap-2"
style={{
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)',
color: '#000',
}}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="w-4 h-4" />
Create Study
</>
)}
</button>
</div>
</div>
</div>
);
};
export default CreateStudyCard;

View File

@@ -0,0 +1,270 @@
/**
* ExpressionList - Display discovered expressions with selection capability
*
* Shows expressions from NX introspection, allowing users to:
* - View all discovered expressions
* - See which are design variable candidates (auto-detected)
* - Select/deselect expressions to use as design variables
* - View expression values and units
*/
import React, { useState } from 'react';
import {
Check,
Search,
AlertTriangle,
Sparkles,
Info,
Variable,
} from 'lucide-react';
import { ExpressionInfo } from '../../types/intake';
interface ExpressionListProps {
/** Expression data from introspection */
expressions: ExpressionInfo[];
/** Mass from introspection (kg) */
massKg?: number | null;
/** Currently selected expressions (to become DVs) */
selectedExpressions: string[];
/** Callback when selection changes */
onSelectionChange: (selected: string[]) => void;
/** Whether in read-only mode */
readOnly?: boolean;
/** Compact display mode */
compact?: boolean;
}
export const ExpressionList: React.FC<ExpressionListProps> = ({
expressions,
massKg,
selectedExpressions,
onSelectionChange,
readOnly = false,
compact = false,
}) => {
const [filter, setFilter] = useState('');
const [showCandidatesOnly, setShowCandidatesOnly] = useState(true);
// Filter expressions based on search and candidate toggle
const filteredExpressions = expressions.filter((expr) => {
const matchesSearch = filter === '' ||
expr.name.toLowerCase().includes(filter.toLowerCase());
const matchesCandidate = !showCandidatesOnly || expr.is_candidate;
return matchesSearch && matchesCandidate;
});
// Sort: candidates first, then by confidence, then alphabetically
const sortedExpressions = [...filteredExpressions].sort((a, b) => {
if (a.is_candidate !== b.is_candidate) {
return a.is_candidate ? -1 : 1;
}
if (a.confidence !== b.confidence) {
return b.confidence - a.confidence;
}
return a.name.localeCompare(b.name);
});
const toggleExpression = (name: string) => {
if (readOnly) return;
if (selectedExpressions.includes(name)) {
onSelectionChange(selectedExpressions.filter(n => n !== name));
} else {
onSelectionChange([...selectedExpressions, name]);
}
};
const selectAllCandidates = () => {
const candidateNames = expressions
.filter(e => e.is_candidate)
.map(e => e.name);
onSelectionChange(candidateNames);
};
const clearSelection = () => {
onSelectionChange([]);
};
const candidateCount = expressions.filter(e => e.is_candidate).length;
if (expressions.length === 0) {
return (
<div className="p-4 rounded-lg bg-dark-700/50 border border-dark-600">
<div className="flex items-center gap-2 text-dark-400">
<AlertTriangle className="w-4 h-4" />
<span>No expressions found. Run introspection to discover model parameters.</span>
</div>
</div>
);
}
return (
<div className="space-y-3">
{/* Header with stats */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
<Variable className="w-4 h-4" />
Discovered Expressions
</h5>
<span className="text-xs text-dark-500">
{expressions.length} total, {candidateCount} candidates
</span>
{massKg && (
<span className="text-xs text-primary-400">
Mass: {massKg.toFixed(3)} kg
</span>
)}
</div>
{!readOnly && selectedExpressions.length > 0 && (
<span className="text-xs text-green-400">
{selectedExpressions.length} selected
</span>
)}
</div>
{/* Controls */}
{!compact && (
<div className="flex items-center gap-3">
{/* Search */}
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-dark-500" />
<input
type="text"
placeholder="Search expressions..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-lg bg-dark-700 border border-dark-600
text-white placeholder-dark-500 focus:border-primary-500/50 focus:outline-none"
/>
</div>
{/* Show candidates only toggle */}
<label className="flex items-center gap-2 text-xs text-dark-400 cursor-pointer">
<input
type="checkbox"
checked={showCandidatesOnly}
onChange={(e) => setShowCandidatesOnly(e.target.checked)}
className="w-4 h-4 rounded border-dark-500 bg-dark-700 text-primary-500
focus:ring-primary-500/30"
/>
Candidates only
</label>
{/* Quick actions */}
{!readOnly && (
<div className="flex items-center gap-2">
<button
onClick={selectAllCandidates}
className="px-2 py-1 text-xs rounded bg-primary-500/10 text-primary-400
hover:bg-primary-500/20 transition-colors"
>
Select all candidates
</button>
<button
onClick={clearSelection}
className="px-2 py-1 text-xs rounded bg-dark-600 text-dark-400
hover:bg-dark-500 transition-colors"
>
Clear
</button>
</div>
)}
</div>
)}
{/* Expression list */}
<div className={`rounded-lg border border-dark-600 overflow-hidden ${
compact ? 'max-h-48' : 'max-h-72'
} overflow-y-auto`}>
<table className="w-full text-sm">
<thead className="bg-dark-700 sticky top-0">
<tr>
{!readOnly && (
<th className="w-8 px-2 py-2"></th>
)}
<th className="px-3 py-2 text-left text-dark-400 font-medium">Name</th>
<th className="px-3 py-2 text-right text-dark-400 font-medium w-24">Value</th>
<th className="px-3 py-2 text-left text-dark-400 font-medium w-16">Units</th>
<th className="px-3 py-2 text-center text-dark-400 font-medium w-20">Candidate</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-700">
{sortedExpressions.map((expr) => {
const isSelected = selectedExpressions.includes(expr.name);
return (
<tr
key={expr.name}
onClick={() => toggleExpression(expr.name)}
className={`
${readOnly ? '' : 'cursor-pointer hover:bg-dark-700/50'}
${isSelected ? 'bg-primary-500/10' : ''}
transition-colors
`}
>
{!readOnly && (
<td className="px-2 py-2">
<div className={`w-5 h-5 rounded border flex items-center justify-center
${isSelected
? 'bg-primary-500 border-primary-500'
: 'border-dark-500 bg-dark-700'
}`}
>
{isSelected && <Check className="w-3 h-3 text-white" />}
</div>
</td>
)}
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<code className={`text-xs ${isSelected ? 'text-primary-300' : 'text-white'}`}>
{expr.name}
</code>
{expr.formula && (
<span className="text-xs text-dark-500" title={expr.formula}>
<Info className="w-3 h-3" />
</span>
)}
</div>
</td>
<td className="px-3 py-2 text-right font-mono text-xs text-dark-300">
{expr.value !== null ? expr.value.toFixed(3) : '-'}
</td>
<td className="px-3 py-2 text-xs text-dark-400">
{expr.units || '-'}
</td>
<td className="px-3 py-2 text-center">
{expr.is_candidate ? (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs
bg-green-500/10 text-green-400">
<Sparkles className="w-3 h-3" />
{Math.round(expr.confidence * 100)}%
</span>
) : (
<span className="text-xs text-dark-500">-</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{sortedExpressions.length === 0 && (
<div className="px-4 py-8 text-center text-dark-500">
No expressions match your filter
</div>
)}
</div>
{/* Help text */}
{!readOnly && !compact && (
<p className="text-xs text-dark-500">
Select expressions to use as design variables. Candidates (marked with %) are
automatically identified based on naming patterns and units.
</p>
)}
</div>
);
};
export default ExpressionList;

View File

@@ -0,0 +1,348 @@
/**
* FileDropzone - Drag and drop file upload component
*
* Supports drag-and-drop or click-to-browse for model files.
* Accepts .prt, .sim, .fem, .afem files.
*/
import React, { useState, useCallback, useRef } from 'react';
import { Upload, FileText, X, Loader2, AlertCircle, CheckCircle } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface FileDropzoneProps {
studyName: string;
onUploadComplete: () => void;
compact?: boolean;
}
interface FileStatus {
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
message?: string;
}
const VALID_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
export const FileDropzone: React.FC<FileDropzoneProps> = ({
studyName,
onUploadComplete,
compact = false,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<FileStatus[]>([]);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const validateFile = (file: File): { valid: boolean; reason?: string } => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
if (!VALID_EXTENSIONS.includes(ext)) {
return { valid: false, reason: `Invalid type: ${ext}` };
}
// Max 500MB per file
if (file.size > 500 * 1024 * 1024) {
return { valid: false, reason: 'File too large (max 500MB)' };
}
return { valid: true };
};
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const addFiles = useCallback((newFiles: File[]) => {
const validFiles: FileStatus[] = [];
for (const file of newFiles) {
// Skip duplicates
if (files.some(f => f.file.name === file.name)) {
continue;
}
const validation = validateFile(file);
if (validation.valid) {
validFiles.push({ file, status: 'pending' });
} else {
validFiles.push({ file, status: 'error', message: validation.reason });
}
}
setFiles(prev => [...prev, ...validFiles]);
}, [files]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
addFiles(droppedFiles);
}, [addFiles]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
addFiles(selectedFiles);
// Reset input so the same file can be selected again
e.target.value = '';
}, [addFiles]);
const removeFile = (index: number) => {
setFiles(prev => prev.filter((_, i) => i !== index));
};
const handleUpload = async () => {
const pendingFiles = files.filter(f => f.status === 'pending');
if (pendingFiles.length === 0) return;
setIsUploading(true);
setError(null);
try {
// Upload files
const response = await intakeApi.uploadFiles(
studyName,
pendingFiles.map(f => f.file)
);
// Update file statuses based on response
const uploadResults = new Map(
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
);
setFiles(prev => prev.map(f => {
if (f.status !== 'pending') return f;
const success = uploadResults.get(f.file.name);
return {
...f,
status: success ? 'success' : 'error',
message: success ? undefined : 'Upload failed',
};
}));
// Clear successful uploads after a moment and refresh
setTimeout(() => {
setFiles(prev => prev.filter(f => f.status !== 'success'));
onUploadComplete();
}, 1500);
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
setFiles(prev => prev.map(f =>
f.status === 'pending'
? { ...f, status: 'error', message: 'Upload failed' }
: f
));
} finally {
setIsUploading(false);
}
};
const pendingCount = files.filter(f => f.status === 'pending').length;
if (compact) {
// Compact inline version
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-dark-700 text-dark-300 hover:bg-dark-600 hover:text-white
transition-colors"
>
<Upload className="w-4 h-4" />
Add Files
</button>
{pendingCount > 0 && (
<button
onClick={handleUpload}
disabled={isUploading}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-primary-500/10 text-primary-400 hover:bg-primary-500/20
disabled:opacity-50 transition-colors"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
</button>
)}
</div>
{/* File list */}
{files.length > 0 && (
<div className="flex flex-wrap gap-2">
{files.map((f, i) => (
<span
key={i}
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs
${f.status === 'error' ? 'bg-red-500/10 text-red-400' :
f.status === 'success' ? 'bg-green-500/10 text-green-400' :
'bg-dark-700 text-dark-300'}`}
>
{f.status === 'uploading' && <Loader2 className="w-3 h-3 animate-spin" />}
{f.status === 'success' && <CheckCircle className="w-3 h-3" />}
{f.status === 'error' && <AlertCircle className="w-3 h-3" />}
{f.file.name}
{f.status === 'pending' && (
<button onClick={() => removeFile(i)} className="hover:text-white">
<X className="w-3 h-3" />
</button>
)}
</span>
))}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={VALID_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
}
// Full dropzone version
return (
<div className="space-y-4">
{/* Dropzone */}
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`
relative border-2 border-dashed rounded-xl p-6 cursor-pointer
transition-all duration-200
${isDragging
? 'border-primary-400 bg-primary-400/5'
: 'border-dark-600 hover:border-primary-400/50 hover:bg-white/5'
}
`}
>
<div className="flex flex-col items-center text-center">
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3
${isDragging ? 'bg-primary-400/20 text-primary-400' : 'bg-dark-700 text-dark-400'}`}>
<Upload className="w-6 h-6" />
</div>
<p className="text-white font-medium mb-1">
{isDragging ? 'Drop files here' : 'Drop model files here'}
</p>
<p className="text-sm text-dark-400">
or <span className="text-primary-400">click to browse</span>
</p>
<p className="text-xs text-dark-500 mt-2">
Accepts: {VALID_EXTENSIONS.join(', ')}
</p>
</div>
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* File List */}
{files.length > 0 && (
<div className="space-y-2">
<h5 className="text-sm font-medium text-dark-300">Files to Upload</h5>
<div className="space-y-1">
{files.map((f, i) => (
<div
key={i}
className={`flex items-center justify-between p-2 rounded-lg
${f.status === 'error' ? 'bg-red-500/10' :
f.status === 'success' ? 'bg-green-500/10' :
'bg-dark-700'}`}
>
<div className="flex items-center gap-2">
{f.status === 'pending' && <FileText className="w-4 h-4 text-dark-400" />}
{f.status === 'uploading' && <Loader2 className="w-4 h-4 text-primary-400 animate-spin" />}
{f.status === 'success' && <CheckCircle className="w-4 h-4 text-green-400" />}
{f.status === 'error' && <AlertCircle className="w-4 h-4 text-red-400" />}
<span className={`text-sm ${f.status === 'error' ? 'text-red-400' :
f.status === 'success' ? 'text-green-400' :
'text-white'}`}>
{f.file.name}
</span>
{f.message && (
<span className="text-xs text-red-400">({f.message})</span>
)}
</div>
{f.status === 'pending' && (
<button
onClick={(e) => {
e.stopPropagation();
removeFile(i);
}}
className="p-1 hover:bg-white/10 rounded text-dark-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
))}
</div>
{/* Upload Button */}
{pendingCount > 0 && (
<button
onClick={handleUpload}
disabled={isUploading}
className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg
bg-primary-500 text-white font-medium
hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {pendingCount} {pendingCount === 1 ? 'File' : 'Files'}
</>
)}
</button>
)}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={VALID_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
};
export default FileDropzone;

View File

@@ -0,0 +1,272 @@
/**
* FinalizeModal - Modal for finalizing an inbox study
*
* Allows user to:
* - Select/create topic folder
* - Choose whether to run baseline FEA
* - See progress during finalization
*/
import React, { useState, useEffect } from 'react';
import {
X,
Folder,
CheckCircle,
Loader2,
AlertCircle,
} from 'lucide-react';
import { intakeApi } from '../../api/intake';
import { TopicInfo, InboxStudyDetail } from '../../types/intake';
interface FinalizeModalProps {
studyName: string;
topics: TopicInfo[];
onClose: () => void;
onFinalized: (finalPath: string) => void;
}
export const FinalizeModal: React.FC<FinalizeModalProps> = ({
studyName,
topics,
onClose,
onFinalized,
}) => {
const [studyDetail, setStudyDetail] = useState<InboxStudyDetail | null>(null);
const [selectedTopic, setSelectedTopic] = useState('');
const [newTopic, setNewTopic] = useState('');
const [runBaseline, setRunBaseline] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [isFinalizing, setIsFinalizing] = useState(false);
const [progress, setProgress] = useState<string>('');
const [error, setError] = useState<string | null>(null);
// Load study detail
useEffect(() => {
const loadStudy = async () => {
try {
const detail = await intakeApi.getInboxStudy(studyName);
setStudyDetail(detail);
// Pre-select topic if set in spec
if (detail.spec.meta.topic) {
setSelectedTopic(detail.spec.meta.topic);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load study');
} finally {
setIsLoading(false);
}
};
loadStudy();
}, [studyName]);
const handleFinalize = async () => {
const topic = newTopic.trim() || selectedTopic;
if (!topic) {
setError('Please select or create a topic folder');
return;
}
setIsFinalizing(true);
setError(null);
setProgress('Starting finalization...');
try {
setProgress('Validating study configuration...');
await new Promise((r) => setTimeout(r, 500)); // Visual feedback
if (runBaseline) {
setProgress('Running baseline FEA solve...');
}
const result = await intakeApi.finalize(studyName, {
topic,
run_baseline: runBaseline,
});
setProgress('Finalization complete!');
await new Promise((r) => setTimeout(r, 500));
onFinalized(result.final_path);
} catch (err) {
setError(err instanceof Error ? err.message : 'Finalization failed');
setIsFinalizing(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-900/80 backdrop-blur-sm">
<div className="w-full max-w-lg glass-strong rounded-xl border border-primary-400/20 overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-primary-400/10 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-primary-400/10 flex items-center justify-center">
<Folder className="w-5 h-5 text-primary-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-white">Finalize Study</h3>
<p className="text-sm text-dark-400">{studyName}</p>
</div>
</div>
{!isFinalizing && (
<button
onClick={onClose}
className="p-2 hover:bg-white/5 rounded-lg transition-colors text-dark-400 hover:text-white"
>
<X className="w-5 h-5" />
</button>
)}
</div>
{/* Content */}
<div className="p-6 space-y-6">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-primary-400" />
</div>
) : isFinalizing ? (
/* Progress View */
<div className="text-center py-8 space-y-4">
<Loader2 className="w-12 h-12 animate-spin text-primary-400 mx-auto" />
<p className="text-white font-medium">{progress}</p>
<p className="text-sm text-dark-400">
Please wait while your study is being finalized...
</p>
</div>
) : (
<>
{/* Error Display */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Study Summary */}
{studyDetail && (
<div className="p-4 rounded-lg bg-dark-800 space-y-2">
<h4 className="text-sm font-medium text-dark-300">Study Summary</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-dark-500">Status:</span>
<span className="ml-2 text-white capitalize">
{studyDetail.spec.meta.status}
</span>
</div>
<div>
<span className="text-dark-500">Model Files:</span>
<span className="ml-2 text-white">
{studyDetail.files.sim.length + studyDetail.files.prt.length + studyDetail.files.fem.length}
</span>
</div>
<div>
<span className="text-dark-500">Design Variables:</span>
<span className="ml-2 text-white">
{studyDetail.spec.design_variables?.length || 0}
</span>
</div>
<div>
<span className="text-dark-500">Objectives:</span>
<span className="ml-2 text-white">
{studyDetail.spec.objectives?.length || 0}
</span>
</div>
</div>
</div>
)}
{/* Topic Selection */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Topic Folder <span className="text-red-400">*</span>
</label>
<div className="flex gap-2">
<select
value={selectedTopic}
onChange={(e) => {
setSelectedTopic(e.target.value);
setNewTopic('');
}}
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white focus:border-primary-400 focus:outline-none
focus:ring-1 focus:ring-primary-400/50"
>
<option value="">Select existing topic...</option>
{topics.map((topic) => (
<option key={topic.name} value={topic.name}>
{topic.name} ({topic.study_count} studies)
</option>
))}
</select>
<span className="text-dark-500 self-center">or</span>
<input
type="text"
value={newTopic}
onChange={(e) => {
setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'));
setSelectedTopic('');
}}
placeholder="New_Topic"
className="flex-1 px-4 py-2.5 rounded-lg bg-dark-800 border border-dark-600
text-white placeholder-dark-500 focus:border-primary-400
focus:outline-none focus:ring-1 focus:ring-primary-400/50"
/>
</div>
<p className="mt-1 text-xs text-dark-500">
Study will be created at: studies/{newTopic || selectedTopic || '<topic>'}/{studyName}/
</p>
</div>
{/* Baseline Option */}
<div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={runBaseline}
onChange={(e) => setRunBaseline(e.target.checked)}
className="w-4 h-4 rounded border-dark-600 bg-dark-800 text-primary-400
focus:ring-primary-400/50"
/>
<div>
<span className="text-white font-medium">Run baseline FEA solve</span>
<p className="text-xs text-dark-500">
Validates the model and captures baseline performance metrics
</p>
</div>
</label>
</div>
</>
)}
</div>
{/* Footer */}
{!isLoading && !isFinalizing && (
<div className="px-6 py-4 border-t border-primary-400/10 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg border border-dark-600 text-dark-300
hover:border-dark-500 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleFinalize}
disabled={!selectedTopic && !newTopic.trim()}
className="px-6 py-2 rounded-lg font-medium transition-all disabled:opacity-50
flex items-center gap-2"
style={{
background: 'linear-gradient(135deg, #00d4e6 0%, #0891b2 100%)',
color: '#000',
}}
>
<CheckCircle className="w-4 h-4" />
Finalize Study
</button>
</div>
)}
</div>
</div>
);
};
export default FinalizeModal;

View File

@@ -0,0 +1,147 @@
/**
* InboxSection - Section displaying inbox studies on Home page
*
* Shows the "Create New Study" card and lists all inbox studies
* with their current status and available actions.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Inbox, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react';
import { intakeApi } from '../../api/intake';
import { InboxStudy, TopicInfo } from '../../types/intake';
import { CreateStudyCard } from './CreateStudyCard';
import { InboxStudyCard } from './InboxStudyCard';
import { FinalizeModal } from './FinalizeModal';
interface InboxSectionProps {
onStudyFinalized?: () => void;
}
export const InboxSection: React.FC<InboxSectionProps> = ({ onStudyFinalized }) => {
const [inboxStudies, setInboxStudies] = useState<InboxStudy[]>([]);
const [topics, setTopics] = useState<TopicInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isExpanded, setIsExpanded] = useState(true);
const [selectedStudyForFinalize, setSelectedStudyForFinalize] = useState<string | null>(null);
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [inboxResponse, topicsResponse] = await Promise.all([
intakeApi.listInbox(),
intakeApi.listTopics(),
]);
setInboxStudies(inboxResponse.studies);
setTopics(topicsResponse.topics);
} catch (err) {
console.error('Failed to load inbox data:', err);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const handleStudyCreated = (_studyName: string) => {
loadData();
};
const handleStudyFinalized = (_finalPath: string) => {
setSelectedStudyForFinalize(null);
loadData();
onStudyFinalized?.();
};
const pendingStudies = inboxStudies.filter(
(s) => !['ready', 'running', 'completed'].includes(s.status)
);
return (
<div className="space-y-4">
{/* Section Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-2 py-1 hover:bg-white/5 rounded-lg transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-primary-400/10 flex items-center justify-center">
<Inbox className="w-4 h-4 text-primary-400" />
</div>
<div className="text-left">
<h2 className="text-lg font-semibold text-white">Study Inbox</h2>
<p className="text-sm text-dark-400">
{pendingStudies.length} pending studies
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
loadData();
}}
className="p-2 hover:bg-white/5 rounded-lg transition-colors text-dark-400 hover:text-primary-400"
title="Refresh"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
{isExpanded ? (
<ChevronDown className="w-5 h-5 text-dark-400" />
) : (
<ChevronRight className="w-5 h-5 text-dark-400" />
)}
</div>
</button>
{/* Content */}
{isExpanded && (
<div className="space-y-4">
{/* Create Study Card */}
<CreateStudyCard topics={topics} onStudyCreated={handleStudyCreated} />
{/* Inbox Studies List */}
{inboxStudies.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-dark-400 px-2">
Inbox Studies ({inboxStudies.length})
</h3>
{inboxStudies.map((study) => (
<InboxStudyCard
key={study.study_name}
study={study}
onRefresh={loadData}
onSelect={setSelectedStudyForFinalize}
/>
))}
</div>
)}
{/* Empty State */}
{!isLoading && inboxStudies.length === 0 && (
<div className="text-center py-8 text-dark-400">
<Inbox className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No studies in inbox</p>
<p className="text-sm text-dark-500">
Create a new study to get started
</p>
</div>
)}
</div>
)}
{/* Finalize Modal */}
{selectedStudyForFinalize && (
<FinalizeModal
studyName={selectedStudyForFinalize}
topics={topics}
onClose={() => setSelectedStudyForFinalize(null)}
onFinalized={handleStudyFinalized}
/>
)}
</div>
);
};
export default InboxSection;

View File

@@ -0,0 +1,455 @@
/**
* InboxStudyCard - Card displaying an inbox study with actions
*
* Shows study status, files, and provides actions for:
* - Running introspection
* - Generating README
* - Finalizing the study
*/
import React, { useState, useEffect } from 'react';
import {
FileText,
Folder,
Trash2,
Play,
CheckCircle,
Clock,
AlertCircle,
Loader2,
ChevronDown,
ChevronRight,
Sparkles,
ArrowRight,
Eye,
Save,
} from 'lucide-react';
import { InboxStudy, SpecStatus, ExpressionInfo, InboxStudyDetail } from '../../types/intake';
import { intakeApi } from '../../api/intake';
import { FileDropzone } from './FileDropzone';
import { ContextFileUpload } from './ContextFileUpload';
import { ExpressionList } from './ExpressionList';
interface InboxStudyCardProps {
study: InboxStudy;
onRefresh: () => void;
onSelect: (studyName: string) => void;
}
const statusConfig: Record<SpecStatus, { icon: React.ReactNode; color: string; label: string }> = {
draft: {
icon: <Clock className="w-4 h-4" />,
color: 'text-dark-400 bg-dark-600',
label: 'Draft',
},
introspected: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-blue-400 bg-blue-500/10',
label: 'Introspected',
},
configured: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-green-400 bg-green-500/10',
label: 'Configured',
},
validated: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-green-400 bg-green-500/10',
label: 'Validated',
},
ready: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-primary-400 bg-primary-500/10',
label: 'Ready',
},
running: {
icon: <Play className="w-4 h-4" />,
color: 'text-yellow-400 bg-yellow-500/10',
label: 'Running',
},
completed: {
icon: <CheckCircle className="w-4 h-4" />,
color: 'text-green-400 bg-green-500/10',
label: 'Completed',
},
failed: {
icon: <AlertCircle className="w-4 h-4" />,
color: 'text-red-400 bg-red-500/10',
label: 'Failed',
},
};
export const InboxStudyCard: React.FC<InboxStudyCardProps> = ({
study,
onRefresh,
onSelect,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
const [isIntrospecting, setIsIntrospecting] = useState(false);
const [isGeneratingReadme, setIsGeneratingReadme] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Introspection data (fetched when expanded)
const [studyDetail, setStudyDetail] = useState<InboxStudyDetail | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
const [selectedExpressions, setSelectedExpressions] = useState<string[]>([]);
const [showReadme, setShowReadme] = useState(false);
const [readmeContent, setReadmeContent] = useState<string | null>(null);
const [isSavingDVs, setIsSavingDVs] = useState(false);
const [dvSaveMessage, setDvSaveMessage] = useState<string | null>(null);
const status = statusConfig[study.status] || statusConfig.draft;
// Fetch study details when expanded for the first time
useEffect(() => {
if (isExpanded && !studyDetail && !isLoadingDetail) {
loadStudyDetail();
}
}, [isExpanded]);
const loadStudyDetail = async () => {
setIsLoadingDetail(true);
try {
const detail = await intakeApi.getInboxStudy(study.study_name);
setStudyDetail(detail);
// Auto-select candidate expressions
const introspection = detail.spec?.model?.introspection;
if (introspection?.expressions) {
const candidates = introspection.expressions
.filter((e: ExpressionInfo) => e.is_candidate)
.map((e: ExpressionInfo) => e.name);
setSelectedExpressions(candidates);
}
} catch (err) {
console.error('Failed to load study detail:', err);
} finally {
setIsLoadingDetail(false);
}
};
const handleIntrospect = async () => {
setIsIntrospecting(true);
setError(null);
try {
await intakeApi.introspect({ study_name: study.study_name });
// Reload study detail to get new introspection data
await loadStudyDetail();
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Introspection failed');
} finally {
setIsIntrospecting(false);
}
};
const handleGenerateReadme = async () => {
setIsGeneratingReadme(true);
setError(null);
try {
const response = await intakeApi.generateReadme(study.study_name);
setReadmeContent(response.content);
setShowReadme(true);
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'README generation failed');
} finally {
setIsGeneratingReadme(false);
}
};
const handleDelete = async () => {
if (!confirm(`Delete inbox study "${study.study_name}"? This cannot be undone.`)) {
return;
}
setIsDeleting(true);
try {
await intakeApi.deleteInboxStudy(study.study_name);
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Delete failed');
setIsDeleting(false);
}
};
const handleSaveDesignVariables = async () => {
if (selectedExpressions.length === 0) {
setError('Please select at least one expression to use as a design variable');
return;
}
setIsSavingDVs(true);
setError(null);
setDvSaveMessage(null);
try {
const result = await intakeApi.createDesignVariables(study.study_name, selectedExpressions);
setDvSaveMessage(`Created ${result.total_created} design variable(s)`);
// Reload study detail to see updated spec
await loadStudyDetail();
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save design variables');
} finally {
setIsSavingDVs(false);
}
};
const canIntrospect = study.status === 'draft' && study.model_files.length > 0;
const canGenerateReadme = study.status === 'introspected';
const canFinalize = ['introspected', 'configured'].includes(study.status);
const canSaveDVs = study.status === 'introspected' && selectedExpressions.length > 0;
return (
<div className="glass rounded-xl border border-primary-400/10 overflow-hidden">
{/* Header - Always visible */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-dark-700 flex items-center justify-center">
<Folder className="w-5 h-5 text-primary-400" />
</div>
<div className="text-left">
<h4 className="text-white font-medium">{study.study_name}</h4>
{study.description && (
<p className="text-sm text-dark-400 truncate max-w-[300px]">
{study.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-3">
{/* Status Badge */}
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${status.color}`}>
{status.icon}
{status.label}
</span>
{/* File Count */}
<span className="text-dark-500 text-sm">
{study.model_files.length} files
</span>
{/* Expand Icon */}
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-dark-400" />
) : (
<ChevronRight className="w-4 h-4 text-dark-400" />
)}
</div>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="px-4 pb-4 space-y-4 border-t border-primary-400/10 pt-4">
{/* Error Display */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
{/* Success Message */}
{dvSaveMessage && (
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-sm flex items-center gap-2">
<CheckCircle className="w-4 h-4 flex-shrink-0" />
{dvSaveMessage}
</div>
)}
{/* Files Section */}
{study.model_files.length > 0 && (
<div>
<h5 className="text-sm font-medium text-dark-300 mb-2">Model Files</h5>
<div className="flex flex-wrap gap-2">
{study.model_files.map((file) => (
<span
key={file}
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-dark-700 text-dark-300 text-xs"
>
<FileText className="w-3 h-3" />
{file}
</span>
))}
</div>
</div>
)}
{/* Model File Upload Section */}
<div>
<h5 className="text-sm font-medium text-dark-300 mb-2">Upload Model Files</h5>
<FileDropzone
studyName={study.study_name}
onUploadComplete={onRefresh}
compact={true}
/>
</div>
{/* Context File Upload Section */}
<ContextFileUpload
studyName={study.study_name}
onUploadComplete={onRefresh}
/>
{/* Introspection Results - Expressions */}
{isLoadingDetail && (
<div className="flex items-center gap-2 text-dark-400 text-sm py-4">
<Loader2 className="w-4 h-4 animate-spin" />
Loading introspection data...
</div>
)}
{studyDetail?.spec?.model?.introspection?.expressions &&
studyDetail.spec.model.introspection.expressions.length > 0 && (
<ExpressionList
expressions={studyDetail.spec.model.introspection.expressions}
massKg={studyDetail.spec.model.introspection.mass_kg}
selectedExpressions={selectedExpressions}
onSelectionChange={setSelectedExpressions}
readOnly={study.status === 'configured'}
compact={true}
/>
)}
{/* README Preview Section */}
{(readmeContent || study.status === 'configured') && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-dark-300 flex items-center gap-2">
<FileText className="w-4 h-4" />
README.md
</h5>
<button
onClick={() => setShowReadme(!showReadme)}
className="flex items-center gap-1 px-2 py-1 text-xs rounded bg-dark-600
text-dark-300 hover:bg-dark-500 transition-colors"
>
<Eye className="w-3 h-3" />
{showReadme ? 'Hide' : 'Preview'}
</button>
</div>
{showReadme && readmeContent && (
<div className="max-h-64 overflow-y-auto rounded-lg border border-dark-600
bg-dark-800 p-4">
<pre className="text-xs text-dark-300 whitespace-pre-wrap font-mono">
{readmeContent}
</pre>
</div>
)}
</div>
)}
{/* No Files Warning */}
{study.model_files.length === 0 && (
<div className="p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-yellow-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
No model files found. Upload .prt, .sim, or .fem files to continue.
</div>
)}
{/* Actions */}
<div className="flex flex-wrap gap-2">
{/* Introspect */}
{canIntrospect && (
<button
onClick={handleIntrospect}
disabled={isIntrospecting}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-blue-500/10 text-blue-400 hover:bg-blue-500/20
disabled:opacity-50 transition-colors"
>
{isIntrospecting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Play className="w-4 h-4" />
)}
Introspect Model
</button>
)}
{/* Save Design Variables */}
{canSaveDVs && (
<button
onClick={handleSaveDesignVariables}
disabled={isSavingDVs}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-green-500/10 text-green-400 hover:bg-green-500/20
disabled:opacity-50 transition-colors"
>
{isSavingDVs ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save as DVs ({selectedExpressions.length})
</button>
)}
{/* Generate README */}
{canGenerateReadme && (
<button
onClick={handleGenerateReadme}
disabled={isGeneratingReadme}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-purple-500/10 text-purple-400 hover:bg-purple-500/20
disabled:opacity-50 transition-colors"
>
{isGeneratingReadme ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
Generate README
</button>
)}
{/* Finalize */}
{canFinalize && (
<button
onClick={() => onSelect(study.study_name)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-primary-500/10 text-primary-400 hover:bg-primary-500/20
transition-colors"
>
<ArrowRight className="w-4 h-4" />
Finalize Study
</button>
)}
{/* Delete */}
<button
onClick={handleDelete}
disabled={isDeleting}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm font-medium
bg-red-500/10 text-red-400 hover:bg-red-500/20
disabled:opacity-50 transition-colors ml-auto"
>
{isDeleting ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Delete
</button>
</div>
{/* Workflow Hint */}
{study.status === 'draft' && study.model_files.length > 0 && (
<p className="text-xs text-dark-500">
Next step: Run introspection to discover expressions and model properties.
</p>
)}
{study.status === 'introspected' && (
<p className="text-xs text-dark-500">
Next step: Generate README with Claude AI, then finalize to create the study.
</p>
)}
</div>
)}
</div>
);
};
export default InboxStudyCard;

View File

@@ -0,0 +1,13 @@
/**
* Intake Components Index
*
* Export all intake workflow components.
*/
export { CreateStudyCard } from './CreateStudyCard';
export { InboxStudyCard } from './InboxStudyCard';
export { FinalizeModal } from './FinalizeModal';
export { InboxSection } from './InboxSection';
export { FileDropzone } from './FileDropzone';
export { ContextFileUpload } from './ContextFileUpload';
export { ExpressionList } from './ExpressionList';

View File

@@ -0,0 +1,254 @@
/**
* StudioBuildDialog - Final dialog to name and build the study
*/
import React, { useState, useEffect } from 'react';
import { X, Loader2, FolderOpen, AlertCircle, CheckCircle, Sparkles, Play } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface StudioBuildDialogProps {
draftId: string;
onClose: () => void;
onBuildComplete: (finalPath: string, finalName: string) => void;
}
interface Topic {
name: string;
study_count: number;
}
export const StudioBuildDialog: React.FC<StudioBuildDialogProps> = ({
draftId,
onClose,
onBuildComplete,
}) => {
const [studyName, setStudyName] = useState('');
const [topic, setTopic] = useState('');
const [newTopic, setNewTopic] = useState('');
const [useNewTopic, setUseNewTopic] = useState(false);
const [topics, setTopics] = useState<Topic[]>([]);
const [isBuilding, setIsBuilding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [validationErrors, setValidationErrors] = useState<string[]>([]);
// Load topics
useEffect(() => {
loadTopics();
}, []);
const loadTopics = async () => {
try {
const response = await intakeApi.listTopics();
setTopics(response.topics);
if (response.topics.length > 0) {
setTopic(response.topics[0].name);
}
} catch (err) {
console.error('Failed to load topics:', err);
}
};
// Validate study name
useEffect(() => {
const errors: string[] = [];
if (studyName.length > 0) {
if (studyName.length < 3) {
errors.push('Name must be at least 3 characters');
}
if (!/^[a-z0-9_]+$/.test(studyName)) {
errors.push('Use only lowercase letters, numbers, and underscores');
}
if (studyName.startsWith('draft_')) {
errors.push('Name cannot start with "draft_"');
}
}
setValidationErrors(errors);
}, [studyName]);
const handleBuild = async () => {
const finalTopic = useNewTopic ? newTopic : topic;
if (!studyName || !finalTopic) {
setError('Please provide both a study name and topic');
return;
}
if (validationErrors.length > 0) {
setError('Please fix validation errors');
return;
}
setIsBuilding(true);
setError(null);
try {
const response = await intakeApi.finalizeStudio(draftId, {
topic: finalTopic,
newName: studyName,
runBaseline: false,
});
onBuildComplete(response.final_path, response.final_name);
} catch (err) {
setError(err instanceof Error ? err.message : 'Build failed');
} finally {
setIsBuilding(false);
}
};
const isValid = studyName.length >= 3 &&
validationErrors.length === 0 &&
(topic || (useNewTopic && newTopic));
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-dark-850 border border-dark-700 rounded-xl shadow-xl w-full max-w-lg mx-4">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">Build Study</h2>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-dark-700 rounded text-dark-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Study Name */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Study Name
</label>
<input
type="text"
value={studyName}
onChange={(e) => setStudyName(e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '_'))}
placeholder="my_optimization_study"
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white placeholder-dark-500 focus:outline-none focus:border-primary-400"
/>
{validationErrors.length > 0 && (
<div className="mt-2 space-y-1">
{validationErrors.map((err, i) => (
<p key={i} className="text-xs text-red-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{err}
</p>
))}
</div>
)}
{studyName.length >= 3 && validationErrors.length === 0 && (
<p className="mt-2 text-xs text-green-400 flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Name is valid
</p>
)}
</div>
{/* Topic Selection */}
<div>
<label className="block text-sm font-medium text-dark-300 mb-2">
Topic Folder
</label>
{!useNewTopic && topics.length > 0 && (
<div className="space-y-2">
<select
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white focus:outline-none focus:border-primary-400"
>
{topics.map((t) => (
<option key={t.name} value={t.name}>
{t.name} ({t.study_count} studies)
</option>
))}
</select>
<button
onClick={() => setUseNewTopic(true)}
className="text-sm text-primary-400 hover:text-primary-300"
>
+ Create new topic
</button>
</div>
)}
{(useNewTopic || topics.length === 0) && (
<div className="space-y-2">
<input
type="text"
value={newTopic}
onChange={(e) => setNewTopic(e.target.value.replace(/[^A-Za-z0-9_]/g, '_'))}
placeholder="NewTopic"
className="w-full bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-white placeholder-dark-500 focus:outline-none focus:border-primary-400"
/>
{topics.length > 0 && (
<button
onClick={() => setUseNewTopic(false)}
className="text-sm text-dark-400 hover:text-white"
>
Use existing topic
</button>
)}
</div>
)}
</div>
{/* Preview */}
<div className="p-3 bg-dark-700/50 rounded-lg">
<p className="text-xs text-dark-400 mb-1">Study will be created at:</p>
<p className="text-sm text-white font-mono flex items-center gap-2">
<FolderOpen className="w-4 h-4 text-primary-400" />
studies/{useNewTopic ? newTopic || '...' : topic}/{studyName || '...'}
</p>
</div>
{/* Error */}
{error && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-4 border-t border-dark-700">
<button
onClick={onClose}
disabled={isBuilding}
className="px-4 py-2 text-sm text-dark-300 hover:text-white hover:bg-dark-700 rounded-lg transition-colors"
>
Cancel
</button>
<button
onClick={handleBuild}
disabled={!isValid || isBuilding}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-primary-500 text-white rounded-lg hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isBuilding ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Building...
</>
) : (
<>
<Play className="w-4 h-4" />
Build Study
</>
)}
</button>
</div>
</div>
</div>
);
};
export default StudioBuildDialog;

View File

@@ -0,0 +1,375 @@
/**
* StudioChat - Context-aware AI chat for Studio
*
* Uses the existing useChat hook to communicate with Claude via WebSocket.
* Injects model files and context documents into the conversation.
*/
import React, { useRef, useEffect, useState, useMemo } from 'react';
import { Send, Loader2, Sparkles, FileText, Wifi, WifiOff, Bot, User, File, AlertCircle } from 'lucide-react';
import { useChat } from '../../hooks/useChat';
import { useSpecStore, useSpec } from '../../hooks/useSpecStore';
import { MarkdownRenderer } from '../MarkdownRenderer';
import { ToolCallCard } from '../chat/ToolCallCard';
interface StudioChatProps {
draftId: string;
contextFiles: string[];
contextContent: string;
modelFiles: string[];
onSpecUpdated: () => void;
}
export const StudioChat: React.FC<StudioChatProps> = ({
draftId,
contextFiles,
contextContent,
modelFiles,
onSpecUpdated,
}) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const [input, setInput] = useState('');
const [hasInjectedContext, setHasInjectedContext] = useState(false);
// Get spec store for canvas updates
const spec = useSpec();
const { reloadSpec, setSpecFromWebSocket } = useSpecStore();
// Build canvas state with full context for Claude
const canvasState = useMemo(() => ({
nodes: [],
edges: [],
studyName: draftId,
studyPath: `_inbox/${draftId}`,
// Include file info for Claude context
modelFiles,
contextFiles,
contextContent: contextContent.substring(0, 50000), // Limit context size
}), [draftId, modelFiles, contextFiles, contextContent]);
// Use the chat hook with WebSocket
// Power mode gives Claude write permissions to modify the spec
const {
messages,
isThinking,
error,
isConnected,
sendMessage,
updateCanvasState,
} = useChat({
studyId: draftId,
mode: 'power', // Power mode = --dangerously-skip-permissions = can write files
useWebSocket: true,
canvasState,
onError: (err) => console.error('[StudioChat] Error:', err),
onSpecUpdated: (newSpec) => {
// Claude modified the spec - update the store directly
console.log('[StudioChat] Spec updated by Claude');
setSpecFromWebSocket(newSpec, draftId);
onSpecUpdated();
},
onCanvasModification: (modification) => {
// Claude wants to modify canvas - reload the spec
console.log('[StudioChat] Canvas modification:', modification);
reloadSpec();
onSpecUpdated();
},
});
// Update canvas state when context changes
useEffect(() => {
updateCanvasState(canvasState);
}, [canvasState, updateCanvasState]);
// Scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// Auto-focus input
useEffect(() => {
inputRef.current?.focus();
}, []);
// Build context summary for display
const contextSummary = useMemo(() => {
const parts: string[] = [];
if (modelFiles.length > 0) {
parts.push(`${modelFiles.length} model file${modelFiles.length > 1 ? 's' : ''}`);
}
if (contextFiles.length > 0) {
parts.push(`${contextFiles.length} context doc${contextFiles.length > 1 ? 's' : ''}`);
}
if (contextContent) {
parts.push(`${contextContent.length.toLocaleString()} chars context`);
}
return parts.join(', ');
}, [modelFiles, contextFiles, contextContent]);
const handleSend = () => {
if (!input.trim() || isThinking) return;
let messageToSend = input.trim();
// On first message, inject full context so Claude has everything it needs
if (!hasInjectedContext && (modelFiles.length > 0 || contextContent)) {
const contextParts: string[] = [];
// Add model files info
if (modelFiles.length > 0) {
contextParts.push(`**Model Files Uploaded:**\n${modelFiles.map(f => `- ${f}`).join('\n')}`);
}
// Add context document content (full text)
if (contextContent) {
contextParts.push(`**Context Documents Content:**\n\`\`\`\n${contextContent.substring(0, 30000)}\n\`\`\``);
}
// Add current spec state
if (spec) {
const dvCount = spec.design_variables?.length || 0;
const objCount = spec.objectives?.length || 0;
const extCount = spec.extractors?.length || 0;
if (dvCount > 0 || objCount > 0 || extCount > 0) {
contextParts.push(`**Current Configuration:** ${dvCount} design variables, ${objCount} objectives, ${extCount} extractors`);
}
}
if (contextParts.length > 0) {
messageToSend = `${contextParts.join('\n\n')}\n\n---\n\n**User Request:** ${messageToSend}`;
}
setHasInjectedContext(true);
}
sendMessage(messageToSend);
setInput('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Welcome message for empty state
const showWelcome = messages.length === 0;
// Check if we have any context
const hasContext = modelFiles.length > 0 || contextContent.length > 0;
return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="p-3 border-b border-dark-700 flex-shrink-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-primary-400" />
<span className="font-medium text-white">Studio Assistant</span>
</div>
<span className={`flex items-center gap-1 text-xs px-2 py-0.5 rounded ${
isConnected
? 'text-green-400 bg-green-400/10'
: 'text-red-400 bg-red-400/10'
}`}>
{isConnected ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
{/* Context indicator */}
{contextSummary && (
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-1 text-amber-400 bg-amber-400/10 px-2 py-1 rounded">
<FileText className="w-3 h-3" />
<span>{contextSummary}</span>
</div>
{hasContext && !hasInjectedContext && (
<span className="text-dark-500">Will be sent with first message</span>
)}
{hasInjectedContext && (
<span className="text-green-500">Context sent</span>
)}
</div>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-3 space-y-4">
{/* Welcome message with context awareness */}
{showWelcome && (
<div className="flex gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-primary-500/20 text-primary-400">
<Bot className="w-4 h-4" />
</div>
<div className="flex-1 bg-dark-700 rounded-lg px-4 py-3 text-sm text-dark-100">
<MarkdownRenderer content={hasContext
? `I can see you've uploaded files. Here's what I have access to:
${modelFiles.length > 0 ? `**Model Files:** ${modelFiles.join(', ')}` : ''}
${contextContent ? `\n**Context Document:** ${contextContent.substring(0, 200)}...` : ''}
Tell me what you want to optimize and I'll help you configure the study!`
: `Welcome to Atomizer Studio! I'm here to help you configure your optimization study.
**What I can do:**
- Read your uploaded context documents
- Help set up design variables, objectives, and constraints
- Create extractors for physics outputs
- Suggest optimization strategies
Upload your model files and any requirements documents, then tell me what you want to optimize!`} />
</div>
</div>
)}
{/* File context display (only if we have files but no messages yet) */}
{showWelcome && modelFiles.length > 0 && (
<div className="bg-dark-800/50 rounded-lg p-3 border border-dark-700">
<p className="text-xs text-dark-400 mb-2 font-medium">Loaded Files:</p>
<div className="flex flex-wrap gap-2">
{modelFiles.map((file, idx) => (
<span key={idx} className="flex items-center gap-1 text-xs bg-blue-500/10 text-blue-400 px-2 py-1 rounded">
<File className="w-3 h-3" />
{file}
</span>
))}
{contextFiles.map((file, idx) => (
<span key={idx} className="flex items-center gap-1 text-xs bg-amber-500/10 text-amber-400 px-2 py-1 rounded">
<FileText className="w-3 h-3" />
{file}
</span>
))}
</div>
</div>
)}
{/* Chat messages */}
{messages.map((msg) => {
const isAssistant = msg.role === 'assistant';
const isSystem = msg.role === 'system';
// System messages
if (isSystem) {
return (
<div key={msg.id} className="flex justify-center my-2">
<div className="px-3 py-1 bg-dark-700/50 rounded-full text-xs text-dark-400 border border-dark-600">
{msg.content}
</div>
</div>
);
}
return (
<div
key={msg.id}
className={`flex gap-3 ${isAssistant ? '' : 'flex-row-reverse'}`}
>
{/* Avatar */}
<div
className={`flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center ${
isAssistant
? 'bg-primary-500/20 text-primary-400'
: 'bg-dark-600 text-dark-300'
}`}
>
{isAssistant ? <Bot className="w-4 h-4" /> : <User className="w-4 h-4" />}
</div>
{/* Message content */}
<div
className={`flex-1 max-w-[85%] rounded-lg px-4 py-3 text-sm ${
isAssistant
? 'bg-dark-700 text-dark-100'
: 'bg-primary-500 text-white ml-auto'
}`}
>
{isAssistant ? (
<>
{msg.content && <MarkdownRenderer content={msg.content} />}
{msg.isStreaming && !msg.content && (
<span className="text-dark-400">Thinking...</span>
)}
{/* Tool calls */}
{msg.toolCalls && msg.toolCalls.length > 0 && (
<div className="mt-3 space-y-2">
{msg.toolCalls.map((tool, idx) => (
<ToolCallCard key={idx} toolCall={tool} />
))}
</div>
)}
</>
) : (
<span className="whitespace-pre-wrap">{msg.content}</span>
)}
</div>
</div>
);
})}
{/* Thinking indicator */}
{isThinking && messages.length > 0 && !messages[messages.length - 1]?.isStreaming && (
<div className="flex gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-primary-500/20 text-primary-400">
<Bot className="w-4 h-4" />
</div>
<div className="bg-dark-700 rounded-lg px-4 py-3 flex items-center gap-2">
<Loader2 className="w-4 h-4 text-primary-400 animate-spin" />
<span className="text-sm text-dark-300">Thinking...</span>
</div>
</div>
)}
{/* Error display */}
{error && (
<div className="flex gap-3">
<div className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center bg-red-500/20 text-red-400">
<AlertCircle className="w-4 h-4" />
</div>
<div className="flex-1 px-4 py-3 bg-red-500/10 rounded-lg text-sm text-red-400 border border-red-500/30">
{error}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-3 border-t border-dark-700 flex-shrink-0">
<div className="flex gap-2">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isConnected ? "Ask about your optimization..." : "Connecting..."}
disabled={!isConnected}
rows={1}
className="flex-1 bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-white placeholder-dark-400 resize-none focus:outline-none focus:border-primary-400 disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={!input.trim() || isThinking || !isConnected}
className="p-2 bg-primary-500 text-white rounded-lg hover:bg-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isThinking ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Send className="w-5 h-5" />
)}
</button>
</div>
{!isConnected && (
<p className="text-xs text-dark-500 mt-1">
Waiting for connection to Claude...
</p>
)}
</div>
</div>
);
};
export default StudioChat;

View File

@@ -0,0 +1,117 @@
/**
* StudioContextFiles - Context document upload and display
*/
import React, { useState, useRef } from 'react';
import { FileText, Upload, Trash2, Loader2 } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface StudioContextFilesProps {
draftId: string;
files: string[];
onUploadComplete: () => void;
}
export const StudioContextFiles: React.FC<StudioContextFilesProps> = ({
draftId,
files,
onUploadComplete,
}) => {
const [isUploading, setIsUploading] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const VALID_EXTENSIONS = ['.md', '.txt', '.pdf', '.json', '.csv', '.docx'];
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
if (selectedFiles.length === 0) return;
e.target.value = '';
setIsUploading(true);
try {
await intakeApi.uploadContextFiles(draftId, selectedFiles);
onUploadComplete();
} catch (err) {
console.error('Failed to upload context files:', err);
} finally {
setIsUploading(false);
}
};
const deleteFile = async (filename: string) => {
setDeleting(filename);
try {
await intakeApi.deleteContextFile(draftId, filename);
onUploadComplete();
} catch (err) {
console.error('Failed to delete context file:', err);
} finally {
setDeleting(null);
}
};
const getFileIcon = (_filename: string) => {
return <FileText className="w-3.5 h-3.5 text-amber-400" />;
};
return (
<div className="space-y-2">
{/* File List */}
{files.length > 0 && (
<div className="space-y-1">
{files.map((name) => (
<div
key={name}
className="flex items-center gap-2 px-2 py-1.5 rounded bg-dark-700/50 text-sm group"
>
{getFileIcon(name)}
<span className="text-dark-200 truncate flex-1">{name}</span>
<button
onClick={() => deleteFile(name)}
disabled={deleting === name}
className="p-1 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 rounded text-red-400 transition-all"
>
{deleting === name ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Trash2 className="w-3 h-3" />
)}
</button>
</div>
))}
</div>
)}
{/* Upload Button */}
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg
border border-dashed border-dark-600 text-dark-400 text-sm
hover:border-primary-400/50 hover:text-primary-400 hover:bg-primary-400/5
disabled:opacity-50 transition-colors"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
{isUploading ? 'Uploading...' : 'Add context files'}
</button>
<input
ref={fileInputRef}
type="file"
multiple
accept={VALID_EXTENSIONS.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
};
export default StudioContextFiles;

View File

@@ -0,0 +1,242 @@
/**
* StudioDropZone - Smart file drop zone for Studio
*
* Handles both model files (.sim, .prt, .fem) and context files (.pdf, .md, .txt)
*/
import React, { useState, useCallback, useRef } from 'react';
import { Upload, X, Loader2, AlertCircle, CheckCircle, File } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface StudioDropZoneProps {
draftId: string;
type: 'model' | 'context';
files: string[];
onUploadComplete: () => void;
}
interface FileStatus {
file: File;
status: 'pending' | 'uploading' | 'success' | 'error';
message?: string;
}
const MODEL_EXTENSIONS = ['.prt', '.sim', '.fem', '.afem'];
const CONTEXT_EXTENSIONS = ['.md', '.txt', '.pdf', '.json', '.csv', '.docx'];
export const StudioDropZone: React.FC<StudioDropZoneProps> = ({
draftId,
type,
files,
onUploadComplete,
}) => {
const [isDragging, setIsDragging] = useState(false);
const [pendingFiles, setPendingFiles] = useState<FileStatus[]>([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const validExtensions = type === 'model' ? MODEL_EXTENSIONS : CONTEXT_EXTENSIONS;
const validateFile = (file: File): { valid: boolean; reason?: string } => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
if (!validExtensions.includes(ext)) {
return { valid: false, reason: `Invalid type: ${ext}` };
}
if (file.size > 500 * 1024 * 1024) {
return { valid: false, reason: 'File too large (max 500MB)' };
}
return { valid: true };
};
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const addFiles = useCallback((newFiles: File[]) => {
const validFiles: FileStatus[] = [];
for (const file of newFiles) {
if (pendingFiles.some(f => f.file.name === file.name)) {
continue;
}
const validation = validateFile(file);
validFiles.push({
file,
status: validation.valid ? 'pending' : 'error',
message: validation.reason,
});
}
setPendingFiles(prev => [...prev, ...validFiles]);
}, [pendingFiles, validExtensions]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
addFiles(Array.from(e.dataTransfer.files));
}, [addFiles]);
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(e.target.files || []));
e.target.value = '';
}, [addFiles]);
const removeFile = (index: number) => {
setPendingFiles(prev => prev.filter((_, i) => i !== index));
};
const uploadFiles = async () => {
const toUpload = pendingFiles.filter(f => f.status === 'pending');
if (toUpload.length === 0) return;
setIsUploading(true);
try {
const uploadFn = type === 'model'
? intakeApi.uploadFiles
: intakeApi.uploadContextFiles;
const response = await uploadFn(draftId, toUpload.map(f => f.file));
const results = new Map(
response.uploaded_files.map(f => [f.name, f.status === 'uploaded'])
);
setPendingFiles(prev => prev.map(f => {
if (f.status !== 'pending') return f;
const success = results.get(f.file.name);
return {
...f,
status: success ? 'success' : 'error',
message: success ? undefined : 'Upload failed',
};
}));
setTimeout(() => {
setPendingFiles(prev => prev.filter(f => f.status !== 'success'));
onUploadComplete();
}, 1000);
} catch (err) {
setPendingFiles(prev => prev.map(f =>
f.status === 'pending'
? { ...f, status: 'error', message: 'Upload failed' }
: f
));
} finally {
setIsUploading(false);
}
};
// Auto-upload when files are added
React.useEffect(() => {
const pending = pendingFiles.filter(f => f.status === 'pending');
if (pending.length > 0 && !isUploading) {
uploadFiles();
}
}, [pendingFiles, isUploading]);
return (
<div className="space-y-2">
{/* Drop Zone */}
<div
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`
relative border-2 border-dashed rounded-lg p-4 cursor-pointer
transition-all duration-200 text-center
${isDragging
? 'border-primary-400 bg-primary-400/5'
: 'border-dark-600 hover:border-primary-400/50 hover:bg-white/5'
}
`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center mx-auto mb-2
${isDragging ? 'bg-primary-400/20 text-primary-400' : 'bg-dark-700 text-dark-400'}`}>
<Upload className="w-4 h-4" />
</div>
<p className="text-sm text-dark-300">
{isDragging ? 'Drop files here' : 'Drop or click to add'}
</p>
<p className="text-xs text-dark-500 mt-1">
{validExtensions.join(', ')}
</p>
</div>
{/* Existing Files */}
{files.length > 0 && (
<div className="space-y-1">
{files.map((name, i) => (
<div
key={i}
className="flex items-center gap-2 px-2 py-1.5 rounded bg-dark-700/50 text-sm"
>
<File className="w-3.5 h-3.5 text-dark-400" />
<span className="text-dark-200 truncate flex-1">{name}</span>
<CheckCircle className="w-3.5 h-3.5 text-green-400" />
</div>
))}
</div>
)}
{/* Pending Files */}
{pendingFiles.length > 0 && (
<div className="space-y-1">
{pendingFiles.map((f, i) => (
<div
key={i}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm
${f.status === 'error' ? 'bg-red-500/10' :
f.status === 'success' ? 'bg-green-500/10' : 'bg-dark-700'}`}
>
{f.status === 'pending' && <Loader2 className="w-3.5 h-3.5 text-primary-400 animate-spin" />}
{f.status === 'uploading' && <Loader2 className="w-3.5 h-3.5 text-primary-400 animate-spin" />}
{f.status === 'success' && <CheckCircle className="w-3.5 h-3.5 text-green-400" />}
{f.status === 'error' && <AlertCircle className="w-3.5 h-3.5 text-red-400" />}
<span className={`truncate flex-1 ${f.status === 'error' ? 'text-red-400' : 'text-dark-200'}`}>
{f.file.name}
</span>
{f.message && (
<span className="text-xs text-red-400">({f.message})</span>
)}
{f.status === 'pending' && (
<button onClick={(e) => { e.stopPropagation(); removeFile(i); }} className="p-0.5 hover:bg-white/10 rounded">
<X className="w-3 h-3 text-dark-400" />
</button>
)}
</div>
))}
</div>
)}
<input
ref={fileInputRef}
type="file"
multiple
accept={validExtensions.join(',')}
onChange={handleFileSelect}
className="hidden"
/>
</div>
);
};
export default StudioDropZone;

View File

@@ -0,0 +1,172 @@
/**
* StudioParameterList - Display and add discovered parameters as design variables
*/
import React, { useState, useEffect } from 'react';
import { Plus, Check, SlidersHorizontal, Loader2 } from 'lucide-react';
import { intakeApi } from '../../api/intake';
interface Expression {
name: string;
value: number | null;
units: string | null;
is_candidate: boolean;
confidence: number;
}
interface StudioParameterListProps {
draftId: string;
onParameterAdded: () => void;
}
export const StudioParameterList: React.FC<StudioParameterListProps> = ({
draftId,
onParameterAdded,
}) => {
const [expressions, setExpressions] = useState<Expression[]>([]);
const [addedParams, setAddedParams] = useState<Set<string>>(new Set());
const [adding, setAdding] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// Load expressions from spec introspection
useEffect(() => {
loadExpressions();
}, [draftId]);
const loadExpressions = async () => {
setLoading(true);
try {
const data = await intakeApi.getStudioDraft(draftId);
const introspection = (data.spec as any)?.model?.introspection;
if (introspection?.expressions) {
setExpressions(introspection.expressions);
// Check which are already added as DVs
const existingDVs = new Set<string>(
((data.spec as any)?.design_variables || []).map((dv: any) => dv.expression_name as string)
);
setAddedParams(existingDVs);
}
} catch (err) {
console.error('Failed to load expressions:', err);
} finally {
setLoading(false);
}
};
const addAsDesignVariable = async (expressionName: string) => {
setAdding(expressionName);
try {
await intakeApi.createDesignVariables(draftId, [expressionName]);
setAddedParams(prev => new Set([...prev, expressionName]));
onParameterAdded();
} catch (err) {
console.error('Failed to add design variable:', err);
} finally {
setAdding(null);
}
};
// Sort: candidates first, then by confidence
const sortedExpressions = [...expressions].sort((a, b) => {
if (a.is_candidate !== b.is_candidate) {
return b.is_candidate ? 1 : -1;
}
return (b.confidence || 0) - (a.confidence || 0);
});
// Show only candidates by default, with option to show all
const [showAll, setShowAll] = useState(false);
const displayExpressions = showAll
? sortedExpressions
: sortedExpressions.filter(e => e.is_candidate);
if (loading) {
return (
<div className="flex items-center justify-center py-4">
<Loader2 className="w-5 h-5 text-primary-400 animate-spin" />
</div>
);
}
if (expressions.length === 0) {
return (
<p className="text-xs text-dark-500 italic py-2">
No expressions found. Try running introspection.
</p>
);
}
const candidateCount = expressions.filter(e => e.is_candidate).length;
return (
<div className="space-y-2">
{/* Header with toggle */}
<div className="flex items-center justify-between text-xs text-dark-400">
<span>{candidateCount} candidates</span>
<button
onClick={() => setShowAll(!showAll)}
className="hover:text-primary-400 transition-colors"
>
{showAll ? 'Show candidates only' : `Show all (${expressions.length})`}
</button>
</div>
{/* Parameter List */}
<div className="space-y-1 max-h-48 overflow-y-auto">
{displayExpressions.map((expr) => {
const isAdded = addedParams.has(expr.name);
const isAdding = adding === expr.name;
return (
<div
key={expr.name}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-sm
${isAdded ? 'bg-green-500/10' : 'bg-dark-700/50 hover:bg-dark-700'}
transition-colors`}
>
<SlidersHorizontal className="w-3.5 h-3.5 text-dark-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<span className={`block truncate ${isAdded ? 'text-green-400' : 'text-dark-200'}`}>
{expr.name}
</span>
{expr.value !== null && (
<span className="text-xs text-dark-500">
= {expr.value}{expr.units ? ` ${expr.units}` : ''}
</span>
)}
</div>
{isAdded ? (
<Check className="w-4 h-4 text-green-400 flex-shrink-0" />
) : (
<button
onClick={() => addAsDesignVariable(expr.name)}
disabled={isAdding}
className="p-1 hover:bg-primary-400/20 rounded text-primary-400 transition-colors disabled:opacity-50"
title="Add as design variable"
>
{isAdding ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
</button>
)}
</div>
);
})}
</div>
{displayExpressions.length === 0 && (
<p className="text-xs text-dark-500 italic py-2">
No candidate parameters found. Click "Show all" to see all expressions.
</p>
)}
</div>
);
};
export default StudioParameterList;

View File

@@ -0,0 +1,11 @@
/**
* Studio Components Index
*
* Export all Studio-related components.
*/
export { StudioDropZone } from './StudioDropZone';
export { StudioParameterList } from './StudioParameterList';
export { StudioContextFiles } from './StudioContextFiles';
export { StudioChat } from './StudioChat';
export { StudioBuildDialog } from './StudioBuildDialog';

View File

@@ -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"

View 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>
);
}

View File

@@ -0,0 +1,201 @@
/**
* Intake Workflow TypeScript Types
*
* Types for the study intake/creation workflow.
*/
// ============================================================================
// Status Types
// ============================================================================
export type SpecStatus =
| 'draft'
| 'introspected'
| 'configured'
| 'validated'
| 'ready'
| 'running'
| 'completed'
| 'failed';
// ============================================================================
// Expression/Introspection Types
// ============================================================================
export interface ExpressionInfo {
/** Expression name in NX */
name: string;
/** Current value */
value: number | null;
/** Physical units */
units: string | null;
/** Expression formula if any */
formula: string | null;
/** Whether this is a design variable candidate */
is_candidate: boolean;
/** Confidence that this is a DV (0-1) */
confidence: number;
}
export interface BaselineData {
/** When baseline was run */
timestamp: string;
/** How long the solve took */
solve_time_seconds: number;
/** Computed mass from BDF/FEM */
mass_kg: number | null;
/** Max displacement result */
max_displacement_mm: number | null;
/** Max von Mises stress */
max_stress_mpa: number | null;
/** Whether baseline solve succeeded */
success: boolean;
/** Error message if failed */
error: string | null;
}
export interface IntrospectionData {
/** When introspection was run */
timestamp: string;
/** Detected solver type */
solver_type: string | null;
/** Mass from expressions or properties */
mass_kg: number | null;
/** Volume from mass properties */
volume_mm3: number | null;
/** Discovered expressions */
expressions: ExpressionInfo[];
/** Baseline solve results */
baseline: BaselineData | null;
/** Warnings from introspection */
warnings: string[];
}
// ============================================================================
// Request/Response Types
// ============================================================================
export interface CreateInboxRequest {
study_name: string;
description?: string;
topic?: string;
}
export interface CreateInboxResponse {
success: boolean;
study_name: string;
inbox_path: string;
spec_path: string;
status: SpecStatus;
}
export interface IntrospectRequest {
study_name: string;
model_file?: string;
}
export interface IntrospectResponse {
success: boolean;
study_name: string;
status: SpecStatus;
expressions_count: number;
candidates_count: number;
mass_kg: number | null;
warnings: string[];
}
export interface InboxStudy {
study_name: string;
status: SpecStatus;
description: string | null;
topic: string | null;
created: string | null;
modified: string | null;
model_files: string[];
has_context: boolean;
}
export interface ListInboxResponse {
studies: InboxStudy[];
total: number;
}
export interface TopicInfo {
name: string;
study_count: number;
path: string;
}
export interface ListTopicsResponse {
topics: TopicInfo[];
total: number;
}
export interface InboxStudyDetail {
study_name: string;
inbox_path: string;
spec: import('./atomizer-spec').AtomizerSpec;
files: {
sim: string[];
prt: string[];
fem: string[];
};
context_files: string[];
}
// ============================================================================
// Finalize Types
// ============================================================================
export interface FinalizeRequest {
topic: string;
run_baseline?: boolean;
}
export interface FinalizeProgress {
step: string;
progress: number;
message: string;
completed: boolean;
error?: string;
}
export interface FinalizeResponse {
success: boolean;
study_name: string;
final_path: string;
status: SpecStatus;
baseline?: BaselineData;
readme_generated: boolean;
}
// ============================================================================
// README Generation Types
// ============================================================================
export interface GenerateReadmeRequest {
study_name: string;
}
export interface GenerateReadmeResponse {
success: boolean;
content: string;
path: string;
}
// ============================================================================
// Upload Types
// ============================================================================
export interface UploadFilesResponse {
success: boolean;
study_name: string;
uploaded_files: Array<{
name: string;
status: 'uploaded' | 'rejected' | 'skipped';
path?: string;
size?: number;
reason?: string;
}>;
total_uploaded: number;
}