aboutsummaryrefslogtreecommitdiff
path: root/packages/memory-graph/src/components
diff options
context:
space:
mode:
authornexxeln <[email protected]>2025-11-19 18:57:55 +0000
committernexxeln <[email protected]>2025-11-19 18:57:56 +0000
commit5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb (patch)
tree60336fd37b41e3597065729d098877483eba73b6 /packages/memory-graph/src/components
parentFix: Prevent multiple prompts while AI response is generated (fixes #538) (#583) (diff)
downloadsupermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.tar.xz
supermemory-5e24eb66c3ca7d2224d0d1f7837cda17015f5fcb.zip
includes: - a package that contains a MemoryGraph component which handles fetching data and rendering the graph - a playground to test the package problems: - the bundle size is huge - the styles are kinda broken? we are using [https://www.npmjs.com/package/vite-plugin-libgi-inject-css](https://www.npmjs.com/package/vite-plugin-lib-inject-css) to inject the styles ![image.png](https://app.graphite.com/user-attachments/assets/cb1822c5-850a-45a2-9bfa-72b73436659f.png)
Diffstat (limited to 'packages/memory-graph/src/components')
-rw-r--r--packages/memory-graph/src/components/canvas-common.css.ts10
-rw-r--r--packages/memory-graph/src/components/graph-canvas.tsx764
-rw-r--r--packages/memory-graph/src/components/legend.css.ts345
-rw-r--r--packages/memory-graph/src/components/legend.tsx275
-rw-r--r--packages/memory-graph/src/components/loading-indicator.css.ts55
-rw-r--r--packages/memory-graph/src/components/loading-indicator.tsx40
-rw-r--r--packages/memory-graph/src/components/memory-graph-wrapper.tsx198
-rw-r--r--packages/memory-graph/src/components/memory-graph.css.ts75
-rw-r--r--packages/memory-graph/src/components/memory-graph.tsx448
-rw-r--r--packages/memory-graph/src/components/navigation-controls.css.ts77
-rw-r--r--packages/memory-graph/src/components/navigation-controls.tsx73
-rw-r--r--packages/memory-graph/src/components/node-detail-panel.css.ts170
-rw-r--r--packages/memory-graph/src/components/node-detail-panel.tsx266
-rw-r--r--packages/memory-graph/src/components/spaces-dropdown.css.ts158
-rw-r--r--packages/memory-graph/src/components/spaces-dropdown.tsx110
15 files changed, 3064 insertions, 0 deletions
diff --git a/packages/memory-graph/src/components/canvas-common.css.ts b/packages/memory-graph/src/components/canvas-common.css.ts
new file mode 100644
index 00000000..91005488
--- /dev/null
+++ b/packages/memory-graph/src/components/canvas-common.css.ts
@@ -0,0 +1,10 @@
+import { style } from "@vanilla-extract/css";
+
+/**
+ * Canvas wrapper/container that fills its parent
+ * Used by both graph-canvas and graph-webgl-canvas
+ */
+export const canvasWrapper = style({
+ position: "absolute",
+ inset: 0,
+});
diff --git a/packages/memory-graph/src/components/graph-canvas.tsx b/packages/memory-graph/src/components/graph-canvas.tsx
new file mode 100644
index 00000000..59efa74d
--- /dev/null
+++ b/packages/memory-graph/src/components/graph-canvas.tsx
@@ -0,0 +1,764 @@
+"use client";
+
+import {
+ memo,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+} from "react";
+import { colors } from "@/constants";
+import type {
+ DocumentWithMemories,
+ GraphCanvasProps,
+ GraphNode,
+ MemoryEntry,
+} from "@/types";
+import { canvasWrapper } from "./canvas-common.css";
+
+export const GraphCanvas = memo<GraphCanvasProps>(
+ ({
+ nodes,
+ edges,
+ panX,
+ panY,
+ zoom,
+ width,
+ height,
+ onNodeHover,
+ onNodeClick,
+ onNodeDragStart,
+ onNodeDragMove,
+ onNodeDragEnd,
+ onPanStart,
+ onPanMove,
+ onPanEnd,
+ onWheel,
+ onDoubleClick,
+ onTouchStart,
+ onTouchMove,
+ onTouchEnd,
+ draggingNodeId,
+ highlightDocumentIds,
+ }) => {
+ const canvasRef = useRef<HTMLCanvasElement>(null);
+ const animationRef = useRef<number>(0);
+ const startTimeRef = useRef<number>(Date.now());
+ const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
+ const currentHoveredNode = useRef<string | null>(null);
+
+ // Initialize start time once
+ useEffect(() => {
+ startTimeRef.current = Date.now();
+ }, []);
+
+ // Efficient hit detection
+ const getNodeAtPosition = useCallback(
+ (x: number, y: number): string | null => {
+ // Check from top-most to bottom-most: memory nodes are drawn after documents
+ for (let i = nodes.length - 1; i >= 0; i--) {
+ const node = nodes[i]!;
+ const screenX = node.x * zoom + panX;
+ const screenY = node.y * zoom + panY;
+ const nodeSize = node.size * zoom;
+
+ const dx = x - screenX;
+ const dy = y - screenY;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+
+ if (distance <= nodeSize / 2) {
+ return node.id;
+ }
+ }
+ return null;
+ },
+ [nodes, panX, panY, zoom],
+ );
+
+ // Handle mouse events
+ const handleMouseMove = useCallback(
+ (e: React.MouseEvent) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ mousePos.current = { x, y };
+
+ const nodeId = getNodeAtPosition(x, y);
+ if (nodeId !== currentHoveredNode.current) {
+ currentHoveredNode.current = nodeId;
+ onNodeHover(nodeId);
+ }
+
+ // Handle node dragging
+ if (draggingNodeId) {
+ onNodeDragMove(e);
+ }
+ },
+ [getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove],
+ );
+
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ const nodeId = getNodeAtPosition(x, y);
+ if (nodeId) {
+ // When starting a node drag, prevent initiating pan
+ e.stopPropagation();
+ onNodeDragStart(nodeId, e);
+ return;
+ }
+ onPanStart(e);
+ },
+ [getNodeAtPosition, onNodeDragStart, onPanStart],
+ );
+
+ const handleClick = useCallback(
+ (e: React.MouseEvent) => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const y = e.clientY - rect.top;
+
+ const nodeId = getNodeAtPosition(x, y);
+ if (nodeId) {
+ onNodeClick(nodeId);
+ }
+ },
+ [getNodeAtPosition, onNodeClick],
+ );
+
+ // Professional rendering function with LOD
+ const render = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const ctx = canvas.getContext("2d");
+ if (!ctx) return;
+
+ const currentTime = Date.now();
+ const _elapsed = currentTime - startTimeRef.current;
+
+ // Level-of-detail optimization based on zoom
+ const useSimplifiedRendering = zoom < 0.3;
+
+ // Clear canvas
+ ctx.clearRect(0, 0, width, height);
+
+ // Set high quality rendering
+ ctx.imageSmoothingEnabled = true;
+ ctx.imageSmoothingQuality = "high";
+
+ // Draw minimal background grid
+ ctx.strokeStyle = "rgba(148, 163, 184, 0.03)"; // Very subtle grid
+ ctx.lineWidth = 1;
+ const gridSpacing = 100 * zoom;
+ const offsetX = panX % gridSpacing;
+ const offsetY = panY % gridSpacing;
+
+ // Simple, clean grid lines
+ for (let x = offsetX; x < width; x += gridSpacing) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, height);
+ ctx.stroke();
+ }
+ for (let y = offsetY; y < height; y += gridSpacing) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(width, y);
+ ctx.stroke();
+ }
+
+ // Create node lookup map
+ const nodeMap = new Map(nodes.map((node) => [node.id, node]));
+
+ // Draw enhanced edges with sophisticated styling
+ ctx.lineCap = "round";
+ edges.forEach((edge) => {
+ const sourceNode = nodeMap.get(edge.source);
+ const targetNode = nodeMap.get(edge.target);
+
+ if (sourceNode && targetNode) {
+ const sourceX = sourceNode.x * zoom + panX;
+ const sourceY = sourceNode.y * zoom + panY;
+ const targetX = targetNode.x * zoom + panX;
+ const targetY = targetNode.y * zoom + panY;
+
+ // Enhanced viewport culling with edge type considerations
+ if (
+ sourceX < -100 ||
+ sourceX > width + 100 ||
+ targetX < -100 ||
+ targetX > width + 100
+ ) {
+ return;
+ }
+
+ // Skip very weak connections when zoomed out for performance
+ if (useSimplifiedRendering) {
+ if (
+ edge.edgeType === "doc-memory" &&
+ edge.visualProps.opacity < 0.3
+ ) {
+ return; // Skip very weak doc-memory edges when zoomed out
+ }
+ }
+
+ // Enhanced connection styling based on edge type
+ let connectionColor = colors.connection.weak;
+ let dashPattern: number[] = [];
+ let opacity = edge.visualProps.opacity;
+ let lineWidth = Math.max(1, edge.visualProps.thickness * zoom);
+
+ if (edge.edgeType === "doc-memory") {
+ // Doc-memory: Solid thin lines, subtle
+ dashPattern = [];
+ connectionColor = colors.connection.memory;
+ opacity = 0.9;
+ lineWidth = 1;
+ } else if (edge.edgeType === "doc-doc") {
+ // Doc-doc: Thick dashed lines with strong similarity emphasis
+ dashPattern = useSimplifiedRendering ? [] : [10, 5]; // Solid lines when zoomed out
+ opacity = Math.max(0, edge.similarity * 0.5);
+ lineWidth = Math.max(1, edge.similarity * 2); // Thicker for stronger similarity
+
+ if (edge.similarity > 0.85)
+ connectionColor = colors.connection.strong;
+ else if (edge.similarity > 0.725)
+ connectionColor = colors.connection.medium;
+ } else if (edge.edgeType === "version") {
+ // Version chains: Double line effect with relation-specific colors
+ dashPattern = [];
+ connectionColor = edge.color || colors.relations.updates;
+ opacity = 0.8;
+ lineWidth = 2;
+ }
+
+ ctx.strokeStyle = connectionColor;
+ ctx.lineWidth = lineWidth;
+ ctx.globalAlpha = opacity;
+ ctx.setLineDash(dashPattern);
+
+ if (edge.edgeType === "version") {
+ // Special double-line rendering for version chains
+ // First line (outer)
+ ctx.lineWidth = 3;
+ ctx.globalAlpha = opacity * 0.3;
+ ctx.beginPath();
+ ctx.moveTo(sourceX, sourceY);
+ ctx.lineTo(targetX, targetY);
+ ctx.stroke();
+
+ // Second line (inner)
+ ctx.lineWidth = 1;
+ ctx.globalAlpha = opacity;
+ ctx.beginPath();
+ ctx.moveTo(sourceX, sourceY);
+ ctx.lineTo(targetX, targetY);
+ ctx.stroke();
+ } else {
+ // Simplified lines when zoomed out, curved when zoomed in
+ if (useSimplifiedRendering) {
+ // Straight lines for performance
+ ctx.beginPath();
+ ctx.moveTo(sourceX, sourceY);
+ ctx.lineTo(targetX, targetY);
+ ctx.stroke();
+ } else {
+ // Regular curved line for doc-memory and doc-doc
+ const midX = (sourceX + targetX) / 2;
+ const midY = (sourceY + targetY) / 2;
+ const dx = targetX - sourceX;
+ const dy = targetY - sourceY;
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ const controlOffset =
+ edge.edgeType === "doc-memory"
+ ? 15
+ : Math.min(30, distance * 0.2);
+
+ ctx.beginPath();
+ ctx.moveTo(sourceX, sourceY);
+ ctx.quadraticCurveTo(
+ midX + controlOffset * (dy / distance),
+ midY - controlOffset * (dx / distance),
+ targetX,
+ targetY,
+ );
+ ctx.stroke();
+ }
+ }
+
+ // Subtle arrow head for version edges
+ if (edge.edgeType === "version") {
+ const angle = Math.atan2(targetY - sourceY, targetX - sourceX);
+ const arrowLength = Math.max(6, 8 * zoom); // Shorter, more subtle
+ const arrowWidth = Math.max(8, 12 * zoom);
+
+ // Calculate arrow position offset from node edge
+ const nodeRadius = (targetNode.size * zoom) / 2;
+ const offsetDistance = nodeRadius + 2;
+ const arrowX = targetX - Math.cos(angle) * offsetDistance;
+ const arrowY = targetY - Math.sin(angle) * offsetDistance;
+
+ ctx.save();
+ ctx.translate(arrowX, arrowY);
+ ctx.rotate(angle);
+ ctx.setLineDash([]);
+
+ // Simple outlined arrow (not filled)
+ ctx.strokeStyle = connectionColor;
+ ctx.lineWidth = Math.max(1, 1.5 * zoom);
+ ctx.globalAlpha = opacity;
+
+ ctx.beginPath();
+ ctx.moveTo(0, 0);
+ ctx.lineTo(-arrowLength, arrowWidth / 2);
+ ctx.moveTo(0, 0);
+ ctx.lineTo(-arrowLength, -arrowWidth / 2);
+ ctx.stroke();
+
+ ctx.restore();
+ }
+ }
+ });
+
+ ctx.globalAlpha = 1;
+ ctx.setLineDash([]);
+
+ // Prepare highlight set from provided document IDs (customId or internal)
+ const highlightSet = new Set<string>(highlightDocumentIds ?? []);
+
+ // Draw nodes with enhanced styling and LOD optimization
+ nodes.forEach((node) => {
+ const screenX = node.x * zoom + panX;
+ const screenY = node.y * zoom + panY;
+ const nodeSize = node.size * zoom;
+
+ // Enhanced viewport culling
+ const margin = nodeSize + 50;
+ if (
+ screenX < -margin ||
+ screenX > width + margin ||
+ screenY < -margin ||
+ screenY > height + margin
+ ) {
+ return;
+ }
+
+ const isHovered = currentHoveredNode.current === node.id;
+ const isDragging = node.isDragging;
+ const isHighlightedDocument = (() => {
+ if (node.type !== "document" || highlightSet.size === 0) return false;
+ const doc = node.data as DocumentWithMemories;
+ if (doc.customId && highlightSet.has(doc.customId)) return true;
+ return highlightSet.has(doc.id);
+ })();
+
+ if (node.type === "document") {
+ // Enhanced glassmorphism document styling
+ const docWidth = nodeSize * 1.4;
+ const docHeight = nodeSize * 0.9;
+
+ // Multi-layer glass effect
+ ctx.fillStyle = isDragging
+ ? colors.document.accent
+ : isHovered
+ ? colors.document.secondary
+ : colors.document.primary;
+ ctx.globalAlpha = 1;
+
+ // Enhanced border with subtle glow
+ ctx.strokeStyle = isDragging
+ ? colors.document.glow
+ : isHovered
+ ? colors.document.accent
+ : colors.document.border;
+ ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1;
+
+ // Rounded rectangle with enhanced styling
+ const radius = useSimplifiedRendering ? 6 : 12;
+ ctx.beginPath();
+ ctx.roundRect(
+ screenX - docWidth / 2,
+ screenY - docHeight / 2,
+ docWidth,
+ docHeight,
+ radius,
+ );
+ ctx.fill();
+ ctx.stroke();
+
+ // Subtle inner highlight for glass effect (skip when zoomed out)
+ if (!useSimplifiedRendering && (isHovered || isDragging)) {
+ ctx.strokeStyle = "rgba(255, 255, 255, 0.1)";
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.roundRect(
+ screenX - docWidth / 2 + 1,
+ screenY - docHeight / 2 + 1,
+ docWidth - 2,
+ docHeight - 2,
+ radius - 1,
+ );
+ ctx.stroke();
+ }
+
+ // Highlight ring for search hits
+ if (isHighlightedDocument) {
+ ctx.save();
+ ctx.globalAlpha = 0.9;
+ ctx.strokeStyle = colors.accent.primary;
+ ctx.lineWidth = 3;
+ ctx.setLineDash([6, 4]);
+ const ringPadding = 10;
+ ctx.beginPath();
+ ctx.roundRect(
+ screenX - docWidth / 2 - ringPadding,
+ screenY - docHeight / 2 - ringPadding,
+ docWidth + ringPadding * 2,
+ docHeight + ringPadding * 2,
+ radius + 6,
+ );
+ ctx.stroke();
+ ctx.setLineDash([]);
+ ctx.restore();
+ }
+ } else {
+ // Enhanced memory styling with status indicators
+ const mem = node.data as MemoryEntry;
+ const isForgotten =
+ mem.isForgotten ||
+ (mem.forgetAfter &&
+ new Date(mem.forgetAfter).getTime() < Date.now());
+ const isLatest = mem.isLatest;
+
+ // Check if memory is expiring soon (within 7 days)
+ const expiringSoon =
+ mem.forgetAfter &&
+ !isForgotten &&
+ new Date(mem.forgetAfter).getTime() - Date.now() <
+ 1000 * 60 * 60 * 24 * 7;
+
+ // Check if memory is new (created within last 24 hours)
+ const isNew =
+ !isForgotten &&
+ new Date(mem.createdAt).getTime() >
+ Date.now() - 1000 * 60 * 60 * 24;
+
+ // Determine colors based on status
+ let fillColor = colors.memory.primary;
+ let borderColor = colors.memory.border;
+ let glowColor = colors.memory.glow;
+
+ if (isForgotten) {
+ fillColor = colors.status.forgotten;
+ borderColor = "rgba(220,38,38,0.3)";
+ glowColor = "rgba(220,38,38,0.2)";
+ } else if (expiringSoon) {
+ borderColor = colors.status.expiring;
+ glowColor = colors.accent.amber;
+ } else if (isNew) {
+ borderColor = colors.status.new;
+ glowColor = colors.accent.emerald;
+ }
+
+ if (isDragging) {
+ fillColor = colors.memory.accent;
+ borderColor = glowColor;
+ } else if (isHovered) {
+ fillColor = colors.memory.secondary;
+ }
+
+ const radius = nodeSize / 2;
+
+ ctx.fillStyle = fillColor;
+ ctx.globalAlpha = isLatest ? 1 : 0.4;
+ ctx.strokeStyle = borderColor;
+ ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5;
+
+ if (useSimplifiedRendering) {
+ // Simple circles when zoomed out for performance
+ ctx.beginPath();
+ ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI);
+ ctx.fill();
+ ctx.stroke();
+ } else {
+ // HEXAGONAL memory nodes when zoomed in
+ const sides = 6;
+ ctx.beginPath();
+ for (let i = 0; i < sides; i++) {
+ const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; // Start from top
+ const x = screenX + radius * Math.cos(angle);
+ const y = screenY + radius * Math.sin(angle);
+ if (i === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+ ctx.closePath();
+ ctx.fill();
+ ctx.stroke();
+
+ // Inner highlight for glass effect
+ if (isHovered || isDragging) {
+ ctx.strokeStyle = "rgba(147, 197, 253, 0.3)";
+ ctx.lineWidth = 1;
+ const innerRadius = radius - 2;
+ ctx.beginPath();
+ for (let i = 0; i < sides; i++) {
+ const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
+ const x = screenX + innerRadius * Math.cos(angle);
+ const y = screenY + innerRadius * Math.sin(angle);
+ if (i === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+ ctx.closePath();
+ ctx.stroke();
+ }
+ }
+
+ // Status indicators overlay (always preserve these as required)
+ if (isForgotten) {
+ // Cross for forgotten memories
+ ctx.strokeStyle = "rgba(220,38,38,0.4)";
+ ctx.lineWidth = 2;
+ const r = nodeSize * 0.25;
+ ctx.beginPath();
+ ctx.moveTo(screenX - r, screenY - r);
+ ctx.lineTo(screenX + r, screenY + r);
+ ctx.moveTo(screenX + r, screenY - r);
+ ctx.lineTo(screenX - r, screenY + r);
+ ctx.stroke();
+ } else if (isNew) {
+ // Small dot for new memories
+ ctx.fillStyle = colors.status.new;
+ ctx.beginPath();
+ ctx.arc(
+ screenX + nodeSize * 0.25,
+ screenY - nodeSize * 0.25,
+ Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px
+ 0,
+ 2 * Math.PI,
+ );
+ ctx.fill();
+ }
+ }
+
+ // Enhanced hover glow effect (skip when zoomed out for performance)
+ if (!useSimplifiedRendering && (isHovered || isDragging)) {
+ const glowColor =
+ node.type === "document"
+ ? colors.document.glow
+ : colors.memory.glow;
+
+ ctx.strokeStyle = glowColor;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([3, 3]);
+ ctx.globalAlpha = 0.6;
+
+ ctx.beginPath();
+ const glowSize = nodeSize * 0.7;
+ if (node.type === "document") {
+ ctx.roundRect(
+ screenX - glowSize,
+ screenY - glowSize / 1.4,
+ glowSize * 2,
+ glowSize * 1.4,
+ 15,
+ );
+ } else {
+ // Hexagonal glow for memory nodes
+ const glowRadius = glowSize;
+ const sides = 6;
+ for (let i = 0; i < sides; i++) {
+ const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
+ const x = screenX + glowRadius * Math.cos(angle);
+ const y = screenY + glowRadius * Math.sin(angle);
+ if (i === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+ ctx.closePath();
+ }
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ });
+
+ ctx.globalAlpha = 1;
+ }, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]);
+
+ // Change-based rendering instead of continuous animation
+ const lastRenderParams = useRef<string>("");
+
+ // Create a render key that changes when visual state changes
+ const renderKey = useMemo(() => {
+ const nodePositions = nodes
+ .map(
+ (n) =>
+ `${n.id}:${n.x}:${n.y}:${n.isDragging ? "1" : "0"}:${currentHoveredNode.current === n.id ? "1" : "0"}`,
+ )
+ .join("|");
+ const highlightKey = (highlightDocumentIds ?? []).join("|");
+ return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}`;
+ }, [
+ nodes,
+ edges.length,
+ panX,
+ panY,
+ zoom,
+ width,
+ height,
+ highlightDocumentIds,
+ ]);
+
+ // Only render when something actually changed
+ useEffect(() => {
+ if (renderKey !== lastRenderParams.current) {
+ lastRenderParams.current = renderKey;
+ render();
+ }
+ }, [renderKey, render]);
+
+ // Cleanup any existing animation frames
+ useEffect(() => {
+ return () => {
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current);
+ }
+ };
+ }, []);
+
+ // Add native wheel event listener to prevent browser zoom
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const handleNativeWheel = (e: WheelEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Call the onWheel handler with a synthetic-like event
+ // @ts-expect-error - partial WheelEvent object
+ onWheel({
+ deltaY: e.deltaY,
+ deltaX: e.deltaX,
+ clientX: e.clientX,
+ clientY: e.clientY,
+ currentTarget: canvas,
+ nativeEvent: e,
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ } as React.WheelEvent);
+ };
+
+ // Add listener with passive: false to ensure preventDefault works
+ canvas.addEventListener("wheel", handleNativeWheel, { passive: false });
+
+ // Also prevent gesture events for touch devices
+ const handleGesture = (e: Event) => {
+ e.preventDefault();
+ };
+
+ canvas.addEventListener("gesturestart", handleGesture, {
+ passive: false,
+ });
+ canvas.addEventListener("gesturechange", handleGesture, {
+ passive: false,
+ });
+ canvas.addEventListener("gestureend", handleGesture, { passive: false });
+
+ return () => {
+ canvas.removeEventListener("wheel", handleNativeWheel);
+ canvas.removeEventListener("gesturestart", handleGesture);
+ canvas.removeEventListener("gesturechange", handleGesture);
+ canvas.removeEventListener("gestureend", handleGesture);
+ };
+ }, [onWheel]);
+
+ // High-DPI handling --------------------------------------------------
+ const dpr =
+ typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
+
+ useLayoutEffect(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ // upscale backing store
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+
+ const ctx = canvas.getContext("2d");
+ ctx?.scale(dpr, dpr);
+ }, [width, height, dpr]);
+ // -----------------------------------------------------------------------
+
+ return (
+ <canvas
+ className={canvasWrapper}
+ height={height}
+ onClick={handleClick}
+ onDoubleClick={onDoubleClick}
+ onMouseDown={handleMouseDown}
+ onMouseLeave={() => {
+ if (draggingNodeId) {
+ onNodeDragEnd();
+ } else {
+ onPanEnd();
+ }
+ }}
+ onMouseMove={(e) => {
+ handleMouseMove(e);
+ if (!draggingNodeId) {
+ onPanMove(e);
+ }
+ }}
+ onMouseUp={() => {
+ if (draggingNodeId) {
+ onNodeDragEnd();
+ } else {
+ onPanEnd();
+ }
+ }}
+ onTouchStart={onTouchStart}
+ onTouchMove={onTouchMove}
+ onTouchEnd={onTouchEnd}
+ ref={canvasRef}
+ style={{
+ cursor: draggingNodeId
+ ? "grabbing"
+ : currentHoveredNode.current
+ ? "grab"
+ : "move",
+ touchAction: "none",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+ }}
+ width={width}
+ />
+ );
+ },
+);
+
+GraphCanvas.displayName = "GraphCanvas";
diff --git a/packages/memory-graph/src/components/legend.css.ts b/packages/memory-graph/src/components/legend.css.ts
new file mode 100644
index 00000000..b758cf9d
--- /dev/null
+++ b/packages/memory-graph/src/components/legend.css.ts
@@ -0,0 +1,345 @@
+import { style, styleVariants, globalStyle } from "@vanilla-extract/css";
+import { themeContract } from "../styles/theme.css";
+
+/**
+ * Legend container base
+ */
+const legendContainerBase = style({
+ position: "absolute",
+ zIndex: 20, // Above most elements but below node detail panel
+ borderRadius: themeContract.radii.xl,
+ overflow: "hidden",
+ width: "fit-content",
+ height: "fit-content",
+ maxHeight: "calc(100vh - 2rem)", // Prevent overflow
+});
+
+/**
+ * Legend container variants for positioning
+ * Console: Bottom-right (doesn't conflict with anything)
+ * Consumer: Bottom-right (moved from top to avoid conflicts)
+ */
+export const legendContainer = styleVariants({
+ consoleDesktop: [
+ legendContainerBase,
+ {
+ bottom: themeContract.space[4],
+ right: themeContract.space[4],
+ },
+ ],
+ consoleMobile: [
+ legendContainerBase,
+ {
+ bottom: themeContract.space[4],
+ right: themeContract.space[4],
+ "@media": {
+ "screen and (max-width: 767px)": {
+ display: "none",
+ },
+ },
+ },
+ ],
+ consumerDesktop: [
+ legendContainerBase,
+ {
+ // Changed from top to bottom to avoid overlap with node detail panel
+ bottom: themeContract.space[4],
+ right: themeContract.space[4],
+ },
+ ],
+ consumerMobile: [
+ legendContainerBase,
+ {
+ bottom: themeContract.space[4],
+ right: themeContract.space[4],
+ "@media": {
+ "screen and (max-width: 767px)": {
+ display: "none",
+ },
+ },
+ },
+ ],
+});
+
+/**
+ * Mobile size variants
+ */
+export const mobileSize = styleVariants({
+ expanded: {
+ maxWidth: "20rem", // max-w-xs
+ },
+ collapsed: {
+ width: "4rem", // w-16
+ height: "3rem", // h-12
+ },
+});
+
+/**
+ * Legend content wrapper
+ */
+export const legendContent = style({
+ position: "relative",
+ zIndex: 10,
+});
+
+/**
+ * Collapsed trigger button
+ */
+export const collapsedTrigger = style({
+ width: "100%",
+ height: "100%",
+ padding: themeContract.space[2],
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ transition: themeContract.transitions.normal,
+
+ selectors: {
+ "&:hover": {
+ backgroundColor: "rgba(255, 255, 255, 0.05)",
+ },
+ },
+});
+
+export const collapsedContent = style({
+ display: "flex",
+ flexDirection: "column",
+ alignItems: "center",
+ gap: themeContract.space[1],
+});
+
+export const collapsedText = style({
+ fontSize: themeContract.typography.fontSize.xs,
+ color: themeContract.colors.text.secondary,
+ fontWeight: themeContract.typography.fontWeight.medium,
+});
+
+export const collapsedIcon = style({
+ width: "0.75rem",
+ height: "0.75rem",
+ color: themeContract.colors.text.muted,
+});
+
+/**
+ * Header
+ */
+export const legendHeader = style({
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingLeft: themeContract.space[4],
+ paddingRight: themeContract.space[4],
+ paddingTop: themeContract.space[3],
+ paddingBottom: themeContract.space[3],
+ borderBottom: "1px solid rgba(71, 85, 105, 0.5)", // slate-600/50
+});
+
+export const legendTitle = style({
+ fontSize: themeContract.typography.fontSize.sm,
+ fontWeight: themeContract.typography.fontWeight.medium,
+ color: themeContract.colors.text.primary,
+});
+
+export const headerTrigger = style({
+ padding: themeContract.space[1],
+ borderRadius: themeContract.radii.sm,
+ transition: themeContract.transitions.normal,
+
+ selectors: {
+ "&:hover": {
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
+ },
+ },
+});
+
+export const headerIcon = style({
+ width: "1rem",
+ height: "1rem",
+ color: themeContract.colors.text.muted,
+});
+
+/**
+ * Content sections
+ */
+export const sectionsContainer = style({
+ fontSize: themeContract.typography.fontSize.xs,
+ color: themeContract.colors.text.secondary,
+ paddingLeft: themeContract.space[4],
+ paddingRight: themeContract.space[4],
+ paddingTop: themeContract.space[3],
+ paddingBottom: themeContract.space[3],
+});
+
+export const sectionWrapper = style({
+ marginTop: themeContract.space[3],
+ selectors: {
+ "&:first-child": {
+ marginTop: 0,
+ },
+ },
+});
+
+export const sectionTitle = style({
+ fontSize: themeContract.typography.fontSize.xs,
+ fontWeight: themeContract.typography.fontWeight.medium,
+ color: themeContract.colors.text.secondary,
+ textTransform: "uppercase",
+ letterSpacing: "0.05em",
+ marginBottom: themeContract.space[2],
+});
+
+export const itemsList = style({
+ display: "flex",
+ flexDirection: "column",
+ gap: "0.375rem", // gap-1.5
+});
+
+export const legendItem = style({
+ display: "flex",
+ alignItems: "center",
+ gap: themeContract.space[2],
+});
+
+export const legendIcon = style({
+ width: "0.75rem",
+ height: "0.75rem",
+ flexShrink: 0,
+});
+
+export const legendText = style({
+ fontSize: themeContract.typography.fontSize.xs,
+});
+
+/**
+ * Shape styles
+ */
+export const hexagon = style({
+ clipPath: "polygon(50% 0%, 93% 25%, 93% 75%, 50% 100%, 7% 75%, 7% 25%)",
+});
+
+export const documentNode = style({
+ width: "1rem",
+ height: "0.75rem",
+ background: "rgba(255, 255, 255, 0.08)",
+ border: "1px solid rgba(255, 255, 255, 0.25)",
+ borderRadius: themeContract.radii.sm,
+ flexShrink: 0,
+});
+
+export const memoryNode = style([
+ hexagon,
+ {
+ width: "0.75rem",
+ height: "0.75rem",
+ background: "rgba(147, 197, 253, 0.1)",
+ border: "1px solid rgba(147, 197, 253, 0.35)",
+ flexShrink: 0,
+ },
+]);
+
+export const memoryNodeOlder = style([
+ memoryNode,
+ {
+ opacity: 0.4,
+ },
+]);
+
+export const forgottenNode = style([
+ hexagon,
+ {
+ width: "0.75rem",
+ height: "0.75rem",
+ background: "rgba(239, 68, 68, 0.3)",
+ border: "1px solid rgba(239, 68, 68, 0.8)",
+ position: "relative",
+ flexShrink: 0,
+ },
+]);
+
+export const forgottenIcon = style({
+ position: "absolute",
+ inset: 0,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ color: "rgb(248, 113, 113)",
+ fontSize: themeContract.typography.fontSize.xs,
+ lineHeight: "1",
+});
+
+export const expiringNode = style([
+ hexagon,
+ {
+ width: "0.75rem",
+ height: "0.75rem",
+ background: "rgba(147, 197, 253, 0.1)",
+ border: "2px solid rgb(245, 158, 11)",
+ flexShrink: 0,
+ },
+]);
+
+export const newNode = style([
+ hexagon,
+ {
+ width: "0.75rem",
+ height: "0.75rem",
+ background: "rgba(147, 197, 253, 0.1)",
+ border: "2px solid rgb(16, 185, 129)",
+ position: "relative",
+ flexShrink: 0,
+ },
+]);
+
+export const newBadge = style({
+ position: "absolute",
+ top: "-0.25rem",
+ right: "-0.25rem",
+ width: "0.5rem",
+ height: "0.5rem",
+ backgroundColor: "rgb(16, 185, 129)",
+ borderRadius: themeContract.radii.full,
+});
+
+export const connectionLine = style({
+ width: "1rem",
+ height: 0,
+ borderTop: "1px solid rgb(148, 163, 184)",
+ flexShrink: 0,
+});
+
+export const similarityLine = style({
+ width: "1rem",
+ height: 0,
+ borderTop: "2px dashed rgb(148, 163, 184)",
+ flexShrink: 0,
+});
+
+export const relationLine = style({
+ width: "1rem",
+ height: 0,
+ borderTop: "2px solid",
+ flexShrink: 0,
+});
+
+export const weakSimilarity = style({
+ width: "0.75rem",
+ height: "0.75rem",
+ borderRadius: themeContract.radii.full,
+ background: "rgba(148, 163, 184, 0.2)",
+ flexShrink: 0,
+});
+
+export const strongSimilarity = style({
+ width: "0.75rem",
+ height: "0.75rem",
+ borderRadius: themeContract.radii.full,
+ background: "rgba(148, 163, 184, 0.6)",
+ flexShrink: 0,
+});
+
+export const gradientCircle = style({
+ width: "0.75rem",
+ height: "0.75rem",
+ background: "linear-gradient(to right, rgb(148, 163, 184), rgb(96, 165, 250))",
+ borderRadius: themeContract.radii.full,
+});
diff --git a/packages/memory-graph/src/components/legend.tsx b/packages/memory-graph/src/components/legend.tsx
new file mode 100644
index 00000000..16f588a9
--- /dev/null
+++ b/packages/memory-graph/src/components/legend.tsx
@@ -0,0 +1,275 @@
+"use client";
+
+import { useIsMobile } from "@/hooks/use-mobile";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/ui/collapsible";
+import { GlassMenuEffect } from "@/ui/glass-effect";
+import { Brain, ChevronDown, ChevronUp, FileText } from "lucide-react";
+import { memo, useEffect, useState } from "react";
+import { colors } from "@/constants";
+import type { GraphEdge, GraphNode, LegendProps } from "@/types";
+import * as styles from "./legend.css";
+
+// Cookie utility functions for legend state
+const setCookie = (name: string, value: string, days = 365) => {
+ if (typeof document === "undefined") return;
+ const expires = new Date();
+ expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
+ document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
+};
+
+const getCookie = (name: string): string | null => {
+ if (typeof document === "undefined") return null;
+ const nameEQ = `${name}=`;
+ const ca = document.cookie.split(";");
+ for (let i = 0; i < ca.length; i++) {
+ let c = ca[i];
+ if (!c) continue;
+ while (c.charAt(0) === " ") c = c.substring(1, c.length);
+ if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length);
+ }
+ return null;
+};
+
+interface ExtendedLegendProps extends LegendProps {
+ id?: string;
+ nodes?: GraphNode[];
+ edges?: GraphEdge[];
+ isLoading?: boolean;
+}
+
+export const Legend = memo(function Legend({
+ variant = "console",
+ id,
+ nodes = [],
+ edges = [],
+ isLoading = false,
+}: ExtendedLegendProps) {
+ const isMobile = useIsMobile();
+ const [isExpanded, setIsExpanded] = useState(true);
+ const [isInitialized, setIsInitialized] = useState(false);
+
+ // Load saved preference on client side
+ useEffect(() => {
+ if (!isInitialized) {
+ const savedState = getCookie("legendCollapsed");
+ if (savedState === "true") {
+ setIsExpanded(false);
+ } else if (savedState === "false") {
+ setIsExpanded(true);
+ } else {
+ // Default: collapsed on mobile, expanded on desktop
+ setIsExpanded(!isMobile);
+ }
+ setIsInitialized(true);
+ }
+ }, [isInitialized, isMobile]);
+
+ // Save to cookie when state changes
+ const handleToggleExpanded = (expanded: boolean) => {
+ setIsExpanded(expanded);
+ setCookie("legendCollapsed", expanded ? "false" : "true");
+ };
+
+ // Get container class based on variant and mobile state
+ const getContainerClass = () => {
+ if (variant === "console") {
+ return isMobile ? styles.legendContainer.consoleMobile : styles.legendContainer.consoleDesktop;
+ }
+ return isMobile ? styles.legendContainer.consumerMobile : styles.legendContainer.consumerDesktop;
+ };
+
+ // Calculate stats
+ const memoryCount = nodes.filter((n) => n.type === "memory").length;
+ const documentCount = nodes.filter((n) => n.type === "document").length;
+
+ const containerClass = isMobile && !isExpanded
+ ? `${getContainerClass()} ${styles.mobileSize.collapsed}`
+ : isMobile
+ ? `${getContainerClass()} ${styles.mobileSize.expanded}`
+ : getContainerClass();
+
+ return (
+ <div
+ className={containerClass}
+ id={id}
+ >
+ <Collapsible onOpenChange={handleToggleExpanded} open={isExpanded}>
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="xl" />
+
+ <div className={styles.legendContent}>
+ {/* Mobile and Desktop collapsed state */}
+ {!isExpanded && (
+ <CollapsibleTrigger className={styles.collapsedTrigger}>
+ <div className={styles.collapsedContent}>
+ <div className={styles.collapsedText}>?</div>
+ <ChevronUp className={styles.collapsedIcon} />
+ </div>
+ </CollapsibleTrigger>
+ )}
+
+ {/* Expanded state */}
+ {isExpanded && (
+ <>
+ {/* Header with toggle */}
+ <div className={styles.legendHeader}>
+ <div className={styles.legendTitle}>Legend</div>
+ <CollapsibleTrigger className={styles.headerTrigger}>
+ <ChevronDown className={styles.headerIcon} />
+ </CollapsibleTrigger>
+ </div>
+
+ <CollapsibleContent>
+ <div className={styles.sectionsContainer}>
+ {/* Stats Section */}
+ {!isLoading && (
+ <div className={styles.sectionWrapper}>
+ <div className={styles.sectionTitle}>
+ Statistics
+ </div>
+ <div className={styles.itemsList}>
+ <div className={styles.legendItem}>
+ <Brain className={styles.legendIcon} style={{ color: "rgb(96, 165, 250)" }} />
+ <span className={styles.legendText}>
+ {memoryCount} memories
+ </span>
+ </div>
+ <div className={styles.legendItem}>
+ <FileText className={styles.legendIcon} style={{ color: "rgb(203, 213, 225)" }} />
+ <span className={styles.legendText}>
+ {documentCount} documents
+ </span>
+ </div>
+ <div className={styles.legendItem}>
+ <div className={styles.gradientCircle} />
+ <span className={styles.legendText}>
+ {edges.length} connections
+ </span>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* Node Types */}
+ <div className={styles.sectionWrapper}>
+ <div className={styles.sectionTitle}>
+ Nodes
+ </div>
+ <div className={styles.itemsList}>
+ <div className={styles.legendItem}>
+ <div className={styles.documentNode} />
+ <span className={styles.legendText}>Document</span>
+ </div>
+ <div className={styles.legendItem}>
+ <div className={styles.memoryNode} />
+ <span className={styles.legendText}>Memory (latest)</span>
+ </div>
+ <div className={styles.legendItem}>
+ <div className={styles.memoryNodeOlder} />
+ <span className={styles.legendText}>Memory (older)</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Status Indicators */}
+ <div className={styles.sectionWrapper}>
+ <div className={styles.sectionTitle}>
+ Status
+ </div>
+ <div className={styles.itemsList}>
+ <div className={styles.legendItem}>
+ <div className={styles.forgottenNode}>
+ <div className={styles.forgottenIcon}>
+ ✕
+ </div>
+ </div>
+ <span className={styles.legendText}>Forgotten</span>
+ </div>
+ <div className={styles.legendItem}>
+ <div className={styles.expiringNode} />
+ <span className={styles.legendText}>Expiring soon</span>
+ </div>
+ <div className={styles.legendItem}>
+ <div className={styles.newNode}>
+ <div className={styles.newBadge} />
+ </div>
+ <span className={styles.legendText}>New memory</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Connection Types */}
+ <div className={styles.sectionWrapper}>
+ <div className={styles.sectionTitle}>
+ Connections
+ </div>
+ <div className={styles.itemsList}>
+ <div className={styles.legendItem}>
+ <div className={styles.connectionLine} />
+ <span className={styles.legendText}>Doc → Memory</span>
+ </div>
+ <div className={styles.legendItem}>
+ <div className={styles.similarityLine} />
+ <span className={styles.legendText}>Doc similarity</span>
+ </div>
+ </div>
+ </div>
+
+ {/* Relation Types */}
+ <div className={styles.sectionWrapper}>
+ <div className={styles.sectionTitle}>
+ Relations
+ </div>
+ <div className={styles.itemsList}>
+ {[
+ ["updates", colors.relations.updates],
+ ["extends", colors.relations.extends],
+ ["derives", colors.relations.derives],
+ ].map(([label, color]) => (
+ <div className={styles.legendItem} key={label}>
+ <div
+ className={styles.relationLine}
+ style={{ borderColor: color }}
+ />
+ <span
+ className={styles.legendText}
+ style={{ color: color, textTransform: "capitalize" }}
+ >
+ {label}
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ {/* Similarity Strength */}
+ <div className={styles.sectionWrapper}>
+ <div className={styles.sectionTitle}>
+ Similarity
+ </div>
+ <div className={styles.itemsList}>
+ <div className={styles.legendItem}>
+ <div className={styles.weakSimilarity} />
+ <span className={styles.legendText}>Weak</span>
+ </div>
+ <div className={styles.legendItem}>
+ <div className={styles.strongSimilarity} />
+ <span className={styles.legendText}>Strong</span>
+ </div>
+ </div>
+ </div>
+ </div>
+ </CollapsibleContent>
+ </>
+ )}
+ </div>
+ </Collapsible>
+ </div>
+ );
+});
+
+Legend.displayName = "Legend";
diff --git a/packages/memory-graph/src/components/loading-indicator.css.ts b/packages/memory-graph/src/components/loading-indicator.css.ts
new file mode 100644
index 00000000..09010f28
--- /dev/null
+++ b/packages/memory-graph/src/components/loading-indicator.css.ts
@@ -0,0 +1,55 @@
+import { style } from "@vanilla-extract/css";
+import { themeContract } from "../styles/theme.css";
+import { animations } from "../styles";
+
+/**
+ * Loading indicator container
+ * Positioned top-left, below spaces dropdown
+ */
+export const loadingContainer = style({
+ position: "absolute",
+ zIndex: 30, // High priority so it's visible when loading
+ borderRadius: themeContract.radii.xl,
+ overflow: "hidden",
+ top: "5.5rem", // Below spaces dropdown (~88px)
+ left: themeContract.space[4],
+});
+
+/**
+ * Content wrapper
+ */
+export const loadingContent = style({
+ position: "relative",
+ zIndex: 10,
+ color: themeContract.colors.text.secondary,
+ paddingLeft: themeContract.space[4],
+ paddingRight: themeContract.space[4],
+ paddingTop: themeContract.space[3],
+ paddingBottom: themeContract.space[3],
+});
+
+/**
+ * Flex container for icon and text
+ */
+export const loadingFlex = style({
+ display: "flex",
+ alignItems: "center",
+ gap: themeContract.space[2],
+});
+
+/**
+ * Spinning icon
+ */
+export const loadingIcon = style({
+ width: "1rem",
+ height: "1rem",
+ animation: `${animations.spin} 1s linear infinite`,
+ color: themeContract.colors.memory.border,
+});
+
+/**
+ * Loading text
+ */
+export const loadingText = style({
+ fontSize: themeContract.typography.fontSize.sm,
+});
diff --git a/packages/memory-graph/src/components/loading-indicator.tsx b/packages/memory-graph/src/components/loading-indicator.tsx
new file mode 100644
index 00000000..be31430b
--- /dev/null
+++ b/packages/memory-graph/src/components/loading-indicator.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { GlassMenuEffect } from "@/ui/glass-effect";
+import { Sparkles } from "lucide-react";
+import { memo } from "react";
+import type { LoadingIndicatorProps } from "@/types";
+import {
+ loadingContainer,
+ loadingContent,
+ loadingFlex,
+ loadingIcon,
+ loadingText,
+} from "./loading-indicator.css";
+
+export const LoadingIndicator = memo<LoadingIndicatorProps>(
+ ({ isLoading, isLoadingMore, totalLoaded, variant = "console" }) => {
+ if (!isLoading && !isLoadingMore) return null;
+
+ return (
+ <div className={loadingContainer}>
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="xl" />
+
+ <div className={loadingContent}>
+ <div className={loadingFlex}>
+ {/*@ts-ignore */}
+ <Sparkles className={loadingIcon} />
+ <span className={loadingText}>
+ {isLoading
+ ? "Loading memory graph..."
+ : `Loading more documents... (${totalLoaded})`}
+ </span>
+ </div>
+ </div>
+ </div>
+ );
+ },
+);
+
+LoadingIndicator.displayName = "LoadingIndicator";
diff --git a/packages/memory-graph/src/components/memory-graph-wrapper.tsx b/packages/memory-graph/src/components/memory-graph-wrapper.tsx
new file mode 100644
index 00000000..cfc8e148
--- /dev/null
+++ b/packages/memory-graph/src/components/memory-graph-wrapper.tsx
@@ -0,0 +1,198 @@
+"use client";
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { useEffect, useMemo, useRef } from "react";
+import {
+ flattenDocuments,
+ getLoadedCount,
+ getTotalDocuments,
+ useInfiniteDocumentsQuery,
+} from "@/hooks/use-documents-query";
+import { MemoryGraph } from "./memory-graph";
+import { defaultTheme } from "@/styles/theme.css";
+import type { ApiClientError } from "@/lib/api-client";
+
+export interface MemoryGraphWrapperProps {
+ /** API key for authentication */
+ apiKey: string;
+ /** Optional base URL for the API (defaults to https://api.supermemory.ai) */
+ baseUrl?: string;
+ /** Optional document ID to filter by */
+ id?: string;
+ /** Visual variant - console for full view, consumer for embedded */
+ variant?: "console" | "consumer";
+ /** Show/hide the spaces filter dropdown */
+ showSpacesSelector?: boolean;
+ /** Optional container tags to filter documents */
+ containerTags?: string[];
+ /** Callback when data fetching fails */
+ onError?: (error: ApiClientError) => void;
+ /** Callback when data is successfully loaded */
+ onSuccess?: (totalDocuments: number) => void;
+ /** Empty state content */
+ children?: React.ReactNode;
+ /** Documents to highlight */
+ highlightDocumentIds?: string[];
+ /** Whether highlights are visible */
+ highlightsVisible?: boolean;
+ /** Pixels occluded on the right side of the viewport */
+ occludedRightPx?: number;
+}
+
+/**
+ * Internal component that uses the query hooks
+ */
+function MemoryGraphWithQuery(props: MemoryGraphWrapperProps) {
+ const {
+ apiKey,
+ baseUrl,
+ containerTags,
+ variant = "console",
+ showSpacesSelector,
+ onError,
+ onSuccess,
+ children,
+ highlightDocumentIds,
+ highlightsVisible,
+ occludedRightPx,
+ } = props;
+
+ // Derive showSpacesSelector from variant if not explicitly provided
+ // console variant shows spaces selector, consumer variant hides it
+ const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console");
+
+ // Use infinite query for automatic pagination
+ const {
+ data,
+ isLoading,
+ isFetchingNextPage,
+ hasNextPage,
+ fetchNextPage,
+ error,
+ } = useInfiniteDocumentsQuery({
+ apiKey,
+ baseUrl,
+ containerTags,
+ enabled: !!apiKey,
+ });
+
+ // Flatten documents from all pages
+ const documents = useMemo(() => flattenDocuments(data), [data]);
+ const totalLoaded = useMemo(() => getLoadedCount(data), [data]);
+ const totalDocuments = useMemo(() => getTotalDocuments(data), [data]);
+
+ // Eagerly load all pages to ensure complete graph data
+ const isLoadingAllPages = useRef(false);
+
+ useEffect(() => {
+ // Only start loading once, when initial data is loaded
+ if (isLoading || isLoadingAllPages.current || !data?.pages?.[0]) return;
+
+ const abortController = new AbortController();
+
+ // Start recursive page loading
+ const loadAllPages = async () => {
+ isLoadingAllPages.current = true;
+
+ try {
+ // Keep fetching until no more pages or aborted
+ let shouldContinue = hasNextPage;
+
+ while (shouldContinue && !abortController.signal.aborted) {
+ const result = await fetchNextPage();
+ shouldContinue = result.hasNextPage ?? false;
+
+ // Throttle requests to avoid overwhelming server (50ms delay like console app)
+ if (shouldContinue && !abortController.signal.aborted) {
+ await new Promise(resolve => setTimeout(resolve, 50));
+ }
+ }
+ } catch (error) {
+ if (!abortController.signal.aborted) {
+ console.error('[MemoryGraph] Error loading pages:', error);
+ }
+ }
+ };
+
+ if (hasNextPage) {
+ loadAllPages();
+ }
+
+ // Cleanup on unmount
+ return () => {
+ abortController.abort();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []); // Only run once on mount
+
+ // Call callbacks
+ if (error && onError) {
+ onError(error as ApiClientError);
+ }
+
+ if (data && onSuccess && totalDocuments > 0) {
+ onSuccess(totalDocuments);
+ }
+
+ // Load more function
+ const loadMoreDocuments = async () => {
+ if (hasNextPage && !isFetchingNextPage) {
+ await fetchNextPage();
+ }
+ };
+
+ return (
+ <MemoryGraph
+ documents={documents}
+ isLoading={isLoading}
+ isLoadingMore={isFetchingNextPage}
+ error={error as Error | null}
+ totalLoaded={totalLoaded}
+ hasMore={hasNextPage ?? false}
+ loadMoreDocuments={loadMoreDocuments}
+ variant={variant}
+ showSpacesSelector={finalShowSpacesSelector}
+ highlightDocumentIds={highlightDocumentIds}
+ highlightsVisible={highlightsVisible}
+ occludedRightPx={occludedRightPx}
+ autoLoadOnViewport={true}
+ themeClassName={defaultTheme}
+ >
+ {children}
+ </MemoryGraph>
+ );
+}
+
+// Create a default query client for the wrapper
+const defaultQueryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ refetchOnWindowFocus: false,
+ refetchOnMount: false,
+ retry: 2,
+ },
+ },
+});
+
+/**
+ * MemoryGraph component with built-in data fetching
+ *
+ * This component handles all data fetching internally using the provided API key.
+ * Simply pass your API key and it will fetch and render the graph automatically.
+ *
+ * @example
+ * ```tsx
+ * <MemoryGraphWrapper
+ * apiKey="your-api-key"
+ * variant="console"
+ * onError={(error) => console.error(error)}
+ * />
+ * ```
+ */
+export function MemoryGraphWrapper(props: MemoryGraphWrapperProps) {
+ return (
+ <QueryClientProvider client={defaultQueryClient}>
+ <MemoryGraphWithQuery {...props} />
+ </QueryClientProvider>
+ );
+}
diff --git a/packages/memory-graph/src/components/memory-graph.css.ts b/packages/memory-graph/src/components/memory-graph.css.ts
new file mode 100644
index 00000000..f5b38273
--- /dev/null
+++ b/packages/memory-graph/src/components/memory-graph.css.ts
@@ -0,0 +1,75 @@
+import { style } from "@vanilla-extract/css";
+import { themeContract } from "../styles/theme.css";
+
+/**
+ * Error state container
+ */
+export const errorContainer = style({
+ height: "100%",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ backgroundColor: themeContract.colors.background.primary,
+});
+
+export const errorCard = style({
+ borderRadius: themeContract.radii.xl,
+ overflow: "hidden",
+});
+
+export const errorContent = style({
+ position: "relative",
+ zIndex: 10,
+ color: themeContract.colors.text.secondary,
+ paddingLeft: themeContract.space[6],
+ paddingRight: themeContract.space[6],
+ paddingTop: themeContract.space[4],
+ paddingBottom: themeContract.space[4],
+});
+
+/**
+ * Main graph container
+ * Position relative so absolutely positioned children position relative to this container
+ */
+export const mainContainer = style({
+ position: "relative",
+ height: "100%",
+ borderRadius: themeContract.radii.xl,
+ overflow: "hidden",
+ backgroundColor: themeContract.colors.background.primary,
+});
+
+/**
+ * Spaces selector positioning
+ * Top-left corner, below most overlays
+ */
+export const spacesSelectorContainer = style({
+ position: "absolute",
+ top: themeContract.space[4],
+ left: themeContract.space[4],
+ zIndex: 15, // Above base elements, below loading/panels
+});
+
+/**
+ * Graph canvas container
+ */
+export const graphContainer = style({
+ width: "100%",
+ height: "100%",
+ position: "relative",
+ overflow: "hidden",
+ touchAction: "none",
+ userSelect: "none",
+ WebkitUserSelect: "none",
+});
+
+/**
+ * Navigation controls positioning
+ * Bottom-left corner
+ */
+export const navControlsContainer = style({
+ position: "absolute",
+ bottom: themeContract.space[4],
+ left: themeContract.space[4],
+ zIndex: 15, // Same level as spaces dropdown
+});
diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx
new file mode 100644
index 00000000..3eeed37b
--- /dev/null
+++ b/packages/memory-graph/src/components/memory-graph.tsx
@@ -0,0 +1,448 @@
+"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 { 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 type { MemoryGraphProps } from "@/types";
+
+export const MemoryGraph = ({
+ children,
+ documents,
+ isLoading,
+ isLoadingMore,
+ error,
+ totalLoaded,
+ hasMore,
+ loadMoreDocuments,
+ showSpacesSelector,
+ variant = "console",
+ legendId,
+ highlightDocumentIds = [],
+ highlightsVisible = true,
+ occludedRightPx = 0,
+ autoLoadOnViewport = true,
+ themeClassName,
+}: MemoryGraphProps) => {
+ // Derive showSpacesSelector from variant if not explicitly provided
+ // console variant shows spaces selector, consumer variant hides it
+ const finalShowSpacesSelector = showSpacesSelector ?? (variant === "console");
+
+ const [selectedSpace, setSelectedSpace] = useState<string>("all");
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
+ const containerRef = useRef<HTMLDivElement>(null);
+
+ // Create data object with pagination to satisfy type requirements
+ const data = useMemo(() => {
+ return documents && documents.length > 0
+ ? {
+ documents,
+ pagination: {
+ currentPage: 1,
+ limit: documents.length,
+ totalItems: documents.length,
+ totalPages: 1,
+ },
+ }
+ : null;
+ }, [documents]);
+
+ // Graph interactions with variant-specific settings
+ const {
+ panX,
+ panY,
+ zoom,
+ /** hoveredNode currently unused within this component */
+ hoveredNode: _hoveredNode,
+ selectedNode,
+ draggingNodeId,
+ nodePositions,
+ handlePanStart,
+ handlePanMove,
+ handlePanEnd,
+ handleWheel,
+ handleNodeHover,
+ handleNodeClick,
+ handleNodeDragStart,
+ handleNodeDragMove,
+ handleNodeDragEnd,
+ handleDoubleClick,
+ handleTouchStart,
+ handleTouchMove,
+ handleTouchEnd,
+ setSelectedNode,
+ autoFitToViewport,
+ centerViewportOn,
+ zoomIn,
+ zoomOut,
+ } = useGraphInteractions(variant);
+
+ // Graph data
+ const { nodes, edges } = useGraphData(
+ data,
+ selectedSpace,
+ nodePositions,
+ draggingNodeId,
+ );
+
+ // Auto-fit once per unique highlight set to show the full graph for context
+ const lastFittedHighlightKeyRef = useRef<string>("");
+ useEffect(() => {
+ const highlightKey = highlightsVisible
+ ? highlightDocumentIds.join("|")
+ : "";
+ if (
+ highlightKey &&
+ highlightKey !== lastFittedHighlightKeyRef.current &&
+ containerSize.width > 0 &&
+ containerSize.height > 0 &&
+ nodes.length > 0
+ ) {
+ autoFitToViewport(nodes, containerSize.width, containerSize.height, {
+ occludedRightPx,
+ animate: true,
+ });
+ lastFittedHighlightKeyRef.current = highlightKey;
+ }
+ }, [
+ highlightsVisible,
+ highlightDocumentIds,
+ containerSize.width,
+ containerSize.height,
+ nodes.length,
+ occludedRightPx,
+ autoFitToViewport,
+ ]);
+
+ // Auto-fit graph when component mounts or nodes change significantly
+ const hasAutoFittedRef = useRef(false);
+ useEffect(() => {
+ // Only auto-fit once when we have nodes and container size
+ if (
+ !hasAutoFittedRef.current &&
+ nodes.length > 0 &&
+ containerSize.width > 0 &&
+ containerSize.height > 0
+ ) {
+ // 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);
+ }
+ }, [
+ 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;
+ }
+ }, [nodes.length]);
+
+ // Extract unique spaces from memories and calculate counts
+ const { availableSpaces, spaceMemoryCounts } = useMemo(() => {
+ if (!data?.documents) return { availableSpaces: [], spaceMemoryCounts: {} };
+
+ 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;
+ });
+ });
+
+ return {
+ availableSpaces: Array.from(spaceSet).sort(),
+ spaceMemoryCounts: counts,
+ };
+ }, [data]);
+
+ // Handle container resize
+ useEffect(() => {
+ const updateSize = () => {
+ if (containerRef.current) {
+ 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 prev;
+ });
+ }
+ };
+
+ // Use a slight delay to ensure DOM is fully rendered
+ 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);
+ if (containerRef.current) {
+ resizeObserver.observe(containerRef.current);
+ }
+
+ return () => {
+ 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, nodes],
+ );
+
+ // Navigation callbacks
+ const handleCenter = useCallback(() => {
+ if (nodes.length > 0) {
+ // Calculate center of all nodes
+ 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)
+ }
+ }
+ }, [nodes, centerViewportOn, containerSize.width, containerSize.height])
+
+ const handleAutoFit = useCallback(() => {
+ 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])
+
+ // Get selected node data
+ const selectedNodeData = useMemo(() => {
+ 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(() => {
+ if (
+ isLoadingMore ||
+ !hasMore ||
+ !data?.documents ||
+ data.documents.length === 0
+ )
+ return;
+
+ // Calculate viewport bounds
+ const viewportBounds = {
+ left: -panX / zoom - 200,
+ 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;
+ if (visibilityRatio >= 0.8) {
+ loadMoreDocuments();
+ }
+ }, [
+ isLoadingMore,
+ hasMore,
+ data,
+ panX,
+ panY,
+ zoom,
+ containerSize.width,
+ containerSize.height,
+ nodes,
+ loadMoreDocuments,
+ ]);
+
+ // Throttled version to avoid excessive checks
+ const lastLoadCheckRef = useRef(0);
+ const throttledCheckAndLoadMore = useCallback(() => {
+ const now = Date.now();
+ if (now - lastLoadCheckRef.current > 1000) {
+ // Check at most once per second
+ lastLoadCheckRef.current = now;
+ checkAndLoadMore();
+ }
+ }, [checkAndLoadMore]);
+
+ // Monitor viewport changes to trigger loading
+ useEffect(() => {
+ if (!autoLoadOnViewport) return;
+ throttledCheckAndLoadMore();
+ }, [throttledCheckAndLoadMore, autoLoadOnViewport]);
+
+ // Initial load trigger when graph is first rendered
+ useEffect(() => {
+ 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
+ }
+ }, [data, hasMore, throttledCheckAndLoadMore, autoLoadOnViewport]);
+
+ if (error) {
+ return (
+ <div className={styles.errorContainer}>
+ <div className={styles.errorCard}>
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="xl" />
+
+ <div className={styles.errorContent}>
+ Error loading documents: {error.message}
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className={themeClassName ? `${themeClassName} ${styles.mainContainer}` : styles.mainContainer}>
+ {/* Spaces selector - only shown for console */}
+ {finalShowSpacesSelector && availableSpaces.length > 0 && (
+ <div className={styles.spacesSelectorContainer}>
+ <SpacesDropdown
+ availableSpaces={availableSpaces}
+ onSpaceChange={setSelectedSpace}
+ selectedSpace={selectedSpace}
+ spaceMemoryCounts={spaceMemoryCounts}
+ />
+ </div>
+ )}
+
+ {/* Loading indicator */}
+ <LoadingIndicator
+ isLoading={isLoading}
+ isLoadingMore={isLoadingMore}
+ totalLoaded={totalLoaded}
+ variant={variant}
+ />
+
+ {/* Legend */}
+ <Legend
+ edges={edges}
+ id={legendId}
+ isLoading={isLoading}
+ nodes={nodes}
+ variant={variant}
+ />
+
+ {/* Node detail panel */}
+ <AnimatePresence>
+ {selectedNodeData && (
+ <NodeDetailPanel
+ node={selectedNodeData}
+ onClose={() => setSelectedNode(null)}
+ variant={variant}
+ />
+ )}
+ </AnimatePresence>
+
+ {/* Show welcome screen when no memories exist */}
+ {!isLoading &&
+ (!data || nodes.filter((n) => n.type === "document").length === 0) && (
+ <>{children}</>
+ )}
+
+ {/* Graph container */}
+ <div
+ className={styles.graphContainer}
+ ref={containerRef}
+ >
+ {(containerSize.width > 0 && containerSize.height > 0) && (
+ <GraphCanvas
+ draggingNodeId={draggingNodeId}
+ edges={edges}
+ height={containerSize.height}
+ nodes={nodes}
+ highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
+ onDoubleClick={handleDoubleClick}
+ onNodeClick={handleNodeClick}
+ onNodeDragEnd={handleNodeDragEnd}
+ onNodeDragMove={handleNodeDragMove}
+ onNodeDragStart={handleNodeDragStartWithNodes}
+ onNodeHover={handleNodeHover}
+ onPanEnd={handlePanEnd}
+ onPanMove={handlePanMove}
+ onPanStart={handlePanStart}
+ onTouchStart={handleTouchStart}
+ onTouchMove={handleTouchMove}
+ onTouchEnd={handleTouchEnd}
+ onWheel={handleWheel}
+ panX={panX}
+ panY={panY}
+ width={containerSize.width}
+ zoom={zoom}
+ />
+ )}
+
+ {/* Navigation controls */}
+ {containerSize.width > 0 && (
+ <NavigationControls
+ onCenter={handleCenter}
+ onZoomIn={() => zoomIn(containerSize.width / 2, containerSize.height / 2)}
+ onZoomOut={() => zoomOut(containerSize.width / 2, containerSize.height / 2)}
+ onAutoFit={handleAutoFit}
+ nodes={nodes}
+ className={styles.navControlsContainer}
+ />
+ )}
+ </div>
+ </div>
+ );
+};
diff --git a/packages/memory-graph/src/components/navigation-controls.css.ts b/packages/memory-graph/src/components/navigation-controls.css.ts
new file mode 100644
index 00000000..3a4094bd
--- /dev/null
+++ b/packages/memory-graph/src/components/navigation-controls.css.ts
@@ -0,0 +1,77 @@
+import { style } from "@vanilla-extract/css";
+import { themeContract } from "../styles/theme.css";
+
+/**
+ * Navigation controls container
+ */
+export const navContainer = style({
+ display: "flex",
+ flexDirection: "column",
+ gap: themeContract.space[1],
+});
+
+/**
+ * Base button styles for navigation controls
+ */
+const navButtonBase = style({
+ backgroundColor: "rgba(0, 0, 0, 0.2)",
+ backdropFilter: "blur(8px)",
+ WebkitBackdropFilter: "blur(8px)",
+ border: `1px solid rgba(255, 255, 255, 0.1)`,
+ borderRadius: themeContract.radii.lg,
+ padding: themeContract.space[2],
+ color: "rgba(255, 255, 255, 0.7)",
+ fontSize: themeContract.typography.fontSize.xs,
+ fontWeight: themeContract.typography.fontWeight.medium,
+ minWidth: "64px",
+ cursor: "pointer",
+ transition: themeContract.transitions.normal,
+
+ selectors: {
+ "&:hover": {
+ backgroundColor: "rgba(0, 0, 0, 0.3)",
+ borderColor: "rgba(255, 255, 255, 0.2)",
+ color: "rgba(255, 255, 255, 1)",
+ },
+ },
+});
+
+/**
+ * Standard navigation button
+ */
+export const navButton = navButtonBase;
+
+/**
+ * Zoom controls container
+ */
+export const zoomContainer = style({
+ display: "flex",
+ flexDirection: "column",
+});
+
+/**
+ * Zoom in button (top rounded)
+ */
+export const zoomInButton = style([
+ navButtonBase,
+ {
+ borderTopLeftRadius: themeContract.radii.lg,
+ borderTopRightRadius: themeContract.radii.lg,
+ borderBottomLeftRadius: 0,
+ borderBottomRightRadius: 0,
+ borderBottom: 0,
+ },
+]);
+
+/**
+ * Zoom out button (bottom rounded)
+ */
+export const zoomOutButton = style([
+ navButtonBase,
+ {
+ borderTopLeftRadius: 0,
+ borderTopRightRadius: 0,
+ borderBottomLeftRadius: themeContract.radii.lg,
+ borderBottomRightRadius: themeContract.radii.lg,
+ },
+]);
diff --git a/packages/memory-graph/src/components/navigation-controls.tsx b/packages/memory-graph/src/components/navigation-controls.tsx
new file mode 100644
index 00000000..19caa888
--- /dev/null
+++ b/packages/memory-graph/src/components/navigation-controls.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { memo } from "react";
+import type { GraphNode } from "@/types";
+import {
+ navContainer,
+ navButton,
+ zoomContainer,
+ zoomInButton,
+ zoomOutButton,
+} from "./navigation-controls.css";
+
+interface NavigationControlsProps {
+ onCenter: () => void;
+ onZoomIn: () => void;
+ onZoomOut: () => void;
+ onAutoFit: () => void;
+ nodes: GraphNode[];
+ className?: string;
+}
+
+export const NavigationControls = memo<NavigationControlsProps>(
+ ({ onCenter, onZoomIn, onZoomOut, onAutoFit, nodes, className = "" }) => {
+ if (nodes.length === 0) {
+ return null;
+ }
+
+ const containerClassName = className
+ ? `${navContainer} ${className}`
+ : navContainer;
+
+ return (
+ <div className={containerClassName}>
+ <button
+ type="button"
+ onClick={onAutoFit}
+ className={navButton}
+ title="Auto-fit graph to viewport"
+ >
+ Fit
+ </button>
+ <button
+ type="button"
+ onClick={onCenter}
+ className={navButton}
+ title="Center view on graph"
+ >
+ Center
+ </button>
+ <div className={zoomContainer}>
+ <button
+ type="button"
+ onClick={onZoomIn}
+ className={zoomInButton}
+ title="Zoom in"
+ >
+ +
+ </button>
+ <button
+ type="button"
+ onClick={onZoomOut}
+ className={zoomOutButton}
+ title="Zoom out"
+ >
+ −
+ </button>
+ </div>
+ </div>
+ );
+ },
+);
+
+NavigationControls.displayName = "NavigationControls";
diff --git a/packages/memory-graph/src/components/node-detail-panel.css.ts b/packages/memory-graph/src/components/node-detail-panel.css.ts
new file mode 100644
index 00000000..a3c30e06
--- /dev/null
+++ b/packages/memory-graph/src/components/node-detail-panel.css.ts
@@ -0,0 +1,170 @@
+import { style } from "@vanilla-extract/css";
+import { themeContract } from "../styles/theme.css";
+
+/**
+ * Main container (positioned absolutely)
+ * Highest z-index so it appears above everything when open
+ */
+export const container = style({
+ position: "absolute",
+ width: "20rem", // w-80 = 320px = 20rem
+ borderRadius: themeContract.radii.xl,
+ overflow: "hidden",
+ zIndex: 40, // Highest priority - always on top when open
+ maxHeight: "calc(100vh - 2rem)", // Leave some breathing room
+ top: themeContract.space[4],
+ right: themeContract.space[4],
+
+ // Add shadow for depth
+ boxShadow: "0 20px 25px -5px rgb(0 0 0 / 0.3), 0 8px 10px -6px rgb(0 0 0 / 0.3)",
+});
+
+/**
+ * Content wrapper with scrolling
+ */
+export const content = style({
+ position: "relative",
+ zIndex: 10,
+ padding: themeContract.space[4],
+ overflowY: "auto",
+ maxHeight: "80vh",
+});
+
+/**
+ * Header section
+ */
+export const header = style({
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ marginBottom: themeContract.space[3],
+});
+
+export const headerLeft = style({
+ display: "flex",
+ alignItems: "center",
+ gap: themeContract.space[2],
+});
+
+export const headerIcon = style({
+ width: "1.25rem",
+ height: "1.25rem",
+ color: themeContract.colors.text.secondary,
+});
+
+export const headerIconMemory = style({
+ width: "1.25rem",
+ height: "1.25rem",
+ color: "rgb(96, 165, 250)", // blue-400
+});
+
+export const closeButton = style({
+ height: "32px",
+ width: "32px",
+ padding: 0,
+ color: themeContract.colors.text.secondary,
+
+ selectors: {
+ "&:hover": {
+ color: themeContract.colors.text.primary,
+ },
+ },
+});
+
+export const closeIcon = style({
+ width: "1rem",
+ height: "1rem",
+});
+
+/**
+ * Content sections
+ */
+export const sections = style({
+ display: "flex",
+ flexDirection: "column",
+ gap: themeContract.space[3],
+});
+
+export const section = style({});
+
+export const sectionLabel = style({
+ fontSize: themeContract.typography.fontSize.xs,
+ color: themeContract.colors.text.muted,
+ textTransform: "uppercase",
+ letterSpacing: "0.05em",
+});
+
+export const sectionValue = style({
+ fontSize: themeContract.typography.fontSize.sm,
+ color: themeContract.colors.text.secondary,
+ marginTop: themeContract.space[1],
+});
+
+export const sectionValueTruncated = style({
+ fontSize: themeContract.typography.fontSize.sm,
+ color: themeContract.colors.text.secondary,
+ marginTop: themeContract.space[1],
+ overflow: "hidden",
+ display: "-webkit-box",
+ WebkitLineClamp: 3,
+ WebkitBoxOrient: "vertical",
+});
+
+export const link = style({
+ fontSize: themeContract.typography.fontSize.sm,
+ color: "rgb(129, 140, 248)", // indigo-400
+ marginTop: themeContract.space[1],
+ display: "flex",
+ alignItems: "center",
+ gap: themeContract.space[1],
+ textDecoration: "none",
+ transition: themeContract.transitions.normal,
+
+ selectors: {
+ "&:hover": {
+ color: "rgb(165, 180, 252)", // indigo-300
+ },
+ },
+});
+
+export const linkIcon = style({
+ width: "0.75rem",
+ height: "0.75rem",
+});
+
+export const badge = style({
+ marginTop: themeContract.space[2],
+});
+
+export const expiryText = style({
+ fontSize: themeContract.typography.fontSize.xs,
+ color: themeContract.colors.text.muted,
+ marginTop: themeContract.space[1],
+});
+
+/**
+ * Footer section (metadata)
+ */
+export const footer = style({
+ paddingTop: themeContract.space[2],
+ borderTop: "1px solid rgba(71, 85, 105, 0.5)", // slate-700/50
+});
+
+export const metadata = style({
+ display: "flex",
+ alignItems: "center",
+ gap: themeContract.space[4],
+ fontSize: themeContract.typography.fontSize.xs,
+ color: themeContract.colors.text.muted,
+});
+
+export const metadataItem = style({
+ display: "flex",
+ alignItems: "center",
+ gap: themeContract.space[1],
+});
+
+export const metadataIcon = style({
+ width: "0.75rem",
+ height: "0.75rem",
+});
diff --git a/packages/memory-graph/src/components/node-detail-panel.tsx b/packages/memory-graph/src/components/node-detail-panel.tsx
new file mode 100644
index 00000000..e2ae0133
--- /dev/null
+++ b/packages/memory-graph/src/components/node-detail-panel.tsx
@@ -0,0 +1,266 @@
+"use client";
+
+import { Badge } from "@/ui/badge";
+import { Button } from "@/ui/button";
+import { GlassMenuEffect } from "@/ui/glass-effect";
+import { Brain, Calendar, ExternalLink, FileText, Hash, X } from "lucide-react";
+import { motion } from "motion/react";
+import { memo } from "react";
+import {
+ GoogleDocs,
+ GoogleDrive,
+ GoogleSheets,
+ GoogleSlides,
+ MicrosoftExcel,
+ MicrosoftOneNote,
+ MicrosoftPowerpoint,
+ MicrosoftWord,
+ NotionDoc,
+ OneDrive,
+ PDF,
+} from "@/assets/icons";
+import { HeadingH3Bold } from "@/ui/heading";
+import type {
+ DocumentWithMemories,
+ MemoryEntry,
+} from "@/types";
+import type { NodeDetailPanelProps } from "@/types";
+import * as styles from "./node-detail-panel.css";
+
+const formatDocumentType = (type: string) => {
+ // Special case for PDF
+ if (type.toLowerCase() === "pdf") return "PDF";
+
+ // Replace underscores with spaces and capitalize each word
+ return type
+ .split("_")
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
+ .join(" ");
+};
+
+const getDocumentIcon = (type: string) => {
+ const iconProps = { className: "w-5 h-5 text-slate-300" };
+
+ switch (type) {
+ case "google_doc":
+ return <GoogleDocs {...iconProps} />;
+ case "google_sheet":
+ return <GoogleSheets {...iconProps} />;
+ case "google_slide":
+ return <GoogleSlides {...iconProps} />;
+ case "google_drive":
+ return <GoogleDrive {...iconProps} />;
+ case "notion":
+ case "notion_doc":
+ return <NotionDoc {...iconProps} />;
+ case "word":
+ case "microsoft_word":
+ return <MicrosoftWord {...iconProps} />;
+ case "excel":
+ case "microsoft_excel":
+ return <MicrosoftExcel {...iconProps} />;
+ case "powerpoint":
+ case "microsoft_powerpoint":
+ return <MicrosoftPowerpoint {...iconProps} />;
+ case "onenote":
+ case "microsoft_onenote":
+ return <MicrosoftOneNote {...iconProps} />;
+ case "onedrive":
+ return <OneDrive {...iconProps} />;
+ case "pdf":
+ return <PDF {...iconProps} />;
+ default:
+ {/*@ts-ignore */}
+ return <FileText {...iconProps} />;
+ }
+};
+
+export const NodeDetailPanel = memo(
+ function NodeDetailPanel({ node, onClose, variant = "console" }: NodeDetailPanelProps) {
+ if (!node) return null;
+
+ const isDocument = node.type === "document";
+ const data = node.data;
+
+ return (
+ <motion.div
+ animate={{ opacity: 1 }}
+ className={styles.container}
+ exit={{ opacity: 0 }}
+ initial={{ opacity: 0 }}
+ transition={{
+ duration: 0.2,
+ ease: "easeInOut",
+ }}
+ >
+ {/* Glass effect background */}
+ <GlassMenuEffect rounded="xl" />
+
+ <motion.div
+ animate={{ opacity: 1 }}
+ className={styles.content}
+ initial={{ opacity: 0 }}
+ transition={{ delay: 0.05, duration: 0.15 }}
+ >
+ <div className={styles.header}>
+ <div className={styles.headerLeft}>
+ {isDocument ? (
+ getDocumentIcon((data as DocumentWithMemories).type ?? "")
+ ) : (
+ // @ts-ignore
+ <Brain className={styles.headerIconMemory} />
+ )}
+ <HeadingH3Bold>
+ {isDocument ? "Document" : "Memory"}
+ </HeadingH3Bold>
+ </div>
+ <motion.div whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.9 }}>
+ <Button
+ className={styles.closeButton}
+ onClick={onClose}
+ size="sm"
+ variant="ghost"
+ >
+ {/* @ts-ignore */}
+ <X className={styles.closeIcon} />
+ </Button>
+ </motion.div>
+ </div>
+
+ <div className={styles.sections}>
+ {isDocument ? (
+ <>
+ <div className={styles.section}>
+ <span className={styles.sectionLabel}>
+ Title
+ </span>
+ <p className={styles.sectionValue}>
+ {(data as DocumentWithMemories).title ||
+ "Untitled Document"}
+ </p>
+ </div>
+
+ {(data as DocumentWithMemories).summary && (
+ <div className={styles.section}>
+ <span className={styles.sectionLabel}>
+ Summary
+ </span>
+ <p className={styles.sectionValueTruncated}>
+ {(data as DocumentWithMemories).summary}
+ </p>
+ </div>
+ )}
+
+ <div className={styles.section}>
+ <span className={styles.sectionLabel}>
+ Type
+ </span>
+ <p className={styles.sectionValue}>
+ {formatDocumentType((data as DocumentWithMemories).type ?? "")}
+ </p>
+ </div>
+
+ <div className={styles.section}>
+ <span className={styles.sectionLabel}>
+ Memory Count
+ </span>
+ <p className={styles.sectionValue}>
+ {(data as DocumentWithMemories).memoryEntries.length}{" "}
+ memories
+ </p>
+ </div>
+
+ {((data as DocumentWithMemories).url ||
+ (data as DocumentWithMemories).customId) && (
+ <div className={styles.section}>
+ <span className={styles.sectionLabel}>
+ URL
+ </span>
+ <a
+ className={styles.link}
+ href={(() => {
+ const doc = data as DocumentWithMemories;
+ if (doc.type === "google_doc" && doc.customId) {
+ return `https://docs.google.com/document/d/${doc.customId}`;
+ }
+ if (doc.type === "google_sheet" && doc.customId) {
+ return `https://docs.google.com/spreadsheets/d/${doc.customId}`;
+ }
+ if (doc.type === "google_slide" && doc.customId) {
+ return `https://docs.google.com/presentation/d/${doc.customId}`;
+ }
+ return doc.url ?? undefined;
+ })()}
+ rel="noopener noreferrer"
+ target="_blank"
+ >
+ {/* @ts-ignore */}
+ <ExternalLink className={styles.linkIcon} />
+ View Document
+ </a>
+ </div>
+ )}
+ </>
+ ) : (
+ <>
+ <div className={styles.section}>
+ <span className={styles.sectionLabel}>
+ Memory
+ </span>
+ <p className={styles.sectionValue}>
+ {(data as MemoryEntry).memory}
+ </p>
+ {(data as MemoryEntry).isForgotten && (
+ <Badge className={styles.badge} variant="destructive">
+ Forgotten
+ </Badge>
+ )}
+ {(data as MemoryEntry).forgetAfter && (
+ <p className={styles.expiryText}>
+ Expires:{" "}
+ {(data as MemoryEntry).forgetAfter
+ ? new Date(
+ (data as MemoryEntry).forgetAfter!,
+ ).toLocaleDateString()
+ : ""}{" "}
+ {("forgetReason" in data &&
+ (data as any).forgetReason
+ ? `- ${(data as any).forgetReason}`
+ : null)}
+ </p>
+ )}
+ </div>
+
+ <div className={styles.section}>
+ <span className={styles.sectionLabel}>
+ Space
+ </span>
+ <p className={styles.sectionValue}>
+ {(data as MemoryEntry).spaceId || "Default"}
+ </p>
+ </div>
+ </>
+ )}
+
+ <div className={styles.footer}>
+ <div className={styles.metadata}>
+ <span className={styles.metadataItem}>
+ {/* @ts-ignore */}
+ <Calendar className={styles.metadataIcon} />
+ {new Date(data.createdAt).toLocaleDateString()}
+ </span>
+ <span className={styles.metadataItem}>
+ {/* @ts-ignore */}
+ <Hash className={styles.metadataIcon} />
+ {node.id}
+ </span>
+ </div>
+ </div>
+ </div>
+ </motion.div>
+ </motion.div>
+ );
+ },
+);
+
+NodeDetailPanel.displayName = "NodeDetailPanel";
diff --git a/packages/memory-graph/src/components/spaces-dropdown.css.ts b/packages/memory-graph/src/components/spaces-dropdown.css.ts
new file mode 100644
index 00000000..d7af2258
--- /dev/null
+++ b/packages/memory-graph/src/components/spaces-dropdown.css.ts
@@ -0,0 +1,158 @@
+import { style } from "@vanilla-extract/css";
+import { themeContract } from "../styles/theme.css";
+
+/**
+ * Dropdown container
+ */
+export const container = style({
+ position: "relative",
+});
+
+/**
+ * Main trigger button with gradient border effect
+ */
+export const trigger = style({
+ display: "flex",
+ alignItems: "center",
+ gap: themeContract.space[3],
+ paddingLeft: themeContract.space[4],
+ paddingRight: themeContract.space[4],
+ paddingTop: themeContract.space[3],
+ paddingBottom: themeContract.space[3],
+ borderRadius: themeContract.radii.xl,
+ border: "2px solid transparent",
+ backgroundImage:
+ "linear-gradient(#1a1f29, #1a1f29), linear-gradient(150.262deg, #A4E8F5 0%, #267FFA 26%, #464646 49%, #747474 70%, #A4E8F5 100%)",
+ backgroundOrigin: "border-box",
+ backgroundClip: "padding-box, border-box",
+ boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.15)",
+ backdropFilter: "blur(12px)",
+ WebkitBackdropFilter: "blur(12px)",
+ transition: themeContract.transitions.normal,
+ cursor: "pointer",
+ minWidth: "15rem", // min-w-60 = 240px = 15rem
+
+ selectors: {
+ "&:hover": {
+ boxShadow: "inset 0px 2px 1px rgba(84, 84, 84, 0.25)",
+ },
+ },
+});
+
+export const triggerIcon = style({
+ width: "1rem",
+ height: "1rem",
+ color: themeContract.colors.text.secondary,
+});
+
+export const triggerContent = style({
+ flex: 1,
+ textAlign: "left",
+});
+
+export const triggerLabel = style({
+ fontSize: themeContract.typography.fontSize.sm,
+ color: themeContract.colors.text.secondary,
+ fontWeight: themeContract.typography.fontWeight.medium,
+});
+
+export const triggerSubtext = style({
+ fontSize: themeContract.typography.fontSize.xs,
+ color: themeContract.colors.text.muted,
+});
+
+export const triggerChevron = style({
+ width: "1rem",
+ height: "1rem",
+ color: themeContract.colors.text.secondary,
+ transition: "transform 200ms ease",
+});
+
+export const triggerChevronOpen = style({
+ transform: "rotate(180deg)",
+});
+
+/**
+ * Dropdown menu
+ */
+export const dropdown = style({
+ position: "absolute",
+ top: "100%",
+ left: 0,
+ right: 0,
+ marginTop: themeContract.space[2],
+ background: "rgba(15, 23, 42, 0.95)", // slate-900/95
+ backdropFilter: "blur(12px)",
+ WebkitBackdropFilter: "blur(12px)",
+ border: "1px solid rgba(71, 85, 105, 0.4)", // slate-700/40
+ borderRadius: themeContract.radii.xl,
+ boxShadow:
+ "0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)", // shadow-xl
+ zIndex: 20,
+ overflow: "hidden",
+});
+
+export const dropdownInner = style({
+ padding: themeContract.space[1],
+});
+
+/**
+ * Dropdown items
+ */
+const dropdownItemBase = style({
+ width: "100%",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ paddingLeft: themeContract.space[3],
+ paddingRight: themeContract.space[3],
+ paddingTop: themeContract.space[2],
+ paddingBottom: themeContract.space[2],
+ borderRadius: themeContract.radii.lg,
+ textAlign: "left",
+ transition: themeContract.transitions.normal,
+ cursor: "pointer",
+ border: "none",
+ background: "transparent",
+});
+
+export const dropdownItem = style([
+ dropdownItemBase,
+ {
+ color: themeContract.colors.text.secondary,
+
+ selectors: {
+ "&:hover": {
+ backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50
+ },
+ },
+ },
+]);
+
+export const dropdownItemActive = style([
+ dropdownItemBase,
+ {
+ backgroundColor: "rgba(59, 130, 246, 0.2)", // blue-500/20
+ color: "rgb(147, 197, 253)", // blue-300
+ },
+]);
+
+export const dropdownItemLabel = style({
+ fontSize: themeContract.typography.fontSize.sm,
+ flex: 1,
+});
+
+export const dropdownItemLabelTruncate = style({
+ fontSize: themeContract.typography.fontSize.sm,
+ flex: 1,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+});
+
+export const dropdownItemBadge = style({
+ backgroundColor: "rgba(51, 65, 85, 0.5)", // slate-700/50
+ color: themeContract.colors.text.secondary,
+ fontSize: themeContract.typography.fontSize.xs,
+ marginLeft: themeContract.space[2],
+});
diff --git a/packages/memory-graph/src/components/spaces-dropdown.tsx b/packages/memory-graph/src/components/spaces-dropdown.tsx
new file mode 100644
index 00000000..b70059f5
--- /dev/null
+++ b/packages/memory-graph/src/components/spaces-dropdown.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { Badge } from "@/ui/badge";
+import { ChevronDown, Eye } from "lucide-react";
+import { memo, useEffect, useRef, useState } from "react";
+import type { SpacesDropdownProps } from "@/types";
+import * as styles from "./spaces-dropdown.css";
+
+export const SpacesDropdown = memo<SpacesDropdownProps>(
+ ({ selectedSpace, availableSpaces, spaceMemoryCounts, onSpaceChange }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const dropdownRef = useRef<HTMLDivElement>(null);
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () =>
+ document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ const totalMemories = Object.values(spaceMemoryCounts).reduce(
+ (sum, count) => sum + count,
+ 0,
+ );
+
+ return (
+ <div className={styles.container} ref={dropdownRef}>
+ <button
+ className={styles.trigger}
+ onClick={() => setIsOpen(!isOpen)}
+ type="button"
+ >
+ {/*@ts-ignore */}
+ <Eye className={styles.triggerIcon} />
+ <div className={styles.triggerContent}>
+ <span className={styles.triggerLabel}>
+ {selectedSpace === "all"
+ ? "All Spaces"
+ : selectedSpace || "Select space"}
+ </span>
+ <div className={styles.triggerSubtext}>
+ {selectedSpace === "all"
+ ? `${totalMemories} total memories`
+ : `${spaceMemoryCounts[selectedSpace] || 0} memories`}
+ </div>
+ </div>
+ {/*@ts-ignore */}
+ <ChevronDown
+ className={`${styles.triggerChevron} ${isOpen ? styles.triggerChevronOpen : ""}`}
+ />
+ </button>
+
+ {isOpen && (
+ <div className={styles.dropdown}>
+ <div className={styles.dropdownInner}>
+ <button
+ className={
+ selectedSpace === "all"
+ ? styles.dropdownItemActive
+ : styles.dropdownItem
+ }
+ onClick={() => {
+ onSpaceChange("all");
+ setIsOpen(false);
+ }}
+ type="button"
+ >
+ <span className={styles.dropdownItemLabel}>All Spaces</span>
+ <Badge className={styles.dropdownItemBadge}>
+ {totalMemories}
+ </Badge>
+ </button>
+ {availableSpaces.map((space) => (
+ <button
+ className={
+ selectedSpace === space
+ ? styles.dropdownItemActive
+ : styles.dropdownItem
+ }
+ key={space}
+ onClick={() => {
+ onSpaceChange(space);
+ setIsOpen(false);
+ }}
+ type="button"
+ >
+ <span className={styles.dropdownItemLabelTruncate}>{space}</span>
+ <Badge className={styles.dropdownItemBadge}>
+ {spaceMemoryCounts[space] || 0}
+ </Badge>
+ </button>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ );
+ },
+);
+
+SpacesDropdown.displayName = "SpacesDropdown";