From dfb0c05ab33cb20537002eaeb896e6b2ab35af25 Mon Sep 17 00:00:00 2001
From: nexxeln <95541290+nexxeln@users.noreply.github.com>
Date: Tue, 2 Dec 2025 18:37:24 +0000
Subject: add spaces selector with search (#600)

relevant files to review:
\- memory-graph.tsx
\- spaces-dropdown.tsx
\- spaces-dropdown.css.ts
---
 .../memory-graph/src/components/memory-graph.tsx   | 284 ++++++++++++---------
 1 file changed, 159 insertions(+), 125 deletions(-)

(limited to 'packages/memory-graph/src/components/memory-graph.tsx')

diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx
index 21d4a08f..8f356d2f 100644
--- a/packages/memory-graph/src/components/memory-graph.tsx
+++ b/packages/memory-graph/src/components/memory-graph.tsx
@@ -1,21 +1,21 @@
-"use client";
-
-import { GlassMenuEffect } from "@/ui/glass-effect";
-import { AnimatePresence } from "motion/react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { GraphCanvas } from "./graph-canvas";
-import { useGraphData } from "@/hooks/use-graph-data";
-import { useGraphInteractions } from "@/hooks/use-graph-interactions";
-import { injectStyles } from "@/lib/inject-styles";
-import { Legend } from "./legend";
-import { LoadingIndicator } from "./loading-indicator";
-import { NavigationControls } from "./navigation-controls";
-import { NodeDetailPanel } from "./node-detail-panel";
-import { SpacesDropdown } from "./spaces-dropdown";
-import * as styles from "./memory-graph.css";
-import { defaultTheme } from "@/styles/theme.css";
-
-import type { MemoryGraphProps } from "@/types";
+"use client"
+
+import { GlassMenuEffect } from "@/ui/glass-effect"
+import { AnimatePresence } from "motion/react"
+import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { GraphCanvas } from "./graph-canvas"
+import { useGraphData } from "@/hooks/use-graph-data"
+import { useGraphInteractions } from "@/hooks/use-graph-interactions"
+import { injectStyles } from "@/lib/inject-styles"
+import { Legend } from "./legend"
+import { LoadingIndicator } from "./loading-indicator"
+import { NavigationControls } from "./navigation-controls"
+import { NodeDetailPanel } from "./node-detail-panel"
+import { SpacesDropdown } from "./spaces-dropdown"
+import * as styles from "./memory-graph.css"
+import { defaultTheme } from "@/styles/theme.css"
+
+import type { MemoryGraphProps } from "@/types"
 
 export const MemoryGraph = ({
 	children,
@@ -34,23 +34,45 @@ export const MemoryGraph = ({
 	occludedRightPx = 0,
 	autoLoadOnViewport = true,
 	themeClassName,
+	selectedSpace: externalSelectedSpace,
+	onSpaceChange: externalOnSpaceChange,
+	memoryLimit,
+	isExperimental,
 }: MemoryGraphProps) => {
 	// Inject styles on first render (client-side only)
 	useEffect(() => {
-		injectStyles();
-	}, []);
+		injectStyles()
+	}, [])
 
 	// Derive totalLoaded from documents if not provided
-	const effectiveTotalLoaded = totalLoaded ?? documents.length;
+	const effectiveTotalLoaded = totalLoaded ?? documents.length
 	// No-op for loadMoreDocuments if not provided
-	const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {});
+	const effectiveLoadMoreDocuments = loadMoreDocuments ?? (async () => {})
 	// Derive showSpacesSelector from variant if not explicitly provided
 	// console variant shows spaces selector, consumer variant hides it
-	const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console");
+	const finalShowSpacesSelector = showSpacesSelector ?? variant === "console"
 
-	const [selectedSpace, setSelectedSpace] = useState<string>("all");
-	const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
-	const containerRef = useRef<HTMLDivElement>(null);
+	// Internal state for controlled/uncontrolled pattern
+	const [internalSelectedSpace, setInternalSelectedSpace] =
+		useState<string>("all")
+
+	const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
+	const containerRef = useRef<HTMLDivElement>(null)
+
+	// Use external state if provided, otherwise use internal state
+	const selectedSpace = externalSelectedSpace ?? internalSelectedSpace
+
+	// Handle space change
+	const handleSpaceChange = useCallback(
+		(spaceId: string) => {
+			if (externalOnSpaceChange) {
+				externalOnSpaceChange(spaceId)
+			} else {
+				setInternalSelectedSpace(spaceId)
+			}
+		},
+		[externalOnSpaceChange],
+	)
 
 	// Create data object with pagination to satisfy type requirements
 	const data = useMemo(() => {
@@ -64,8 +86,8 @@ export const MemoryGraph = ({
 						totalPages: 1,
 					},
 				}
-			: null;
-	}, [documents]);
+			: null
+	}, [documents])
 
 	// Graph interactions with variant-specific settings
 	const {
@@ -95,7 +117,7 @@ export const MemoryGraph = ({
 		centerViewportOn,
 		zoomIn,
 		zoomOut,
-	} = useGraphInteractions(variant);
+	} = useGraphInteractions(variant)
 
 	// Graph data
 	const { nodes, edges } = useGraphData(
@@ -103,14 +125,13 @@ export const MemoryGraph = ({
 		selectedSpace,
 		nodePositions,
 		draggingNodeId,
-	);
+		memoryLimit,
+	)
 
 	// Auto-fit once per unique highlight set to show the full graph for context
-	const lastFittedHighlightKeyRef = useRef<string>("");
+	const lastFittedHighlightKeyRef = useRef<string>("")
 	useEffect(() => {
-		const highlightKey = highlightsVisible
-			? highlightDocumentIds.join("|")
-			: "";
+		const highlightKey = highlightsVisible ? highlightDocumentIds.join("|") : ""
 		if (
 			highlightKey &&
 			highlightKey !== lastFittedHighlightKeyRef.current &&
@@ -121,8 +142,8 @@ export const MemoryGraph = ({
 			autoFitToViewport(nodes, containerSize.width, containerSize.height, {
 				occludedRightPx,
 				animate: true,
-			});
-			lastFittedHighlightKeyRef.current = highlightKey;
+			})
+			lastFittedHighlightKeyRef.current = highlightKey
 		}
 	}, [
 		highlightsVisible,
@@ -132,10 +153,10 @@ export const MemoryGraph = ({
 		nodes.length,
 		occludedRightPx,
 		autoFitToViewport,
-	]);
+	])
 
 	// Auto-fit graph when component mounts or nodes change significantly
-	const hasAutoFittedRef = useRef(false);
+	const hasAutoFittedRef = useRef(false)
 	useEffect(() => {
 		// Only auto-fit once when we have nodes and container size
 		if (
@@ -147,90 +168,85 @@ export const MemoryGraph = ({
 			// Auto-fit to show all content for both variants
 			// Add a small delay to ensure the canvas is fully initialized
 			const timer = setTimeout(() => {
-				autoFitToViewport(nodes, containerSize.width, containerSize.height);
-				hasAutoFittedRef.current = true;
-			}, 100);
-			
-			return () => clearTimeout(timer);
+				autoFitToViewport(nodes, containerSize.width, containerSize.height)
+				hasAutoFittedRef.current = true
+			}, 100)
+
+			return () => clearTimeout(timer)
 		}
-	}, [
-		nodes,
-		containerSize.width,
-		containerSize.height,
-		autoFitToViewport,
-	]);
+	}, [nodes, containerSize.width, containerSize.height, autoFitToViewport])
 
 	// Reset auto-fit flag when nodes array becomes empty (switching views)
 	useEffect(() => {
 		if (nodes.length === 0) {
-			hasAutoFittedRef.current = false;
+			hasAutoFittedRef.current = false
 		}
-	}, [nodes.length]);
+	}, [nodes.length])
 
 	// Extract unique spaces from memories and calculate counts
 	const { availableSpaces, spaceMemoryCounts } = useMemo(() => {
-		if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} };
+		if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} }
 
