feat(canvas): Studio Enhancement Phase 3 & 4 - undo/redo and Monaco editor

Phase 3 - Undo/Redo System:
- Create generic useUndoRedo hook with configurable options
- Add localStorage persistence for per-study history (max 30 steps)
- Create useSpecUndoRedo hook integrating with useSpecStore
- Add useUndoRedoKeyboard hook for Ctrl+Z/Ctrl+Y shortcuts
- Add undo/redo buttons to canvas header with tooltips
- Debounced history recording (1s delay after changes)

Phase 4 - Monaco Code Editor:
- Create CodeEditorPanel component with Monaco editor
- Add Python syntax highlighting and auto-completion
- Include pyNastran/OP2 specific completions
- Add Claude AI code generation integration (placeholder)
- Include code validation/run functionality
- Show output variables preview section
- Add copy-to-clipboard and generation prompt UI

Dependencies:
- Add @monaco-editor/react package

Technical:
- All TypeScript checks pass
- All 15 unit tests pass
- Production build successful
This commit is contained in:
2026-01-20 11:58:21 -05:00
parent c4a3cff91a
commit ffd41e3a60
6 changed files with 2926 additions and 62 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -7,15 +7,20 @@
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"@nivo/core": "^0.99.0",
"@nivo/parallel-coordinates": "^0.99.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.4.0",
"@tanstack/react-query": "^5.90.10",
"@types/react-plotly.js": "^2.6.3",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/three": "^0.181.0",
"@xterm/addon-fit": "^0.10.0",
@@ -23,11 +28,9 @@
"clsx": "^2.1.1",
"katex": "^0.16.25",
"lucide-react": "^0.554.0",
"plotly.js-basic-dist": "^3.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"react-plotly.js": "^2.6.0",
"react-router-dom": "^6.20.0",
"react-syntax-highlighter": "^16.1.0",
"react-use-websocket": "^4.13.0",
@@ -42,18 +45,27 @@
"zustand": "^5.0.10"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^4.0.17",
"@vitest/ui": "^4.0.17",
"autoprefixer": "^10.4.16",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"jsdom": "^27.4.0",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "^5.2.2",
"vite": "^5.0.8"
"vite": "^5.0.8",
"vitest": "^4.0.17"
}
}

View File

