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

@@ -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';