Phase 1 - Panel Management System: - Create usePanelStore.ts for centralized panel state management - Add PanelContainer.tsx for draggable floating panels - Create FloatingIntrospectionPanel.tsx (persistent, doesn't disappear on node click) - Create ResultsPanel.tsx for trial result details - Refactor NodeConfigPanelV2 to use panel store for introspection - Integrate PanelContainer into CanvasView Phase 2 - Pre-run Validation: - Create specValidator.ts with comprehensive validation rules - Add ValidationPanel (enhanced version with error navigation) - Add Validate button to SpecRenderer with status indicator - Block run if validation fails - Check for: design vars, objectives, extractors, bounds, connections Phase 3 - Error Handling & Recovery: - Create ErrorPanel.tsx for displaying optimization errors - Add error classification (nx_crash, solver_fail, extractor_error, etc.) - Add recovery suggestions based on error type - Update status endpoint to return error info - Add _get_study_error_info helper to check error_status.json and DB - Integrate error detection into status polling Documentation: - Add CANVAS_ROBUSTNESS_PLAN.md with full implementation plan
208 lines
6.1 KiB
TypeScript
208 lines
6.1 KiB
TypeScript
/**
|
|
* PanelContainer - Orchestrates all floating panels in the canvas view
|
|
*
|
|
* This component renders floating panels (Introspection, Validation, Error, Results)
|
|
* in a portal, positioned absolutely within the canvas area.
|
|
*
|
|
* Features:
|
|
* - Draggable panels
|
|
* - Z-index management (click to bring to front)
|
|
* - Keyboard shortcuts (Escape to close all)
|
|
* - Position persistence via usePanelStore
|
|
*/
|
|
|
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import {
|
|
usePanelStore,
|
|
useIntrospectionPanel,
|
|
useValidationPanel,
|
|
useErrorPanel,
|
|
useResultsPanel,
|
|
PanelPosition,
|
|
} from '../../../hooks/usePanelStore';
|
|
import { FloatingIntrospectionPanel } from './FloatingIntrospectionPanel';
|
|
import { FloatingValidationPanel } from './ValidationPanel';
|
|
import { ErrorPanel } from './ErrorPanel';
|
|
import { ResultsPanel } from './ResultsPanel';
|
|
|
|
interface PanelContainerProps {
|
|
/** Container element to render panels into (defaults to document.body) */
|
|
container?: HTMLElement;
|
|
/** Callback when retry is requested from error panel */
|
|
onRetry?: (trial?: number) => void;
|
|
/** Callback when skip trial is requested */
|
|
onSkipTrial?: (trial: number) => void;
|
|
}
|
|
|
|
type PanelName = 'introspection' | 'validation' | 'error' | 'results';
|
|
|
|
export function PanelContainer({ container, onRetry, onSkipTrial }: PanelContainerProps) {
|
|
const { closePanel, setPanelPosition, closeAllPanels } = usePanelStore();
|
|
|
|
const introspectionPanel = useIntrospectionPanel();
|
|
const validationPanel = useValidationPanel();
|
|
const errorPanel = useErrorPanel();
|
|
const resultsPanel = useResultsPanel();
|
|
|
|
// Track which panel is on top (for z-index)
|
|
const [topPanel, setTopPanel] = useState<PanelName | null>(null);
|
|
|
|
// Dragging state
|
|
const [dragging, setDragging] = useState<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
|
|
const dragRef = useRef<{ panel: PanelName; offset: { x: number; y: number } } | null>(null);
|
|
|
|
// Escape key to close all panels
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
closeAllPanels();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [closeAllPanels]);
|
|
|
|
// Mouse move handler for dragging
|
|
useEffect(() => {
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (!dragRef.current) return;
|
|
|
|
const { panel, offset } = dragRef.current;
|
|
const newPosition: PanelPosition = {
|
|
x: e.clientX - offset.x,
|
|
y: e.clientY - offset.y,
|
|
};
|
|
|
|
// Clamp to viewport
|
|
newPosition.x = Math.max(0, Math.min(window.innerWidth - 100, newPosition.x));
|
|
newPosition.y = Math.max(0, Math.min(window.innerHeight - 50, newPosition.y));
|
|
|
|
setPanelPosition(panel, newPosition);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
dragRef.current = null;
|
|
setDragging(null);
|
|
};
|
|
|
|
if (dragging) {
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}, [dragging, setPanelPosition]);
|
|
|
|
// Start dragging a panel
|
|
const handleDragStart = useCallback((panel: PanelName, e: React.MouseEvent, position: PanelPosition) => {
|
|
const offset = {
|
|
x: e.clientX - position.x,
|
|
y: e.clientY - position.y,
|
|
};
|
|
dragRef.current = { panel, offset };
|
|
setDragging({ panel, offset });
|
|
setTopPanel(panel);
|
|
}, []);
|
|
|
|
// Click to bring panel to front
|
|
const handlePanelClick = useCallback((panel: PanelName) => {
|
|
setTopPanel(panel);
|
|
}, []);
|
|
|
|
// Get z-index for a panel
|
|
const getZIndex = (panel: PanelName) => {
|
|
const baseZ = 100;
|
|
if (panel === topPanel) return baseZ + 10;
|
|
return baseZ;
|
|
};
|
|
|
|
// Render a draggable wrapper
|
|
const renderDraggable = (
|
|
panel: PanelName,
|
|
position: PanelPosition,
|
|
isOpen: boolean,
|
|
children: React.ReactNode
|
|
) => {
|
|
if (!isOpen) return null;
|
|
|
|
return (
|
|
<div
|
|
key={panel}
|
|
className="fixed select-none"
|
|
style={{
|
|
left: position.x,
|
|
top: position.y,
|
|
zIndex: getZIndex(panel),
|
|
cursor: dragging?.panel === panel ? 'grabbing' : 'default',
|
|
}}
|
|
onClick={() => handlePanelClick(panel)}
|
|
>
|
|
{/* Drag handle - the header area */}
|
|
<div
|
|
className="absolute top-0 left-0 right-0 h-12 cursor-grab active:cursor-grabbing"
|
|
onMouseDown={(e) => handleDragStart(panel, e, position)}
|
|
style={{ zIndex: 1 }}
|
|
/>
|
|
{/* Panel content */}
|
|
<div className="relative" style={{ zIndex: 0 }}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Determine what to render
|
|
const panels = (
|
|
<>
|
|
{/* Introspection Panel */}
|
|
{renderDraggable(
|
|
'introspection',
|
|
introspectionPanel.position || { x: 100, y: 100 },
|
|
introspectionPanel.open,
|
|
<FloatingIntrospectionPanel onClose={() => closePanel('introspection')} />
|
|
)}
|
|
|
|
{/* Validation Panel */}
|
|
{renderDraggable(
|
|
'validation',
|
|
validationPanel.position || { x: 150, y: 150 },
|
|
validationPanel.open,
|
|
<FloatingValidationPanel onClose={() => closePanel('validation')} />
|
|
)}
|
|
|
|
{/* Error Panel */}
|
|
{renderDraggable(
|
|
'error',
|
|
errorPanel.position || { x: 200, y: 100 },
|
|
errorPanel.open,
|
|
<ErrorPanel
|
|
onClose={() => closePanel('error')}
|
|
onRetry={onRetry}
|
|
onSkipTrial={onSkipTrial}
|
|
/>
|
|
)}
|
|
|
|
{/* Results Panel */}
|
|
{renderDraggable(
|
|
'results',
|
|
resultsPanel.position || { x: 250, y: 150 },
|
|
resultsPanel.open,
|
|
<ResultsPanel onClose={() => closePanel('results')} />
|
|
)}
|
|
</>
|
|
);
|
|
|
|
// Use portal if container specified, otherwise render in place
|
|
if (container) {
|
|
return createPortal(panels, container);
|
|
}
|
|
|
|
return panels;
|
|
}
|
|
|
|
export default PanelContainer;
|