456 lines
16 KiB
TypeScript
456 lines
16 KiB
TypeScript
|
|
/**
|
||
|
|
* 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;
|