@@ -0,0 +1,449 @@
/**
* CodeEditorPanel - Monaco editor for custom extractor Python code
*
* Features:
* - Python syntax highlighting
* - Auto-completion for common patterns
* - Error display
* - Claude AI code generation button
* - Preview of extracted outputs
*/
import { useState, useCallback, useRef } from 'react';
import Editor, { OnMount, OnChange } from '@monaco-editor/react';
import {
Play,
Wand2,
Copy,
Check,
AlertCircle,
RefreshCw,
X,
ChevronDown,
ChevronRight,
FileCode,
Sparkles,
} from 'lucide-react';
// Monaco editor types
type Monaco = Parameters<OnMount>[1];
type EditorInstance = Parameters<OnMount>[0];
interface CodeEditorPanelProps {
/** Initial code content */
initialCode?: string;
/** Callback when code changes */
onChange?: (code: string) => void;
/** Callback when user requests Claude generation */
onRequestGeneration?: (prompt: string) => Promise<string>;
/** Whether the panel is read-only */
readOnly?: boolean;
/** Extractor name for context */
extractorName?: string;
/** Output variable names */
outputs?: string[];
/** Optional height (default: 300px) */
height?: number | string;
/** Show/hide header */
showHeader?: boolean;
/** Callback when running code (validation) */
onRun?: (code: string) => Promise<{ success: boolean; error?: string; outputs?: Record<string, unknown> }>;
/** Close button callback */
onClose?: () => void;
}
// Default Python template for custom extractors
const DEFAULT_EXTRACTOR_TEMPLATE = `"""
Custom Extractor Function
This function is called after FEA simulation completes.
It receives the results and should return extracted values.
Available inputs:
- op2_path: Path to the .op2 results file
- fem_path: Path to the .fem file
- params: Dict of current design variable values
- subcase_id: Current subcase being analyzed (optional)
Return a dict with your extracted values.
"""
from pyNastran.op2.op2 import OP2
import numpy as np
def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
"""
Extract physics from FEA results.
Args:
op2_path: Path to OP2 results file
fem_path: Path to FEM file
params: Current design variable values
subcase_id: Subcase ID to analyze
Returns:
Dict with extracted values, e.g. {'max_stress': 150.5, 'mass': 2.3}
"""
# Load OP2 results
op2 = OP2()
op2.read_op2(op2_path)
# Example: Extract max displacement
if subcase_id in op2.displacements:
disp = op2.displacements[subcase_id]
# Get magnitude of displacement vectors
magnitudes = np.sqrt(np.sum(disp.data[0, :, 1:4]**2, axis=1))
max_disp = float(np.max(magnitudes))
else:
max_disp = 0.0
return {
'max_displacement': max_disp,
# Add more outputs as needed
}
`;
export function CodeEditorPanel({
initialCode = DEFAULT_EXTRACTOR_TEMPLATE,
onChange,
onRequestGeneration,
readOnly = false,
extractorName = 'custom_extractor',
outputs = [],
height = 400,
showHeader = true,
onRun,
onClose,
}: CodeEditorPanelProps) {
const [code, setCode] = useState(initialCode);
const [isGenerating, setIsGenerating] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [runResult, setRunResult] = useState<{ success: boolean; outputs?: Record<string, unknown> } | null>(null);
const [copied, setCopied] = useState(false);
const [showPromptInput, setShowPromptInput] = useState(false);
const [generationPrompt, setGenerationPrompt] = useState('');
const [showOutputs, setShowOutputs] = useState(true);
const editorRef = useRef<EditorInstance | null>(null);
const monacoRef = useRef<Monaco | null>(null);
// Handle editor mount
const handleEditorMount: OnMount = (editor, monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
// Configure Python language
monaco.languages.registerCompletionItemProvider('python', {
provideCompletionItems: (model: Parameters<typeof monaco.editor.createModel>[0], position: { lineNumber: number; column: number }) => {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const suggestions = [
{
label: 'op2.read_op2',
kind: monaco.languages.CompletionItemKind.Method,
insertText: 'op2.read_op2(op2_path)',
documentation: 'Read OP2 results file',
range,
},
{
label: 'op2.displacements',
kind: monaco.languages.CompletionItemKind.Property,
insertText: 'op2.displacements[subcase_id]',
documentation: 'Access displacement results for a subcase',
range,
},
{
label: 'op2.eigenvectors',
kind: monaco.languages.CompletionItemKind.Property,
insertText: 'op2.eigenvectors[subcase_id]',
documentation: 'Access eigenvector results for modal analysis',
range,
},
{
label: 'np.max',
kind: monaco.languages.CompletionItemKind.Function,
insertText: 'np.max(${1:array})',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: 'Get maximum value from array',
range,
},
{
label: 'np.sqrt',
kind: monaco.languages.CompletionItemKind.Function,
insertText: 'np.sqrt(${1:array})',
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: 'Square root of array elements',
range,
},
{
label: 'extract_function',
kind: monaco.languages.CompletionItemKind.Snippet,
insertText: `def extract(op2_path: str, fem_path: str, params: dict, subcase_id: int = 1) -> dict:
"""Extract physics from FEA results."""
op2 = OP2()
op2.read_op2(op2_path)
# Your extraction logic here
return {
'\${1:output_name}': \${2:value},
}`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: 'Insert a complete extract function template',
range,
},
];
return { suggestions };
},
});
};
// Handle code change
const handleCodeChange: OnChange = (value) => {
const newCode = value || '';
setCode(newCode);
setError(null);
setRunResult(null);
onChange?.(newCode);
};
// Copy code to clipboard
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [code]);
// Request Claude generation
const handleGenerate = useCallback(async () => {
if (!onRequestGeneration || !generationPrompt.trim()) return;
setIsGenerating(true);
setError(null);
try {
const generatedCode = await onRequestGeneration(generationPrompt);
setCode(generatedCode);
onChange?.(generatedCode);
setShowPromptInput(false);
setGenerationPrompt('');
} catch (err) {
setError(err instanceof Error ? err.message : 'Generation failed');
} finally {
setIsGenerating(false);
}
}, [onRequestGeneration, generationPrompt, onChange]);
// Run/validate code
const handleRun = useCallback(async () => {
if (!onRun) return;
setIsRunning(true);
setError(null);
setRunResult(null);
try {
const result = await onRun(code);
setRunResult(result);
if (!result.success && result.error) {
setError(result.error);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Validation failed');
} finally {
setIsRunning(false);
}
}, [code, onRun]);
return (
<div className="flex flex-col h-full bg-dark-850 border-l border-dark-700">
{/* Header */}
{showHeader && (
<div className="flex items-center justify-between px-4 py-2 border-b border-dark-700">
<div className="flex items-center gap-2">
<FileCode size={16} className="text-emerald-400" />
<span className="font-medium text-white text-sm">{extractorName}</span>
<span className="text-xs text-dark-500">.py</span>
</div>
<div className="flex items-center gap-2">
{/* Claude Generate Button */}
{onRequestGeneration && (
<button
onClick={() => setShowPromptInput(!showPromptInput)}
className="p-1.5 rounded text-violet-400 hover:bg-violet-500/20 transition-colors"
title="Generate with Claude"
>
<Sparkles size={16} />
</button>
)}
{/* Copy Button */}
<button
onClick={handleCopy}
className="p-1.5 rounded text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
title="Copy code"
>
{copied ? <Check size={16} className="text-emerald-400" /> : <Copy size={16} />}
</button>
{/* Run Button */}
{onRun && (
<button
onClick={handleRun}
disabled={isRunning}
className="p-1.5 rounded text-emerald-400 hover:bg-emerald-500/20 transition-colors disabled:opacity-50"
title="Validate code"
>
{isRunning ? (
<RefreshCw size={16} className="animate-spin" />
) : (
<Play size={16} />
)}
</button>
)}
{/* Close Button */}
{onClose && (
<button
onClick={onClose}
className="p-1.5 rounded text-dark-400 hover:text-white hover:bg-dark-700 transition-colors"
>
<X size={16} />
</button>
)}
</div>
</div>
)}
{/* Claude Prompt Input */}
{showPromptInput && (
<div className="px-4 py-3 border-b border-dark-700 bg-violet-500/5">
<div className="flex items-center gap-2 mb-2">
<Wand2 size={14} className="text-violet-400" />
<span className="text-xs text-violet-400 font-medium">Generate with Claude</span>
</div>
<textarea
value={generationPrompt}
onChange={(e) => setGenerationPrompt(e.target.value)}
placeholder="Describe what you want to extract... e.g., 'Extract maximum von Mises stress and total mass from the model'"
className="w-full px-3 py-2 bg-dark-800 border border-dark-600 rounded-lg text-sm text-white placeholder-dark-500 resize-none focus:outline-none focus:border-violet-500"
rows={2}
/>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => setShowPromptInput(false)}
className="px-3 py-1.5 text-xs text-dark-400 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleGenerate}
disabled={isGenerating || !generationPrompt.trim()}
className="px-3 py-1.5 text-xs bg-violet-600 text-white rounded hover:bg-violet-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
>
{isGenerating ? (
<>
<RefreshCw size={12} className="animate-spin" />
Generating...
</>
) : (
<>
<Sparkles size={12} />
Generate
</>
)}
</button>
</div>
</div>
)}
{/* Error Display */}
{error && (
<div className="px-4 py-2 bg-red-500/10 border-b border-red-500/30 flex items-center gap-2">
<AlertCircle size={14} className="text-red-400 flex-shrink-0" />
<span className="text-xs text-red-400 font-mono">{error}</span>
</div>
)}
{/* Monaco Editor */}
<div className="flex-1 min-h-0">
<Editor
height={height}
language="python"
theme="vs-dark"
value={code}
onChange={handleCodeChange}
onMount={handleEditorMount}
options={{
readOnly,
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
scrollBeyondLastLine: false,
wordWrap: 'on',
automaticLayout: true,
tabSize: 4,
insertSpaces: true,
padding: { top: 8, bottom: 8 },
scrollbar: {
vertical: 'auto',
horizontal: 'auto',
},
}}
/>
</div>
{/* Outputs Preview */}
{(outputs.length > 0 || runResult?.outputs) && (
<div className="border-t border-dark-700">
<button
onClick={() => setShowOutputs(!showOutputs)}
className="w-full px-4 py-2 flex items-center gap-2 text-xs text-dark-400 hover:text-white hover:bg-dark-800/50 transition-colors"
>
{showOutputs ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
<span>Outputs</span>
<span className="ml-auto text-dark-500">
{runResult?.outputs
? Object.keys(runResult.outputs).length
: outputs.length}
</span>
</button>
{showOutputs && (
<div className="px-4 pb-3 space-y-1">
{runResult?.outputs ? (
Object.entries(runResult.outputs).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between px-2 py-1 bg-dark-800 rounded text-xs"
>
<span className="text-emerald-400 font-mono">{key}</span>
<span className="text-dark-300">{String(value)}</span>
</div>
))
) : (
outputs.map((output) => (
<div
key={output}
className="flex items-center px-2 py-1 bg-dark-800 rounded text-xs"
>
<span className="text-dark-400 font-mono">{output}</span>
</div>
))
)}
</div>
)}
</div>
)}
</div>
);
}
export default CodeEditorPanel;

