interbrain-store.ts
1 import { create } from 'zustand'; 2 import { persist } from 'zustand/middleware'; 3 import { DreamNode } from '../types/dreamnode'; 4 import { DreamSongData } from '../types/dreamsong'; 5 import { FibonacciSphereConfig, DEFAULT_FIBONACCI_CONFIG } from '../dreamspace/FibonacciSphereLayout'; 6 import { MockDataConfig } from '../mock/dreamnode-mock-data'; 7 import { 8 OllamaConfigSlice, 9 createOllamaConfigSlice, 10 extractOllamaPersistenceData, 11 restoreOllamaPersistenceData, 12 OllamaConfig 13 } from '../features/semantic-search/store/ollama-config-slice'; 14 // OllamaConfig imports are in the semantic search slice 15 import { VectorData } from '../features/semantic-search/services/indexing-service'; 16 import { FlipState } from '../types/dreamsong'; 17 import { 18 DreamSongRelationshipGraph, 19 SerializableDreamSongGraph, 20 serializeRelationshipGraph, 21 deserializeRelationshipGraph 22 } from '../types/constellation'; 23 24 // Helper function to get current scroll position of DreamSong content 25 function getDreamSongScrollPosition(nodeId: string): number | null { 26 try { 27 // Ensure we're in browser environment 28 if (typeof document === 'undefined') return null; 29 30 // Look for DreamSong leaf in right pane containing this nodeId 31 32 const dreamSongLeaf = document.querySelector(`[data-type="dreamsong-fullscreen"][data-node-id="${nodeId}"]`); 33 if (dreamSongLeaf) { 34 const scrollContainer = dreamSongLeaf.querySelector('.dreamsong-content'); 35 if (scrollContainer && 'scrollTop' in scrollContainer) { 36 37 return (scrollContainer as HTMLElement).scrollTop; 38 } 39 } 40 41 // Also check for embedded DreamSong content in DreamSpace 42 43 const dreamSpaceContent = document.querySelector(`.dreamsong-container[data-node-id="${nodeId}"] .dreamsong-content`); 44 if (dreamSpaceContent && 'scrollTop' in dreamSpaceContent) { 45 46 return (dreamSpaceContent as HTMLElement).scrollTop; 47 } 48 49 return null; 50 } catch (error) { 51 console.warn(`Failed to get scroll position for node ${nodeId}:`, error); 52 return null; 53 } 54 } 55 56 // Helper function to restore scroll position of DreamSong content 57 function restoreDreamSongScrollPosition(nodeId: string, scrollPosition: number): void { 58 try { 59 // Ensure we're in browser environment 60 if (typeof document === 'undefined') return; 61 62 // Look for DreamSong leaf in right pane containing this nodeId 63 64 const dreamSongLeaf = document.querySelector(`[data-type="dreamsong-fullscreen"][data-node-id="${nodeId}"]`); 65 if (dreamSongLeaf) { 66 const scrollContainer = dreamSongLeaf.querySelector('.dreamsong-content'); 67 if (scrollContainer && 'scrollTop' in scrollContainer) { 68 69 (scrollContainer as HTMLElement).scrollTop = scrollPosition; 70 return; 71 } 72 } 73 74 // Also check for embedded DreamSong content in DreamSpace 75 76 const dreamSpaceContent = document.querySelector(`.dreamsong-container[data-node-id="${nodeId}"] .dreamsong-content`); 77 if (dreamSpaceContent && 'scrollTop' in dreamSpaceContent) { 78 79 (dreamSpaceContent as HTMLElement).scrollTop = scrollPosition; 80 } 81 } catch (error) { 82 console.warn(`Failed to restore scroll position for node ${nodeId}:`, error); 83 } 84 } 85 86 // Navigation history types 87 export interface NavigationHistoryEntry { 88 /** Node ID that was focused (null for constellation view) */ 89 nodeId: string | null; 90 /** Layout type at the time */ 91 layout: 'constellation' | 'liminal-web'; 92 /** Timestamp of the navigation action */ 93 timestamp: number; 94 /** Flip state of the focused node (null if not flipped or no focused node) */ 95 flipState: FlipState | null; 96 /** Scroll position in DreamSong content (null if not applicable) */ 97 scrollPosition: number | null; 98 } 99 100 export interface NavigationHistoryState { 101 /** Stack of past navigation states */ 102 history: NavigationHistoryEntry[]; 103 /** Current position in history (0 = most recent) */ 104 currentIndex: number; 105 /** Maximum history entries to keep */ 106 maxHistorySize: number; 107 } 108 109 // Creation state types 110 export interface ProtoNode { 111 title: string; 112 type: 'dream' | 'dreamer'; 113 dreamTalkFile?: globalThis.File; 114 additionalFiles?: globalThis.File[]; 115 position: [number, number, number]; 116 urlMetadata?: import('../utils/url-utils').UrlMetadata; 117 } 118 119 export interface ValidationErrors { 120 title?: string; 121 dreamTalk?: string; 122 } 123 124 export interface CreationState { 125 isCreating: boolean; 126 protoNode: ProtoNode | null; 127 validationErrors: ValidationErrors; 128 } 129 130 // Edit mode state types 131 export interface EditModeState { 132 isActive: boolean; 133 editingNode: DreamNode | null; 134 originalRelationships: string[]; // Store original relationships for cancel operation 135 pendingRelationships: string[]; // Track relationship changes 136 searchResults: DreamNode[]; // Search results for relationship discovery 137 validationErrors: EditModeValidationErrors; // Validation errors for edit mode 138 newDreamTalkFile?: globalThis.File; // New media file for DreamTalk editing 139 isSearchingRelationships: boolean; // Toggle state for relationship search interface 140 } 141 142 export interface EditModeValidationErrors { 143 title?: string; 144 dreamTalk?: string; 145 relationships?: string; 146 } 147 148 // Copilot mode state types 149 export interface CopilotModeState { 150 isActive: boolean; 151 conversationPartner: DreamNode | null; // The person node at center 152 transcriptionFilePath: string | null; // Path to active transcription file 153 showSearchResults: boolean; // Option key held state for showing/hiding results 154 frozenSearchResults: DreamNode[]; // Snapshot of results when showing 155 sharedNodeIds: string[]; // Track invoked nodes for post-call processing 156 } 157 158 // Real node storage - persisted across sessions 159 export interface RealNodeData { 160 node: DreamNode; 161 fileHash?: string; // For detecting file changes 162 lastSynced: number; // Timestamp of last vault sync 163 } 164 165 // DreamSong cache interface for service layer 166 export interface DreamSongCacheEntry { 167 data: DreamSongData; 168 timestamp: number; 169 structureHash: string; 170 } 171 172 // Note: OllamaConfig and DEFAULT_OLLAMA_CONFIG moved to semantic search feature 173 174 export interface InterBrainState extends OllamaConfigSlice { 175 // Data mode toggle 176 dataMode: 'mock' | 'real'; 177 setDataMode: (mode: 'mock' | 'real') => void; 178 179 // Real nodes storage (persisted) 180 realNodes: Map<string, RealNodeData>; 181 setRealNodes: (nodes: Map<string, RealNodeData>) => void; 182 updateRealNode: (id: string, data: RealNodeData) => void; 183 batchUpdateNodePositions: (positions: Map<string, [number, number, number]>) => void; 184 deleteRealNode: (id: string) => void; 185 186 // Selected DreamNode state 187 selectedNode: DreamNode | null; 188 setSelectedNode: (node: DreamNode | null) => void; 189 190 // Selected DreamNode's DreamSong data (for reuse in full-screen) 191 selectedNodeDreamSongData: DreamSongData | null; 192 setSelectedNodeDreamSongData: (data: DreamSongData | null) => void; 193 194 // DreamSong cache for service layer 195 dreamSongCache: Map<string, DreamSongCacheEntry>; 196 getCachedDreamSong: (nodeId: string, structureHash: string) => DreamSongCacheEntry | null; 197 setCachedDreamSong: (nodeId: string, structureHash: string, data: DreamSongData) => void; 198 199 // Creator mode state 200 creatorMode: { 201 isActive: boolean; 202 nodeId: string | null; // ID of the node being edited 203 }; 204 setCreatorMode: (active: boolean, nodeId?: string | null) => void; 205 206 // Search functionality state 207 searchResults: DreamNode[]; 208 setSearchResults: (results: DreamNode[]) => void; 209 210 // Search interface state 211 searchInterface: { 212 isActive: boolean; 213 isSaving: boolean; // Track if save animation is in progress 214 currentQuery: string; 215 lastQuery: string; // For change detection 216 }; 217 setSearchActive: (active: boolean) => void; 218 setSearchQuery: (query: string) => void; 219 setSearchSaving: (saving: boolean) => void; 220 221 // Spatial layout state - expanded to include edit modes as first-class states 222 spatialLayout: 'constellation' | 'creation' | 'search' | 'liminal-web' | 'edit' | 'edit-search' | 'copilot'; 223 setSpatialLayout: (layout: 'constellation' | 'creation' | 'search' | 'liminal-web' | 'edit' | 'edit-search' | 'copilot') => void; 224 225 // Fibonacci sphere layout configuration 226 fibonacciConfig: FibonacciSphereConfig; 227 setFibonacciConfig: (config: Partial<FibonacciSphereConfig>) => void; 228 resetFibonacciConfig: () => void; 229 230 // Camera state management 231 camera: { 232 position: [number, number, number]; 233 target: [number, number, number]; 234 isTransitioning: boolean; 235 transitionDuration: number; 236 }; 237 setCameraPosition: (position: [number, number, number]) => void; 238 setCameraTarget: (target: [number, number, number]) => void; 239 setCameraTransition: (isTransitioning: boolean, duration?: number) => void; 240 241 // Layout transition state 242 layoutTransition: { 243 isTransitioning: boolean; 244 progress: number; 245 previousLayout: 'constellation' | 'creation' | 'search' | 'liminal-web' | 'edit' | 'edit-search' | 'copilot' | null; 246 }; 247 setLayoutTransition: (isTransitioning: boolean, progress?: number, previousLayout?: 'constellation' | 'creation' | 'search' | 'liminal-web' | 'edit' | 'edit-search' | 'copilot' | null) => void; 248 249 // Debug wireframe sphere toggle 250 debugWireframeSphere: boolean; 251 setDebugWireframeSphere: (visible: boolean) => void; 252 253 // Debug intersection point toggle 254 debugIntersectionPoint: boolean; 255 setDebugIntersectionPoint: (visible: boolean) => void; 256 257 // Debug flying camera controls toggle 258 debugFlyingControls: boolean; 259 setDebugFlyingControls: (enabled: boolean) => void; 260 261 // Mock data configuration 262 mockDataConfig: MockDataConfig; 263 setMockDataConfig: (config: MockDataConfig) => void; 264 265 // Persistent mock relationship data 266 mockRelationshipData: Map<string, string[]> | null; 267 generateMockRelationships: () => void; 268 clearMockRelationships: () => void; 269 270 // Drag state management (prevents hover interference during sphere rotation) 271 isDragging: boolean; 272 setIsDragging: (dragging: boolean) => void; 273 274 // Creation state management 275 creationState: CreationState; 276 startCreation: (position: [number, number, number]) => void; 277 startCreationWithData: (position: [number, number, number], initialData?: Partial<ProtoNode>) => void; 278 updateProtoNode: (updates: Partial<ProtoNode>) => void; 279 setValidationErrors: (errors: ValidationErrors) => void; 280 completeCreation: () => void; 281 cancelCreation: () => void; 282 283 // Edit mode state management 284 editMode: EditModeState; 285 startEditMode: (node: DreamNode) => void; 286 exitEditMode: () => void; 287 updateEditingNodeMetadata: (updates: Partial<DreamNode>) => void; 288 setEditModeNewDreamTalkFile: (file: globalThis.File | undefined) => void; 289 setEditModeSearchResults: (results: DreamNode[]) => void; 290 setEditModeSearchActive: (active: boolean) => void; 291 togglePendingRelationship: (nodeId: string) => void; 292 savePendingRelationships: () => void; 293 setEditModeValidationErrors: (errors: EditModeValidationErrors) => void; 294 295 // Copilot mode state management 296 copilotMode: CopilotModeState; 297 startCopilotMode: (conversationPartner: DreamNode) => void; 298 exitCopilotMode: () => void; 299 setShowSearchResults: (show: boolean) => void; 300 freezeSearchResults: () => void; 301 addSharedNode: (nodeId: string) => void; 302 303 // Navigation history management 304 navigationHistory: NavigationHistoryState; 305 isRestoringFromHistory: boolean; 306 setRestoringFromHistory: (restoring: boolean) => void; 307 addHistoryEntry: (nodeId: string | null, layout: 'constellation' | 'liminal-web') => void; 308 getHistoryEntryForUndo: () => NavigationHistoryEntry | null; 309 getHistoryEntryForRedo: () => NavigationHistoryEntry | null; 310 performUndo: () => boolean; 311 performRedo: () => boolean; 312 clearNavigationHistory: () => void; 313 restoreVisualState: (entry: NavigationHistoryEntry) => void; 314 315 // DreamNode flip animation state 316 flipState: { 317 flippedNodeId: string | null; 318 flipStates: Map<string, FlipState>; 319 }; 320 setFlippedNode: (nodeId: string | null) => void; 321 startFlipAnimation: (nodeId: string, direction: 'front-to-back' | 'back-to-front') => void; 322 completeFlipAnimation: (nodeId: string) => void; 323 resetAllFlips: () => void; 324 getNodeFlipState: (nodeId: string) => FlipState | null; 325 326 // DreamSong relationship graph state 327 constellationData: { 328 relationshipGraph: DreamSongRelationshipGraph | null; 329 lastScanTimestamp: number | null; 330 isScanning: boolean; 331 positions: Map<string, [number, number, number]> | null; 332 lastLayoutTimestamp: number | null; 333 // Lightweight node metadata for instant startup rendering 334 nodeMetadata: Map<string, { name: string; type: string; uuid: string }> | null; 335 }; 336 setRelationshipGraph: (graph: DreamSongRelationshipGraph | null) => void; 337 setConstellationScanning: (scanning: boolean) => void; 338 setConstellationPositions: (positions: Map<string, [number, number, number]> | null) => void; 339 setNodeMetadata: (metadata: Map<string, { name: string; type: string; uuid: string }> | null) => void; 340 clearConstellationData: () => void; 341 342 // Radial button UI state (option-key triggered) 343 radialButtonUI: { 344 isActive: boolean; 345 buttonCount: number; 346 optionKeyPressed: boolean; // Track actual hardware key state 347 }; 348 setRadialButtonUIActive: (active: boolean) => void; 349 setRadialButtonCount: (count: number) => void; 350 setOptionKeyPressed: (pressed: boolean) => void; 351 352 // Update status for DreamNodes (non-persisted) 353 updateStatus: Map<string, import('../services/git-service').FetchResult>; 354 setNodeUpdateStatus: (nodeId: string, result: import('../services/git-service').FetchResult) => void; 355 clearNodeUpdateStatus: (nodeId: string) => void; 356 getNodeUpdateStatus: (nodeId: string) => import('../services/git-service').FetchResult | null; 357 } 358 359 // Helper to convert Map to serializable format for persistence 360 const mapToArray = <K, V>(map: Map<K, V>): [K, V][] => Array.from(map.entries()); 361 const arrayToMap = <K, V>(array: [K, V][]): Map<K, V> => new Map(array); 362 363 export const useInterBrainStore = create<InterBrainState>()( 364 persist( 365 (set, get) => ({ 366 // Initial state 367 dataMode: 'real' as const, // Start in real mode by default 368 realNodes: new Map<string, RealNodeData>(), 369 370 // Initialize Ollama config slice 371 ...createOllamaConfigSlice(set, get, {} as never), 372 selectedNode: null, 373 selectedNodeDreamSongData: null, 374 375 // DreamSong cache for service layer 376 dreamSongCache: new Map<string, DreamSongCacheEntry>(), 377 378 creatorMode: { 379 isActive: false, 380 nodeId: null 381 }, 382 searchResults: [], 383 searchInterface: { 384 isActive: false, 385 isSaving: false, 386 currentQuery: '', 387 lastQuery: '' 388 }, 389 spatialLayout: 'constellation', 390 fibonacciConfig: DEFAULT_FIBONACCI_CONFIG, 391 392 // Camera initial state 393 camera: { 394 position: [0, 0, 0], // Camera at origin for proper Dynamic View Scaling 395 target: [0, 0, 0], // Looking at origin 396 isTransitioning: false, 397 transitionDuration: 1000, // 1 second default 398 }, 399 400 // Layout transition initial state 401 layoutTransition: { 402 isTransitioning: false, 403 progress: 0, 404 previousLayout: null, 405 }, 406 407 // Debug wireframe sphere initial state (on by default for development) 408 debugWireframeSphere: false, 409 410 // Debug intersection point initial state (on by default for development) 411 debugIntersectionPoint: false, 412 413 // Debug flying camera controls initial state (off by default) 414 debugFlyingControls: false, 415 416 // Mock data configuration initial state (single node for testing) 417 mockDataConfig: 'fibonacci-100', 418 419 // Persistent mock relationship data initial state 420 mockRelationshipData: null, 421 422 // Drag state initial state (not dragging) 423 isDragging: false, 424 425 // Creation state initial state (not creating) 426 creationState: { 427 isCreating: false, 428 protoNode: null, 429 validationErrors: {} 430 }, 431 432 // Edit mode initial state (not editing) 433 editMode: { 434 isActive: false, 435 editingNode: null, 436 originalRelationships: [], 437 pendingRelationships: [], 438 searchResults: [], 439 validationErrors: {}, 440 isSearchingRelationships: false 441 }, 442 443 // Copilot mode initial state (not active) 444 copilotMode: { 445 isActive: false, 446 conversationPartner: null, 447 transcriptionFilePath: null, 448 showSearchResults: false, 449 frozenSearchResults: [], 450 sharedNodeIds: [] 451 }, 452 453 // Navigation history initial state (with initial constellation state) 454 navigationHistory: { 455 history: [{ 456 nodeId: null, 457 layout: 'constellation', 458 timestamp: Date.now(), 459 flipState: null, 460 scrollPosition: null 461 }], 462 currentIndex: 0, // Start at the initial constellation state 463 maxHistorySize: 150 // High limit for ultra-lightweight entries 464 }, 465 466 // Flag to disable history tracking during undo/redo operations 467 isRestoringFromHistory: false, 468 469 // DreamNode flip animation initial state 470 flipState: { 471 flippedNodeId: null, 472 flipStates: new Map<string, FlipState>() 473 }, 474 475 // Constellation relationship graph initial state 476 constellationData: { 477 relationshipGraph: null, 478 lastScanTimestamp: null, 479 isScanning: false, 480 positions: null, 481 lastLayoutTimestamp: null, 482 nodeMetadata: null 483 }, 484 485 radialButtonUI: { 486 isActive: false, 487 buttonCount: 6, 488 optionKeyPressed: false 489 }, 490 491 // Actions 492 setDataMode: (mode) => set({ dataMode: mode }), 493 setRealNodes: (nodes) => set({ realNodes: nodes }), 494 updateRealNode: (id, data) => set(state => { 495 const newMap = new Map(state.realNodes); 496 newMap.set(id, data); 497 return { realNodes: newMap }; 498 }), 499 batchUpdateNodePositions: (positions) => set(state => { 500 const newMap = new Map(state.realNodes); 501 for (const [nodeId, position] of positions) { 502 const nodeData = newMap.get(nodeId); 503 if (nodeData) { 504 newMap.set(nodeId, { 505 ...nodeData, 506 node: { ...nodeData.node, position } 507 }); 508 } 509 } 510 return { realNodes: newMap }; 511 }), 512 deleteRealNode: (id) => set(state => { 513 const newMap = new Map(state.realNodes); 514 newMap.delete(id); 515 return { realNodes: newMap }; 516 }), 517 518 // Note: Vector data and Ollama config actions provided by OllamaConfigSlice 519 520 setSelectedNode: (node) => set(state => { 521 const previousNode = state.selectedNode; 522 const currentLayout = state.spatialLayout; 523 524 // Trigger lazy media loading for node and 2-degree neighborhood 525 if (node) { 526 // Import and trigger media loading asynchronously (non-blocking) 527 import('../services/media-loading-service').then(({ getMediaLoadingService }) => { 528 try { 529 const mediaLoadingService = getMediaLoadingService(); 530 mediaLoadingService.loadNodeWithNeighborhood(node.id); 531 } catch (error) { 532 console.warn('[Store] MediaLoadingService not initialized:', error); 533 } 534 }).catch(error => { 535 console.error('[Store] Failed to load media service:', error); 536 }); 537 } 538 539 // Detect meaningful node selection changes for history tracking 540 const isMeaningfulChange = ( 541 // Within Liminal Web: different node selected 542 currentLayout === 'liminal-web' && 543 previousNode && 544 node && 545 previousNode.id !== node.id 546 ); 547 548 // Record history entry for meaningful changes (but not during undo/redo operations) 549 if (isMeaningfulChange && !state.isRestoringFromHistory) { 550 // Create new entry 551 const newEntry: NavigationHistoryEntry = { 552 nodeId: node.id, 553 layout: 'liminal-web', 554 timestamp: Date.now(), 555 flipState: state.flipState.flipStates.get(node.id) || null, 556 scrollPosition: getDreamSongScrollPosition(node.id) // We'll implement this helper 557 }; 558 559 // Add to history (reuse existing addHistoryEntry logic) 560 const { history, currentIndex, maxHistorySize } = state.navigationHistory; 561 562 // Check if this entry is a duplicate of the current entry (prevent undo/redo loops) 563 const currentEntry = history[currentIndex]; 564 const isDuplicate = currentEntry && 565 currentEntry.nodeId === newEntry.nodeId && 566 currentEntry.layout === newEntry.layout; 567 568 if (isDuplicate) { 569 // Don't add duplicate entry - just update selected node 570 return { selectedNode: node }; 571 } 572 573 // If we're not at the end of history, clear everything after current position 574 const newHistory = currentIndex >= 0 575 ? [...history.slice(0, currentIndex + 1), newEntry] 576 : [newEntry]; 577 578 // Ensure history doesn't exceed max size 579 const trimmedHistory = newHistory.length > maxHistorySize 580 ? newHistory.slice(-maxHistorySize) 581 : newHistory; 582 583 return { 584 selectedNode: node, 585 navigationHistory: { 586 ...state.navigationHistory, 587 history: trimmedHistory, 588 currentIndex: trimmedHistory.length - 1 589 } 590 }; 591 } 592 593 // Non-meaningful change - just update selected node 594 return { selectedNode: node }; 595 }), 596 597 setSelectedNodeDreamSongData: (data) => set({ selectedNodeDreamSongData: data }), 598 599 // DreamSong cache methods for service layer 600 getCachedDreamSong: (nodeId: string, structureHash: string) => { 601 const cacheKey = `${nodeId}-${structureHash}`; 602 return get().dreamSongCache.get(cacheKey) || null; 603 }, 604 605 setCachedDreamSong: (nodeId: string, structureHash: string, data: DreamSongData) => { 606 const cacheKey = `${nodeId}-${structureHash}`; 607 const entry: DreamSongCacheEntry = { 608 data, 609 timestamp: Date.now(), 610 structureHash 611 }; 612 set((state) => { 613 const newCache = new Map(state.dreamSongCache); 614 newCache.set(cacheKey, entry); 615 return { dreamSongCache: newCache }; 616 }); 617 }, 618 619 setCreatorMode: (active, nodeId = null) => set({ 620 creatorMode: { isActive: active, nodeId: nodeId } 621 }), 622 setSearchResults: (results) => set({ searchResults: results }), 623 setSearchActive: (active) => set(state => ({ 624 searchInterface: { 625 ...state.searchInterface, 626 isActive: active, 627 // Clear query when deactivating for fresh start on reentry 628 currentQuery: active ? state.searchInterface.currentQuery : '', 629 lastQuery: active ? state.searchInterface.lastQuery : '' 630 }, 631 // Also clear search results when deactivating 632 searchResults: active ? state.searchResults : [] 633 })), 634 setSearchQuery: (query) => set(state => ({ 635 searchInterface: { 636 ...state.searchInterface, 637 currentQuery: query 638 } 639 })), 640 setSearchSaving: (saving) => set(state => ({ 641 searchInterface: { 642 ...state.searchInterface, 643 isSaving: saving 644 } 645 })), 646 setSpatialLayout: (layout) => set(state => { 647 const previousLayout = state.spatialLayout; 648 const selectedNode = state.selectedNode; 649 650 // Only log actual changes, not redundant calls 651 if (previousLayout !== layout) { 652 } 653 654 // Detect meaningful layout changes for history tracking 655 const isMeaningfulChange = ( 656 // Constellation â Liminal Web (with selected node) 657 (previousLayout === 'constellation' && layout === 'liminal-web' && selectedNode) || 658 // Liminal Web â Constellation 659 (previousLayout === 'liminal-web' && layout === 'constellation') 660 // Note: Within Liminal Web changes are handled by setSelectedNode 661 ); 662 663 // Record history entry for meaningful changes (but not during undo/redo operations) 664 if (isMeaningfulChange && !state.isRestoringFromHistory) { 665 // Create new entry (inside this block, layout can only be 'constellation' or 'liminal-web') 666 const newEntry: NavigationHistoryEntry = { 667 nodeId: layout === 'liminal-web' ? selectedNode?.id || null : null, 668 layout: layout, // Already narrowed to valid types by isMeaningfulChange condition 669 timestamp: Date.now(), 670 flipState: (layout === 'liminal-web' && selectedNode) ? 671 state.flipState.flipStates.get(selectedNode.id) || null : null, 672 scrollPosition: (layout === 'liminal-web' && selectedNode) ? 673 getDreamSongScrollPosition(selectedNode.id) : null 674 }; 675 676 // Add to history (reuse existing addHistoryEntry logic) 677 const { history, currentIndex, maxHistorySize } = state.navigationHistory; 678 679 // Check if this entry is a duplicate of the current entry (prevent undo/redo loops) 680 const currentEntry = history[currentIndex]; 681 const isDuplicate = currentEntry && 682 currentEntry.nodeId === newEntry.nodeId && 683 currentEntry.layout === newEntry.layout; 684 685 if (isDuplicate) { 686 // Don't add duplicate entry - just update layout without history changes 687 return { 688 spatialLayout: layout, 689 layoutTransition: { 690 ...state.layoutTransition, 691 previousLayout: state.spatialLayout, 692 } 693 }; 694 } 695 696 // If we're not at the end of history, clear everything after current position 697 const newHistory = currentIndex >= 0 698 ? [...history.slice(0, currentIndex + 1), newEntry] 699 : [newEntry]; 700 701 // Ensure history doesn't exceed max size 702 const trimmedHistory = newHistory.length > maxHistorySize 703 ? newHistory.slice(-maxHistorySize) 704 : newHistory; 705 706 return { 707 spatialLayout: layout, 708 layoutTransition: { 709 ...state.layoutTransition, 710 previousLayout: state.spatialLayout, 711 }, 712 navigationHistory: { 713 ...state.navigationHistory, 714 history: trimmedHistory, 715 currentIndex: trimmedHistory.length - 1 716 } 717 }; 718 } 719 720 // Non-meaningful change - just update layout 721 return { 722 spatialLayout: layout, 723 layoutTransition: { 724 ...state.layoutTransition, 725 previousLayout: state.spatialLayout, 726 } 727 }; 728 }), 729 730 // Fibonacci sphere configuration actions 731 setFibonacciConfig: (config) => set(state => ({ 732 fibonacciConfig: { ...state.fibonacciConfig, ...config } 733 })), 734 resetFibonacciConfig: () => set({ fibonacciConfig: DEFAULT_FIBONACCI_CONFIG }), 735 736 // Camera actions 737 setCameraPosition: (position) => set(state => ({ 738 camera: { ...state.camera, position } 739 })), 740 setCameraTarget: (target) => set(state => ({ 741 camera: { ...state.camera, target } 742 })), 743 setCameraTransition: (isTransitioning, duration = 1000) => set(state => ({ 744 camera: { ...state.camera, isTransitioning, transitionDuration: duration } 745 })), 746 747 // Layout transition actions 748 setLayoutTransition: (isTransitioning, progress = 0, previousLayout = null) => set(state => ({ 749 layoutTransition: { 750 isTransitioning, 751 progress, 752 previousLayout: previousLayout || state.layoutTransition.previousLayout 753 } 754 })), 755 756 // Debug wireframe sphere actions 757 setDebugWireframeSphere: (visible) => set({ debugWireframeSphere: visible }), 758 759 // Debug intersection point actions 760 setDebugIntersectionPoint: (visible) => set({ debugIntersectionPoint: visible }), 761 762 // Debug flying camera controls actions 763 setDebugFlyingControls: (enabled) => set({ debugFlyingControls: enabled }), 764 765 // Mock data configuration actions 766 setMockDataConfig: (config) => set({ mockDataConfig: config }), 767 768 // Mock relationship data actions 769 generateMockRelationships: () => set(state => { 770 const { mockDataConfig } = state; 771 const nodeCount = mockDataConfig === 'single-node' ? 1 : 772 mockDataConfig === 'fibonacci-12' ? 12 : 773 mockDataConfig === 'fibonacci-50' ? 50 : 100; 774 775 const relationships = new Map<string, string[]>(); 776 777 // First pass: Initialize all nodes in the map 778 for (let i = 0; i < nodeCount; i++) { 779 const nodeType = i % 3 !== 0 ? 'dream' : 'dreamer'; 780 const nodeId = `mock-${nodeType}-${i}`; 781 relationships.set(nodeId, []); 782 } 783 784 // Second pass: Generate bidirectional relationships between Dreams and Dreamers 785 for (let i = 0; i < nodeCount; i++) { 786 const sourceType = i % 3 !== 0 ? 'dream' : 'dreamer'; 787 const sourceId = `mock-${sourceType}-${i}`; 788 789 // Use deterministic pattern for consistent relationships with more variety 790 const stepSizes = [1, 2, 3, 5, 7, 11, 13, 17, 19]; 791 792 // Create more diversity in connection counts based on node index 793 const baseConnections = 2; 794 const variabilityFactor = ((i * 13) % 17) / 17; // 0 to 1, varies by node 795 const maxConnections = Math.min( 796 Math.floor(nodeCount * 0.8), // Up to 80% of opposite-type nodes 797 baseConnections + Math.floor(variabilityFactor * Math.min(25, nodeCount - baseConnections)) 798 ); 799 800 for (let j = 0; j < Math.min(stepSizes.length, maxConnections); j++) { 801 const step = stepSizes[j]; 802 const targetIndex = (i + step) % nodeCount; 803 const targetType = targetIndex % 3 !== 0 ? 'dream' : 'dreamer'; 804 805 // Only connect Dreams to Dreamers and vice versa 806 if (sourceType !== targetType) { 807 const targetId = `mock-${targetType}-${targetIndex}`; 808 809 // Add forward connection if not already present 810 const sourceConnections = relationships.get(sourceId)!; 811 if (!sourceConnections.includes(targetId)) { 812 sourceConnections.push(targetId); 813 } 814 815 // Add reverse connection if not already present 816 const targetConnections = relationships.get(targetId)!; 817 if (!targetConnections.includes(sourceId)) { 818 targetConnections.push(sourceId); 819 } 820 } 821 } 822 823 // Ensure at least one connection if possible 824 const sourceConnections = relationships.get(sourceId)!; 825 if (sourceConnections.length === 0 && nodeCount > 1) { 826 for (let offset = 1; offset < nodeCount; offset++) { 827 const targetIndex = (i + offset) % nodeCount; 828 const targetType = targetIndex % 3 !== 0 ? 'dream' : 'dreamer'; 829 830 if (sourceType !== targetType) { 831 const targetId = `mock-${targetType}-${targetIndex}`; 832 833 // Add forward connection 834 sourceConnections.push(targetId); 835 836 // Add reverse connection 837 const targetConnections = relationships.get(targetId)!; 838 if (!targetConnections.includes(sourceId)) { 839 targetConnections.push(sourceId); 840 } 841 break; 842 } 843 } 844 } 845 } 846 847 // Verify bidirectionality (basic check) 848 relationships.forEach((connections, sourceId) => { 849 connections.forEach(targetId => { 850 const targetConnections = relationships.get(targetId); 851 if (!targetConnections || !targetConnections.includes(sourceId)) { 852 // Skip tracking errors for now - just ensure basic structure 853 } 854 }); 855 }); 856 857 return { mockRelationshipData: relationships }; 858 }), 859 860 clearMockRelationships: () => set({ mockRelationshipData: null }), 861 862 // Drag state actions 863 setIsDragging: (dragging) => set({ isDragging: dragging }), 864 865 // Creation state actions 866 startCreation: (position) => set((_state) => { 867 return { 868 spatialLayout: 'creation', 869 creationState: { 870 isCreating: true, 871 protoNode: { 872 title: '', 873 type: 'dream', // Default to dream type 874 position, 875 dreamTalkFile: undefined 876 }, 877 validationErrors: {} 878 } 879 }; 880 }), 881 882 startCreationWithData: (position, initialData) => set((_state) => { 883 return { 884 spatialLayout: 'creation', 885 creationState: { 886 isCreating: true, 887 protoNode: { 888 title: initialData?.title || '', 889 type: initialData?.type || 'dream', 890 position, 891 dreamTalkFile: initialData?.dreamTalkFile || undefined, 892 additionalFiles: initialData?.additionalFiles || undefined 893 }, 894 validationErrors: {} 895 } 896 }; 897 }), 898 899 updateProtoNode: (updates) => set(state => ({ 900 creationState: { 901 ...state.creationState, 902 protoNode: state.creationState.protoNode 903 ? { ...state.creationState.protoNode, ...updates } 904 : null 905 } 906 })), 907 908 setValidationErrors: (errors) => set(state => ({ 909 creationState: { 910 ...state.creationState, 911 validationErrors: errors 912 } 913 })), 914 915 completeCreation: () => set((_state) => { 916 return { 917 spatialLayout: 'constellation', 918 creationState: { 919 isCreating: false, 920 protoNode: null, 921 validationErrors: {} 922 } 923 }; 924 }), 925 926 cancelCreation: () => set((_state) => { 927 return { 928 spatialLayout: 'constellation', 929 creationState: { 930 isCreating: false, 931 protoNode: null, 932 validationErrors: {} 933 } 934 }; 935 }), 936 937 // Edit mode actions 938 startEditMode: (node) => set((_state) => { 939 return { 940 editMode: { 941 isActive: true, 942 editingNode: { ...node }, // Create a copy to avoid mutations 943 originalRelationships: [...node.liminalWebConnections], // Store original relationships 944 pendingRelationships: [...node.liminalWebConnections], // Show existing relationships with glow 945 searchResults: [], 946 validationErrors: {}, 947 isSearchingRelationships: false 948 }, 949 // Also set the spatial layout to 'edit' mode 950 spatialLayout: 'edit' as const 951 }; 952 }), 953 954 exitEditMode: () => set((_state) => { 955 // Note: We don't change the layout here - the calling code should handle that 956 // This allows for proper transitions (edit â liminal-web, edit-search â edit, etc.) 957 958 return { 959 editMode: { 960 isActive: false, 961 editingNode: null, 962 originalRelationships: [], 963 pendingRelationships: [], 964 searchResults: [], 965 validationErrors: {}, 966 newDreamTalkFile: undefined, 967 isSearchingRelationships: false 968 } 969 }; 970 }), 971 972 updateEditingNodeMetadata: (updates) => set(state => ({ 973 editMode: { 974 ...state.editMode, 975 editingNode: state.editMode.editingNode 976 ? { ...state.editMode.editingNode, ...updates } 977 : null 978 } 979 })), 980 981 setEditModeNewDreamTalkFile: (file) => set(state => ({ 982 editMode: { 983 ...state.editMode, 984 newDreamTalkFile: file 985 } 986 })), 987 988 setEditModeSearchResults: (results) => set(state => ({ 989 editMode: { 990 ...state.editMode, 991 searchResults: results 992 } 993 })), 994 995 setEditModeSearchActive: (active) => set(state => { 996 const newLayout = active ? 'edit-search' as const : 'edit' as const; 997 998 return { 999 editMode: { 1000 ...state.editMode, 1001 isSearchingRelationships: active, 1002 // Clear search results when exiting edit-search mode to prevent persistence 1003 searchResults: active ? state.editMode.searchResults : [] 1004 }, 1005 // Update spatial layout based on search mode state 1006 spatialLayout: newLayout, 1007 // Also clear main search results when exiting edit-search to clean up spatial layout 1008 searchResults: active ? state.searchResults : [] 1009 }; 1010 }), 1011 1012 togglePendingRelationship: (nodeId) => set(state => { 1013 const currentPending = state.editMode.pendingRelationships; 1014 const isAlreadyPending = currentPending.includes(nodeId); 1015 1016 return { 1017 editMode: { 1018 ...state.editMode, 1019 pendingRelationships: isAlreadyPending 1020 ? currentPending.filter(id => id !== nodeId) // Remove if exists 1021 : [...currentPending, nodeId] // Add if doesn't exist 1022 } 1023 }; 1024 }), 1025 1026 savePendingRelationships: () => set(state => { 1027 if (!state.editMode.editingNode) return state; 1028 1029 // Update the editing node with pending relationships 1030 const updatedNode = { 1031 ...state.editMode.editingNode, 1032 liminalWebConnections: [...state.editMode.pendingRelationships] 1033 }; 1034 1035 return { 1036 editMode: { 1037 ...state.editMode, 1038 editingNode: updatedNode, 1039 originalRelationships: [...state.editMode.pendingRelationships] // Update original to match 1040 } 1041 }; 1042 }), 1043 1044 setEditModeValidationErrors: (errors) => set(state => ({ 1045 editMode: { 1046 ...state.editMode, 1047 validationErrors: errors 1048 } 1049 })), 1050 1051 // Copilot mode actions 1052 startCopilotMode: (conversationPartner) => set((_state) => { 1053 // Hide ribbon for cleaner video call interface 1054 try { 1055 const app = (globalThis as any).app; 1056 if (app?.workspace?.leftRibbon) { 1057 app.workspace.leftRibbon.hide(); 1058 console.log(`đ¯ [Copilot-Entry] Hidden ribbon for cleaner interface`); 1059 } 1060 } catch (error) { 1061 console.warn('Failed to hide ribbon:', error); 1062 } 1063 1064 return { 1065 spatialLayout: 'copilot', 1066 copilotMode: { 1067 isActive: true, 1068 conversationPartner: { ...conversationPartner }, // Create a copy 1069 transcriptionFilePath: null, 1070 showSearchResults: false, 1071 frozenSearchResults: [], 1072 sharedNodeIds: [] 1073 } 1074 }; 1075 }), 1076 1077 exitCopilotMode: () => set((state) => { 1078 // PROCESS SHARED NODES BEFORE CLEARING STATE 1079 const { conversationPartner, sharedNodeIds } = state.copilotMode; 1080 1081 if (conversationPartner && sharedNodeIds.length > 0) { 1082 console.log(`đ [Copilot-Exit] Processing ${sharedNodeIds.length} shared nodes for "${conversationPartner.name}"`); 1083 console.log(`đ [Copilot-Exit] Shared node IDs: ${sharedNodeIds.join(', ')}`); 1084 1085 // Filter out nodes that are already related to avoid duplicates 1086 const newRelationships = sharedNodeIds.filter(id => !conversationPartner.liminalWebConnections.includes(id)); 1087 1088 if (newRelationships.length > 0) { 1089 // Create updated conversation partner with new relationships 1090 const updatedPartner = { 1091 ...conversationPartner, 1092 liminalWebConnections: [...conversationPartner.liminalWebConnections, ...newRelationships] 1093 }; 1094 1095 console.log(`â [Copilot-Exit] Adding ${newRelationships.length} new relationships: ${newRelationships.join(', ')}`); 1096 console.log(`â [Copilot-Exit] "${conversationPartner.name}" now has ${updatedPartner.liminalWebConnections.length} total relationships`); 1097 1098 // Update the conversation partner node in store 1099 const existingNodeData = state.realNodes.get(conversationPartner.id); 1100 if (existingNodeData) { 1101 state.realNodes.set(conversationPartner.id, { 1102 ...existingNodeData, 1103 node: updatedPartner 1104 }); 1105 } 1106 1107 // Also update selectedNode if it matches the conversation partner 1108 if (state.selectedNode?.id === conversationPartner.id) { 1109 state.selectedNode = updatedPartner; 1110 } 1111 1112 // Update bidirectional relationships in store for immediate UI feedback 1113 for (const sharedNodeId of newRelationships) { 1114 const sharedNodeData = state.realNodes.get(sharedNodeId); 1115 if (sharedNodeData) { 1116 const updatedSharedNode = { 1117 ...sharedNodeData.node, 1118 liminalWebConnections: [...sharedNodeData.node.liminalWebConnections, conversationPartner.id] 1119 }; 1120 state.realNodes.set(sharedNodeId, { 1121 ...sharedNodeData, 1122 node: updatedSharedNode 1123 }); 1124 console.log(`â [Copilot-Exit] Updated bidirectional relationship for shared node: ${updatedSharedNode.name}`); 1125 } 1126 } 1127 } else { 1128 console.log(`âšī¸ [Copilot-Exit] No new relationships to add - all shared nodes were already related`); 1129 } 1130 } 1131 1132 // Show ribbon again when exiting copilot mode 1133 try { 1134 const app = (globalThis as any).app; 1135 if (app?.workspace?.leftRibbon) { 1136 app.workspace.leftRibbon.show(); 1137 console.log(`đ¯ [Copilot-Exit] Restored ribbon visibility`); 1138 } 1139 } catch (error) { 1140 console.warn('Failed to show ribbon:', error); 1141 } 1142 1143 return { 1144 spatialLayout: 'liminal-web', // Return to liminal-web layout with updated relationships 1145 copilotMode: { 1146 isActive: false, 1147 conversationPartner: null, 1148 transcriptionFilePath: null, 1149 showSearchResults: false, 1150 frozenSearchResults: [], 1151 sharedNodeIds: [] 1152 } 1153 }; 1154 }), 1155 1156 // Copilot show/hide actions 1157 setShowSearchResults: (show: boolean) => set((state) => ({ 1158 copilotMode: { 1159 ...state.copilotMode, 1160 showSearchResults: show 1161 } 1162 })), 1163 1164 freezeSearchResults: () => set((state) => ({ 1165 copilotMode: { 1166 ...state.copilotMode, 1167 frozenSearchResults: [...state.searchResults] // Capture current search results 1168 } 1169 })), 1170 1171 addSharedNode: (nodeId: string) => set((state) => ({ 1172 copilotMode: { 1173 ...state.copilotMode, 1174 sharedNodeIds: [...state.copilotMode.sharedNodeIds, nodeId] 1175 } 1176 })), 1177 1178 // Navigation history actions 1179 addHistoryEntry: (nodeId, layout) => set(state => { 1180 const { history, currentIndex, maxHistorySize } = state.navigationHistory; 1181 1182 // Create new entry 1183 const newEntry: NavigationHistoryEntry = { 1184 nodeId, 1185 layout, 1186 timestamp: Date.now(), 1187 flipState: (nodeId && layout === 'liminal-web') ? 1188 state.flipState.flipStates.get(nodeId) || null : null, 1189 scrollPosition: (nodeId && layout === 'liminal-web') ? 1190 getDreamSongScrollPosition(nodeId) : null 1191 }; 1192 1193 // If we're not at the end of history (user has undone some actions), 1194 // clear everything after current position (standard undo/redo behavior) 1195 const newHistory = currentIndex >= 0 1196 ? [...history.slice(0, currentIndex + 1), newEntry] 1197 : [newEntry]; 1198 1199 // Ensure history doesn't exceed max size (remove oldest entries) 1200 const trimmedHistory = newHistory.length > maxHistorySize 1201 ? newHistory.slice(-maxHistorySize) 1202 : newHistory; 1203 1204 return { 1205 navigationHistory: { 1206 ...state.navigationHistory, 1207 history: trimmedHistory, 1208 currentIndex: trimmedHistory.length - 1 1209 } 1210 }; 1211 }), 1212 1213 getHistoryEntryForUndo: () => { 1214 // This function will be called from commands which have access to getState() 1215 return null; // Commands will implement the logic 1216 }, 1217 1218 getHistoryEntryForRedo: () => { 1219 // This function will be called from commands which have access to getState() 1220 return null; // Commands will implement the logic 1221 }, 1222 1223 performUndo: () => { 1224 let success = false; 1225 1226 set(state => { 1227 const { currentIndex } = state.navigationHistory; 1228 1229 if (currentIndex <= 0) { 1230 return state; // Nothing to undo 1231 } 1232 1233 success = true; 1234 1235 // Move to previous entry 1236 return { 1237 navigationHistory: { 1238 ...state.navigationHistory, 1239 currentIndex: currentIndex - 1 1240 } 1241 }; 1242 }); 1243 1244 return success; 1245 }, 1246 1247 performRedo: () => { 1248 let success = false; 1249 1250 set(state => { 1251 const { currentIndex, history } = state.navigationHistory; 1252 1253 if (currentIndex >= history.length - 1) { 1254 return state; // Nothing to redo 1255 } 1256 1257 success = true; 1258 1259 // Move to next entry 1260 return { 1261 navigationHistory: { 1262 ...state.navigationHistory, 1263 currentIndex: currentIndex + 1 1264 } 1265 }; 1266 }); 1267 1268 return success; 1269 }, 1270 1271 clearNavigationHistory: () => set(state => ({ 1272 navigationHistory: { 1273 ...state.navigationHistory, 1274 history: [], 1275 currentIndex: -1 1276 } 1277 })), 1278 1279 setRestoringFromHistory: (restoring) => set({ isRestoringFromHistory: restoring }), 1280 1281 restoreVisualState: (entry) => set((state) => { 1282 const newState = { ...state }; 1283 1284 // Restore FlipState if present 1285 if (entry.nodeId && entry.flipState) { 1286 // Update the flip state for this node 1287 const updatedFlipStates = new Map(state.flipState.flipStates); 1288 updatedFlipStates.set(entry.nodeId, entry.flipState); 1289 1290 newState.flipState = { 1291 ...state.flipState, 1292 flipStates: updatedFlipStates, 1293 flippedNodeId: entry.flipState.isFlipped ? entry.nodeId : state.flipState.flippedNodeId 1294 }; 1295 } 1296 1297 // Restore scroll position (async, but we don't wait for it) 1298 if (entry.nodeId && entry.scrollPosition !== null) { 1299 // Use setTimeout to ensure DOM has updated after state change 1300 if (typeof setTimeout !== 'undefined') { 1301 1302 setTimeout(() => { 1303 restoreDreamSongScrollPosition(entry.nodeId!, entry.scrollPosition!); 1304 }, 100); 1305 } else { 1306 // Fallback for non-browser environments 1307 restoreDreamSongScrollPosition(entry.nodeId!, entry.scrollPosition!); 1308 } 1309 } 1310 1311 return newState; 1312 }), 1313 1314 // DreamNode flip animation actions 1315 setFlippedNode: (nodeId) => set((state) => { 1316 1317 // Reset previous flipped node if different 1318 if (state.flipState.flippedNodeId && state.flipState.flippedNodeId !== nodeId) { 1319 const updatedFlipStates = new Map(state.flipState.flipStates); 1320 updatedFlipStates.delete(state.flipState.flippedNodeId); 1321 1322 return { 1323 flipState: { 1324 flippedNodeId: nodeId, 1325 flipStates: updatedFlipStates 1326 } 1327 }; 1328 } 1329 1330 return { 1331 flipState: { 1332 ...state.flipState, 1333 flippedNodeId: nodeId 1334 } 1335 }; 1336 }), 1337 1338 startFlipAnimation: (nodeId, direction) => set((state) => { 1339 1340 const updatedFlipStates = new Map(state.flipState.flipStates); 1341 1342 const currentFlipState = updatedFlipStates.get(nodeId) || { 1343 isFlipped: false, 1344 isFlipping: false, 1345 flipDirection: 'front-to-back' as const, 1346 animationStartTime: 0 1347 }; 1348 1349 const newFlipState = { 1350 ...currentFlipState, 1351 isFlipping: true, 1352 flipDirection: direction, 1353 animationStartTime: globalThis.performance.now() 1354 }; 1355 1356 updatedFlipStates.set(nodeId, newFlipState); 1357 1358 return { 1359 ...state, 1360 flipState: { 1361 ...state.flipState, 1362 flipStates: updatedFlipStates, 1363 flippedNodeId: nodeId 1364 } 1365 }; 1366 }), 1367 1368 completeFlipAnimation: (nodeId) => set((state) => { 1369 1370 const updatedFlipStates = new Map(state.flipState.flipStates); 1371 const currentFlipState = updatedFlipStates.get(nodeId); 1372 1373 if (currentFlipState) { 1374 const finalFlippedState = currentFlipState.flipDirection === 'front-to-back'; 1375 const completedFlipState = { 1376 ...currentFlipState, 1377 isFlipped: finalFlippedState, 1378 isFlipping: false, 1379 animationStartTime: 0 1380 }; 1381 1382 updatedFlipStates.set(nodeId, completedFlipState); 1383 } 1384 1385 return { 1386 flipState: { 1387 ...state.flipState, 1388 flipStates: updatedFlipStates 1389 } 1390 }; 1391 }), 1392 1393 resetAllFlips: () => set(() => ({ 1394 flipState: { 1395 flippedNodeId: null, 1396 flipStates: new Map<string, FlipState>() 1397 } 1398 })), 1399 1400 getNodeFlipState: (nodeId) => { 1401 const state = get(); 1402 return state.flipState.flipStates.get(nodeId) || null; 1403 }, 1404 1405 // Constellation relationship graph actions 1406 setRelationshipGraph: (graph) => set((state) => ({ 1407 constellationData: { 1408 ...state.constellationData, 1409 relationshipGraph: graph, 1410 lastScanTimestamp: graph ? Date.now() : null, 1411 isScanning: false 1412 } 1413 })), 1414 1415 setConstellationScanning: (scanning) => set((state) => ({ 1416 constellationData: { 1417 ...state.constellationData, 1418 isScanning: scanning 1419 } 1420 })), 1421 1422 setConstellationPositions: (positions) => set((state) => ({ 1423 constellationData: { 1424 ...state.constellationData, 1425 positions, 1426 lastLayoutTimestamp: positions ? Date.now() : null 1427 } 1428 })), 1429 1430 setNodeMetadata: (metadata) => set((state) => ({ 1431 constellationData: { 1432 ...state.constellationData, 1433 nodeMetadata: metadata 1434 } 1435 })), 1436 1437 clearConstellationData: () => set(() => ({ 1438 constellationData: { 1439 relationshipGraph: null, 1440 lastScanTimestamp: null, 1441 isScanning: false, 1442 positions: null, 1443 lastLayoutTimestamp: null, 1444 nodeMetadata: null 1445 } 1446 })), 1447 1448 setRadialButtonUIActive: (active) => set((state) => ({ 1449 radialButtonUI: { 1450 ...state.radialButtonUI, 1451 isActive: active 1452 } 1453 })), 1454 1455 setRadialButtonCount: (count) => set((state) => ({ 1456 radialButtonUI: { 1457 ...state.radialButtonUI, 1458 buttonCount: count 1459 } 1460 })), 1461 1462 setOptionKeyPressed: (pressed) => set((state) => ({ 1463 radialButtonUI: { 1464 ...state.radialButtonUI, 1465 optionKeyPressed: pressed 1466 } 1467 })), 1468 1469 // Update status management (non-persisted) 1470 updateStatus: new Map(), 1471 1472 setNodeUpdateStatus: (nodeId, result) => set((state) => { 1473 const newStatus = new Map(state.updateStatus); 1474 newStatus.set(nodeId, result); 1475 return { updateStatus: newStatus }; 1476 }), 1477 1478 clearNodeUpdateStatus: (nodeId) => set((state) => { 1479 const newStatus = new Map(state.updateStatus); 1480 newStatus.delete(nodeId); 1481 return { updateStatus: newStatus }; 1482 }), 1483 1484 getNodeUpdateStatus: (nodeId) => { 1485 return get().updateStatus.get(nodeId) || null; 1486 }, 1487 }), 1488 { 1489 name: 'interbrain-storage', // Storage key 1490 // Only persist real nodes data, data mode, vector data, mock relationships, constellation data, and Ollama config 1491 partialize: (state) => ({ 1492 dataMode: state.dataMode, 1493 // Don't persist realNodes at all - they reload from vault on app start 1494 // This avoids localStorage quota issues entirely 1495 realNodes: [], 1496 mockRelationshipData: state.mockRelationshipData ? mapToArray(state.mockRelationshipData) : null, 1497 constellationData: (state.constellationData.relationshipGraph || state.constellationData.positions || state.constellationData.nodeMetadata) ? { 1498 ...state.constellationData, 1499 relationshipGraph: state.constellationData.relationshipGraph ? 1500 serializeRelationshipGraph(state.constellationData.relationshipGraph) : null, 1501 positions: state.constellationData.positions ? 1502 mapToArray(state.constellationData.positions) : null, 1503 nodeMetadata: state.constellationData.nodeMetadata ? 1504 mapToArray(state.constellationData.nodeMetadata) : null 1505 } : null, 1506 ...extractOllamaPersistenceData(state), 1507 }), 1508 // Custom merge function to handle Map deserialization 1509 merge: (persisted: unknown, current) => { 1510 const persistedData = persisted as { 1511 dataMode: 'mock' | 'real'; 1512 realNodes: [string, RealNodeData][]; 1513 mockRelationshipData: [string, string[]][] | null; 1514 constellationData?: { 1515 relationshipGraph: SerializableDreamSongGraph | null; 1516 lastScanTimestamp: number | null; 1517 isScanning: boolean; 1518 positions: [string, [number, number, number]][] | null; 1519 lastLayoutTimestamp: number | null; 1520 nodeMetadata: [string, { name: string; type: string; uuid: string }][] | null; 1521 } | null; 1522 vectorData?: [string, VectorData][]; 1523 ollamaConfig?: OllamaConfig; 1524 }; 1525 1526 // Restore constellation data if present 1527 let constellationData = { 1528 relationshipGraph: null as DreamSongRelationshipGraph | null, 1529 lastScanTimestamp: null as number | null, 1530 isScanning: false, 1531 positions: null as Map<string, [number, number, number]> | null, 1532 lastLayoutTimestamp: null as number | null, 1533 nodeMetadata: null as Map<string, { name: string; type: string; uuid: string }> | null 1534 }; 1535 1536 if (persistedData.constellationData) { 1537 try { 1538 constellationData = { 1539 relationshipGraph: persistedData.constellationData.relationshipGraph ? 1540 deserializeRelationshipGraph(persistedData.constellationData.relationshipGraph) : null, 1541 lastScanTimestamp: persistedData.constellationData.lastScanTimestamp, 1542 isScanning: false, 1543 positions: persistedData.constellationData.positions ? 1544 arrayToMap(persistedData.constellationData.positions) : null, 1545 lastLayoutTimestamp: persistedData.constellationData.lastLayoutTimestamp, 1546 nodeMetadata: persistedData.constellationData.nodeMetadata ? 1547 arrayToMap(persistedData.constellationData.nodeMetadata) : null 1548 }; 1549 } catch (error) { 1550 console.warn('Failed to deserialize constellation data:', error); 1551 // Keep default null state 1552 } 1553 } 1554 1555 return { 1556 ...current, 1557 dataMode: persistedData.dataMode || 'mock', 1558 realNodes: persistedData.realNodes ? arrayToMap(persistedData.realNodes) : new Map(), 1559 mockRelationshipData: persistedData.mockRelationshipData ? arrayToMap(persistedData.mockRelationshipData) : null, 1560 constellationData, 1561 ...restoreOllamaPersistenceData(persistedData), 1562 }; 1563 }, 1564 } 1565 ) 1566 ); 1567 1568 // DIAGNOSTIC: Log all store updates to identify re-render storm trigger 1569 if (typeof window !== 'undefined') { 1570 let updateCount = 0; 1571 useInterBrainStore.subscribe((state, prevState) => { 1572 updateCount++; 1573 1574 // Diagnostic logging disabled 1575 }); 1576 }