-		const spaceSet = new Set<string>();
-		const counts: Record<string, number> = {};
+		const spaceSet = new Set<string>()
+		const counts: Record<string, number> = {}
 
 		data.documents.forEach((doc) => {
 			doc.memoryEntries.forEach((memory) => {
-				const spaceId = memory.spaceContainerTag || memory.spaceId || "default";
-				spaceSet.add(spaceId);
-				counts[spaceId] = (counts[spaceId] || 0) + 1;
-			});
-		});
+				const spaceId = memory.spaceContainerTag || memory.spaceId || "default"
+				spaceSet.add(spaceId)
+				counts[spaceId] = (counts[spaceId] || 0) + 1
+			})
+		})
 
 		return {
 			availableSpaces: Array.from(spaceSet).sort(),
 			spaceMemoryCounts: counts,
-		};
-	}, [data]);
+		}
+	}, [data])
 
 	// Handle container resize
 	useEffect(() => {
 		const updateSize = () => {
 			if (containerRef.current) {
-				const newWidth = containerRef.current.clientWidth;
-				const newHeight = containerRef.current.clientHeight;
-				
+				const newWidth = containerRef.current.clientWidth
+				const newHeight = containerRef.current.clientHeight
+
 				// Only update if size actually changed and is valid
 				setContainerSize((prev) => {
 					if (prev.width !== newWidth || prev.height !== newHeight) {
-						return { width: newWidth, height: newHeight };
+						return { width: newWidth, height: newHeight }
 					}
-					return prev;
-				});
+					return prev
+				})
 			}
-		};
+		}
 
 		// Use a slight delay to ensure DOM is fully rendered