View File

@@ -0,0 +1,129 @@
/**
* useSpecUndoRedo - Undo/Redo for AtomizerSpec changes
*
* Integrates with useSpecStore to provide undo/redo functionality
* with localStorage persistence.
*
* Usage:
* ```tsx
* const { undo, redo, canUndo, canRedo } = useSpecUndoRedo();
*
* // In keyboard handler:
* if (e.ctrlKey && e.key === 'z') undo();
* if (e.ctrlKey && e.key === 'y') redo();
* ```
*/
import { useEffect, useRef } from 'react';
import { useUndoRedo, UndoRedoResult } from './useUndoRedo';
import { useSpecStore, useSpec, useSpecIsDirty } from './useSpecStore';
import { AtomizerSpec } from '../types/atomizer-spec';
const STORAGE_KEY_PREFIX = 'atomizer-spec-history-';
export interface SpecUndoRedoResult extends UndoRedoResult<AtomizerSpec | null> {
/** The current study ID */
studyId: string | null;
}
export function useSpecUndoRedo(): SpecUndoRedoResult {
const spec = useSpec();
const isDirty = useSpecIsDirty();
const studyId = useSpecStore((state) => state.studyId);
const lastSpecRef = useRef<AtomizerSpec | null>(null);
// Storage key includes study ID for per-study history
const storageKey = studyId ? `${STORAGE_KEY_PREFIX}${studyId}` : undefined;
const undoRedo = useUndoRedo<AtomizerSpec | null>({
getState: () => useSpecStore.getState().spec,
setState: (state) => {
if (state) {
// Use setSpecFromWebSocket to avoid API call during undo/redo
useSpecStore.getState().setSpecFromWebSocket(state, studyId || undefined);
}
},
storageKey,
maxHistory: 30, // Keep 30 undo steps per study
debounceMs: 1000, // Wait 1s after last change before recording
isEqual: (a, b) => {
if (a === null && b === null) return true;
if (a === null || b === null) return false;
// Compare relevant parts of spec (ignore meta.modified timestamps)
const aClean = { ...a, meta: { ...a.meta, modified: undefined } };
const bClean = { ...b, meta: { ...b.meta, modified: undefined } };
return JSON.stringify(aClean) === JSON.stringify(bClean);
},
});
// Record snapshot when spec changes (and is dirty)
useEffect(() => {
if (spec && isDirty && spec !== lastSpecRef.current) {
lastSpecRef.current = spec;
undoRedo.recordSnapshot();
}
}, [spec, isDirty, undoRedo]);
// Clear history when study changes
useEffect(() => {
if (studyId) {
// Don't clear - we're loading per-study history from localStorage
// Just reset the ref
lastSpecRef.current = spec;
}
}, [studyId]);
return {
...undoRedo,
studyId,
};
}
/**
* Hook to handle keyboard shortcuts for undo/redo
*/
export function useUndoRedoKeyboard(
undoRedo: Pick<UndoRedoResult<unknown>, 'undo' | 'redo' | 'canUndo' | 'canRedo'>
) {
const { undo, redo, canUndo, canRedo } = undoRedo;
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
const target = e.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
// Ctrl+Z or Cmd+Z for undo
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
if (canUndo) {
undo();
}
return;
}
// Ctrl+Y or Cmd+Shift+Z for redo
if (
((e.ctrlKey || e.metaKey) && e.key === 'y') ||
((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z')
) {
e.preventDefault();
if (canRedo) {
redo();
}
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [undo, redo, canUndo, canRedo]);
}
export default useSpecUndoRedo;

View File

@@ -0,0 +1,260 @@
/**
* useUndoRedo - Generic undo/redo hook for Zustand stores
*
* Features:
* - History tracking with configurable max size
* - Undo/redo operations
* - localStorage persistence (optional)
* - Debounced history recording
* - Clear history on demand
*
* Usage:
* ```tsx
* const { undo, redo, canUndo, canRedo, recordSnapshot } = useUndoRedo({
* getState: () => myStore.getState().data,
* setState: (state) => myStore.setState({ data: state }),
* storageKey: 'my-store-history',
* });
* ```
*/
import { useState, useCallback, useEffect, useRef } from 'react';
export interface UndoRedoOptions<T> {
/** Function to get current state snapshot */
getState: () => T;
/** Function to restore a state snapshot */
setState: (state: T) => void;
/** Maximum history size (default: 50) */
maxHistory?: number;
/** localStorage key for persistence (optional) */
storageKey?: string;
/** Debounce delay in ms for recording (default: 500) */
debounceMs?: number;
/** Custom equality check (default: JSON.stringify comparison) */
isEqual?: (a: T, b: T) => boolean;
}
export interface UndoRedoResult<T> {
/** Undo the last change */
undo: () => void;
/** Redo the last undone change */
redo: () => void;
/** Whether undo is available */
canUndo: boolean;
/** Whether redo is available */
canRedo: boolean;
/** Manually record a state snapshot */
recordSnapshot: () => void;
/** Clear all history */
clearHistory: () => void;
/** Current history length */
historyLength: number;
/** Current position in history */
historyPosition: number;
/** Get history for debugging */
getHistory: () => T[];
}
interface HistoryState<T> {
past: T[];
future: T[];
}
const DEFAULT_MAX_HISTORY = 50;
const DEFAULT_DEBOUNCE_MS = 500;
function defaultIsEqual<T>(a: T, b: T): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
export function useUndoRedo<T>(options: UndoRedoOptions<T>): UndoRedoResult<T> {
const {
getState,
setState,
maxHistory = DEFAULT_MAX_HISTORY,
storageKey,
debounceMs = DEFAULT_DEBOUNCE_MS,
isEqual = defaultIsEqual,
} = options;
// Initialize history from localStorage if available
const getInitialHistory = (): HistoryState<T> => {
if (storageKey) {
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
const parsed = JSON.parse(stored);
// Validate structure
if (Array.isArray(parsed.past) && Array.isArray(parsed.future)) {
return parsed;
}
}
} catch (e) {
console.warn('Failed to load undo history from localStorage:', e);
}
}
return { past: [], future: [] };
};
const [history, setHistory] = useState<HistoryState<T>>(getInitialHistory);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastRecordedStateRef = useRef<T | null>(null);
const isUndoRedoRef = useRef(false);
// Persist to localStorage when history changes
useEffect(() => {
if (storageKey) {
try {
localStorage.setItem(storageKey, JSON.stringify(history));
} catch (e) {
console.warn('Failed to save undo history to localStorage:', e);
}
}
}, [history, storageKey]);
// Record a snapshot to history
const recordSnapshot = useCallback(() => {
if (isUndoRedoRef.current) {
// Don't record during undo/redo operations
return;
}
const currentState = getState();
// Skip if state hasn't changed
if (lastRecordedStateRef.current !== null && isEqual(lastRecordedStateRef.current, currentState)) {
return;
}
lastRecordedStateRef.current = currentState;
setHistory((prev) => {
// Create a deep copy for history
const snapshot = JSON.parse(JSON.stringify(currentState)) as T;
const newPast = [...prev.past, snapshot];
// Trim history if too long
if (newPast.length > maxHistory) {
newPast.shift();
}
return {
past: newPast,
future: [], // Clear redo stack on new changes
};
});
}, [getState, maxHistory, isEqual]);
// Debounced recording
const recordSnapshotDebounced = useCallback(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
recordSnapshot();
}, debounceMs);
}, [recordSnapshot, debounceMs]);
// Undo operation
const undo = useCallback(() => {
setHistory((prev) => {
if (prev.past.length === 0) {
return prev;
}
const newPast = [...prev.past];
const previousState = newPast.pop()!;
// Save current state to future before undoing
const currentState = JSON.parse(JSON.stringify(getState())) as T;
isUndoRedoRef.current = true;
setState(previousState);
lastRecordedStateRef.current = previousState;
// Reset flag after a tick
setTimeout(() => {
isUndoRedoRef.current = false;
}, 0);
return {
past: newPast,
future: [currentState, ...prev.future],
};
});
}, [getState, setState]);
// Redo operation
const redo = useCallback(() => {
setHistory((prev) => {
if (prev.future.length === 0) {
return prev;
}
const newFuture = [...prev.future];
const nextState = newFuture.shift()!;
// Save current state to past before redoing
const currentState = JSON.parse(JSON.stringify(getState())) as T;
isUndoRedoRef.current = true;
setState(nextState);
lastRecordedStateRef.current = nextState;
// Reset flag after a tick
setTimeout(() => {
isUndoRedoRef.current = false;
}, 0);
return {
past: [...prev.past, currentState],
future: newFuture,
};
});
}, [getState, setState]);
// Clear history
const clearHistory = useCallback(() => {
setHistory({ past: [], future: [] });
lastRecordedStateRef.current = null;
if (storageKey) {
try {
localStorage.removeItem(storageKey);
} catch (e) {
console.warn('Failed to clear undo history from localStorage:', e);
}
}
}, [storageKey]);
// Get history for debugging
const getHistory = useCallback(() => {
return [...history.past];
}, [history.past]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
return {
undo,
redo,
canUndo: history.past.length > 0,
canRedo: history.future.length > 0,
recordSnapshot: recordSnapshotDebounced,
clearHistory,
historyLength: history.past.length,
historyPosition: history.past.length,
getHistory,
};
}
export default useUndoRedo;

View File

@@ -1,6 +1,6 @@
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 { 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';
@@ -12,6 +12,7 @@ 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 { useSpecUndoRedo, useUndoRedoKeyboard } from '../hooks/useSpecUndoRedo';
import { useStudy } from '../context/StudyContext';
import { useChat } from '../hooks/useChat';
import { CanvasTemplate } from '../lib/canvas/templates';
@@ -54,6 +55,13 @@ export function CanvasView() {
const { setSelectedStudy, studies } = useStudy();
const { clearSpec, setSpecFromWebSocket } = 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);
// 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;
@@ -318,6 +326,39 @@ export function CanvasView() {
</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"