702 lines
27 KiB
TypeScript
702 lines
27 KiB
TypeScript
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, Undo2, Redo2 } 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 { PanelContainer } from '../components/canvas/panels/PanelContainer';
|
|
import { ResizeHandle } from '../components/canvas/ResizeHandle';
|
|
import { useCanvasStore } from '../hooks/useCanvasStore';
|
|
import { useSpecStore, useSpec, useSpecLoading, useSpecIsDirty, useSelectedNodeId, useSpecHash } from '../hooks/useSpecStore';
|
|
import { useResizablePanel } from '../hooks/useResizablePanel';
|
|
// usePanelStore is now used by child components - PanelContainer handles panels
|
|
import { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo';
|
|
import { useSpecDraft } from '../hooks/useSpecDraft';
|
|
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();
|
|
|
|
// Resizable panels
|
|
const leftPanel = useResizablePanel({
|
|
storageKey: 'left-sidebar',
|
|
defaultWidth: 240,
|
|
minWidth: 200,
|
|
maxWidth: 400,
|
|
side: 'left',
|
|
});
|
|
|
|
const rightPanel = useResizablePanel({
|
|
storageKey: 'right-panel',
|
|
defaultWidth: 384,
|
|
minWidth: 280,
|
|
maxWidth: 600,
|
|
side: 'right',
|
|
});
|
|
const [searchParams] = useSearchParams();
|
|
|
|
// 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 }>();
|
|
|
|
// 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;
|
|
|
|
// 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 specHash = useSpecHash();
|
|
const selectedNodeId = useSelectedNodeId();
|
|
const { loadSpec, saveSpec, reloadSpec } = useSpecStore();
|
|
|
|
// S2: local autosave draft (crash-proof) — publish remains explicit
|
|
const { hasDraft, draft, discardDraft, reloadDraft } = useSpecDraft({
|
|
studyId: activeStudyId,
|
|
spec,
|
|
serverHash: specHash,
|
|
enabled: useSpecMode,
|
|
});
|
|
|
|
const [showDraftPrompt, setShowDraftPrompt] = useState(false);
|
|
|
|
const { setSelectedStudy, studies } = useStudy();
|
|
const { clearSpec, setSpecFromWebSocket, setSpecLocalDraft } = useSpecStore();
|
|
|
|
// Undo/Redo for spec mode
|
|
const undoRedo = useSpecUndoRedo();
|
|
const { undo, redo, canUndo, canRedo, historyLength } = undoRedo;
|
|
|
|
// Enable keyboard shortcuts for undo/redo (Ctrl+Z, Ctrl+Y)
|
|
useUndoRedoKeyboard(undoRedo);
|
|
|
|
// 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]);
|
|
|
|
// If a local draft exists for this study, prompt user to restore/discard.
|
|
useEffect(() => {
|
|
if (!useSpecMode) return;
|
|
if (!activeStudyId) return;
|
|
if (specLoading) return;
|
|
if (!spec) return;
|
|
if (!hasDraft || !draft) return;
|
|
|
|
// Show prompt once per navigation
|
|
setShowDraftPrompt(true);
|
|
}, [useSpecMode, activeStudyId, specLoading, spec, hasDraft, draft]);
|
|
|
|
// 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('Published 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 */}
|
|
<header className="flex-shrink-0 h-12 bg-dark-850 border-b border-dark-700 px-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{/* Home button */}
|
|
<button
|
|
onClick={() => navigate('/')}
|
|
className="p-1.5 rounded-lg text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
|
|
title="Back to Home"
|
|
>
|
|
<Home size={18} />
|
|
</button>
|
|
|
|
{/* Breadcrumb */}
|
|
<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>
|
|
{activeStudyId && (
|
|
<>
|
|
<ChevronRight size={14} className="text-dark-500" />
|
|
<span className="text-sm text-primary-400 font-medium">
|
|
{activeStudyId}
|
|
</span>
|
|
{hasUnsavedChanges && (
|
|
<span className="text-xs text-amber-400 ml-1" title="Unsaved changes">•</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
{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 - always show in spec mode with study, grayed when no changes */}
|
|
{useSpecMode && spec && (
|
|
<button
|
|
onClick={saveToConfig}
|
|
disabled={isSaving || !specIsDirty}
|
|
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${
|
|
specIsDirty
|
|
? 'bg-green-600 hover:bg-green-500 text-white'
|
|
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
|
|
}`}
|
|
title={specIsDirty ? 'Publish draft to atomizer_spec.json' : 'No changes to publish'}
|
|
>
|
|
<Save size={14} />
|
|
{isSaving ? 'Publishing...' : 'Publish'}
|
|
</button>
|
|
)}
|
|
|
|
{/* Legacy Save Button */}
|
|
{!useSpecMode && activeStudyId && (
|
|
<button
|
|
onClick={saveToConfig}
|
|
disabled={isSaving || !hasUnsavedChanges}
|
|
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-1.5 ${
|
|
hasUnsavedChanges
|
|
? 'bg-green-600 hover:bg-green-500 text-white'
|
|
: 'bg-dark-700 text-dark-400 cursor-not-allowed border border-dark-600'
|
|
}`}
|
|
title={hasUnsavedChanges ? 'Save changes to optimization_config.json' : 'No changes to save'}
|
|
>
|
|
<Save size={14} />
|
|
{isSaving ? 'Saving...' : 'Save'}
|
|
</button>
|
|
)}
|
|
|
|
{/* Reload Button */}
|
|
{(useSpecMode ? spec : 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>
|
|
)}
|
|
|
|
{/* Undo/Redo Buttons (spec mode only) */}
|
|
{useSpecMode && activeStudyId && (
|
|
<>
|
|
<div className="w-px h-6 bg-dark-600" />
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={undo}
|
|
disabled={!canUndo}
|
|
className={`p-1.5 rounded-lg transition-colors ${
|
|
canUndo
|
|
? 'text-dark-200 hover:bg-dark-700 hover:text-white'
|
|
: 'text-dark-600 cursor-not-allowed'
|
|
}`}
|
|
title={`Undo (Ctrl+Z)${historyLength > 0 ? ` - ${historyLength} steps` : ''}`}
|
|
>
|
|
<Undo2 size={16} />
|
|
</button>
|
|
<button
|
|
onClick={redo}
|
|
disabled={!canRedo}
|
|
className={`p-1.5 rounded-lg transition-colors ${
|
|
canRedo
|
|
? 'text-dark-200 hover:bg-dark-700 hover:text-white'
|
|
: 'text-dark-600 cursor-not-allowed'
|
|
}`}
|
|
title="Redo (Ctrl+Y)"
|
|
>
|
|
<Redo2 size={16} />
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<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"
|
|
>
|
|
<ClipboardList size={14} />
|
|
Templates
|
|
</button>
|
|
<button
|
|
onClick={() => setShowImporter(true)}
|
|
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"
|
|
>
|
|
<Download size={14} />
|
|
Import
|
|
</button>
|
|
<button
|
|
onClick={handleClear}
|
|
className="px-3 py-1.5 bg-dark-700 text-dark-200 hover:bg-red-900/50 hover:text-red-400 text-sm rounded-lg transition-colors flex items-center gap-1.5 border border-dark-600"
|
|
>
|
|
<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 flex">
|
|
{/* Left Sidebar with tabs (spec mode only - AtomizerCanvas has its own) */}
|
|
{useSpecMode && (
|
|
<div
|
|
className="relative bg-dark-850 border-r border-dark-700 flex flex-col"
|
|
style={{ width: paletteCollapsed ? 56 : leftPanel.width }}
|
|
>
|
|
{/* 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>
|
|
|
|
{/* Resize handle (only when not collapsed) */}
|
|
{!paletteCollapsed && (
|
|
<ResizeHandle
|
|
onMouseDown={leftPanel.startDrag}
|
|
onDoubleClick={leftPanel.resetWidth}
|
|
isDragging={leftPanel.isDragging}
|
|
position="right"
|
|
/>
|
|
)}
|
|
</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 */}
|
|
{/* Shows INSTEAD of chat when a node is selected */}
|
|
{selectedNodeId ? (
|
|
useSpecMode ? (
|
|
<div
|
|
className="relative border-l border-dark-700 bg-dark-850 flex flex-col"
|
|
style={{ width: rightPanel.width }}
|
|
>
|
|
<ResizeHandle
|
|
onMouseDown={rightPanel.startDrag}
|
|
onDoubleClick={rightPanel.resetWidth}
|
|
isDragging={rightPanel.isDragging}
|
|
position="left"
|
|
/>
|
|
<NodeConfigPanelV2 onClose={() => useSpecStore.getState().clearSelection()} />
|
|
</div>
|
|
) : (
|
|
<div className="w-80 border-l border-dark-700 bg-dark-850 overflow-y-auto">
|
|
<NodeConfigPanel nodeId={selectedNodeId} />
|
|
</div>
|
|
)
|
|
) : showChat ? (
|
|
<div
|
|
className="relative border-l border-dark-700 bg-dark-850 flex flex-col"
|
|
style={{ width: rightPanel.width }}
|
|
>
|
|
{/* Resize handle */}
|
|
<ResizeHandle
|
|
onMouseDown={rightPanel.startDrag}
|
|
onDoubleClick={rightPanel.resetWidth}
|
|
isDragging={rightPanel.isDragging}
|
|
position="left"
|
|
/>
|
|
{/* 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>
|
|
) : null}
|
|
</main>
|
|
|
|
{/* Template Selector Modal */}
|
|
<TemplateSelector
|
|
isOpen={showTemplates}
|
|
onClose={() => setShowTemplates(false)}
|
|
onSelect={handleTemplateSelect}
|
|
/>
|
|
|
|
{/* Config Importer Modal */}
|
|
<ConfigImporter
|
|
isOpen={showImporter}
|
|
onClose={() => setShowImporter(false)}
|
|
onImport={handleImport}
|
|
/>
|
|
|
|
{/* Floating Panels (Introspection, Validation, Error, Results) */}
|
|
{useSpecMode && <PanelContainer />}
|
|
|
|
{/* Draft Restore Prompt (S2) */}
|
|
{useSpecMode && showDraftPrompt && draft && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
<div className="w-[640px] max-w-[92vw] bg-dark-850 rounded-xl border border-dark-600 shadow-2xl p-5">
|
|
<h3 className="text-lg font-semibold text-white">Restore local draft?</h3>
|
|
<p className="text-sm text-dark-300 mt-2">
|
|
A local draft was found for this study (autosaved). You can restore it (recommended) or discard it and keep the published version.
|
|
</p>
|
|
|
|
<div className="mt-4 p-3 bg-dark-900/40 border border-dark-700 rounded-lg text-xs text-dark-400">
|
|
<div>Draft updated: {new Date(draft.updatedAt).toLocaleString()}</div>
|
|
<div>Base hash: {draft.baseHash || '(unknown)'}</div>
|
|
</div>
|
|
|
|
<div className="mt-5 flex justify-end gap-2">
|
|
<button
|
|
onClick={() => {
|
|
discardDraft();
|
|
setShowDraftPrompt(false);
|
|
showNotification('Discarded local draft');
|
|
}}
|
|
className="px-4 py-2 bg-dark-700 text-dark-200 hover:bg-dark-600 rounded-lg border border-dark-600 transition-colors"
|
|
>
|
|
Discard Draft
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setSpecLocalDraft(draft.spec, activeStudyId || undefined);
|
|
setShowDraftPrompt(false);
|
|
showNotification('Restored local draft');
|
|
}}
|
|
className="px-4 py-2 bg-primary-600 text-white hover:bg-primary-500 rounded-lg border border-primary-500 transition-colors"
|
|
>
|
|
Restore Draft
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Notification Toast */}
|
|
{notification && (
|
|
<div
|
|
className="fixed bottom-4 left-1/2 transform -translate-x-1/2 px-4 py-2 bg-dark-800 text-white rounded-lg shadow-lg z-50 border border-dark-600"
|
|
style={{ animation: 'slideUp 0.3s ease-out' }}
|
|
>
|
|
{notification}
|
|
</div>
|
|
)}
|
|
|
|
<style>{`
|
|
@keyframes slideUp {
|
|
from { opacity: 0; transform: translate(-50%, 20px); }
|
|
to { opacity: 1; transform: translate(-50%, 0); }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default CanvasView;
|