-		const timer = setTimeout(updateSize, 0);
-		updateSize(); // Also call immediately
-		
-		window.addEventListener("resize", updateSize);
-		
+		const timer = setTimeout(updateSize, 0)
+		updateSize() // Also call immediately
+
+		window.addEventListener("resize", updateSize)
+
 		// Use ResizeObserver for more accurate container size detection
-		const resizeObserver = new ResizeObserver(updateSize);
+		const resizeObserver = new ResizeObserver(updateSize)
 		if (containerRef.current) {
-			resizeObserver.observe(containerRef.current);
+			resizeObserver.observe(containerRef.current)
 		}
-		
+
 		return () => {
-			clearTimeout(timer);
-			window.removeEventListener("resize", updateSize);
-			resizeObserver.disconnect();
-		};
-	}, []);
+			clearTimeout(timer)
+			window.removeEventListener("resize", updateSize)
+			resizeObserver.disconnect()
+		}
+	}, [])
 
 	// Enhanced node drag start that includes nodes data
 	const handleNodeDragStartWithNodes = useCallback(
 		(nodeId: string, e: React.MouseEvent) => {
-			handleNodeDragStart(nodeId, e, nodes);
+			handleNodeDragStart(nodeId, e, nodes)
 		},
 		[handleNodeDragStart, nodes],
-	);
+	)
 
 	// Navigation callbacks
 	const handleCenter = useCallback(() => {
@@ -239,35 +255,50 @@ export const MemoryGraph = ({
 			let sumX = 0
 			let sumY = 0
 			let count = 0
-			
+
 			nodes.forEach((node) => {
 				sumX += node.x
 				sumY += node.y
 				count++
 			})
-			
+
 			if (count > 0) {
 				const centerX = sumX / count
 				const centerY = sumY / count
-				centerViewportOn(centerX, centerY, containerSize.width, containerSize.height)
+				centerViewportOn(
+					centerX,
+					centerY,
+					containerSize.width,
+					containerSize.height,
+				)
 			}
 		}
 	}, [nodes, centerViewportOn, containerSize.width, containerSize.height])
 
 	const handleAutoFit = useCallback(() => {
-		if (nodes.length > 0 && containerSize.width > 0 && containerSize.height > 0) {
+		if (
+			nodes.length > 0 &&
+			containerSize.width > 0 &&
+			containerSize.height > 0
+		) {
 			autoFitToViewport(nodes, containerSize.width, containerSize.height, {
 				occludedRightPx,
 				animate: true,
 			})
 		}
-	}, [nodes, containerSize.width, containerSize.height, occludedRightPx, autoFitToViewport])
+	}, [
+		nodes,
+		containerSize.width,
+		containerSize.height,
+		occludedRightPx,
+		autoFitToViewport,
+	])
 
 	// Get selected node data
 	const selectedNodeData = useMemo(() => {
-		if (!selectedNode) return null;
-		return nodes.find((n) => n.id === selectedNode) || null;
-	}, [selectedNode, nodes]);
+		if (!selectedNode) return null
+		return nodes.find((n) => n.id === selectedNode) || null
+	}, [selectedNode, nodes])
 
 	// Viewport-based loading: load more when most documents are visible (optional)
 	const checkAndLoadMore = useCallback(() => {
@@ -277,7 +308,7 @@ export const MemoryGraph = ({
 			!data?.documents ||
 			data.documents.length === 0
 		)
-			return;
+			return
 
 		// Calculate viewport bounds
 		const viewportBounds = {
@@ -285,26 +316,26 @@ export const MemoryGraph = ({
 			right: (-panX + containerSize.width) / zoom + 200,
 			top: -panY / zoom - 200,
 			bottom: (-panY + containerSize.height) / zoom + 200,
-		};
+		}
 
 		// Count visible documents
 		const visibleDocuments = data.documents.filter((doc) => {
 			const docNodes = nodes.filter(
 				(node) => node.type === "document" && node.data.id === doc.id,
-			);
+			)
 			return docNodes.some(
 				(node) =>
 					node.x >= viewportBounds.left &&
 					node.x <= viewportBounds.right &&
 					node.y >= viewportBounds.top &&
 					node.y <= viewportBounds.bottom,
-			);
-		});
+			)
+		})
 
 		// If 80% or more of documents are visible, load more
