feat(canvas): Studio Enhancement Phase 1 & 2 - v2.0 architecture and file structure

Phase 1 - Foundation:
- Add NodeConfigPanelV2 using useSpecStore for AtomizerSpec v2.0 mode
- Deprecate AtomizerCanvas and useCanvasStore with migration docs
- Add VITE_USE_LEGACY_CANVAS env var for emergency fallback
- Enhance NodePalette with collapse support, filtering, exports
- Add drag-drop support to SpecRenderer with default node data
- Setup test infrastructure (Vitest + Playwright configs)
- Add useSpecStore unit tests (15 tests)

Phase 2 - File Structure & Model:
- Create FileStructurePanel with tree view of study files
- Add ModelNodeV2 with collapsible file dependencies
- Add tabbed left sidebar (Components/Files tabs)
- Add GET /api/files/structure/{study_id} backend endpoint
- Auto-expand 1_setup folders in file tree
- Show model file introspection with solver type and expressions

Technical:
- All TypeScript checks pass
- All 15 unit tests pass
- Production build successful
This commit is contained in:
2026-01-20 11:53:26 -05:00
parent ea437d360e
commit c4a3cff91a
16 changed files with 4067 additions and 239 deletions

View File

@@ -1,42 +1,236 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight } from 'lucide-react';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ClipboardList, Download, Trash2, Layers, Home, ChevronRight, Save, RefreshCw, Zap, MessageSquare, X, Folder, SlidersHorizontal } from 'lucide-react';
import { AtomizerCanvas } from '../components/canvas/AtomizerCanvas';
import { SpecRenderer } from '../components/canvas/SpecRenderer';
import { NodePalette } from '../components/canvas/palette/NodePalette';
import { FileStructurePanel } from '../components/canvas/panels/FileStructurePanel';
import { TemplateSelector } from '../components/canvas/panels/TemplateSelector';
import { ConfigImporter } from '../components/canvas/panels/ConfigImporter';
import { NodeConfigPanel } from '../components/canvas/panels/NodeConfigPanel';
import { NodeConfigPanelV2 } from '../components/canvas/panels/NodeConfigPanelV2';
import { ChatPanel } from '../components/canvas/panels/ChatPanel';
import { useCanvasStore } from '../hooks/useCanvasStore';
import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId } from '../hooks/useSpecStore';
import { useStudy } from '../context/StudyContext';
import { useChat } from '../hooks/useChat';
import { CanvasTemplate } from '../lib/canvas/templates';
export function CanvasView() {
const [showTemplates, setShowTemplates] = useState(false);
const [showImporter, setShowImporter] = useState(false);
const [showChat, setShowChat] = useState(true);
const [chatPowerMode, setChatPowerMode] = useState(false);
const [notification, setNotification] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [paletteCollapsed, setPaletteCollapsed] = useState(false);
const [leftSidebarTab, setLeftSidebarTab] = useState<'components' | 'files'>('components');
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { nodes, edges, clear } = useCanvasStore();
const { selectedStudy } = useStudy();
// Spec mode is the default (AtomizerSpec v2.0)
// Legacy mode can be enabled via:
// 1. VITE_USE_LEGACY_CANVAS=true environment variable
// 2. ?mode=legacy query param (for emergency fallback)
const legacyEnvEnabled = import.meta.env.VITE_USE_LEGACY_CANVAS === 'true';
const legacyQueryParam = searchParams.get('mode') === 'legacy';
const useSpecMode = !legacyEnvEnabled && !legacyQueryParam;
// Get study ID from URL params (supports nested paths like M1_Mirror/study_name)
const { '*': urlStudyId } = useParams<{ '*': string }>();
// Legacy canvas store (for backwards compatibility)
const { nodes, edges, clear, loadFromConfig, toIntent } = useCanvasStore();
// New spec store (AtomizerSpec v2.0)
const spec = useSpec();
const specLoading = useSpecLoading();
const specIsDirty = useSpecIsDirty();
const selectedNodeId = useSelectedNodeId();
const { loadSpec, saveSpec, reloadSpec } = useSpecStore();
const { setSelectedStudy, studies } = useStudy();
const { clearSpec, setSpecFromWebSocket } = useSpecStore();
// Active study ID comes ONLY from URL - don't auto-load from context
// This ensures /canvas shows empty canvas, /canvas/{id} shows the study
const activeStudyId = urlStudyId;
// Chat hook for assistant panel
const { messages, isThinking, isConnected, sendMessage, notifyCanvasEdit } = useChat({
studyId: activeStudyId,
mode: chatPowerMode ? 'power' : 'user',
useWebSocket: true,
onCanvasModification: chatPowerMode ? (modification) => {
// Handle canvas modifications from Claude in power mode (legacy)
console.log('Canvas modification from Claude:', modification);
showNotification(`Claude: ${modification.action} ${modification.nodeType || modification.nodeId || ''}`);
// The actual modification is handled by the MCP tools on the backend
// which update atomizer_spec.json, then the canvas reloads via WebSocket
reloadSpec();
} : undefined,
onSpecUpdated: useSpecMode ? (newSpec) => {
// Direct spec update from Claude via WebSocket - no HTTP reload needed
console.log('Spec updated from Claude via WebSocket:', newSpec.meta?.study_name);
setSpecFromWebSocket(newSpec, activeStudyId);
showNotification('Canvas synced with Claude');
} : undefined,
});
// Load or clear spec based on URL study ID
useEffect(() => {
if (urlStudyId) {
if (useSpecMode) {
// Try to load spec first, fall back to legacy config
loadSpec(urlStudyId).catch(() => {
// If spec doesn't exist, try legacy config
loadStudyConfig(urlStudyId);
});
} else {
loadStudyConfig(urlStudyId);
}
} else {
// No study ID in URL - clear spec for empty canvas (new study creation)
if (useSpecMode) {
clearSpec();
} else {
clear();
}
}
}, [urlStudyId, useSpecMode]);
// Notify Claude when user edits the spec (bi-directional sync)
// This sends the updated spec to Claude so it knows what the user changed
useEffect(() => {
if (useSpecMode && spec && specIsDirty && chatPowerMode) {
// User made changes - notify Claude via WebSocket
notifyCanvasEdit(spec);
}
}, [spec, specIsDirty, useSpecMode, chatPowerMode, notifyCanvasEdit]);
// Track unsaved changes (legacy mode only)
useEffect(() => {
if (!useSpecMode && activeStudyId && nodes.length > 0) {
setHasUnsavedChanges(true);
}
}, [nodes, edges, useSpecMode]);
const loadStudyConfig = async (studyId: string) => {
setIsLoading(true);
try {
const response = await fetch(`/api/optimization/studies/${encodeURIComponent(studyId)}/config`);
if (!response.ok) {
throw new Error(`Failed to load study: ${response.status}`);
}
const data = await response.json();
loadFromConfig(data.config);
setHasUnsavedChanges(false);
// Also select the study in context
const study = studies.find(s => s.id === studyId);
if (study) {
setSelectedStudy(study);
}
showNotification(`Loaded: ${studyId}`);
} catch (error) {
console.error('Failed to load study config:', error);
showNotification('Failed to load study config');
} finally {
setIsLoading(false);
}
};
const saveToConfig = async () => {
if (!activeStudyId) {
showNotification('No study selected to save to');
return;
}
setIsSaving(true);
try {
if (useSpecMode && spec) {
// Save spec using new API
await saveSpec(spec);
showNotification('Saved to atomizer_spec.json');
} else {
// Legacy save
const intent = toIntent();
const response = await fetch(`/api/optimization/studies/${encodeURIComponent(activeStudyId)}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ intent }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to save');
}
setHasUnsavedChanges(false);
showNotification('Saved to optimization_config.json');
}
} catch (error) {
console.error('Failed to save:', error);
showNotification('Failed to save: ' + (error instanceof Error ? error.message : 'Unknown error'));
} finally {
setIsSaving(false);
}
};
const handleTemplateSelect = (template: CanvasTemplate) => {
setHasUnsavedChanges(true);
showNotification(`Loaded template: ${template.name}`);
};
const handleImport = (source: string) => {
setHasUnsavedChanges(true);
showNotification(`Imported from ${source}`);
};
const handleClear = () => {
if (useSpecMode) {
// In spec mode, clearing is not typically needed since changes sync automatically
showNotification('Use Reload to reset to saved state');
return;
}
if (nodes.length === 0 || window.confirm('Clear all nodes from the canvas?')) {
clear();
setHasUnsavedChanges(true);
showNotification('Canvas cleared');
}
};
const handleReload = () => {
if (activeStudyId) {
const hasChanges = useSpecMode ? specIsDirty : hasUnsavedChanges;
if (hasChanges && !window.confirm('Reload will discard unsaved changes. Continue?')) {
return;
}
if (useSpecMode) {
reloadSpec();
showNotification('Reloaded from atomizer_spec.json');
} else {
loadStudyConfig(activeStudyId);
}
}
};
const showNotification = (message: string) => {
setNotification(message);
setTimeout(() => setNotification(null), 3000);
};
// Navigate to canvas with study ID
const navigateToStudy = useCallback((studyId: string) => {
navigate(`/canvas/${studyId}`);
}, [navigate]);
return (
<div className="h-screen flex flex-col bg-dark-900">
{/* Minimal Header */}
@@ -55,24 +249,75 @@ export function CanvasView() {
<div className="flex items-center gap-2">
<Layers size={18} className="text-primary-400" />
<span className="text-sm font-medium text-white">Canvas Builder</span>
{selectedStudy && (
{activeStudyId && (
<>
<ChevronRight size={14} className="text-dark-500" />
<span className="text-sm text-primary-400 font-medium">
{selectedStudy.name || selectedStudy.id}
{activeStudyId}
</span>
{hasUnsavedChanges && (
<span className="text-xs text-amber-400 ml-1" title="Unsaved changes"></span>
)}
</>
)}
</div>
{/* Stats */}
<span className="text-xs text-dark-500 tabular-nums ml-2">
{nodes.length} node{nodes.length !== 1 ? 's' : ''} {edges.length} edge{edges.length !== 1 ? 's' : ''}
</span>
{useSpecMode && spec ? (
<span className="text-xs text-dark-500 tabular-nums ml-2">
{spec.design_variables.length} vars {spec.extractors.length} ext {spec.objectives.length} obj
</span>
) : (
<span className="text-xs text-dark-500 tabular-nums ml-2">
{nodes.length} node{nodes.length !== 1 ? 's' : ''} {edges.length} edge{edges.length !== 1 ? 's' : ''}
</span>
)}
{/* Mode indicator */}
{useSpecMode && (
<span className="ml-2 px-1.5 py-0.5 text-xs bg-primary-900/50 text-primary-400 rounded border border-primary-800 flex items-center gap-1">
<Zap size={10} />
v2.0
</span>
)}
{(isLoading || specLoading) && (
<RefreshCw size={14} className="text-primary-400 animate-spin ml-2" />
)}
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{/* Save Button - only show when there's a study and changes */}
{activeStudyId && (
<button
onClick={saveToConfig}
disabled={isSaving || (useSpecMode ? !specIsDirty : !hasUnsavedChanges)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${
(useSpecMode ? specIsDirty : hasUnsavedChanges)
? 'bg-green-600 hover:bg-green-500 text-white'
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
}`}
title={(useSpecMode ? specIsDirty : hasUnsavedChanges) ? `Save changes to ${useSpecMode ? 'atomizer_spec.json' : 'optimization_config.json'}` : 'No changes to save'}
>
<Save size={14} />
{isSaving ? 'Saving...' : 'Save'}
</button>
)}
{/* Reload Button */}
{activeStudyId && (
<button
onClick={handleReload}
disabled={isLoading || specLoading}
className="px-3 py-1.5 bg-dark-700 text-dark-200 hover:bg-dark-600 hover:text-white text-sm rounded-lg transition-colors flex items-center gap-1.5 border border-dark-600"
title={`Reload from ${useSpecMode ? 'atomizer_spec.json' : 'optimization_config.json'}`}
>
<RefreshCw size={14} className={(isLoading || specLoading) ? 'animate-spin' : ''} />
Reload
</button>
)}
<button
onClick={() => setShowTemplates(true)}
className="px-3 py-1.5 bg-primary-600 hover:bg-primary-500 text-white text-sm rounded-lg transition-colors flex items-center gap-1.5"
@@ -94,12 +339,151 @@ export function CanvasView() {
<Trash2 size={14} />
Clear
</button>
{/* Divider */}
<div className="w-px h-6 bg-dark-600" />
{/* Chat Toggle */}
<button
onClick={() => setShowChat(!showChat)}
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 border ${
showChat
? 'bg-primary-600 text-white border-primary-500'
: 'bg-dark-700 text-dark-200 hover:bg-dark-600 hover:text-white border-dark-600'
}`}
title={showChat ? 'Hide Assistant' : 'Show Assistant'}
>
<MessageSquare size={14} />
Assistant
</button>
</div>
</header>
{/* Main Canvas */}
<main className="flex-1 overflow-hidden">
<AtomizerCanvas />
<main className="flex-1 overflow-hidden flex">
{/* Left Sidebar with tabs (spec mode only - AtomizerCanvas has its own) */}
{useSpecMode && (
<div className={`${paletteCollapsed ? 'w-14' : 'w-60'} bg-dark-850 border-r border-dark-700 flex flex-col transition-all duration-200`}>
{/* Tab buttons (only show when expanded) */}
{!paletteCollapsed && (
<div className="flex border-b border-dark-700">
<button
onClick={() => setLeftSidebarTab('components')}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 text-xs font-medium transition-colors
${leftSidebarTab === 'components'
? 'text-primary-400 border-b-2 border-primary-400 -mb-px bg-dark-800/50'
: 'text-dark-400 hover:text-white'}`}
>
<SlidersHorizontal size={14} />
Components
</button>
<button
onClick={() => setLeftSidebarTab('files')}
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2.5 text-xs font-medium transition-colors
${leftSidebarTab === 'files'
? 'text-primary-400 border-b-2 border-primary-400 -mb-px bg-dark-800/50'
: 'text-dark-400 hover:text-white'}`}
>
<Folder size={14} />
Files
</button>
</div>
)}
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{leftSidebarTab === 'components' || paletteCollapsed ? (
<NodePalette
collapsed={paletteCollapsed}
onToggleCollapse={() => setPaletteCollapsed(!paletteCollapsed)}
showToggle={true}
/>
) : (
<FileStructurePanel
studyId={activeStudyId || null}
selectedModelPath={spec?.model?.sim?.path}
onModelSelect={(path, _type) => {
// TODO: Update model path in spec
showNotification(`Selected: ${path.split(/[/\\]/).pop()}`);
}}
/>
)}
</div>
</div>
)}
{/* Canvas area - must have explicit height for ReactFlow */}
<div className="flex-1 h-full">
{useSpecMode ? (
<SpecRenderer
studyId={activeStudyId}
onStudyChange={navigateToStudy}
enableWebSocket={true}
showConnectionStatus={true}
editable={true}
/>
) : (
<AtomizerCanvas
studyId={activeStudyId}
onStudyChange={navigateToStudy}
/>
)}
</div>
{/* Config Panel - use V2 for spec mode, legacy for AtomizerCanvas */}
{selectedNodeId && !showChat && (
useSpecMode ? (
<NodeConfigPanelV2 onClose={() => useSpecStore.getState().clearSelection()} />
) : (
<div className="w-80 border-l border-dark-700 bg-dark-850 overflow-y-auto">
<NodeConfigPanel nodeId={selectedNodeId} />
</div>
)
)}
{/* Chat/Assistant Panel */}
{showChat && (
<div className="w-96 border-l border-dark-700 bg-dark-850 flex flex-col">
{/* Chat Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700">
<div className="flex items-center gap-2">
<MessageSquare size={16} className="text-primary-400" />
<span className="font-medium text-white">Assistant</span>
{isConnected && (
<span className="w-2 h-2 rounded-full bg-green-400" title="Connected" />
)}
</div>
<div className="flex items-center gap-2">
{/* Power Mode Toggle */}
<button
onClick={() => setChatPowerMode(!chatPowerMode)}
className={`px-2 py-1 text-xs rounded transition-colors flex items-center gap-1 ${
chatPowerMode
? 'bg-amber-600 text-white'
: 'bg-dark-700 text-dark-400 hover:text-white'
}`}
title={chatPowerMode ? 'Power Mode: Claude can modify the canvas' : 'User Mode: Read-only assistant'}
>
<Zap size={12} />
{chatPowerMode ? 'Power' : 'User'}
</button>
<button
onClick={() => setShowChat(false)}
className="p-1 rounded hover:bg-dark-700 text-dark-400 hover:text-white transition-colors"
>
<X size={16} />
</button>
</div>
</div>
{/* Chat Content */}
<ChatPanel
messages={messages}
isThinking={isThinking}
onSendMessage={sendMessage}
isConnected={isConnected}
/>
</div>
)}
</main>
{/* Template Selector Modal */}