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,3 +1,19 @@
/**
* @deprecated This store is deprecated as of January 2026.
* Use useSpecStore instead, which works with AtomizerSpec v2.0.
*
* Migration guide:
* - Import useSpecStore from '../hooks/useSpecStore' instead
* - Use spec.design_variables, spec.extractors, etc. instead of nodes/edges
* - Use addNode(), updateNode(), removeNode() instead of canvas mutations
* - Spec changes sync automatically via WebSocket
*
* This store is kept for emergency fallback only with AtomizerCanvas.
*
* @see useSpecStore for the new state management
* @see AtomizerSpec v2.0 documentation
*/
import { create } from 'zustand';
import { Node, Edge, addEdge, applyNodeChanges, applyEdgeChanges, Connection, NodeChange, EdgeChange } from 'reactflow';
import { CanvasNodeData, NodeType } from '../lib/canvas/schema';

View File

@@ -0,0 +1,209 @@
/**
* useSpecStore Unit Tests
*
* Tests for the AtomizerSpec v2.0 state management store.
*/
/// <reference types="vitest/globals" />
import { describe, it, expect, beforeEach } from 'vitest';
import { useSpecStore } from './useSpecStore';
import { createMockSpec, mockFetch } from '../test/utils';
// Type for global context
declare const global: typeof globalThis;
describe('useSpecStore', () => {
beforeEach(() => {
// Reset the store state before each test
useSpecStore.setState({
spec: null,
studyId: null,
hash: null,
isLoading: false,
error: null,
validation: null,
selectedNodeId: null,
selectedEdgeId: null,
isDirty: false,
pendingChanges: [],
});
});
describe('initial state', () => {
it('should have null spec initially', () => {
const { spec } = useSpecStore.getState();
expect(spec).toBeNull();
});
it('should not be loading initially', () => {
const { isLoading } = useSpecStore.getState();
expect(isLoading).toBe(false);
});
it('should have no selected node initially', () => {
const { selectedNodeId } = useSpecStore.getState();
expect(selectedNodeId).toBeNull();
});
});
describe('selection', () => {
it('should select a node', () => {
const { selectNode } = useSpecStore.getState();
selectNode('dv_001');
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
expect(selectedNodeId).toBe('dv_001');
expect(selectedEdgeId).toBeNull();
});
it('should select an edge', () => {
const { selectEdge } = useSpecStore.getState();
selectEdge('edge_1');
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
expect(selectedEdgeId).toBe('edge_1');
expect(selectedNodeId).toBeNull();
});
it('should clear selection', () => {
const { selectNode, clearSelection } = useSpecStore.getState();
selectNode('dv_001');
clearSelection();
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
expect(selectedNodeId).toBeNull();
expect(selectedEdgeId).toBeNull();
});
it('should clear edge when selecting node', () => {
const { selectEdge, selectNode } = useSpecStore.getState();
selectEdge('edge_1');
selectNode('dv_001');
const { selectedNodeId, selectedEdgeId } = useSpecStore.getState();
expect(selectedNodeId).toBe('dv_001');
expect(selectedEdgeId).toBeNull();
});
});
describe('setSpecFromWebSocket', () => {
it('should set spec directly', () => {
const mockSpec = createMockSpec({ meta: { study_name: 'ws_test' } });
const { setSpecFromWebSocket } = useSpecStore.getState();
setSpecFromWebSocket(mockSpec, 'test_study');
const { spec, studyId, isLoading, error } = useSpecStore.getState();
expect(spec?.meta.study_name).toBe('ws_test');
expect(studyId).toBe('test_study');
expect(isLoading).toBe(false);
expect(error).toBeNull();
});
});
describe('loadSpec', () => {
it('should set loading state', async () => {
mockFetch({
'spec': createMockSpec(),
'hash': { hash: 'abc123' },
});
const { loadSpec } = useSpecStore.getState();
const loadPromise = loadSpec('test_study');
// Should be loading immediately
expect(useSpecStore.getState().isLoading).toBe(true);
await loadPromise;
// Should no longer be loading
expect(useSpecStore.getState().isLoading).toBe(false);
});
it('should handle errors', async () => {
(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
const { loadSpec } = useSpecStore.getState();
await loadSpec('test_study');
const { error, isLoading } = useSpecStore.getState();
expect(error).toContain('error');
expect(isLoading).toBe(false);
});
});
describe('getNodeById', () => {
beforeEach(() => {
const mockSpec = createMockSpec({
design_variables: [
{ id: 'dv_001', name: 'thickness', expression_name: 't', type: 'continuous', bounds: { min: 1, max: 10 } },
{ id: 'dv_002', name: 'width', expression_name: 'w', type: 'continuous', bounds: { min: 5, max: 20 } },
],
extractors: [
{ id: 'ext_001', name: 'displacement', type: 'displacement', outputs: ['d'] },
],
objectives: [
{ id: 'obj_001', name: 'mass', type: 'minimize', weight: 1.0 },
],
});
useSpecStore.setState({ spec: mockSpec });
});
it('should find design variable by id', () => {
const { getNodeById } = useSpecStore.getState();
const node = getNodeById('dv_001');
expect(node).not.toBeNull();
expect((node as any).name).toBe('thickness');
});
it('should find extractor by id', () => {
const { getNodeById } = useSpecStore.getState();
const node = getNodeById('ext_001');
expect(node).not.toBeNull();
expect((node as any).name).toBe('displacement');
});
it('should find objective by id', () => {
const { getNodeById } = useSpecStore.getState();
const node = getNodeById('obj_001');
expect(node).not.toBeNull();
expect((node as any).name).toBe('mass');
});
it('should return null for unknown id', () => {
const { getNodeById } = useSpecStore.getState();
const node = getNodeById('unknown_999');
expect(node).toBeNull();
});
});
describe('clearSpec', () => {
it('should reset all state', () => {
// Set up some state
useSpecStore.setState({
spec: createMockSpec(),
studyId: 'test',
hash: 'abc',
selectedNodeId: 'dv_001',
isDirty: true,
});
const { clearSpec } = useSpecStore.getState();
clearSpec();
const state = useSpecStore.getState();
expect(state.spec).toBeNull();
expect(state.studyId).toBeNull();
expect(state.hash).toBeNull();
expect(state.selectedNodeId).toBeNull();
expect(state.isDirty).toBe(false);
});
});
});

View File

@@ -0,0 +1,742 @@
/**
* useSpecStore - Zustand store for AtomizerSpec v2.0
*
* Central state management for the unified configuration system.
* All spec modifications flow through this store and sync with backend.
*
* Features:
* - Load spec from backend API
* - Optimistic updates with rollback on error
* - Patch operations via JSONPath
* - Node CRUD operations
* - Hash-based conflict detection
*/
import { create } from 'zustand';
import { devtools, subscribeWithSelector } from 'zustand/middleware';
import {
AtomizerSpec,
DesignVariable,
Extractor,
Objective,
Constraint,
CanvasPosition,
SpecValidationReport,
SpecModification,
} from '../types/atomizer-spec';
// API base URL
const API_BASE = '/api';
// ============================================================================
// Types
// ============================================================================
interface SpecStoreState {
// Spec data
spec: AtomizerSpec | null;
studyId: string | null;
hash: string | null;
// Loading state
isLoading: boolean;
error: string | null;
// Validation
validation: SpecValidationReport | null;
// Selection state (for canvas)
selectedNodeId: string | null;
selectedEdgeId: string | null;
// Dirty tracking
isDirty: boolean;
pendingChanges: SpecModification[];
}
interface SpecStoreActions {
// Loading
loadSpec: (studyId: string) => Promise<void>;
reloadSpec: () => Promise<void>;
clearSpec: () => void;
// WebSocket integration - set spec directly without API call
setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => void;
// Full spec operations
saveSpec: (spec: AtomizerSpec) => Promise<void>;
replaceSpec: (spec: AtomizerSpec) => Promise<void>;
// Patch operations
patchSpec: (path: string, value: unknown) => Promise<void>;
patchSpecOptimistic: (path: string, value: unknown) => void;
// Node operations
addNode: (
type: 'designVar' | 'extractor' | 'objective' | 'constraint',
data: Record<string, unknown>
) => Promise<string>;
updateNode: (nodeId: string, updates: Record<string, unknown>) => Promise<void>;
removeNode: (nodeId: string) => Promise<void>;
updateNodePosition: (nodeId: string, position: CanvasPosition) => Promise<void>;
// Edge operations
addEdge: (source: string, target: string) => Promise<void>;
removeEdge: (source: string, target: string) => Promise<void>;
// Custom function
addCustomFunction: (
name: string,
code: string,
outputs: string[],
description?: string
) => Promise<string>;
// Validation
validateSpec: () => Promise<SpecValidationReport>;
// Selection
selectNode: (nodeId: string | null) => void;
selectEdge: (edgeId: string | null) => void;
clearSelection: () => void;
// Utility
getNodeById: (nodeId: string) => DesignVariable | Extractor | Objective | Constraint | null;
setError: (error: string | null) => void;
}
type SpecStore = SpecStoreState & SpecStoreActions;
// ============================================================================
// API Functions
// ============================================================================
async function fetchSpec(studyId: string): Promise<{ spec: AtomizerSpec; hash: string }> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec`);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Failed to load spec' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
const spec = await response.json();
// Get hash
const hashResponse = await fetch(`${API_BASE}/studies/${studyId}/spec/hash`);
const { hash } = await hashResponse.json();
return { spec, hash };
}
async function patchSpecApi(
studyId: string,
path: string,
value: unknown
): Promise<{ hash: string; modified: string }> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, value, modified_by: 'canvas' }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Patch failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
async function addNodeApi(
studyId: string,
type: string,
data: Record<string, unknown>
): Promise<{ node_id: string }> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, data, modified_by: 'canvas' }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Add node failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
async function updateNodeApi(
studyId: string,
nodeId: string,
updates: Record<string, unknown>
): Promise<void> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates, modified_by: 'canvas' }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Update node failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}
async function deleteNodeApi(studyId: string, nodeId: string): Promise<void> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/nodes/${nodeId}?modified_by=canvas`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Delete node failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}
async function addEdgeApi(studyId: string, source: string, target: string): Promise<void> {
const response = await fetch(
`${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`,
{ method: 'POST' }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Add edge failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}
async function removeEdgeApi(studyId: string, source: string, target: string): Promise<void> {
const response = await fetch(
`${API_BASE}/studies/${studyId}/spec/edges?source=${source}&target=${target}&modified_by=canvas`,
{ method: 'DELETE' }
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Remove edge failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
}
async function addCustomFunctionApi(
studyId: string,
name: string,
code: string,
outputs: string[],
description?: string
): Promise<{ node_id: string }> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/custom-functions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, code, outputs, description, modified_by: 'claude' }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Add custom function failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
async function validateSpecApi(studyId: string): Promise<SpecValidationReport> {
const response = await fetch(`${API_BASE}/studies/${studyId}/spec/validate`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Validation failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.json();
}
// ============================================================================
// Helper Functions
// ============================================================================
function applyPatchLocally(spec: AtomizerSpec, path: string, value: unknown): AtomizerSpec {
// Deep clone spec
const newSpec = JSON.parse(JSON.stringify(spec)) as AtomizerSpec;
// Parse path and apply value
const parts = path.split(/\.|\[|\]/).filter(Boolean);
let current: Record<string, unknown> = newSpec as unknown as Record<string, unknown>;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
const index = parseInt(part, 10);
if (!isNaN(index)) {
current = (current as unknown as unknown[])[index] as Record<string, unknown>;
} else {
current = current[part] as Record<string, unknown>;
}
}
const finalKey = parts[parts.length - 1];
const index = parseInt(finalKey, 10);
if (!isNaN(index)) {
(current as unknown as unknown[])[index] = value;
} else {
current[finalKey] = value;
}
return newSpec;
}
function findNodeById(
spec: AtomizerSpec,
nodeId: string
): DesignVariable | Extractor | Objective | Constraint | null {
// Check design variables
const dv = spec.design_variables.find((d) => d.id === nodeId);
if (dv) return dv;
// Check extractors
const ext = spec.extractors.find((e) => e.id === nodeId);
if (ext) return ext;
// Check objectives
const obj = spec.objectives.find((o) => o.id === nodeId);
if (obj) return obj;
// Check constraints
const con = spec.constraints?.find((c) => c.id === nodeId);
if (con) return con;
return null;
}
// ============================================================================
// Store
// ============================================================================
export const useSpecStore = create<SpecStore>()(
devtools(
subscribeWithSelector((set, get) => ({
// Initial state
spec: null,
studyId: null,
hash: null,
isLoading: false,
error: null,
validation: null,
selectedNodeId: null,
selectedEdgeId: null,
isDirty: false,
pendingChanges: [],
// =====================================================================
// Loading Actions
// =====================================================================
loadSpec: async (studyId: string) => {
set({ isLoading: true, error: null, studyId });
try {
const { spec, hash } = await fetchSpec(studyId);
set({
spec,
hash,
isLoading: false,
isDirty: false,
pendingChanges: [],
});
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to load spec',
});
}
},
reloadSpec: async () => {
const { studyId } = get();
if (!studyId) return;
set({ isLoading: true, error: null });
try {
const { spec, hash } = await fetchSpec(studyId);
set({
spec,
hash,
isLoading: false,
isDirty: false,
pendingChanges: [],
});
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to reload spec',
});
}
},
clearSpec: () => {
set({
spec: null,
studyId: null,
hash: null,
isLoading: false,
error: null,
validation: null,
selectedNodeId: null,
selectedEdgeId: null,
isDirty: false,
pendingChanges: [],
});
},
// Set spec directly from WebSocket (no API call)
setSpecFromWebSocket: (spec: AtomizerSpec, studyId?: string) => {
const currentStudyId = studyId || get().studyId;
console.log('[useSpecStore] Setting spec from WebSocket:', spec.meta?.study_name);
set({
spec,
studyId: currentStudyId,
isLoading: false,
isDirty: false,
error: null,
});
},
// =====================================================================
// Full Spec Operations
// =====================================================================
saveSpec: async (spec: AtomizerSpec) => {
const { studyId, hash } = get();
if (!studyId) throw new Error('No study loaded');
set({ isLoading: true, error: null });
try {
const response = await fetch(
`${API_BASE}/studies/${studyId}/spec?modified_by=canvas${hash ? `&expected_hash=${hash}` : ''}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spec),
}
);
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Save failed' }));
throw new Error(error.detail || `HTTP ${response.status}`);
}
const result = await response.json();
set({
spec,
hash: result.hash,
isLoading: false,
isDirty: false,
pendingChanges: [],
});
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Save failed',
});
throw error;
}
},
replaceSpec: async (spec: AtomizerSpec) => {
await get().saveSpec(spec);
},
// =====================================================================
// Patch Operations
// =====================================================================
patchSpec: async (path: string, value: unknown) => {
const { studyId, spec } = get();
if (!studyId || !spec) throw new Error('No study loaded');
// Optimistic update
const oldSpec = spec;
const newSpec = applyPatchLocally(spec, path, value);
set({ spec: newSpec, isDirty: true });
try {
const result = await patchSpecApi(studyId, path, value);
set({ hash: result.hash, isDirty: false });
} catch (error) {
// Rollback on error
set({ spec: oldSpec, isDirty: false });
const message = error instanceof Error ? error.message : 'Patch failed';
set({ error: message });
throw error;
}
},
patchSpecOptimistic: (path: string, value: unknown) => {
const { spec, studyId } = get();
if (!spec) return;
// Apply locally immediately
const newSpec = applyPatchLocally(spec, path, value);
set({
spec: newSpec,
isDirty: true,
pendingChanges: [...get().pendingChanges, { operation: 'set', path, value }],
});
// Sync with backend (fire and forget, but handle errors)
if (studyId) {
patchSpecApi(studyId, path, value)
.then((result) => {
set({ hash: result.hash });
// Remove from pending
set({
pendingChanges: get().pendingChanges.filter(
(c) => !(c.path === path && c.value === value)
),
});
})
.catch((error) => {
console.error('Patch sync failed:', error);
set({ error: error.message });
});
}
},
// =====================================================================
// Node Operations
// =====================================================================
addNode: async (type, data) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
set({ isLoading: true, error: null });
try {
const result = await addNodeApi(studyId, type, data);
// Reload spec to get new state
await get().reloadSpec();
return result.node_id;
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Add node failed',
});
throw error;
}
},
updateNode: async (nodeId, updates) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
try {
await updateNodeApi(studyId, nodeId, updates);
await get().reloadSpec();
} catch (error) {
const message = error instanceof Error ? error.message : 'Update failed';
set({ error: message });
throw error;
}
},
removeNode: async (nodeId) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
set({ isLoading: true, error: null });
try {
await deleteNodeApi(studyId, nodeId);
await get().reloadSpec();
// Clear selection if deleted node was selected
if (get().selectedNodeId === nodeId) {
set({ selectedNodeId: null });
}
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Delete failed',
});
throw error;
}
},
updateNodePosition: async (nodeId, position) => {
const { studyId, spec } = get();
if (!studyId || !spec) return;
// Find the node type and index
let path: string | null = null;
const dvIndex = spec.design_variables.findIndex((d) => d.id === nodeId);
if (dvIndex >= 0) {
path = `design_variables[${dvIndex}].canvas_position`;
}
if (!path) {
const extIndex = spec.extractors.findIndex((e) => e.id === nodeId);
if (extIndex >= 0) {
path = `extractors[${extIndex}].canvas_position`;
}
}
if (!path) {
const objIndex = spec.objectives.findIndex((o) => o.id === nodeId);
if (objIndex >= 0) {
path = `objectives[${objIndex}].canvas_position`;
}
}
if (!path && spec.constraints) {
const conIndex = spec.constraints.findIndex((c) => c.id === nodeId);
if (conIndex >= 0) {
path = `constraints[${conIndex}].canvas_position`;
}
}
if (path) {
// Use optimistic update for smooth dragging
get().patchSpecOptimistic(path, position);
}
},
// =====================================================================
// Edge Operations
// =====================================================================
addEdge: async (source, target) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
try {
await addEdgeApi(studyId, source, target);
await get().reloadSpec();
} catch (error) {
const message = error instanceof Error ? error.message : 'Add edge failed';
set({ error: message });
throw error;
}
},
removeEdge: async (source, target) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
try {
await removeEdgeApi(studyId, source, target);
await get().reloadSpec();
} catch (error) {
const message = error instanceof Error ? error.message : 'Remove edge failed';
set({ error: message });
throw error;
}
},
// =====================================================================
// Custom Function
// =====================================================================
addCustomFunction: async (name, code, outputs, description) => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
set({ isLoading: true, error: null });
try {
const result = await addCustomFunctionApi(studyId, name, code, outputs, description);
await get().reloadSpec();
return result.node_id;
} catch (error) {
set({
isLoading: false,
error: error instanceof Error ? error.message : 'Add custom function failed',
});
throw error;
}
},
// =====================================================================
// Validation
// =====================================================================
validateSpec: async () => {
const { studyId } = get();
if (!studyId) throw new Error('No study loaded');
try {
const validation = await validateSpecApi(studyId);
set({ validation });
return validation;
} catch (error) {
const message = error instanceof Error ? error.message : 'Validation failed';
set({ error: message });
throw error;
}
},
// =====================================================================
// Selection
// =====================================================================
selectNode: (nodeId) => {
set({ selectedNodeId: nodeId, selectedEdgeId: null });
},
selectEdge: (edgeId) => {
set({ selectedEdgeId: edgeId, selectedNodeId: null });
},
clearSelection: () => {
set({ selectedNodeId: null, selectedEdgeId: null });
},
// =====================================================================
// Utility
// =====================================================================
getNodeById: (nodeId) => {
const { spec } = get();
if (!spec) return null;
return findNodeById(spec, nodeId);
},
setError: (error) => {
set({ error });
},
})),
{ name: 'spec-store' }
)
);
// ============================================================================
// Selector Hooks
// ============================================================================
export const useSpec = () => useSpecStore((state) => state.spec);
export const useSpecLoading = () => useSpecStore((state) => state.isLoading);
export const useSpecError = () => useSpecStore((state) => state.error);
export const useSpecValidation = () => useSpecStore((state) => state.validation);
export const useSelectedNodeId = () => useSpecStore((state) => state.selectedNodeId);
export const useSelectedEdgeId = () => useSpecStore((state) => state.selectedEdgeId);
export const useSpecHash = () => useSpecStore((state) => state.hash);
export const useSpecIsDirty = () => useSpecStore((state) => state.isDirty);
// Computed selectors
export const useDesignVariables = () =>
useSpecStore((state) => state.spec?.design_variables ?? []);
export const useExtractors = () => useSpecStore((state) => state.spec?.extractors ?? []);
export const useObjectives = () => useSpecStore((state) => state.spec?.objectives ?? []);
export const useConstraints = () => useSpecStore((state) => state.spec?.constraints ?? []);
export const useCanvasEdges = () => useSpecStore((state) => state.spec?.canvas?.edges ?? []);
export const useSelectedNode = () =>
useSpecStore((state) => {
if (!state.spec || !state.selectedNodeId) return null;
return findNodeById(state.spec, state.selectedNodeId);
});