-		const visibilityRatio = visibleDocuments.length / data.documents.length;
+		const visibilityRatio = visibleDocuments.length / data.documents.length
 		if (visibilityRatio >= 0.8) {
-			effectiveLoadMoreDocuments();
+			effectiveLoadMoreDocuments()
 		}
 	}, [
 		isLoadingMore,
@@ -317,35 +348,35 @@ export const MemoryGraph = ({
 		containerSize.height,
 		nodes,
 		effectiveLoadMoreDocuments,
-	]);
+	])
 
 	// Throttled version to avoid excessive checks
-	const lastLoadCheckRef = useRef(0);
+	const lastLoadCheckRef = useRef(0)
 	const throttledCheckAndLoadMore = useCallback(() => {
-		const now = Date.now();
+		const now = Date.now()
 		if (now - lastLoadCheckRef.current > 1000) {
 			// Check at most once per second
-			lastLoadCheckRef.current = now;
-			checkAndLoadMore();
+			lastLoadCheckRef.current = now
+			checkAndLoadMore()
 		}
-	}, [checkAndLoadMore]);
+	}, [checkAndLoadMore])
 
 	// Monitor viewport changes to trigger loading
 	useEffect(() => {
-		if (!autoLoadOnViewport) return;
-		throttledCheckAndLoadMore();
-	}, [throttledCheckAndLoadMore, autoLoadOnViewport]);
+		if (!autoLoadOnViewport) return
+		throttledCheckAndLoadMore()
+	}, [throttledCheckAndLoadMore, autoLoadOnViewport])
 
 	// Initial load trigger when graph is first rendered
 	useEffect(() => {
-		if (!autoLoadOnViewport) return;
+		if (!autoLoadOnViewport) return
 		if (data?.documents && data.documents.length > 0 && hasMore) {
 			// Start loading more documents after initial render
 			setTimeout(() => {
-				throttledCheckAndLoadMore();
-			}, 500); // Small delay to allow initial layout
+				throttledCheckAndLoadMore()
+			}, 500) // Small delay to allow initial layout
 		}
-	}, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]);
+	}, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport])
 
 	if (error) {
 		return (
@@ -359,17 +390,19 @@ export const MemoryGraph = ({
 					</div>
 				</div>
 			</div>
-		);
+		)
 	}
 
 	return (
-		<div className={`${themeClassName ?? defaultTheme} ${styles.mainContainer}`}>
-			{/* Spaces selector - only shown for console */}
-			{finalShowSpacesSelector && availableSpaces.length > 0 && (
+		<div
+			className={`${themeClassName ?? defaultTheme} ${styles.mainContainer}`}
+		>
+			{/* Spaces selector - only shown for console variant */}
+			{variant === "console" && availableSpaces.length > 0 && (
 				<div className={styles.spacesSelectorContainer}>
 					<SpacesDropdown
 						availableSpaces={availableSpaces}
-						onSpaceChange={setSelectedSpace}
+						onSpaceChange={handleSpaceChange}
 						selectedSpace={selectedSpace}
 						spaceMemoryCounts={spaceMemoryCounts}
 					/>
@@ -411,11 +444,8 @@ export const MemoryGraph = ({
 				)}
 
 			{/* Graph container */}
-			<div
-				className={styles.graphContainer}
-				ref={containerRef}
-			>
-				{(containerSize.width > 0 && containerSize.height > 0) && (
+			<div className={styles.graphContainer} ref={containerRef}>
+				{containerSize.width > 0 && containerSize.height > 0 && (
 					<GraphCanvas
 						draggingNodeId={draggingNodeId}
 						edges={edges}
@@ -446,8 +476,12 @@ export const MemoryGraph = ({
 				{containerSize.width > 0 && (
 					<NavigationControls
 						onCenter={handleCenter}
-						onZoomIn={() => zoomIn(containerSize.width / 2, containerSize.height / 2)}
-						onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)}
+						onZoomIn={() =>
+							zoomIn(containerSize.width / 2, containerSize.height / 2)
+						}
+						onZoomOut={() =>
+							zoomOut(containerSize.width / 2, containerSize.height / 2)
+						}
 						onAutoFit={handleAutoFit}
 						nodes={nodes}
 						className={styles.navControlsContainer}
@@ -455,5 +489,5 @@ export const MemoryGraph = ({
 				)}
 			</div>
 		</div>
-	);
-};
+	)
+}
-- 
cgit v1.2.3

